From ac7f11b59a34445329479ea31c0ae87fcd70f96e Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 22 Jan 2026 20:06:19 -0700 Subject: [PATCH 01/15] feat: Reorganize RampsController initialization: init() only fetches geolocation and countries, add countries to state, and create hydrateStore() for providers/tokens --- .../src/RampsController.test.ts | 1064 ++++++++--------- .../ramps-controller/src/RampsController.ts | 282 ++--- .../ramps-controller/src/selectors.test.ts | 15 + 3 files changed, 572 insertions(+), 789 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index a209b2f3c2d..8a1786e61e5 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -31,6 +31,7 @@ describe('RampsController', () => { await withController(({ controller }) => { expect(controller.state).toMatchInlineSnapshot(` Object { + "countries": Array [], "paymentMethods": Array [], "preferredProvider": null, "providers": Array [], @@ -63,6 +64,7 @@ describe('RampsController', () => { await withController({ options: { state: {} } }, ({ controller }) => { expect(controller.state).toMatchInlineSnapshot(` Object { + "countries": Array [], "paymentMethods": Array [], "preferredProvider": null, "providers": Array [], @@ -376,6 +378,7 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` Object { + "countries": Array [], "paymentMethods": Array [], "preferredProvider": null, "providers": Array [], @@ -398,6 +401,7 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` Object { + "countries": Array [], "paymentMethods": Array [], "preferredProvider": null, "providers": Array [], @@ -419,6 +423,7 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` Object { + "countries": Array [], "preferredProvider": null, "providers": Array [], "tokens": null, @@ -438,6 +443,7 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` Object { + "countries": Array [], "paymentMethods": Array [], "preferredProvider": null, "providers": Array [], @@ -451,216 +457,6 @@ describe('RampsController', () => { }); }); - describe('updateUserRegion', () => { - it('updates user region state when region is fetched', async () => { - await withController(async ({ controller, rootMessenger }) => { - rootMessenger.registerActionHandler( - 'RampsService:getGeolocation', - async () => 'US-CA', - ); - rootMessenger.registerActionHandler( - 'RampsService:getCountries', - async () => createMockCountries(), - ); - - await controller.updateUserRegion(); - - expect(controller.state.userRegion?.regionCode).toBe('us-ca'); - expect(controller.state.userRegion?.country.isoCode).toBe('US'); - expect(controller.state.userRegion?.state?.stateId).toBe('CA'); - }); - }); - - it('calls getCountriesData internally when fetching countries', async () => { - await withController(async ({ controller, rootMessenger }) => { - let countriesCallCount = 0; - rootMessenger.registerActionHandler( - 'RampsService:getGeolocation', - async () => 'US', - ); - rootMessenger.registerActionHandler( - 'RampsService:getCountries', - async () => { - countriesCallCount += 1; - return createMockCountries(); - }, - ); - await controller.updateUserRegion(); - - expect(countriesCallCount).toBe(1); - expect(controller.state.userRegion?.regionCode).toBe('us'); - }); - }); - - it('stores request state in cache', async () => { - await withController(async ({ controller, rootMessenger }) => { - rootMessenger.registerActionHandler( - 'RampsService:getGeolocation', - async () => 'US', - ); - rootMessenger.registerActionHandler( - 'RampsService:getCountries', - async () => createMockCountries(), - ); - - const result = await controller.updateUserRegion(); - - const cacheKey = createCacheKey('updateUserRegion', []); - const requestState = controller.state.requests[cacheKey]; - - expect(requestState).toBeDefined(); - expect(requestState?.status).toBe(RequestStatus.SUCCESS); - expect(result).toBeDefined(); - expect(result?.regionCode).toBe('us'); - expect(requestState?.error).toBeNull(); - }); - }); - - it('returns cached result on subsequent calls within TTL', async () => { - await withController(async ({ controller, rootMessenger }) => { - let callCount = 0; - rootMessenger.registerActionHandler( - 'RampsService:getGeolocation', - async () => { - callCount += 1; - return 'US'; - }, - ); - rootMessenger.registerActionHandler( - 'RampsService:getCountries', - async () => createMockCountries(), - ); - - await controller.updateUserRegion(); - await controller.updateUserRegion(); - - expect(callCount).toBe(1); - }); - }); - - it('makes a new request when forceRefresh is true', async () => { - await withController(async ({ controller, rootMessenger }) => { - let callCount = 0; - rootMessenger.registerActionHandler( - 'RampsService:getGeolocation', - async () => { - callCount += 1; - return 'US'; - }, - ); - rootMessenger.registerActionHandler( - 'RampsService:getCountries', - async () => createMockCountries(), - ); - - await controller.updateUserRegion(); - await controller.updateUserRegion({ forceRefresh: true }); - - expect(callCount).toBe(2); - }); - }); - - it('handles null geolocation result', async () => { - await withController(async ({ controller, rootMessenger }) => { - rootMessenger.registerActionHandler( - 'RampsService:getGeolocation', - async () => null as unknown as string, - ); - - const result = await controller.updateUserRegion(); - - expect(result).toBeNull(); - expect(controller.state.userRegion).toBeNull(); - }); - }); - - it('handles undefined geolocation result', async () => { - await withController(async ({ controller, rootMessenger }) => { - rootMessenger.registerActionHandler( - 'RampsService:getGeolocation', - async () => undefined as unknown as string, - ); - - const result = await controller.updateUserRegion(); - - expect(result).toBeNull(); - expect(controller.state.userRegion).toBeNull(); - }); - }); - - it('returns null when countries fetch fails', async () => { - await withController(async ({ controller, rootMessenger }) => { - rootMessenger.registerActionHandler( - 'RampsService:getGeolocation', - async () => 'FR', - ); - rootMessenger.registerActionHandler( - 'RampsService:getCountries', - async () => { - throw new Error('Countries API error'); - }, - ); - - const result = await controller.updateUserRegion(); - - expect(result).toBeNull(); - expect(controller.state.userRegion).toBeNull(); - expect(controller.state.tokens).toBeNull(); - }); - }); - - it('returns null when region is not found in countries data', async () => { - await withController(async ({ controller, rootMessenger }) => { - rootMessenger.registerActionHandler( - 'RampsService:getGeolocation', - async () => 'XX', - ); - rootMessenger.registerActionHandler( - 'RampsService:getCountries', - async () => createMockCountries(), - ); - - const result = await controller.updateUserRegion(); - - expect(result).toBeNull(); - expect(controller.state.userRegion).toBeNull(); - expect(controller.state.tokens).toBeNull(); - }); - }); - - it('does not overwrite existing user region when called', async () => { - const existingRegion = createMockUserRegion( - 'us-co', - 'United States', - 'Colorado', - ); - await withController( - { - options: { - state: { - userRegion: existingRegion, - }, - }, - }, - async ({ controller, rootMessenger }) => { - rootMessenger.registerActionHandler( - 'RampsService:getGeolocation', - async () => 'US-UT', - ); - rootMessenger.registerActionHandler( - 'RampsService:getCountries', - async () => createMockCountries(), - ); - - const result = await controller.updateUserRegion(); - - expect(result).toStrictEqual(existingRegion); - expect(controller.state.userRegion).toStrictEqual(existingRegion); - expect(controller.state.userRegion?.regionCode).toBe('us-co'); - }, - ); - }); - }); describe('executeRequest', () => { it('deduplicates concurrent requests with the same cache key', async () => { @@ -998,73 +794,37 @@ describe('RampsController', () => { }); describe('sync trigger methods', () => { - describe('triggerUpdateUserRegion', () => { - it('triggers user region update and returns void', async () => { - await withController(async ({ controller, rootMessenger }) => { - rootMessenger.registerActionHandler( - 'RampsService:getGeolocation', - async () => 'us', - ); - rootMessenger.registerActionHandler( - 'RampsService:getCountries', - async () => createMockCountries(), - ); - rootMessenger.registerActionHandler( - 'RampsService:getProviders', - async () => ({ providers: [] }), - ); - - const result = controller.triggerUpdateUserRegion(); - expect(result).toBeUndefined(); - - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(controller.state.userRegion?.regionCode).toBe('us'); - }); - }); - - it('does not throw when update fails', async () => { - await withController(async ({ controller, rootMessenger }) => { - rootMessenger.registerActionHandler( - 'RampsService:getGeolocation', - async () => { - throw new Error('geolocation failed'); - }, - ); - - expect(() => controller.triggerUpdateUserRegion()).not.toThrow(); - }); - }); - }); - describe('triggerSetUserRegion', () => { it('triggers set user region and returns void', async () => { - await withController(async ({ controller, rootMessenger }) => { - rootMessenger.registerActionHandler( - 'RampsService:getCountries', - async () => createMockCountries(), - ); - rootMessenger.registerActionHandler( - 'RampsService:getProviders', - async () => ({ providers: [] }), - ); + await withController( + { + options: { + state: { + countries: createMockCountries(), + }, + }, + }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async () => ({ topTokens: [], allTokens: [] }), + ); + rootMessenger.registerActionHandler( + 'RampsService:getProviders', + async () => ({ providers: [] }), + ); - const result = controller.triggerSetUserRegion('us'); - expect(result).toBeUndefined(); + const result = controller.triggerSetUserRegion('us'); + expect(result).toBeUndefined(); - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(controller.state.userRegion?.regionCode).toBe('us'); - }); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(controller.state.userRegion?.regionCode).toBe('us'); + }, + ); }); it('does not throw when set fails', async () => { - await withController(async ({ controller, rootMessenger }) => { - rootMessenger.registerActionHandler( - 'RampsService:getCountries', - async () => { - throw new Error('countries failed'); - }, - ); - + await withController(async ({ controller }) => { expect(() => controller.triggerSetUserRegion('us')).not.toThrow(); }); }); @@ -1202,13 +962,15 @@ describe('RampsController', () => { }, ]; - it('fetches countries from the service', async () => { + it('fetches countries from the service and saves to state', async () => { await withController(async ({ controller, rootMessenger }) => { rootMessenger.registerActionHandler( 'RampsService:getCountries', async () => mockCountries, ); + expect(controller.state.countries).toStrictEqual([]); + const countries = await controller.getCountries('buy'); expect(countries).toMatchInlineSnapshot(` @@ -1240,6 +1002,7 @@ describe('RampsController', () => { }, ] `); + expect(controller.state.countries).toStrictEqual(mockCountries); }); }); @@ -1266,7 +1029,7 @@ describe('RampsController', () => { let receivedAction: string | undefined; rootMessenger.registerActionHandler( 'RampsService:getCountries', - async (action) => { + async (action?: 'buy' | 'sell') => { receivedAction = action; return mockCountries; }, @@ -1283,7 +1046,7 @@ describe('RampsController', () => { let receivedAction: string | undefined; rootMessenger.registerActionHandler( 'RampsService:getCountries', - async (action) => { + async (action?: 'buy' | 'sell') => { receivedAction = action; return mockCountries; }, @@ -1297,42 +1060,12 @@ describe('RampsController', () => { }); describe('init', () => { - it('initializes controller by fetching user region, tokens, and providers', async () => { + it('initializes controller by fetching countries and geolocation', async () => { await withController(async ({ controller, rootMessenger }) => { - const mockTokens: TokensResponse = { - topTokens: [], - allTokens: [], - }; - const mockProviders: Provider[] = [ - { - id: '/providers/test', - name: 'Test Provider', - environmentType: 'STAGING', - description: 'Test', - hqAddress: '123 Test St', - links: [], - logos: { - light: '/assets/test_light.png', - dark: '/assets/test_dark.png', - height: 24, - width: 77, - }, - }, - ]; - rootMessenger.registerActionHandler( 'RampsService:getGeolocation', async () => 'US', ); - rootMessenger.registerActionHandler( - 'RampsService:getTokens', - async (_region: string, _action?: 'buy' | 'sell') => mockTokens, - ); - rootMessenger.registerActionHandler( - 'RampsService:getProviders', - async (_regionCode: string) => ({ providers: mockProviders }), - ); - rootMessenger.registerActionHandler( 'RampsService:getCountries', async () => createMockCountries(), @@ -1340,75 +1073,141 @@ describe('RampsController', () => { await controller.init(); + expect(controller.state.countries).toStrictEqual(createMockCountries()); expect(controller.state.userRegion?.regionCode).toBe('us'); - expect(controller.state.tokens).toStrictEqual(mockTokens); - expect(controller.state.providers).toStrictEqual(mockProviders); }); }); - it('handles initialization failure gracefully', async () => { - await withController(async ({ controller, rootMessenger }) => { - rootMessenger.registerActionHandler( - 'RampsService:getGeolocation', - async () => { - throw new Error('Network error'); + it('uses existing userRegion if already set', async () => { + const existingRegion = createMockUserRegion('us-ca'); + await withController( + { + options: { + state: { + userRegion: existingRegion, + }, }, - ); + }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async () => createMockCountries(), + ); - await controller.init(); + await controller.init(); - expect(controller.state.userRegion).toBeNull(); - expect(controller.state.tokens).toBeNull(); - expect(controller.state.providers).toStrictEqual([]); - }); + expect(controller.state.countries).toStrictEqual(createMockCountries()); + expect(controller.state.userRegion?.regionCode).toBe('us-ca'); + }, + ); }); - it('handles token fetch failure gracefully when region is set', async () => { + it('throws error when geolocation fetch fails', async () => { await withController(async ({ controller, rootMessenger }) => { - rootMessenger.registerActionHandler( - 'RampsService:getGeolocation', - async () => 'US', - ); rootMessenger.registerActionHandler( 'RampsService:getCountries', async () => createMockCountries(), ); rootMessenger.registerActionHandler( - 'RampsService:getTokens', - async (_region: string, _action?: 'buy' | 'sell') => { - throw new Error('Token fetch error'); - }, + 'RampsService:getGeolocation', + async () => null as unknown as string, ); + + await expect(controller.init()).rejects.toThrow( + 'Failed to fetch geolocation. Cannot initialize controller without valid region information.', + ); + }); + }); + + it('handles countries fetch failure', async () => { + await withController(async ({ controller, rootMessenger }) => { rootMessenger.registerActionHandler( - 'RampsService:getProviders', - async (_regionCode: string) => { - throw new Error('Provider fetch error'); + 'RampsService:getCountries', + async () => { + throw new Error('Countries fetch error'); }, ); - await controller.init(); + await expect(controller.init()).rejects.toThrow('Countries fetch error'); + }); + }); + }); - expect(controller.state.userRegion?.regionCode).toBe('us'); - expect(controller.state.tokens).toBeNull(); - expect(controller.state.providers).toStrictEqual([]); + describe('hydrateState', () => { + it('triggers fetching tokens and providers for user region', async () => { + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us'), + }, + }, + }, + async ({ controller, rootMessenger }) => { + let tokensCalled = false; + let providersCalled = false; + + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async () => { + tokensCalled = true; + return { topTokens: [], allTokens: [] }; + }, + ); + rootMessenger.registerActionHandler( + 'RampsService:getProviders', + async () => { + providersCalled = true; + return { providers: [] }; + }, + ); + + await controller.hydrateState(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(tokensCalled).toBe(true); + expect(providersCalled).toBe(true); + }, + ); + }); + + it('throws error when userRegion is not set', async () => { + await withController(async ({ controller }) => { + await expect(controller.hydrateState()).rejects.toThrow( + 'Region code is required. Cannot hydrate state without valid region information.', + ); }); }); }); describe('setUserRegion', () => { - it('sets user region manually', async () => { - await withController(async ({ controller, rootMessenger }) => { - rootMessenger.registerActionHandler( - 'RampsService:getCountries', - async () => createMockCountries(), - ); + it('sets user region manually using countries from state', async () => { + await withController( + { + options: { + state: { + countries: createMockCountries(), + }, + }, + }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async () => ({ topTokens: [], allTokens: [] }), + ); + rootMessenger.registerActionHandler( + 'RampsService:getProviders', + async () => ({ providers: [] }), + ); - await controller.setUserRegion('US-CA'); + await controller.setUserRegion('US-CA'); - expect(controller.state.userRegion?.regionCode).toBe('us-ca'); - expect(controller.state.userRegion?.country.isoCode).toBe('US'); - expect(controller.state.userRegion?.state?.stateId).toBe('CA'); - }); + expect(controller.state.userRegion?.regionCode).toBe('us-ca'); + expect(controller.state.userRegion?.country.isoCode).toBe('US'); + expect(controller.state.userRegion?.state?.stateId).toBe('CA'); + }, + ); }); it('clears tokens, providers, paymentMethods, and selectedPaymentMethod when user region changes', async () => { @@ -1420,186 +1219,229 @@ describe('RampsController', () => { icon: 'card', }; - await withController(async ({ controller, rootMessenger }) => { - const mockTokens: TokensResponse = { - topTokens: [], - allTokens: [], - }; - const mockProviders: Provider[] = [ - { - id: '/providers/test', - name: 'Test Provider', - environmentType: 'STAGING', - description: 'Test', - hqAddress: '123 Test St', - links: [], - logos: { - light: '/assets/test_light.png', - dark: '/assets/test_dark.png', - height: 24, - width: 77, + await withController( + { + options: { + state: { + countries: createMockCountries(), }, }, - ]; + }, + async ({ controller, rootMessenger }) => { + const mockTokens: TokensResponse = { + topTokens: [], + allTokens: [], + }; + const mockProviders: Provider[] = [ + { + id: '/providers/test', + name: 'Test Provider', + environmentType: 'STAGING', + description: 'Test', + hqAddress: '123 Test St', + links: [], + logos: { + light: '/assets/test_light.png', + dark: '/assets/test_dark.png', + height: 24, + width: 77, + }, + }, + ]; - rootMessenger.registerActionHandler( - 'RampsService:getCountries', - async () => createMockCountries(), - ); - rootMessenger.registerActionHandler( - 'RampsService:getTokens', - async (_region: string, _action?: 'buy' | 'sell') => mockTokens, - ); - let providersToReturn = mockProviders; - rootMessenger.registerActionHandler( - 'RampsService:getProviders', - async (_regionCode: string) => ({ providers: providersToReturn }), - ); - rootMessenger.registerActionHandler( - 'RampsService:getPaymentMethods', - async () => ({ payments: [mockPaymentMethod] }), - ); + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async (_region: string, _action?: 'buy' | 'sell') => mockTokens, + ); + let providersToReturn = mockProviders; + rootMessenger.registerActionHandler( + 'RampsService:getProviders', + async (_regionCode: string) => ({ providers: providersToReturn }), + ); + rootMessenger.registerActionHandler( + 'RampsService:getPaymentMethods', + async () => ({ payments: [mockPaymentMethod] }), + ); - await controller.setUserRegion('US'); - await controller.getTokens('us', 'buy'); - await controller.getPaymentMethods({ - assetId: 'eip155:1/slip44:60', - provider: '/providers/test', - }); - controller.setSelectedPaymentMethod(mockPaymentMethod); + await controller.setUserRegion('US'); + await new Promise((resolve) => setTimeout(resolve, 50)); + await controller.getPaymentMethods({ + assetId: 'eip155:1/slip44:60', + provider: '/providers/test', + }); + controller.setSelectedPaymentMethod(mockPaymentMethod); - expect(controller.state.tokens).toStrictEqual(mockTokens); - expect(controller.state.providers).toStrictEqual(mockProviders); - expect(controller.state.paymentMethods).toStrictEqual([ - mockPaymentMethod, - ]); - expect(controller.state.selectedPaymentMethod).toStrictEqual( - mockPaymentMethod, - ); + expect(controller.state.tokens).toStrictEqual(mockTokens); + expect(controller.state.providers).toStrictEqual(mockProviders); + expect(controller.state.paymentMethods).toStrictEqual([ + mockPaymentMethod, + ]); + expect(controller.state.selectedPaymentMethod).toStrictEqual( + mockPaymentMethod, + ); - providersToReturn = []; - await controller.setUserRegion('FR'); - expect(controller.state.tokens).toBeNull(); - expect(controller.state.providers).toStrictEqual([]); - expect(controller.state.paymentMethods).toStrictEqual([]); - expect(controller.state.selectedPaymentMethod).toBeNull(); - }); + providersToReturn = []; + await controller.setUserRegion('FR'); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(controller.state.tokens).toStrictEqual(mockTokens); + expect(controller.state.providers).toStrictEqual([]); + expect(controller.state.paymentMethods).toStrictEqual([]); + expect(controller.state.selectedPaymentMethod).toBeNull(); + }, + ); }); it('finds country by id starting with /regions/', async () => { - await withController(async ({ controller, rootMessenger }) => { - const countriesWithId: Country[] = [ - { - id: '/regions/us', - isoCode: 'XX', - name: 'United States', - flag: 'πŸ‡ΊπŸ‡Έ', - currency: 'USD', - phone: { prefix: '+1', placeholder: '', template: '' }, - supported: true, - states: [{ stateId: 'CA', name: 'California', supported: true }], - }, - ]; + const countriesWithId: Country[] = [ + { + id: '/regions/us', + isoCode: 'XX', + name: 'United States', + flag: 'πŸ‡ΊπŸ‡Έ', + currency: 'USD', + phone: { prefix: '+1', placeholder: '', template: '' }, + supported: true, + states: [{ stateId: 'CA', name: 'California', supported: true }], + }, + ]; - rootMessenger.registerActionHandler( - 'RampsService:getCountries', - async () => countriesWithId, - ); + await withController( + { + options: { + state: { + countries: countriesWithId, + }, + }, + }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async () => ({ topTokens: [], allTokens: [] }), + ); + rootMessenger.registerActionHandler( + 'RampsService:getProviders', + async () => ({ providers: [] }), + ); - await controller.setUserRegion('us'); + await controller.setUserRegion('us'); - expect(controller.state.userRegion?.regionCode).toBe('us'); - expect(controller.state.userRegion?.country.name).toBe('United States'); - }); + expect(controller.state.userRegion?.regionCode).toBe('us'); + expect(controller.state.userRegion?.country.name).toBe('United States'); + }, + ); }); it('finds country by id ending with /countryCode', async () => { - await withController(async ({ controller, rootMessenger }) => { - const countriesWithId: Country[] = [ - { - id: '/some/path/fr', - isoCode: 'YY', - name: 'France', - flag: 'πŸ‡«πŸ‡·', - currency: 'EUR', - phone: { prefix: '+33', placeholder: '', template: '' }, - supported: true, + const countriesWithId: Country[] = [ + { + id: '/some/path/fr', + isoCode: 'YY', + name: 'France', + flag: 'πŸ‡«πŸ‡·', + currency: 'EUR', + phone: { prefix: '+33', placeholder: '', template: '' }, + supported: true, + }, + ]; + + await withController( + { + options: { + state: { + countries: countriesWithId, + }, }, - ]; + }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async () => ({ topTokens: [], allTokens: [] }), + ); + rootMessenger.registerActionHandler( + 'RampsService:getProviders', + async () => ({ providers: [] }), + ); - rootMessenger.registerActionHandler( - 'RampsService:getCountries', - async () => countriesWithId, - ); - await controller.setUserRegion('fr'); + await controller.setUserRegion('fr'); - expect(controller.state.userRegion?.regionCode).toBe('fr'); - expect(controller.state.userRegion?.country.name).toBe('France'); - }); + expect(controller.state.userRegion?.regionCode).toBe('fr'); + expect(controller.state.userRegion?.country.name).toBe('France'); + }, + ); }); it('finds country by id matching countryCode directly', async () => { - await withController(async ({ controller, rootMessenger }) => { - const countriesWithId: Country[] = [ - { - id: 'us', - isoCode: 'ZZ', - name: 'United States', - flag: 'πŸ‡ΊπŸ‡Έ', - currency: 'USD', - phone: { prefix: '+1', placeholder: '', template: '' }, - supported: true, - }, - ]; + const countriesWithId: Country[] = [ + { + id: 'us', + isoCode: 'ZZ', + name: 'United States', + flag: 'πŸ‡ΊπŸ‡Έ', + currency: 'USD', + phone: { prefix: '+1', placeholder: '', template: '' }, + supported: true, + }, + ]; - rootMessenger.registerActionHandler( - 'RampsService:getCountries', - async () => countriesWithId, - ); + await withController( + { + options: { + state: { + countries: countriesWithId, + }, + }, + }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async () => ({ topTokens: [], allTokens: [] }), + ); + rootMessenger.registerActionHandler( + 'RampsService:getProviders', + async () => ({ providers: [] }), + ); - await controller.setUserRegion('us'); + await controller.setUserRegion('us'); - expect(controller.state.userRegion?.regionCode).toBe('us'); - expect(controller.state.userRegion?.country.name).toBe('United States'); - }); + expect(controller.state.userRegion?.regionCode).toBe('us'); + expect(controller.state.userRegion?.country.name).toBe('United States'); + }, + ); }); it('throws error when country is not found', async () => { - await withController(async ({ controller, rootMessenger }) => { - const countries: Country[] = [ - { - isoCode: 'FR', - name: 'France', - flag: 'πŸ‡«πŸ‡·', - currency: 'EUR', - phone: { prefix: '+33', placeholder: '', template: '' }, - supported: true, - }, - ]; - - rootMessenger.registerActionHandler( - 'RampsService:getCountries', - async () => countries, - ); + const countries: Country[] = [ + { + isoCode: 'FR', + name: 'France', + flag: 'πŸ‡«πŸ‡·', + currency: 'EUR', + phone: { prefix: '+33', placeholder: '', template: '' }, + supported: true, + }, + ]; - await expect(controller.setUserRegion('xx')).rejects.toThrow( - 'Region "xx" not found in countries data', - ); + await withController( + { + options: { + state: { + countries, + }, + }, + }, + async ({ controller }) => { + await expect(controller.setUserRegion('xx')).rejects.toThrow( + 'Region "xx" not found in countries data', + ); - expect(controller.state.userRegion).toBeNull(); - }); + expect(controller.state.userRegion).toBeNull(); + }, + ); }); - it('throws error when countries fetch fails', async () => { - await withController(async ({ controller, rootMessenger }) => { - rootMessenger.registerActionHandler( - 'RampsService:getCountries', - async () => { - throw new Error('Network error'); - }, - ); - + it('throws error when countries are not in state', async () => { + await withController(async ({ controller }) => { await expect(controller.setUserRegion('us')).rejects.toThrow( - 'Failed to fetch countries data. Cannot set user region without valid country information.', + 'No countries found. Cannot set user region without valid country information.', ); expect(controller.state.userRegion).toBeNull(); @@ -1607,126 +1449,160 @@ describe('RampsController', () => { }); }); - it('clears pre-existing userRegion when countries fetch fails', async () => { - await withController(async ({ controller, rootMessenger }) => { - let shouldFailCountriesFetch = false; - - rootMessenger.registerActionHandler( - 'RampsService:getCountries', - async () => { - if (shouldFailCountriesFetch) { - throw new Error('Network error'); - } - return createMockCountries(); + it('clears pre-existing userRegion when countries are not in state', async () => { + await withController( + { + options: { + state: { + countries: [], + userRegion: createMockUserRegion('us-ca'), + }, }, - ); - await controller.setUserRegion('US-CA'); - expect(controller.state.userRegion?.regionCode).toBe('us-ca'); - - shouldFailCountriesFetch = true; - - await expect( - controller.setUserRegion('FR', { forceRefresh: true }), - ).rejects.toThrow( - 'Failed to fetch countries data. Cannot set user region without valid country information.', - ); + }, + async ({ controller }) => { + await expect(controller.setUserRegion('FR')).rejects.toThrow( + 'No countries found. Cannot set user region without valid country information.', + ); - expect(controller.state.userRegion).toBeNull(); - expect(controller.state.tokens).toBeNull(); - }); + expect(controller.state.userRegion).toBeNull(); + expect(controller.state.tokens).toBeNull(); + }, + ); }); it('finds state by id including -stateCode', async () => { - await withController(async ({ controller, rootMessenger }) => { - const countriesWithStateId: Country[] = [ - { - isoCode: 'US', - name: 'United States', - flag: 'πŸ‡ΊπŸ‡Έ', - currency: 'USD', - phone: { prefix: '+1', placeholder: '', template: '' }, - supported: true, - states: [ - { - id: '/regions/us-ny', - name: 'New York', - supported: true, - }, - ], + const countriesWithStateId: Country[] = [ + { + isoCode: 'US', + name: 'United States', + flag: 'πŸ‡ΊπŸ‡Έ', + currency: 'USD', + phone: { prefix: '+1', placeholder: '', template: '' }, + supported: true, + states: [ + { + id: '/regions/us-ny', + name: 'New York', + supported: true, + }, + ], + }, + ]; + + await withController( + { + options: { + state: { + countries: countriesWithStateId, + }, }, - ]; + }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async () => ({ topTokens: [], allTokens: [] }), + ); + rootMessenger.registerActionHandler( + 'RampsService:getProviders', + async () => ({ providers: [] }), + ); - rootMessenger.registerActionHandler( - 'RampsService:getCountries', - async () => countriesWithStateId, - ); - await controller.setUserRegion('us-ny'); + await controller.setUserRegion('us-ny'); - expect(controller.state.userRegion?.regionCode).toBe('us-ny'); - expect(controller.state.userRegion?.country.isoCode).toBe('US'); - expect(controller.state.userRegion?.state?.name).toBe('New York'); - }); + expect(controller.state.userRegion?.regionCode).toBe('us-ny'); + expect(controller.state.userRegion?.country.isoCode).toBe('US'); + expect(controller.state.userRegion?.state?.name).toBe('New York'); + }, + ); }); it('finds state by id ending with /stateCode', async () => { - await withController(async ({ controller, rootMessenger }) => { - const countriesWithStateId: Country[] = [ - { - isoCode: 'US', - name: 'United States', - flag: 'πŸ‡ΊπŸ‡Έ', - currency: 'USD', - phone: { prefix: '+1', placeholder: '', template: '' }, - supported: true, - states: [ - { - id: '/some/path/ca', - name: 'California', - supported: true, - }, - ], + const countriesWithStateId: Country[] = [ + { + isoCode: 'US', + name: 'United States', + flag: 'πŸ‡ΊπŸ‡Έ', + currency: 'USD', + phone: { prefix: '+1', placeholder: '', template: '' }, + supported: true, + states: [ + { + id: '/some/path/ca', + name: 'California', + supported: true, + }, + ], + }, + ]; + + await withController( + { + options: { + state: { + countries: countriesWithStateId, + }, }, - ]; + }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async () => ({ topTokens: [], allTokens: [] }), + ); + rootMessenger.registerActionHandler( + 'RampsService:getProviders', + async () => ({ providers: [] }), + ); - rootMessenger.registerActionHandler( - 'RampsService:getCountries', - async () => countriesWithStateId, - ); - await controller.setUserRegion('us-ca'); + await controller.setUserRegion('us-ca'); - expect(controller.state.userRegion?.regionCode).toBe('us-ca'); - expect(controller.state.userRegion?.country.isoCode).toBe('US'); - expect(controller.state.userRegion?.state?.name).toBe('California'); - }); + expect(controller.state.userRegion?.regionCode).toBe('us-ca'); + expect(controller.state.userRegion?.country.isoCode).toBe('US'); + expect(controller.state.userRegion?.state?.name).toBe('California'); + }, + ); }); it('returns null state when state code does not match any state', async () => { - await withController(async ({ controller, rootMessenger }) => { - const countriesWithStates: Country[] = [ - { - isoCode: 'US', - name: 'United States', - flag: 'πŸ‡ΊπŸ‡Έ', - currency: 'USD', - phone: { prefix: '+1', placeholder: '', template: '' }, - supported: true, - states: [ - { stateId: 'CA', name: 'California', supported: true }, - { stateId: 'NY', name: 'New York', supported: true }, - ], + const countriesWithStates: Country[] = [ + { + isoCode: 'US', + name: 'United States', + flag: 'πŸ‡ΊπŸ‡Έ', + currency: 'USD', + phone: { prefix: '+1', placeholder: '', template: '' }, + supported: true, + states: [ + { stateId: 'CA', name: 'California', supported: true }, + { stateId: 'NY', name: 'New York', supported: true }, + ], + }, + ]; + + await withController( + { + options: { + state: { + countries: countriesWithStates, + }, }, - ]; + }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async () => ({ topTokens: [], allTokens: [] }), + ); + rootMessenger.registerActionHandler( + 'RampsService:getProviders', + async () => ({ providers: [] }), + ); - rootMessenger.registerActionHandler( - 'RampsService:getCountries', - async () => countriesWithStates, - ); - await controller.setUserRegion('us-xx'); + await controller.setUserRegion('us-xx'); - expect(controller.state.userRegion?.regionCode).toBe('us-xx'); - expect(controller.state.userRegion?.country.isoCode).toBe('US'); - expect(controller.state.userRegion?.state).toBeNull(); - }); + expect(controller.state.userRegion?.regionCode).toBe('us-xx'); + expect(controller.state.userRegion?.country.isoCode).toBe('US'); + expect(controller.state.userRegion?.state).toBeNull(); + }, + ); }); }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 49429f67f68..31f7d7a4fc3 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -6,6 +6,7 @@ import type { import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; import type { Json } from '@metamask/utils'; +import type { Draft } from 'immer'; import type { Country, @@ -85,6 +86,10 @@ export type RampsControllerState = { * Can be manually set by the user. */ preferredProvider: Provider | null; + /** + * List of countries available for ramp actions. + */ + countries: Country[]; /** * List of providers available for the current region. */ @@ -127,6 +132,12 @@ const rampsControllerMetadata = { includeInStateLogs: true, usedInUi: true, }, + countries: { + persist: true, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: true, + }, providers: { persist: true, includeInDebugSnapshot: true, @@ -171,6 +182,7 @@ export function getDefaultRampsControllerState(): RampsControllerState { return { userRegion: null, preferredProvider: null, + countries: [], providers: [], tokens: null, paymentMethods: [], @@ -474,7 +486,7 @@ export class RampsController extends BaseController< * @param cacheKey - The cache key to remove. */ #removeRequestState(cacheKey: string): void { - this.update((state) => { + this.update((state: Draft) => { const requests = state.requests as unknown as Record< string, RequestState | undefined @@ -483,6 +495,18 @@ export class RampsController extends BaseController< }); } + #cleanupState(): void { + this.update((state: Draft) => { + state.userRegion = null; + state.preferredProvider = null; + state.tokens = null; + state.providers = []; + state.paymentMethods = []; + state.selectedPaymentMethod = null; + state.requests = {}; + }); + } + /** * Gets the state of a specific cached request. * @@ -503,7 +527,7 @@ export class RampsController extends BaseController< const maxSize = this.#requestCacheMaxSize; const ttl = this.#requestCacheTTL; - this.update((state) => { + this.update((state: Draft) => { const requests = state.requests as unknown as Record< string, RequestState | undefined @@ -546,114 +570,6 @@ export class RampsController extends BaseController< }); } - /** - * Updates the user's region by fetching geolocation. - * This method calls the RampsService to get the geolocation. - * - * @param options - Options for cache behavior. - * @returns The user region object. - */ - async updateUserRegion( - options?: ExecuteRequestOptions, - ): Promise { - // If a userRegion already exists and forceRefresh is not requested, - // return it immediately without fetching geolocation. - // This ensures that once a region is set (either via geolocation or manual selection), - // it will not be overwritten by subsequent geolocation fetches. - if (this.state.userRegion && !options?.forceRefresh) { - return this.state.userRegion; - } - - // When forceRefresh is true, clear the existing region and region-dependent state before fetching - if (options?.forceRefresh) { - this.update((state) => { - state.userRegion = null; - state.tokens = null; - state.providers = []; - state.paymentMethods = []; - state.selectedPaymentMethod = null; - }); - } - - const cacheKey = createCacheKey('updateUserRegion', []); - - const regionCode = await this.executeRequest( - cacheKey, - async () => { - const result = await this.messenger.call('RampsService:getGeolocation'); - return result; - }, - options, - ); - - if (!regionCode) { - this.update((state) => { - state.userRegion = null; - state.tokens = null; - state.providers = []; - state.paymentMethods = []; - state.selectedPaymentMethod = null; - }); - return null; - } - - const normalizedRegion = regionCode.toLowerCase().trim(); - - try { - const countries = await this.getCountries('buy', options); - const userRegion = findRegionFromCode(normalizedRegion, countries); - - if (userRegion) { - this.update((state) => { - const regionChanged = - state.userRegion?.regionCode !== userRegion.regionCode; - state.userRegion = userRegion; - // Clear region-dependent state when region changes - if (regionChanged) { - state.tokens = null; - state.providers = []; - state.paymentMethods = []; - state.selectedPaymentMethod = null; - } - }); - - // Fetch providers for the new region - if (userRegion.regionCode) { - try { - await this.getProviders(userRegion.regionCode, options); - } catch { - // Provider fetch failed - error state will be available via selectors - } - } - - return userRegion; - } - - // Region not found in countries data - this.update((state) => { - state.userRegion = null; - state.tokens = null; - state.providers = []; - state.paymentMethods = []; - state.selectedPaymentMethod = null; - }); - - return null; - } catch { - // If countries fetch fails, we can't create a valid UserRegion - // Return null to indicate we don't have valid country data - this.update((state) => { - state.userRegion = null; - state.tokens = null; - state.providers = []; - state.paymentMethods = []; - state.selectedPaymentMethod = null; - }); - - return null; - } - } - /** * Sets the user's region manually (without fetching geolocation). * This allows users to override the detected region. @@ -669,56 +585,30 @@ export class RampsController extends BaseController< const normalizedRegion = region.toLowerCase().trim(); try { - const countries = await this.getCountries('buy', options); - const userRegion = findRegionFromCode(normalizedRegion, countries); - - if (userRegion) { - this.update((state) => { - state.userRegion = userRegion; - state.tokens = null; - state.providers = []; - state.paymentMethods = []; - state.selectedPaymentMethod = null; - }); + const countries = this.state.countries; + if(!countries || countries.length === 0) { + this.#cleanupState(); + throw new Error('No countries found. Cannot set user region without valid country information.'); + } - // Fetch providers for the new region - try { - await this.getProviders(userRegion.regionCode, options); - } catch { - // Provider fetch failed - error state will be available via selectors - } + const userRegion = findRegionFromCode(normalizedRegion, countries); - return userRegion; + if(!userRegion) { + this.#cleanupState(); + throw new Error(`Region "${normalizedRegion}" not found in countries data. Cannot set user region without valid country information.`); } - // Region not found in countries data - this.update((state) => { - state.userRegion = null; - state.tokens = null; - state.providers = []; - state.paymentMethods = []; - state.selectedPaymentMethod = null; + + this.#cleanupState(); + this.update((state: Draft) => { + state.userRegion = userRegion; }); - throw new Error( - `Region "${normalizedRegion}" not found in countries data. Cannot set user region without valid country information.`, - ); + this.triggerGetTokens(userRegion.regionCode, 'buy', options); + this.triggerGetProviders(userRegion.regionCode, options); + return userRegion; } catch (error) { - // If the error is "not found", re-throw it - // Otherwise, it's from countries fetch failure - if (error instanceof Error && error.message.includes('not found')) { - throw error; - } - // Countries fetch failed - this.update((state) => { - state.userRegion = null; - state.tokens = null; - state.providers = []; - state.paymentMethods = []; - state.selectedPaymentMethod = null; - }); - throw new Error( - 'Failed to fetch countries data. Cannot set user region without valid country information.', - ); + this.#cleanupState(); + throw error; } } @@ -729,7 +619,7 @@ export class RampsController extends BaseController< * @param provider - The provider object to set. */ setPreferredProvider(provider: Provider | null): void { - this.update((state) => { + this.update((state: Draft) => { state.preferredProvider = provider; }); } @@ -737,37 +627,43 @@ export class RampsController extends BaseController< /** * Initializes the controller by fetching the user's region from geolocation. * This should be called once at app startup to set up the initial region. - * After the region is set, tokens are fetched and saved to state. * * If a userRegion already exists (from persistence or manual selection), - * this method will skip geolocation fetch and only fetch tokens if needed. + * this method will skip geolocation fetch and use the existing region. * * @param options - Options for cache behavior. * @returns Promise that resolves when initialization is complete. */ async init(options?: ExecuteRequestOptions): Promise { - const userRegion = await this.updateUserRegion(options).catch(() => { - // User region fetch failed - error state will be available via selectors - return null; - }); + await this.getCountries('buy', options); + + let regionCode = this.state.userRegion?.regionCode; + if(!regionCode) { + regionCode = await this.messenger.call( + 'RampsService:getGeolocation', + ); + } - if (userRegion) { - try { - await this.getTokens(userRegion.regionCode, 'buy', options); - } catch { - // Token fetch failed - error state will be available via selectors - } + if(!regionCode) { + throw new Error('Failed to fetch geolocation. Cannot initialize controller without valid region information.'); + } - try { - await this.getProviders(userRegion.regionCode, options); - } catch { - // Provider fetch failed - error state will be available via selectors - } + this.triggerSetUserRegion(regionCode, options); + } + + async hydrateState(options?: ExecuteRequestOptions ): Promise { + const regionCode = this.state.userRegion?.regionCode; + if(!regionCode) { + throw new Error('Region code is required. Cannot hydrate state without valid region information.'); } + + this.triggerGetTokens(regionCode, 'buy', options); + this.triggerGetProviders(regionCode, options); } /** * Fetches the list of supported countries for a given ramp action. + * The countries are saved in the controller state once fetched. * * @param action - The ramp action type ('buy' or 'sell'). * @param options - Options for cache behavior. @@ -779,13 +675,20 @@ export class RampsController extends BaseController< ): Promise { const cacheKey = createCacheKey('getCountries', [action]); - return this.executeRequest( - cacheKey, - async () => { - return this.messenger.call('RampsService:getCountries', action); - }, - options, - ); + const countries = await this.executeRequest( + cacheKey, + async () => { + return this.messenger.call('RampsService:getCountries', action); + }, + options, + ); + + + this.update((state: Draft) => { + state.countries = countries; + }); + + return countries; } /** @@ -835,7 +738,7 @@ export class RampsController extends BaseController< options, ); - this.update((state) => { + this.update((state: Draft) => { const userRegionCode = state.userRegion?.regionCode; if (userRegionCode === undefined || userRegionCode === normalizedRegion) { @@ -901,7 +804,7 @@ export class RampsController extends BaseController< options, ); - this.update((state) => { + this.update((state: Draft) => { const userRegionCode = state.userRegion?.regionCode; if (userRegionCode === undefined || userRegionCode === normalizedRegion) { @@ -970,14 +873,14 @@ export class RampsController extends BaseController< { forceRefresh: options.forceRefresh, ttl: options.ttl }, ); - this.update((state) => { + this.update((state: Draft) => { state.paymentMethods = response.payments; // Only clear selected payment method if it's no longer in the new list // This preserves the selection when cached data is returned (same context) if ( state.selectedPaymentMethod && !response.payments.some( - (pm) => pm.id === state.selectedPaymentMethod?.id, + (pm: PaymentMethod) => pm.id === state.selectedPaymentMethod?.id, ) ) { state.selectedPaymentMethod = null; @@ -993,7 +896,7 @@ export class RampsController extends BaseController< * @param paymentMethod - The payment method to select, or null to clear. */ setSelectedPaymentMethod(paymentMethod: PaymentMethod | null): void { - this.update((state) => { + this.update((state: Draft) => { state.selectedPaymentMethod = paymentMethod; }); } @@ -1004,17 +907,6 @@ export class RampsController extends BaseController< // Errors are stored in state and available via selectors. // ============================================================ - /** - * Triggers a user region update without throwing. - * - * @param options - Options for cache behavior. - */ - triggerUpdateUserRegion(options?: ExecuteRequestOptions): void { - this.updateUserRegion(options).catch(() => { - // Error stored in state - }); - } - /** * Triggers setting the user region without throwing. * diff --git a/packages/ramps-controller/src/selectors.test.ts b/packages/ramps-controller/src/selectors.test.ts index f2df4fcafff..d1c08381144 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: { userRegion: null, + countries: [], preferredProvider: null, providers: [], tokens: null, @@ -58,6 +59,7 @@ describe('createRequestSelector', () => { const state: TestRootState = { ramps: { userRegion: null, + countries: [], preferredProvider: null, providers: [], tokens: null, @@ -94,6 +96,7 @@ describe('createRequestSelector', () => { const state: TestRootState = { ramps: { userRegion: null, + countries: [], preferredProvider: null, providers: [], tokens: null, @@ -126,6 +129,7 @@ describe('createRequestSelector', () => { const state: TestRootState = { ramps: { userRegion: null, + countries: [], preferredProvider: null, providers: [], tokens: null, @@ -181,6 +185,7 @@ describe('createRequestSelector', () => { const state: TestRootState = { ramps: { userRegion: null, + countries: [], preferredProvider: null, providers: [], tokens: null, @@ -209,6 +214,7 @@ describe('createRequestSelector', () => { const state1: TestRootState = { ramps: { userRegion: null, + countries: [], preferredProvider: null, providers: [], tokens: null, @@ -226,6 +232,7 @@ describe('createRequestSelector', () => { const state2: TestRootState = { ramps: { userRegion: null, + countries: [], preferredProvider: null, providers: [], tokens: null, @@ -255,6 +262,7 @@ describe('createRequestSelector', () => { const state: TestRootState = { ramps: { userRegion: null, + countries: [], preferredProvider: null, providers: [], tokens: null, @@ -287,6 +295,7 @@ describe('createRequestSelector', () => { const state: TestRootState = { ramps: { userRegion: null, + countries: [], preferredProvider: null, providers: [], tokens: null, @@ -318,6 +327,7 @@ describe('createRequestSelector', () => { const loadingState: TestRootState = { ramps: { userRegion: null, + countries: [], preferredProvider: null, providers: [], tokens: null, @@ -337,6 +347,7 @@ describe('createRequestSelector', () => { const successState: TestRootState = { ramps: { userRegion: null, + countries: [], preferredProvider: null, providers: [], tokens: null, @@ -364,6 +375,7 @@ describe('createRequestSelector', () => { const successState: TestRootState = { ramps: { userRegion: null, + countries: [], preferredProvider: null, providers: [], tokens: null, @@ -382,6 +394,7 @@ describe('createRequestSelector', () => { const errorState: TestRootState = { ramps: { userRegion: null, + countries: [], preferredProvider: null, providers: [], tokens: null, @@ -415,6 +428,7 @@ describe('createRequestSelector', () => { const state: TestRootState = { ramps: { userRegion: null, + countries: [], preferredProvider: null, providers: [], tokens: null, @@ -452,6 +466,7 @@ describe('createRequestSelector', () => { const state: TestRootState = { ramps: { userRegion: null, + countries: [], preferredProvider: null, providers: [], tokens: null, From f441e0bec1fc64d4a7084d6cde7f2dbd8e252051 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 22 Jan 2026 20:09:40 -0700 Subject: [PATCH 02/15] chore: changelog --- packages/ramps-controller/CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 7a49db1110a..1591213bfbf 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `hydrateState()` method to fetch providers and tokens for user region ([#7707](https://github.com/MetaMask/core/pull/7707)) +- Add `countries` state to RampsController with 24 hour TTL caching ([#7707](https://github.com/MetaMask/core/pull/7707)) + +### Changed + +- Reorganize `init()` to only fetch geolocation and countries; remove token and provider fetching ([#7707](https://github.com/MetaMask/core/pull/7707)) + + ## [4.1.0] ### Added From 03aa3c0625c61e2d0c83860ba44b802b269bac3d Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 22 Jan 2026 21:44:14 -0700 Subject: [PATCH 03/15] fix: fixes init error swallowing bug --- .../src/RampsController.test.ts | 19 +++++-- .../ramps-controller/src/RampsController.ts | 57 ++++++++++--------- 2 files changed, 42 insertions(+), 34 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 8a1786e61e5..dac0d61e7d1 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -23,7 +23,7 @@ import type { RampsServiceGetProvidersAction, RampsServiceGetPaymentMethodsAction, } from './RampsService-method-action-types'; -import { RequestStatus, createCacheKey } from './RequestCache'; +import { RequestStatus } from './RequestCache'; describe('RampsController', () => { describe('constructor', () => { @@ -457,7 +457,6 @@ describe('RampsController', () => { }); }); - describe('executeRequest', () => { it('deduplicates concurrent requests with the same cache key', async () => { await withController(async ({ controller }) => { @@ -1096,7 +1095,9 @@ describe('RampsController', () => { await controller.init(); - expect(controller.state.countries).toStrictEqual(createMockCountries()); + expect(controller.state.countries).toStrictEqual( + createMockCountries(), + ); expect(controller.state.userRegion?.regionCode).toBe('us-ca'); }, ); @@ -1128,7 +1129,9 @@ describe('RampsController', () => { }, ); - await expect(controller.init()).rejects.toThrow('Countries fetch error'); + await expect(controller.init()).rejects.toThrow( + 'Countries fetch error', + ); }); }); }); @@ -1325,7 +1328,9 @@ describe('RampsController', () => { await controller.setUserRegion('us'); expect(controller.state.userRegion?.regionCode).toBe('us'); - expect(controller.state.userRegion?.country.name).toBe('United States'); + expect(controller.state.userRegion?.country.name).toBe( + 'United States', + ); }, ); }); @@ -1403,7 +1408,9 @@ describe('RampsController', () => { await controller.setUserRegion('us'); expect(controller.state.userRegion?.regionCode).toBe('us'); - expect(controller.state.userRegion?.country.name).toBe('United States'); + expect(controller.state.userRegion?.country.name).toBe( + 'United States', + ); }, ); }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 31f7d7a4fc3..0f502876e15 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -503,7 +503,6 @@ export class RampsController extends BaseController< state.providers = []; state.paymentMethods = []; state.selectedPaymentMethod = null; - state.requests = {}; }); } @@ -585,20 +584,23 @@ export class RampsController extends BaseController< const normalizedRegion = region.toLowerCase().trim(); try { - const countries = this.state.countries; - if(!countries || countries.length === 0) { + const { countries } = this.state; + if (!countries || countries.length === 0) { this.#cleanupState(); - throw new Error('No countries found. Cannot set user region without valid country information.'); + throw new Error( + 'No countries found. Cannot set user region without valid country information.', + ); } const userRegion = findRegionFromCode(normalizedRegion, countries); - if(!userRegion) { + if (!userRegion) { this.#cleanupState(); - throw new Error(`Region "${normalizedRegion}" not found in countries data. Cannot set user region without valid country information.`); + throw new Error( + `Region "${normalizedRegion}" not found in countries data. Cannot set user region without valid country information.`, + ); } - this.#cleanupState(); this.update((state: Draft) => { state.userRegion = userRegion; @@ -635,26 +637,26 @@ export class RampsController extends BaseController< * @returns Promise that resolves when initialization is complete. */ async init(options?: ExecuteRequestOptions): Promise { - await this.getCountries('buy', options); - + await this.getCountries('buy', options); + let regionCode = this.state.userRegion?.regionCode; - if(!regionCode) { - regionCode = await this.messenger.call( - 'RampsService:getGeolocation', - ); - } + regionCode ??= await this.messenger.call('RampsService:getGeolocation'); - if(!regionCode) { - throw new Error('Failed to fetch geolocation. Cannot initialize controller without valid region information.'); + if (!regionCode) { + throw new Error( + 'Failed to fetch geolocation. Cannot initialize controller without valid region information.', + ); } - this.triggerSetUserRegion(regionCode, options); + await this.setUserRegion(regionCode, options); } - async hydrateState(options?: ExecuteRequestOptions ): Promise { + async hydrateState(options?: ExecuteRequestOptions): Promise { const regionCode = this.state.userRegion?.regionCode; - if(!regionCode) { - throw new Error('Region code is required. Cannot hydrate state without valid region information.'); + if (!regionCode) { + throw new Error( + 'Region code is required. Cannot hydrate state without valid region information.', + ); } this.triggerGetTokens(regionCode, 'buy', options); @@ -675,15 +677,14 @@ export class RampsController extends BaseController< ): Promise { const cacheKey = createCacheKey('getCountries', [action]); - const countries = await this.executeRequest( - cacheKey, - async () => { - return this.messenger.call('RampsService:getCountries', action); - }, - options, - ); + const countries = await this.executeRequest( + cacheKey, + async () => { + return this.messenger.call('RampsService:getCountries', action); + }, + options, + ); - this.update((state: Draft) => { state.countries = countries; }); From 47f001bf3a739d9a26d2c29bcf2d6dffc1096576 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 22 Jan 2026 22:02:55 -0700 Subject: [PATCH 04/15] chore: changelog whitespace --- packages/ramps-controller/CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 1591213bfbf..909f4f2588a 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -16,7 +16,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Reorganize `init()` to only fetch geolocation and countries; remove token and provider fetching ([#7707](https://github.com/MetaMask/core/pull/7707)) - ## [4.1.0] ### Added From b412fd51d88b71d12aff6108b9806f97314bee25 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 22 Jan 2026 22:07:30 -0700 Subject: [PATCH 05/15] fix: bugbot --- .../src/RampsController.test.ts | 218 ++++++++++++++++++ .../ramps-controller/src/RampsController.ts | 19 +- 2 files changed, 234 insertions(+), 3 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index dac0d61e7d1..9226d8f6379 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -1103,6 +1103,81 @@ describe('RampsController', () => { ); }); + it('does not clear persisted state when init() is called with same persisted region', async () => { + const mockTokens: TokensResponse = { + topTokens: [], + allTokens: [], + }; + const mockProviders: Provider[] = [ + { + id: '/providers/test', + name: 'Test Provider', + environmentType: 'STAGING', + description: 'Test', + hqAddress: '123 Test St', + links: [], + logos: { + light: '/assets/test_light.png', + dark: '/assets/test_dark.png', + height: 24, + width: 77, + }, + }, + ]; + const mockPreferredProvider: Provider = { + id: '/providers/preferred', + name: 'Preferred Provider', + environmentType: 'STAGING', + description: 'Preferred', + hqAddress: '456 Preferred St', + links: [], + logos: { + light: '/assets/preferred_light.png', + dark: '/assets/preferred_dark.png', + height: 24, + width: 77, + }, + }; + + await withController( + { + options: { + state: { + countries: createMockCountries(), + userRegion: createMockUserRegion('us'), + tokens: mockTokens, + providers: mockProviders, + preferredProvider: mockPreferredProvider, + }, + }, + }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async () => createMockCountries(), + ); + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async () => ({ topTokens: [], allTokens: [] }), + ); + rootMessenger.registerActionHandler( + 'RampsService:getProviders', + async () => ({ providers: [] }), + ); + + await controller.init(); + + // Verify persisted state is preserved + expect(controller.state.userRegion?.regionCode).toBe('us'); + expect(controller.state.tokens).toStrictEqual(mockTokens); + expect(controller.state.providers).toStrictEqual(mockProviders); + expect(controller.state.preferredProvider).toStrictEqual( + mockPreferredProvider, + ); + }, + ); + }); + it('throws error when geolocation fetch fails', async () => { await withController(async ({ controller, rootMessenger }) => { rootMessenger.registerActionHandler( @@ -1293,6 +1368,149 @@ describe('RampsController', () => { }, ); }); + + it('does not clear persisted state when setting the same region', async () => { + const mockTokens: TokensResponse = { + topTokens: [], + allTokens: [], + }; + const mockProviders: Provider[] = [ + { + id: '/providers/test', + name: 'Test Provider', + environmentType: 'STAGING', + description: 'Test', + hqAddress: '123 Test St', + links: [], + logos: { + light: '/assets/test_light.png', + dark: '/assets/test_dark.png', + height: 24, + width: 77, + }, + }, + ]; + const mockPreferredProvider: Provider = { + id: '/providers/preferred', + name: 'Preferred Provider', + environmentType: 'STAGING', + description: 'Preferred', + hqAddress: '456 Preferred St', + links: [], + logos: { + light: '/assets/preferred_light.png', + dark: '/assets/preferred_dark.png', + height: 24, + width: 77, + }, + }; + + await withController( + { + options: { + state: { + countries: createMockCountries(), + userRegion: createMockUserRegion('us'), + tokens: mockTokens, + providers: mockProviders, + preferredProvider: mockPreferredProvider, + }, + }, + }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async () => ({ topTokens: [], allTokens: [] }), + ); + rootMessenger.registerActionHandler( + 'RampsService:getProviders', + async () => ({ providers: [] }), + ); + + // Set the same region + await controller.setUserRegion('US'); + + // Verify persisted state is preserved + expect(controller.state.userRegion?.regionCode).toBe('us'); + expect(controller.state.tokens).toStrictEqual(mockTokens); + expect(controller.state.providers).toStrictEqual(mockProviders); + expect(controller.state.preferredProvider).toStrictEqual( + mockPreferredProvider, + ); + }, + ); + }); + + it('clears persisted state when setting a different region', async () => { + const mockTokens: TokensResponse = { + topTokens: [], + allTokens: [], + }; + const mockProviders: Provider[] = [ + { + id: '/providers/test', + name: 'Test Provider', + environmentType: 'STAGING', + description: 'Test', + hqAddress: '123 Test St', + links: [], + logos: { + light: '/assets/test_light.png', + dark: '/assets/test_dark.png', + height: 24, + width: 77, + }, + }, + ]; + const mockPreferredProvider: Provider = { + id: '/providers/preferred', + name: 'Preferred Provider', + environmentType: 'STAGING', + description: 'Preferred', + hqAddress: '456 Preferred St', + links: [], + logos: { + light: '/assets/preferred_light.png', + dark: '/assets/preferred_dark.png', + height: 24, + width: 77, + }, + }; + + await withController( + { + options: { + state: { + countries: createMockCountries(), + userRegion: createMockUserRegion('us'), + tokens: mockTokens, + providers: mockProviders, + preferredProvider: mockPreferredProvider, + }, + }, + }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async () => ({ topTokens: [], allTokens: [] }), + ); + rootMessenger.registerActionHandler( + 'RampsService:getProviders', + async () => ({ providers: [] }), + ); + + // Set a different region + await controller.setUserRegion('FR'); + + // Verify persisted state is cleared + expect(controller.state.userRegion?.regionCode).toBe('fr'); + expect(controller.state.tokens).toBeNull(); + expect(controller.state.providers).toStrictEqual([]); + expect(controller.state.preferredProvider).toBeNull(); + }, + ); + }); + it('finds country by id starting with /regions/', async () => { const countriesWithId: Country[] = [ { diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 0f502876e15..6769c30028d 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -601,12 +601,25 @@ export class RampsController extends BaseController< ); } - this.#cleanupState(); + // Only cleanup state if region is actually changing + const regionChanged = + normalizedRegion !== this.state.userRegion?.regionCode; + if (regionChanged) { + this.#cleanupState(); + } + this.update((state: Draft) => { state.userRegion = userRegion; }); - this.triggerGetTokens(userRegion.regionCode, 'buy', options); - this.triggerGetProviders(userRegion.regionCode, options); + + // Only trigger fetches if region changed or if data is missing + if (regionChanged || !this.state.tokens) { + this.triggerGetTokens(userRegion.regionCode, 'buy', options); + } + if (regionChanged || this.state.providers.length === 0) { + this.triggerGetProviders(userRegion.regionCode, options); + } + return userRegion; } catch (error) { this.#cleanupState(); From a9a8c1ed02672c8123d69cecf72b7b9b85cc4de5 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 22 Jan 2026 22:14:53 -0700 Subject: [PATCH 06/15] chore: delcare hydrateState as non async --- 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 6769c30028d..5376fe14946 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -664,7 +664,7 @@ export class RampsController extends BaseController< await this.setUserRegion(regionCode, options); } - async hydrateState(options?: ExecuteRequestOptions): Promise { + hydrateState(options?: ExecuteRequestOptions): void { const regionCode = this.state.userRegion?.regionCode; if (!regionCode) { throw new Error( From c8550643e28e191f582a9b16be56524abc993492 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 22 Jan 2026 22:21:51 -0700 Subject: [PATCH 07/15] chore: delcare hydrateState as non async in test --- packages/ramps-controller/src/RampsController.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 9226d8f6379..4dcc85f2714 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -1240,7 +1240,7 @@ describe('RampsController', () => { }, ); - await controller.hydrateState(); + controller.hydrateState(); await new Promise((resolve) => setTimeout(resolve, 10)); From 0731ca5b31f78257103e9213ffb76ca29e53ae54 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 22 Jan 2026 22:26:38 -0700 Subject: [PATCH 08/15] fix: test fix --- packages/ramps-controller/src/RampsController.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 4dcc85f2714..60f6be9961f 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -1252,7 +1252,7 @@ describe('RampsController', () => { it('throws error when userRegion is not set', async () => { await withController(async ({ controller }) => { - await expect(controller.hydrateState()).rejects.toThrow( + expect(() => controller.hydrateState()).toThrow( 'Region code is required. Cannot hydrate state without valid region information.', ); }); From cce760515754ac9019c1da7cca882c64af5aaf87 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Fri, 23 Jan 2026 09:37:05 -0700 Subject: [PATCH 09/15] fix: bugbot --- packages/ramps-controller/src/RampsController.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 5376fe14946..63311d9a7dd 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -604,11 +604,16 @@ export class RampsController extends BaseController< // Only cleanup state if region is actually changing const regionChanged = normalizedRegion !== this.state.userRegion?.regionCode; - if (regionChanged) { - this.#cleanupState(); - } + // Set the new region atomically with cleanup to avoid intermediate null state this.update((state: Draft) => { + if (regionChanged) { + state.preferredProvider = null; + state.tokens = null; + state.providers = []; + state.paymentMethods = []; + state.selectedPaymentMethod = null; + } state.userRegion = userRegion; }); From 175ec88cc6a6b86555b853efcbb3dca76f29e977 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Fri, 23 Jan 2026 10:21:41 -0700 Subject: [PATCH 10/15] chore: removes draft type --- .../ramps-controller/src/RampsController.ts | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 63311d9a7dd..b6cc7cc2502 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -6,7 +6,6 @@ import type { import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; import type { Json } from '@metamask/utils'; -import type { Draft } from 'immer'; import type { Country, @@ -486,7 +485,7 @@ export class RampsController extends BaseController< * @param cacheKey - The cache key to remove. */ #removeRequestState(cacheKey: string): void { - this.update((state: Draft) => { + this.update((state) => { const requests = state.requests as unknown as Record< string, RequestState | undefined @@ -496,7 +495,7 @@ export class RampsController extends BaseController< } #cleanupState(): void { - this.update((state: Draft) => { + this.update((state) => { state.userRegion = null; state.preferredProvider = null; state.tokens = null; @@ -526,7 +525,7 @@ export class RampsController extends BaseController< const maxSize = this.#requestCacheMaxSize; const ttl = this.#requestCacheTTL; - this.update((state: Draft) => { + this.update((state) => { const requests = state.requests as unknown as Record< string, RequestState | undefined @@ -606,7 +605,7 @@ export class RampsController extends BaseController< normalizedRegion !== this.state.userRegion?.regionCode; // Set the new region atomically with cleanup to avoid intermediate null state - this.update((state: Draft) => { + this.update((state) => { if (regionChanged) { state.preferredProvider = null; state.tokens = null; @@ -639,7 +638,7 @@ export class RampsController extends BaseController< * @param provider - The provider object to set. */ setPreferredProvider(provider: Provider | null): void { - this.update((state: Draft) => { + this.update((state) => { state.preferredProvider = provider; }); } @@ -703,7 +702,7 @@ export class RampsController extends BaseController< options, ); - this.update((state: Draft) => { + this.update((state) => { state.countries = countries; }); @@ -757,7 +756,7 @@ export class RampsController extends BaseController< options, ); - this.update((state: Draft) => { + this.update((state) => { const userRegionCode = state.userRegion?.regionCode; if (userRegionCode === undefined || userRegionCode === normalizedRegion) { @@ -823,7 +822,7 @@ export class RampsController extends BaseController< options, ); - this.update((state: Draft) => { + this.update((state) => { const userRegionCode = state.userRegion?.regionCode; if (userRegionCode === undefined || userRegionCode === normalizedRegion) { @@ -892,7 +891,7 @@ export class RampsController extends BaseController< { forceRefresh: options.forceRefresh, ttl: options.ttl }, ); - this.update((state: Draft) => { + this.update((state) => { state.paymentMethods = response.payments; // Only clear selected payment method if it's no longer in the new list // This preserves the selection when cached data is returned (same context) @@ -915,7 +914,7 @@ export class RampsController extends BaseController< * @param paymentMethod - The payment method to select, or null to clear. */ setSelectedPaymentMethod(paymentMethod: PaymentMethod | null): void { - this.update((state: Draft) => { + this.update((state) => { state.selectedPaymentMethod = paymentMethod; }); } From 3576004afcb83e147799c40831645b015cc9347d Mon Sep 17 00:00:00 2001 From: George Weiler Date: Fri, 23 Jan 2026 12:28:27 -0700 Subject: [PATCH 11/15] feat: updates country interface to include buy and sell support --- .../src/RampsController.test.ts | 98 +++--- .../ramps-controller/src/RampsController.ts | 23 +- .../src/RampsService-method-action-types.ts | 2 +- .../ramps-controller/src/RampsService.test.ts | 284 ++++++------------ packages/ramps-controller/src/RampsService.ts | 37 ++- packages/ramps-controller/src/index.ts | 1 + 6 files changed, 165 insertions(+), 280 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 60f6be9961f..a797f3e4adc 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -837,7 +837,7 @@ describe('RampsController', () => { async () => createMockCountries(), ); - const result = controller.triggerGetCountries('buy'); + const result = controller.triggerGetCountries(); expect(result).toBeUndefined(); }); }); @@ -944,7 +944,7 @@ describe('RampsController', () => { template: '(XXX) XXX-XXXX', }, currency: 'USD', - supported: true, + supported: { buy: true, sell: true }, recommended: true, }, { @@ -957,7 +957,7 @@ describe('RampsController', () => { template: 'XXX XXXXXXX', }, currency: 'EUR', - supported: true, + supported: { buy: true, sell: false }, }, ]; @@ -970,7 +970,7 @@ describe('RampsController', () => { expect(controller.state.countries).toStrictEqual([]); - const countries = await controller.getCountries('buy'); + const countries = await controller.getCountries(); expect(countries).toMatchInlineSnapshot(` Array [ @@ -985,7 +985,10 @@ describe('RampsController', () => { "template": "(XXX) XXX-XXXX", }, "recommended": true, - "supported": true, + "supported": Object { + "buy": true, + "sell": true, + }, }, Object { "currency": "EUR", @@ -997,7 +1000,10 @@ describe('RampsController', () => { "prefix": "+43", "template": "XXX XXXXXXX", }, - "supported": true, + "supported": Object { + "buy": true, + "sell": false, + }, }, ] `); @@ -1016,44 +1022,10 @@ describe('RampsController', () => { }, ); - await controller.getCountries('buy'); - await controller.getCountries('buy'); - - 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?: 'buy' | 'sell') => { - 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?: 'buy' | 'sell') => { - receivedAction = action; - return mockCountries; - }, - ); - + await controller.getCountries(); await controller.getCountries(); - expect(receivedAction).toBe('buy'); + expect(callCount).toBe(1); }); }); }); @@ -1520,8 +1492,8 @@ describe('RampsController', () => { flag: 'πŸ‡ΊπŸ‡Έ', currency: 'USD', phone: { prefix: '+1', placeholder: '', template: '' }, - supported: true, - states: [{ stateId: 'CA', name: 'California', supported: true }], + supported: { buy: true, sell: true }, + states: [{ stateId: 'CA', name: 'California', supported: { buy: true, sell: true } }], }, ]; @@ -1562,7 +1534,7 @@ describe('RampsController', () => { flag: 'πŸ‡«πŸ‡·', currency: 'EUR', phone: { prefix: '+33', placeholder: '', template: '' }, - supported: true, + supported: { buy: true, sell: true }, }, ]; @@ -1601,7 +1573,7 @@ describe('RampsController', () => { flag: 'πŸ‡ΊπŸ‡Έ', currency: 'USD', phone: { prefix: '+1', placeholder: '', template: '' }, - supported: true, + supported: { buy: true, sell: true }, }, ]; @@ -1641,7 +1613,7 @@ describe('RampsController', () => { flag: 'πŸ‡«πŸ‡·', currency: 'EUR', phone: { prefix: '+33', placeholder: '', template: '' }, - supported: true, + supported: { buy: true, sell: true }, }, ]; @@ -1703,12 +1675,12 @@ describe('RampsController', () => { flag: 'πŸ‡ΊπŸ‡Έ', currency: 'USD', phone: { prefix: '+1', placeholder: '', template: '' }, - supported: true, + supported: { buy: true, sell: true }, states: [ { id: '/regions/us-ny', name: 'New York', - supported: true, + supported: { buy: true, sell: true }, }, ], }, @@ -1749,12 +1721,12 @@ describe('RampsController', () => { flag: 'πŸ‡ΊπŸ‡Έ', currency: 'USD', phone: { prefix: '+1', placeholder: '', template: '' }, - supported: true, + supported: { buy: true, sell: true }, states: [ { id: '/some/path/ca', name: 'California', - supported: true, + supported: { buy: true, sell: true }, }, ], }, @@ -1795,10 +1767,10 @@ describe('RampsController', () => { flag: 'πŸ‡ΊπŸ‡Έ', currency: 'USD', phone: { prefix: '+1', placeholder: '', template: '' }, - supported: true, + supported: { buy: true, sell: true }, states: [ - { stateId: 'CA', name: 'California', supported: true }, - { stateId: 'NY', name: 'New York', supported: true }, + { stateId: 'CA', name: 'California', supported: { buy: true, sell: true } }, + { stateId: 'NY', name: 'New York', supported: { buy: true, sell: true } }, ], }, ]; @@ -2577,7 +2549,7 @@ describe('RampsController', () => { flag: 'πŸ‡ΊπŸ‡Έ', currency: undefined as unknown as string, phone: { prefix: '+1', placeholder: '', template: '' }, - supported: true, + supported: { buy: true, sell: true }, }, state: null, regionCode: 'us', @@ -2732,13 +2704,13 @@ function createMockUserRegion( flag: '🏳️', currency: 'USD', phone: { prefix: '+1', placeholder: '', template: '' }, - supported: true, + supported: { buy: true, sell: true }, ...(stateCode && { states: [ { stateId: stateCode.toUpperCase(), name: stateName ?? `State ${stateCode.toUpperCase()}`, - supported: true, + supported: { buy: true, sell: true }, }, ], }), @@ -2748,7 +2720,7 @@ function createMockUserRegion( ? { stateId: stateCode.toUpperCase(), name: stateName ?? `State ${stateCode.toUpperCase()}`, - supported: true, + supported: { buy: true, sell: true }, } : null; @@ -2772,11 +2744,11 @@ function createMockCountries(): Country[] { flag: 'πŸ‡ΊπŸ‡Έ', currency: 'USD', phone: { prefix: '+1', placeholder: '', template: '' }, - supported: true, + supported: { buy: true, sell: true }, states: [ - { stateId: 'CA', name: 'California', supported: true }, - { stateId: 'NY', name: 'New York', supported: true }, - { stateId: 'UT', name: 'Utah', supported: true }, + { stateId: 'CA', name: 'California', supported: { buy: true, sell: true } }, + { stateId: 'NY', name: 'New York', supported: { buy: true, sell: true } }, + { stateId: 'UT', name: 'Utah', supported: { buy: true, sell: true } }, ], }, { @@ -2785,7 +2757,7 @@ function createMockCountries(): Country[] { flag: 'πŸ‡«πŸ‡·', currency: 'EUR', phone: { prefix: '+33', placeholder: '', template: '' }, - supported: true, + supported: { buy: true, sell: true }, }, ]; } diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index b6cc7cc2502..1ea76631fbd 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -654,7 +654,7 @@ export class RampsController extends BaseController< * @returns Promise that resolves when initialization is complete. */ async init(options?: ExecuteRequestOptions): Promise { - await this.getCountries('buy', options); + await this.getCountries(options); let regionCode = this.state.userRegion?.regionCode; regionCode ??= await this.messenger.call('RampsService:getGeolocation'); @@ -681,23 +681,20 @@ export class RampsController extends BaseController< } /** - * Fetches the list of supported countries for a given ramp action. + * Fetches the list of supported countries. + * The API returns countries with support information for both buy and sell actions. * The countries are saved in the controller state once fetched. * - * @param action - The ramp action type ('buy' or 'sell'). * @param options - Options for cache behavior. * @returns An array of countries. */ - async getCountries( - action: RampAction = 'buy', - options?: ExecuteRequestOptions, - ): Promise { - const cacheKey = createCacheKey('getCountries', [action]); + async getCountries(options?: ExecuteRequestOptions): Promise { + const cacheKey = createCacheKey('getCountries', []); const countries = await this.executeRequest( cacheKey, async () => { - return this.messenger.call('RampsService:getCountries', action); + return this.messenger.call('RampsService:getCountries'); }, options, ); @@ -940,14 +937,10 @@ export class RampsController extends BaseController< /** * Triggers fetching countries without throwing. * - * @param action - The ramp action type ('buy' or 'sell'). * @param options - Options for cache behavior. */ - triggerGetCountries( - action: 'buy' | 'sell' = 'buy', - options?: ExecuteRequestOptions, - ): void { - this.getCountries(action, options).catch(() => { + triggerGetCountries(options?: ExecuteRequestOptions): void { + this.getCountries(options).catch(() => { // Error stored in state }); } diff --git a/packages/ramps-controller/src/RampsService-method-action-types.ts b/packages/ramps-controller/src/RampsService-method-action-types.ts index e156498638f..0c74225f37a 100644 --- a/packages/ramps-controller/src/RampsService-method-action-types.ts +++ b/packages/ramps-controller/src/RampsService-method-action-types.ts @@ -18,9 +18,9 @@ export type RampsServiceGetGeolocationAction = { /** * Makes a request to the cached API to retrieve the list of supported countries. + * The API returns countries with support information for both buy and sell actions. * 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. */ export type RampsServiceGetCountriesAction = { diff --git a/packages/ramps-controller/src/RampsService.test.ts b/packages/ramps-controller/src/RampsService.test.ts index fef587c3e8e..6741eb35d5e 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -256,7 +256,7 @@ describe('RampsService', () => { template: '(XXX) XXX-XXXX', }, currency: 'USD', - supported: true, + supported: { buy: true, sell: true }, recommended: true, }, { @@ -269,7 +269,7 @@ describe('RampsService', () => { template: 'XXX XXXXXXX', }, currency: 'EUR', - supported: true, + supported: { buy: true, sell: false }, }, ]; @@ -277,7 +277,6 @@ describe('RampsService', () => { nock('https://on-ramp-cache.uat-api.cx.metamask.io') .get('/v2/regions/countries') .query({ - action: 'buy', sdk: '2.1.6', controller: CONTROLLER_VERSION, context: 'mobile-ios', @@ -285,10 +284,7 @@ describe('RampsService', () => { .reply(200, mockCountriesResponse); const { rootMessenger } = getService(); - const countriesPromise = rootMessenger.call( - 'RampsService:getCountries', - 'buy', - ); + const countriesPromise = rootMessenger.call('RampsService:getCountries'); await clock.runAllAsync(); await flushPromises(); const countriesResponse = await countriesPromise; @@ -306,7 +302,10 @@ describe('RampsService', () => { "template": "(XXX) XXX-XXXX", }, "recommended": true, - "supported": true, + "supported": Object { + "buy": true, + "sell": true, + }, }, Object { "currency": "EUR", @@ -318,7 +317,10 @@ describe('RampsService', () => { "prefix": "+43", "template": "XXX XXXXXXX", }, - "supported": true, + "supported": Object { + "buy": true, + "sell": false, + }, }, ] `); @@ -328,7 +330,6 @@ describe('RampsService', () => { nock('https://on-ramp-cache.api.cx.metamask.io') .get('/v2/regions/countries') .query({ - action: 'buy', sdk: '2.1.6', controller: CONTROLLER_VERSION, context: 'mobile-ios', @@ -338,10 +339,7 @@ describe('RampsService', () => { options: { environment: RampsEnvironment.Production }, }); - const countriesPromise = rootMessenger.call( - 'RampsService:getCountries', - 'buy', - ); + const countriesPromise = rootMessenger.call('RampsService:getCountries'); await clock.runAllAsync(); await flushPromises(); const countriesResponse = await countriesPromise; @@ -359,7 +357,10 @@ describe('RampsService', () => { "template": "(XXX) XXX-XXXX", }, "recommended": true, - "supported": true, + "supported": Object { + "buy": true, + "sell": true, + }, }, Object { "currency": "EUR", @@ -371,7 +372,10 @@ describe('RampsService', () => { "prefix": "+43", "template": "XXX XXXXXXX", }, - "supported": true, + "supported": Object { + "buy": true, + "sell": false, + }, }, ] `); @@ -381,7 +385,6 @@ describe('RampsService', () => { nock('https://on-ramp-cache.uat-api.cx.metamask.io') .get('/v2/regions/countries') .query({ - action: 'buy', sdk: '2.1.6', controller: CONTROLLER_VERSION, context: 'mobile-ios', @@ -391,10 +394,7 @@ describe('RampsService', () => { options: { environment: RampsEnvironment.Development }, }); - const countriesPromise = rootMessenger.call( - 'RampsService:getCountries', - 'buy', - ); + const countriesPromise = rootMessenger.call('RampsService:getCountries'); await clock.runAllAsync(); await flushPromises(); const countriesResponse = await countriesPromise; @@ -412,66 +412,10 @@ describe('RampsService', () => { "template": "(XXX) XXX-XXXX", }, "recommended": true, - "supported": true, - }, - Object { - "currency": "EUR", - "flag": "πŸ‡¦πŸ‡Ή", - "isoCode": "AT", - "name": "Austria", - "phone": Object { - "placeholder": "660 1234567", - "prefix": "+43", - "template": "XXX XXXXXXX", + "supported": Object { + "buy": true, + "sell": true, }, - "supported": true, - }, - ] - `); - }); - - it('passes the action parameter correctly', async () => { - nock('https://on-ramp-cache.uat-api.cx.metamask.io') - .get('/v2/regions/countries') - .query({ - action: 'sell', - sdk: '2.1.6', - controller: CONTROLLER_VERSION, - context: 'mobile-ios', - }) - .reply(200, mockCountriesResponse); - nock('https://on-ramp.uat-api.cx.metamask.io') - .get('/geolocation') - .query({ - sdk: '2.1.6', - controller: CONTROLLER_VERSION, - context: 'mobile-ios', - }) - .reply(200, 'us'); - const { rootMessenger } = getService(); - - const countriesPromise = rootMessenger.call( - 'RampsService:getCountries', - 'sell', - ); - await clock.runAllAsync(); - await flushPromises(); - const countriesResponse = await countriesPromise; - - 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, }, Object { "currency": "EUR", @@ -483,13 +427,16 @@ describe('RampsService', () => { "prefix": "+43", "template": "XXX XXXXXXX", }, - "supported": true, + "supported": Object { + "buy": true, + "sell": false, + }, }, ] `); }); - it('includes country with unsupported country but supported state for sell action', async () => { + it('includes country with unsupported country but supported state', async () => { const mockCountriesWithUnsupportedCountry = [ { isoCode: 'US', @@ -498,19 +445,19 @@ describe('RampsService', () => { name: 'United States', phone: { prefix: '+1', placeholder: '', template: '' }, currency: 'USD', - supported: false, + supported: { buy: false, sell: false }, states: [ { id: '/regions/us-tx', stateId: 'TX', name: 'Texas', - supported: true, + supported: { buy: true, sell: true }, }, { id: '/regions/us-ny', stateId: 'NY', name: 'New York', - supported: false, + supported: { buy: false, sell: false }, }, ], }, @@ -518,7 +465,6 @@ describe('RampsService', () => { nock('https://on-ramp-cache.uat-api.cx.metamask.io') .get('/v2/regions/countries') .query({ - action: 'sell', sdk: '2.1.6', controller: CONTROLLER_VERSION, context: 'mobile-ios', @@ -526,19 +472,25 @@ describe('RampsService', () => { .reply(200, mockCountriesWithUnsupportedCountry); const { service } = getService(); - const countriesPromise = service.getCountries('sell'); + const countriesPromise = service.getCountries(); await clock.runAllAsync(); await flushPromises(); const countriesResponse = await countriesPromise; expect(countriesResponse).toHaveLength(1); expect(countriesResponse[0]?.isoCode).toBe('US'); - expect(countriesResponse[0]?.supported).toBe(false); - expect(countriesResponse[0]?.states?.[0]?.supported).toBe(true); + expect(countriesResponse[0]?.supported).toStrictEqual({ + buy: false, + sell: false, + }); + expect(countriesResponse[0]?.states?.[0]?.supported).toStrictEqual({ + buy: true, + sell: true, + }); }); - it('includes country with unsupported country but supported state for buy action', async () => { - const mockCountriesWithUnsupportedCountry = [ + it('filters out countries with no supported actions', async () => { + const mockCountriesWithNoSupport = [ { isoCode: 'US', id: '/regions/us', @@ -546,49 +498,41 @@ describe('RampsService', () => { 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, - }, - ], + supported: { buy: true, sell: false }, + }, + { + isoCode: 'XX', + id: '/regions/xx', + flag: '🏳️', + name: 'Unsupported Country', + phone: { prefix: '+0', placeholder: '', template: '' }, + currency: 'XXX', + supported: { buy: false, sell: false }, }, ]; nock('https://on-ramp-cache.uat-api.cx.metamask.io') .get('/v2/regions/countries') .query({ - action: 'buy', sdk: '2.1.6', controller: CONTROLLER_VERSION, context: 'mobile-ios', }) - .reply(200, mockCountriesWithUnsupportedCountry); + .reply(200, mockCountriesWithNoSupport); const { service } = getService(); - const countriesPromise = service.getCountries('buy'); + const countriesPromise = service.getCountries(); await clock.runAllAsync(); await flushPromises(); const countriesResponse = await countriesPromise; 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('/v2/regions/countries') .query({ - action: 'buy', sdk: '2.1.6', controller: CONTROLLER_VERSION, context: 'mobile-ios', @@ -600,14 +544,11 @@ describe('RampsService', () => { clock.nextAsync().catch(() => undefined); }); - const countriesPromise = rootMessenger.call( - 'RampsService:getCountries', - 'buy', - ); + const countriesPromise = rootMessenger.call('RampsService:getCountries'); await clock.runAllAsync(); await flushPromises(); await expect(countriesPromise).rejects.toThrow( - `Fetching 'https://on-ramp-cache.uat-api.cx.metamask.io/v2/regions/countries?action=buy&sdk=2.1.6&controller=${CONTROLLER_VERSION}&context=mobile-ios' failed with status '500'`, + `Fetching 'https://on-ramp-cache.uat-api.cx.metamask.io/v2/regions/countries?sdk=2.1.6&controller=${CONTROLLER_VERSION}&context=mobile-ios' failed with status '500'`, ); }); @@ -615,7 +556,6 @@ describe('RampsService', () => { nock('https://on-ramp-cache.uat-api.cx.metamask.io') .get('/v2/regions/countries') .query({ - action: 'buy', sdk: '2.1.6', controller: CONTROLLER_VERSION, context: 'mobile-ios', @@ -623,10 +563,7 @@ describe('RampsService', () => { .reply(200, () => null); const { rootMessenger } = getService(); - const countriesPromise = rootMessenger.call( - 'RampsService:getCountries', - 'buy', - ); + const countriesPromise = rootMessenger.call('RampsService:getCountries'); await clock.runAllAsync(); await flushPromises(); await expect(countriesPromise).rejects.toThrow( @@ -638,7 +575,6 @@ describe('RampsService', () => { nock('https://on-ramp-cache.uat-api.cx.metamask.io') .get('/v2/regions/countries') .query({ - action: 'buy', sdk: '2.1.6', controller: CONTROLLER_VERSION, context: 'mobile-ios', @@ -646,10 +582,7 @@ describe('RampsService', () => { .reply(200, { error: 'Something went wrong' }); const { rootMessenger } = getService(); - const countriesPromise = rootMessenger.call( - 'RampsService:getCountries', - 'buy', - ); + const countriesPromise = rootMessenger.call('RampsService:getCountries'); await clock.runAllAsync(); await flushPromises(); await expect(countriesPromise).rejects.toThrow( @@ -667,29 +600,20 @@ describe('RampsService', () => { name: 'United States', phone: { prefix: '+1', placeholder: '', template: '' }, currency: 'USD', - supported: true, + supported: { buy: true, sell: true }, }, ]; nock('https://on-ramp-cache.uat-api.cx.metamask.io') .get('/v2/regions/countries') .query({ - action: 'buy', sdk: '2.1.6', controller: CONTROLLER_VERSION, context: 'mobile-ios', }) .reply(200, mockCountries); - nock('https://on-ramp.uat-api.cx.metamask.io') - .get('/geolocation') - .query({ - sdk: '2.1.6', - controller: CONTROLLER_VERSION, - context: 'mobile-ios', - }) - .reply(200, 'us'); const { service } = getService(); - const countriesPromise = service.getCountries('buy'); + const countriesPromise = service.getCountries(); await clock.runAllAsync(); await flushPromises(); const countriesResponse = await countriesPromise; @@ -706,50 +630,15 @@ describe('RampsService', () => { "prefix": "+1", "template": "", }, - "supported": true, + "supported": Object { + "buy": true, + "sell": true, + }, }, ] `); }); - 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('/v2/regions/countries') - .query({ - action: 'buy', - sdk: '2.1.6', - controller: CONTROLLER_VERSION, - context: 'mobile-ios', - }) - .reply(200, mockCountries); - nock('https://on-ramp.uat-api.cx.metamask.io') - .get('/geolocation') - .query({ - sdk: '2.1.6', - controller: CONTROLLER_VERSION, - context: 'mobile-ios', - }) - .reply(200, 'us'); - const { service } = getService(); - - const countriesPromise = service.getCountries(); - await clock.runAllAsync(); - await flushPromises(); - const countriesResponse = await countriesPromise; - - expect(countriesResponse[0]?.isoCode).toBe('US'); - }); - it('filters countries with states by support', async () => { const mockCountriesWithStates = [ { @@ -763,19 +652,19 @@ describe('RampsService', () => { template: '(XXX) XXX-XXXX', }, currency: 'USD', - supported: true, + supported: { buy: true, sell: true }, states: [ { id: '/regions/us-tx', stateId: 'TX', name: 'Texas', - supported: true, + supported: { buy: true, sell: true }, }, { id: '/regions/us-ny', stateId: 'NY', name: 'New York', - supported: false, + supported: { buy: false, sell: false }, }, ], }, @@ -783,7 +672,6 @@ describe('RampsService', () => { nock('https://on-ramp-cache.uat-api.cx.metamask.io') .get('/v2/regions/countries') .query({ - action: 'buy', sdk: '2.1.6', controller: CONTROLLER_VERSION, context: 'mobile-ios', @@ -791,14 +679,23 @@ describe('RampsService', () => { .reply(200, mockCountriesWithStates); const { service } = getService(); - const countriesPromise = service.getCountries('buy'); + const countriesPromise = service.getCountries(); await clock.runAllAsync(); await flushPromises(); const countriesResponse = await countriesPromise; - expect(countriesResponse[0]?.supported).toBe(true); - expect(countriesResponse[0]?.states?.[0]?.supported).toBe(true); - expect(countriesResponse[0]?.states?.[1]?.supported).toBe(false); + expect(countriesResponse[0]?.supported).toStrictEqual({ + buy: true, + sell: true, + }); + expect(countriesResponse[0]?.states?.[0]?.supported).toStrictEqual({ + buy: true, + sell: true, + }); + expect(countriesResponse[0]?.states?.[1]?.supported).toStrictEqual({ + buy: false, + sell: false, + }); }); it('filters countries with states correctly', async () => { @@ -810,13 +707,13 @@ describe('RampsService', () => { name: 'United States', phone: { prefix: '+1', placeholder: '', template: '' }, currency: 'USD', - supported: true, + supported: { buy: true, sell: false }, states: [ { id: '/regions/us-tx', stateId: 'TX', name: 'Texas', - supported: true, + supported: { buy: true, sell: true }, }, ], }, @@ -824,7 +721,6 @@ describe('RampsService', () => { nock('https://on-ramp-cache.uat-api.cx.metamask.io') .get('/v2/regions/countries') .query({ - action: 'buy', sdk: '2.1.6', controller: CONTROLLER_VERSION, context: 'mobile-ios', @@ -832,13 +728,19 @@ describe('RampsService', () => { .reply(200, mockCountries); const { service } = getService(); - const countriesPromise = service.getCountries('buy'); + const countriesPromise = service.getCountries(); await clock.runAllAsync(); await flushPromises(); const countriesResponse = await countriesPromise; - expect(countriesResponse[0]?.supported).toBe(true); - expect(countriesResponse[0]?.states?.[0]?.supported).toBe(true); + expect(countriesResponse[0]?.supported).toStrictEqual({ + buy: true, + sell: false, + }); + expect(countriesResponse[0]?.states?.[0]?.supported).toStrictEqual({ + buy: true, + sell: true, + }); }); }); diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index 3e1ea36b80b..ec1f9949cd6 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -17,6 +17,20 @@ export type CountryPhone = { template: string; }; +/** + * Indicates whether a region supports buy and/or sell actions. + */ +export type SupportedActions = { + /** + * Whether buy actions are supported. + */ + buy: boolean; + /** + * Whether sell actions are supported. + */ + sell: boolean; +}; + /** * Represents a state/province within a country. */ @@ -34,9 +48,9 @@ export type State = { */ stateId?: string; /** - * Whether this state is supported for ramps. + * Whether this state is supported for buy and/or sell ramp actions. */ - supported?: boolean; + supported?: SupportedActions; /** * Whether this state is recommended. */ @@ -159,9 +173,9 @@ export type Country = { */ currency: string; /** - * Whether this country is supported for ramps. + * Whether this country is supported for buy and/or sell ramp actions. */ - supported: boolean; + supported: SupportedActions; /** * Whether this country is recommended. */ @@ -588,16 +602,16 @@ export class RampsService { /** * Makes a request to the cached API to retrieve the list of supported countries. + * The API returns countries with support information for both buy and sell actions. * 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. */ - async getCountries(action: RampAction = 'buy'): Promise { + async getCountries(): Promise { const countries = await this.#request( RampsApiService.Regions, getApiPath('regions/countries'), - { action, responseType: 'json' }, + { responseType: 'json' }, ); if (!Array.isArray(countries)) { @@ -605,14 +619,17 @@ export class RampsService { } return countries.filter((country) => { + const isCountrySupported = + country.supported.buy || country.supported.sell; + if (country.states && country.states.length > 0) { const hasSupportedState = country.states.some( - (state) => state.supported !== false, + (state) => state.supported?.buy || state.supported?.sell, ); - return country.supported || hasSupportedState; + return isCountrySupported || hasSupportedState; } - return country.supported; + return isCountrySupported; }); } diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 82158b4c5af..7649f417d90 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -18,6 +18,7 @@ export type { RampsServiceMessenger, Country, State, + SupportedActions, CountryPhone, Provider, ProviderLink, From 46bcc52747cfe7316093be064458a15c75c087cf Mon Sep 17 00:00:00 2001 From: George Weiler Date: Fri, 23 Jan 2026 12:36:29 -0700 Subject: [PATCH 12/15] chore: channgelog updates --- packages/ramps-controller/CHANGELOG.md | 3 ++ .../src/RampsController.test.ts | 32 ++++++++++++++++--- packages/ramps-controller/src/RampsService.ts | 2 +- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 909f4f2588a..44131aa439f 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -11,10 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `hydrateState()` method to fetch providers and tokens for user region ([#7707](https://github.com/MetaMask/core/pull/7707)) - Add `countries` state to RampsController with 24 hour TTL caching ([#7707](https://github.com/MetaMask/core/pull/7707)) +- Add `SupportedActions` type for `{ buy: boolean; sell: boolean }` support info ### Changed - Reorganize `init()` to only fetch geolocation and countries; remove token and provider fetching ([#7707](https://github.com/MetaMask/core/pull/7707)) +- **BREAKING:** Change `Country.supported` and `State.supported` from `boolean` to `SupportedActions` object. The API now returns buy/sell support info in a single call. +- **BREAKING:** Remove `action` parameter from `getCountries()`. Countries are no longer fetched separately for buy/sell actions. ## [4.1.0] diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index a797f3e4adc..0a3158f618f 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -1493,7 +1493,13 @@ describe('RampsController', () => { currency: 'USD', phone: { prefix: '+1', placeholder: '', template: '' }, supported: { buy: true, sell: true }, - states: [{ stateId: 'CA', name: 'California', supported: { buy: true, sell: true } }], + states: [ + { + stateId: 'CA', + name: 'California', + supported: { buy: true, sell: true }, + }, + ], }, ]; @@ -1769,8 +1775,16 @@ describe('RampsController', () => { phone: { prefix: '+1', placeholder: '', template: '' }, supported: { buy: true, sell: true }, states: [ - { stateId: 'CA', name: 'California', supported: { buy: true, sell: true } }, - { stateId: 'NY', name: 'New York', supported: { buy: true, sell: true } }, + { + stateId: 'CA', + name: 'California', + supported: { buy: true, sell: true }, + }, + { + stateId: 'NY', + name: 'New York', + supported: { buy: true, sell: true }, + }, ], }, ]; @@ -2746,8 +2760,16 @@ function createMockCountries(): Country[] { phone: { prefix: '+1', placeholder: '', template: '' }, supported: { buy: true, sell: true }, states: [ - { stateId: 'CA', name: 'California', supported: { buy: true, sell: true } }, - { stateId: 'NY', name: 'New York', supported: { buy: true, sell: true } }, + { + stateId: 'CA', + name: 'California', + supported: { buy: true, sell: true }, + }, + { + stateId: 'NY', + name: 'New York', + supported: { buy: true, sell: true }, + }, { stateId: 'UT', name: 'Utah', supported: { buy: true, sell: true } }, ], }, diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index ec1f9949cd6..fd82b13bfdc 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -624,7 +624,7 @@ export class RampsService { if (country.states && country.states.length > 0) { const hasSupportedState = country.states.some( - (state) => state.supported?.buy || state.supported?.sell, + (state) => state.supported?.buy ?? state.supported?.sell, ); return isCountrySupported || hasSupportedState; } From 74a0e278fae176d5610bc822cb135948cc0afbbc Mon Sep 17 00:00:00 2001 From: George Weiler Date: Fri, 23 Jan 2026 12:46:58 -0700 Subject: [PATCH 13/15] chore: test cov --- .../ramps-controller/src/RampsService.test.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/packages/ramps-controller/src/RampsService.test.ts b/packages/ramps-controller/src/RampsService.test.ts index 6741eb35d5e..eab09c9fe33 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -742,6 +742,47 @@ describe('RampsService', () => { sell: true, }); }); + + it('includes country when state has undefined buy but truthy sell', async () => { + const mockCountries = [ + { + isoCode: 'US', + id: '/regions/us', + flag: 'πŸ‡ΊπŸ‡Έ', + name: 'United States', + phone: { prefix: '+1', placeholder: '', template: '' }, + currency: 'USD', + supported: { buy: false, sell: false }, + states: [ + { + id: '/regions/us-tx', + stateId: 'TX', + name: 'Texas', + supported: { sell: true }, + }, + ], + }, + ]; + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/v2/regions/countries') + .query({ + sdk: '2.1.6', + controller: CONTROLLER_VERSION, + context: 'mobile-ios', + }) + .reply(200, mockCountries); + const { service } = getService(); + + const countriesPromise = service.getCountries(); + await clock.runAllAsync(); + await flushPromises(); + const countriesResponse = await countriesPromise; + + expect(countriesResponse).toHaveLength(1); + expect(countriesResponse[0]?.states?.[0]?.supported).toStrictEqual({ + sell: true, + }); + }); }); describe('getTokens', () => { From 96f0718cd23b6b5540d710690f4de9eec7fa18eb Mon Sep 17 00:00:00 2001 From: George Weiler Date: Fri, 23 Jan 2026 14:00:41 -0700 Subject: [PATCH 14/15] chore: bugbot --- 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 fd82b13bfdc..ec1f9949cd6 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -624,7 +624,7 @@ export class RampsService { if (country.states && country.states.length > 0) { const hasSupportedState = country.states.some( - (state) => state.supported?.buy ?? state.supported?.sell, + (state) => state.supported?.buy || state.supported?.sell, ); return isCountrySupported || hasSupportedState; } From 556edd5fe2da869cd435a37233168934eb058cdd Mon Sep 17 00:00:00 2001 From: George Weiler Date: Fri, 23 Jan 2026 14:16:25 -0700 Subject: [PATCH 15/15] chore: ts-ignore --- packages/ramps-controller/src/RampsService.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index ec1f9949cd6..5f9104b72a7 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -624,6 +624,7 @@ export class RampsService { if (country.states && country.states.length > 0) { const hasSupportedState = country.states.some( + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- intentionally using || to treat false as unsupported (state) => state.supported?.buy || state.supported?.sell, ); return isCountrySupported || hasSupportedState;