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
9 changes: 9 additions & 0 deletions packages/ramps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add the `getDefaultRedirectCallbackUrl(environment)` helper, which derives the widened Headless Buy default redirect ("fake callback") URL from a `RampsEnvironment` (`on-ramp-content` hosts for production/staging, `on-ramp.dev-api` for development, `localhost:3000` for local) ([#9752](https://github.com/MetaMask/core/pull/9752))
- Add a required `environment` option to `RampsControllerOptions` that the controller uses to derive the widened-path default redirect URL internally. Consumers must pass the same environment used by `RampsService` and migrate callback-matching UI code to that environment at the same time. ([#9752](https://github.com/MetaMask/core/pull/9752))

### Removed

- **BREAKING:** Remove the `getDefaultRedirectUrl` callback option from `RampsControllerOptions`; the widened-path default redirect URL is now derived inside the controller from its required `environment` via `getDefaultRedirectCallbackUrl`. ([#9752](https://github.com/MetaMask/core/pull/9752))

## [18.0.1]

### Changed
Expand Down
49 changes: 27 additions & 22 deletions packages/ramps-controller/src/RampsController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ import type {
RampsToken,
RampsOrder,
} from './RampsService.js';
import { RampsOrderStatus } from './RampsService.js';
import {
getDefaultRedirectCallbackUrl,
RampsEnvironment,
RampsOrderStatus,
} from './RampsService.js';
import { RequestStatus } from './RequestCache.js';
import type {
TransakAccessToken,
Expand Down Expand Up @@ -1387,19 +1391,18 @@ describe('RampsController', () => {
);
});

it('forwards the injected default redirectUrl on the widened path when the caller omits one', async () => {
it('forwards the environment-derived default redirectUrl on the widened path when the caller omits one', async () => {
const response: QuotesResponse = {
success: [appBrowserQuote(MOONPAY, 90)],
sorted: [{ sortBy: 'reliability', ids: [MOONPAY] }],
error: [],
customActions: [],
};
const DEFAULT_REDIRECT = 'https://default.example/callback';

await withController(
{
options: {
getDefaultRedirectUrl: () => DEFAULT_REDIRECT,
environment: RampsEnvironment.Production,
state: scopeState([buildScopeProvider(MOONPAY, 'aggregator')]),
},
},
Expand All @@ -1416,27 +1419,28 @@ describe('RampsController', () => {

await callScopedGetQuotes(messenger);

// The caller omitted redirectUrl, so the widened path supplies the
// injected default and forwards it to the service.
expect(forwardedRedirectUrl).toBe(DEFAULT_REDIRECT);
// The caller omitted redirectUrl, so the widened path derives the
// default from the controller's environment and forwards it.
expect(forwardedRedirectUrl).toBe(
getDefaultRedirectCallbackUrl(RampsEnvironment.Production),
);
},
);
});

it('prefers an explicit caller redirectUrl over the injected default on the widened path', async () => {
it('prefers an explicit caller redirectUrl over the environment-derived default on the widened path', async () => {
const response: QuotesResponse = {
success: [appBrowserQuote(MOONPAY, 90)],
sorted: [{ sortBy: 'reliability', ids: [MOONPAY] }],
error: [],
customActions: [],
};
const DEFAULT_REDIRECT = 'https://default.example/callback';
const EXPLICIT_REDIRECT = 'https://explicit.example/callback';

await withController(
{
options: {
getDefaultRedirectUrl: () => DEFAULT_REDIRECT,
environment: RampsEnvironment.Production,
state: scopeState([buildScopeProvider(MOONPAY, 'aggregator')]),
},
},
Expand Down Expand Up @@ -1469,12 +1473,10 @@ describe('RampsController', () => {
error: [],
customActions: [],
};
const DEFAULT_REDIRECT = 'https://default.example/callback';

await withController(
{
options: {
getDefaultRedirectUrl: () => DEFAULT_REDIRECT,
environment: RampsEnvironment.Production,
state: scopeState([buildScopeProvider(NATIVE, 'native')]),
},
},
Expand All @@ -1496,13 +1498,13 @@ describe('RampsController', () => {
await callScopedGetQuotes(messenger);

// The disabled flag never widens, so the default is not injected
// even when a `getDefaultRedirectUrl` callback is present.
// even though the controller has an environment configured.
expect(forwardedRedirectUrl).toBeUndefined();
},
);
});

it('forwards undefined on the widened path when no getDefaultRedirectUrl option is provided', async () => {
it('derives the default redirectUrl from the environment supplied by the test helper', async () => {
const response: QuotesResponse = {
success: [appBrowserQuote(MOONPAY, 90)],
sorted: [{ sortBy: 'reliability', ids: [MOONPAY] }],
Expand Down Expand Up @@ -1531,10 +1533,12 @@ describe('RampsController', () => {

await callScopedGetQuotes(messenger);

// With no injected callback, the constructor default returns
// undefined, so the widened path forwards undefined.
// The test helper supplies staging when this test does not override
// the environment.
expect(redirectUrlWasSeen).toBe(true);
expect(forwardedRedirectUrl).toBeUndefined();
expect(forwardedRedirectUrl).toBe(
getDefaultRedirectCallbackUrl(RampsEnvironment.Staging),
);
},
);
});
Expand Down Expand Up @@ -11452,10 +11456,10 @@ function getMessenger(rootMessenger: RootMessenger): RampsControllerMessenger {
* created ahead of time and then safely destroyed afterward as needed.
*
* @param args - Either a function, or an options bag + a function. The options
* bag contains arguments for the controller constructor. All constructor
* arguments are optional and will be filled in with defaults in as needed
* (including `messenger`). The function is called with the new
* controller, root messenger, and controller messenger.
* bag contains arguments for the controller constructor. The helper supplies
* a messenger and a deliberate staging environment unless overridden. The
* function is called with the new controller, root messenger, and controller
* messenger.
* @returns The same return value as the given function.
*/
async function withController<ReturnValue>(
Expand All @@ -11469,6 +11473,7 @@ async function withController<ReturnValue>(
const messenger = getMessenger(rootMessenger);
const controller = new RampsController({
messenger,
environment: RampsEnvironment.Staging,
...options,
});
return await testFunction({ controller, rootMessenger, messenger });
Expand Down
53 changes: 29 additions & 24 deletions packages/ramps-controller/src/RampsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@ import type {
RampsServiceActions,
RampsOrder,
} from './RampsService.js';
import { RampsOrderStatus } from './RampsService.js';
import {
getDefaultRedirectCallbackUrl,
RampsEnvironment,
RampsOrderStatus,
} from './RampsService.js';
import type {
RequestCache as RequestCacheType,
RequestState,
Expand Down Expand Up @@ -682,16 +686,16 @@ export type RampsControllerOptions = {
/** Maximum number of entries in the request cache. Defaults to 250. */
requestCacheMaxSize?: number;
/**
* Optional callback returning the default redirect URL to use for the widened
* 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
* aggregator quotes carry a usable widget URL. Only applied on the
* The ramps environment, used to derive the default redirect URL for the
* widened 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 on the widened path this default
* lets 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.
* native-only path never injects. Consumers must pass the same environment
* used by {@link RampsService} and by callback-matching UI code.
*/
getDefaultRedirectUrl?: () => string | undefined;
environment: RampsEnvironment;
};

// === HELPER FUNCTIONS ===
Expand Down Expand Up @@ -879,11 +883,10 @@ export class RampsController extends BaseController<
readonly #requestCacheMaxSize: number;

/**
* Resolves the default redirect URL for the widened quote fetch when
* the caller omits `redirectUrl`. Defaults to `() => undefined` when no
* callback is injected.
* The ramps environment used to derive the default redirect URL for the
* widened quote fetch when the caller omits `redirectUrl`.
*/
readonly #getDefaultRedirectUrl: () => string | undefined;
readonly #environment: RampsEnvironment;

/**
* Map of pending requests for deduplication.
Expand Down Expand Up @@ -951,16 +954,17 @@ 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.getDefaultRedirectUrl - Optional callback returning the default
* redirect URL used for the widened quote fetch when the caller omits
* `redirectUrl`. Defaults to a callback returning `undefined`.
* @param args.environment - The ramps environment used to derive the default
* redirect URL for the widened quote fetch when the caller omits
* `redirectUrl`. Must match the environment used by {@link RampsService} and
* by callback-matching UI code.
*/
constructor({
messenger,
state = {},
requestCacheTTL = DEFAULT_REQUEST_CACHE_TTL,
requestCacheMaxSize = DEFAULT_REQUEST_CACHE_MAX_SIZE,
getDefaultRedirectUrl,
environment,
}: RampsControllerOptions) {
super({
messenger,
Expand All @@ -976,8 +980,7 @@ export class RampsController extends BaseController<

this.#requestCacheTTL = requestCacheTTL;
this.#requestCacheMaxSize = requestCacheMaxSize;
this.#getDefaultRedirectUrl =
getDefaultRedirectUrl ?? ((): string | undefined => undefined);
this.#environment = environment;

this.messenger.registerMethodActionHandlers(
this,
Expand Down Expand Up @@ -2000,13 +2003,15 @@ 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 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 the native-only path
// (flag off) never injects.
// present, so on the widened path (where MM Pay omits one) derive the
// default from the environment so aggregator quotes carry a usable widget
// URL. An explicit caller `redirectUrl` always wins, and the native-only
// path (flag off) never injects.
const effectiveRedirectUrl =
options.redirectUrl ??
(widenToAllProviders ? this.#getDefaultRedirectUrl() : undefined);
(widenToAllProviders
? getDefaultRedirectCallbackUrl(this.#environment)
: undefined);

const cacheKey = createCacheKey('getQuotes', [
normalizedRegion,
Expand Down
32 changes: 31 additions & 1 deletion packages/ramps-controller/src/RampsService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import nock, { cleanAll } from 'nock';
import { flushPromises } from '../../../tests/helpers.js';
import packageJson from '../package.json';
import type { RampsServiceMessenger } from './RampsService.js';
import { RampsService, RampsEnvironment } from './RampsService.js';
import {
getDefaultRedirectCallbackUrl,
RampsService,
RampsEnvironment,
} from './RampsService.js';

const CONTROLLER_VERSION = packageJson.version;

Expand Down Expand Up @@ -3131,6 +3135,32 @@ describe('RampsService', () => {
});
});

describe('getDefaultRedirectCallbackUrl', () => {
it.each([
[
RampsEnvironment.Production,
'https://on-ramp-content.api.cx.metamask.io/regions/fake-callback',
],
[
RampsEnvironment.Staging,
'https://on-ramp-content.uat-api.cx.metamask.io/regions/fake-callback',
],
[
RampsEnvironment.Development,
'https://on-ramp.dev-api.cx.metamask.io/regions/fake-callback',
],
[RampsEnvironment.Local, 'http://localhost:3000/regions/fake-callback'],
])('derives the callback URL for the %s environment', (environment, url) => {
expect(getDefaultRedirectCallbackUrl(environment)).toBe(url);
});

it('throws for an unknown environment', () => {
expect(() =>
getDefaultRedirectCallbackUrl('unknown' as unknown as RampsEnvironment),
).toThrow('Invalid environment: unknown');
});
});

/**
* The type of the messenger populated with all external actions and events
* required by the service under test.
Expand Down
39 changes: 39 additions & 0 deletions packages/ramps-controller/src/RampsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,45 @@ function getBaseUrl(
}
}

/**
* The path served by the ramps content host that redirects back into the
* client once a non-native provider's widget flow completes.
*/
const FAKE_CALLBACK_PATH = '/regions/fake-callback';

/**
* Derives the default redirect ("fake callback") URL for the widened Headless
* Buy quote fetch from the ramps environment.
*
* The quotes API only embeds a `buyURL`/`buyWidget` (the WebView page a
* non-native provider needs) when a `redirectUrl` is present, so the widened
* aggregator path supplies this default when the caller omits one. Production
* and staging serve the callback from the `on-ramp-content` CDN hosts;
* development has no `on-ramp-content.dev-api` deployment, so it uses the
* `on-ramp.dev-api` host (which serves `/regions/fake-callback` and returns
* 200). This intentionally does not reuse {@link getBaseUrl}, whose Regions
* host is `on-ramp{-cache}`, not `on-ramp-content`.
*
* @param environment - The environment to derive the callback URL for.
* @returns The default redirect callback URL for that environment.
*/
export function getDefaultRedirectCallbackUrl(
environment: RampsEnvironment,
): string {
switch (environment) {
case RampsEnvironment.Production:
return `https://on-ramp-content.api.cx.metamask.io${FAKE_CALLBACK_PATH}`;
case RampsEnvironment.Staging:
return `https://on-ramp-content.uat-api.cx.metamask.io${FAKE_CALLBACK_PATH}`;
case RampsEnvironment.Development:
return `https://on-ramp.dev-api.cx.metamask.io${FAKE_CALLBACK_PATH}`;
case RampsEnvironment.Local:
return `http://localhost:3000${FAKE_CALLBACK_PATH}`;
default:
throw new Error(`Invalid environment: ${String(environment)}`);
}
}

/**
* Constructs an API path with a version prefix.
*
Expand Down
1 change: 1 addition & 0 deletions packages/ramps-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export {
RampsApiService,
RampsOrderStatus,
RAMPS_SDK_VERSION,
getDefaultRedirectCallbackUrl,
} from './RampsService.js';
export type {
RampsServiceGetGeolocationAction,
Expand Down