From eaad2dbd569c3342e2fc339ae6f043aeb8be828b Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Mon, 3 Aug 2026 08:14:59 -0700 Subject: [PATCH 1/3] feat(ramps-controller)!: derive widened Headless Buy default redirect URL from environment Move the widened-path default redirect ("fake callback") URL derivation into the controller instead of accepting a client-injected URL callback. - Add `getDefaultRedirectCallbackUrl(environment)` mapping each `RampsEnvironment` to its `on-ramp-content` fake-callback host (dev uses `on-ramp.dev-api`, which has no content deployment; local uses `localhost:3000`). - Replace the `getDefaultRedirectUrl` callback option on `RampsControllerOptions` with an `environment` option (defaults to Staging, matching `RampsService`); the widened quote path derives the default from it. BREAKING: `getDefaultRedirectUrl` is removed from `RampsControllerOptions`; clients pass `environment` instead of injecting a URL callback. Behaviour is preserved: an explicit caller `redirectUrl` still wins, and the native-only (flag-off) path still injects nothing. --- packages/ramps-controller/CHANGELOG.md | 9 ++++ .../src/RampsController.test.ts | 40 +++++++------- .../ramps-controller/src/RampsController.ts | 52 ++++++++++--------- .../ramps-controller/src/RampsService.test.ts | 34 +++++++++++- packages/ramps-controller/src/RampsService.ts | 39 ++++++++++++++ packages/ramps-controller/src/index.ts | 1 + 6 files changed, 132 insertions(+), 43 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index c7cd05d266d..c22f3af3d91 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -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 an optional `environment` option to `RampsControllerOptions` (defaults to `RampsEnvironment.Staging`, matching `RampsService`) that the controller uses to derive the widened-path default redirect URL internally ([#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 `environment` via `getDefaultRedirectCallbackUrl`, so clients pass `environment` instead of injecting a URL callback ([#9752](https://github.com/MetaMask/core/pull/9752)) + ## [18.0.1] ### Changed diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 1bb8099f86a..2a525432bd5 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -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, @@ -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')]), }, }, @@ -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')]), }, }, @@ -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')]), }, }, @@ -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 default (staging) environment when none is provided', async () => { const response: QuotesResponse = { success: [appBrowserQuote(MOONPAY, 90)], sorted: [{ sortBy: 'reliability', ids: [MOONPAY] }], @@ -1531,10 +1533,12 @@ describe('RampsController', () => { await callScopedGetQuotes(messenger); - // With no injected callback, the constructor default returns - // undefined, so the widened path forwards undefined. + // With no environment option, the controller defaults to staging and + // the widened path forwards the staging-derived callback URL. expect(redirectUrlWasSeen).toBe(true); - expect(forwardedRedirectUrl).toBeUndefined(); + expect(forwardedRedirectUrl).toBe( + getDefaultRedirectCallbackUrl(RampsEnvironment.Staging), + ); }, ); }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 3f388e457f3..fb198ec2e71 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -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, @@ -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. Defaults to {@link RampsEnvironment.Staging}, + * matching {@link RampsService}. */ - getDefaultRedirectUrl?: () => string | undefined; + environment?: RampsEnvironment; }; // === HELPER FUNCTIONS === @@ -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. @@ -951,16 +954,16 @@ 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`. Defaults to {@link RampsEnvironment.Staging}. */ constructor({ messenger, state = {}, requestCacheTTL = DEFAULT_REQUEST_CACHE_TTL, requestCacheMaxSize = DEFAULT_REQUEST_CACHE_MAX_SIZE, - getDefaultRedirectUrl, + environment = RampsEnvironment.Staging, }: RampsControllerOptions) { super({ messenger, @@ -976,8 +979,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, @@ -2000,13 +2002,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, diff --git a/packages/ramps-controller/src/RampsService.test.ts b/packages/ramps-controller/src/RampsService.test.ts index d645d447456..1e7e4170f42 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -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; @@ -3131,6 +3135,34 @@ 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. diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index 819e95bcdc1..b48e92d3b20 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -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. * diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 239e3a5b7ec..457e784e4d4 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -109,6 +109,7 @@ export { RampsApiService, RampsOrderStatus, RAMPS_SDK_VERSION, + getDefaultRedirectCallbackUrl, } from './RampsService.js'; export type { RampsServiceGetGeolocationAction, From 600acd3d8dc21cccd00b77512d817b4fda82e1f0 Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Mon, 3 Aug 2026 08:15:00 -0700 Subject: [PATCH 2/3] fix(ramps-controller): require environment for redirect URL derivation Make RampsControllerOptions.environment required so a production consumer cannot silently fall back to the Staging fake-callback after dropping getDefaultRedirectUrl. Document that the same environment must be used by RampsService and callback-matching UI. --- packages/ramps-controller/CHANGELOG.md | 4 ++-- .../ramps-controller/src/RampsController.test.ts | 15 ++++++++------- packages/ramps-controller/src/RampsController.ts | 11 ++++++----- .../ramps-controller/src/RampsService.test.ts | 4 +--- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index c22f3af3d91..f1acf2414bc 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -10,11 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 an optional `environment` option to `RampsControllerOptions` (defaults to `RampsEnvironment.Staging`, matching `RampsService`) that the controller uses to derive the widened-path default redirect URL internally ([#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 `environment` via `getDefaultRedirectCallbackUrl`, so clients pass `environment` instead of injecting a URL callback ([#9752](https://github.com/MetaMask/core/pull/9752)) +- **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] diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 2a525432bd5..32d6ddbf0ab 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -1504,7 +1504,7 @@ describe('RampsController', () => { ); }); - it('derives the default redirectUrl from the default (staging) environment when none 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] }], @@ -1533,8 +1533,8 @@ describe('RampsController', () => { await callScopedGetQuotes(messenger); - // With no environment option, the controller defaults to staging and - // the widened path forwards the staging-derived callback URL. + // The test helper supplies staging when this test does not override + // the environment. expect(redirectUrlWasSeen).toBe(true); expect(forwardedRedirectUrl).toBe( getDefaultRedirectCallbackUrl(RampsEnvironment.Staging), @@ -11456,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( @@ -11473,6 +11473,7 @@ async function withController( const messenger = getMessenger(rootMessenger); const controller = new RampsController({ messenger, + environment: RampsEnvironment.Staging, ...options, }); return await testFunction({ controller, rootMessenger, messenger }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index fb198ec2e71..4e57bc443bb 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -692,10 +692,10 @@ export type RampsControllerOptions = { * 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 path never injects. Defaults to {@link RampsEnvironment.Staging}, - * matching {@link RampsService}. + * native-only path never injects. Consumers must pass the same environment + * used by {@link RampsService} and by callback-matching UI code. */ - environment?: RampsEnvironment; + environment: RampsEnvironment; }; // === HELPER FUNCTIONS === @@ -956,14 +956,15 @@ export class RampsController extends BaseController< * @param args.requestCacheMaxSize - Maximum number of entries in the request cache. * @param args.environment - The ramps environment used to derive the default * redirect URL for the widened quote fetch when the caller omits - * `redirectUrl`. Defaults to {@link RampsEnvironment.Staging}. + * `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, - environment = RampsEnvironment.Staging, + environment, }: RampsControllerOptions) { super({ messenger, diff --git a/packages/ramps-controller/src/RampsService.test.ts b/packages/ramps-controller/src/RampsService.test.ts index 1e7e4170f42..6b8d275c4d6 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -3156,9 +3156,7 @@ describe('getDefaultRedirectCallbackUrl', () => { it('throws for an unknown environment', () => { expect(() => - getDefaultRedirectCallbackUrl( - 'unknown' as unknown as RampsEnvironment, - ), + getDefaultRedirectCallbackUrl('unknown' as unknown as RampsEnvironment), ).toThrow('Invalid environment: unknown'); }); }); From 366b2aeac81ad803ba97763220a0f6a6ddf3eeff Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Mon, 3 Aug 2026 08:15:00 -0700 Subject: [PATCH 3/3] feat(ramps-controller)!: source widened default redirect URL from RampsService Make RampsService the single runtime owner of the Headless Buy fake-callback URL. RampsController asks for it through a new messenger action on the widened path when the caller omits redirectUrl, instead of taking a duplicated environment option or a client URL callback. - Export getDefaultRedirectCallbackUrl(environment) as the canonical map. - Expose RampsService:getDefaultRedirectCallbackUrl (sync; baseUrlOverride does not apply, because prod/staging callbacks use on-ramp-content hosts). - Remove getDefaultRedirectUrl and the temporary environment constructor option. - Pin the missing-delegation contract: undelegated hosts reject the whole getQuotes call, including MM Pay fiat quoting. BREAKING: hosts must delegate the new action (or spread RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS) and stop passing getDefaultRedirectUrl. --- packages/ramps-controller/CHANGELOG.md | 13 +- .../src/RampsController.test.ts | 181 +++++++++++++----- .../ramps-controller/src/RampsController.ts | 44 ++--- .../src/RampsService-method-action-types.ts | 32 ++++ .../ramps-controller/src/RampsService.test.ts | 61 ++++++ packages/ramps-controller/src/RampsService.ts | 39 ++++ packages/ramps-controller/src/index.ts | 1 + 7 files changed, 282 insertions(+), 89 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index f1acf2414bc..c84d5b0cf06 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -9,12 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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)) +- Add `RampsService.getDefaultRedirectCallbackUrl()` and the matching `RampsService:getDefaultRedirectCallbackUrl` messenger action (plus the exported `RampsServiceGetDefaultRedirectCallbackUrlAction` type), which return the widened Headless Buy default redirect ("fake callback") URL for the environment the service was constructed with. The method is synchronous. ([#9752](https://github.com/MetaMask/core/pull/9752)) + - `baseUrlOverride` deliberately does not apply. It overrides the ramps API host for local development, which in production and staging is not the host that serves `/regions/fake-callback` (`on-ramp-content` versus `on-ramp{-cache}`), and the redirect URL is matched by client UI to detect flow completion. For development the callback already shares the API host family (`on-ramp.dev-api`). Use `RampsEnvironment.Local` for a localhost callback, noting it is pinned to `http://localhost:3000` and does not follow a non-3000 `baseUrlOverride`. +- Add the exported `getDefaultRedirectCallbackUrl(environment)` helper, the canonical environment-to-callback map that `RampsService` uses: `on-ramp-content` hosts for production and staging, `on-ramp.dev-api` for development (there is no `on-ramp-content.dev-api` deployment), and `localhost:3000` for local. Client code that needs the value synchronously, without the messenger, can call it directly with the same environment the service was given. ([#9752](https://github.com/MetaMask/core/pull/9752)) + +### Changed + +- **BREAKING:** `RampsController` now calls `RampsService:getDefaultRedirectCallbackUrl` on the widened quote path, so hosts must delegate that action to the controller's messenger. It is included in the exported `RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS` list; hosts that spell out their delegated action list instead of spreading that constant have to add it, or the entire `RampsController:getQuotes` call rejects with a messenger "handler has not been delegated" error (including MM Pay's fiat quote path, which omits `redirectUrl` and relies on widening). ([#9752](https://github.com/MetaMask/core/pull/9752)) + - The action is only called when the `moneyHeadlessAllProviders` widening is in effect and the caller omitted `redirectUrl`. An explicit `redirectUrl` and the native-only path never reach the service. ### 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)) +- **BREAKING:** Remove the `getDefaultRedirectUrl` callback option from `RampsControllerOptions`. The controller asks `RampsService` for the default redirect URL instead, which keeps the environment a single runtime source of truth so the callback host cannot drift from the API host the service is talking to. ([#9752](https://github.com/MetaMask/core/pull/9752)) + - Mobile should drop the `getDefaultRedirectUrl: () => getRampCallbackBaseUrl()` argument from its `RampsController` init once it upgrades, and reimplement `getRampCallbackBaseUrl()` as `getDefaultRedirectCallbackUrl(getRampsEnvironment())` so the UI callback matcher and the controller default resolve from the same environment source. ## [18.0.1] diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 32d6ddbf0ab..e6535ad34dd 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -45,11 +45,7 @@ import type { RampsToken, RampsOrder, } from './RampsService.js'; -import { - getDefaultRedirectCallbackUrl, - RampsEnvironment, - RampsOrderStatus, -} from './RampsService.js'; +import { RampsOrderStatus } from './RampsService.js'; import { RequestStatus } from './RequestCache.js'; import type { TransakAccessToken, @@ -68,6 +64,14 @@ import type { PatchUserRequestBody, } from './TransakService.js'; +/** + * The default redirect ("fake callback") URL a staging `RampsService` returns. + * Written out in full so the tests pin the exact host the widened quote path + * forwards, rather than re-deriving it from the code under test. + */ +const STAGING_REDIRECT_CALLBACK_URL = + 'https://on-ramp-content.uat-api.cx.metamask.io/regions/fake-callback'; + describe('RampsController', () => { const circuitBreakerOpenErrorMessage = 'Execution prevented because the circuit breaker is open'; @@ -1391,7 +1395,7 @@ describe('RampsController', () => { ); }); - it('forwards the environment-derived default redirectUrl on the widened path when the caller omits one', async () => { + it("forwards the service's default redirectUrl on the widened path when the caller omits one", async () => { const response: QuotesResponse = { success: [appBrowserQuote(MOONPAY, 90)], sorted: [{ sortBy: 'reliability', ids: [MOONPAY] }], @@ -1402,12 +1406,13 @@ describe('RampsController', () => { await withController( { options: { - environment: RampsEnvironment.Production, state: scopeState([buildScopeProvider(MOONPAY, 'aggregator')]), }, }, async ({ messenger, rootMessenger }) => { registerFeatureFlagState(rootMessenger); + const getDefaultRedirectCallbackUrlSpy = + spyOnDefaultRedirectCallbackUrl(rootMessenger); let forwardedRedirectUrl: string | undefined; rootMessenger.registerActionHandler( 'RampsService:getQuotes', @@ -1419,73 +1424,69 @@ describe('RampsController', () => { await callScopedGetQuotes(messenger); - // The caller omitted redirectUrl, so the widened path derives the - // default from the controller's environment and forwards it. + // The caller omitted redirectUrl, so the widened path asks the + // service for the callback URL of its environment and forwards it. + expect(getDefaultRedirectCallbackUrlSpy).toHaveBeenCalledTimes(1); expect(forwardedRedirectUrl).toBe( - getDefaultRedirectCallbackUrl(RampsEnvironment.Production), + 'https://on-ramp-content.uat-api.cx.metamask.io/regions/fake-callback', ); }, ); }); - it('prefers an explicit caller redirectUrl over the environment-derived default on the widened path', async () => { + it('rejects the entire getQuotes call when the default-redirect service action is not delegated', async () => { const response: QuotesResponse = { success: [appBrowserQuote(MOONPAY, 90)], sorted: [{ sortBy: 'reliability', ids: [MOONPAY] }], error: [], customActions: [], }; - const EXPLICIT_REDIRECT = 'https://explicit.example/callback'; await withController( { options: { - environment: RampsEnvironment.Production, state: scopeState([buildScopeProvider(MOONPAY, 'aggregator')]), }, }, async ({ messenger, rootMessenger }) => { registerFeatureFlagState(rootMessenger); - let forwardedRedirectUrl: string | undefined; + // Simulate a host that upgraded without adding the new action to + // its hand-written messenger delegation list. + rootMessenger.unregisterActionHandler( + 'RampsService:getDefaultRedirectCallbackUrl', + ); rootMessenger.registerActionHandler( 'RampsService:getQuotes', - async (params: { redirectUrl?: string }) => { - forwardedRedirectUrl = params.redirectUrl; - return response; - }, + async () => response, ); - await callScopedGetQuotes(messenger, { - redirectUrl: EXPLICIT_REDIRECT, - }); - - // An explicit caller redirectUrl always wins; the default is not - // applied. - expect(forwardedRedirectUrl).toBe(EXPLICIT_REDIRECT); + await expect(callScopedGetQuotes(messenger)).rejects.toThrow( + /A handler for RampsService:getDefaultRedirectCallbackUrl has not been (registered|delegated)/u, + ); }, ); }); - it('does not inject the default redirectUrl when the flag is disabled', async () => { + it('forwards whichever callback URL the service reports, without rederiving it', async () => { const response: QuotesResponse = { - success: [appBrowserQuote(NATIVE, 70)], - sorted: [{ sortBy: 'reliability', ids: [NATIVE] }], + success: [appBrowserQuote(MOONPAY, 90)], + sorted: [{ sortBy: 'reliability', ids: [MOONPAY] }], error: [], customActions: [], }; + await withController( { options: { - environment: RampsEnvironment.Production, - state: scopeState([buildScopeProvider(NATIVE, 'native')]), + state: scopeState([buildScopeProvider(MOONPAY, 'aggregator')]), }, }, async ({ messenger, rootMessenger }) => { - registerFeatureFlagState(rootMessenger, { - remoteFeatureFlags: { - [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: false, - }, - }); + registerFeatureFlagState(rootMessenger); + spyOnDefaultRedirectCallbackUrl( + rootMessenger, + 'https://on-ramp-content.api.cx.metamask.io/regions/fake-callback', + ); let forwardedRedirectUrl: string | undefined; rootMessenger.registerActionHandler( 'RampsService:getQuotes', @@ -1497,20 +1498,23 @@ describe('RampsController', () => { await callScopedGetQuotes(messenger); - // The disabled flag never widens, so the default is not injected - // even though the controller has an environment configured. - expect(forwardedRedirectUrl).toBeUndefined(); + // A production service reports the production host, and the + // controller passes it through untouched. + expect(forwardedRedirectUrl).toBe( + 'https://on-ramp-content.api.cx.metamask.io/regions/fake-callback', + ); }, ); }); - it('derives the default redirectUrl from the environment supplied by the test helper', async () => { + it('prefers an explicit caller redirectUrl and never asks the service on the widened path', async () => { const response: QuotesResponse = { success: [appBrowserQuote(MOONPAY, 90)], sorted: [{ sortBy: 'reliability', ids: [MOONPAY] }], error: [], customActions: [], }; + const EXPLICIT_REDIRECT = 'https://explicit.example/callback'; await withController( { @@ -1520,25 +1524,65 @@ describe('RampsController', () => { }, async ({ messenger, rootMessenger }) => { registerFeatureFlagState(rootMessenger); + const getDefaultRedirectCallbackUrlSpy = + spyOnDefaultRedirectCallbackUrl(rootMessenger); let forwardedRedirectUrl: string | undefined; - let redirectUrlWasSeen = false; rootMessenger.registerActionHandler( 'RampsService:getQuotes', async (params: { redirectUrl?: string }) => { forwardedRedirectUrl = params.redirectUrl; - redirectUrlWasSeen = true; return response; }, ); - await callScopedGetQuotes(messenger); + await callScopedGetQuotes(messenger, { + redirectUrl: EXPLICIT_REDIRECT, + }); - // The test helper supplies staging when this test does not override - // the environment. - expect(redirectUrlWasSeen).toBe(true); - expect(forwardedRedirectUrl).toBe( - getDefaultRedirectCallbackUrl(RampsEnvironment.Staging), + // An explicit caller redirectUrl always wins, and the controller + // short-circuits before reaching the service. + expect(forwardedRedirectUrl).toBe(EXPLICIT_REDIRECT); + expect(getDefaultRedirectCallbackUrlSpy).not.toHaveBeenCalled(); + }, + ); + }); + + it('does not inject the default redirectUrl or ask the service when the flag is disabled', async () => { + const response: QuotesResponse = { + success: [appBrowserQuote(NATIVE, 70)], + sorted: [{ sortBy: 'reliability', ids: [NATIVE] }], + error: [], + customActions: [], + }; + await withController( + { + options: { + state: scopeState([buildScopeProvider(NATIVE, 'native')]), + }, + }, + async ({ messenger, rootMessenger }) => { + registerFeatureFlagState(rootMessenger, { + remoteFeatureFlags: { + [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: false, + }, + }); + const getDefaultRedirectCallbackUrlSpy = + spyOnDefaultRedirectCallbackUrl(rootMessenger); + let forwardedRedirectUrl: string | undefined; + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async (params: { redirectUrl?: string }) => { + forwardedRedirectUrl = params.redirectUrl; + return response; + }, ); + + await callScopedGetQuotes(messenger); + + // The disabled flag never widens, so nothing is injected and the + // service is not consulted. + expect(forwardedRedirectUrl).toBeUndefined(); + expect(getDefaultRedirectCallbackUrlSpy).not.toHaveBeenCalled(); }, ); }); @@ -11426,7 +11470,39 @@ type WithControllerOptions = { * @returns The root messenger. */ function getRootMessenger(): RootMessenger { - return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + // Stands in for the real service, which derives this from its environment. + rootMessenger.registerActionHandler( + 'RampsService:getDefaultRedirectCallbackUrl', + () => STAGING_REDIRECT_CALLBACK_URL, + ); + return rootMessenger; +} + +/** + * Replaces the default `RampsService:getDefaultRedirectCallbackUrl` handler + * with a spy, so a test can assert whether the controller asked the service + * for the default redirect URL at all. + * + * @param rootMessenger - The root messenger to re-register the handler on. + * @param url - The URL the spy returns. Defaults to the staging callback URL. + * @returns The spy standing in for the service method. + */ +function spyOnDefaultRedirectCallbackUrl( + rootMessenger: RootMessenger, + url: string = STAGING_REDIRECT_CALLBACK_URL, +): jest.Mock { + const handler = jest.fn(() => url); + rootMessenger.unregisterActionHandler( + 'RampsService:getDefaultRedirectCallbackUrl', + ); + rootMessenger.registerActionHandler( + 'RampsService:getDefaultRedirectCallbackUrl', + handler, + ); + return handler; } /** @@ -11456,10 +11532,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. 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. + * 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. * @returns The same return value as the given function. */ async function withController( @@ -11473,7 +11549,6 @@ async function withController( const messenger = getMessenger(rootMessenger); const controller = new RampsController({ messenger, - environment: RampsEnvironment.Staging, ...options, }); return await testFunction({ controller, rootMessenger, messenger }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 4e57bc443bb..5c3df5c3186 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -20,6 +20,7 @@ import type { RampsControllerMethodActions } from './RampsController-method-acti import type { RampsErrorCode } from './rampsErrorCodes.js'; import { RAMPS_ERROR_CODES } from './rampsErrorCodes.js'; import type { + RampsServiceGetDefaultRedirectCallbackUrlAction, RampsServiceGetGeolocationAction, RampsServiceGetCountriesAction, RampsServiceGetTokensAction, @@ -46,11 +47,7 @@ import type { RampsServiceActions, RampsOrder, } from './RampsService.js'; -import { - getDefaultRedirectCallbackUrl, - RampsEnvironment, - RampsOrderStatus, -} from './RampsService.js'; +import { RampsOrderStatus } from './RampsService.js'; import type { RequestCache as RequestCacheType, RequestState, @@ -131,6 +128,7 @@ export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS: readonly ( | RampsServiceActions['type'] | TransakServiceActions['type'] )[] = [ + 'RampsService:getDefaultRedirectCallbackUrl', 'RampsService:getGeolocation', 'RampsService:getCountries', 'RampsService:getTokens', @@ -599,6 +597,7 @@ export type RampsControllerActions = */ type AllowedActions = | RemoteFeatureFlagControllerGetStateAction + | RampsServiceGetDefaultRedirectCallbackUrlAction | RampsServiceGetGeolocationAction | RampsServiceGetCountriesAction | RampsServiceGetTokensAction @@ -685,17 +684,6 @@ export type RampsControllerOptions = { requestCacheTTL?: number; /** Maximum number of entries in the request cache. Defaults to 250. */ requestCacheMaxSize?: number; - /** - * 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 path never injects. Consumers must pass the same environment - * used by {@link RampsService} and by callback-matching UI code. - */ - environment: RampsEnvironment; }; // === HELPER FUNCTIONS === @@ -882,12 +870,6 @@ export class RampsController extends BaseController< */ readonly #requestCacheMaxSize: number; - /** - * The ramps environment used to derive the default redirect URL for the - * widened quote fetch when the caller omits `redirectUrl`. - */ - readonly #environment: RampsEnvironment; - /** * Map of pending requests for deduplication. * Key is the cache key, value is the pending request with abort controller. @@ -954,17 +936,12 @@ 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.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, - environment, }: RampsControllerOptions) { super({ messenger, @@ -980,7 +957,6 @@ export class RampsController extends BaseController< this.#requestCacheTTL = requestCacheTTL; this.#requestCacheMaxSize = requestCacheMaxSize; - this.#environment = environment; this.messenger.registerMethodActionHandlers( this, @@ -2003,14 +1979,16 @@ 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) 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. + // present, so on the widened path (where MM Pay omits one) ask the service + // for the callback URL of the environment it is configured with, so + // aggregator quotes carry a usable widget URL that always matches the + // environment the quotes came from. An explicit caller `redirectUrl` + // always wins, and the native-only path (flag off) never injects, so + // neither reaches the service. const effectiveRedirectUrl = options.redirectUrl ?? (widenToAllProviders - ? getDefaultRedirectCallbackUrl(this.#environment) + ? this.messenger.call('RampsService:getDefaultRedirectCallbackUrl') : undefined); const cacheKey = createCacheKey('getQuotes', [ diff --git a/packages/ramps-controller/src/RampsService-method-action-types.ts b/packages/ramps-controller/src/RampsService-method-action-types.ts index e4e3044b28a..c9b00ec92d4 100644 --- a/packages/ramps-controller/src/RampsService-method-action-types.ts +++ b/packages/ramps-controller/src/RampsService-method-action-types.ts @@ -5,6 +5,37 @@ import type { RampsService } from './RampsService.js'; +/** + * Returns the default redirect ("fake callback") URL for this service's + * environment. + * + * The quotes API only embeds a `buyURL`/`buyWidget` (the WebView page a + * non-native provider needs) when a `redirectUrl` is present, so callers + * that omit one (MM Pay's widened Headless Buy fetch) use this value. + * Exposing it here makes the service's environment the single runtime source + * of truth, so the callback can never point at a different environment than + * the one the quotes themselves came from. + * + * `baseUrlOverride` deliberately does not apply. That option overrides the + * ramps API base URL for local development. In production and staging the + * callback lives on a different host (`on-ramp-content` versus + * `on-ramp{-cache}`), so an API override says nothing about where + * `/regions/fake-callback` lives. In development the callback already shares + * the API host family (`on-ramp.dev-api`). The redirect URL is also handed + * to the provider and matched by client UI to detect flow completion, so + * returning an unrelated local API origin here would break completion + * detection rather than help it. Point `environment` at + * {@link RampsEnvironment.Local} for a localhost callback, noting that URL + * is pinned to `http://localhost:3000` and does not follow a non-3000 + * `baseUrlOverride`. + * + * @returns The default redirect callback URL for the configured environment. + */ +export type RampsServiceGetDefaultRedirectCallbackUrlAction = { + type: `RampsService:getDefaultRedirectCallbackUrl`; + handler: RampsService['getDefaultRedirectCallbackUrl']; +}; + /** * Makes a request to the API in order to retrieve the user's geolocation * based on their IP address. @@ -149,6 +180,7 @@ export type RampsServiceGetOrderFromCallbackAction = { * Union of all RampsService action types. */ export type RampsServiceMethodActions = + | RampsServiceGetDefaultRedirectCallbackUrlAction | RampsServiceGetGeolocationAction | RampsServiceGetCountriesAction | RampsServiceGetTokensAction diff --git a/packages/ramps-controller/src/RampsService.test.ts b/packages/ramps-controller/src/RampsService.test.ts index 6b8d275c4d6..bb59974639f 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -3133,6 +3133,67 @@ describe('RampsService', () => { await expect(orderPromise).rejects.toThrow("failed with status '500'"); }); }); + + 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'], + ])( + 'returns the callback URL for the %s environment', + (environment, url) => { + const { service } = getService({ options: { environment } }); + + expect(service.getDefaultRedirectCallbackUrl()).toBe(url); + }, + ); + + it('defaults to the staging callback URL, matching the default environment', () => { + const { service } = getService(); + + expect(service.getDefaultRedirectCallbackUrl()).toBe( + 'https://on-ramp-content.uat-api.cx.metamask.io/regions/fake-callback', + ); + }); + + it('ignores baseUrlOverride, which only redirects the ramps API host', () => { + const { service } = getService({ + options: { + environment: RampsEnvironment.Production, + baseUrlOverride: 'http://custom-url.test', + }, + }); + + // The callback is served by the content host, not the API host, and the + // client matches this URL to detect flow completion, so a local API + // override must not move it. + expect(service.getDefaultRedirectCallbackUrl()).toBe( + 'https://on-ramp-content.api.cx.metamask.io/regions/fake-callback', + ); + }); + + it('is callable through the messenger', () => { + const { rootMessenger } = getService({ + options: { environment: RampsEnvironment.Production }, + }); + + expect( + rootMessenger.call('RampsService:getDefaultRedirectCallbackUrl'), + ).toBe( + 'https://on-ramp-content.api.cx.metamask.io/regions/fake-callback', + ); + }); + }); }); describe('getDefaultRedirectCallbackUrl', () => { diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index b48e92d3b20..14c6c8b45bd 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -672,6 +672,7 @@ export enum RampsApiService { // === MESSENGER === const MESSENGER_EXPOSED_METHODS = [ + 'getDefaultRedirectCallbackUrl', 'getGeolocation', 'getCountries', 'getTokens', @@ -768,6 +769,14 @@ const FAKE_CALLBACK_PATH = '/regions/fake-callback'; * 200). This intentionally does not reuse {@link getBaseUrl}, whose Regions * host is `on-ramp{-cache}`, not `on-ramp-content`. * + * This is the canonical environment-to-callback map for the whole package. + * Prefer the `RampsService:getDefaultRedirectCallbackUrl` messenger action at + * runtime so the value always follows the environment the service is actually + * configured with. Call this function directly only where a synchronous, + * messenger-free value is needed (for example client UI that matches the + * callback URL in a WebView), and pass the same environment the service was + * constructed with. + * * @param environment - The environment to derive the callback URL for. * @returns The default redirect callback URL for that environment. */ @@ -938,6 +947,36 @@ export class RampsService { return getBaseUrl(this.#environment, service); } + /** + * Returns the default redirect ("fake callback") URL for this service's + * environment. + * + * The quotes API only embeds a `buyURL`/`buyWidget` (the WebView page a + * non-native provider needs) when a `redirectUrl` is present, so callers + * that omit one (MM Pay's widened Headless Buy fetch) use this value. + * Exposing it here makes the service's environment the single runtime source + * of truth, so the callback can never point at a different environment than + * the one the quotes themselves came from. + * + * `baseUrlOverride` deliberately does not apply. That option overrides the + * ramps API base URL for local development. In production and staging the + * callback lives on a different host (`on-ramp-content` versus + * `on-ramp{-cache}`), so an API override says nothing about where + * `/regions/fake-callback` lives. In development the callback already shares + * the API host family (`on-ramp.dev-api`). The redirect URL is also handed + * to the provider and matched by client UI to detect flow completion, so + * returning an unrelated local API origin here would break completion + * detection rather than help it. Point `environment` at + * {@link RampsEnvironment.Local} for a localhost callback, noting that URL + * is pinned to `http://localhost:3000` and does not follow a non-3000 + * `baseUrlOverride`. + * + * @returns The default redirect callback URL for the configured environment. + */ + getDefaultRedirectCallbackUrl(): string { + return getDefaultRedirectCallbackUrl(this.#environment); + } + /** * Builds the request headers for authenticated ramps API calls. * diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 457e784e4d4..ef452ca5620 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -112,6 +112,7 @@ export { getDefaultRedirectCallbackUrl, } from './RampsService.js'; export type { + RampsServiceGetDefaultRedirectCallbackUrlAction, RampsServiceGetGeolocationAction, RampsServiceGetCountriesAction, RampsServiceGetPaymentMethodsAction,