From af72a990ff71a875975bcc73ea5df126644014d2 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 18 Dec 2025 22:57:54 -0700 Subject: [PATCH 1/4] feat(ramps-controller): adds ramps controller methods for region eligibility --- .../src/RampsController.test.ts | 314 +++++++++++++++++- .../ramps-controller/src/RampsController.ts | 76 ++++- .../src/RampsService-method-action-types.ts | 15 +- .../ramps-controller/src/RampsService.test.ts | 139 ++++++++ packages/ramps-controller/src/RampsService.ts | 77 ++++- packages/ramps-controller/src/index.ts | 7 +- 6 files changed, 620 insertions(+), 8 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index c2197bba703..854bb463d37 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -8,7 +8,11 @@ import type { import type { RampsControllerMessenger } from './RampsController'; import { RampsController } from './RampsController'; -import type { RampsServiceGetGeolocationAction } from './RampsService-method-action-types'; +import type { + RampsServiceGetGeolocationAction, + RampsServiceGetCountriesAction, +} from './RampsService-method-action-types'; +import type { Country } from './RampsService'; import { RequestStatus, createCacheKey } from './RequestCache'; describe('RampsController', () => { @@ -502,6 +506,308 @@ describe('RampsController', () => { }); }); }); + + describe('getCountries', () => { + const mockCountries: Country[] = [ + { + isoCode: 'US', + flag: 'πŸ‡ΊπŸ‡Έ', + name: 'United States of America', + phone: { + prefix: '+1', + placeholder: '(555) 123-4567', + template: '(XXX) XXX-XXXX', + }, + currency: 'USD', + supported: true, + recommended: true, + unsupportedStates: ['ny'], + transakSupported: true, + }, + { + isoCode: 'AT', + flag: 'πŸ‡¦πŸ‡Ή', + name: 'Austria', + phone: { + prefix: '+43', + placeholder: '660 1234567', + template: 'XXX XXXXXXX', + }, + currency: 'EUR', + supported: true, + transakSupported: true, + }, + ]; + + it('fetches countries from the service', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async () => mockCountries, + ); + + const countries = await controller.getCountries(); + + expect(countries).toMatchSnapshot(); + }); + }); + + it('caches countries response', async () => { + await withController(async ({ controller, rootMessenger }) => { + let callCount = 0; + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async () => { + callCount += 1; + return mockCountries; + }, + ); + + await controller.getCountries(); + await controller.getCountries(); + + expect(callCount).toBe(1); + }); + }); + + it('uses different cache keys for different actions', async () => { + await withController(async ({ controller, rootMessenger }) => { + let callCount = 0; + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async () => { + callCount += 1; + return mockCountries; + }, + ); + + await controller.getCountries('deposit'); + await controller.getCountries('withdraw'); + + expect(callCount).toBe(2); + }); + }); + }); + + describe('getRegionEligibility', () => { + const mockCountries: Country[] = [ + { + isoCode: 'US', + flag: 'πŸ‡ΊπŸ‡Έ', + name: 'United States of America', + phone: { + prefix: '+1', + placeholder: '(555) 123-4567', + template: '(XXX) XXX-XXXX', + }, + currency: 'USD', + supported: true, + unsupportedStates: ['ny'], + }, + { + isoCode: 'AT', + flag: 'πŸ‡¦πŸ‡Ή', + name: 'Austria', + phone: { + prefix: '+43', + placeholder: '660 1234567', + template: 'XXX XXXXXXX', + }, + currency: 'EUR', + supported: true, + }, + { + isoCode: 'RU', + flag: 'πŸ‡·πŸ‡Ί', + name: 'Russia', + phone: { + prefix: '+7', + placeholder: '999 123-45-67', + template: 'XXX XXX-XX-XX', + }, + currency: 'RUB', + supported: false, + }, + ]; + + it('fetches geolocation and returns eligibility when geolocation is null', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getGeolocation', + async () => 'AT', + ); + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async () => mockCountries, + ); + + expect(controller.state.geolocation).toBeNull(); + + const eligible = await controller.getRegionEligibility(); + + expect(controller.state.geolocation).toBe('AT'); + expect(eligible).toBe(true); + }); + }); + + it('fetches geolocation and returns false for unsupported region when geolocation is null', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getGeolocation', + async () => 'RU', + ); + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async () => mockCountries, + ); + + expect(controller.state.geolocation).toBeNull(); + + const eligible = await controller.getRegionEligibility(); + + expect(controller.state.geolocation).toBe('RU'); + expect(eligible).toBe(false); + }); + }); + + it('passes options to updateGeolocation when geolocation is null', async () => { + await withController(async ({ controller, rootMessenger }) => { + let geolocationCallCount = 0; + rootMessenger.registerActionHandler( + 'RampsService:getGeolocation', + async () => { + geolocationCallCount += 1; + return 'AT'; + }, + ); + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async () => mockCountries, + ); + + await controller.getRegionEligibility('deposit'); + await controller.getRegionEligibility('deposit', { forceRefresh: true }); + + expect(geolocationCallCount).toBe(2); + }); + }); + + it('returns true for a supported country', async () => { + await withController( + { options: { state: { geolocation: 'AT' } } }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async () => mockCountries, + ); + + const eligible = await controller.getRegionEligibility(); + + expect(eligible).toBe(true); + }, + ); + }); + + it('returns true for a supported US state', async () => { + await withController( + { options: { state: { geolocation: 'US-TX' } } }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async () => mockCountries, + ); + + const eligible = await controller.getRegionEligibility(); + + expect(eligible).toBe(true); + }, + ); + }); + + it('returns false for an unsupported US state', async () => { + await withController( + { options: { state: { geolocation: 'US-NY' } } }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async () => mockCountries, + ); + + const eligible = await controller.getRegionEligibility(); + + expect(eligible).toBe(false); + }, + ); + }); + + it('returns false for a country not in the list', async () => { + await withController( + { options: { state: { geolocation: 'XX' } } }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async () => mockCountries, + ); + + const eligible = await controller.getRegionEligibility(); + + expect(eligible).toBe(false); + }, + ); + }); + + it('returns false for a country that is not supported', async () => { + await withController( + { options: { state: { geolocation: 'RU' } } }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async () => mockCountries, + ); + + const eligible = await controller.getRegionEligibility(); + + expect(eligible).toBe(false); + }, + ); + }); + + it('is case-insensitive for state codes', async () => { + await withController( + { options: { state: { geolocation: 'US-ny' } } }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async () => mockCountries, + ); + + const eligible = await controller.getRegionEligibility(); + + expect(eligible).toBe(false); + }, + ); + }); + + it('passes action parameter to getCountries', async () => { + await withController( + { options: { state: { geolocation: 'AT' } } }, + async ({ controller, rootMessenger }) => { + let receivedAction: string | undefined; + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async (action) => { + receivedAction = action; + return mockCountries; + }, + ); + + await controller.getRegionEligibility('withdraw'); + + expect(receivedAction).toBe('withdraw'); + }, + ); + }); + }); }); /** @@ -510,7 +816,9 @@ describe('RampsController', () => { */ type RootMessenger = Messenger< MockAnyNamespace, - MessengerActions | RampsServiceGetGeolocationAction, + | MessengerActions + | RampsServiceGetGeolocationAction + | RampsServiceGetCountriesAction, MessengerEvents >; @@ -554,7 +862,7 @@ function getMessenger(rootMessenger: RootMessenger): RampsControllerMessenger { }); rootMessenger.delegate({ messenger, - actions: ['RampsService:getGeolocation'], + actions: ['RampsService:getGeolocation', 'RampsService:getCountries'], }); return messenger; } diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 9b72f9dfdbb..803235a4aef 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -7,7 +7,11 @@ import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; import type { Json } from '@metamask/utils'; -import type { RampsServiceGetGeolocationAction } from './RampsService-method-action-types'; +import type { + RampsServiceGetGeolocationAction, + RampsServiceGetCountriesAction, +} from './RampsService-method-action-types'; +import type { Country } from './RampsService'; import type { RequestCache as RequestCacheType, RequestState, @@ -101,7 +105,9 @@ export type RampsControllerActions = RampsControllerGetStateAction; /** * Actions from other messengers that {@link RampsController} calls. */ -type AllowedActions = RampsServiceGetGeolocationAction; +type AllowedActions = + | RampsServiceGetGeolocationAction + | RampsServiceGetCountriesAction; /** * Published when the state of {@link RampsController} changes. @@ -393,4 +399,70 @@ export class RampsController extends BaseController< return geolocation; } + + /** + * Fetches the list of supported countries for a given ramp action. + * + * @param action - The ramp action type + * @param options - Options for cache behavior. + * @returns An array of countries with their eligibility information. + */ + async getCountries( + action: 'deposit', + options?: ExecuteRequestOptions, + ): Promise { + const cacheKey = createCacheKey('getCountries', [action]); + + return this.executeRequest( + cacheKey, + async () => { + return this.messenger.call('RampsService:getCountries', action); + }, + options, + ); + } + + /** + * Determines if the user's current region is eligible for ramps. + * Checks the user's geolocation against the list of supported countries. + * + * @param action - The ramp action type ('deposit' or 'withdraw'). + * @param options - Options for cache behavior. + * @returns True if the user's region is eligible for ramps, false otherwise. + */ + async getRegionEligibility( + action: 'deposit', + options?: ExecuteRequestOptions, + ): Promise { + const { geolocation } = this.state; + + if (!geolocation) { + await this.updateGeolocation(options); + return this.getRegionEligibility(action, options); + } + + const countries = await this.getCountries(action, options); + + const countryCode = geolocation.split('-')[0]; + const stateCode = geolocation.split('-')[1]?.toLowerCase(); + + const country = countries.find( + (c) => c.isoCode.toUpperCase() === countryCode?.toUpperCase(), + ); + + if (!country || !country.supported) { + return false; + } + + if ( + stateCode && + country.unsupportedStates?.some( + (state) => state.toLowerCase() === stateCode, + ) + ) { + return false; + } + + return true; + } } diff --git a/packages/ramps-controller/src/RampsService-method-action-types.ts b/packages/ramps-controller/src/RampsService-method-action-types.ts index c6a0dffbd75..152c4258b52 100644 --- a/packages/ramps-controller/src/RampsService-method-action-types.ts +++ b/packages/ramps-controller/src/RampsService-method-action-types.ts @@ -16,7 +16,20 @@ export type RampsServiceGetGeolocationAction = { handler: RampsService['getGeolocation']; }; +/** + * Makes a request to the cached API to retrieve the list of supported countries. + * + * @param action - The ramp action type ('deposit' or 'withdraw'). + * @returns An array of countries with their eligibility information. + */ +export type RampsServiceGetCountriesAction = { + type: `RampsService:getCountries`; + handler: RampsService['getCountries']; +}; + /** * Union of all RampsService action types. */ -export type RampsServiceMethodActions = RampsServiceGetGeolocationAction; +export type RampsServiceMethodActions = + | RampsServiceGetGeolocationAction + | RampsServiceGetCountriesAction; diff --git a/packages/ramps-controller/src/RampsService.test.ts b/packages/ramps-controller/src/RampsService.test.ts index 31fda495ca9..083e5967100 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -139,6 +139,145 @@ describe('RampsService', () => { expect(geolocationResponse).toBe('US-TX'); }); }); + + describe('RampsService:getCountries', () => { + const mockCountriesResponse = [ + { + isoCode: 'US', + flag: 'πŸ‡ΊπŸ‡Έ', + name: 'United States of America', + phone: { + prefix: '+1', + placeholder: '(555) 123-4567', + template: '(XXX) XXX-XXXX', + }, + currency: 'USD', + supported: true, + recommended: true, + unsupportedStates: ['ny'], + transakSupported: true, + }, + { + isoCode: 'AT', + flag: 'πŸ‡¦πŸ‡Ή', + name: 'Austria', + phone: { + prefix: '+43', + placeholder: '660 1234567', + template: 'XXX XXXXXXX', + }, + currency: 'EUR', + supported: true, + transakSupported: true, + }, + ]; + + it('returns the countries from the cache API', async () => { + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/countries') + .query({ action: 'deposit', sdk: '2.1.6', context: 'mobile-ios' }) + .reply(200, mockCountriesResponse); + const { rootMessenger } = getService(); + + const countriesResponse = await rootMessenger.call( + 'RampsService:getCountries', + 'deposit', + ); + + expect(countriesResponse).toMatchSnapshot(); + }); + + it('uses the production cache URL when environment is Production', async () => { + nock('https://on-ramp-cache.api.cx.metamask.io') + .get('/regions/countries') + .query({ action: 'deposit', sdk: '2.1.6', context: 'mobile-ios' }) + .reply(200, mockCountriesResponse); + const { rootMessenger } = getService({ + options: { environment: RampsEnvironment.Production }, + }); + + const countriesResponse = await rootMessenger.call( + 'RampsService:getCountries', + 'deposit', + ); + + expect(countriesResponse).toMatchSnapshot(); + }); + + it('uses localhost cache URL when environment is Development', async () => { + nock('http://localhost:3001') + .get('/regions/countries') + .query({ action: 'deposit', sdk: '2.1.6', context: 'mobile-ios' }) + .reply(200, mockCountriesResponse); + const { rootMessenger } = getService({ + options: { environment: RampsEnvironment.Development }, + }); + + const countriesResponse = await rootMessenger.call( + 'RampsService:getCountries', + 'deposit', + ); + + expect(countriesResponse).toMatchSnapshot(); + }); + + it('passes the action parameter correctly', async () => { + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/countries') + .query({ action: 'withdraw', sdk: '2.1.6', context: 'mobile-ios' }) + .reply(200, mockCountriesResponse); + const { rootMessenger } = getService(); + + const countriesResponse = await rootMessenger.call( + 'RampsService:getCountries', + 'withdraw', + ); + + expect(countriesResponse).toMatchSnapshot(); + }); + + it('throws if the API returns an error', async () => { + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/countries') + .query({ action: 'deposit', sdk: '2.1.6', context: 'mobile-ios' }) + .times(4) + .reply(500); + const { service, rootMessenger } = getService(); + service.onRetry(() => { + clock.nextAsync().catch(() => undefined); + }); + + await expect( + rootMessenger.call('RampsService:getCountries', 'deposit'), + ).rejects.toThrow( + "Fetching 'https://on-ramp-cache.uat-api.cx.metamask.io/regions/countries?action=deposit&sdk=2.1.6&context=mobile-ios' failed with status '500'", + ); + }); + }); + + describe('getCountries', () => { + it('does the same thing as the messenger action', async () => { + const mockCountries = [ + { + isoCode: 'US', + flag: 'πŸ‡ΊπŸ‡Έ', + name: 'United States', + phone: { prefix: '+1', placeholder: '', template: '' }, + currency: 'USD', + supported: true, + }, + ]; + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/countries') + .query({ action: 'deposit', sdk: '2.1.6', context: 'mobile-ios' }) + .reply(200, mockCountries); + const { service } = getService(); + + const countriesResponse = await service.getCountries('deposit'); + + expect(countriesResponse).toMatchSnapshot(); + }); + }); }); /** diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index 3d0d88ab187..5e26f48c4bb 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -7,6 +7,30 @@ import type { Messenger } from '@metamask/messenger'; import type { RampsServiceMethodActions } from './RampsService-method-action-types'; +/** + * Represents phone number information for a country. + */ +export type CountryPhone = { + prefix: string; + placeholder: string; + template: string; +}; + +/** + * Represents a country returned from the regions/countries API. + */ +export type Country = { + isoCode: string; + flag: string; + name: string; + phone: CountryPhone; + currency: string; + supported: boolean; + recommended?: boolean; + unsupportedStates?: string[]; + transakSupported?: boolean; +}; + // === GENERAL === /** @@ -26,7 +50,7 @@ export enum RampsEnvironment { // === MESSENGER === -const MESSENGER_EXPOSED_METHODS = ['getGeolocation'] as const; +const MESSENGER_EXPOSED_METHODS = ['getGeolocation', 'getCountries'] as const; /** * Actions that {@link RampsService} exposes to other consumers. @@ -79,6 +103,25 @@ function getBaseUrl(environment: RampsEnvironment): string { } } +/** + * Gets the base URL for cached API requests based on the environment. + * + * @param environment - The environment to use. + * @returns The cache base URL for API requests. + */ +function getCacheBaseUrl(environment: RampsEnvironment): string { + switch (environment) { + case RampsEnvironment.Production: + return 'https://on-ramp-cache.api.cx.metamask.io'; + case RampsEnvironment.Staging: + return 'https://on-ramp-cache.uat-api.cx.metamask.io'; + case RampsEnvironment.Development: + return 'http://localhost:3001'; + default: + throw new Error(`Invalid environment: ${String(environment)}`); + } +} + /** * This service object is responsible for interacting with the Ramps API. * @@ -150,6 +193,11 @@ export class RampsService { */ readonly #baseUrl: string; + /** + * The base URL for cached API requests. + */ + readonly #cacheBaseUrl: string; + /** * Constructs a new RampsService object. * @@ -179,6 +227,7 @@ export class RampsService { this.#fetch = fetchFunction; this.#policy = createServicePolicy(policyOptions); this.#baseUrl = getBaseUrl(environment); + this.#cacheBaseUrl = getCacheBaseUrl(environment); this.#messenger.registerMethodActionHandlers( this, @@ -270,4 +319,30 @@ export class RampsService { throw new Error('Malformed response received from geolocation API'); } + + /** + * Makes a request to the cached API to retrieve the list of supported countries. + * + * @param action - The ramp action type ('deposit' or 'withdraw'). + * @returns An array of countries with their eligibility information. + */ + async getCountries(action: 'deposit' | 'withdraw' = 'deposit'): Promise { + const responseData = await this.#policy.execute(async () => { + const url = new URL('regions/countries', this.#cacheBaseUrl); + url.searchParams.set('action', action); + url.searchParams.set('sdk', '2.1.6'); + url.searchParams.set('context', 'mobile-ios'); + + const localResponse = await this.#fetch(url); + if (!localResponse.ok) { + throw new HttpError( + localResponse.status, + `Fetching '${url.toString()}' failed with status '${localResponse.status}'`, + ); + } + return localResponse.json() as Promise; + }); + + return responseData; + } } diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 78527ba470b..b0f9038a6df 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -15,9 +15,14 @@ export type { RampsServiceActions, RampsServiceEvents, RampsServiceMessenger, + Country, + CountryPhone, } from './RampsService'; export { RampsService, RampsEnvironment } from './RampsService'; -export type { RampsServiceGetGeolocationAction } from './RampsService-method-action-types'; +export type { + RampsServiceGetGeolocationAction, + RampsServiceGetCountriesAction, +} from './RampsService-method-action-types'; export type { RequestCache, RequestState, From 2b0ea62b6e41d827c16e0a7d1212af36aa2e0a79 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Fri, 19 Dec 2025 15:37:46 +0000 Subject: [PATCH 2/4] refactor: improve code style in RampsController --- .../src/RampsController.test.ts | 41 +++++++++++++++++-- .../ramps-controller/src/RampsController.ts | 6 +-- packages/ramps-controller/src/RampsService.ts | 4 +- 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 854bb463d37..ee11d14488e 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -8,11 +8,11 @@ import type { import type { RampsControllerMessenger } from './RampsController'; import { RampsController } from './RampsController'; +import type { Country } from './RampsService'; import type { RampsServiceGetGeolocationAction, RampsServiceGetCountriesAction, } from './RampsService-method-action-types'; -import type { Country } from './RampsService'; import { RequestStatus, createCacheKey } from './RequestCache'; describe('RampsController', () => { @@ -548,7 +548,40 @@ describe('RampsController', () => { const countries = await controller.getCountries(); - expect(countries).toMatchSnapshot(); + expect(countries).toMatchInlineSnapshot(` + Array [ + Object { + "currency": "USD", + "flag": "πŸ‡ΊπŸ‡Έ", + "isoCode": "US", + "name": "United States of America", + "phone": Object { + "placeholder": "(555) 123-4567", + "prefix": "+1", + "template": "(XXX) XXX-XXXX", + }, + "recommended": true, + "supported": true, + "transakSupported": true, + "unsupportedStates": Array [ + "ny", + ], + }, + Object { + "currency": "EUR", + "flag": "πŸ‡¦πŸ‡Ή", + "isoCode": "AT", + "name": "Austria", + "phone": Object { + "placeholder": "660 1234567", + "prefix": "+43", + "template": "XXX XXXXXXX", + }, + "supported": true, + "transakSupported": true, + }, + ] + `); }); }); @@ -686,7 +719,9 @@ describe('RampsController', () => { ); await controller.getRegionEligibility('deposit'); - await controller.getRegionEligibility('deposit', { forceRefresh: true }); + await controller.getRegionEligibility('deposit', { + forceRefresh: true, + }); expect(geolocationCallCount).toBe(2); }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 803235a4aef..7dbc657bbd1 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -7,11 +7,11 @@ import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; import type { Json } from '@metamask/utils'; +import type { Country } from './RampsService'; import type { RampsServiceGetGeolocationAction, RampsServiceGetCountriesAction, } from './RampsService-method-action-types'; -import type { Country } from './RampsService'; import type { RequestCache as RequestCacheType, RequestState, @@ -447,10 +447,10 @@ export class RampsController extends BaseController< const stateCode = geolocation.split('-')[1]?.toLowerCase(); const country = countries.find( - (c) => c.isoCode.toUpperCase() === countryCode?.toUpperCase(), + (entry) => entry.isoCode.toUpperCase() === countryCode?.toUpperCase(), ); - if (!country || !country.supported) { + if (!country?.supported) { return false; } diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index 5e26f48c4bb..745265281e2 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -326,7 +326,9 @@ export class RampsService { * @param action - The ramp action type ('deposit' or 'withdraw'). * @returns An array of countries with their eligibility information. */ - async getCountries(action: 'deposit' | 'withdraw' = 'deposit'): Promise { + async getCountries( + action: 'deposit' | 'withdraw' = 'deposit', + ): Promise { const responseData = await this.#policy.execute(async () => { const url = new URL('regions/countries', this.#cacheBaseUrl); url.searchParams.set('action', action); From 337f6cc335661912768497661a344a8e4a8bfbe4 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Fri, 19 Dec 2025 08:38:50 -0700 Subject: [PATCH 3/4] chore: lint --- .../ramps-controller/src/RampsService.test.ts | 157 +++++++++++++++++- 1 file changed, 152 insertions(+), 5 deletions(-) diff --git a/packages/ramps-controller/src/RampsService.test.ts b/packages/ramps-controller/src/RampsService.test.ts index 083e5967100..963222d3cb0 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -184,7 +184,40 @@ describe('RampsService', () => { 'deposit', ); - expect(countriesResponse).toMatchSnapshot(); + expect(countriesResponse).toMatchInlineSnapshot(` + Array [ + Object { + "currency": "USD", + "flag": "πŸ‡ΊπŸ‡Έ", + "isoCode": "US", + "name": "United States of America", + "phone": Object { + "placeholder": "(555) 123-4567", + "prefix": "+1", + "template": "(XXX) XXX-XXXX", + }, + "recommended": true, + "supported": true, + "transakSupported": true, + "unsupportedStates": Array [ + "ny", + ], + }, + Object { + "currency": "EUR", + "flag": "πŸ‡¦πŸ‡Ή", + "isoCode": "AT", + "name": "Austria", + "phone": Object { + "placeholder": "660 1234567", + "prefix": "+43", + "template": "XXX XXXXXXX", + }, + "supported": true, + "transakSupported": true, + }, + ] + `); }); it('uses the production cache URL when environment is Production', async () => { @@ -201,7 +234,40 @@ describe('RampsService', () => { 'deposit', ); - expect(countriesResponse).toMatchSnapshot(); + expect(countriesResponse).toMatchInlineSnapshot(` + Array [ + Object { + "currency": "USD", + "flag": "πŸ‡ΊπŸ‡Έ", + "isoCode": "US", + "name": "United States of America", + "phone": Object { + "placeholder": "(555) 123-4567", + "prefix": "+1", + "template": "(XXX) XXX-XXXX", + }, + "recommended": true, + "supported": true, + "transakSupported": true, + "unsupportedStates": Array [ + "ny", + ], + }, + Object { + "currency": "EUR", + "flag": "πŸ‡¦πŸ‡Ή", + "isoCode": "AT", + "name": "Austria", + "phone": Object { + "placeholder": "660 1234567", + "prefix": "+43", + "template": "XXX XXXXXXX", + }, + "supported": true, + "transakSupported": true, + }, + ] + `); }); it('uses localhost cache URL when environment is Development', async () => { @@ -218,7 +284,40 @@ describe('RampsService', () => { 'deposit', ); - expect(countriesResponse).toMatchSnapshot(); + expect(countriesResponse).toMatchInlineSnapshot(` + Array [ + Object { + "currency": "USD", + "flag": "πŸ‡ΊπŸ‡Έ", + "isoCode": "US", + "name": "United States of America", + "phone": Object { + "placeholder": "(555) 123-4567", + "prefix": "+1", + "template": "(XXX) XXX-XXXX", + }, + "recommended": true, + "supported": true, + "transakSupported": true, + "unsupportedStates": Array [ + "ny", + ], + }, + Object { + "currency": "EUR", + "flag": "πŸ‡¦πŸ‡Ή", + "isoCode": "AT", + "name": "Austria", + "phone": Object { + "placeholder": "660 1234567", + "prefix": "+43", + "template": "XXX XXXXXXX", + }, + "supported": true, + "transakSupported": true, + }, + ] + `); }); it('passes the action parameter correctly', async () => { @@ -233,7 +332,40 @@ describe('RampsService', () => { 'withdraw', ); - expect(countriesResponse).toMatchSnapshot(); + expect(countriesResponse).toMatchInlineSnapshot(` + Array [ + Object { + "currency": "USD", + "flag": "πŸ‡ΊπŸ‡Έ", + "isoCode": "US", + "name": "United States of America", + "phone": Object { + "placeholder": "(555) 123-4567", + "prefix": "+1", + "template": "(XXX) XXX-XXXX", + }, + "recommended": true, + "supported": true, + "transakSupported": true, + "unsupportedStates": Array [ + "ny", + ], + }, + Object { + "currency": "EUR", + "flag": "πŸ‡¦πŸ‡Ή", + "isoCode": "AT", + "name": "Austria", + "phone": Object { + "placeholder": "660 1234567", + "prefix": "+43", + "template": "XXX XXXXXXX", + }, + "supported": true, + "transakSupported": true, + }, + ] + `); }); it('throws if the API returns an error', async () => { @@ -275,7 +407,22 @@ describe('RampsService', () => { const countriesResponse = await service.getCountries('deposit'); - expect(countriesResponse).toMatchSnapshot(); + expect(countriesResponse).toMatchInlineSnapshot(` + Array [ + Object { + "currency": "USD", + "flag": "πŸ‡ΊπŸ‡Έ", + "isoCode": "US", + "name": "United States", + "phone": Object { + "placeholder": "", + "prefix": "+1", + "template": "", + }, + "supported": true, + }, + ] + `); }); }); }); From 9433aa39ce39b2c603c0a65e221b362b74f51f9b Mon Sep 17 00:00:00 2001 From: George Weiler Date: Fri, 19 Dec 2025 08:55:11 -0700 Subject: [PATCH 4/4] refactor(ramps-controller): consolidate base URL logic by service type --- .../src/RampsController.test.ts | 52 +++++----------- .../ramps-controller/src/RampsService.test.ts | 17 ++---- packages/ramps-controller/src/RampsService.ts | 60 ++++++++----------- packages/ramps-controller/src/index.ts | 2 +- 4 files changed, 49 insertions(+), 82 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index ee11d14488e..e75c8b5391a 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -546,7 +546,7 @@ describe('RampsController', () => { async () => mockCountries, ); - const countries = await controller.getCountries(); + const countries = await controller.getCountries('deposit'); expect(countries).toMatchInlineSnapshot(` Array [ @@ -586,24 +586,6 @@ describe('RampsController', () => { }); it('caches countries response', async () => { - await withController(async ({ controller, rootMessenger }) => { - let callCount = 0; - rootMessenger.registerActionHandler( - 'RampsService:getCountries', - async () => { - callCount += 1; - return mockCountries; - }, - ); - - await controller.getCountries(); - await controller.getCountries(); - - expect(callCount).toBe(1); - }); - }); - - it('uses different cache keys for different actions', async () => { await withController(async ({ controller, rootMessenger }) => { let callCount = 0; rootMessenger.registerActionHandler( @@ -615,9 +597,9 @@ describe('RampsController', () => { ); await controller.getCountries('deposit'); - await controller.getCountries('withdraw'); + await controller.getCountries('deposit'); - expect(callCount).toBe(2); + expect(callCount).toBe(1); }); }); }); @@ -676,7 +658,7 @@ describe('RampsController', () => { expect(controller.state.geolocation).toBeNull(); - const eligible = await controller.getRegionEligibility(); + const eligible = await controller.getRegionEligibility('deposit'); expect(controller.state.geolocation).toBe('AT'); expect(eligible).toBe(true); @@ -696,14 +678,14 @@ describe('RampsController', () => { expect(controller.state.geolocation).toBeNull(); - const eligible = await controller.getRegionEligibility(); + const eligible = await controller.getRegionEligibility('deposit'); expect(controller.state.geolocation).toBe('RU'); expect(eligible).toBe(false); }); }); - it('passes options to updateGeolocation when geolocation is null', async () => { + it('only fetches geolocation once when already set', async () => { await withController(async ({ controller, rootMessenger }) => { let geolocationCallCount = 0; rootMessenger.registerActionHandler( @@ -719,11 +701,9 @@ describe('RampsController', () => { ); await controller.getRegionEligibility('deposit'); - await controller.getRegionEligibility('deposit', { - forceRefresh: true, - }); + await controller.getRegionEligibility('deposit'); - expect(geolocationCallCount).toBe(2); + expect(geolocationCallCount).toBe(1); }); }); @@ -736,7 +716,7 @@ describe('RampsController', () => { async () => mockCountries, ); - const eligible = await controller.getRegionEligibility(); + const eligible = await controller.getRegionEligibility('deposit'); expect(eligible).toBe(true); }, @@ -752,7 +732,7 @@ describe('RampsController', () => { async () => mockCountries, ); - const eligible = await controller.getRegionEligibility(); + const eligible = await controller.getRegionEligibility('deposit'); expect(eligible).toBe(true); }, @@ -768,7 +748,7 @@ describe('RampsController', () => { async () => mockCountries, ); - const eligible = await controller.getRegionEligibility(); + const eligible = await controller.getRegionEligibility('deposit'); expect(eligible).toBe(false); }, @@ -784,7 +764,7 @@ describe('RampsController', () => { async () => mockCountries, ); - const eligible = await controller.getRegionEligibility(); + const eligible = await controller.getRegionEligibility('deposit'); expect(eligible).toBe(false); }, @@ -800,7 +780,7 @@ describe('RampsController', () => { async () => mockCountries, ); - const eligible = await controller.getRegionEligibility(); + const eligible = await controller.getRegionEligibility('deposit'); expect(eligible).toBe(false); }, @@ -816,7 +796,7 @@ describe('RampsController', () => { async () => mockCountries, ); - const eligible = await controller.getRegionEligibility(); + const eligible = await controller.getRegionEligibility('deposit'); expect(eligible).toBe(false); }, @@ -836,9 +816,9 @@ describe('RampsController', () => { }, ); - await controller.getRegionEligibility('withdraw'); + await controller.getRegionEligibility('deposit'); - expect(receivedAction).toBe('withdraw'); + expect(receivedAction).toBe('deposit'); }, ); }); diff --git a/packages/ramps-controller/src/RampsService.test.ts b/packages/ramps-controller/src/RampsService.test.ts index 963222d3cb0..46c36279997 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -51,8 +51,10 @@ describe('RampsService', () => { expect(geolocationResponse).toBe('US-TX'); }); - it('uses localhost URL when environment is Development', async () => { - nock('http://localhost:3000').get('/geolocation').reply(200, 'US-TX'); + it('uses staging URL when environment is Development', async () => { + nock('https://on-ramp.uat-api.cx.metamask.io') + .get('/geolocation') + .reply(200, 'US-TX'); const { rootMessenger } = getService({ options: { environment: RampsEnvironment.Development }, }); @@ -64,13 +66,6 @@ describe('RampsService', () => { expect(geolocationResponse).toBe('US-TX'); }); - it('throws if the environment is invalid', () => { - expect(() => - getService({ - options: { environment: 'invalid' as RampsEnvironment }, - }), - ).toThrow('Invalid environment: invalid'); - }); it('throws if the API returns an empty response', async () => { nock('https://on-ramp.uat-api.cx.metamask.io') @@ -270,8 +265,8 @@ describe('RampsService', () => { `); }); - it('uses localhost cache URL when environment is Development', async () => { - nock('http://localhost:3001') + it('uses staging cache URL when environment is Development', async () => { + nock('https://on-ramp-cache.uat-api.cx.metamask.io') .get('/regions/countries') .query({ action: 'deposit', sdk: '2.1.6', context: 'mobile-ios' }) .reply(200, mockCountriesResponse); diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index 745265281e2..5ccbf3cdedc 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -48,6 +48,15 @@ export enum RampsEnvironment { Development = 'development', } +/** + * The type of ramps API service. + * Determines which base URL to use (cache vs standard). + */ +export enum RampsApiService { + Regions = 'regions', + Orders = 'orders', +} + // === MESSENGER === const MESSENGER_EXPOSED_METHODS = ['getGeolocation', 'getCountries'] as const; @@ -85,38 +94,25 @@ export type RampsServiceMessenger = Messenger< // === SERVICE DEFINITION === /** - * Gets the base URL for API requests based on the environment. + * Gets the base URL for API requests based on the environment and service type. + * The Regions service uses a cache URL, while other services use the standard URL. * * @param environment - The environment to use. + * @param service - The API service type (determines if cache URL is used). * @returns The base URL for API requests. */ -function getBaseUrl(environment: RampsEnvironment): string { - switch (environment) { - case RampsEnvironment.Production: - return 'https://on-ramp.api.cx.metamask.io'; - case RampsEnvironment.Staging: - return 'https://on-ramp.uat-api.cx.metamask.io'; - case RampsEnvironment.Development: - return 'http://localhost:3000'; - default: - throw new Error(`Invalid environment: ${String(environment)}`); - } -} +function getBaseUrl( + environment: RampsEnvironment, + service: RampsApiService, +): string { + const cache = service === RampsApiService.Regions ? '-cache' : ''; -/** - * Gets the base URL for cached API requests based on the environment. - * - * @param environment - The environment to use. - * @returns The cache base URL for API requests. - */ -function getCacheBaseUrl(environment: RampsEnvironment): string { switch (environment) { case RampsEnvironment.Production: - return 'https://on-ramp-cache.api.cx.metamask.io'; + return `https://on-ramp${cache}.api.cx.metamask.io`; case RampsEnvironment.Staging: - return 'https://on-ramp-cache.uat-api.cx.metamask.io'; case RampsEnvironment.Development: - return 'http://localhost:3001'; + return `https://on-ramp${cache}.uat-api.cx.metamask.io`; default: throw new Error(`Invalid environment: ${String(environment)}`); } @@ -189,14 +185,9 @@ export class RampsService { readonly #policy: ServicePolicy; /** - * The base URL for API requests. - */ - readonly #baseUrl: string; - - /** - * The base URL for cached API requests. + * The environment used for API requests. */ - readonly #cacheBaseUrl: string; + readonly #environment: RampsEnvironment; /** * Constructs a new RampsService object. @@ -226,8 +217,7 @@ export class RampsService { this.#messenger = messenger; this.#fetch = fetchFunction; this.#policy = createServicePolicy(policyOptions); - this.#baseUrl = getBaseUrl(environment); - this.#cacheBaseUrl = getCacheBaseUrl(environment); + this.#environment = environment; this.#messenger.registerMethodActionHandlers( this, @@ -297,7 +287,8 @@ export class RampsService { */ async getGeolocation(): Promise { const responseData = await this.#policy.execute(async () => { - const url = new URL('geolocation', this.#baseUrl); + const baseUrl = getBaseUrl(this.#environment, RampsApiService.Orders); + const url = new URL('geolocation', baseUrl); const localResponse = await this.#fetch(url); if (!localResponse.ok) { throw new HttpError( @@ -330,7 +321,8 @@ export class RampsService { action: 'deposit' | 'withdraw' = 'deposit', ): Promise { const responseData = await this.#policy.execute(async () => { - const url = new URL('regions/countries', this.#cacheBaseUrl); + const baseUrl = getBaseUrl(this.#environment, RampsApiService.Regions); + const url = new URL('regions/countries', baseUrl); url.searchParams.set('action', action); url.searchParams.set('sdk', '2.1.6'); url.searchParams.set('context', 'mobile-ios'); diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index b0f9038a6df..7610db0a1d6 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -18,7 +18,7 @@ export type { Country, CountryPhone, } from './RampsService'; -export { RampsService, RampsEnvironment } from './RampsService'; +export { RampsService, RampsEnvironment, RampsApiService } from './RampsService'; export type { RampsServiceGetGeolocationAction, RampsServiceGetCountriesAction,