Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/ramps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) ([#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<string, string[]> }` 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

## [17.0.0]

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
316 changes: 315 additions & 1 deletion packages/ramps-controller/src/RampsController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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)],
Expand Down Expand Up @@ -1487,6 +1535,272 @@ 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<string, unknown>,
): Partial<RemoteFeatureFlagControllerState> => ({
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', () => {
Expand Down
Loading