From ac7f11b59a34445329479ea31c0ae87fcd70f96e Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 22 Jan 2026 20:06:19 -0700 Subject: [PATCH 01/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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 2200d9e12de37d9a274309dc018309f563d59ecb Mon Sep 17 00:00:00 2001 From: George Weiler Date: Fri, 23 Jan 2026 10:09:25 -0700 Subject: [PATCH 11/16] feat: adds payment method to controller state --- .../src/RampsController.test.ts | 2 + .../ramps-controller/src/RampsController.ts | 147 ++++++++++++++++-- 2 files changed, 136 insertions(+), 13 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 71325cca615..624359c9c60 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -37,6 +37,7 @@ describe('RampsController', () => { "providers": Array [], "requests": Object {}, "selectedPaymentMethod": null, + "selectedToken": null, "tokens": null, "userRegion": null, } @@ -70,6 +71,7 @@ describe('RampsController', () => { "providers": Array [], "requests": Object {}, "selectedPaymentMethod": null, + "selectedToken": null, "tokens": null, "userRegion": null, } diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 8f73fa1bd46..57617e9d284 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, @@ -16,6 +15,7 @@ import type { RampAction, PaymentMethod, PaymentMethodsResponse, + RampsToken, } from './RampsService'; import type { RampsServiceGetGeolocationAction, @@ -109,6 +109,11 @@ export type RampsControllerState = { * Can be manually set by the user. */ selectedPaymentMethod: PaymentMethod | null; + /** + * The user's selected token. + * When set, automatically fetches and sets payment methods for that token. + */ + selectedToken: RampsToken | null; /** * Cache of request states, keyed by cache key. * This stores loading, success, and error states for API requests. @@ -162,6 +167,12 @@ const rampsControllerMetadata = { includeInStateLogs: true, usedInUi: true, }, + selectedToken: { + persist: true, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: true, + }, requests: { persist: false, includeInDebugSnapshot: true, @@ -187,6 +198,7 @@ export function getDefaultRampsControllerState(): RampsControllerState { tokens: null, paymentMethods: [], selectedPaymentMethod: null, + selectedToken: null, requests: {}, }; } @@ -486,7 +498,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 +508,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 +538,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 +618,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; @@ -635,13 +647,31 @@ export class RampsController extends BaseController< /** * Sets the user's preferred provider. * This allows users to set their preferred ramp provider. + * If a token is already selected, automatically fetches payment methods for that token. * * @param provider - The provider object to set. */ setPreferredProvider(provider: Provider | null): void { - this.update((state: Draft) => { + const hadProvider = this.state.preferredProvider !== null; + this.update((state) => { state.preferredProvider = provider; }); + + // If token is selected and provider changed, fetch payment methods + if (this.state.selectedToken && provider) { + this.#fetchAndSetPaymentMethods( + provider.id, + this.state.selectedToken, + ).catch(() => { + // Error stored in state + }); + } else if (hadProvider && !provider && this.state.selectedToken) { + // Provider was cleared, clear payment methods + this.update((state) => { + state.paymentMethods = []; + state.selectedPaymentMethod = null; + }); + } } /** @@ -704,7 +734,7 @@ export class RampsController extends BaseController< ); if (!options?.doNotUpdateState) { - this.update((state: Draft) => { + this.update((state) => { state.countries = countries; }); } @@ -760,10 +790,13 @@ export class RampsController extends BaseController< ); if (!options?.doNotUpdateState) { - this.update((state: Draft) => { + this.update((state) => { const userRegionCode = state.userRegion?.regionCode; - if (userRegionCode === undefined || userRegionCode === normalizedRegion) { + if ( + userRegionCode === undefined || + userRegionCode === normalizedRegion + ) { state.tokens = tokens; } }); @@ -828,10 +861,13 @@ export class RampsController extends BaseController< ); if (!options?.doNotUpdateState) { - this.update((state: Draft) => { + this.update((state) => { const userRegionCode = state.userRegion?.regionCode; - if (userRegionCode === undefined || userRegionCode === normalizedRegion) { + if ( + userRegionCode === undefined || + userRegionCode === normalizedRegion + ) { state.providers = providers; } }); @@ -851,6 +887,7 @@ export class RampsController extends BaseController< * @param options.provider - Provider ID path. * @param options.forceRefresh - Whether to bypass cache. * @param options.ttl - Custom TTL for this request. + * @param options.doNotUpdateState - If true, does not update controller state with results. * @returns The payment methods response containing payments array. */ async getPaymentMethods(options: { @@ -904,7 +941,7 @@ export class RampsController extends BaseController< ); if (!options?.doNotUpdateState) { - 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) @@ -922,13 +959,96 @@ export class RampsController extends BaseController< return response; } + /** + * Sets the user's selected token. + * Automatically fetches and sets payment methods for that token, and auto-selects the first one. + * + * @param token - The token object, or null to clear. + * @param options - Options for cache behavior. + */ + async setSelectedToken( + token: RampsToken | null, + options?: ExecuteRequestOptions, + ): Promise { + this.update((state) => { + state.selectedToken = token; + }); + + if (!token) { + // Clear payment methods when token is cleared + this.update((state) => { + state.paymentMethods = []; + state.selectedPaymentMethod = null; + }); + return; + } + + // Automatically fetch payment methods for the selected token + const provider = this.state.preferredProvider ?? this.state.providers[0]; + if (provider) { + await this.#fetchAndSetPaymentMethods(provider.id, token, options); + } else { + // No provider available, clear payment methods + this.update((state) => { + state.paymentMethods = []; + state.selectedPaymentMethod = null; + }); + } + } + + /** + * Fetches payment methods for the given provider and token, then auto-selects the first one. + * + * @param providerId - The provider ID. + * @param token - The token object (optional, uses selectedToken if not provided). + * @param options - Options for cache behavior. + */ + async #fetchAndSetPaymentMethods( + providerId: string, + token?: RampsToken, + options?: ExecuteRequestOptions, + ): Promise { + const tokenToUse = token ?? this.state.selectedToken; + if (!tokenToUse) { + return; + } + + const { assetId } = tokenToUse; + + const regionCode = this.state.userRegion?.regionCode; + const fiatCurrency = this.state.userRegion?.country?.currency; + + if (!regionCode || !fiatCurrency) { + return; + } + + try { + const response = await this.getPaymentMethods({ + assetId, + provider: providerId, + region: regionCode, + fiat: fiatCurrency, + ...options, + }); + + // Auto-select the first payment method + if (response.payments.length > 0) { + this.update((state) => { + state.selectedPaymentMethod = response.payments[0]; + }); + } + } catch { + // Error is stored in request state, no need to throw + } + } + /** * Sets the user's selected payment method. * * @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; }); } @@ -1013,6 +1133,7 @@ export class RampsController extends BaseController< * @param options.provider - Provider ID path. * @param options.forceRefresh - Whether to bypass cache. * @param options.ttl - Custom TTL for this request. + * @param options.doNotUpdateState - If true, does not update controller state with results. */ triggerGetPaymentMethods(options: { region?: string; From 175ec88cc6a6b86555b853efcbb3dca76f29e977 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Fri, 23 Jan 2026 10:21:41 -0700 Subject: [PATCH 12/16] 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 13/16] 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 From 1a27b4aba1b066ac8f9df96d3c86adfe4f4d9f33 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Sat, 24 Jan 2026 14:07:18 -0700 Subject: [PATCH 14/16] updates --- .../src/RampsController.test.ts | 26 +- .../ramps-controller/src/RampsController.ts | 314 ++++++++++++++---- packages/ramps-controller/src/RampsService.ts | 181 ++++++++-- packages/ramps-controller/src/index.ts | 1 + 4 files changed, 426 insertions(+), 96 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index b7a408ada08..ebd3c821a89 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -404,6 +404,7 @@ describe('RampsController', () => { "providers": Array [], "requests": Object {}, "selectedPaymentMethod": null, + "selectedToken": null, "tokens": null, "userRegion": null, } @@ -426,6 +427,7 @@ describe('RampsController', () => { "preferredProvider": null, "providers": Array [], "selectedPaymentMethod": null, + "selectedToken": null, "tokens": null, "userRegion": null, } @@ -446,6 +448,7 @@ describe('RampsController', () => { "countries": Array [], "preferredProvider": null, "providers": Array [], + "selectedToken": null, "tokens": null, "userRegion": null, } @@ -469,6 +472,7 @@ describe('RampsController', () => { "providers": Array [], "requests": Object {}, "selectedPaymentMethod": null, + "selectedToken": null, "tokens": null, "userRegion": null, } @@ -1371,7 +1375,7 @@ describe('RampsController', () => { await controller.setUserRegion('US'); await new Promise((resolve) => setTimeout(resolve, 50)); - await controller.getPaymentMethods({ + await controller.getPaymentMethods(undefined, { assetId: 'eip155:1/slip44:60', provider: '/providers/test', }); @@ -2456,7 +2460,7 @@ describe('RampsController', () => { mockPaymentMethod1, ); - await controller.getPaymentMethods({ + await controller.getPaymentMethods(undefined, { assetId: 'eip155:1/slip44:60', provider: '/providers/stripe', }); @@ -2502,7 +2506,7 @@ describe('RampsController', () => { removedPaymentMethod, ); - await controller.getPaymentMethods({ + await controller.getPaymentMethods(undefined, { assetId: 'eip155:1/slip44:60', provider: '/providers/stripe', }); @@ -2536,7 +2540,7 @@ describe('RampsController', () => { expect(controller.state.selectedPaymentMethod).toBeNull(); - await controller.getPaymentMethods({ + await controller.getPaymentMethods(undefined, { assetId: 'eip155:1/slip44:60', provider: '/providers/stripe', }); @@ -2568,7 +2572,7 @@ describe('RampsController', () => { expect(controller.state.paymentMethods).toStrictEqual([]); - await controller.getPaymentMethods({ + await controller.getPaymentMethods(undefined, { assetId: 'eip155:1/slip44:60', provider: '/providers/stripe', }); @@ -2584,7 +2588,7 @@ describe('RampsController', () => { it('throws error when region is not provided and userRegion is not set', async () => { await withController(async ({ controller }) => { await expect( - controller.getPaymentMethods({ + controller.getPaymentMethods(undefined, { assetId: 'eip155:1/slip44:60', provider: '/providers/stripe', }), @@ -2618,7 +2622,7 @@ describe('RampsController', () => { }, ); - await controller.getPaymentMethods({ + await controller.getPaymentMethods(undefined, { assetId: 'eip155:1/slip44:60', provider: '/providers/stripe', }); @@ -2653,7 +2657,7 @@ describe('RampsController', () => { }, async ({ controller }) => { await expect( - controller.getPaymentMethods({ + controller.getPaymentMethods(undefined, { assetId: 'eip155:1/slip44:60', provider: '/providers/stripe', }), @@ -2681,7 +2685,7 @@ describe('RampsController', () => { expect(controller.state.paymentMethods).toStrictEqual([]); - const response = await controller.getPaymentMethods({ + const response = await controller.getPaymentMethods(undefined, { assetId: 'eip155:1/slip44:60', provider: '/providers/stripe', doNotUpdateState: true, @@ -2769,7 +2773,7 @@ describe('RampsController', () => { ); // Should not throw - controller.triggerGetPaymentMethods({ + controller.triggerGetPaymentMethods(undefined, { assetId: 'eip155:1/slip44:60', provider: '/providers/stripe', }); @@ -2788,7 +2792,7 @@ describe('RampsController', () => { await withController(async ({ controller }) => { // Should not throw even when getPaymentMethods would fail (no region) expect(() => { - controller.triggerGetPaymentMethods({ + controller.triggerGetPaymentMethods(undefined, { assetId: 'eip155:1/slip44:60', provider: '/providers/stripe', }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 90d978a87b0..a1aeb117562 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -411,49 +411,74 @@ export class RampsController extends BaseController< ): Promise { const ttl = options?.ttl ?? this.#requestCacheTTL; - // Check for existing pending request - join it instead of making a duplicate + console.log('[RampsController] executeRequest called:', { + cacheKey, + forceRefresh: options?.forceRefresh, + ttl, + }); + const pending = this.#pendingRequests.get(cacheKey); if (pending) { + console.log('[RampsController] executeRequest - returning pending request'); return pending.promise as Promise; } - // Check cache validity (unless force refresh) if (!options?.forceRefresh) { const cached = this.state.requests[cacheKey]; if (cached && !isCacheExpired(cached, ttl)) { + console.log('[RampsController] executeRequest - cache HIT:', { + cacheKey, + cachedStatus: cached.status, + cachedTimestamp: cached.timestamp, + }); return cached.data as TResult; } + console.log('[RampsController] executeRequest - cache MISS:', { + cacheKey, + hasCached: !!cached, + isExpired: cached ? isCacheExpired(cached, ttl) : 'N/A', + }); + } else { + console.log( + '[RampsController] executeRequest - forceRefresh, skipping cache', + ); } - // Create abort controller for this request const abortController = new AbortController(); const lastFetchedAt = Date.now(); - // Update state to loading this.#updateRequestState(cacheKey, createLoadingState()); - // Create the fetch promise const promise = (async (): Promise => { try { + console.log('[RampsController] executeRequest - fetching data...'); const data = await fetcher(abortController.signal); - // Don't update state if aborted if (abortController.signal.aborted) { + console.log('[RampsController] executeRequest - request was aborted'); throw new Error('Request was aborted'); } + console.log('[RampsController] executeRequest - fetch SUCCESS:', { + cacheKey, + dataType: typeof data, + }); + this.#updateRequestState( cacheKey, createSuccessState(data as Json, lastFetchedAt), ); return data; } catch (error) { - // Don't update state if aborted if (abortController.signal.aborted) { throw error; } const errorMessage = (error as Error)?.message; + console.log('[RampsController] executeRequest - fetch ERROR:', { + cacheKey, + errorMessage, + }); this.#updateRequestState( cacheKey, @@ -461,7 +486,6 @@ export class RampsController extends BaseController< ); throw error; } finally { - // Only delete if this is still our entry (not replaced by a new request) const currentPending = this.#pendingRequests.get(cacheKey); if (currentPending?.abortController === abortController) { this.#pendingRequests.delete(cacheKey); @@ -469,7 +493,6 @@ export class RampsController extends BaseController< } })(); - // Store pending request for deduplication this.#pendingRequests.set(cacheKey, { promise, abortController }); return promise; @@ -613,11 +636,9 @@ export class RampsController extends BaseController< ); } - // Only cleanup state if region is actually changing const regionChanged = normalizedRegion !== this.state.userRegion?.regionCode; - // Set the new region atomically with cleanup to avoid intermediate null state this.update((state) => { if (regionChanged) { state.preferredProvider = null; @@ -629,7 +650,6 @@ export class RampsController extends BaseController< state.userRegion = userRegion; }); - // Only trigger fetches if region changed or if data is missing if (regionChanged || !this.state.tokens) { this.triggerGetTokens(userRegion.regionCode, 'buy', options); } @@ -652,28 +672,53 @@ export class RampsController extends BaseController< * @param provider - The provider object to set. */ setPreferredProvider(provider: Provider | null): void { -<<<<<<< HEAD + console.log('[RampsController] setPreferredProvider called with:', { + providerId: provider?.id ?? null, + providerName: provider?.name ?? null, + currentSelectedToken: this.state.selectedToken?.assetId ?? null, + hadProvider: this.state.preferredProvider !== null, + }); + const hadProvider = this.state.preferredProvider !== null; -======= ->>>>>>> 910d8769ff6d2f88aa994a6432364e05832be447 this.update((state) => { state.preferredProvider = provider; }); // If token is selected and provider changed, fetch payment methods if (this.state.selectedToken && provider) { + console.log( + '[RampsController] setPreferredProvider - Token exists, fetching payment methods', + { + providerId: provider.id, + tokenAssetId: this.state.selectedToken.assetId, + }, + ); this.#fetchAndSetPaymentMethods( provider.id, this.state.selectedToken, - ).catch(() => { - // Error stored in state + ).catch((error) => { + console.log( + '[RampsController] setPreferredProvider - Error fetching payment methods:', + error, + ); }); } else if (hadProvider && !provider && this.state.selectedToken) { - // Provider was cleared, clear payment methods + console.log( + '[RampsController] setPreferredProvider - Provider cleared, clearing payment methods', + ); this.update((state) => { state.paymentMethods = []; state.selectedPaymentMethod = null; }); + } else { + console.log( + '[RampsController] setPreferredProvider - No payment methods fetch needed', + { + hasSelectedToken: !!this.state.selectedToken, + hasProvider: !!provider, + hadProvider, + }, + ); } } @@ -710,6 +755,11 @@ export class RampsController extends BaseController< ); } + console.log('[RampsController] hydrateState:', { + regionCode, + options, + }); + this.triggerGetTokens(regionCode, 'buy', options); this.triggerGetProviders(regionCode, options); } @@ -834,6 +884,11 @@ export class RampsController extends BaseController< ); } + console.log('[RampsController] getProviders called with:', { + regionToUse, + options, + }); + const normalizedRegion = regionToUse.toLowerCase().trim(); const cacheKey = createCacheKey('getProviders', [ normalizedRegion, @@ -843,6 +898,12 @@ export class RampsController extends BaseController< options?.payments, ]); + console.log('[RampsController] getProviders - executing request:', { + cacheKey, + normalizedRegion, + options, + }); + const { providers } = await this.executeRequest( cacheKey, async () => { @@ -860,6 +921,10 @@ export class RampsController extends BaseController< options, ); + console.log('[RampsController] getProviders - executeRequest result:', { + providers + }); + if (!options?.doNotUpdateState) { this.update((state) => { const userRegionCode = state.userRegion?.regionCode; @@ -868,6 +933,10 @@ export class RampsController extends BaseController< userRegionCode === undefined || userRegionCode === normalizedRegion ) { + + console.log('[RampsController] getProviders - updating state with providers:', { + providers, + }); state.providers = providers; } }); @@ -880,44 +949,63 @@ export class RampsController extends BaseController< * Fetches the list of payment methods for a given context. * The payment methods are saved in the controller state once fetched. * + * @param region - User's region code. If not provided, uses the user's region from controller state. * @param options - Query parameters for filtering payment methods. - * @param options.region - User's region code. If not provided, uses the user's region from controller state. * @param options.fiat - Fiat currency code (e.g., "usd"). If not provided, uses the user's region currency. * @param options.assetId - CAIP-19 cryptocurrency identifier. * @param options.provider - Provider ID path. * @param options.forceRefresh - Whether to bypass cache. * @param options.ttl - Custom TTL for this request. -<<<<<<< HEAD * @param options.doNotUpdateState - If true, does not update controller state with results. -======= - * @param options.doNotUpdateState - If true, skip updating controller state (but still update request cache for deduplication). ->>>>>>> 910d8769ff6d2f88aa994a6432364e05832be447 * @returns The payment methods response containing payments array. */ - async getPaymentMethods(options: { - region?: string; - fiat?: string; - assetId: string; - 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; + async getPaymentMethods( + region?: string, + options?: ExecuteRequestOptions & { + fiat?: string; + assetId: string; + provider: string; + }, + ): Promise { + console.log('[RampsController] getPaymentMethods called with:', { + region, + options, + }); + + const regionToUse = region ?? this.state.userRegion?.regionCode; + const fiatToUse = options?.fiat ?? this.state.userRegion?.country?.currency; + + console.log('[RampsController] getPaymentMethods - resolved values:', { + regionToUse, + fiatToUse, + }); if (!regionToUse) { + console.log('[RampsController] getPaymentMethods - Error: Region missing'); throw new Error( 'Region is required. Either provide a region parameter or ensure userRegion is set in controller state.', ); } if (!fiatToUse) { + console.log( + '[RampsController] getPaymentMethods - Error: Fiat currency missing', + ); throw new Error( 'Fiat currency is required. Either provide a fiat parameter or ensure userRegion is set in controller state.', ); } + if (!options?.assetId) { + console.log('[RampsController] getPaymentMethods - Error: assetId missing'); + throw new Error('assetId is required.'); + } + + if (!options?.provider) { + console.log('[RampsController] getPaymentMethods - Error: provider missing'); + throw new Error('provider is required.'); + } + const normalizedRegion = regionToUse.toLowerCase().trim(); const normalizedFiat = fiatToUse.toLowerCase().trim(); const cacheKey = createCacheKey('getPaymentMethods', [ @@ -927,37 +1015,66 @@ export class RampsController extends BaseController< options.provider, ]); + console.log('[RampsController] getPaymentMethods - executing request:', { + cacheKey, + normalizedRegion, + normalizedFiat, + assetId: options.assetId, + provider: options.provider, + }); + const response = await this.executeRequest( cacheKey, async () => { - return this.messenger.call('RampsService:getPaymentMethods', { + console.log( + '[RampsController] getPaymentMethods - calling RampsService:getPaymentMethods', + ); + const result = this.messenger.call('RampsService:getPaymentMethods', { region: normalizedRegion, fiat: normalizedFiat, assetId: options.assetId, provider: options.provider, }); + console.log( + '[RampsController] getPaymentMethods - RampsService:getPaymentMethods returned:', + result, + ); + return result; }, - { - forceRefresh: options.forceRefresh, - ttl: options.ttl, - doNotUpdateState: options.doNotUpdateState, - }, + options, ); + console.log('[RampsController] getPaymentMethods - executeRequest result:', { + paymentsCount: response.payments?.length ?? 0, + doNotUpdateState: options?.doNotUpdateState, + }); + if (!options?.doNotUpdateState) { + console.log( + '[RampsController] getPaymentMethods - updating state with payments:', + { + paymentsCount: response.payments.length, + currentSelectedPaymentMethod: this.state.selectedPaymentMethod?.id, + }, + ); 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) if ( state.selectedPaymentMethod && !response.payments.some( (pm: PaymentMethod) => pm.id === state.selectedPaymentMethod?.id, ) ) { + console.log( + '[RampsController] getPaymentMethods - clearing selectedPaymentMethod (not in new list)', + ); state.selectedPaymentMethod = null; } }); + console.log( + '[RampsController] getPaymentMethods - state updated, paymentMethods count:', + this.state.paymentMethods.length, + ); } return response; @@ -974,12 +1091,17 @@ export class RampsController extends BaseController< token: RampsToken | null, options?: ExecuteRequestOptions, ): Promise { + console.log('[RampsController] setSelectedToken called with:', { + token: token ? { assetId: token.assetId, symbol: token.symbol } : null, + options, + }); + this.update((state) => { state.selectedToken = token; }); if (!token) { - // Clear payment methods when token is cleared + console.log('[RampsController] Token is null, clearing payment methods'); this.update((state) => { state.paymentMethods = []; state.selectedPaymentMethod = null; @@ -989,10 +1111,26 @@ export class RampsController extends BaseController< // Automatically fetch payment methods for the selected token const provider = this.state.preferredProvider ?? this.state.providers[0]; + console.log('[RampsController] setSelectedToken - provider resolution:', { + preferredProvider: this.state.preferredProvider?.id ?? null, + firstProvider: this.state.providers[0]?.id ?? null, + providersCount: this.state.providers.length, + resolvedProvider: provider?.id ?? null, + }); + if (provider) { + console.log( + '[RampsController] Calling #fetchAndSetPaymentMethods with:', + { + providerId: provider.id, + tokenAssetId: token.assetId, + }, + ); await this.#fetchAndSetPaymentMethods(provider.id, token, options); } else { - // No provider available, clear payment methods + console.log( + '[RampsController] No provider available, clearing payment methods', + ); this.update((state) => { state.paymentMethods = []; state.selectedPaymentMethod = null; @@ -1012,8 +1150,17 @@ export class RampsController extends BaseController< token?: RampsToken, options?: ExecuteRequestOptions, ): Promise { + console.log('[RampsController] #fetchAndSetPaymentMethods called with:', { + providerId, + token: token ? { assetId: token.assetId, symbol: token.symbol } : null, + options, + }); + const tokenToUse = token ?? this.state.selectedToken; if (!tokenToUse) { + console.log( + '[RampsController] #fetchAndSetPaymentMethods - No token available, returning early', + ); return; } @@ -1022,27 +1169,66 @@ export class RampsController extends BaseController< const regionCode = this.state.userRegion?.regionCode; const fiatCurrency = this.state.userRegion?.country?.currency; + console.log('[RampsController] #fetchAndSetPaymentMethods - Context:', { + assetId, + regionCode, + fiatCurrency, + userRegion: this.state.userRegion, + }); + if (!regionCode || !fiatCurrency) { + console.log( + '[RampsController] #fetchAndSetPaymentMethods - Missing regionCode or fiatCurrency, returning early', + { regionCode, fiatCurrency }, + ); return; } try { - const response = await this.getPaymentMethods({ + console.log( + '[RampsController] #fetchAndSetPaymentMethods - Calling getPaymentMethods with:', + { + regionCode, + assetId, + provider: providerId, + fiat: fiatCurrency, + }, + ); + + const response = await this.getPaymentMethods(regionCode, { assetId, provider: providerId, - region: regionCode, fiat: fiatCurrency, ...options, }); + console.log( + '[RampsController] #fetchAndSetPaymentMethods - getPaymentMethods response:', + { + paymentsCount: response.payments.length, + payments: response.payments.map((p) => ({ id: p.id, name: p.name })), + }, + ); + // Auto-select the first payment method if (response.payments.length > 0) { + console.log( + '[RampsController] #fetchAndSetPaymentMethods - Auto-selecting first payment method:', + { id: response.payments[0].id, name: response.payments[0].name }, + ); this.update((state) => { state.selectedPaymentMethod = response.payments[0]; }); + } else { + console.log( + '[RampsController] #fetchAndSetPaymentMethods - No payment methods returned', + ); } - } catch { - // Error is stored in request state, no need to throw + } catch (error) { + console.log( + '[RampsController] #fetchAndSetPaymentMethods - Error fetching payment methods:', + error, + ); } } @@ -1118,7 +1304,12 @@ export class RampsController extends BaseController< payments?: string | string[]; }, ): void { - this.getProviders(region, options).catch(() => { + console.log('[RampsController] triggerGetProviders called with:', { + region, + options, + }); + this.getProviders(region, options).catch((error) => { + console.log('[RampsController] triggerGetProviders - Error:', error); // Error stored in state }); } @@ -1126,29 +1317,24 @@ export class RampsController extends BaseController< /** * Triggers fetching payment methods without throwing. * + * @param region - User's region code. If not provided, uses userRegion from state. * @param options - Query parameters for filtering payment methods. - * @param options.region - User's region code. If not provided, uses userRegion from state. * @param options.fiat - Fiat currency code. If not provided, uses userRegion currency. * @param options.assetId - CAIP-19 cryptocurrency identifier. * @param options.provider - Provider ID path. * @param options.forceRefresh - Whether to bypass cache. * @param options.ttl - Custom TTL for this request. -<<<<<<< HEAD * @param options.doNotUpdateState - If true, does not update controller state with results. -======= - * @param options.doNotUpdateState - If true, skip updating controller state. ->>>>>>> 910d8769ff6d2f88aa994a6432364e05832be447 */ - triggerGetPaymentMethods(options: { - region?: string; - fiat?: string; - assetId: string; - provider: string; - forceRefresh?: boolean; - ttl?: number; - doNotUpdateState?: boolean; - }): void { - this.getPaymentMethods(options).catch(() => { + triggerGetPaymentMethods( + region?: string, + options?: ExecuteRequestOptions & { + fiat?: string; + assetId: string; + provider: string; + }, + ): void { + this.getPaymentMethods(region, options).catch(() => { // Error stored in state }); } diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index 5f9104b72a7..03a67098fb1 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -356,6 +356,69 @@ function getApiPath(path: string, version: string = 'v2'): string { return `${version}/${path}`; } +const PAYMENT_METHOD_POOL: PaymentMethod[] = [ + { + id: '/payments/debit-credit-card', + paymentType: 'debit-credit-card', + name: 'Debit or Credit', + score: 90, + icon: 'card', + disclaimer: "Credit card purchases may incur your bank's cash advance fees.", + delay: '5 to 10 minutes.', + pendingOrderDescription: 'Card purchases may take a few minutes to complete.', + }, + { + id: '/payments/bank-transfer', + paymentType: 'bank-transfer', + name: 'Bank Transfer', + score: 80, + icon: 'bank', + disclaimer: 'Bank transfers may take 1-3 business days.', + delay: '1-3 business days', + pendingOrderDescription: 'Bank transfers may take 1-3 business days to complete.', + }, + { + id: '/payments/apple-pay', + paymentType: 'apple-pay', + name: 'Apple Pay', + score: 95, + icon: 'apple', + delay: '5 to 10 minutes.', + pendingOrderDescription: 'Apple Pay purchases may take a few minutes to complete.', + }, + { + id: '/payments/google-pay', + paymentType: 'google-pay', + name: 'Google Pay', + score: 92, + icon: 'google', + delay: '5 to 10 minutes.', + pendingOrderDescription: 'Google Pay purchases may take a few minutes to complete.', + }, + { + id: '/payments/paypal', + paymentType: 'paypal', + name: 'PayPal', + score: 85, + icon: 'paypal', + disclaimer: 'PayPal transactions may have additional fees.', + delay: '10 to 15 minutes.', + pendingOrderDescription: 'PayPal purchases may take a few minutes to complete.', + }, +]; + +/** + * Generates a mock payment methods response with a random subset of payment methods. + * Returns 3 random payment methods from a pool of 5. + * + * @returns A PaymentMethodsResponse with 3 randomly selected payment methods. + */ +export function generatePaymentMethodMockResponse(): PaymentMethodsResponse { + const shuffled = [...PAYMENT_METHOD_POOL].sort(() => Math.random() - 0.5); + const selected = shuffled.slice(0, 3); + return { payments: selected }; +} + /** * This service object is responsible for interacting with the Ramps API. * @@ -619,8 +682,8 @@ export class RampsService { } return countries.filter((country) => { - const isCountrySupported = - country.supported.buy || country.supported.sell; + const isCountrySupported = true + // country.supported.buy || country.supported.sell; if (country.states && country.states.length > 0) { const hasSupportedState = country.states.some( @@ -711,13 +774,21 @@ export class RampsService { payments?: string | string[]; }, ): Promise<{ providers: Provider[] }> { + console.log('[RampsService] getProviders called with:', { + regionCode, + options, + }); const normalizedRegion = regionCode.toLowerCase().trim(); const url = new URL( getApiPath(`regions/${normalizedRegion}/providers`), getBaseUrl(this.#environment, RampsApiService.Regions), ); + console.log('[RampsService] getProviders - url:', { + url: url.toString(), + }); this.#addCommonParams(url); + if (options?.provider) { const providerIds = Array.isArray(options.provider) ? options.provider @@ -746,15 +817,81 @@ export class RampsService { paymentIds.forEach((id) => url.searchParams.append('payments', id)); } - const response = await this.#policy.execute(async () => { - const fetchResponse = await this.#fetch(url); - if (!fetchResponse.ok) { - throw new HttpError( - fetchResponse.status, - `Fetching '${url.toString()}' failed with status '${fetchResponse.status}'`, - ); - } - return fetchResponse.json() as Promise<{ providers: Provider[] }>; + // const response = await this.#policy.execute(async () => { + // const fetchResponse = await this.#fetch(url); + // if (!fetchResponse.ok) { + // throw new HttpError( + // fetchResponse.status, + // `Fetching '${url.toString()}' failed with status '${fetchResponse.status}'`, + // ); + // } + // return fetchResponse.json() as Promise<{ providers: Provider[] }>; + // }); + + const response: { providers: Provider[] } = { + providers: [ + { + id: '/providers/transak', + name: 'Transak', + environmentType: 'PRODUCTION', + description: + 'Per Transak: "The fastest and securest way to buy 100+ cryptocurrencies on 75+ blockchains. Pay via Apple Pay, UPI, bank transfer or use your debit or credit card."', + hqAddress: '35 Shearing Street, Bury St. Edmunds, IP32 6FE, United Kingdom', + links: [ + { name: 'Homepage', url: 'https://www.transak.com/' }, + { name: 'Privacy Policy', url: 'https://transak.com/privacy-policy' }, + { name: 'Support', url: 'https://support.transak.com/hc/en-us' }, + ], + logos: { + light: 'https://on-ramp-content.api.cx.metamask.io/assets/providers/transak_light.png', + dark: 'https://on-ramp-content.api.cx.metamask.io/assets/providers/transak_dark.png', + height: 24, + width: 90, + }, + }, + { + id: '/providers/moonpay', + name: 'Moonpay', + environmentType: 'PRODUCTION', + description: + 'Per MoonPay: "MoonPay provides a smooth experience for converting between fiat currencies and cryptocurrencies."', + hqAddress: '8 The Green, Dover, DE, 19901, USA', + links: [ + { name: 'Homepage', url: 'https://www.moonpay.com/' }, + { name: 'Privacy Policy', url: 'https://www.moonpay.com/legal/privacy_policy' }, + { name: 'Support', url: 'https://support.moonpay.com/hc/en-gb/categories/360001595097-Customer-Support-Help-Center' }, + ], + logos: { + light: 'https://on-ramp-content.api.cx.metamask.io/assets/providers/moonpay_light.png', + dark: 'https://on-ramp-content.api.cx.metamask.io/assets/providers/moonpay_dark.png', + height: 24, + width: 88, + }, + }, + { + id: '/providers/stripe', + name: 'Stripe', + environmentType: 'PRODUCTION', + description: + 'Per Stripe: "The Stripe crypto onramp gives web3 developers an easy and fast way for their consumers to purchase crypto with a credit card, debit card, or ACH."', + hqAddress: '354 Oyster Point Blvd, South San Francisco, CA 94080, USA', + links: [ + { name: 'Homepage', url: 'https://crypto.link.com' }, + { name: 'Support', url: 'https://support.link.com/topics/crypto' }, + { name: 'Privacy Policy', url: 'https://link.com/privacy' }, + ], + logos: { + light: 'https://on-ramp-content.api.cx.metamask.io/assets/providers/stripe_light.png', + dark: 'https://on-ramp-content.api.cx.metamask.io/assets/providers/stripe_dark.png', + height: 24, + width: 100, + }, + }, + ], + }; + + console.log('[RampsService] getProviders - response:', { + response, }); if (!response || typeof response !== 'object') { @@ -795,16 +932,18 @@ export class RampsService { url.searchParams.set('assetId', options.assetId); url.searchParams.set('provider', options.provider); - const response = await this.#policy.execute(async () => { - const fetchResponse = await this.#fetch(url); - if (!fetchResponse.ok) { - throw new HttpError( - fetchResponse.status, - `Fetching '${url.toString()}' failed with status '${fetchResponse.status}'`, - ); - } - return fetchResponse.json() as Promise; - }); + // const response = await this.#policy.execute(async () => { + // const fetchResponse = await this.#fetch(url); + // if (!fetchResponse.ok) { + // throw new HttpError( + // fetchResponse.status, + // `Fetching '${url.toString()}' failed with status '${fetchResponse.status}'`, + // ); + // } + // return fetchResponse.json() as Promise; + // }); + + const response = generatePaymentMethodMockResponse(); if (!response || typeof response !== 'object') { throw new Error('Malformed response received from paymentMethods API'); diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 7649f417d90..0f10731f7cc 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -32,6 +32,7 @@ export { RampsEnvironment, RampsApiService, RAMPS_SDK_VERSION, + generatePaymentMethodMockResponse, } from './RampsService'; export type { RampsServiceGetGeolocationAction, From 20b032fe7748fef6b77e5a5ec6defd3852cbf8b5 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Sun, 25 Jan 2026 12:54:04 -0700 Subject: [PATCH 15/16] adds local env override support --- .../ramps-controller/src/RampsService.test.ts | 49 +++++++++++++++++++ packages/ramps-controller/src/RampsService.ts | 28 +++++++++-- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/packages/ramps-controller/src/RampsService.test.ts b/packages/ramps-controller/src/RampsService.test.ts index eab09c9fe33..3ce9a306993 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -94,6 +94,55 @@ describe('RampsService', () => { expect(geolocationResponse).toBe('us-tx'); }); + it('uses baseUrlOverride when provided', async () => { + nock('http://localhost:3000') + .get('/geolocation') + .query({ + sdk: '2.1.6', + controller: CONTROLLER_VERSION, + context: 'mobile-ios', + }) + .reply(200, 'local-test'); + const { rootMessenger } = getService({ + options: { baseUrlOverride: 'http://localhost:3000' }, + }); + + const geolocationPromise = rootMessenger.call( + 'RampsService:getGeolocation', + ); + await clock.runAllAsync(); + await flushPromises(); + const geolocationResponse = await geolocationPromise; + + expect(geolocationResponse).toBe('local-test'); + }); + + it('baseUrlOverride takes precedence over environment', async () => { + nock('http://localhost:4000') + .get('/geolocation') + .query({ + sdk: '2.1.6', + controller: CONTROLLER_VERSION, + context: 'mobile-ios', + }) + .reply(200, 'override-test'); + const { rootMessenger } = getService({ + options: { + environment: RampsEnvironment.Production, + baseUrlOverride: 'http://localhost:4000', + }, + }); + + const geolocationPromise = rootMessenger.call( + 'RampsService:getGeolocation', + ); + await clock.runAllAsync(); + await flushPromises(); + const geolocationResponse = await geolocationPromise; + + expect(geolocationResponse).toBe('override-test'); + }); + it('throws if the API returns an empty response', async () => { nock('https://on-ramp.uat-api.cx.metamask.io') .get('/geolocation') diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index 03a67098fb1..498ad60a481 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -326,12 +326,18 @@ export type RampsServiceMessenger = Messenger< * * @param environment - The environment to use. * @param service - The API service type (determines if cache URL is used). + * @param baseUrlOverride - Optional base URL override for local development. * @returns The base URL for API requests. */ function getBaseUrl( environment: RampsEnvironment, service: RampsApiService, + baseUrlOverride?: string, ): string { + if (baseUrlOverride) { + return baseUrlOverride; + } + const cache = service === RampsApiService.Regions ? '-cache' : ''; switch (environment) { @@ -496,6 +502,12 @@ export class RampsService { */ readonly #context: string; + /** + * Optional base URL override for local development. + * When set, this URL will be used instead of the environment-based URL. + */ + readonly #baseUrlOverride?: string; + /** * Constructs a new RampsService object. * @@ -509,6 +521,9 @@ export class RampsService { * `node-fetch`). * @param args.policyOptions - Options to pass to `createServicePolicy`, which * is used to wrap each request. See {@link CreateServicePolicyOptions}. + * @param args.baseUrlOverride - Optional base URL override for local development. + * When provided, this URL will be used for all API requests instead of the + * environment-based URLs. Useful for pointing to a local API server. */ constructor({ messenger, @@ -516,12 +531,14 @@ export class RampsService { context, fetch: fetchFunction, policyOptions = {}, + baseUrlOverride, }: { messenger: RampsServiceMessenger; environment?: RampsEnvironment; context: string; fetch: typeof fetch; policyOptions?: CreateServicePolicyOptions; + baseUrlOverride?: string; }) { this.name = serviceName; this.#messenger = messenger; @@ -529,6 +546,7 @@ export class RampsService { this.#policy = createServicePolicy(policyOptions); this.#environment = environment; this.#context = context; + this.#baseUrlOverride = baseUrlOverride; this.#messenger.registerMethodActionHandlers( this, @@ -624,7 +642,7 @@ export class RampsService { }, ): Promise { return this.#policy.execute(async () => { - const baseUrl = getBaseUrl(this.#environment, service); + const baseUrl = getBaseUrl(this.#environment, service, this.#baseUrlOverride); const url = new URL(path, baseUrl); this.#addCommonParams(url, options.action); @@ -673,7 +691,7 @@ export class RampsService { async getCountries(): Promise { const countries = await this.#request( RampsApiService.Regions, - getApiPath('regions/countries'), + getApiPath('regions-v2/countries'), { responseType: 'json' }, ); @@ -717,7 +735,7 @@ export class RampsService { const normalizedRegion = region.toLowerCase().trim(); const url = new URL( getApiPath(`regions/${normalizedRegion}/topTokens`), - getBaseUrl(this.#environment, RampsApiService.Regions), + getBaseUrl(this.#environment, RampsApiService.Regions, this.#baseUrlOverride), ); this.#addCommonParams(url, action); @@ -781,7 +799,7 @@ export class RampsService { const normalizedRegion = regionCode.toLowerCase().trim(); const url = new URL( getApiPath(`regions/${normalizedRegion}/providers`), - getBaseUrl(this.#environment, RampsApiService.Regions), + getBaseUrl(this.#environment, RampsApiService.Regions, this.#baseUrlOverride), ); console.log('[RampsService] getProviders - url:', { url: url.toString(), @@ -923,7 +941,7 @@ export class RampsService { }): Promise { const url = new URL( getApiPath('paymentMethods'), - getBaseUrl(this.#environment, RampsApiService.Regions), + getBaseUrl(this.#environment, RampsApiService.Regions, this.#baseUrlOverride), ); this.#addCommonParams(url); From 94d5d3a86ecccda6f824cd0f3f03441931245bd7 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Sun, 25 Jan 2026 13:05:36 -0700 Subject: [PATCH 16/16] chore: removes logs --- .../ramps-controller/src/RampsController.ts | 224 +----------------- 1 file changed, 6 insertions(+), 218 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index a1aeb117562..6a436cf137f 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -411,37 +411,16 @@ export class RampsController extends BaseController< ): Promise { const ttl = options?.ttl ?? this.#requestCacheTTL; - console.log('[RampsController] executeRequest called:', { - cacheKey, - forceRefresh: options?.forceRefresh, - ttl, - }); - const pending = this.#pendingRequests.get(cacheKey); if (pending) { - console.log('[RampsController] executeRequest - returning pending request'); return pending.promise as Promise; } if (!options?.forceRefresh) { const cached = this.state.requests[cacheKey]; if (cached && !isCacheExpired(cached, ttl)) { - console.log('[RampsController] executeRequest - cache HIT:', { - cacheKey, - cachedStatus: cached.status, - cachedTimestamp: cached.timestamp, - }); return cached.data as TResult; } - console.log('[RampsController] executeRequest - cache MISS:', { - cacheKey, - hasCached: !!cached, - isExpired: cached ? isCacheExpired(cached, ttl) : 'N/A', - }); - } else { - console.log( - '[RampsController] executeRequest - forceRefresh, skipping cache', - ); } const abortController = new AbortController(); @@ -451,19 +430,12 @@ export class RampsController extends BaseController< const promise = (async (): Promise => { try { - console.log('[RampsController] executeRequest - fetching data...'); const data = await fetcher(abortController.signal); if (abortController.signal.aborted) { - console.log('[RampsController] executeRequest - request was aborted'); throw new Error('Request was aborted'); } - console.log('[RampsController] executeRequest - fetch SUCCESS:', { - cacheKey, - dataType: typeof data, - }); - this.#updateRequestState( cacheKey, createSuccessState(data as Json, lastFetchedAt), @@ -475,10 +447,6 @@ export class RampsController extends BaseController< } const errorMessage = (error as Error)?.message; - console.log('[RampsController] executeRequest - fetch ERROR:', { - cacheKey, - errorMessage, - }); this.#updateRequestState( cacheKey, @@ -672,13 +640,6 @@ export class RampsController extends BaseController< * @param provider - The provider object to set. */ setPreferredProvider(provider: Provider | null): void { - console.log('[RampsController] setPreferredProvider called with:', { - providerId: provider?.id ?? null, - providerName: provider?.name ?? null, - currentSelectedToken: this.state.selectedToken?.assetId ?? null, - hadProvider: this.state.preferredProvider !== null, - }); - const hadProvider = this.state.preferredProvider !== null; this.update((state) => { state.preferredProvider = provider; @@ -686,39 +647,17 @@ export class RampsController extends BaseController< // If token is selected and provider changed, fetch payment methods if (this.state.selectedToken && provider) { - console.log( - '[RampsController] setPreferredProvider - Token exists, fetching payment methods', - { - providerId: provider.id, - tokenAssetId: this.state.selectedToken.assetId, - }, - ); this.#fetchAndSetPaymentMethods( provider.id, this.state.selectedToken, - ).catch((error) => { - console.log( - '[RampsController] setPreferredProvider - Error fetching payment methods:', - error, - ); + ).catch(() => { + // Error stored in state }); } else if (hadProvider && !provider && this.state.selectedToken) { - console.log( - '[RampsController] setPreferredProvider - Provider cleared, clearing payment methods', - ); this.update((state) => { state.paymentMethods = []; state.selectedPaymentMethod = null; }); - } else { - console.log( - '[RampsController] setPreferredProvider - No payment methods fetch needed', - { - hasSelectedToken: !!this.state.selectedToken, - hasProvider: !!provider, - hadProvider, - }, - ); } } @@ -755,11 +694,6 @@ export class RampsController extends BaseController< ); } - console.log('[RampsController] hydrateState:', { - regionCode, - options, - }); - this.triggerGetTokens(regionCode, 'buy', options); this.triggerGetProviders(regionCode, options); } @@ -884,11 +818,6 @@ export class RampsController extends BaseController< ); } - console.log('[RampsController] getProviders called with:', { - regionToUse, - options, - }); - const normalizedRegion = regionToUse.toLowerCase().trim(); const cacheKey = createCacheKey('getProviders', [ normalizedRegion, @@ -898,12 +827,6 @@ export class RampsController extends BaseController< options?.payments, ]); - console.log('[RampsController] getProviders - executing request:', { - cacheKey, - normalizedRegion, - options, - }); - const { providers } = await this.executeRequest( cacheKey, async () => { @@ -921,10 +844,6 @@ export class RampsController extends BaseController< options, ); - console.log('[RampsController] getProviders - executeRequest result:', { - providers - }); - if (!options?.doNotUpdateState) { this.update((state) => { const userRegionCode = state.userRegion?.regionCode; @@ -933,10 +852,6 @@ export class RampsController extends BaseController< userRegionCode === undefined || userRegionCode === normalizedRegion ) { - - console.log('[RampsController] getProviders - updating state with providers:', { - providers, - }); state.providers = providers; } }); @@ -967,42 +882,26 @@ export class RampsController extends BaseController< provider: string; }, ): Promise { - console.log('[RampsController] getPaymentMethods called with:', { - region, - options, - }); - const regionToUse = region ?? this.state.userRegion?.regionCode; const fiatToUse = options?.fiat ?? this.state.userRegion?.country?.currency; - console.log('[RampsController] getPaymentMethods - resolved values:', { - regionToUse, - fiatToUse, - }); - if (!regionToUse) { - console.log('[RampsController] getPaymentMethods - Error: Region missing'); throw new Error( 'Region is required. Either provide a region parameter or ensure userRegion is set in controller state.', ); } if (!fiatToUse) { - console.log( - '[RampsController] getPaymentMethods - Error: Fiat currency missing', - ); throw new Error( 'Fiat currency is required. Either provide a fiat parameter or ensure userRegion is set in controller state.', ); } if (!options?.assetId) { - console.log('[RampsController] getPaymentMethods - Error: assetId missing'); throw new Error('assetId is required.'); } if (!options?.provider) { - console.log('[RampsController] getPaymentMethods - Error: provider missing'); throw new Error('provider is required.'); } @@ -1015,48 +914,20 @@ export class RampsController extends BaseController< options.provider, ]); - console.log('[RampsController] getPaymentMethods - executing request:', { - cacheKey, - normalizedRegion, - normalizedFiat, - assetId: options.assetId, - provider: options.provider, - }); - const response = await this.executeRequest( cacheKey, async () => { - console.log( - '[RampsController] getPaymentMethods - calling RampsService:getPaymentMethods', - ); - const result = this.messenger.call('RampsService:getPaymentMethods', { + return this.messenger.call('RampsService:getPaymentMethods', { region: normalizedRegion, fiat: normalizedFiat, assetId: options.assetId, provider: options.provider, }); - console.log( - '[RampsController] getPaymentMethods - RampsService:getPaymentMethods returned:', - result, - ); - return result; }, options, ); - console.log('[RampsController] getPaymentMethods - executeRequest result:', { - paymentsCount: response.payments?.length ?? 0, - doNotUpdateState: options?.doNotUpdateState, - }); - if (!options?.doNotUpdateState) { - console.log( - '[RampsController] getPaymentMethods - updating state with payments:', - { - paymentsCount: response.payments.length, - currentSelectedPaymentMethod: this.state.selectedPaymentMethod?.id, - }, - ); this.update((state) => { state.paymentMethods = response.payments; if ( @@ -1065,16 +936,9 @@ export class RampsController extends BaseController< (pm: PaymentMethod) => pm.id === state.selectedPaymentMethod?.id, ) ) { - console.log( - '[RampsController] getPaymentMethods - clearing selectedPaymentMethod (not in new list)', - ); state.selectedPaymentMethod = null; } }); - console.log( - '[RampsController] getPaymentMethods - state updated, paymentMethods count:', - this.state.paymentMethods.length, - ); } return response; @@ -1091,17 +955,11 @@ export class RampsController extends BaseController< token: RampsToken | null, options?: ExecuteRequestOptions, ): Promise { - console.log('[RampsController] setSelectedToken called with:', { - token: token ? { assetId: token.assetId, symbol: token.symbol } : null, - options, - }); - this.update((state) => { state.selectedToken = token; }); if (!token) { - console.log('[RampsController] Token is null, clearing payment methods'); this.update((state) => { state.paymentMethods = []; state.selectedPaymentMethod = null; @@ -1111,26 +969,10 @@ export class RampsController extends BaseController< // Automatically fetch payment methods for the selected token const provider = this.state.preferredProvider ?? this.state.providers[0]; - console.log('[RampsController] setSelectedToken - provider resolution:', { - preferredProvider: this.state.preferredProvider?.id ?? null, - firstProvider: this.state.providers[0]?.id ?? null, - providersCount: this.state.providers.length, - resolvedProvider: provider?.id ?? null, - }); if (provider) { - console.log( - '[RampsController] Calling #fetchAndSetPaymentMethods with:', - { - providerId: provider.id, - tokenAssetId: token.assetId, - }, - ); await this.#fetchAndSetPaymentMethods(provider.id, token, options); } else { - console.log( - '[RampsController] No provider available, clearing payment methods', - ); this.update((state) => { state.paymentMethods = []; state.selectedPaymentMethod = null; @@ -1150,17 +992,8 @@ export class RampsController extends BaseController< token?: RampsToken, options?: ExecuteRequestOptions, ): Promise { - console.log('[RampsController] #fetchAndSetPaymentMethods called with:', { - providerId, - token: token ? { assetId: token.assetId, symbol: token.symbol } : null, - options, - }); - const tokenToUse = token ?? this.state.selectedToken; if (!tokenToUse) { - console.log( - '[RampsController] #fetchAndSetPaymentMethods - No token available, returning early', - ); return; } @@ -1169,32 +1002,11 @@ export class RampsController extends BaseController< const regionCode = this.state.userRegion?.regionCode; const fiatCurrency = this.state.userRegion?.country?.currency; - console.log('[RampsController] #fetchAndSetPaymentMethods - Context:', { - assetId, - regionCode, - fiatCurrency, - userRegion: this.state.userRegion, - }); - if (!regionCode || !fiatCurrency) { - console.log( - '[RampsController] #fetchAndSetPaymentMethods - Missing regionCode or fiatCurrency, returning early', - { regionCode, fiatCurrency }, - ); return; } try { - console.log( - '[RampsController] #fetchAndSetPaymentMethods - Calling getPaymentMethods with:', - { - regionCode, - assetId, - provider: providerId, - fiat: fiatCurrency, - }, - ); - const response = await this.getPaymentMethods(regionCode, { assetId, provider: providerId, @@ -1202,33 +1014,14 @@ export class RampsController extends BaseController< ...options, }); - console.log( - '[RampsController] #fetchAndSetPaymentMethods - getPaymentMethods response:', - { - paymentsCount: response.payments.length, - payments: response.payments.map((p) => ({ id: p.id, name: p.name })), - }, - ); - // Auto-select the first payment method if (response.payments.length > 0) { - console.log( - '[RampsController] #fetchAndSetPaymentMethods - Auto-selecting first payment method:', - { id: response.payments[0].id, name: response.payments[0].name }, - ); this.update((state) => { state.selectedPaymentMethod = response.payments[0]; }); - } else { - console.log( - '[RampsController] #fetchAndSetPaymentMethods - No payment methods returned', - ); } - } catch (error) { - console.log( - '[RampsController] #fetchAndSetPaymentMethods - Error fetching payment methods:', - error, - ); + } catch { + // Error stored in state } } @@ -1304,12 +1097,7 @@ export class RampsController extends BaseController< payments?: string | string[]; }, ): void { - console.log('[RampsController] triggerGetProviders called with:', { - region, - options, - }); - this.getProviders(region, options).catch((error) => { - console.log('[RampsController] triggerGetProviders - Error:', error); + this.getProviders(region, options).catch(() => { // Error stored in state }); }