From acaf697497341a934c0b2298de440b344cf30939 Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Wed, 15 Jul 2026 08:59:02 -0700 Subject: [PATCH 1/3] feat(ramps-controller): accept a JSON moneyHeadlessAllProviders payload carrying provider allowlists --- packages/ramps-controller/CHANGELOG.md | 12 + .../RampsController-method-action-types.ts | 7 + .../src/RampsController.test.ts | 318 +++++++++++++++++- .../ramps-controller/src/RampsController.ts | 81 ++++- .../ramps-controller/src/featureFlags.test.ts | 278 ++++++++++++++- packages/ramps-controller/src/featureFlags.ts | 183 +++++++++- packages/ramps-controller/src/index.ts | 7 +- 7 files changed, 851 insertions(+), 35 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index fe5293619e..af91a9aa63 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add the pure `getHeadlessProviderAllowlist(remoteFeatureFlagState, surface?)` helper plus the `HEADLESS_ALLOWLIST_SURFACES` constant and `HeadlessAllowlistSurface` type, resolving the provider-id allowlist carried by the `moneyHeadlessAllProviders` flag's object payload (`surfaces[surface]` overrides the top-level `providerIds`; absent, empty, or malformed levels fall through to no restriction) ([#9999](https://github.com/MetaMask/core/pull/9999)) +- Add an optional `surface` option to `RampsController.getQuotes` (canonical values `money` | `perps` | `predictions`) selecting the per-surface allowlist from the flag payload on the widened all-providers path; it does not affect fetching or caching ([#9999](https://github.com/MetaMask/core/pull/9999)) + +### Changed + +- Widen the `moneyHeadlessAllProviders` flag value contract to accept an object payload `{ enabled: true, providerIds?: string[], surfaces?: Record }` alongside the boolean form ([#9999](https://github.com/MetaMask/core/pull/9999)) + - `isHeadlessAllProvidersEnabled` now returns `true` for an object payload whose `enabled` is the literal `true`; every other previously-false value (strings, numbers, arrays, disabled or malformed payloads) still resolves to `false`, and the boolean forms behave exactly as before, so boolean-only configurations see no behavior change + - When the payload lists provider ids, the widened quote pick drops candidates whose provider is not listed (ids match in prefixed `/providers/x` or bare form, case-insensitively); if nothing survives, `getQuotes` returns an empty `success[]` with `sorted` / `error` / `customActions` preserved + - Clients on earlier versions coerce the object payload to `false` (native-only), so serving it cannot enable widening for a client that cannot parse it + ## [17.0.0] ### Added diff --git a/packages/ramps-controller/src/RampsController-method-action-types.ts b/packages/ramps-controller/src/RampsController-method-action-types.ts index 4792d073e0..83ec3f4bc7 100644 --- a/packages/ramps-controller/src/RampsController-method-action-types.ts +++ b/packages/ramps-controller/src/RampsController-method-action-types.ts @@ -216,6 +216,13 @@ export type RampsControllerSetSelectedPaymentMethodAction = { * passed `providers` list is filtered to those supporting the region and * asset. If nothing qualifies, `getQuotes` returns an empty response * instead of quoting other providers. + * @param options.surface - Optional consumer surface key for the + * `moneyHeadlessAllProviders` flag payload's `surfaces` map (canonical + * values: `money` | `perps` | `predictions`). Selects that surface's + * provider allowlist over the payload's top-level `providerIds`. Only + * consulted on the widened all-providers path; has no effect on fetching + * or caching, and a surface absent from the payload falls back to the + * top-level list. * @param options.redirectUrl - Optional redirect URL after order completion. * @param options.action - The ramp action type. Defaults to 'buy'. * @param options.forceRefresh - Whether to bypass cache. diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 517ff9b7e7..5442191853 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -7,6 +7,7 @@ import type { MessengerEvents, } from '@metamask/messenger'; import type { RemoteFeatureFlagControllerState } from '@metamask/remote-feature-flag-controller'; +import type { Json } from '@metamask/utils'; import * as fs from 'fs'; import * as path from 'path'; @@ -908,7 +909,12 @@ describe('RampsController', () => { it.each([ ['the string "true"', 'true'], ['a number', 1], - ['an object', { enabled: true }], + ['an object with enabled false', { enabled: false }], + ['an object with a non-boolean enabled', { enabled: 'true' }], + [ + 'a disabled payload with provider ids', + { enabled: false, providerIds: ['/providers/moonpay'] }, + ], ['a scope string', 'all'], ])( 'does not widen when the flag value is %s', @@ -952,6 +958,48 @@ describe('RampsController', () => { }, ); + it('widens when the flag value is an enabled object payload', async () => { + const response: QuotesResponse = { + success: [appBrowserQuote(MOONPAY, 90), appBrowserQuote(NATIVE, 70)], + sorted: [{ sortBy: 'reliability', ids: [MOONPAY, 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]: { enabled: true }, + }, + }); + let quotedProviders: string[] | undefined; + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async (params: { providers?: string[] }) => { + quotedProviders = params.providers; + return response; + }, + ); + + const quotes = await callScopedGetQuotes(messenger); + + // The object payload widens exactly like the boolean `true`, and + // with no provider ids listed it applies no restriction. + expect(quotedProviders).toStrictEqual([NATIVE, MOONPAY]); + expect(quotes.success[0]?.provider).toBe(MOONPAY); + }, + ); + }); + it('does not widen when RemoteFeatureFlagController:getState is not wired up', async () => { const response: QuotesResponse = { success: [appBrowserQuote(NATIVE, 70)], @@ -1487,6 +1535,274 @@ describe('RampsController', () => { }, ); }); + + describe('provider allowlist', () => { + const customActionQuote = ( + provider: string, + reliability: number, + ): Quote => { + const quote = appBrowserQuote(provider, reliability); + return { + ...quote, + quote: { + ...quote.quote, + isCustomAction: true, + } as unknown as Quote['quote'], + }; + }; + + const fourProviderState = scopeState([ + buildScopeProvider(NATIVE, 'native'), + buildScopeProvider(MOONPAY, 'aggregator'), + buildScopeProvider(REVOLUT, 'aggregator'), + buildScopeProvider(COINBASE, 'aggregator'), + ]); + + const fourProviderResponse = (): QuotesResponse => ({ + success: [ + appBrowserQuote(MOONPAY, 90), + appBrowserQuote(REVOLUT, 80), + externalBrowserQuote(COINBASE, 99), + appBrowserQuote(NATIVE, 70), + ], + sorted: [ + { sortBy: 'reliability', ids: [COINBASE, MOONPAY, REVOLUT, NATIVE] }, + ], + error: [], + customActions: [], + }); + + const enabledPayload = ( + payload: Record, + ): Partial => ({ + remoteFeatureFlags: { + [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: { + enabled: true, + ...payload, + } as unknown as Json, + }, + }); + + it('restricts the pick to the top-level providerIds list', async () => { + await withController( + { options: { state: fourProviderState } }, + async ({ messenger, rootMessenger }) => { + registerFeatureFlagState( + rootMessenger, + enabledPayload({ providerIds: [MOONPAY, REVOLUT] }), + ); + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => fourProviderResponse(), + ); + + const quotes = await callScopedGetQuotes(messenger); + + // Coinbase is the reliability winner but is not listed, so the + // best listed candidate wins the pick. + expect(quotes.success[0]?.provider).toBe(MOONPAY); + }, + ); + }); + + it('forces an external-browser-only pick over a more reliable in-app quote', async () => { + const response = fourProviderResponse(); + response.sorted = [ + { + sortBy: 'reliability', + ids: [MOONPAY, REVOLUT, NATIVE, COINBASE], + }, + ]; + + await withController( + { options: { state: fourProviderState } }, + async ({ messenger, rootMessenger }) => { + registerFeatureFlagState( + rootMessenger, + enabledPayload({ providerIds: [COINBASE] }), + ); + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => response, + ); + + const quotes = await callScopedGetQuotes(messenger); + + // The QA forcing scenario: only the external-browser provider is + // listed, so its quote wins even though in-app quotes rank higher. + expect(quotes.success[0]?.provider).toBe(COINBASE); + expect(quotes.success[0]?.quote.buyWidget?.browser).toBe( + 'IN_APP_OS_BROWSER', + ); + }, + ); + }); + + it('matches allowlist entries in bare or mixed-case form against prefixed quote ids', async () => { + await withController( + { options: { state: fourProviderState } }, + async ({ messenger, rootMessenger }) => { + registerFeatureFlagState( + rootMessenger, + // Quote ids are `/providers/revolut`; the payload lists the + // bare, mixed-case form. + enabledPayload({ providerIds: ['ReVoLuT'] }), + ); + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => fourProviderResponse(), + ); + + const quotes = await callScopedGetQuotes(messenger); + + expect(quotes.success[0]?.provider).toBe(REVOLUT); + }, + ); + }); + + it('applies the allowlist to custom-action quotes in success[]', async () => { + const response = fourProviderResponse(); + response.success = [ + customActionQuote(MOONPAY, 90), + appBrowserQuote(REVOLUT, 80), + ]; + response.sorted = [ + { sortBy: 'reliability', ids: [MOONPAY, REVOLUT] }, + ]; + + await withController( + { options: { state: fourProviderState } }, + async ({ messenger, rootMessenger }) => { + registerFeatureFlagState( + rootMessenger, + enabledPayload({ providerIds: [REVOLUT] }), + ); + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => response, + ); + + const quotes = await callScopedGetQuotes(messenger); + + // The unlisted custom-action quote is dropped like any other + // candidate. + expect(quotes.success[0]?.provider).toBe(REVOLUT); + }, + ); + }); + + it('returns an empty success[] preserving diagnostics when the allowlist eliminates every candidate', async () => { + const response = fourProviderResponse(); + response.error = [{ provider: NATIVE, error: 'boom' }]; + + await withController( + { options: { state: fourProviderState } }, + async ({ messenger, rootMessenger }) => { + registerFeatureFlagState( + rootMessenger, + enabledPayload({ providerIds: ['/providers/none'] }), + ); + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => response, + ); + + const quotes = await callScopedGetQuotes(messenger); + + expect(quotes.success).toStrictEqual([]); + expect(quotes.sorted).toStrictEqual(response.sorted); + expect(quotes.error).toStrictEqual(response.error); + expect(quotes.customActions).toStrictEqual(response.customActions); + }, + ); + }); + + it('applies no restriction for a malformed providerIds value', async () => { + await withController( + { options: { state: fourProviderState } }, + async ({ messenger, rootMessenger }) => { + registerFeatureFlagState( + rootMessenger, + enabledPayload({ providerIds: 'moonpay' }), + ); + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => fourProviderResponse(), + ); + + const quotes = await callScopedGetQuotes(messenger); + + // Malformed list: ignored, so the reliability winner stands. + expect(quotes.success[0]?.provider).toBe(COINBASE); + }, + ); + }); + + it('lets a surface entry override the top-level list only for that surface', async () => { + const flagState = enabledPayload({ + providerIds: [MOONPAY], + surfaces: { money: [REVOLUT] }, + }); + + await withController( + { options: { state: fourProviderState } }, + async ({ messenger, rootMessenger }) => { + registerFeatureFlagState(rootMessenger, flagState); + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => fourProviderResponse(), + ); + + const moneyQuotes = await callScopedGetQuotes(messenger, { + surface: 'money', + }); + const untaggedQuotes = await callScopedGetQuotes(messenger); + const perpsQuotes = await callScopedGetQuotes(messenger, { + surface: 'perps', + }); + + // `money` has its own list; an untagged call and a surface absent + // from the payload both resolve the top-level list. + expect(moneyQuotes.success[0]?.provider).toBe(REVOLUT); + expect(untaggedQuotes.success[0]?.provider).toBe(MOONPAY); + expect(perpsQuotes.success[0]?.provider).toBe(MOONPAY); + }, + ); + }); + + it('re-applies the pick per call over a cached response without fragmenting the cache', async () => { + const flagState = enabledPayload({ + providerIds: [MOONPAY], + surfaces: { money: [REVOLUT] }, + }); + + await withController( + { options: { state: fourProviderState } }, + async ({ messenger, rootMessenger }) => { + registerFeatureFlagState(rootMessenger, flagState); + let serviceCalls = 0; + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => { + serviceCalls += 1; + return fourProviderResponse(); + }, + ); + + const untaggedQuotes = await callScopedGetQuotes(messenger); + const moneyQuotes = await callScopedGetQuotes(messenger, { + surface: 'money', + }); + + // `surface` is not part of the cache key: the second call reuses + // the cached raw response but picks per its own surface list. + expect(serviceCalls).toBe(1); + expect(untaggedQuotes.success[0]?.provider).toBe(MOONPAY); + expect(moneyQuotes.success[0]?.provider).toBe(REVOLUT); + }, + ); + }); + }); }); describe('getProviders', () => { diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 52a14fa12e..4366872014 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -10,7 +10,11 @@ import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote import type { Json } from '@metamask/utils'; import type { Draft } from 'immer'; -import { isHeadlessAllProvidersEnabled } from './featureFlags'; +import { + getHeadlessProviderAllowlist, + isHeadlessAllProvidersEnabled, + normalizeHeadlessProviderId, +} from './featureFlags'; import { getProvidersServingAsset } from './providerAvailability'; import type { RampsControllerMethodActions } from './RampsController-method-action-types'; import type { RampsErrorCode } from './rampsErrorCodes'; @@ -978,26 +982,41 @@ export class RampsController extends BaseController< } /** - * Whether the Headless Buy all-providers feature flag is enabled. + * Resolves the Headless Buy all-providers feature flag: whether widening is + * enabled, and the provider-id allowlist the widened pick is restricted to + * (if the flag's object payload carries one). * * 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 + * runtime without reconstructing the controller. The single read keeps the + * enabled bit and the allowlist consistent even if the flag changes while a + * quote request is in flight. Key lookup, local-override merging, and value + * coercion live in the shared `isHeadlessAllProvidersEnabled` / + * `getHeadlessProviderAllowlist` helpers 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. + * @param surface - Optional consumer surface key selecting a per-surface + * allowlist from the flag payload's `surfaces` map. + * @returns The enabled bit and the optional provider-id allowlist. */ - #isAllProvidersEnabled(): boolean { + #resolveAllProvidersFlag(surface?: string): { + enabled: boolean; + allowlist?: string[]; + } { try { const remoteFeatureFlagState = this.messenger.call( 'RemoteFeatureFlagController:getState', ); - return isHeadlessAllProvidersEnabled(remoteFeatureFlagState); + return { + enabled: isHeadlessAllProvidersEnabled(remoteFeatureFlagState), + allowlist: getHeadlessProviderAllowlist( + remoteFeatureFlagState, + surface, + ), + }; } catch { - return false; + return { enabled: false }; } } @@ -1845,6 +1864,13 @@ export class RampsController extends BaseController< * passed `providers` list is filtered to those supporting the region and * asset. If nothing qualifies, `getQuotes` returns an empty response * instead of quoting other providers. + * @param options.surface - Optional consumer surface key for the + * `moneyHeadlessAllProviders` flag payload's `surfaces` map (canonical + * values: `money` | `perps` | `predictions`). Selects that surface's + * provider allowlist over the payload's top-level `providerIds`. Only + * consulted on the widened all-providers path; has no effect on fetching + * or caching, and a surface absent from the payload falls back to the + * top-level list. * @param options.redirectUrl - Optional redirect URL after order completion. * @param options.action - The ramp action type. Defaults to 'buy'. * @param options.forceRefresh - Whether to bypass cache. @@ -1862,6 +1888,7 @@ export class RampsController extends BaseController< autoSelectProvider?: boolean; preferredProviderIds?: string[]; restrictToKnownOrNativeProviders?: boolean; + surface?: string; redirectUrl?: string; action?: RampAction; forceRefresh?: boolean; @@ -1895,8 +1922,14 @@ export class RampsController extends BaseController< !options.providers && (options.autoSelectProvider === true || options.restrictToKnownOrNativeProviders === true); - const widenToAllProviders = - wantsAutoSelection && this.#isAllProvidersEnabled(); + // Single flag read per call: the enabled bit and the allowlist come from + // the same `RemoteFeatureFlagController` state snapshot, so a flag edit + // during the awaited quote fetch cannot produce a mixed read. + const { enabled: allProvidersEnabled, allowlist: providerAllowlist } = + wantsAutoSelection + ? this.#resolveAllProvidersFlag(options.surface) + : { enabled: false, allowlist: undefined }; + const widenToAllProviders = wantsAutoSelection && allProvidersEnabled; let providersToUse: string[]; let widenedProviderCatalog: Provider[] = this.state.providers.data; @@ -2031,6 +2064,7 @@ export class RampsController extends BaseController< amount: options.amount, fiat: normalizedFiat, providers: widenedProviderCatalog, + allowlist: providerAllowlist, }); if (!selectedQuote) { @@ -2057,16 +2091,19 @@ export class RampsController extends BaseController< * Selects the best quote from a widened multi-provider response. * * 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. + * external-browser / custom-action). When the feature flag payload carries a + * provider allowlist, candidates from unlisted providers are dropped first. + * 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 survives. * * @param response - The multi-provider quotes response. * @param options - Selection inputs. * @param options.amount - Fiat amount, for the limit-fit check. * @param options.fiat - Lowercased fiat short code, for the limit lookup. * @param options.providers - Provider catalog for the limit lookup. + * @param options.allowlist - Optional provider ids (either `/providers/x` + * or bare form) the pick is restricted to. * @returns The selected quote, or `undefined` when none is usable. */ #pickWidenedQuote( @@ -2075,16 +2112,26 @@ export class RampsController extends BaseController< amount, fiat, providers, + allowlist, }: { amount: number; fiat: string; providers: Provider[]; + allowlist?: string[]; }, ): Quote | undefined { const providerByCode = new Map( providers.map((provider) => [provider.id, provider]), ); + const allowedProviderIds = + allowlist && allowlist.length > 0 + ? new Set(allowlist.map(normalizeHeadlessProviderId)) + : undefined; + const isAllowedProvider = (quote: Quote): boolean => + !allowedProviderIds || + allowedProviderIds.has(normalizeHeadlessProviderId(quote.provider)); + const fitsProviderLimits = (quote: Quote): boolean => { const provider = providerByCode.get(quote.provider); const limit = provider?.limits?.fiat?.[fiat]?.[quote.quote.paymentMethod]; @@ -2096,7 +2143,9 @@ export class RampsController extends BaseController< return amount >= limit.minAmount && amount <= limit.maxAmount; }; - const candidates = response.success.filter(fitsProviderLimits); + const candidates = response.success.filter( + (quote) => isAllowedProvider(quote) && fitsProviderLimits(quote), + ); if (candidates.length === 0) { return undefined; } diff --git a/packages/ramps-controller/src/featureFlags.test.ts b/packages/ramps-controller/src/featureFlags.test.ts index 3485d2f59a..d34ac37d0f 100644 --- a/packages/ramps-controller/src/featureFlags.test.ts +++ b/packages/ramps-controller/src/featureFlags.test.ts @@ -1,6 +1,11 @@ +import type { Json } from '@metamask/utils'; + import { + HEADLESS_ALLOWLIST_SURFACES, MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY, + getHeadlessProviderAllowlist, isHeadlessAllProvidersEnabled, + normalizeHeadlessProviderId, } from './featureFlags'; describe('MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY', () => { @@ -11,6 +16,16 @@ describe('MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY', () => { }); }); +describe('HEADLESS_ALLOWLIST_SURFACES', () => { + it('matches the canonical payload surface keys', () => { + expect(HEADLESS_ALLOWLIST_SURFACES).toStrictEqual({ + MONEY: 'money', + PERPS: 'perps', + PREDICTIONS: 'predictions', + }); + }); +}); + describe('isHeadlessAllProvidersEnabled', () => { it('returns true when the remote flag is the literal boolean true', () => { expect( @@ -42,7 +57,13 @@ describe('isHeadlessAllProvidersEnabled', () => { it.each([ ['the string "true"', 'true'], ['a number', 1], - ['an object', { enabled: true }], + ['an empty object', {}], + ['an object with enabled false', { enabled: false }], + ['an object with a non-boolean enabled', { enabled: 'true' }], + [ + 'an object with providerIds but no enabled', + { providerIds: ['/providers/moonpay'] }, + ], ['an array', [true]], ['null', null], ['a scope string', 'all'], @@ -54,6 +75,48 @@ describe('isHeadlessAllProvidersEnabled', () => { ).toBe(false); }); + it('returns true for an object payload whose enabled is the literal true', () => { + expect( + isHeadlessAllProvidersEnabled({ + remoteFeatureFlags: { + [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: { enabled: true }, + }, + }), + ).toBe(true); + }); + + it('returns true for an enabled object payload carrying provider ids', () => { + expect( + isHeadlessAllProvidersEnabled({ + remoteFeatureFlags: { + [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: { + enabled: true, + providerIds: ['/providers/moonpay'], + }, + }, + }), + ).toBe(true); + }); + + it('honors a localOverrides object payload over a remote boolean', () => { + expect( + isHeadlessAllProvidersEnabled({ + remoteFeatureFlags: { [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: false }, + localOverrides: { + [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: { enabled: true }, + }, + }), + ).toBe(true); + expect( + isHeadlessAllProvidersEnabled({ + remoteFeatureFlags: { [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: true }, + localOverrides: { + [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: { enabled: false }, + }, + }), + ).toBe(false); + }); + it('honors a localOverrides true value when the remote flag is missing', () => { expect( isHeadlessAllProvidersEnabled({ @@ -90,3 +153,216 @@ describe('isHeadlessAllProvidersEnabled', () => { ).toBe(true); }); }); + +describe('getHeadlessProviderAllowlist', () => { + const stateWithFlag = ( + value: unknown, + ): { remoteFeatureFlags: Record } => ({ + remoteFeatureFlags: { + [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: value as Json, + }, + }); + + it.each([ + ['the boolean true', true], + ['the boolean false', false], + ['a string', 'all'], + ['a number', 1], + ['an array', ['/providers/moonpay']], + ['null', null], + ])('returns undefined when the flag value is %s', (_description, value) => { + expect(getHeadlessProviderAllowlist(stateWithFlag(value))).toBeUndefined(); + }); + + it('returns undefined for null or undefined state and a missing flag', () => { + expect(getHeadlessProviderAllowlist(null)).toBeUndefined(); + expect(getHeadlessProviderAllowlist(undefined)).toBeUndefined(); + expect( + getHeadlessProviderAllowlist({ remoteFeatureFlags: {} }), + ).toBeUndefined(); + }); + + it('returns undefined when the payload is not enabled, even with provider ids', () => { + expect( + getHeadlessProviderAllowlist( + stateWithFlag({ + enabled: false, + providerIds: ['/providers/moonpay'], + }), + ), + ).toBeUndefined(); + expect( + getHeadlessProviderAllowlist( + stateWithFlag({ providerIds: ['/providers/moonpay'] }), + ), + ).toBeUndefined(); + }); + + it('returns the top-level providerIds as listed', () => { + expect( + getHeadlessProviderAllowlist( + stateWithFlag({ + enabled: true, + providerIds: ['/providers/moonpay', 'coinbasepay'], + }), + ), + ).toStrictEqual(['/providers/moonpay', 'coinbasepay']); + }); + + it('returns undefined for an enabled payload without providerIds', () => { + expect( + getHeadlessProviderAllowlist(stateWithFlag({ enabled: true })), + ).toBeUndefined(); + }); + + it('drops non-string entries and trims strings', () => { + expect( + getHeadlessProviderAllowlist( + stateWithFlag({ + enabled: true, + providerIds: [' /providers/moonpay ', 42, null, '', {}], + }), + ), + ).toStrictEqual(['/providers/moonpay']); + }); + + it.each([ + ['an empty providerIds list', { enabled: true, providerIds: [] }], + [ + 'an all-invalid providerIds list', + { enabled: true, providerIds: [1, null, ''] }, + ], + ['a non-array providerIds', { enabled: true, providerIds: 'moonpay' }], + ])('returns undefined for %s', (_description, value) => { + expect(getHeadlessProviderAllowlist(stateWithFlag(value))).toBeUndefined(); + }); + + it('ignores unknown extra keys in the payload', () => { + expect( + getHeadlessProviderAllowlist( + stateWithFlag({ + enabled: true, + providerIds: ['/providers/moonpay'], + somethingElse: { nested: true }, + }), + ), + ).toStrictEqual(['/providers/moonpay']); + }); + + describe('surfaces', () => { + const payload = { + enabled: true, + providerIds: ['/providers/moonpay'], + surfaces: { + [HEADLESS_ALLOWLIST_SURFACES.MONEY]: ['/providers/transak'], + [HEADLESS_ALLOWLIST_SURFACES.PERPS]: [], + }, + }; + + it('lets a surface entry override the top-level list for that surface', () => { + expect( + getHeadlessProviderAllowlist( + stateWithFlag(payload), + HEADLESS_ALLOWLIST_SURFACES.MONEY, + ), + ).toStrictEqual(['/providers/transak']); + }); + + it('falls back to the top-level list for a surface not in the payload', () => { + expect( + getHeadlessProviderAllowlist( + stateWithFlag(payload), + HEADLESS_ALLOWLIST_SURFACES.PREDICTIONS, + ), + ).toStrictEqual(['/providers/moonpay']); + }); + + it('falls back to the top-level list for an empty surface entry', () => { + expect( + getHeadlessProviderAllowlist( + stateWithFlag(payload), + HEADLESS_ALLOWLIST_SURFACES.PERPS, + ), + ).toStrictEqual(['/providers/moonpay']); + }); + + it('uses the top-level list when no surface is given', () => { + expect( + getHeadlessProviderAllowlist(stateWithFlag(payload)), + ).toStrictEqual(['/providers/moonpay']); + }); + + it('returns a surface list even when the top-level list is absent', () => { + expect( + getHeadlessProviderAllowlist( + stateWithFlag({ + enabled: true, + surfaces: { money: ['/providers/transak'] }, + }), + 'money', + ), + ).toStrictEqual(['/providers/transak']); + }); + + it.each([ + ['a string', 'money'], + ['an array', [['/providers/transak']]], + ['null', null], + ])('ignores surfaces when it is %s', (_description, surfaces) => { + expect( + getHeadlessProviderAllowlist( + stateWithFlag({ + enabled: true, + providerIds: ['/providers/moonpay'], + surfaces, + }), + 'money', + ), + ).toStrictEqual(['/providers/moonpay']); + }); + }); + + it('honors a localOverrides payload over the remote payload', () => { + expect( + getHeadlessProviderAllowlist({ + remoteFeatureFlags: { + [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: { + enabled: true, + providerIds: ['/providers/moonpay'], + }, + }, + localOverrides: { + [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: { + enabled: true, + providerIds: ['/providers/coinbasepay'], + }, + }, + }), + ).toStrictEqual(['/providers/coinbasepay']); + }); + + it('returns undefined when a malformed localOverrides value shadows a valid remote payload', () => { + expect( + getHeadlessProviderAllowlist({ + remoteFeatureFlags: { + [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: { + enabled: true, + providerIds: ['/providers/moonpay'], + }, + }, + localOverrides: { [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: 'oops' }, + }), + ).toBeUndefined(); + }); +}); + +describe('normalizeHeadlessProviderId', () => { + it.each([ + ['/providers/moonpay', 'moonpay'], + ['moonpay', 'moonpay'], + [' /providers/MoonPay ', 'moonpay'], + ['/providers/transak-staging', 'transak-staging'], + ])('normalizes %s to %s', (input, expected) => { + expect(normalizeHeadlessProviderId(input)).toBe(expected); + }); +}); diff --git a/packages/ramps-controller/src/featureFlags.ts b/packages/ramps-controller/src/featureFlags.ts index feb04485b4..4a2a6b54f2 100644 --- a/packages/ramps-controller/src/featureFlags.ts +++ b/packages/ramps-controller/src/featureFlags.ts @@ -1,16 +1,44 @@ import type { FeatureFlags } from '@metamask/remote-feature-flag-controller'; +import type { Json } from '@metamask/utils'; /** * 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. + * expansion. Accepts two value forms: + * + * - The literal boolean `true` widens the headless fiat quote path to every + * provider class (native, in-app WebView aggregator, and external-browser / + * custom-action) with no provider restriction. + * - An object payload `{ enabled: true, providerIds?: string[], surfaces?: + * Record }` widens the same way, and additionally + * restricts the widened quote pick to the listed provider ids (see + * {@link getHeadlessProviderAllowlist}). + * + * `false`, a missing flag, or any other value keeps the native-only default. + * Clients that only understand the boolean form coerce the object payload to + * "disabled" (native-only), so serving the object form can never turn the + * feature on for a client that cannot parse it. 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'; +/** + * Canonical surface keys accepted in the flag payload's `surfaces` map. A + * surface entry overrides the top-level `providerIds` list for quote requests + * tagged with that surface. + */ +export const HEADLESS_ALLOWLIST_SURFACES = { + MONEY: 'money', + PERPS: 'perps', + PREDICTIONS: 'predictions', +} as const; + +/** + * A canonical surface key of the flag payload's `surfaces` map. + */ +export type HeadlessAllowlistSurface = + (typeof HEADLESS_ALLOWLIST_SURFACES)[keyof typeof HEADLESS_ALLOWLIST_SURFACES]; + /** * The subset of `RemoteFeatureFlagController` state that * {@link isHeadlessAllProvidersEnabled} reads. Structural so consumers can @@ -22,17 +50,77 @@ export type HeadlessFeatureFlagsLookup = { localOverrides?: FeatureFlags; }; +/** + * Resolves the flag value with `localOverrides` (written by dev-only override + * screens) 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. + * + * @param remoteFeatureFlagState - `RemoteFeatureFlagController` state (or the + * relevant subset of it). + * @returns The merged flag value, or `undefined` when absent. + */ +function resolveFlagValue( + remoteFeatureFlagState: HeadlessFeatureFlagsLookup | null | undefined, +): Json | undefined { + const flags: FeatureFlags = { + ...(remoteFeatureFlagState?.remoteFeatureFlags ?? {}), + ...(remoteFeatureFlagState?.localOverrides ?? {}), + }; + return flags[MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]; +} + +/** + * Whether a flag value is the object payload form: a plain object (not an + * array) whose `enabled` is the literal boolean `true`. Anything else, + * including `{ enabled: false }` and objects without `enabled`, is not an + * enabled payload, so the flag fails closed. + * + * @param value - The merged flag value. + * @returns Whether the value is an enabled object payload. + */ +function isEnabledPayload(value: Json | undefined): value is { + [key: string]: Json; +} { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + value.enabled === true + ); +} + +/** + * Coerces a payload field into a provider-id list: keeps only string entries, + * trims them, and drops empties. An empty or malformed level is treated as + * "not provided" so resolution falls through to the next level rather than + * restricting to nothing; to force "nothing eligible" list a nonexistent id. + * + * @param value - The candidate `providerIds` / surface entry value. + * @returns The non-empty coerced list, or `undefined`. + */ +function coerceProviderIdList(value: Json | undefined): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const ids = value + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter((entry) => entry !== ''); + return ids.length > 0 ? ids : undefined; +} + /** * 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`. + * flag identically. `localOverrides` are merged over `remoteFeatureFlags` + * (see {@link resolveFlagValue}). Coerces defensively: only the literal + * boolean `true` or an object payload whose `enabled` is the literal `true` + * enables; any other value (missing, string, number, array, `{ enabled: + * false }`) resolves to `false`. * * @param remoteFeatureFlagState - `RemoteFeatureFlagController` state (or the * relevant subset of it). May be `null`/`undefined` before the controller is @@ -43,9 +131,72 @@ export type HeadlessFeatureFlagsLookup = { export function isHeadlessAllProvidersEnabled( remoteFeatureFlagState: HeadlessFeatureFlagsLookup | null | undefined, ): boolean { - const flags: FeatureFlags = { - ...(remoteFeatureFlagState?.remoteFeatureFlags ?? {}), - ...(remoteFeatureFlagState?.localOverrides ?? {}), - }; - return flags[MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY] === true; + const value = resolveFlagValue(remoteFeatureFlagState); + return value === true || isEnabledPayload(value); +} + +/** + * The provider-id allowlist carried by the flag's object payload, or + * `undefined` when the widened pick should not be restricted. + * + * Resolution, most specific first: + * + * 1. When `surface` is given and the payload's `surfaces[surface]` is a + * non-empty valid list, that list wins. + * 2. Otherwise the payload's top-level `providerIds`, when non-empty and + * valid. + * 3. Otherwise `undefined` (no restriction). The boolean `true` form, a + * disabled or malformed payload, and empty or all-invalid lists all land + * here; unknown keys and non-string entries are ignored. + * + * Note that `surfaces` entries only apply to quote requests tagged with a + * `surface`; a request without one (for example MM Pay's `getRampsQuote`) + * resolves the top-level `providerIds` list only. + * + * @param remoteFeatureFlagState - `RemoteFeatureFlagController` state (or the + * relevant subset of it). May be `null`/`undefined` before the controller is + * initialized. + * @param surface - Optional consumer surface key; canonical values are the + * {@link HEADLESS_ALLOWLIST_SURFACES} members. + * @returns The provider ids the widened pick is restricted to, or `undefined` + * for no restriction. + */ +export function getHeadlessProviderAllowlist( + remoteFeatureFlagState: HeadlessFeatureFlagsLookup | null | undefined, + surface?: string, +): string[] | undefined { + const value = resolveFlagValue(remoteFeatureFlagState); + if (!isEnabledPayload(value)) { + return undefined; + } + + if (surface !== undefined) { + const { surfaces } = value; + if ( + typeof surfaces === 'object' && + surfaces !== null && + !Array.isArray(surfaces) + ) { + const surfaceList = coerceProviderIdList(surfaces[surface]); + if (surfaceList) { + return surfaceList; + } + } + } + + return coerceProviderIdList(value.providerIds); +} + +/** + * Normalizes a provider id for allowlist matching only: trims, strips the + * canonical `/providers/` path prefix, and lowercases, so `/providers/moonpay` + * and `moonpay` match each other. Quote and catalog provider ids are matched + * as-is everywhere else; this exists solely so LaunchDarkly payload authors + * can use either id form. Not exported from the package index. + * + * @param id - A provider id in either the prefixed or bare form. + * @returns The normalized id used for allowlist comparison. + */ +export function normalizeHeadlessProviderId(id: string): string { + return id.trim().replace(/^\/providers\//u, '').toLowerCase(); } diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 3ebe6f39e4..148160776b 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -140,9 +140,14 @@ export { export { RAMPS_ERROR_CODES } from './rampsErrorCodes'; export type { RequestSelectorResult } from './selectors'; export { createRequestSelector } from './selectors'; -export type { HeadlessFeatureFlagsLookup } from './featureFlags'; +export type { + HeadlessAllowlistSurface, + HeadlessFeatureFlagsLookup, +} from './featureFlags'; export { + HEADLESS_ALLOWLIST_SURFACES, MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY, + getHeadlessProviderAllowlist, isHeadlessAllProvidersEnabled, } from './featureFlags'; export { From 6f52159cec6413c8d7a7a186f3367c66dc59fc83 Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Wed, 15 Jul 2026 09:00:45 -0700 Subject: [PATCH 2/3] docs(ramps-controller): point changelog entries at #9524 --- packages/ramps-controller/CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index af91a9aa63..028e79f227 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -9,12 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add the pure `getHeadlessProviderAllowlist(remoteFeatureFlagState, surface?)` helper plus the `HEADLESS_ALLOWLIST_SURFACES` constant and `HeadlessAllowlistSurface` type, resolving the provider-id allowlist carried by the `moneyHeadlessAllProviders` flag's object payload (`surfaces[surface]` overrides the top-level `providerIds`; absent, empty, or malformed levels fall through to no restriction) ([#9999](https://github.com/MetaMask/core/pull/9999)) -- Add an optional `surface` option to `RampsController.getQuotes` (canonical values `money` | `perps` | `predictions`) selecting the per-surface allowlist from the flag payload on the widened all-providers path; it does not affect fetching or caching ([#9999](https://github.com/MetaMask/core/pull/9999)) +- Add the pure `getHeadlessProviderAllowlist(remoteFeatureFlagState, surface?)` helper plus the `HEADLESS_ALLOWLIST_SURFACES` constant and `HeadlessAllowlistSurface` type, resolving the provider-id allowlist carried by the `moneyHeadlessAllProviders` flag's object payload (`surfaces[surface]` overrides the top-level `providerIds`; absent, empty, or malformed levels fall through to no restriction) ([#9524](https://github.com/MetaMask/core/pull/9524)) +- Add an optional `surface` option to `RampsController.getQuotes` (canonical values `money` | `perps` | `predictions`) selecting the per-surface allowlist from the flag payload on the widened all-providers path; it does not affect fetching or caching ([#9524](https://github.com/MetaMask/core/pull/9524)) ### Changed -- Widen the `moneyHeadlessAllProviders` flag value contract to accept an object payload `{ enabled: true, providerIds?: string[], surfaces?: Record }` alongside the boolean form ([#9999](https://github.com/MetaMask/core/pull/9999)) +- Widen the `moneyHeadlessAllProviders` flag value contract to accept an object payload `{ enabled: true, providerIds?: string[], surfaces?: Record }` alongside the boolean form ([#9524](https://github.com/MetaMask/core/pull/9524)) - `isHeadlessAllProvidersEnabled` now returns `true` for an object payload whose `enabled` is the literal `true`; every other previously-false value (strings, numbers, arrays, disabled or malformed payloads) still resolves to `false`, and the boolean forms behave exactly as before, so boolean-only configurations see no behavior change - When the payload lists provider ids, the widened quote pick drops candidates whose provider is not listed (ids match in prefixed `/providers/x` or bare form, case-insensitively); if nothing survives, `getQuotes` returns an empty `success[]` with `sorted` / `error` / `customActions` preserved - Clients on earlier versions coerce the object payload to `false` (native-only), so serving it cannot enable widening for a client that cannot parse it From 1ccf11bcb14f2c125af1cfd0c9d7484133b6f994 Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Wed, 15 Jul 2026 09:22:39 -0700 Subject: [PATCH 3/3] style(ramps-controller): apply oxfmt formatting --- packages/ramps-controller/src/RampsController.test.ts | 4 +--- packages/ramps-controller/src/featureFlags.ts | 5 ++++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 5442191853..8e1cd78ed0 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -1666,9 +1666,7 @@ describe('RampsController', () => { customActionQuote(MOONPAY, 90), appBrowserQuote(REVOLUT, 80), ]; - response.sorted = [ - { sortBy: 'reliability', ids: [MOONPAY, REVOLUT] }, - ]; + response.sorted = [{ sortBy: 'reliability', ids: [MOONPAY, REVOLUT] }]; await withController( { options: { state: fourProviderState } }, diff --git a/packages/ramps-controller/src/featureFlags.ts b/packages/ramps-controller/src/featureFlags.ts index 4a2a6b54f2..1e39cf374f 100644 --- a/packages/ramps-controller/src/featureFlags.ts +++ b/packages/ramps-controller/src/featureFlags.ts @@ -198,5 +198,8 @@ export function getHeadlessProviderAllowlist( * @returns The normalized id used for allowlist comparison. */ export function normalizeHeadlessProviderId(id: string): string { - return id.trim().replace(/^\/providers\//u, '').toLowerCase(); + return id + .trim() + .replace(/^\/providers\//u, '') + .toLowerCase(); }