From ac7f11b59a34445329479ea31c0ae87fcd70f96e Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 22 Jan 2026 20:06:19 -0700 Subject: [PATCH 01/12] 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/12] 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 b97172e876c4a9ef17ea3135b6d89471797e0989 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 22 Jan 2026 21:26:24 -0700 Subject: [PATCH 03/12] feat: adds doNotUpdateState option to the controller --- .../src/RampsController.test.ts | 104 ++++++++++++++++++ .../ramps-controller/src/RampsController.ts | 79 +++++++------ packages/ramps-controller/src/RequestCache.ts | 2 + 3 files changed, 152 insertions(+), 33 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 8a1786e61e5..74d9e9f37ae 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -365,6 +365,24 @@ describe('RampsController', () => { ); }); }); + + it('does not update state when doNotUpdateState is true', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getProviders', + async () => ({ providers: mockProviders }), + ); + + expect(controller.state.providers).toStrictEqual([]); + + const result = await controller.getProviders('us', { + doNotUpdateState: true, + }); + + expect(result.providers).toStrictEqual(mockProviders); + expect(controller.state.providers).toStrictEqual([]); + }); + }); }); describe('metadata', () => { @@ -1057,6 +1075,42 @@ describe('RampsController', () => { expect(receivedAction).toBe('buy'); }); }); + + it('does not update state when doNotUpdateState is true', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async () => mockCountries, + ); + + expect(controller.state.countries).toStrictEqual([]); + + const countries = await controller.getCountries('buy', { + doNotUpdateState: true, + }); + + expect(countries).toStrictEqual(mockCountries); + expect(controller.state.countries).toStrictEqual([]); + }); + }); + + it('still updates request cache when doNotUpdateState is true', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async () => mockCountries, + ); + + await controller.getCountries('buy', { doNotUpdateState: true }); + + const cacheKey = createCacheKey('getCountries', ['buy']); + const requestState = controller.getRequestState(cacheKey); + + expect(requestState).toBeDefined(); + expect(requestState?.status).toBe(RequestStatus.SUCCESS); + expect(requestState?.data).toStrictEqual(mockCountries); + }); + }); }); describe('init', () => { @@ -2127,6 +2181,24 @@ describe('RampsController', () => { expect(callCount).toBe(2); }); }); + + it('does not update state when doNotUpdateState is true', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async () => mockTokens, + ); + + expect(controller.state.tokens).toBeNull(); + + const tokens = await controller.getTokens('us', 'buy', { + doNotUpdateState: true, + }); + + expect(tokens).toStrictEqual(mockTokens); + expect(controller.state.tokens).toBeNull(); + }); + }); }); describe('getPaymentMethods', () => { @@ -2378,6 +2450,38 @@ describe('RampsController', () => { }, ); }); + + it('does not update state when doNotUpdateState is true', async () => { + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us'), + }, + }, + }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getPaymentMethods', + async () => mockPaymentMethodsResponse, + ); + + expect(controller.state.paymentMethods).toStrictEqual([]); + + const response = await controller.getPaymentMethods({ + assetId: 'eip155:1/slip44:60', + provider: '/providers/stripe', + doNotUpdateState: true, + }); + + expect(response.payments).toStrictEqual([ + mockPaymentMethod1, + mockPaymentMethod2, + ]); + expect(controller.state.paymentMethods).toStrictEqual([]); + }, + ); + }); }); describe('setSelectedPaymentMethod', () => { diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 31f7d7a4fc3..77779889971 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -679,14 +679,15 @@ export class RampsController extends BaseController< cacheKey, async () => { return this.messenger.call('RampsService:getCountries', action); - }, - options, - ); + }, + options, + ); - - this.update((state: Draft) => { - state.countries = countries; - }); + if (!options?.doNotUpdateState) { + this.update((state: Draft) => { + state.countries = countries; + }); + } return countries; } @@ -738,13 +739,15 @@ export class RampsController extends BaseController< options, ); - this.update((state: Draft) => { - const userRegionCode = state.userRegion?.regionCode; + if (!options?.doNotUpdateState) { + this.update((state: Draft) => { + const userRegionCode = state.userRegion?.regionCode; - if (userRegionCode === undefined || userRegionCode === normalizedRegion) { - state.tokens = tokens; - } - }); + if (userRegionCode === undefined || userRegionCode === normalizedRegion) { + state.tokens = tokens; + } + }); + } return tokens; } @@ -804,13 +807,15 @@ export class RampsController extends BaseController< options, ); - this.update((state: Draft) => { - const userRegionCode = state.userRegion?.regionCode; + if (!options?.doNotUpdateState) { + this.update((state: Draft) => { + const userRegionCode = state.userRegion?.regionCode; - if (userRegionCode === undefined || userRegionCode === normalizedRegion) { - state.providers = providers; - } - }); + if (userRegionCode === undefined || userRegionCode === normalizedRegion) { + state.providers = providers; + } + }); + } return { providers }; } @@ -835,6 +840,7 @@ export class RampsController extends BaseController< provider: string; forceRefresh?: boolean; ttl?: number; + doNotUpdateState?: boolean; }): Promise { const regionToUse = options.region ?? this.state.userRegion?.regionCode; const fiatToUse = options.fiat ?? this.state.userRegion?.country?.currency; @@ -870,22 +876,28 @@ export class RampsController extends BaseController< provider: options.provider, }); }, - { forceRefresh: options.forceRefresh, ttl: options.ttl }, + { + forceRefresh: options.forceRefresh, + ttl: options.ttl, + doNotUpdateState: options.doNotUpdateState, + }, ); - 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: PaymentMethod) => pm.id === state.selectedPaymentMethod?.id, - ) - ) { - state.selectedPaymentMethod = null; - } - }); + if (!options?.doNotUpdateState) { + 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: PaymentMethod) => pm.id === state.selectedPaymentMethod?.id, + ) + ) { + state.selectedPaymentMethod = null; + } + }); + } return response; } @@ -989,6 +1001,7 @@ export class RampsController extends BaseController< provider: string; forceRefresh?: boolean; ttl?: number; + doNotUpdateState?: boolean; }): void { this.getPaymentMethods(options).catch(() => { // Error stored in state diff --git a/packages/ramps-controller/src/RequestCache.ts b/packages/ramps-controller/src/RequestCache.ts index 7abcea71727..7c2030d7303 100644 --- a/packages/ramps-controller/src/RequestCache.ts +++ b/packages/ramps-controller/src/RequestCache.ts @@ -135,6 +135,8 @@ export type ExecuteRequestOptions = { forceRefresh?: boolean; /** Custom TTL for this request in milliseconds */ ttl?: number; + /** If true, skip updating controller state (but still update request cache for deduplication) */ + doNotUpdateState?: boolean; }; /** From 03aa3c0625c61e2d0c83860ba44b802b269bac3d Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 22 Jan 2026 21:44:14 -0700 Subject: [PATCH 04/12] 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 05/12] 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 06/12] 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 07/12] 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 08/12] 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 09/12] 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 10/12] 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 11/12] 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 910d8769ff6d2f88aa994a6432364e05832be447 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Fri, 23 Jan 2026 16:26:57 -0700 Subject: [PATCH 12/12] chore: changelog --- packages/ramps-controller/CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 44131aa439f..5444a504222 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -9,9 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `doNotUpdateState` option to `ExecuteRequestOptions` to allow external consumers to use controller methods without updating state ([#7708](https://github.com/MetaMask/core/pull/7708)) - 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 +- Add `SupportedActions` type for `{ buy: boolean; sell: boolean }` support info ([#7707](https://github.com/MetaMask/core/pull/7707)) ### Changed