diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 7a49db1110a..44131aa439f 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -7,6 +7,18 @@ 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)) +- Add `SupportedActions` type for `{ buy: boolean; sell: boolean }` support info + +### Changed + +- Reorganize `init()` to only fetch geolocation and countries; remove token and provider fetching ([#7707](https://github.com/MetaMask/core/pull/7707)) +- **BREAKING:** Change `Country.supported` and `State.supported` from `boolean` to `SupportedActions` object. The API now returns buy/sell support info in a single call. +- **BREAKING:** Remove `action` parameter from `getCountries()`. Countries are no longer fetched separately for buy/sell actions. + ## [4.1.0] ### Added diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index a209b2f3c2d..0a3158f618f 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', () => { @@ -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,217 +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 () => { await withController(async ({ controller }) => { @@ -998,73 +793,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(); }); }); @@ -1078,7 +837,7 @@ describe('RampsController', () => { async () => createMockCountries(), ); - const result = controller.triggerGetCountries('buy'); + const result = controller.triggerGetCountries(); expect(result).toBeUndefined(); }); }); @@ -1185,7 +944,7 @@ describe('RampsController', () => { template: '(XXX) XXX-XXXX', }, currency: 'USD', - supported: true, + supported: { buy: true, sell: true }, recommended: true, }, { @@ -1198,18 +957,20 @@ describe('RampsController', () => { template: 'XXX XXXXXXX', }, currency: 'EUR', - supported: true, + supported: { buy: true, sell: false }, }, ]; - 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, ); - const countries = await controller.getCountries('buy'); + expect(controller.state.countries).toStrictEqual([]); + + const countries = await controller.getCountries(); expect(countries).toMatchInlineSnapshot(` Array [ @@ -1224,7 +985,10 @@ describe('RampsController', () => { "template": "(XXX) XXX-XXXX", }, "recommended": true, - "supported": true, + "supported": Object { + "buy": true, + "sell": true, + }, }, Object { "currency": "EUR", @@ -1236,10 +1000,14 @@ describe('RampsController', () => { "prefix": "+43", "template": "XXX XXXXXXX", }, - "supported": true, + "supported": Object { + "buy": true, + "sell": false, + }, }, ] `); + expect(controller.state.countries).toStrictEqual(mockCountries); }); }); @@ -1254,161 +1022,242 @@ describe('RampsController', () => { }, ); - await controller.getCountries('buy'); - await controller.getCountries('buy'); + await controller.getCountries(); + await controller.getCountries(); expect(callCount).toBe(1); }); }); + }); - it('fetches countries with sell action', async () => { + describe('init', () => { + it('initializes controller by fetching countries and geolocation', async () => { await withController(async ({ controller, rootMessenger }) => { - let receivedAction: string | undefined; rootMessenger.registerActionHandler( - 'RampsService:getCountries', - async (action) => { - receivedAction = action; - return mockCountries; - }, + 'RampsService:getGeolocation', + async () => 'US', ); - - await controller.getCountries('sell'); - - expect(receivedAction).toBe('sell'); - }); - }); - - it('uses default buy action when no argument is provided', async () => { - await withController(async ({ controller, rootMessenger }) => { - let receivedAction: string | undefined; rootMessenger.registerActionHandler( 'RampsService:getCountries', - async (action) => { - receivedAction = action; - return mockCountries; - }, + async () => createMockCountries(), ); - await controller.getCountries(); + await controller.init(); - expect(receivedAction).toBe('buy'); + expect(controller.state.countries).toStrictEqual(createMockCountries()); + expect(controller.state.userRegion?.regionCode).toBe('us'); }); }); - }); - describe('init', () => { - it('initializes controller by fetching user region, tokens, and providers', 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, + 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(), + ); - 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 }), - ); + await controller.init(); - rootMessenger.registerActionHandler( - 'RampsService:getCountries', - async () => createMockCountries(), - ); + expect(controller.state.countries).toStrictEqual( + createMockCountries(), + ); + expect(controller.state.userRegion?.regionCode).toBe('us-ca'); + }, + ); + }); - await controller.init(); + 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, + }, + }; - expect(controller.state.userRegion?.regionCode).toBe('us'); - expect(controller.state.tokens).toStrictEqual(mockTokens); - expect(controller.state.providers).toStrictEqual(mockProviders); - }); + 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('handles initialization failure gracefully', async () => { + it('throws error when geolocation fetch fails', async () => { await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async () => createMockCountries(), + ); rootMessenger.registerActionHandler( 'RampsService:getGeolocation', - async () => { - throw new Error('Network error'); - }, + async () => null as unknown as string, ); - await controller.init(); - - expect(controller.state.userRegion).toBeNull(); - expect(controller.state.tokens).toBeNull(); - expect(controller.state.providers).toStrictEqual([]); + await expect(controller.init()).rejects.toThrow( + 'Failed to fetch geolocation. Cannot initialize controller without valid region information.', + ); }); }); - it('handles token fetch failure gracefully when region is set', async () => { + it('handles countries fetch failure', 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'); + async () => { + throw new Error('Countries fetch error'); }, ); - rootMessenger.registerActionHandler( - 'RampsService:getProviders', - async (_regionCode: string) => { - throw new Error('Provider fetch error'); - }, + + await expect(controller.init()).rejects.toThrow( + 'Countries fetch error', ); + }); + }); + }); - await controller.init(); + 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; - expect(controller.state.userRegion?.regionCode).toBe('us'); - expect(controller.state.tokens).toBeNull(); - expect(controller.state.providers).toStrictEqual([]); + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async () => { + tokensCalled = true; + return { topTokens: [], allTokens: [] }; + }, + ); + rootMessenger.registerActionHandler( + 'RampsService:getProviders', + async () => { + providersCalled = true; + return { providers: [] }; + }, + ); + + 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 }) => { + expect(() => controller.hydrateState()).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 +1269,382 @@ 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('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 () => { - 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: { buy: true, sell: true }, + states: [ + { + stateId: 'CA', + name: 'California', + supported: { buy: true, sell: 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: { buy: true, sell: 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: { buy: true, sell: 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: { buy: true, sell: 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 +1652,168 @@ 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: { buy: true, sell: true }, + states: [ + { + id: '/regions/us-ny', + name: 'New York', + supported: { buy: true, sell: 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: { buy: true, sell: true }, + states: [ + { + id: '/some/path/ca', + name: 'California', + supported: { buy: true, sell: 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: { buy: true, sell: true }, + states: [ + { + stateId: 'CA', + name: 'California', + supported: { buy: true, sell: true }, + }, + { + stateId: 'NY', + name: 'New York', + supported: { buy: true, sell: 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(); + }, + ); }); }); @@ -2476,7 +2563,7 @@ describe('RampsController', () => { flag: 'πŸ‡ΊπŸ‡Έ', currency: undefined as unknown as string, phone: { prefix: '+1', placeholder: '', template: '' }, - supported: true, + supported: { buy: true, sell: true }, }, state: null, regionCode: 'us', @@ -2631,13 +2718,13 @@ function createMockUserRegion( flag: '🏳️', currency: 'USD', phone: { prefix: '+1', placeholder: '', template: '' }, - supported: true, + supported: { buy: true, sell: true }, ...(stateCode && { states: [ { stateId: stateCode.toUpperCase(), name: stateName ?? `State ${stateCode.toUpperCase()}`, - supported: true, + supported: { buy: true, sell: true }, }, ], }), @@ -2647,7 +2734,7 @@ function createMockUserRegion( ? { stateId: stateCode.toUpperCase(), name: stateName ?? `State ${stateCode.toUpperCase()}`, - supported: true, + supported: { buy: true, sell: true }, } : null; @@ -2671,11 +2758,19 @@ function createMockCountries(): Country[] { flag: 'πŸ‡ΊπŸ‡Έ', currency: 'USD', phone: { prefix: '+1', placeholder: '', template: '' }, - supported: true, + supported: { buy: true, sell: true }, states: [ - { stateId: 'CA', name: 'California', supported: true }, - { stateId: 'NY', name: 'New York', supported: true }, - { stateId: 'UT', name: 'Utah', supported: true }, + { + stateId: 'CA', + name: 'California', + supported: { buy: true, sell: true }, + }, + { + stateId: 'NY', + name: 'New York', + supported: { buy: true, sell: true }, + }, + { stateId: 'UT', name: 'Utah', supported: { buy: true, sell: true } }, ], }, { @@ -2684,7 +2779,7 @@ function createMockCountries(): Country[] { flag: 'πŸ‡«πŸ‡·', currency: 'EUR', phone: { prefix: '+33', placeholder: '', template: '' }, - supported: true, + supported: { buy: true, sell: true }, }, ]; } diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 49429f67f68..1ea76631fbd 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -85,6 +85,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 +131,12 @@ const rampsControllerMetadata = { includeInStateLogs: true, usedInUi: true, }, + countries: { + persist: true, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: true, + }, providers: { persist: true, includeInDebugSnapshot: true, @@ -171,6 +181,7 @@ export function getDefaultRampsControllerState(): RampsControllerState { return { userRegion: null, preferredProvider: null, + countries: [], providers: [], tokens: null, paymentMethods: [], @@ -483,6 +494,17 @@ export class RampsController extends BaseController< }); } + #cleanupState(): void { + this.update((state) => { + state.userRegion = null; + state.preferredProvider = null; + state.tokens = null; + state.providers = []; + state.paymentMethods = []; + state.selectedPaymentMethod = null; + }); + } + /** * Gets the state of a specific cached request. * @@ -546,114 +568,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 +583,51 @@ export class RampsController extends BaseController< const normalizedRegion = region.toLowerCase().trim(); try { - const countries = await this.getCountries('buy', options); + 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.', + ); + } + const userRegion = findRegionFromCode(normalizedRegion, countries); - if (userRegion) { - this.update((state) => { - state.userRegion = userRegion; + if (!userRegion) { + this.#cleanupState(); + throw new Error( + `Region "${normalizedRegion}" not found in countries data. Cannot set user region without valid country information.`, + ); + } + + // 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; state.tokens = null; state.providers = []; state.paymentMethods = []; state.selectedPaymentMethod = null; - }); - - // Fetch providers for the new region - try { - await this.getProviders(userRegion.regionCode, options); - } catch { - // Provider fetch failed - error state will be available via selectors } + state.userRegion = userRegion; + }); - return userRegion; + // 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); } - // Region not found in countries data - this.update((state) => { - state.userRegion = null; - state.tokens = null; - state.providers = []; - state.paymentMethods = []; - state.selectedPaymentMethod = null; - }); - throw new Error( - `Region "${normalizedRegion}" not found in countries data. Cannot set user region without valid country information.`, - ); + 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; } } @@ -737,55 +646,64 @@ 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(options); - if (userRegion) { - try { - await this.getTokens(userRegion.regionCode, 'buy', options); - } catch { - // Token fetch failed - error state will be available via selectors - } + let regionCode = this.state.userRegion?.regionCode; + regionCode ??= await this.messenger.call('RampsService:getGeolocation'); - try { - await this.getProviders(userRegion.regionCode, options); - } catch { - // Provider 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.', + ); } + + await this.setUserRegion(regionCode, options); + } + + hydrateState(options?: ExecuteRequestOptions): void { + 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. + * Fetches the list of supported countries. + * The API returns countries with support information for both buy and sell actions. + * The countries are saved in the controller state once fetched. * - * @param action - The ramp action type ('buy' or 'sell'). * @param options - Options for cache behavior. * @returns An array of countries. */ - async getCountries( - action: RampAction = 'buy', - options?: ExecuteRequestOptions, - ): Promise { - const cacheKey = createCacheKey('getCountries', [action]); + async getCountries(options?: ExecuteRequestOptions): Promise { + const cacheKey = createCacheKey('getCountries', []); - return this.executeRequest( + const countries = await this.executeRequest( cacheKey, async () => { - return this.messenger.call('RampsService:getCountries', action); + return this.messenger.call('RampsService:getCountries'); }, options, ); + + this.update((state) => { + state.countries = countries; + }); + + return countries; } /** @@ -977,7 +895,7 @@ export class RampsController extends BaseController< if ( state.selectedPaymentMethod && !response.payments.some( - (pm) => pm.id === state.selectedPaymentMethod?.id, + (pm: PaymentMethod) => pm.id === state.selectedPaymentMethod?.id, ) ) { state.selectedPaymentMethod = null; @@ -1004,17 +922,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. * @@ -1030,14 +937,10 @@ export class RampsController extends BaseController< /** * Triggers fetching countries without throwing. * - * @param action - The ramp action type ('buy' or 'sell'). * @param options - Options for cache behavior. */ - triggerGetCountries( - action: 'buy' | 'sell' = 'buy', - options?: ExecuteRequestOptions, - ): void { - this.getCountries(action, options).catch(() => { + triggerGetCountries(options?: ExecuteRequestOptions): void { + this.getCountries(options).catch(() => { // Error stored in state }); } diff --git a/packages/ramps-controller/src/RampsService-method-action-types.ts b/packages/ramps-controller/src/RampsService-method-action-types.ts index e156498638f..0c74225f37a 100644 --- a/packages/ramps-controller/src/RampsService-method-action-types.ts +++ b/packages/ramps-controller/src/RampsService-method-action-types.ts @@ -18,9 +18,9 @@ export type RampsServiceGetGeolocationAction = { /** * Makes a request to the cached API to retrieve the list of supported countries. + * The API returns countries with support information for both buy and sell actions. * Filters countries based on aggregator support (preserves OnRampSDK logic). * - * @param action - The ramp action type ('buy' or 'sell'). * @returns An array of countries filtered by aggregator support. */ export type RampsServiceGetCountriesAction = { diff --git a/packages/ramps-controller/src/RampsService.test.ts b/packages/ramps-controller/src/RampsService.test.ts index fef587c3e8e..eab09c9fe33 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -256,7 +256,7 @@ describe('RampsService', () => { template: '(XXX) XXX-XXXX', }, currency: 'USD', - supported: true, + supported: { buy: true, sell: true }, recommended: true, }, { @@ -269,7 +269,7 @@ describe('RampsService', () => { template: 'XXX XXXXXXX', }, currency: 'EUR', - supported: true, + supported: { buy: true, sell: false }, }, ]; @@ -277,7 +277,6 @@ describe('RampsService', () => { nock('https://on-ramp-cache.uat-api.cx.metamask.io') .get('/v2/regions/countries') .query({ - action: 'buy', sdk: '2.1.6', controller: CONTROLLER_VERSION, context: 'mobile-ios', @@ -285,10 +284,7 @@ describe('RampsService', () => { .reply(200, mockCountriesResponse); const { rootMessenger } = getService(); - const countriesPromise = rootMessenger.call( - 'RampsService:getCountries', - 'buy', - ); + const countriesPromise = rootMessenger.call('RampsService:getCountries'); await clock.runAllAsync(); await flushPromises(); const countriesResponse = await countriesPromise; @@ -306,7 +302,10 @@ describe('RampsService', () => { "template": "(XXX) XXX-XXXX", }, "recommended": true, - "supported": true, + "supported": Object { + "buy": true, + "sell": true, + }, }, Object { "currency": "EUR", @@ -318,7 +317,10 @@ describe('RampsService', () => { "prefix": "+43", "template": "XXX XXXXXXX", }, - "supported": true, + "supported": Object { + "buy": true, + "sell": false, + }, }, ] `); @@ -328,7 +330,6 @@ describe('RampsService', () => { nock('https://on-ramp-cache.api.cx.metamask.io') .get('/v2/regions/countries') .query({ - action: 'buy', sdk: '2.1.6', controller: CONTROLLER_VERSION, context: 'mobile-ios', @@ -338,10 +339,7 @@ describe('RampsService', () => { options: { environment: RampsEnvironment.Production }, }); - const countriesPromise = rootMessenger.call( - 'RampsService:getCountries', - 'buy', - ); + const countriesPromise = rootMessenger.call('RampsService:getCountries'); await clock.runAllAsync(); await flushPromises(); const countriesResponse = await countriesPromise; @@ -359,7 +357,10 @@ describe('RampsService', () => { "template": "(XXX) XXX-XXXX", }, "recommended": true, - "supported": true, + "supported": Object { + "buy": true, + "sell": true, + }, }, Object { "currency": "EUR", @@ -371,7 +372,10 @@ describe('RampsService', () => { "prefix": "+43", "template": "XXX XXXXXXX", }, - "supported": true, + "supported": Object { + "buy": true, + "sell": false, + }, }, ] `); @@ -381,7 +385,6 @@ describe('RampsService', () => { nock('https://on-ramp-cache.uat-api.cx.metamask.io') .get('/v2/regions/countries') .query({ - action: 'buy', sdk: '2.1.6', controller: CONTROLLER_VERSION, context: 'mobile-ios', @@ -391,10 +394,7 @@ describe('RampsService', () => { options: { environment: RampsEnvironment.Development }, }); - const countriesPromise = rootMessenger.call( - 'RampsService:getCountries', - 'buy', - ); + const countriesPromise = rootMessenger.call('RampsService:getCountries'); await clock.runAllAsync(); await flushPromises(); const countriesResponse = await countriesPromise; @@ -412,66 +412,10 @@ describe('RampsService', () => { "template": "(XXX) XXX-XXXX", }, "recommended": true, - "supported": true, - }, - Object { - "currency": "EUR", - "flag": "πŸ‡¦πŸ‡Ή", - "isoCode": "AT", - "name": "Austria", - "phone": Object { - "placeholder": "660 1234567", - "prefix": "+43", - "template": "XXX XXXXXXX", - }, - "supported": true, - }, - ] - `); - }); - - it('passes the action parameter correctly', async () => { - nock('https://on-ramp-cache.uat-api.cx.metamask.io') - .get('/v2/regions/countries') - .query({ - action: 'sell', - sdk: '2.1.6', - controller: CONTROLLER_VERSION, - context: 'mobile-ios', - }) - .reply(200, mockCountriesResponse); - nock('https://on-ramp.uat-api.cx.metamask.io') - .get('/geolocation') - .query({ - sdk: '2.1.6', - controller: CONTROLLER_VERSION, - context: 'mobile-ios', - }) - .reply(200, 'us'); - const { rootMessenger } = getService(); - - const countriesPromise = rootMessenger.call( - 'RampsService:getCountries', - 'sell', - ); - await clock.runAllAsync(); - await flushPromises(); - const countriesResponse = await countriesPromise; - - expect(countriesResponse).toMatchInlineSnapshot(` - Array [ - Object { - "currency": "USD", - "flag": "πŸ‡ΊπŸ‡Έ", - "isoCode": "US", - "name": "United States of America", - "phone": Object { - "placeholder": "(555) 123-4567", - "prefix": "+1", - "template": "(XXX) XXX-XXXX", + "supported": Object { + "buy": true, + "sell": true, }, - "recommended": true, - "supported": true, }, Object { "currency": "EUR", @@ -483,13 +427,16 @@ describe('RampsService', () => { "prefix": "+43", "template": "XXX XXXXXXX", }, - "supported": true, + "supported": Object { + "buy": true, + "sell": false, + }, }, ] `); }); - it('includes country with unsupported country but supported state for sell action', async () => { + it('includes country with unsupported country but supported state', async () => { const mockCountriesWithUnsupportedCountry = [ { isoCode: 'US', @@ -498,19 +445,19 @@ describe('RampsService', () => { name: 'United States', phone: { prefix: '+1', placeholder: '', template: '' }, currency: 'USD', - supported: false, + supported: { buy: false, sell: false }, states: [ { id: '/regions/us-tx', stateId: 'TX', name: 'Texas', - supported: true, + supported: { buy: true, sell: true }, }, { id: '/regions/us-ny', stateId: 'NY', name: 'New York', - supported: false, + supported: { buy: false, sell: false }, }, ], }, @@ -518,7 +465,6 @@ describe('RampsService', () => { nock('https://on-ramp-cache.uat-api.cx.metamask.io') .get('/v2/regions/countries') .query({ - action: 'sell', sdk: '2.1.6', controller: CONTROLLER_VERSION, context: 'mobile-ios', @@ -526,19 +472,25 @@ describe('RampsService', () => { .reply(200, mockCountriesWithUnsupportedCountry); const { service } = getService(); - const countriesPromise = service.getCountries('sell'); + const countriesPromise = service.getCountries(); await clock.runAllAsync(); await flushPromises(); const countriesResponse = await countriesPromise; expect(countriesResponse).toHaveLength(1); expect(countriesResponse[0]?.isoCode).toBe('US'); - expect(countriesResponse[0]?.supported).toBe(false); - expect(countriesResponse[0]?.states?.[0]?.supported).toBe(true); + expect(countriesResponse[0]?.supported).toStrictEqual({ + buy: false, + sell: false, + }); + expect(countriesResponse[0]?.states?.[0]?.supported).toStrictEqual({ + buy: true, + sell: true, + }); }); - it('includes country with unsupported country but supported state for buy action', async () => { - const mockCountriesWithUnsupportedCountry = [ + it('filters out countries with no supported actions', async () => { + const mockCountriesWithNoSupport = [ { isoCode: 'US', id: '/regions/us', @@ -546,49 +498,41 @@ describe('RampsService', () => { name: 'United States', phone: { prefix: '+1', placeholder: '', template: '' }, currency: 'USD', - supported: false, - states: [ - { - id: '/regions/us-tx', - stateId: 'TX', - name: 'Texas', - supported: true, - }, - { - id: '/regions/us-ny', - stateId: 'NY', - name: 'New York', - supported: false, - }, - ], + supported: { buy: true, sell: false }, + }, + { + isoCode: 'XX', + id: '/regions/xx', + flag: '🏳️', + name: 'Unsupported Country', + phone: { prefix: '+0', placeholder: '', template: '' }, + currency: 'XXX', + supported: { buy: false, sell: false }, }, ]; nock('https://on-ramp-cache.uat-api.cx.metamask.io') .get('/v2/regions/countries') .query({ - action: 'buy', sdk: '2.1.6', controller: CONTROLLER_VERSION, context: 'mobile-ios', }) - .reply(200, mockCountriesWithUnsupportedCountry); + .reply(200, mockCountriesWithNoSupport); const { service } = getService(); - const countriesPromise = service.getCountries('buy'); + const countriesPromise = service.getCountries(); await clock.runAllAsync(); await flushPromises(); const countriesResponse = await countriesPromise; expect(countriesResponse).toHaveLength(1); expect(countriesResponse[0]?.isoCode).toBe('US'); - expect(countriesResponse[0]?.supported).toBe(false); - expect(countriesResponse[0]?.states?.[0]?.supported).toBe(true); }); + it('throws if the countries API returns an error', async () => { nock('https://on-ramp-cache.uat-api.cx.metamask.io') .get('/v2/regions/countries') .query({ - action: 'buy', sdk: '2.1.6', controller: CONTROLLER_VERSION, context: 'mobile-ios', @@ -600,14 +544,11 @@ describe('RampsService', () => { clock.nextAsync().catch(() => undefined); }); - const countriesPromise = rootMessenger.call( - 'RampsService:getCountries', - 'buy', - ); + const countriesPromise = rootMessenger.call('RampsService:getCountries'); await clock.runAllAsync(); await flushPromises(); await expect(countriesPromise).rejects.toThrow( - `Fetching 'https://on-ramp-cache.uat-api.cx.metamask.io/v2/regions/countries?action=buy&sdk=2.1.6&controller=${CONTROLLER_VERSION}&context=mobile-ios' failed with status '500'`, + `Fetching 'https://on-ramp-cache.uat-api.cx.metamask.io/v2/regions/countries?sdk=2.1.6&controller=${CONTROLLER_VERSION}&context=mobile-ios' failed with status '500'`, ); }); @@ -615,7 +556,6 @@ describe('RampsService', () => { nock('https://on-ramp-cache.uat-api.cx.metamask.io') .get('/v2/regions/countries') .query({ - action: 'buy', sdk: '2.1.6', controller: CONTROLLER_VERSION, context: 'mobile-ios', @@ -623,10 +563,7 @@ describe('RampsService', () => { .reply(200, () => null); const { rootMessenger } = getService(); - const countriesPromise = rootMessenger.call( - 'RampsService:getCountries', - 'buy', - ); + const countriesPromise = rootMessenger.call('RampsService:getCountries'); await clock.runAllAsync(); await flushPromises(); await expect(countriesPromise).rejects.toThrow( @@ -638,7 +575,6 @@ describe('RampsService', () => { nock('https://on-ramp-cache.uat-api.cx.metamask.io') .get('/v2/regions/countries') .query({ - action: 'buy', sdk: '2.1.6', controller: CONTROLLER_VERSION, context: 'mobile-ios', @@ -646,10 +582,7 @@ describe('RampsService', () => { .reply(200, { error: 'Something went wrong' }); const { rootMessenger } = getService(); - const countriesPromise = rootMessenger.call( - 'RampsService:getCountries', - 'buy', - ); + const countriesPromise = rootMessenger.call('RampsService:getCountries'); await clock.runAllAsync(); await flushPromises(); await expect(countriesPromise).rejects.toThrow( @@ -667,29 +600,20 @@ describe('RampsService', () => { name: 'United States', phone: { prefix: '+1', placeholder: '', template: '' }, currency: 'USD', - supported: true, + supported: { buy: true, sell: true }, }, ]; nock('https://on-ramp-cache.uat-api.cx.metamask.io') .get('/v2/regions/countries') .query({ - action: 'buy', sdk: '2.1.6', controller: CONTROLLER_VERSION, context: 'mobile-ios', }) .reply(200, mockCountries); - nock('https://on-ramp.uat-api.cx.metamask.io') - .get('/geolocation') - .query({ - sdk: '2.1.6', - controller: CONTROLLER_VERSION, - context: 'mobile-ios', - }) - .reply(200, 'us'); const { service } = getService(); - const countriesPromise = service.getCountries('buy'); + const countriesPromise = service.getCountries(); await clock.runAllAsync(); await flushPromises(); const countriesResponse = await countriesPromise; @@ -706,40 +630,53 @@ describe('RampsService', () => { "prefix": "+1", "template": "", }, - "supported": true, + "supported": Object { + "buy": true, + "sell": true, + }, }, ] `); }); - it('uses default buy action when no argument is provided', async () => { - const mockCountries = [ + it('filters countries with states by support', async () => { + const mockCountriesWithStates = [ { isoCode: 'US', + id: '/regions/us', flag: 'πŸ‡ΊπŸ‡Έ', - name: 'United States', - phone: { prefix: '+1', placeholder: '', template: '' }, + name: 'United States of America', + phone: { + prefix: '+1', + placeholder: '(555) 123-4567', + template: '(XXX) XXX-XXXX', + }, currency: 'USD', - supported: true, + supported: { buy: true, sell: true }, + states: [ + { + id: '/regions/us-tx', + stateId: 'TX', + name: 'Texas', + supported: { buy: true, sell: true }, + }, + { + id: '/regions/us-ny', + stateId: 'NY', + name: 'New York', + supported: { buy: false, sell: false }, + }, + ], }, ]; nock('https://on-ramp-cache.uat-api.cx.metamask.io') .get('/v2/regions/countries') .query({ - action: 'buy', sdk: '2.1.6', controller: CONTROLLER_VERSION, context: 'mobile-ios', }) - .reply(200, mockCountries); - nock('https://on-ramp.uat-api.cx.metamask.io') - .get('/geolocation') - .query({ - sdk: '2.1.6', - controller: CONTROLLER_VERSION, - context: 'mobile-ios', - }) - .reply(200, 'us'); + .reply(200, mockCountriesWithStates); const { service } = getService(); const countriesPromise = service.getCountries(); @@ -747,35 +684,36 @@ describe('RampsService', () => { await flushPromises(); const countriesResponse = await countriesPromise; - expect(countriesResponse[0]?.isoCode).toBe('US'); + expect(countriesResponse[0]?.supported).toStrictEqual({ + buy: true, + sell: true, + }); + expect(countriesResponse[0]?.states?.[0]?.supported).toStrictEqual({ + buy: true, + sell: true, + }); + expect(countriesResponse[0]?.states?.[1]?.supported).toStrictEqual({ + buy: false, + sell: false, + }); }); - it('filters countries with states by support', async () => { - const mockCountriesWithStates = [ + it('filters countries with states correctly', async () => { + const mockCountries = [ { isoCode: 'US', id: '/regions/us', flag: 'πŸ‡ΊπŸ‡Έ', - name: 'United States of America', - phone: { - prefix: '+1', - placeholder: '(555) 123-4567', - template: '(XXX) XXX-XXXX', - }, + name: 'United States', + phone: { prefix: '+1', placeholder: '', template: '' }, currency: 'USD', - supported: true, + supported: { buy: true, sell: false }, states: [ { id: '/regions/us-tx', stateId: 'TX', name: 'Texas', - supported: true, - }, - { - id: '/regions/us-ny', - stateId: 'NY', - name: 'New York', - supported: false, + supported: { buy: true, sell: true }, }, ], }, @@ -783,25 +721,29 @@ describe('RampsService', () => { nock('https://on-ramp-cache.uat-api.cx.metamask.io') .get('/v2/regions/countries') .query({ - action: 'buy', sdk: '2.1.6', controller: CONTROLLER_VERSION, context: 'mobile-ios', }) - .reply(200, mockCountriesWithStates); + .reply(200, mockCountries); const { service } = getService(); - const countriesPromise = service.getCountries('buy'); + const countriesPromise = service.getCountries(); await clock.runAllAsync(); await flushPromises(); const countriesResponse = await countriesPromise; - expect(countriesResponse[0]?.supported).toBe(true); - expect(countriesResponse[0]?.states?.[0]?.supported).toBe(true); - expect(countriesResponse[0]?.states?.[1]?.supported).toBe(false); + expect(countriesResponse[0]?.supported).toStrictEqual({ + buy: true, + sell: false, + }); + expect(countriesResponse[0]?.states?.[0]?.supported).toStrictEqual({ + buy: true, + sell: true, + }); }); - it('filters countries with states correctly', async () => { + it('includes country when state has undefined buy but truthy sell', async () => { const mockCountries = [ { isoCode: 'US', @@ -810,13 +752,13 @@ describe('RampsService', () => { name: 'United States', phone: { prefix: '+1', placeholder: '', template: '' }, currency: 'USD', - supported: true, + supported: { buy: false, sell: false }, states: [ { id: '/regions/us-tx', stateId: 'TX', name: 'Texas', - supported: true, + supported: { sell: true }, }, ], }, @@ -824,7 +766,6 @@ describe('RampsService', () => { nock('https://on-ramp-cache.uat-api.cx.metamask.io') .get('/v2/regions/countries') .query({ - action: 'buy', sdk: '2.1.6', controller: CONTROLLER_VERSION, context: 'mobile-ios', @@ -832,13 +773,15 @@ describe('RampsService', () => { .reply(200, mockCountries); const { service } = getService(); - const countriesPromise = service.getCountries('buy'); + const countriesPromise = service.getCountries(); await clock.runAllAsync(); await flushPromises(); const countriesResponse = await countriesPromise; - expect(countriesResponse[0]?.supported).toBe(true); - expect(countriesResponse[0]?.states?.[0]?.supported).toBe(true); + expect(countriesResponse).toHaveLength(1); + expect(countriesResponse[0]?.states?.[0]?.supported).toStrictEqual({ + sell: true, + }); }); }); diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index 3e1ea36b80b..5f9104b72a7 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -17,6 +17,20 @@ export type CountryPhone = { template: string; }; +/** + * Indicates whether a region supports buy and/or sell actions. + */ +export type SupportedActions = { + /** + * Whether buy actions are supported. + */ + buy: boolean; + /** + * Whether sell actions are supported. + */ + sell: boolean; +}; + /** * Represents a state/province within a country. */ @@ -34,9 +48,9 @@ export type State = { */ stateId?: string; /** - * Whether this state is supported for ramps. + * Whether this state is supported for buy and/or sell ramp actions. */ - supported?: boolean; + supported?: SupportedActions; /** * Whether this state is recommended. */ @@ -159,9 +173,9 @@ export type Country = { */ currency: string; /** - * Whether this country is supported for ramps. + * Whether this country is supported for buy and/or sell ramp actions. */ - supported: boolean; + supported: SupportedActions; /** * Whether this country is recommended. */ @@ -588,16 +602,16 @@ export class RampsService { /** * Makes a request to the cached API to retrieve the list of supported countries. + * The API returns countries with support information for both buy and sell actions. * Filters countries based on aggregator support (preserves OnRampSDK logic). * - * @param action - The ramp action type ('buy' or 'sell'). * @returns An array of countries filtered by aggregator support. */ - async getCountries(action: RampAction = 'buy'): Promise { + async getCountries(): Promise { const countries = await this.#request( RampsApiService.Regions, getApiPath('regions/countries'), - { action, responseType: 'json' }, + { responseType: 'json' }, ); if (!Array.isArray(countries)) { @@ -605,14 +619,18 @@ export class RampsService { } return countries.filter((country) => { + const isCountrySupported = + country.supported.buy || country.supported.sell; + if (country.states && country.states.length > 0) { const hasSupportedState = country.states.some( - (state) => state.supported !== false, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- intentionally using || to treat false as unsupported + (state) => state.supported?.buy || state.supported?.sell, ); - return country.supported || hasSupportedState; + return isCountrySupported || hasSupportedState; } - return country.supported; + return isCountrySupported; }); } diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 82158b4c5af..7649f417d90 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -18,6 +18,7 @@ export type { RampsServiceMessenger, Country, State, + SupportedActions, CountryPhone, Provider, ProviderLink, 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,