From af72a990ff71a875975bcc73ea5df126644014d2 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 18 Dec 2025 22:57:54 -0700 Subject: [PATCH 01/29] 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 02/29] 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 03/29] 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 04/29] 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, From 20289f0251a7df63857fc6b14c28f0ff57391cf4 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 5 Jan 2026 05:30:13 -0700 Subject: [PATCH 05/29] feat: controller refactoring --- .../src/RampsController.test.ts | 30 ++-- .../ramps-controller/src/RampsController.ts | 8 +- .../ramps-controller/src/RampsService.test.ts | 128 +++++++++++++++--- packages/ramps-controller/src/RampsService.ts | 119 +++++++++++----- packages/ramps-controller/src/index.ts | 7 +- packages/ramps-controller/tsconfig.build.json | 3 +- packages/ramps-controller/tsconfig.json | 3 +- 7 files changed, 224 insertions(+), 74 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index e75c8b5391a..9a07df1ab42 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('deposit'); + const countries = await controller.getCountries('buy'); expect(countries).toMatchInlineSnapshot(` Array [ @@ -596,8 +596,8 @@ describe('RampsController', () => { }, ); - await controller.getCountries('deposit'); - await controller.getCountries('deposit'); + await controller.getCountries('buy'); + await controller.getCountries('buy'); expect(callCount).toBe(1); }); @@ -658,7 +658,7 @@ describe('RampsController', () => { expect(controller.state.geolocation).toBeNull(); - const eligible = await controller.getRegionEligibility('deposit'); + const eligible = await controller.getRegionEligibility('buy'); expect(controller.state.geolocation).toBe('AT'); expect(eligible).toBe(true); @@ -678,7 +678,7 @@ describe('RampsController', () => { expect(controller.state.geolocation).toBeNull(); - const eligible = await controller.getRegionEligibility('deposit'); + const eligible = await controller.getRegionEligibility('buy'); expect(controller.state.geolocation).toBe('RU'); expect(eligible).toBe(false); @@ -700,8 +700,8 @@ describe('RampsController', () => { async () => mockCountries, ); - await controller.getRegionEligibility('deposit'); - await controller.getRegionEligibility('deposit'); + await controller.getRegionEligibility('buy'); + await controller.getRegionEligibility('buy'); expect(geolocationCallCount).toBe(1); }); @@ -716,7 +716,7 @@ describe('RampsController', () => { async () => mockCountries, ); - const eligible = await controller.getRegionEligibility('deposit'); + const eligible = await controller.getRegionEligibility('buy'); expect(eligible).toBe(true); }, @@ -732,7 +732,7 @@ describe('RampsController', () => { async () => mockCountries, ); - const eligible = await controller.getRegionEligibility('deposit'); + const eligible = await controller.getRegionEligibility('buy'); expect(eligible).toBe(true); }, @@ -748,7 +748,7 @@ describe('RampsController', () => { async () => mockCountries, ); - const eligible = await controller.getRegionEligibility('deposit'); + const eligible = await controller.getRegionEligibility('buy'); expect(eligible).toBe(false); }, @@ -764,7 +764,7 @@ describe('RampsController', () => { async () => mockCountries, ); - const eligible = await controller.getRegionEligibility('deposit'); + const eligible = await controller.getRegionEligibility('buy'); expect(eligible).toBe(false); }, @@ -780,7 +780,7 @@ describe('RampsController', () => { async () => mockCountries, ); - const eligible = await controller.getRegionEligibility('deposit'); + const eligible = await controller.getRegionEligibility('buy'); expect(eligible).toBe(false); }, @@ -796,7 +796,7 @@ describe('RampsController', () => { async () => mockCountries, ); - const eligible = await controller.getRegionEligibility('deposit'); + const eligible = await controller.getRegionEligibility('buy'); expect(eligible).toBe(false); }, @@ -816,9 +816,9 @@ describe('RampsController', () => { }, ); - await controller.getRegionEligibility('deposit'); + await controller.getRegionEligibility('buy'); - expect(receivedAction).toBe('deposit'); + expect(receivedAction).toBe('buy'); }, ); }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 7dbc657bbd1..c206482be49 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -403,12 +403,12 @@ export class RampsController extends BaseController< /** * Fetches the list of supported countries for a given ramp action. * - * @param action - The ramp action type + * @param action - The ramp action type ('buy' or 'sell'). * @param options - Options for cache behavior. * @returns An array of countries with their eligibility information. */ async getCountries( - action: 'deposit', + action: 'buy' | 'sell' = 'buy', options?: ExecuteRequestOptions, ): Promise { const cacheKey = createCacheKey('getCountries', [action]); @@ -426,12 +426,12 @@ export class RampsController extends BaseController< * 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 action - The ramp action type ('buy' or 'sell'). * @param options - Options for cache behavior. * @returns True if the user's region is eligible for ramps, false otherwise. */ async getRegionEligibility( - action: 'deposit', + action: 'buy' | 'sell' = 'buy', options?: ExecuteRequestOptions, ): Promise { const { geolocation } = this.state; diff --git a/packages/ramps-controller/src/RampsService.test.ts b/packages/ramps-controller/src/RampsService.test.ts index 46c36279997..e1f2bef2441 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -26,6 +26,7 @@ describe('RampsService', () => { it('returns the geolocation from the API', async () => { nock('https://on-ramp.uat-api.cx.metamask.io') .get('/geolocation') + .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) .reply(200, 'US-TX'); const { rootMessenger } = getService(); @@ -39,6 +40,7 @@ describe('RampsService', () => { it('uses the production URL when environment is Production', async () => { nock('https://on-ramp.api.cx.metamask.io') .get('/geolocation') + .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) .reply(200, 'US-TX'); const { rootMessenger } = getService({ options: { environment: RampsEnvironment.Production }, @@ -54,6 +56,7 @@ describe('RampsService', () => { it('uses staging URL when environment is Development', async () => { nock('https://on-ramp.uat-api.cx.metamask.io') .get('/geolocation') + .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) .reply(200, 'US-TX'); const { rootMessenger } = getService({ options: { environment: RampsEnvironment.Development }, @@ -66,10 +69,10 @@ describe('RampsService', () => { expect(geolocationResponse).toBe('US-TX'); }); - it('throws if the API returns an empty response', async () => { nock('https://on-ramp.uat-api.cx.metamask.io') .get('/geolocation') + .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) .reply(200, ''); const { rootMessenger } = getService(); @@ -81,6 +84,7 @@ describe('RampsService', () => { it('calls onDegraded listeners if the request takes longer than 5 seconds to resolve', async () => { nock('https://on-ramp.uat-api.cx.metamask.io') .get('/geolocation') + .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) .reply(200, () => { clock.tick(6000); return 'US-TX'; @@ -97,6 +101,7 @@ describe('RampsService', () => { it('attempts a request that responds with non-200 up to 4 times, throwing if it never succeeds', async () => { nock('https://on-ramp.uat-api.cx.metamask.io') .get('/geolocation') + .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) .times(4) .reply(500); const { service, rootMessenger } = getService(); @@ -107,7 +112,7 @@ describe('RampsService', () => { await expect( rootMessenger.call('RampsService:getGeolocation'), ).rejects.toThrow( - "Fetching 'https://on-ramp.uat-api.cx.metamask.io/geolocation' failed with status '500'", + "Fetching 'https://on-ramp.uat-api.cx.metamask.io/geolocation?sdk=2.1.6&controller=2.0.0&context=mobile-ios' failed with status '500'", ); }); @@ -126,6 +131,7 @@ describe('RampsService', () => { it('does the same thing as the messenger action', async () => { nock('https://on-ramp.uat-api.cx.metamask.io') .get('/geolocation') + .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) .reply(200, 'US-TX'); const { service } = getService(); @@ -167,16 +173,25 @@ describe('RampsService', () => { }, ]; - it('returns the countries from the cache API', async () => { + it('returns the countries from the cache API with geolocated field', async () => { nock('https://on-ramp-cache.uat-api.cx.metamask.io') .get('/regions/countries') - .query({ action: 'deposit', sdk: '2.1.6', context: 'mobile-ios' }) + .query({ + action: 'buy', + sdk: '2.1.6', + controller: '2.0.0', + context: 'mobile-ios', + }) .reply(200, mockCountriesResponse); + nock('https://on-ramp.uat-api.cx.metamask.io') + .get('/geolocation') + .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) + .reply(200, 'US-TX'); const { rootMessenger } = getService(); const countriesResponse = await rootMessenger.call( 'RampsService:getCountries', - 'deposit', + 'buy', ); expect(countriesResponse).toMatchInlineSnapshot(` @@ -184,6 +199,7 @@ describe('RampsService', () => { Object { "currency": "USD", "flag": "πŸ‡ΊπŸ‡Έ", + "geolocated": true, "isoCode": "US", "name": "United States of America", "phone": Object { @@ -201,6 +217,7 @@ describe('RampsService', () => { Object { "currency": "EUR", "flag": "πŸ‡¦πŸ‡Ή", + "geolocated": false, "isoCode": "AT", "name": "Austria", "phone": Object { @@ -218,15 +235,24 @@ describe('RampsService', () => { 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' }) + .query({ + action: 'buy', + sdk: '2.1.6', + controller: '2.0.0', + context: 'mobile-ios', + }) .reply(200, mockCountriesResponse); + nock('https://on-ramp.api.cx.metamask.io') + .get('/geolocation') + .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) + .reply(200, 'AT'); const { rootMessenger } = getService({ options: { environment: RampsEnvironment.Production }, }); const countriesResponse = await rootMessenger.call( 'RampsService:getCountries', - 'deposit', + 'buy', ); expect(countriesResponse).toMatchInlineSnapshot(` @@ -234,6 +260,7 @@ describe('RampsService', () => { Object { "currency": "USD", "flag": "πŸ‡ΊπŸ‡Έ", + "geolocated": false, "isoCode": "US", "name": "United States of America", "phone": Object { @@ -251,6 +278,7 @@ describe('RampsService', () => { Object { "currency": "EUR", "flag": "πŸ‡¦πŸ‡Ή", + "geolocated": true, "isoCode": "AT", "name": "Austria", "phone": Object { @@ -268,15 +296,24 @@ describe('RampsService', () => { 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' }) + .query({ + action: 'buy', + sdk: '2.1.6', + controller: '2.0.0', + context: 'mobile-ios', + }) .reply(200, mockCountriesResponse); + nock('https://on-ramp.uat-api.cx.metamask.io') + .get('/geolocation') + .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) + .reply(200, 'XX'); const { rootMessenger } = getService({ options: { environment: RampsEnvironment.Development }, }); const countriesResponse = await rootMessenger.call( 'RampsService:getCountries', - 'deposit', + 'buy', ); expect(countriesResponse).toMatchInlineSnapshot(` @@ -284,6 +321,7 @@ describe('RampsService', () => { Object { "currency": "USD", "flag": "πŸ‡ΊπŸ‡Έ", + "geolocated": false, "isoCode": "US", "name": "United States of America", "phone": Object { @@ -301,6 +339,7 @@ describe('RampsService', () => { Object { "currency": "EUR", "flag": "πŸ‡¦πŸ‡Ή", + "geolocated": false, "isoCode": "AT", "name": "Austria", "phone": Object { @@ -318,13 +357,22 @@ describe('RampsService', () => { 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' }) + .query({ + action: 'sell', + sdk: '2.1.6', + controller: '2.0.0', + context: 'mobile-ios', + }) .reply(200, mockCountriesResponse); + nock('https://on-ramp.uat-api.cx.metamask.io') + .get('/geolocation') + .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) + .reply(200, 'US'); const { rootMessenger } = getService(); const countriesResponse = await rootMessenger.call( 'RampsService:getCountries', - 'withdraw', + 'sell', ); expect(countriesResponse).toMatchInlineSnapshot(` @@ -332,6 +380,7 @@ describe('RampsService', () => { Object { "currency": "USD", "flag": "πŸ‡ΊπŸ‡Έ", + "geolocated": true, "isoCode": "US", "name": "United States of America", "phone": Object { @@ -349,6 +398,7 @@ describe('RampsService', () => { Object { "currency": "EUR", "flag": "πŸ‡¦πŸ‡Ή", + "geolocated": false, "isoCode": "AT", "name": "Austria", "phone": Object { @@ -363,10 +413,44 @@ describe('RampsService', () => { `); }); - it('throws if the API returns an error', async () => { + it('continues without geolocation when geolocation fails', async () => { + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/countries') + .query({ + action: 'buy', + sdk: '2.1.6', + controller: '2.0.0', + context: 'mobile-ios', + }) + .reply(200, mockCountriesResponse); + nock('https://on-ramp.uat-api.cx.metamask.io') + .get('/geolocation') + .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) + .times(4) + .reply(500); + const { service, rootMessenger } = getService(); + service.onRetry(() => { + clock.nextAsync().catch(() => undefined); + }); + + const countriesResponse = await rootMessenger.call( + 'RampsService:getCountries', + 'buy', + ); + + expect(countriesResponse[0]?.geolocated).toBe(false); + expect(countriesResponse[1]?.geolocated).toBe(false); + }); + + it('throws if the countries 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' }) + .query({ + action: 'buy', + sdk: '2.1.6', + controller: '2.0.0', + context: 'mobile-ios', + }) .times(4) .reply(500); const { service, rootMessenger } = getService(); @@ -375,9 +459,9 @@ describe('RampsService', () => { }); await expect( - rootMessenger.call('RampsService:getCountries', 'deposit'), + rootMessenger.call('RampsService:getCountries', 'buy'), ).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'", + "Fetching 'https://on-ramp-cache.uat-api.cx.metamask.io/regions/countries?action=buy&sdk=2.1.6&controller=2.0.0&context=mobile-ios' failed with status '500'", ); }); }); @@ -396,17 +480,27 @@ describe('RampsService', () => { ]; nock('https://on-ramp-cache.uat-api.cx.metamask.io') .get('/regions/countries') - .query({ action: 'deposit', sdk: '2.1.6', context: 'mobile-ios' }) + .query({ + action: 'buy', + sdk: '2.1.6', + controller: '2.0.0', + context: 'mobile-ios', + }) .reply(200, mockCountries); + nock('https://on-ramp.uat-api.cx.metamask.io') + .get('/geolocation') + .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) + .reply(200, 'US'); const { service } = getService(); - const countriesResponse = await service.getCountries('deposit'); + const countriesResponse = await service.getCountries('buy'); expect(countriesResponse).toMatchInlineSnapshot(` Array [ Object { "currency": "USD", "flag": "πŸ‡ΊπŸ‡Έ", + "geolocated": true, "isoCode": "US", "name": "United States", "phone": Object { diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index 5ccbf3cdedc..aee738213fc 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -5,6 +5,7 @@ import type { import { createServicePolicy, HttpError } from '@metamask/controller-utils'; import type { Messenger } from '@metamask/messenger'; +import packageJson from '../package.json'; import type { RampsServiceMethodActions } from './RampsService-method-action-types'; /** @@ -29,8 +30,14 @@ export type Country = { recommended?: boolean; unsupportedStates?: string[]; transakSupported?: boolean; + geolocated?: boolean; }; +/** + * The SDK version to send with API requests. (backwards-compatibility) + */ +export const RAMPS_SDK_VERSION = '2.1.6'; + // === GENERAL === /** @@ -280,30 +287,71 @@ export class RampsService { } /** - * Makes a request to the API in order to retrieve the user's geolocation - * based on their IP address. + * Adds common request parameters to a URL. * - * @returns The user's country/region code (e.g., "US-UT" for Utah, USA). + * @param url - The URL to add parameters to. + * @param action - The ramp action type (optional, not all endpoints require it). */ - async getGeolocation(): Promise { - const responseData = await this.#policy.execute(async () => { - const baseUrl = getBaseUrl(this.#environment, RampsApiService.Orders); - const url = new URL('geolocation', baseUrl); - const localResponse = await this.#fetch(url); - if (!localResponse.ok) { + #addCommonParams(url: URL, action?: 'buy' | 'sell'): void { + if (action) { + url.searchParams.set('action', action); + } + url.searchParams.set('sdk', RAMPS_SDK_VERSION); + url.searchParams.set('controller', packageJson.version); + url.searchParams.set('context', 'mobile-ios'); + } + + /** + * Makes an API request with retry policy and error handling. + * + * @param service - The API service type (determines base URL). + * @param path - The endpoint path. + * @param options - Request options. + * @param options.action - The ramp action type (optional). + * @param options.responseType - How to parse the response ('json' or 'text'). + * @returns The parsed response data. + */ + async #request( + service: RampsApiService, + path: string, + options: { + action?: 'buy' | 'sell'; + responseType: 'json' | 'text'; + }, + ): Promise { + return this.#policy.execute(async () => { + const baseUrl = getBaseUrl(this.#environment, service); + const url = new URL(path, baseUrl); + this.#addCommonParams(url, options.action); + + const response = await this.#fetch(url); + if (!response.ok) { throw new HttpError( - localResponse.status, - `Fetching '${url.toString()}' failed with status '${localResponse.status}'`, + response.status, + `Fetching '${url.toString()}' failed with status '${response.status}'`, ); } - const textResponse = await localResponse.text(); - // Return both response and text content since we consumed the body - return { response: localResponse, text: textResponse }; + + return options.responseType === 'json' + ? (response.json() as Promise) + : (response.text() as Promise); }); + } - const textResponse = responseData.text; - const trimmedResponse = textResponse.trim(); + /** + * Makes a request to the API in order to retrieve the user's geolocation + * based on their IP address. + * + * @returns The user's country/region code (e.g., "US-UT" for Utah, USA). + */ + async getGeolocation(): Promise { + const textResponse = await this.#request( + RampsApiService.Orders, + 'geolocation', + { responseType: 'text' }, + ); + const trimmedResponse = textResponse.trim(); if (trimmedResponse.length > 0) { return trimmedResponse; } @@ -313,30 +361,31 @@ export class RampsService { /** * Makes a request to the cached API to retrieve the list of supported countries. + * Enriches the response with geolocation data to indicate the user's current country. * * @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 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'); + async getCountries(action: 'buy' | 'sell' = 'buy'): Promise { + const countries = await this.#request( + RampsApiService.Regions, + 'regions/countries', + { action, responseType: 'json' }, + ); - 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; - }); + let geolocatedCountryCode: string | null = null; + try { + const geolocation = await this.getGeolocation(); + geolocatedCountryCode = geolocation.split('-')[0] ?? null; + } catch { + // If geolocation fails, continue without it + } - return responseData; + return countries.map((country) => ({ + ...country, + geolocated: geolocatedCountryCode + ? country.isoCode.toUpperCase() === geolocatedCountryCode.toUpperCase() + : false, + })); } } diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 7610db0a1d6..024b25a9852 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -18,7 +18,12 @@ export type { Country, CountryPhone, } from './RampsService'; -export { RampsService, RampsEnvironment, RampsApiService } from './RampsService'; +export { + RampsService, + RampsEnvironment, + RampsApiService, + RAMPS_SDK_VERSION, +} from './RampsService'; export type { RampsServiceGetGeolocationAction, RampsServiceGetCountriesAction, diff --git a/packages/ramps-controller/tsconfig.build.json b/packages/ramps-controller/tsconfig.build.json index df01f3c175b..a1560be7379 100644 --- a/packages/ramps-controller/tsconfig.build.json +++ b/packages/ramps-controller/tsconfig.build.json @@ -3,7 +3,8 @@ "compilerOptions": { "baseUrl": "./", "outDir": "./dist", - "rootDir": "./src" + "rootDir": "./src", + "resolveJsonModule": true }, "references": [ { diff --git a/packages/ramps-controller/tsconfig.json b/packages/ramps-controller/tsconfig.json index 68c3ddfc2cd..af410fec63d 100644 --- a/packages/ramps-controller/tsconfig.json +++ b/packages/ramps-controller/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "../../tsconfig.packages.json", "compilerOptions": { - "baseUrl": "./" + "baseUrl": "./", + "resolveJsonModule": true }, "references": [{ "path": "../base-controller" }, { "path": "../messenger" }], "include": ["../../types", "./src"] From ffaf346cc3a778c712c7400f730a022e8e5c8e3d Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 5 Jan 2026 06:14:43 -0700 Subject: [PATCH 06/29] chore: linting --- packages/ramps-controller/src/RampsService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index aee738213fc..b0fc9a3717c 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -5,8 +5,8 @@ import type { import { createServicePolicy, HttpError } from '@metamask/controller-utils'; import type { Messenger } from '@metamask/messenger'; -import packageJson from '../package.json'; import type { RampsServiceMethodActions } from './RampsService-method-action-types'; +import packageJson from '../package.json'; /** * Represents phone number information for a country. From f73a548adaa27ffd11048d3147182bdc2261cbe1 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 5 Jan 2026 06:25:32 -0700 Subject: [PATCH 07/29] chore: generate action types for ranmps service --- .../ramps-controller/src/RampsService-method-action-types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ramps-controller/src/RampsService-method-action-types.ts b/packages/ramps-controller/src/RampsService-method-action-types.ts index 152c4258b52..3da7fff05c7 100644 --- a/packages/ramps-controller/src/RampsService-method-action-types.ts +++ b/packages/ramps-controller/src/RampsService-method-action-types.ts @@ -18,6 +18,7 @@ export type RampsServiceGetGeolocationAction = { /** * Makes a request to the cached API to retrieve the list of supported countries. + * Enriches the response with geolocation data to indicate the user's current country. * * @param action - The ramp action type ('deposit' or 'withdraw'). * @returns An array of countries with their eligibility information. From 6933c4e4dc20e0a2f6c4d3e5eab2c07878b7a236 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 5 Jan 2026 10:34:27 -0700 Subject: [PATCH 08/29] feat(ramps): adds ramps selectors --- packages/ramps-controller/src/index.ts | 2 + packages/ramps-controller/src/selectors.ts | 79 ++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 packages/ramps-controller/src/selectors.ts diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 78527ba470b..81b794a428f 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -34,3 +34,5 @@ export { createSuccessState, createErrorState, } from './RequestCache'; +export type { RequestSelectorResult } from './selectors'; +export { createRequestSelector } from './selectors'; diff --git a/packages/ramps-controller/src/selectors.ts b/packages/ramps-controller/src/selectors.ts new file mode 100644 index 00000000000..48306bafe03 --- /dev/null +++ b/packages/ramps-controller/src/selectors.ts @@ -0,0 +1,79 @@ +import type { RampsControllerState } from './RampsController'; +import { RequestStatus } from './RequestCache'; +import { createCacheKey } from './RequestCache'; + +/** + * Result shape returned by request selectors. + */ +export type RequestSelectorResult = { + data: TData | null; + isFetching: boolean; + error: string | null; +}; + +/** + * Creates a selector for a controller method's request state. + * + * The selector is memoized - it returns the same object reference if + * the underlying values haven't changed, so no need for shallowEqual. + * + * @param getState - Function that extracts RampsControllerState from the client's root state. + * @param method - The controller method name (e.g., 'getCryptoCurrencies'). + * @param params - The parameters passed to the method. + * @returns A selector function that returns { data, isFetching, error }. + * + * @example + * ```ts + * const getRampsState = (state: RootState) => + * state.engine.backgroundState.RampsController; + * + * export const selectCryptoCurrencies = (region: string) => + * createRequestSelector( + * getRampsState, + * 'getCryptoCurrencies', + * [region], + * ); + * + * // In hook - no shallowEqual needed + * const { data, isFetching, error } = useSelector(selectCryptoCurrencies(region)); + * ``` + */ +export function createRequestSelector( + getState: (rootState: TRootState) => RampsControllerState | undefined, + method: string, + params: unknown[], +): (state: TRootState) => RequestSelectorResult { + const cacheKey = createCacheKey(method, params); + + let lastResult: RequestSelectorResult | null = null; + let lastData: TData | null = null; + let lastStatus: string | undefined; + let lastError: string | null = null; + + return (state: TRootState): RequestSelectorResult => { + const request = getState(state)?.requests?.[cacheKey]; + const data = (request?.data as TData) ?? null; + const status = request?.status; + const error = request?.error ?? null; + + if ( + lastResult !== null && + data === lastData && + status === lastStatus && + error === lastError + ) { + return lastResult; + } + + lastData = data; + lastStatus = status; + lastError = error; + lastResult = { + data, + isFetching: status === RequestStatus.LOADING, + error, + }; + + return lastResult; + }; +} From e5f6e8a6bab21e101f370e1b253cbe958129acb0 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 5 Jan 2026 19:41:27 -0700 Subject: [PATCH 09/29] refactor(selectors): use request reference comparison and update docs --- .../ramps-controller/src/selectors.test.ts | 394 ++++++++++++++++++ packages/ramps-controller/src/selectors.ts | 89 ++-- 2 files changed, 451 insertions(+), 32 deletions(-) create mode 100644 packages/ramps-controller/src/selectors.test.ts diff --git a/packages/ramps-controller/src/selectors.test.ts b/packages/ramps-controller/src/selectors.test.ts new file mode 100644 index 00000000000..c9ffa92f858 --- /dev/null +++ b/packages/ramps-controller/src/selectors.test.ts @@ -0,0 +1,394 @@ +import type { RampsControllerState } from './RampsController'; +import { RequestStatus, createLoadingState, createSuccessState, createErrorState } from './RequestCache'; +import { createRequestSelector } from './selectors'; + +type TestRootState = { + ramps: RampsControllerState; +}; + +describe('createRequestSelector', () => { + const getState = (state: TestRootState) => state.ramps; + + describe('basic functionality', () => { + it('returns correct structure for loading state', () => { + const selector = createRequestSelector( + getState, + 'getCryptoCurrencies', + ['US'], + ); + + const loadingRequest = createLoadingState(); + const state: TestRootState = { + ramps: { + geolocation: null, + requests: { + 'getCryptoCurrencies:["US"]': loadingRequest, + }, + }, + }; + + const result = selector(state); + + expect(result).toMatchInlineSnapshot(` + Object { + "data": null, + "error": null, + "isFetching": true, + } + `); + }); + + it('returns correct structure for success state', () => { + const selector = createRequestSelector( + getState, + 'getCryptoCurrencies', + ['US'], + ); + + const successRequest = createSuccessState(['ETH', 'BTC'], Date.now()); + const state: TestRootState = { + ramps: { + geolocation: null, + requests: { + 'getCryptoCurrencies:["US"]': successRequest, + }, + }, + }; + + const result = selector(state); + + expect(result).toMatchInlineSnapshot(` + Object { + "data": Array [ + "ETH", + "BTC", + ], + "error": null, + "isFetching": false, + } + `); + }); + + it('returns correct structure for error state', () => { + const selector = createRequestSelector( + getState, + 'getCryptoCurrencies', + ['US'], + ); + + const errorRequest = createErrorState('Network error', Date.now()); + const state: TestRootState = { + ramps: { + geolocation: null, + requests: { + 'getCryptoCurrencies:["US"]': errorRequest, + }, + }, + }; + + const result = selector(state); + + expect(result).toMatchInlineSnapshot(` + Object { + "data": null, + "error": "Network error", + "isFetching": false, + } + `); + }); + + it('returns null data when request is missing', () => { + const selector = createRequestSelector( + getState, + 'getCryptoCurrencies', + ['US'], + ); + + const state: TestRootState = { + ramps: { + geolocation: null, + requests: {}, + }, + }; + + const result = selector(state); + + expect(result).toMatchInlineSnapshot(` + Object { + "data": null, + "error": null, + "isFetching": false, + } + `); + }); + + it('returns null data when controller state is undefined', () => { + const selector = createRequestSelector( + getState, + 'getCryptoCurrencies', + ['US'], + ); + + const state: TestRootState = { + ramps: undefined as unknown as RampsControllerState, + }; + + const result = selector(state); + + expect(result).toMatchInlineSnapshot(` + Object { + "data": null, + "error": null, + "isFetching": false, + } + `); + }); + }); + + describe('memoization', () => { + it('returns same object reference when request has not changed', () => { + const selector = createRequestSelector( + getState, + 'getCryptoCurrencies', + ['US'], + ); + + const successRequest = createSuccessState(['ETH', 'BTC'], Date.now()); + const state: TestRootState = { + ramps: { + geolocation: null, + requests: { + 'getCryptoCurrencies:["US"]': successRequest, + }, + }, + }; + + const result1 = selector(state); + const result2 = selector(state); + + expect(result1).toBe(result2); + }); + + it('returns new object reference when request changes', () => { + const selector = createRequestSelector( + getState, + 'getCryptoCurrencies', + ['US'], + ); + + const successRequest1 = createSuccessState(['ETH'], Date.now()); + const state1: TestRootState = { + ramps: { + geolocation: null, + requests: { + 'getCryptoCurrencies:["US"]': successRequest1, + }, + }, + }; + + const result1 = selector(state1); + + const successRequest2 = createSuccessState(['ETH', 'BTC'], Date.now()); + const state2: TestRootState = { + ramps: { + geolocation: null, + requests: { + 'getCryptoCurrencies:["US"]': successRequest2, + }, + }, + }; + + const result2 = selector(state2); + + expect(result1).not.toBe(result2); + expect(result2.data).toEqual(['ETH', 'BTC']); + }); + + it('handles array data correctly without deep equality issues', () => { + const selector = createRequestSelector( + getState, + 'getCryptoCurrencies', + ['US'], + ); + + const largeArray = Array.from({ length: 1000 }, (_, i) => `item-${i}`); + const successRequest = createSuccessState(largeArray, Date.now()); + const state: TestRootState = { + ramps: { + geolocation: null, + requests: { + 'getCryptoCurrencies:["US"]': successRequest, + }, + }, + }; + + const result1 = selector(state); + const result2 = selector(state); + + expect(result1).toBe(result2); + expect(result1.data).toHaveLength(1000); + }); + + it('handles object data correctly', () => { + const selector = createRequestSelector( + getState, + 'getData', + [], + ); + + const complexData = { + items: ['a', 'b', 'c'], + metadata: { count: 3 }, + }; + const successRequest = createSuccessState(complexData, Date.now()); + const state: TestRootState = { + ramps: { + geolocation: null, + requests: { + 'getData:[]': successRequest, + }, + }, + }; + + const result1 = selector(state); + const result2 = selector(state); + + expect(result1).toBe(result2); + expect(result1.data).toEqual(complexData); + }); + }); + + describe('state transitions', () => { + it('updates result when transitioning from loading to success', () => { + const selector = createRequestSelector( + getState, + 'getCryptoCurrencies', + ['US'], + ); + + const loadingRequest = createLoadingState(); + const loadingState: TestRootState = { + ramps: { + geolocation: null, + requests: { + 'getCryptoCurrencies:["US"]': loadingRequest, + }, + }, + }; + + const loadingResult = selector(loadingState); + expect(loadingResult.isFetching).toBe(true); + expect(loadingResult.data).toBeNull(); + + const successRequest = createSuccessState(['ETH'], Date.now()); + const successState: TestRootState = { + ramps: { + geolocation: null, + requests: { + 'getCryptoCurrencies:["US"]': successRequest, + }, + }, + }; + + const successResult = selector(successState); + expect(successResult.isFetching).toBe(false); + expect(successResult.data).toEqual(['ETH']); + }); + + it('updates result when transitioning from success to error', () => { + const selector = createRequestSelector( + getState, + 'getCryptoCurrencies', + ['US'], + ); + + const successRequest = createSuccessState(['ETH'], Date.now()); + const successState: TestRootState = { + ramps: { + geolocation: null, + requests: { + 'getCryptoCurrencies:["US"]': successRequest, + }, + }, + }; + + const successResult = selector(successState); + expect(successResult.error).toBeNull(); + + const errorRequest = createErrorState('Failed to fetch', Date.now()); + const errorState: TestRootState = { + ramps: { + geolocation: null, + requests: { + 'getCryptoCurrencies:["US"]': errorRequest, + }, + }, + }; + + const errorResult = selector(errorState); + expect(errorResult.error).toBe('Failed to fetch'); + expect(errorResult.data).toBeNull(); + }); + }); + + describe('cache key isolation', () => { + it('returns different results for different methods', () => { + const selector1 = createRequestSelector( + getState, + 'getCryptoCurrencies', + ['US'], + ); + const selector2 = createRequestSelector( + getState, + 'getPrice', + ['US'], + ); + + const state: TestRootState = { + ramps: { + geolocation: null, + requests: { + 'getCryptoCurrencies:["US"]': createSuccessState(['ETH'], Date.now()), + 'getPrice:["US"]': createSuccessState(100, Date.now()), + }, + }, + }; + + const result1 = selector1(state); + const result2 = selector2(state); + + expect(result1.data).toEqual(['ETH']); + expect(result2.data).toBe(100); + }); + + it('returns different results for different params', () => { + const selector1 = createRequestSelector( + getState, + 'getCryptoCurrencies', + ['US'], + ); + const selector2 = createRequestSelector( + getState, + 'getCryptoCurrencies', + ['CA'], + ); + + const state: TestRootState = { + ramps: { + geolocation: null, + requests: { + 'getCryptoCurrencies:["US"]': createSuccessState(['ETH'], Date.now()), + 'getCryptoCurrencies:["CA"]': createSuccessState(['BTC'], Date.now()), + }, + }, + }; + + const result1 = selector1(state); + const result2 = selector2(state); + + expect(result1.data).toEqual(['ETH']); + expect(result2.data).toEqual(['BTC']); + }); + }); +}); + diff --git a/packages/ramps-controller/src/selectors.ts b/packages/ramps-controller/src/selectors.ts index 48306bafe03..f9e7645112e 100644 --- a/packages/ramps-controller/src/selectors.ts +++ b/packages/ramps-controller/src/selectors.ts @@ -1,41 +1,78 @@ import type { RampsControllerState } from './RampsController'; -import { RequestStatus } from './RequestCache'; +import { RequestStatus, type RequestState } from './RequestCache'; import { createCacheKey } from './RequestCache'; /** * Result shape returned by request selectors. + * + * This object is memoized - the same reference is returned when the underlying + * request state hasn't changed, making it safe to use with React Redux's + * `useSelector` without `shallowEqual`. */ export type RequestSelectorResult = { + /** The data returned by the request, or null if not yet loaded or on error. */ data: TData | null; + /** Whether the request is currently in progress. */ isFetching: boolean; + /** Error message if the request failed, or null if successful or not yet attempted. */ error: string | null; }; /** - * Creates a selector for a controller method's request state. + * Creates a memoized selector for a controller method's request state. + * + * This selector tracks the loading, error, and data state for a specific + * controller method call. It's optimized for use with React Redux's `useSelector` + * hook - the selector returns the same object reference when the underlying + * request state hasn't changed, so no `shallowEqual` is needed. * - * The selector is memoized - it returns the same object reference if - * the underlying values haven't changed, so no need for shallowEqual. + * The selector uses reference equality on the request object itself, so it + * works correctly with arrays and objects without expensive deep equality checks. * - * @param getState - Function that extracts RampsControllerState from the client's root state. - * @param method - The controller method name (e.g., 'getCryptoCurrencies'). - * @param params - The parameters passed to the method. - * @returns A selector function that returns { data, isFetching, error }. + * @param getState - Function that extracts RampsControllerState from the root state. + * Typically a reselect selector like `selectRampsControllerState`. + * @param method - The controller method name (e.g., 'updateGeolocation'). + * @param params - The parameters passed to the method, used to generate the cache key. + * Must match the params used when calling the controller method. + * @returns A selector function that returns `{ data, isFetching, error }`. * * @example * ```ts - * const getRampsState = (state: RootState) => - * state.engine.backgroundState.RampsController; + * // In selectors file - create once at module level + * import { createRequestSelector } from '@metamask/ramps-controller'; + * import { createSelector } from 'reselect'; + * + * const selectRampsControllerState = createSelector( + * (state: RootState) => state.engine.backgroundState.RampsController, + * (rampsControllerState) => rampsControllerState, + * ); + * + * export const selectGeolocationRequest = createRequestSelector< + * RootState, + * string + * >(selectRampsControllerState, 'updateGeolocation', []); * - * export const selectCryptoCurrencies = (region: string) => + * // In hook - use directly with useSelector, no shallowEqual needed + * export function useRampsGeolocation() { + * const { isFetching, error } = useSelector(selectGeolocationRequest); + * // ... rest of hook + * } + * ``` + * + * @example + * ```ts + * // For methods with parameters + * export const selectCryptoCurrenciesRequest = (region: string) => * createRequestSelector( - * getRampsState, + * selectRampsControllerState, * 'getCryptoCurrencies', * [region], * ); * - * // In hook - no shallowEqual needed - * const { data, isFetching, error } = useSelector(selectCryptoCurrencies(region)); + * // In component + * const { data, isFetching, error } = useSelector( + * selectCryptoCurrenciesRequest('US') + * ); * ``` */ export function createRequestSelector( @@ -45,33 +82,21 @@ export function createRequestSelector( ): (state: TRootState) => RequestSelectorResult { const cacheKey = createCacheKey(method, params); + let lastRequest: RequestState | undefined; let lastResult: RequestSelectorResult | null = null; - let lastData: TData | null = null; - let lastStatus: string | undefined; - let lastError: string | null = null; return (state: TRootState): RequestSelectorResult => { const request = getState(state)?.requests?.[cacheKey]; - const data = (request?.data as TData) ?? null; - const status = request?.status; - const error = request?.error ?? null; - if ( - lastResult !== null && - data === lastData && - status === lastStatus && - error === lastError - ) { + if (request === lastRequest && lastResult !== null) { return lastResult; } - lastData = data; - lastStatus = status; - lastError = error; + lastRequest = request; lastResult = { - data, - isFetching: status === RequestStatus.LOADING, - error, + data: (request?.data as TData) ?? null, + isFetching: request?.status === RequestStatus.LOADING, + error: request?.error ?? null, }; return lastResult; From 2cb98d4b5a5073f156816dd31fb53682156a49c7 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 5 Jan 2026 19:48:59 -0700 Subject: [PATCH 10/29] chore: lint --- .../ramps-controller/src/selectors.test.ts | 45 ++++++++++++------- packages/ramps-controller/src/selectors.ts | 4 +- 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/packages/ramps-controller/src/selectors.test.ts b/packages/ramps-controller/src/selectors.test.ts index c9ffa92f858..090665e35fa 100644 --- a/packages/ramps-controller/src/selectors.test.ts +++ b/packages/ramps-controller/src/selectors.test.ts @@ -1,5 +1,9 @@ import type { RampsControllerState } from './RampsController'; -import { RequestStatus, createLoadingState, createSuccessState, createErrorState } from './RequestCache'; +import { + createLoadingState, + createSuccessState, + createErrorState, +} from './RequestCache'; import { createRequestSelector } from './selectors'; type TestRootState = { @@ -7,7 +11,7 @@ type TestRootState = { }; describe('createRequestSelector', () => { - const getState = (state: TestRootState) => state.ramps; + const getState = (state: TestRootState): RampsControllerState => state.ramps; describe('basic functionality', () => { it('returns correct structure for loading state', () => { @@ -201,7 +205,7 @@ describe('createRequestSelector', () => { const result2 = selector(state2); expect(result1).not.toBe(result2); - expect(result2.data).toEqual(['ETH', 'BTC']); + expect(result2.data).toStrictEqual(['ETH', 'BTC']); }); it('handles array data correctly without deep equality issues', () => { @@ -230,11 +234,10 @@ describe('createRequestSelector', () => { }); it('handles object data correctly', () => { - const selector = createRequestSelector( - getState, - 'getData', - [], - ); + const selector = createRequestSelector< + TestRootState, + { items: string[] } + >(getState, 'getData', []); const complexData = { items: ['a', 'b', 'c'], @@ -254,7 +257,7 @@ describe('createRequestSelector', () => { const result2 = selector(state); expect(result1).toBe(result2); - expect(result1.data).toEqual(complexData); + expect(result1.data).toStrictEqual(complexData); }); }); @@ -292,7 +295,7 @@ describe('createRequestSelector', () => { const successResult = selector(successState); expect(successResult.isFetching).toBe(false); - expect(successResult.data).toEqual(['ETH']); + expect(successResult.data).toStrictEqual(['ETH']); }); it('updates result when transitioning from success to error', () => { @@ -348,7 +351,10 @@ describe('createRequestSelector', () => { ramps: { geolocation: null, requests: { - 'getCryptoCurrencies:["US"]': createSuccessState(['ETH'], Date.now()), + 'getCryptoCurrencies:["US"]': createSuccessState( + ['ETH'], + Date.now(), + ), 'getPrice:["US"]': createSuccessState(100, Date.now()), }, }, @@ -357,7 +363,7 @@ describe('createRequestSelector', () => { const result1 = selector1(state); const result2 = selector2(state); - expect(result1.data).toEqual(['ETH']); + expect(result1.data).toStrictEqual(['ETH']); expect(result2.data).toBe(100); }); @@ -377,8 +383,14 @@ describe('createRequestSelector', () => { ramps: { geolocation: null, requests: { - 'getCryptoCurrencies:["US"]': createSuccessState(['ETH'], Date.now()), - 'getCryptoCurrencies:["CA"]': createSuccessState(['BTC'], Date.now()), + 'getCryptoCurrencies:["US"]': createSuccessState( + ['ETH'], + Date.now(), + ), + 'getCryptoCurrencies:["CA"]': createSuccessState( + ['BTC'], + Date.now(), + ), }, }, }; @@ -386,9 +398,8 @@ describe('createRequestSelector', () => { const result1 = selector1(state); const result2 = selector2(state); - expect(result1.data).toEqual(['ETH']); - expect(result2.data).toEqual(['BTC']); + expect(result1.data).toStrictEqual(['ETH']); + expect(result2.data).toStrictEqual(['BTC']); }); }); }); - diff --git a/packages/ramps-controller/src/selectors.ts b/packages/ramps-controller/src/selectors.ts index f9e7645112e..fe84be19e7e 100644 --- a/packages/ramps-controller/src/selectors.ts +++ b/packages/ramps-controller/src/selectors.ts @@ -1,6 +1,6 @@ import type { RampsControllerState } from './RampsController'; -import { RequestStatus, type RequestState } from './RequestCache'; -import { createCacheKey } from './RequestCache'; +import type { RequestState } from './RequestCache'; +import { RequestStatus, createCacheKey } from './RequestCache'; /** * Result shape returned by request selectors. From 93f3172f87588ac6b107ed95a3c5b3e704284d01 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 5 Jan 2026 19:53:55 -0700 Subject: [PATCH 11/29] chore: update changelog --- packages/ramps-controller/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index ecc6b508ef8..3de1d0a9858 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `createRequestSelector` utility function for creating memoized selectors for RampsController request states ([#7554](https://github.com/MetaMask/core/pull/7554)) + - Add request caching infrastructure with TTL, deduplication, and abort support ([#7536](https://github.com/MetaMask/core/pull/7536)) ### Changed From 430259ac9390390273a68e93962937979758464c Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 5 Jan 2026 20:20:32 -0700 Subject: [PATCH 12/29] chore: changelog update --- packages/ramps-controller/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 3de1d0a9858..bea342cf744 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add region eligibility checking via `getRegionEligibility` and `getCountries` methods + - Add `createRequestSelector` utility function for creating memoized selectors for RampsController request states ([#7554](https://github.com/MetaMask/core/pull/7554)) - Add request caching infrastructure with TTL, deduplication, and abort support ([#7536](https://github.com/MetaMask/core/pull/7536)) From da766c19c08cf9df922abb30a032729582a28d40 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 5 Jan 2026 20:24:15 -0700 Subject: [PATCH 13/29] chore: changelog update --- packages/ramps-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index bea342cf744..9691090fdad 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add region eligibility checking via `getRegionEligibility` and `getCountries` methods +- Add region eligibility checking via `getRegionEligibility` and `getCountries` methods ([#7539](https://github.com/MetaMask/core/pull/7539)) - Add `createRequestSelector` utility function for creating memoized selectors for RampsController request states ([#7554](https://github.com/MetaMask/core/pull/7554)) From befef7d12e8fd33fa96c19615e73feebfb02b757 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 5 Jan 2026 20:35:20 -0700 Subject: [PATCH 14/29] chore: test cov 100 percent --- .../src/RampsController.test.ts | 106 ++++++++++++++++++ .../ramps-controller/src/RampsService.test.ts | 96 ++++++++++++++++ packages/ramps-controller/src/RampsService.ts | 3 +- 3 files changed, 204 insertions(+), 1 deletion(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 9a07df1ab42..4cdbc9dcb3c 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -602,6 +602,40 @@ describe('RampsController', () => { expect(callCount).toBe(1); }); }); + + it('fetches countries with sell action', async () => { + await withController(async ({ controller, rootMessenger }) => { + let receivedAction: string | undefined; + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async (action) => { + receivedAction = action; + return mockCountries; + }, + ); + + await controller.getCountries('sell'); + + expect(receivedAction).toBe('sell'); + }); + }); + + it('uses default buy action when no argument is provided', async () => { + await withController(async ({ controller, rootMessenger }) => { + let receivedAction: string | undefined; + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async (action) => { + receivedAction = action; + return mockCountries; + }, + ); + + await controller.getCountries(); + + expect(receivedAction).toBe('buy'); + }); + }); }); describe('getRegionEligibility', () => { @@ -822,6 +856,78 @@ describe('RampsController', () => { }, ); }); + + it('works with sell action', async () => { + await withController( + { options: { state: { geolocation: 'AT' } } }, + async ({ controller, rootMessenger }) => { + let receivedAction: string | undefined; + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async (action) => { + receivedAction = action; + return mockCountries; + }, + ); + + const eligible = await controller.getRegionEligibility('sell'); + + expect(receivedAction).toBe('sell'); + expect(eligible).toBe(true); + }, + ); + }); + + it('uses default buy action when no argument is provided', async () => { + await withController( + { options: { state: { geolocation: 'AT' } } }, + async ({ controller, rootMessenger }) => { + let receivedAction: string | undefined; + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async (action) => { + receivedAction = action; + return mockCountries; + }, + ); + + const eligible = await controller.getRegionEligibility(); + + expect(receivedAction).toBe('buy'); + expect(eligible).toBe(true); + }, + ); + }); + + it('returns true for a supported US state when unsupportedStates is undefined', async () => { + const countriesWithoutUnsupportedStates: Country[] = [ + { + isoCode: 'US', + flag: 'πŸ‡ΊπŸ‡Έ', + name: 'United States of America', + phone: { + prefix: '+1', + placeholder: '(555) 123-4567', + template: '(XXX) XXX-XXXX', + }, + currency: 'USD', + supported: true, + }, + ]; + await withController( + { options: { state: { geolocation: 'US-TX' } } }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async () => countriesWithoutUnsupportedStates, + ); + + const eligible = await controller.getRegionEligibility('buy'); + + expect(eligible).toBe(true); + }, + ); + }); }); }); diff --git a/packages/ramps-controller/src/RampsService.test.ts b/packages/ramps-controller/src/RampsService.test.ts index e1f2bef2441..fd4623d4629 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -125,6 +125,21 @@ describe('RampsService', () => { expect(subscription).toBeDefined(); expect(subscription).toHaveProperty('dispose'); }); + + it('throws error for invalid environment', async () => { + const { service, rootMessenger } = getService({ + options: { + environment: 'invalid' as unknown as RampsEnvironment, + }, + }); + service.onRetry(() => { + clock.nextAsync().catch(() => undefined); + }); + + await expect( + rootMessenger.call('RampsService:getGeolocation'), + ).rejects.toThrow('Invalid environment: invalid'); + }); }); describe('getGeolocation', () => { @@ -442,6 +457,56 @@ describe('RampsService', () => { expect(countriesResponse[1]?.geolocated).toBe(false); }); + it('handles geolocation with only country code', async () => { + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/countries') + .query({ + action: 'buy', + sdk: '2.1.6', + controller: '2.0.0', + context: 'mobile-ios', + }) + .reply(200, mockCountriesResponse); + nock('https://on-ramp.uat-api.cx.metamask.io') + .get('/geolocation') + .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) + .reply(200, 'US'); + const { rootMessenger } = getService(); + + const countriesResponse = await rootMessenger.call( + 'RampsService:getCountries', + 'buy', + ); + + expect(countriesResponse[0]?.geolocated).toBe(true); + expect(countriesResponse[1]?.geolocated).toBe(false); + }); + + it('handles empty geolocation country code', async () => { + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/countries') + .query({ + action: 'buy', + sdk: '2.1.6', + controller: '2.0.0', + context: 'mobile-ios', + }) + .reply(200, mockCountriesResponse); + nock('https://on-ramp.uat-api.cx.metamask.io') + .get('/geolocation') + .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) + .reply(200, '-'); + const { rootMessenger } = getService(); + + const countriesResponse = await rootMessenger.call( + 'RampsService:getCountries', + 'buy', + ); + + expect(countriesResponse[0]?.geolocated).toBe(false); + expect(countriesResponse[1]?.geolocated).toBe(false); + }); + it('throws if the countries API returns an error', async () => { nock('https://on-ramp-cache.uat-api.cx.metamask.io') .get('/regions/countries') @@ -513,6 +578,37 @@ describe('RampsService', () => { ] `); }); + + it('uses default buy action when no argument is provided', 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: 'buy', + sdk: '2.1.6', + controller: '2.0.0', + context: 'mobile-ios', + }) + .reply(200, mockCountries); + nock('https://on-ramp.uat-api.cx.metamask.io') + .get('/geolocation') + .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) + .reply(200, 'US'); + const { service } = getService(); + + const countriesResponse = await service.getCountries(); + + expect(countriesResponse[0]?.isoCode).toBe('US'); + }); }); }); diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index b0fc9a3717c..c7cf2b6e875 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -376,7 +376,8 @@ export class RampsService { let geolocatedCountryCode: string | null = null; try { const geolocation = await this.getGeolocation(); - geolocatedCountryCode = geolocation.split('-')[0] ?? null; + const countryCode = geolocation.split('-')[0]; + geolocatedCountryCode = countryCode || null; } catch { // If geolocation fails, continue without it } From b8f8e12c8fe1e74dbe1ba70adbbea47a3fa0911d Mon Sep 17 00:00:00 2001 From: George Weiler Date: Tue, 6 Jan 2026 09:48:09 -0700 Subject: [PATCH 15/29] refactor: use eligibility endpoint, remove geolocated/unsupportedStates logic --- .../src/RampsController.test.ts | 324 ++++-------------- .../ramps-controller/src/RampsController.ts | 96 +++--- .../src/RampsService-method-action-types.ts | 20 +- .../ramps-controller/src/RampsService.test.ts | 289 ++++++++++------ packages/ramps-controller/src/RampsService.ts | 132 +++++-- packages/ramps-controller/src/index.ts | 3 + 6 files changed, 433 insertions(+), 431 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 4cdbc9dcb3c..b71ca20c12c 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -521,8 +521,6 @@ describe('RampsController', () => { currency: 'USD', supported: true, recommended: true, - unsupportedStates: ['ny'], - transakSupported: true, }, { isoCode: 'AT', @@ -638,296 +636,104 @@ describe('RampsController', () => { }); }); - 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 () => { + describe('updateEligibility', () => { + it('fetches and stores eligibility for a region', async () => { await withController(async ({ controller, rootMessenger }) => { + const mockEligibility = { + aggregator: true, + deposit: true, + global: true, + }; + rootMessenger.registerActionHandler( - 'RampsService:getGeolocation', - async () => 'AT', + 'RampsService:getEligibility', + async () => mockEligibility, ); + + expect(controller.state.eligibility).toBeNull(); + + const eligibility = await controller.updateEligibility('fr'); + + expect(controller.state.eligibility).toEqual(mockEligibility); + expect(eligibility).toEqual(mockEligibility); + }); + }); + + it('handles state codes in ISO format', async () => { + await withController(async ({ controller, rootMessenger }) => { + const mockEligibility = { + aggregator: true, + deposit: false, + global: true, + }; + rootMessenger.registerActionHandler( - 'RampsService:getCountries', - async () => mockCountries, + 'RampsService:getEligibility', + async (isoCode) => { + expect(isoCode).toBe('us-ny'); + return mockEligibility; + }, ); - expect(controller.state.geolocation).toBeNull(); - - const eligible = await controller.getRegionEligibility('buy'); + await controller.updateEligibility('us-ny'); - expect(controller.state.geolocation).toBe('AT'); - expect(eligible).toBe(true); + expect(controller.state.eligibility).toEqual(mockEligibility); }); }); + }); - it('fetches geolocation and returns false for unsupported region when geolocation is null', async () => { + describe('updateGeolocation with automatic eligibility', () => { + it('automatically fetches eligibility after getting geolocation', async () => { await withController(async ({ controller, rootMessenger }) => { + const mockEligibility = { + aggregator: true, + deposit: true, + global: true, + }; + rootMessenger.registerActionHandler( 'RampsService:getGeolocation', - async () => 'RU', + async () => 'fr', ); rootMessenger.registerActionHandler( - 'RampsService:getCountries', - async () => mockCountries, + 'RampsService:getEligibility', + async (isoCode) => { + expect(isoCode).toBe('fr'); + return mockEligibility; + }, ); expect(controller.state.geolocation).toBeNull(); + expect(controller.state.eligibility).toBeNull(); - const eligible = await controller.getRegionEligibility('buy'); + await controller.updateGeolocation(); - expect(controller.state.geolocation).toBe('RU'); - expect(eligible).toBe(false); + expect(controller.state.geolocation).toBe('fr'); + expect(controller.state.eligibility).toEqual(mockEligibility); }); }); - it('only fetches geolocation once when already set', async () => { + it('does not fetch eligibility if geolocation is empty', async () => { await withController(async ({ controller, rootMessenger }) => { - let geolocationCallCount = 0; + let eligibilityCallCount = 0; rootMessenger.registerActionHandler( 'RampsService:getGeolocation', - async () => { - geolocationCallCount += 1; - return 'AT'; - }, + async () => '', ); rootMessenger.registerActionHandler( - 'RampsService:getCountries', - async () => mockCountries, + 'RampsService:getEligibility', + async () => { + eligibilityCallCount += 1; + return { aggregator: true }; + }, ); - await controller.getRegionEligibility('buy'); - await controller.getRegionEligibility('buy'); + await expect(controller.updateGeolocation()).rejects.toThrow(); - expect(geolocationCallCount).toBe(1); + expect(eligibilityCallCount).toBe(0); + expect(controller.state.eligibility).toBeNull(); }); }); - - 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('buy'); - - 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('buy'); - - 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('buy'); - - 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('buy'); - - 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('buy'); - - 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('buy'); - - 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('buy'); - - expect(receivedAction).toBe('buy'); - }, - ); - }); - - it('works with sell action', async () => { - await withController( - { options: { state: { geolocation: 'AT' } } }, - async ({ controller, rootMessenger }) => { - let receivedAction: string | undefined; - rootMessenger.registerActionHandler( - 'RampsService:getCountries', - async (action) => { - receivedAction = action; - return mockCountries; - }, - ); - - const eligible = await controller.getRegionEligibility('sell'); - - expect(receivedAction).toBe('sell'); - expect(eligible).toBe(true); - }, - ); - }); - - it('uses default buy action when no argument is provided', async () => { - await withController( - { options: { state: { geolocation: 'AT' } } }, - async ({ controller, rootMessenger }) => { - let receivedAction: string | undefined; - rootMessenger.registerActionHandler( - 'RampsService:getCountries', - async (action) => { - receivedAction = action; - return mockCountries; - }, - ); - - const eligible = await controller.getRegionEligibility(); - - expect(receivedAction).toBe('buy'); - expect(eligible).toBe(true); - }, - ); - }); - - it('returns true for a supported US state when unsupportedStates is undefined', async () => { - const countriesWithoutUnsupportedStates: Country[] = [ - { - isoCode: 'US', - flag: 'πŸ‡ΊπŸ‡Έ', - name: 'United States of America', - phone: { - prefix: '+1', - placeholder: '(555) 123-4567', - template: '(XXX) XXX-XXXX', - }, - currency: 'USD', - supported: true, - }, - ]; - await withController( - { options: { state: { geolocation: 'US-TX' } } }, - async ({ controller, rootMessenger }) => { - rootMessenger.registerActionHandler( - 'RampsService:getCountries', - async () => countriesWithoutUnsupportedStates, - ); - - const eligible = await controller.getRegionEligibility('buy'); - - expect(eligible).toBe(true); - }, - ); - }); }); }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index c206482be49..bf2916b6689 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -7,10 +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 { Country, Eligibility } from './RampsService'; import type { RampsServiceGetGeolocationAction, RampsServiceGetCountriesAction, + RampsServiceGetEligibilityAction, } from './RampsService-method-action-types'; import type { RequestCache as RequestCacheType, @@ -47,6 +48,10 @@ export type RampsControllerState = { * The user's country code determined by geolocation. */ geolocation: string | null; + /** + * Eligibility information for the user's current region. + */ + eligibility: Eligibility | null; /** * Cache of request states, keyed by cache key. * This stores loading, success, and error states for API requests. @@ -64,6 +69,12 @@ const rampsControllerMetadata = { includeInStateLogs: true, usedInUi: true, }, + eligibility: { + persist: true, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: true, + }, requests: { persist: false, includeInDebugSnapshot: true, @@ -83,6 +94,7 @@ const rampsControllerMetadata = { export function getDefaultRampsControllerState(): RampsControllerState { return { geolocation: null, + eligibility: null, requests: {}, }; } @@ -107,7 +119,8 @@ export type RampsControllerActions = RampsControllerGetStateAction; */ type AllowedActions = | RampsServiceGetGeolocationAction - | RampsServiceGetCountriesAction; + | RampsServiceGetCountriesAction + | RampsServiceGetEligibilityAction; /** * Published when the state of {@link RampsController} changes. @@ -374,9 +387,9 @@ export class RampsController extends BaseController< } /** - * Updates the user's geolocation. - * This method calls the RampsService to get the geolocation - * and stores the result in state. + * Updates the user's geolocation and eligibility. + * This method calls the RampsService to get the geolocation, + * then automatically fetches eligibility for that region. * * @param options - Options for cache behavior. * @returns The geolocation string. @@ -397,72 +410,61 @@ export class RampsController extends BaseController< state.geolocation = geolocation; }); + if (geolocation) { + await this.updateEligibility(geolocation, options); + } + return geolocation; } /** - * Fetches the list of supported countries for a given ramp action. + * Updates the eligibility information for a given region. * - * @param action - The ramp action type ('buy' or 'sell'). + * @param isoCode - The ISO code for the region (e.g., "us", "fr", "us-ny"). * @param options - Options for cache behavior. - * @returns An array of countries with their eligibility information. + * @returns The eligibility information. */ - async getCountries( - action: 'buy' | 'sell' = 'buy', + async updateEligibility( + isoCode: string, options?: ExecuteRequestOptions, - ): Promise { - const cacheKey = createCacheKey('getCountries', [action]); + ): Promise { + const cacheKey = createCacheKey('updateEligibility', [isoCode]); - return this.executeRequest( + const eligibility = await this.executeRequest( cacheKey, async () => { - return this.messenger.call('RampsService:getCountries', action); + return this.messenger.call('RampsService:getEligibility', isoCode); }, options, ); + + this.update((state) => { + state.eligibility = eligibility; + }); + + return eligibility; } /** - * Determines if the user's current region is eligible for ramps. - * Checks the user's geolocation against the list of supported countries. + * Fetches the list of supported countries for a given ramp action. * * @param action - The ramp action type ('buy' or 'sell'). * @param options - Options for cache behavior. - * @returns True if the user's region is eligible for ramps, false otherwise. + * @returns An array of countries with their eligibility information. */ - async getRegionEligibility( + async getCountries( action: 'buy' | 'sell' = 'buy', 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(); + ): Promise { + const cacheKey = createCacheKey('getCountries', [action]); - const country = countries.find( - (entry) => entry.isoCode.toUpperCase() === countryCode?.toUpperCase(), + return this.executeRequest( + cacheKey, + async () => { + return this.messenger.call('RampsService:getCountries', action); + }, + options, ); - - if (!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 3da7fff05c7..872980d384d 100644 --- a/packages/ramps-controller/src/RampsService-method-action-types.ts +++ b/packages/ramps-controller/src/RampsService-method-action-types.ts @@ -18,19 +18,31 @@ export type RampsServiceGetGeolocationAction = { /** * Makes a request to the cached API to retrieve the list of supported countries. - * Enriches the response with geolocation data to indicate the user's current country. + * Filters countries based on aggregator support. * - * @param action - The ramp action type ('deposit' or 'withdraw'). - * @returns An array of countries with their eligibility information. + * @param action - The ramp action type ('buy' or 'sell'). + * @returns An array of countries filtered by aggregator support. */ export type RampsServiceGetCountriesAction = { type: `RampsService:getCountries`; handler: RampsService['getCountries']; }; +/** + * Fetches eligibility information for a specific region. + * + * @param isoCode - The ISO code for the region (e.g., "us", "fr", "us-ny"). + * @returns Eligibility information for the region. + */ +export type RampsServiceGetEligibilityAction = { + type: `RampsService:getEligibility`; + handler: RampsService['getEligibility']; +}; + /** * Union of all RampsService action types. */ export type RampsServiceMethodActions = | RampsServiceGetGeolocationAction - | RampsServiceGetCountriesAction; + | RampsServiceGetCountriesAction + | RampsServiceGetEligibilityAction; diff --git a/packages/ramps-controller/src/RampsService.test.ts b/packages/ramps-controller/src/RampsService.test.ts index fd4623d4629..06376f65320 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -27,21 +27,21 @@ describe('RampsService', () => { nock('https://on-ramp.uat-api.cx.metamask.io') .get('/geolocation') .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) - .reply(200, 'US-TX'); + .reply(200, 'us-tx'); const { rootMessenger } = getService(); const geolocationResponse = await rootMessenger.call( 'RampsService:getGeolocation', ); - expect(geolocationResponse).toBe('US-TX'); + expect(geolocationResponse).toBe('us-tx'); }); it('uses the production URL when environment is Production', async () => { nock('https://on-ramp.api.cx.metamask.io') .get('/geolocation') .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) - .reply(200, 'US-TX'); + .reply(200, 'us-tx'); const { rootMessenger } = getService({ options: { environment: RampsEnvironment.Production }, }); @@ -50,14 +50,14 @@ describe('RampsService', () => { 'RampsService:getGeolocation', ); - expect(geolocationResponse).toBe('US-TX'); + expect(geolocationResponse).toBe('us-tx'); }); it('uses staging URL when environment is Development', async () => { nock('https://on-ramp.uat-api.cx.metamask.io') .get('/geolocation') .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) - .reply(200, 'US-TX'); + .reply(200, 'us-tx'); const { rootMessenger } = getService({ options: { environment: RampsEnvironment.Development }, }); @@ -66,7 +66,7 @@ describe('RampsService', () => { 'RampsService:getGeolocation', ); - expect(geolocationResponse).toBe('US-TX'); + expect(geolocationResponse).toBe('us-tx'); }); it('throws if the API returns an empty response', async () => { @@ -74,11 +74,33 @@ describe('RampsService', () => { .get('/geolocation') .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) .reply(200, ''); + nock('https://api-stg.transak.com') + .get('/fiat/public/v1/get/country') + .reply(200, { ipCountryCode: 'US' }); const { rootMessenger } = getService(); - await expect( - rootMessenger.call('RampsService:getGeolocation'), - ).rejects.toThrow('Malformed response received from geolocation API'); + const geolocationResponse = await rootMessenger.call( + 'RampsService:getGeolocation', + ); + + expect(geolocationResponse).toBe('us'); + }); + + it('falls back to legacy Transak endpoint when primary fails', async () => { + nock('https://on-ramp.uat-api.cx.metamask.io') + .get('/geolocation') + .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) + .reply(500, 'Internal Server Error'); + nock('https://api-stg.transak.com') + .get('/fiat/public/v1/get/country') + .reply(200, { ipCountryCode: 'CA' }); + const { rootMessenger } = getService(); + + const geolocationResponse = await rootMessenger.call( + 'RampsService:getGeolocation', + ); + + expect(geolocationResponse).toBe('ca'); }); it('calls onDegraded listeners if the request takes longer than 5 seconds to resolve', async () => { @@ -147,12 +169,12 @@ describe('RampsService', () => { nock('https://on-ramp.uat-api.cx.metamask.io') .get('/geolocation') .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) - .reply(200, 'US-TX'); + .reply(200, 'us-tx'); const { service } = getService(); const geolocationResponse = await service.getGeolocation(); - expect(geolocationResponse).toBe('US-TX'); + expect(geolocationResponse).toBe('us-tx'); }); }); @@ -170,8 +192,6 @@ describe('RampsService', () => { currency: 'USD', supported: true, recommended: true, - unsupportedStates: ['ny'], - transakSupported: true, }, { isoCode: 'AT', @@ -188,7 +208,7 @@ describe('RampsService', () => { }, ]; - it('returns the countries from the cache API with geolocated field', async () => { + it('returns the countries from the cache API filtered by support', async () => { nock('https://on-ramp-cache.uat-api.cx.metamask.io') .get('/regions/countries') .query({ @@ -198,10 +218,6 @@ describe('RampsService', () => { context: 'mobile-ios', }) .reply(200, mockCountriesResponse); - nock('https://on-ramp.uat-api.cx.metamask.io') - .get('/geolocation') - .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) - .reply(200, 'US-TX'); const { rootMessenger } = getService(); const countriesResponse = await rootMessenger.call( @@ -214,7 +230,6 @@ describe('RampsService', () => { Object { "currency": "USD", "flag": "πŸ‡ΊπŸ‡Έ", - "geolocated": true, "isoCode": "US", "name": "United States of America", "phone": Object { @@ -224,15 +239,10 @@ describe('RampsService', () => { }, "recommended": true, "supported": true, - "transakSupported": true, - "unsupportedStates": Array [ - "ny", - ], }, Object { "currency": "EUR", "flag": "πŸ‡¦πŸ‡Ή", - "geolocated": false, "isoCode": "AT", "name": "Austria", "phone": Object { @@ -241,7 +251,6 @@ describe('RampsService', () => { "template": "XXX XXXXXXX", }, "supported": true, - "transakSupported": true, }, ] `); @@ -257,10 +266,6 @@ describe('RampsService', () => { context: 'mobile-ios', }) .reply(200, mockCountriesResponse); - nock('https://on-ramp.api.cx.metamask.io') - .get('/geolocation') - .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) - .reply(200, 'AT'); const { rootMessenger } = getService({ options: { environment: RampsEnvironment.Production }, }); @@ -275,7 +280,6 @@ describe('RampsService', () => { Object { "currency": "USD", "flag": "πŸ‡ΊπŸ‡Έ", - "geolocated": false, "isoCode": "US", "name": "United States of America", "phone": Object { @@ -285,15 +289,10 @@ describe('RampsService', () => { }, "recommended": true, "supported": true, - "transakSupported": true, - "unsupportedStates": Array [ - "ny", - ], }, Object { "currency": "EUR", "flag": "πŸ‡¦πŸ‡Ή", - "geolocated": true, "isoCode": "AT", "name": "Austria", "phone": Object { @@ -302,7 +301,6 @@ describe('RampsService', () => { "template": "XXX XXXXXXX", }, "supported": true, - "transakSupported": true, }, ] `); @@ -318,10 +316,6 @@ describe('RampsService', () => { context: 'mobile-ios', }) .reply(200, mockCountriesResponse); - nock('https://on-ramp.uat-api.cx.metamask.io') - .get('/geolocation') - .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) - .reply(200, 'XX'); const { rootMessenger } = getService({ options: { environment: RampsEnvironment.Development }, }); @@ -336,7 +330,6 @@ describe('RampsService', () => { Object { "currency": "USD", "flag": "πŸ‡ΊπŸ‡Έ", - "geolocated": false, "isoCode": "US", "name": "United States of America", "phone": Object { @@ -346,15 +339,10 @@ describe('RampsService', () => { }, "recommended": true, "supported": true, - "transakSupported": true, - "unsupportedStates": Array [ - "ny", - ], }, Object { "currency": "EUR", "flag": "πŸ‡¦πŸ‡Ή", - "geolocated": false, "isoCode": "AT", "name": "Austria", "phone": Object { @@ -363,7 +351,6 @@ describe('RampsService', () => { "template": "XXX XXXXXXX", }, "supported": true, - "transakSupported": true, }, ] `); @@ -382,7 +369,7 @@ describe('RampsService', () => { nock('https://on-ramp.uat-api.cx.metamask.io') .get('/geolocation') .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) - .reply(200, 'US'); + .reply(200, 'us'); const { rootMessenger } = getService(); const countriesResponse = await rootMessenger.call( @@ -395,7 +382,6 @@ describe('RampsService', () => { Object { "currency": "USD", "flag": "πŸ‡ΊπŸ‡Έ", - "geolocated": true, "isoCode": "US", "name": "United States of America", "phone": Object { @@ -405,15 +391,10 @@ describe('RampsService', () => { }, "recommended": true, "supported": true, - "transakSupported": true, - "unsupportedStates": Array [ - "ny", - ], }, Object { "currency": "EUR", "flag": "πŸ‡¦πŸ‡Ή", - "geolocated": false, "isoCode": "AT", "name": "Austria", "phone": Object { @@ -422,13 +403,12 @@ describe('RampsService', () => { "template": "XXX XXXXXXX", }, "supported": true, - "transakSupported": true, }, ] `); }); - it('continues without geolocation when geolocation fails', async () => { + it('filters countries by support', async () => { nock('https://on-ramp-cache.uat-api.cx.metamask.io') .get('/regions/countries') .query({ @@ -457,7 +437,8 @@ describe('RampsService', () => { expect(countriesResponse[1]?.geolocated).toBe(false); }); - it('handles geolocation with only country code', async () => { + + it('throws if the countries API returns an error', async () => { nock('https://on-ramp-cache.uat-api.cx.metamask.io') .get('/regions/countries') .query({ @@ -466,68 +447,92 @@ describe('RampsService', () => { controller: '2.0.0', context: 'mobile-ios', }) - .reply(200, mockCountriesResponse); - nock('https://on-ramp.uat-api.cx.metamask.io') - .get('/geolocation') - .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) - .reply(200, 'US'); - const { rootMessenger } = getService(); + .times(4) + .reply(500); + const { service, rootMessenger } = getService(); + service.onRetry(() => { + clock.nextAsync().catch(() => undefined); + }); - const countriesResponse = await rootMessenger.call( - 'RampsService:getCountries', - 'buy', + await expect( + rootMessenger.call('RampsService:getCountries', 'buy'), + ).rejects.toThrow( + "Fetching 'https://on-ramp-cache.uat-api.cx.metamask.io/regions/countries?action=buy&sdk=2.1.6&controller=2.0.0&context=mobile-ios' failed with status '500'", ); - - expect(countriesResponse[0]?.geolocated).toBe(true); - expect(countriesResponse[1]?.geolocated).toBe(false); }); + }); - it('handles empty geolocation country code', async () => { + describe('getEligibility', () => { + it('fetches eligibility for a country code', async () => { nock('https://on-ramp-cache.uat-api.cx.metamask.io') - .get('/regions/countries') + .get('/regions/countries/fr') .query({ - action: 'buy', sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios', }) - .reply(200, mockCountriesResponse); - nock('https://on-ramp.uat-api.cx.metamask.io') - .get('/geolocation') - .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) - .reply(200, '-'); - const { rootMessenger } = getService(); + .reply(200, { + aggregator: true, + deposit: true, + global: true, + }); + const { service } = getService(); - const countriesResponse = await rootMessenger.call( - 'RampsService:getCountries', - 'buy', - ); + const eligibility = await service.getEligibility('fr'); - expect(countriesResponse[0]?.geolocated).toBe(false); - expect(countriesResponse[1]?.geolocated).toBe(false); + expect(eligibility).toEqual({ + aggregator: true, + deposit: true, + global: true, + }); }); - it('throws if the countries API returns an error', async () => { + it('fetches eligibility for a state code', async () => { nock('https://on-ramp-cache.uat-api.cx.metamask.io') - .get('/regions/countries') + .get('/regions/countries/us-ny') .query({ - action: 'buy', sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios', }) - .times(4) - .reply(500); - const { service, rootMessenger } = getService(); - service.onRetry(() => { - clock.nextAsync().catch(() => undefined); + .reply(200, { + aggregator: false, + deposit: true, + global: false, + }); + const { service } = getService(); + + const eligibility = await service.getEligibility('us-ny'); + + expect(eligibility).toEqual({ + aggregator: false, + deposit: true, + global: false, }); + }); - await expect( - rootMessenger.call('RampsService:getCountries', 'buy'), - ).rejects.toThrow( - "Fetching 'https://on-ramp-cache.uat-api.cx.metamask.io/regions/countries?action=buy&sdk=2.1.6&controller=2.0.0&context=mobile-ios' failed with status '500'", - ); + it('normalizes ISO code to lowercase', async () => { + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/countries/fr') + .query({ + sdk: '2.1.6', + controller: '2.0.0', + context: 'mobile-ios', + }) + .reply(200, { + aggregator: true, + deposit: true, + global: true, + }); + const { service } = getService(); + + const eligibility = await service.getEligibility('FR'); + + expect(eligibility).toEqual({ + aggregator: true, + deposit: true, + global: true, + }); }); }); @@ -555,7 +560,7 @@ describe('RampsService', () => { nock('https://on-ramp.uat-api.cx.metamask.io') .get('/geolocation') .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) - .reply(200, 'US'); + .reply(200, 'us'); const { service } = getService(); const countriesResponse = await service.getCountries('buy'); @@ -565,7 +570,6 @@ describe('RampsService', () => { Object { "currency": "USD", "flag": "πŸ‡ΊπŸ‡Έ", - "geolocated": true, "isoCode": "US", "name": "United States", "phone": Object { @@ -602,13 +606,98 @@ describe('RampsService', () => { nock('https://on-ramp.uat-api.cx.metamask.io') .get('/geolocation') .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) - .reply(200, 'US'); + .reply(200, 'us'); const { service } = getService(); const countriesResponse = await service.getCountries(); expect(countriesResponse[0]?.isoCode).toBe('US'); }); + + it('filters countries with states by support', async () => { + const mockCountriesWithStates = [ + { + isoCode: 'US', + id: '/regions/us', + flag: 'πŸ‡ΊπŸ‡Έ', + name: 'United States of America', + phone: { + prefix: '+1', + placeholder: '(555) 123-4567', + template: '(XXX) XXX-XXXX', + }, + currency: 'USD', + supported: true, + states: [ + { + id: '/regions/us-tx', + stateId: 'TX', + name: 'Texas', + supported: true, + }, + { + id: '/regions/us-ny', + stateId: 'NY', + name: 'New York', + supported: false, + }, + ], + }, + ]; + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/countries') + .query({ + action: 'buy', + sdk: '2.1.6', + controller: '2.0.0', + context: 'mobile-ios', + }) + .reply(200, mockCountriesWithStates); + const { service } = getService(); + + const countriesResponse = await service.getCountries('buy'); + + expect(countriesResponse[0]?.supported).toBe(true); + expect(countriesResponse[0]?.states?.[0]?.supported).toBe(true); + expect(countriesResponse[0]?.states?.[1]?.supported).toBe(false); + }); + + it('filters countries with states correctly', async () => { + const mockCountries = [ + { + isoCode: 'US', + id: '/regions/us', + flag: 'πŸ‡ΊπŸ‡Έ', + name: 'United States', + phone: { prefix: '+1', placeholder: '', template: '' }, + currency: 'USD', + supported: true, + states: [ + { + id: '/regions/us-tx', + stateId: 'TX', + name: 'Texas', + supported: true, + }, + ], + }, + ]; + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/countries') + .query({ + action: 'buy', + sdk: '2.1.6', + controller: '2.0.0', + context: 'mobile-ios', + }) + .reply(200, mockCountries); + const { service } = getService(); + + const countriesResponse = await service.getCountries('buy'); + + expect(countriesResponse[0]?.supported).toBe(true); + expect(countriesResponse[0]?.states?.[0]?.supported).toBe(true); + }); }); }); diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index c7cf2b6e875..3b305765525 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -17,20 +17,92 @@ export type CountryPhone = { template: string; }; +/** + * Represents a state/province within a country. + */ +export type State = { + /** + * State identifier. Can be in path format (e.g., "/regions/us-ut") or ISO code format (e.g., "ut"). + */ + id?: string; + /** + * State name. + */ + name?: string; + /** + * ISO state code (e.g., "UT", "NY"). + */ + stateId?: string; + /** + * Whether this state is supported for ramps. + */ + supported?: boolean; + /** + * Whether this state is recommended. + */ + recommended?: boolean; +}; + +/** + * Represents eligibility information for a region. + * Returned from the /regions/countries/{isoCode} endpoint. + */ +export type Eligibility = { + /** + * Whether aggregator providers are available. + */ + aggregator?: boolean; + /** + * Whether deposit (buy) is available. + */ + deposit?: boolean; + /** + * Whether global providers are available. + */ + global?: boolean; +}; + /** * Represents a country returned from the regions/countries API. */ export type Country = { + /** + * ISO-2 country code (e.g., "US", "GB"). + */ isoCode: string; + /** + * Country identifier. Can be in path format (e.g., "/regions/us") or ISO code format. + * If not provided, defaults to isoCode. + */ + id?: string; + /** + * Country flag emoji or code. + */ flag: string; + /** + * Country name. + */ name: string; + /** + * Phone number information. + */ phone: CountryPhone; + /** + * Default currency code. + */ currency: string; + /** + * Whether this country is supported for ramps. + */ supported: boolean; + /** + * Whether this country is recommended. + */ recommended?: boolean; - unsupportedStates?: string[]; - transakSupported?: boolean; - geolocated?: boolean; + /** + * Array of state objects. + */ + states?: State[]; }; /** @@ -66,7 +138,11 @@ export enum RampsApiService { // === MESSENGER === -const MESSENGER_EXPOSED_METHODS = ['getGeolocation', 'getCountries'] as const; +const MESSENGER_EXPOSED_METHODS = [ + 'getGeolocation', + 'getCountries', + 'getEligibility', +] as const; /** * Actions that {@link RampsService} exposes to other consumers. @@ -361,10 +437,10 @@ export class RampsService { /** * Makes a request to the cached API to retrieve the list of supported countries. - * Enriches the response with geolocation data to indicate the user's current country. + * Filters countries based on aggregator support (preserves OnRampSDK logic). * - * @param action - The ramp action type ('deposit' or 'withdraw'). - * @returns An array of countries with their eligibility information. + * @param action - The ramp action type ('buy' or 'sell'). + * @returns An array of countries filtered by aggregator support. */ async getCountries(action: 'buy' | 'sell' = 'buy'): Promise { const countries = await this.#request( @@ -373,20 +449,34 @@ export class RampsService { { action, responseType: 'json' }, ); - let geolocatedCountryCode: string | null = null; - try { - const geolocation = await this.getGeolocation(); - const countryCode = geolocation.split('-')[0]; - geolocatedCountryCode = countryCode || null; - } catch { - // If geolocation fails, continue without it - } + return countries.filter((country) => { + if (action === 'buy') { + return country.supported; + } + + if (country.states && country.states.length > 0) { + const hasSupportedState = country.states.some( + (state) => state.supported !== false, + ); + return country.supported || hasSupportedState; + } + + return country.supported; + }); + } - return countries.map((country) => ({ - ...country, - geolocated: geolocatedCountryCode - ? country.isoCode.toUpperCase() === geolocatedCountryCode.toUpperCase() - : false, - })); + /** + * Fetches eligibility information for a specific region. + * + * @param isoCode - The ISO code for the region (e.g., "us", "fr", "us-ny"). + * @returns Eligibility information for the region. + */ + async getEligibility(isoCode: string): Promise { + const normalizedIsoCode = isoCode.toLowerCase().trim(); + return this.#request( + RampsApiService.Regions, + `regions/countries/${normalizedIsoCode}`, + { responseType: 'json' }, + ); } } diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index f66a6bc1cfd..390e650887b 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -16,6 +16,8 @@ export type { RampsServiceEvents, RampsServiceMessenger, Country, + State, + Eligibility, CountryPhone, } from './RampsService'; export { @@ -27,6 +29,7 @@ export { export type { RampsServiceGetGeolocationAction, RampsServiceGetCountriesAction, + RampsServiceGetEligibilityAction, } from './RampsService-method-action-types'; export type { RequestCache, From 14e3a0226ee9a2ec11fef204c65e7e04632cd45a Mon Sep 17 00:00:00 2001 From: George Weiler Date: Tue, 6 Jan 2026 09:58:48 -0700 Subject: [PATCH 16/29] chore: linting --- packages/ramps-controller/CHANGELOG.md | 2 +- .../ramps-controller/src/RampsController.test.ts | 12 +++++++----- packages/ramps-controller/src/RampsController.ts | 1 - packages/ramps-controller/src/RampsService.test.ts | 7 +++---- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 9691090fdad..d8e5dff18c5 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add region eligibility checking via `getRegionEligibility` and `getCountries` methods ([#7539](https://github.com/MetaMask/core/pull/7539)) +- Add eligibility state ([#7539](https://github.com/MetaMask/core/pull/7539)) - Add `createRequestSelector` utility function for creating memoized selectors for RampsController request states ([#7554](https://github.com/MetaMask/core/pull/7554)) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index b71ca20c12c..0f040efbbf3 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -654,8 +654,8 @@ describe('RampsController', () => { const eligibility = await controller.updateEligibility('fr'); - expect(controller.state.eligibility).toEqual(mockEligibility); - expect(eligibility).toEqual(mockEligibility); + expect(controller.state.eligibility).toStrictEqual(mockEligibility); + expect(eligibility).toStrictEqual(mockEligibility); }); }); @@ -677,7 +677,7 @@ describe('RampsController', () => { await controller.updateEligibility('us-ny'); - expect(controller.state.eligibility).toEqual(mockEligibility); + expect(controller.state.eligibility).toStrictEqual(mockEligibility); }); }); }); @@ -709,7 +709,7 @@ describe('RampsController', () => { await controller.updateGeolocation(); expect(controller.state.geolocation).toBe('fr'); - expect(controller.state.eligibility).toEqual(mockEligibility); + expect(controller.state.eligibility).toStrictEqual(mockEligibility); }); }); @@ -728,7 +728,9 @@ describe('RampsController', () => { }, ); - await expect(controller.updateGeolocation()).rejects.toThrow(); + await expect(controller.updateGeolocation()).rejects.toThrow( + 'Malformed response received from geolocation API', + ); expect(eligibilityCallCount).toBe(0); expect(controller.state.eligibility).toBeNull(); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index bf2916b6689..ae371a9d4c8 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -466,5 +466,4 @@ export class RampsController extends BaseController< options, ); } - } diff --git a/packages/ramps-controller/src/RampsService.test.ts b/packages/ramps-controller/src/RampsService.test.ts index 06376f65320..ee78e9c89f4 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -437,7 +437,6 @@ describe('RampsService', () => { expect(countriesResponse[1]?.geolocated).toBe(false); }); - it('throws if the countries API returns an error', async () => { nock('https://on-ramp-cache.uat-api.cx.metamask.io') .get('/regions/countries') @@ -480,7 +479,7 @@ describe('RampsService', () => { const eligibility = await service.getEligibility('fr'); - expect(eligibility).toEqual({ + expect(eligibility).toStrictEqual({ aggregator: true, deposit: true, global: true, @@ -504,7 +503,7 @@ describe('RampsService', () => { const eligibility = await service.getEligibility('us-ny'); - expect(eligibility).toEqual({ + expect(eligibility).toStrictEqual({ aggregator: false, deposit: true, global: false, @@ -528,7 +527,7 @@ describe('RampsService', () => { const eligibility = await service.getEligibility('FR'); - expect(eligibility).toEqual({ + expect(eligibility).toStrictEqual({ aggregator: true, deposit: true, global: true, From 177a36d2c2e5d357e3de464d9cba9f6f53bb0b58 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Tue, 6 Jan 2026 10:02:36 -0700 Subject: [PATCH 17/29] fix: linting and test fi --- .../src/RampsController.test.ts | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 0f040efbbf3..93c5e5442f4 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -12,6 +12,7 @@ import type { Country } from './RampsService'; import type { RampsServiceGetGeolocationAction, RampsServiceGetCountriesAction, + RampsServiceGetEligibilityAction, } from './RampsService-method-action-types'; import { RequestStatus, createCacheKey } from './RequestCache'; @@ -21,6 +22,7 @@ describe('RampsController', () => { await withController(({ controller }) => { expect(controller.state).toMatchInlineSnapshot(` Object { + "eligibility": null, "geolocation": null, "requests": Object {}, } @@ -37,6 +39,7 @@ describe('RampsController', () => { { options: { state: givenState } }, ({ controller }) => { expect(controller.state).toStrictEqual({ + eligibility: null, geolocation: 'US', requests: {}, }); @@ -48,6 +51,7 @@ describe('RampsController', () => { await withController({ options: { state: {} } }, ({ controller }) => { expect(controller.state).toMatchInlineSnapshot(` Object { + "eligibility": null, "geolocation": null, "requests": Object {}, } @@ -89,6 +93,7 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` Object { + "eligibility": null, "geolocation": null, "requests": Object {}, } @@ -106,6 +111,7 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` Object { + "eligibility": null, "geolocation": null, } `); @@ -122,6 +128,7 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` Object { + "eligibility": null, "geolocation": null, } `); @@ -138,6 +145,7 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` Object { + "eligibility": null, "geolocation": null, "requests": Object {}, } @@ -560,10 +568,6 @@ describe('RampsController', () => { }, "recommended": true, "supported": true, - "transakSupported": true, - "unsupportedStates": Array [ - "ny", - ], }, Object { "currency": "EUR", @@ -747,7 +751,8 @@ type RootMessenger = Messenger< MockAnyNamespace, | MessengerActions | RampsServiceGetGeolocationAction - | RampsServiceGetCountriesAction, + | RampsServiceGetCountriesAction + | RampsServiceGetEligibilityAction, MessengerEvents >; @@ -791,7 +796,11 @@ function getMessenger(rootMessenger: RootMessenger): RampsControllerMessenger { }); rootMessenger.delegate({ messenger, - actions: ['RampsService:getGeolocation', 'RampsService:getCountries'], + actions: [ + 'RampsService:getGeolocation', + 'RampsService:getCountries', + 'RampsService:getEligibility', + ], }); return messenger; } From b05ea960d1b1b52c0524d8df3b6f33a144fc0800 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Tue, 6 Jan 2026 10:14:21 -0700 Subject: [PATCH 18/29] chore: test update --- .../src/RampsController.test.ts | 2 -- .../ramps-controller/src/RampsService.test.ts | 31 ++++++++----------- 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 93c5e5442f4..ad5f40cae14 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -541,7 +541,6 @@ describe('RampsController', () => { }, currency: 'EUR', supported: true, - transakSupported: true, }, ]; @@ -580,7 +579,6 @@ describe('RampsController', () => { "template": "XXX XXXXXXX", }, "supported": true, - "transakSupported": true, }, ] `); diff --git a/packages/ramps-controller/src/RampsService.test.ts b/packages/ramps-controller/src/RampsService.test.ts index ee78e9c89f4..7f0debdd056 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -74,33 +74,29 @@ describe('RampsService', () => { .get('/geolocation') .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) .reply(200, ''); - nock('https://api-stg.transak.com') - .get('/fiat/public/v1/get/country') - .reply(200, { ipCountryCode: 'US' }); const { rootMessenger } = getService(); - const geolocationResponse = await rootMessenger.call( - 'RampsService:getGeolocation', - ); - - expect(geolocationResponse).toBe('us'); + await expect( + rootMessenger.call('RampsService:getGeolocation'), + ).rejects.toThrow('Malformed response received from geolocation API'); }); - it('falls back to legacy Transak endpoint when primary fails', async () => { + it('throws when primary API fails', async () => { nock('https://on-ramp.uat-api.cx.metamask.io') .get('/geolocation') .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) + .times(4) .reply(500, 'Internal Server Error'); - nock('https://api-stg.transak.com') - .get('/fiat/public/v1/get/country') - .reply(200, { ipCountryCode: 'CA' }); - const { rootMessenger } = getService(); + const { service, rootMessenger } = getService(); + service.onRetry(() => { + clock.nextAsync().catch(() => undefined); + }); - const geolocationResponse = await rootMessenger.call( - 'RampsService:getGeolocation', + await expect( + rootMessenger.call('RampsService:getGeolocation'), + ).rejects.toThrow( + "Fetching 'https://on-ramp.uat-api.cx.metamask.io/geolocation?sdk=2.1.6&controller=2.0.0&context=mobile-ios' failed with status '500'", ); - - expect(geolocationResponse).toBe('ca'); }); it('calls onDegraded listeners if the request takes longer than 5 seconds to resolve', async () => { @@ -204,7 +200,6 @@ describe('RampsService', () => { }, currency: 'EUR', supported: true, - transakSupported: true, }, ]; From 579a971ebf85da6b432d66762e409f6cd8f962b9 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Tue, 6 Jan 2026 10:20:58 -0700 Subject: [PATCH 19/29] fix: tests --- .../src/RampsController.test.ts | 24 --------------- .../ramps-controller/src/RampsService.test.ts | 30 ------------------- 2 files changed, 54 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index ad5f40cae14..693d33ad39e 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -714,30 +714,6 @@ describe('RampsController', () => { expect(controller.state.eligibility).toStrictEqual(mockEligibility); }); }); - - it('does not fetch eligibility if geolocation is empty', async () => { - await withController(async ({ controller, rootMessenger }) => { - let eligibilityCallCount = 0; - rootMessenger.registerActionHandler( - 'RampsService:getGeolocation', - async () => '', - ); - rootMessenger.registerActionHandler( - 'RampsService:getEligibility', - async () => { - eligibilityCallCount += 1; - return { aggregator: true }; - }, - ); - - await expect(controller.updateGeolocation()).rejects.toThrow( - 'Malformed response received from geolocation API', - ); - - expect(eligibilityCallCount).toBe(0); - expect(controller.state.eligibility).toBeNull(); - }); - }); }); }); diff --git a/packages/ramps-controller/src/RampsService.test.ts b/packages/ramps-controller/src/RampsService.test.ts index 7f0debdd056..1855065a58a 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -402,36 +402,6 @@ describe('RampsService', () => { ] `); }); - - it('filters countries by support', async () => { - nock('https://on-ramp-cache.uat-api.cx.metamask.io') - .get('/regions/countries') - .query({ - action: 'buy', - sdk: '2.1.6', - controller: '2.0.0', - context: 'mobile-ios', - }) - .reply(200, mockCountriesResponse); - nock('https://on-ramp.uat-api.cx.metamask.io') - .get('/geolocation') - .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) - .times(4) - .reply(500); - const { service, rootMessenger } = getService(); - service.onRetry(() => { - clock.nextAsync().catch(() => undefined); - }); - - const countriesResponse = await rootMessenger.call( - 'RampsService:getCountries', - 'buy', - ); - - expect(countriesResponse[0]?.geolocated).toBe(false); - expect(countriesResponse[1]?.geolocated).toBe(false); - }); - it('throws if the countries API returns an error', async () => { nock('https://on-ramp-cache.uat-api.cx.metamask.io') .get('/regions/countries') From 8b3cd3f7e886c9493e8aecf619868192d8be8f70 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Tue, 6 Jan 2026 10:48:08 -0700 Subject: [PATCH 20/29] chore: cursor bot fixes --- .../src/RampsController.test.ts | 67 +++++++++++++++++++ .../ramps-controller/src/RampsController.ts | 13 ++-- .../ramps-controller/src/RampsService.test.ts | 46 +++++++++++++ .../ramps-controller/src/selectors.test.ts | 15 +++++ 4 files changed, 135 insertions(+), 6 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 693d33ad39e..9ee6c122bd5 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -161,6 +161,14 @@ describe('RampsController', () => { 'RampsService:getGeolocation', async () => 'US', ); + rootMessenger.registerActionHandler( + 'RampsService:getEligibility', + async () => ({ + aggregator: true, + deposit: true, + global: true, + }), + ); await controller.updateGeolocation(); @@ -174,6 +182,14 @@ describe('RampsController', () => { 'RampsService:getGeolocation', async () => 'US', ); + rootMessenger.registerActionHandler( + 'RampsService:getEligibility', + async () => ({ + aggregator: true, + deposit: true, + global: true, + }), + ); await controller.updateGeolocation(); @@ -197,6 +213,14 @@ describe('RampsController', () => { return 'US'; }, ); + rootMessenger.registerActionHandler( + 'RampsService:getEligibility', + async () => ({ + aggregator: true, + deposit: true, + global: true, + }), + ); await controller.updateGeolocation(); await controller.updateGeolocation(); @@ -215,6 +239,14 @@ describe('RampsController', () => { return 'US'; }, ); + rootMessenger.registerActionHandler( + 'RampsService:getEligibility', + async () => ({ + aggregator: true, + deposit: true, + global: true, + }), + ); await controller.updateGeolocation(); await controller.updateGeolocation({ forceRefresh: true }); @@ -682,6 +714,41 @@ describe('RampsController', () => { expect(controller.state.eligibility).toStrictEqual(mockEligibility); }); }); + + it('normalizes isoCode case for cache key consistency', async () => { + await withController(async ({ controller, rootMessenger }) => { + const mockEligibility = { + aggregator: true, + deposit: true, + global: true, + }; + + let callCount = 0; + rootMessenger.registerActionHandler( + 'RampsService:getEligibility', + async (isoCode) => { + callCount++; + expect(isoCode).toBe('fr'); + return mockEligibility; + }, + ); + + await controller.updateEligibility('FR'); + expect(callCount).toBe(1); + + const eligibility1 = await controller.updateEligibility('fr'); + expect(callCount).toBe(1); + expect(eligibility1).toStrictEqual(mockEligibility); + + const eligibility2 = await controller.updateEligibility('Fr'); + expect(callCount).toBe(1); + expect(eligibility2).toStrictEqual(mockEligibility); + + const cacheKey = createCacheKey('updateEligibility', ['fr']); + const requestState = controller.getRequestState(cacheKey); + expect(requestState?.status).toBe('success'); + }); + }); }); describe('updateGeolocation with automatic eligibility', () => { diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index ae371a9d4c8..164a98cb852 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -406,14 +406,14 @@ export class RampsController extends BaseController< options, ); - this.update((state) => { - state.geolocation = geolocation; - }); - if (geolocation) { await this.updateEligibility(geolocation, options); } + this.update((state) => { + state.geolocation = geolocation; + }); + return geolocation; } @@ -428,12 +428,13 @@ export class RampsController extends BaseController< isoCode: string, options?: ExecuteRequestOptions, ): Promise { - const cacheKey = createCacheKey('updateEligibility', [isoCode]); + const normalizedIsoCode = isoCode.toLowerCase().trim(); + const cacheKey = createCacheKey('updateEligibility', [normalizedIsoCode]); const eligibility = await this.executeRequest( cacheKey, async () => { - return this.messenger.call('RampsService:getEligibility', isoCode); + return this.messenger.call('RampsService:getEligibility', normalizedIsoCode); }, options, ); diff --git a/packages/ramps-controller/src/RampsService.test.ts b/packages/ramps-controller/src/RampsService.test.ts index 1855065a58a..a2c6e76ff0d 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -402,6 +402,52 @@ describe('RampsService', () => { ] `); }); + + it('includes country with unsupported country but supported state for sell action', async () => { + const mockCountriesWithUnsupportedCountry = [ + { + isoCode: 'US', + id: '/regions/us', + flag: 'πŸ‡ΊπŸ‡Έ', + name: 'United States', + phone: { prefix: '+1', placeholder: '', template: '' }, + currency: 'USD', + supported: false, + states: [ + { + id: '/regions/us-tx', + stateId: 'TX', + name: 'Texas', + supported: true, + }, + { + id: '/regions/us-ny', + stateId: 'NY', + name: 'New York', + supported: false, + }, + ], + }, + ]; + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/countries') + .query({ + action: 'sell', + sdk: '2.1.6', + controller: '2.0.0', + context: 'mobile-ios', + }) + .reply(200, mockCountriesWithUnsupportedCountry); + const { service } = getService(); + + const countriesResponse = await service.getCountries('sell'); + + expect(countriesResponse).toHaveLength(1); + expect(countriesResponse[0]?.isoCode).toBe('US'); + expect(countriesResponse[0]?.supported).toBe(false); + expect(countriesResponse[0]?.states?.[0]?.supported).toBe(true); + }); + it('throws if the countries API returns an error', async () => { nock('https://on-ramp-cache.uat-api.cx.metamask.io') .get('/regions/countries') diff --git a/packages/ramps-controller/src/selectors.test.ts b/packages/ramps-controller/src/selectors.test.ts index 090665e35fa..034d6b79012 100644 --- a/packages/ramps-controller/src/selectors.test.ts +++ b/packages/ramps-controller/src/selectors.test.ts @@ -25,6 +25,7 @@ describe('createRequestSelector', () => { const state: TestRootState = { ramps: { geolocation: null, + eligibility: null, requests: { 'getCryptoCurrencies:["US"]': loadingRequest, }, @@ -53,6 +54,7 @@ describe('createRequestSelector', () => { const state: TestRootState = { ramps: { geolocation: null, + eligibility: null, requests: { 'getCryptoCurrencies:["US"]': successRequest, }, @@ -84,6 +86,7 @@ describe('createRequestSelector', () => { const state: TestRootState = { ramps: { geolocation: null, + eligibility: null, requests: { 'getCryptoCurrencies:["US"]': errorRequest, }, @@ -111,6 +114,7 @@ describe('createRequestSelector', () => { const state: TestRootState = { ramps: { geolocation: null, + eligibility: null, requests: {}, }, }; @@ -161,6 +165,7 @@ describe('createRequestSelector', () => { const state: TestRootState = { ramps: { geolocation: null, + eligibility: null, requests: { 'getCryptoCurrencies:["US"]': successRequest, }, @@ -184,6 +189,7 @@ describe('createRequestSelector', () => { const state1: TestRootState = { ramps: { geolocation: null, + eligibility: null, requests: { 'getCryptoCurrencies:["US"]': successRequest1, }, @@ -196,6 +202,7 @@ describe('createRequestSelector', () => { const state2: TestRootState = { ramps: { geolocation: null, + eligibility: null, requests: { 'getCryptoCurrencies:["US"]': successRequest2, }, @@ -220,6 +227,7 @@ describe('createRequestSelector', () => { const state: TestRootState = { ramps: { geolocation: null, + eligibility: null, requests: { 'getCryptoCurrencies:["US"]': successRequest, }, @@ -247,6 +255,7 @@ describe('createRequestSelector', () => { const state: TestRootState = { ramps: { geolocation: null, + eligibility: null, requests: { 'getData:[]': successRequest, }, @@ -273,6 +282,7 @@ describe('createRequestSelector', () => { const loadingState: TestRootState = { ramps: { geolocation: null, + eligibility: null, requests: { 'getCryptoCurrencies:["US"]': loadingRequest, }, @@ -287,6 +297,7 @@ describe('createRequestSelector', () => { const successState: TestRootState = { ramps: { geolocation: null, + eligibility: null, requests: { 'getCryptoCurrencies:["US"]': successRequest, }, @@ -309,6 +320,7 @@ describe('createRequestSelector', () => { const successState: TestRootState = { ramps: { geolocation: null, + eligibility: null, requests: { 'getCryptoCurrencies:["US"]': successRequest, }, @@ -322,6 +334,7 @@ describe('createRequestSelector', () => { const errorState: TestRootState = { ramps: { geolocation: null, + eligibility: null, requests: { 'getCryptoCurrencies:["US"]': errorRequest, }, @@ -350,6 +363,7 @@ describe('createRequestSelector', () => { const state: TestRootState = { ramps: { geolocation: null, + eligibility: null, requests: { 'getCryptoCurrencies:["US"]': createSuccessState( ['ETH'], @@ -382,6 +396,7 @@ describe('createRequestSelector', () => { const state: TestRootState = { ramps: { geolocation: null, + eligibility: null, requests: { 'getCryptoCurrencies:["US"]': createSuccessState( ['ETH'], From 5fb1b0f7546c7915ca25602fd6e3d969c2c75b93 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Tue, 6 Jan 2026 11:38:07 -0700 Subject: [PATCH 21/29] chore: cursor bot fixes --- .../src/RampsController.test.ts | 25 +++++++++++++- .../ramps-controller/src/RampsController.ts | 19 ++++++++--- .../ramps-controller/src/RampsService.test.ts | 34 +++++++++++++++++++ packages/ramps-controller/src/RampsService.ts | 4 +++ 4 files changed, 76 insertions(+), 6 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 9ee6c122bd5..9a547f54654 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -727,7 +727,7 @@ describe('RampsController', () => { rootMessenger.registerActionHandler( 'RampsService:getEligibility', async (isoCode) => { - callCount++; + callCount += 1; expect(isoCode).toBe('fr'); return mockEligibility; }, @@ -781,6 +781,29 @@ describe('RampsController', () => { expect(controller.state.eligibility).toStrictEqual(mockEligibility); }); }); + + it('updates geolocation state even when eligibility fetch fails', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getGeolocation', + async () => 'us-ny', + ); + rootMessenger.registerActionHandler( + 'RampsService:getEligibility', + async () => { + throw new Error('Eligibility API error'); + }, + ); + + expect(controller.state.geolocation).toBeNull(); + expect(controller.state.eligibility).toBeNull(); + + await controller.updateGeolocation(); + + expect(controller.state.geolocation).toBe('us-ny'); + expect(controller.state.eligibility).toBeNull(); + }); + }); }); }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 164a98cb852..af13db4b01f 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -406,14 +406,20 @@ export class RampsController extends BaseController< options, ); - if (geolocation) { - await this.updateEligibility(geolocation, options); - } - this.update((state) => { state.geolocation = geolocation; }); + if (geolocation) { + try { + await this.updateEligibility(geolocation, options); + } catch (error) { + // Eligibility fetch failed, but geolocation was successfully fetched and cached. + // Don't let eligibility errors prevent geolocation state from being updated. + // The geolocation is already saved above, so we just continue. + } + } + return geolocation; } @@ -434,7 +440,10 @@ export class RampsController extends BaseController< const eligibility = await this.executeRequest( cacheKey, async () => { - return this.messenger.call('RampsService:getEligibility', normalizedIsoCode); + return this.messenger.call( + 'RampsService:getEligibility', + normalizedIsoCode, + ); }, options, ); diff --git a/packages/ramps-controller/src/RampsService.test.ts b/packages/ramps-controller/src/RampsService.test.ts index a2c6e76ff0d..d1e4943c4f2 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -470,6 +470,40 @@ describe('RampsService', () => { "Fetching 'https://on-ramp-cache.uat-api.cx.metamask.io/regions/countries?action=buy&sdk=2.1.6&controller=2.0.0&context=mobile-ios' failed with status '500'", ); }); + + it('throws if the API returns a non-array response', async () => { + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/countries') + .query({ + action: 'buy', + sdk: '2.1.6', + controller: '2.0.0', + context: 'mobile-ios', + }) + .reply(200, () => null); + const { rootMessenger } = getService(); + + await expect( + rootMessenger.call('RampsService:getCountries', 'buy'), + ).rejects.toThrow('Malformed response received from countries API'); + }); + + it('throws if the API returns an object instead of an array', async () => { + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/countries') + .query({ + action: 'buy', + sdk: '2.1.6', + controller: '2.0.0', + context: 'mobile-ios', + }) + .reply(200, { error: 'Something went wrong' }); + const { rootMessenger } = getService(); + + await expect( + rootMessenger.call('RampsService:getCountries', 'buy'), + ).rejects.toThrow('Malformed response received from countries API'); + }); }); describe('getEligibility', () => { diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index 3b305765525..10de6595d72 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -449,6 +449,10 @@ export class RampsService { { action, responseType: 'json' }, ); + if (!Array.isArray(countries)) { + throw new Error('Malformed response received from countries API'); + } + return countries.filter((country) => { if (action === 'buy') { return country.supported; From 6a2dbaa3b0c86a4e386da19e14a559bcc72c4bbb Mon Sep 17 00:00:00 2001 From: George Weiler Date: Tue, 6 Jan 2026 11:48:00 -0700 Subject: [PATCH 22/29] chore: lint --- packages/ramps-controller/src/RampsController.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index af13db4b01f..d2a35d0bfa0 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -413,7 +413,7 @@ export class RampsController extends BaseController< if (geolocation) { try { await this.updateEligibility(geolocation, options); - } catch (error) { + } catch (_) { // Eligibility fetch failed, but geolocation was successfully fetched and cached. // Don't let eligibility errors prevent geolocation state from being updated. // The geolocation is already saved above, so we just continue. From 543e62ab888f288276ba548b7f4502ebcd3118df Mon Sep 17 00:00:00 2001 From: George Weiler Date: Tue, 6 Jan 2026 11:51:03 -0700 Subject: [PATCH 23/29] chore: cursor bot fixes --- .../src/RampsController.test.ts | 41 +++++++++++++++++++ .../ramps-controller/src/RampsController.ts | 5 ++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 9a547f54654..35dd27d12ff 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -804,6 +804,47 @@ describe('RampsController', () => { expect(controller.state.eligibility).toBeNull(); }); }); + + it('clears stale eligibility when new geolocation is fetched but eligibility fails', async () => { + await withController(async ({ controller, rootMessenger }) => { + const usEligibility = { + aggregator: true, + deposit: true, + global: true, + }; + + let geolocationCallCount = 0; + let eligibilityCallCount = 0; + + rootMessenger.registerActionHandler( + 'RampsService:getGeolocation', + async () => { + geolocationCallCount += 1; + return geolocationCallCount === 1 ? 'us' : 'fr'; + }, + ); + rootMessenger.registerActionHandler( + 'RampsService:getEligibility', + async () => { + eligibilityCallCount += 1; + if (eligibilityCallCount === 1) { + return usEligibility; + } + throw new Error('Eligibility API error'); + }, + ); + + await controller.updateGeolocation(); + + expect(controller.state.geolocation).toBe('us'); + expect(controller.state.eligibility).toStrictEqual(usEligibility); + + await controller.updateGeolocation({ forceRefresh: true }); + + expect(controller.state.geolocation).toBe('fr'); + expect(controller.state.eligibility).toBeNull(); + }); + }); }); }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index d2a35d0bfa0..1b0ca4b6b77 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -416,7 +416,10 @@ export class RampsController extends BaseController< } catch (_) { // Eligibility fetch failed, but geolocation was successfully fetched and cached. // Don't let eligibility errors prevent geolocation state from being updated. - // The geolocation is already saved above, so we just continue. + // Clear eligibility state to avoid showing stale data from a previous location. + this.update((state) => { + state.eligibility = null; + }); } } From c86c3a4667b52e579826c5b557bec202dfdd0163 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Tue, 6 Jan 2026 12:07:10 -0700 Subject: [PATCH 24/29] chore: cursor bot fixes --- .../src/RampsController.test.ts | 45 +++++++++++++++++++ .../ramps-controller/src/RampsController.ts | 9 +++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 35dd27d12ff..1e8e541e56e 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -845,6 +845,51 @@ describe('RampsController', () => { expect(controller.state.eligibility).toBeNull(); }); }); + + it('prevents stale eligibility from overwriting current eligibility in race condition', async () => { + await withController(async ({ controller, rootMessenger }) => { + const usEligibility = { + aggregator: true, + deposit: true, + global: false, + }; + const frEligibility = { + aggregator: true, + deposit: true, + global: true, + }; + + let geolocationCallCount = 0; + + rootMessenger.registerActionHandler( + 'RampsService:getGeolocation', + async () => { + geolocationCallCount += 1; + return geolocationCallCount === 1 ? 'us' : 'fr'; + }, + ); + rootMessenger.registerActionHandler( + 'RampsService:getEligibility', + async (isoCode) => { + if (isoCode === 'us') { + await new Promise((resolve) => setTimeout(resolve, 100)); + return usEligibility; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + return frEligibility; + }, + ); + + const promise1 = controller.updateGeolocation(); + await new Promise((resolve) => setTimeout(resolve, 20)); + const promise2 = controller.updateGeolocation({ forceRefresh: true }); + + await Promise.all([promise1, promise2]); + + expect(controller.state.geolocation).toBe('fr'); + expect(controller.state.eligibility).toStrictEqual(frEligibility); + }); + }); }); }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 1b0ca4b6b77..341df10f63c 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -452,7 +452,14 @@ export class RampsController extends BaseController< ); this.update((state) => { - state.eligibility = eligibility; + if (state.geolocation === null) { + state.eligibility = eligibility; + } else { + const currentGeolocation = state.geolocation.toLowerCase().trim(); + if (currentGeolocation === normalizedIsoCode) { + state.eligibility = eligibility; + } + } }); return eligibility; From df954adc49dbcc8da9bde45ead022a37f5df8302 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Tue, 6 Jan 2026 12:13:51 -0700 Subject: [PATCH 25/29] chore: lint --- packages/ramps-controller/src/RampsController.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 341df10f63c..75ce6616a95 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -413,7 +413,7 @@ export class RampsController extends BaseController< if (geolocation) { try { await this.updateEligibility(geolocation, options); - } catch (_) { + } catch { // Eligibility fetch failed, but geolocation was successfully fetched and cached. // Don't let eligibility errors prevent geolocation state from being updated. // Clear eligibility state to avoid showing stale data from a previous location. From 71f20a5c0511866bed13a580def583a1581fcb01 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Tue, 6 Jan 2026 12:19:26 -0700 Subject: [PATCH 26/29] chore: update service action types --- .../ramps-controller/src/RampsService-method-action-types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ramps-controller/src/RampsService-method-action-types.ts b/packages/ramps-controller/src/RampsService-method-action-types.ts index 872980d384d..57a35bbdd66 100644 --- a/packages/ramps-controller/src/RampsService-method-action-types.ts +++ b/packages/ramps-controller/src/RampsService-method-action-types.ts @@ -18,7 +18,7 @@ export type RampsServiceGetGeolocationAction = { /** * Makes a request to the cached API to retrieve the list of supported countries. - * Filters countries based on aggregator support. + * Filters countries based on aggregator support (preserves OnRampSDK logic). * * @param action - The ramp action type ('buy' or 'sell'). * @returns An array of countries filtered by aggregator support. From 36d44a185eb5d2bbdb59f1b20a28a94513d19fee Mon Sep 17 00:00:00 2001 From: George Weiler Date: Tue, 6 Jan 2026 12:55:13 -0700 Subject: [PATCH 27/29] feat: make ramps service context configurable instead of hardcoding mobile-ios --- packages/ramps-controller/src/RampsService.test.ts | 1 + packages/ramps-controller/src/RampsService.ts | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/ramps-controller/src/RampsService.test.ts b/packages/ramps-controller/src/RampsService.test.ts index d1e4943c4f2..e99c811688a 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -802,6 +802,7 @@ function getService({ const service = new RampsService({ fetch, messenger, + context: 'mobile-ios', ...options, }); diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index 10de6595d72..86bacd4461e 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -231,6 +231,7 @@ function getBaseUrl( * new RampsService({ * messenger: rampsServiceMessenger, * environment: RampsEnvironment.Production, + * context: 'mobile-ios', * fetch, * }); * @@ -272,12 +273,18 @@ export class RampsService { */ readonly #environment: RampsEnvironment; + /** + * The context for API requests (e.g., 'mobile-ios', 'mobile-android'). + */ + readonly #context: string; + /** * Constructs a new RampsService object. * * @param args - The constructor arguments. * @param args.messenger - The messenger suited for this service. * @param args.environment - The environment to use for API requests. + * @param args.context - The context for API requests (e.g., 'mobile-ios', 'mobile-android'). * @param args.fetch - A function that can be used to make an HTTP request. If * your JavaScript environment supports `fetch` natively, you'll probably want * to pass that; otherwise you can pass an equivalent (such as `fetch` via @@ -288,11 +295,13 @@ export class RampsService { constructor({ messenger, environment = RampsEnvironment.Staging, + context, fetch: fetchFunction, policyOptions = {}, }: { messenger: RampsServiceMessenger; environment?: RampsEnvironment; + context: string; fetch: typeof fetch; policyOptions?: CreateServicePolicyOptions; }) { @@ -301,6 +310,7 @@ export class RampsService { this.#fetch = fetchFunction; this.#policy = createServicePolicy(policyOptions); this.#environment = environment; + this.#context = context; this.#messenger.registerMethodActionHandlers( this, @@ -374,7 +384,7 @@ export class RampsService { } url.searchParams.set('sdk', RAMPS_SDK_VERSION); url.searchParams.set('controller', packageJson.version); - url.searchParams.set('context', 'mobile-ios'); + url.searchParams.set('context', this.#context); } /** From 9303f9dccd74a23785950fd9dabfc3c1d6f182e1 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Tue, 6 Jan 2026 12:59:43 -0700 Subject: [PATCH 28/29] chore: small refactor from code review --- packages/ramps-controller/src/RampsController.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 75ce6616a95..1e855443fe5 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -452,13 +452,11 @@ export class RampsController extends BaseController< ); this.update((state) => { - if (state.geolocation === null) { + if ( + state.geolocation === null || + state.geolocation.toLowerCase().trim() === normalizedIsoCode + ) { state.eligibility = eligibility; - } else { - const currentGeolocation = state.geolocation.toLowerCase().trim(); - if (currentGeolocation === normalizedIsoCode) { - state.eligibility = eligibility; - } } }); From 18dd92bbcb113204e9ac2fce56cca53f46f3adf7 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Wed, 7 Jan 2026 07:24:02 -0700 Subject: [PATCH 29/29] fix: check states for buy action in getCountries --- .../ramps-controller/src/RampsService.test.ts | 45 +++++++++++++++++++ packages/ramps-controller/src/RampsService.ts | 6 +-- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/packages/ramps-controller/src/RampsService.test.ts b/packages/ramps-controller/src/RampsService.test.ts index e99c811688a..e03a3e18f5b 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -448,6 +448,51 @@ describe('RampsService', () => { expect(countriesResponse[0]?.states?.[0]?.supported).toBe(true); }); + it('includes country with unsupported country but supported state for buy action', async () => { + const mockCountriesWithUnsupportedCountry = [ + { + isoCode: 'US', + id: '/regions/us', + flag: 'πŸ‡ΊπŸ‡Έ', + name: 'United States', + phone: { prefix: '+1', placeholder: '', template: '' }, + currency: 'USD', + supported: false, + states: [ + { + id: '/regions/us-tx', + stateId: 'TX', + name: 'Texas', + supported: true, + }, + { + id: '/regions/us-ny', + stateId: 'NY', + name: 'New York', + supported: false, + }, + ], + }, + ]; + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/countries') + .query({ + action: 'buy', + sdk: '2.1.6', + controller: '2.0.0', + context: 'mobile-ios', + }) + .reply(200, mockCountriesWithUnsupportedCountry); + const { service } = getService(); + + const countriesResponse = await service.getCountries('buy'); + + expect(countriesResponse).toHaveLength(1); + expect(countriesResponse[0]?.isoCode).toBe('US'); + expect(countriesResponse[0]?.supported).toBe(false); + expect(countriesResponse[0]?.states?.[0]?.supported).toBe(true); + }); + it('throws if the countries API returns an error', async () => { nock('https://on-ramp-cache.uat-api.cx.metamask.io') .get('/regions/countries') diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index 86bacd4461e..549caf83a54 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -22,7 +22,7 @@ export type CountryPhone = { */ export type State = { /** - * State identifier. Can be in path format (e.g., "/regions/us-ut") or ISO code format (e.g., "ut"). + * State identifier. Can be in path format (e.g., "/regions/us-ut") or ISO code format (e.g., "us-ut"). */ id?: string; /** @@ -464,10 +464,6 @@ export class RampsService { } return countries.filter((country) => { - if (action === 'buy') { - return country.supported; - } - if (country.states && country.states.length > 0) { const hasSupportedState = country.states.some( (state) => state.supported !== false,