diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 3de1d0a9858..d8e5dff18c5 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add eligibility state ([#7539](https://github.com/MetaMask/core/pull/7539)) + - Add `createRequestSelector` utility function for creating memoized selectors for RampsController request states ([#7554](https://github.com/MetaMask/core/pull/7554)) - Add request caching infrastructure with TTL, deduplication, and abort support ([#7536](https://github.com/MetaMask/core/pull/7536)) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index c2197bba703..1e8e541e56e 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -8,7 +8,12 @@ import type { import type { RampsControllerMessenger } from './RampsController'; import { RampsController } from './RampsController'; -import type { RampsServiceGetGeolocationAction } from './RampsService-method-action-types'; +import type { Country } from './RampsService'; +import type { + RampsServiceGetGeolocationAction, + RampsServiceGetCountriesAction, + RampsServiceGetEligibilityAction, +} from './RampsService-method-action-types'; import { RequestStatus, createCacheKey } from './RequestCache'; describe('RampsController', () => { @@ -17,6 +22,7 @@ describe('RampsController', () => { await withController(({ controller }) => { expect(controller.state).toMatchInlineSnapshot(` Object { + "eligibility": null, "geolocation": null, "requests": Object {}, } @@ -33,6 +39,7 @@ describe('RampsController', () => { { options: { state: givenState } }, ({ controller }) => { expect(controller.state).toStrictEqual({ + eligibility: null, geolocation: 'US', requests: {}, }); @@ -44,6 +51,7 @@ describe('RampsController', () => { await withController({ options: { state: {} } }, ({ controller }) => { expect(controller.state).toMatchInlineSnapshot(` Object { + "eligibility": null, "geolocation": null, "requests": Object {}, } @@ -85,6 +93,7 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` Object { + "eligibility": null, "geolocation": null, "requests": Object {}, } @@ -102,6 +111,7 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` Object { + "eligibility": null, "geolocation": null, } `); @@ -118,6 +128,7 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` Object { + "eligibility": null, "geolocation": null, } `); @@ -134,6 +145,7 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` Object { + "eligibility": null, "geolocation": null, "requests": Object {}, } @@ -149,6 +161,14 @@ describe('RampsController', () => { 'RampsService:getGeolocation', async () => 'US', ); + rootMessenger.registerActionHandler( + 'RampsService:getEligibility', + async () => ({ + aggregator: true, + deposit: true, + global: true, + }), + ); await controller.updateGeolocation(); @@ -162,6 +182,14 @@ describe('RampsController', () => { 'RampsService:getGeolocation', async () => 'US', ); + rootMessenger.registerActionHandler( + 'RampsService:getEligibility', + async () => ({ + aggregator: true, + deposit: true, + global: true, + }), + ); await controller.updateGeolocation(); @@ -185,6 +213,14 @@ describe('RampsController', () => { return 'US'; }, ); + rootMessenger.registerActionHandler( + 'RampsService:getEligibility', + async () => ({ + aggregator: true, + deposit: true, + global: true, + }), + ); await controller.updateGeolocation(); await controller.updateGeolocation(); @@ -203,6 +239,14 @@ describe('RampsController', () => { return 'US'; }, ); + rootMessenger.registerActionHandler( + 'RampsService:getEligibility', + async () => ({ + aggregator: true, + deposit: true, + global: true, + }), + ); await controller.updateGeolocation(); await controller.updateGeolocation({ forceRefresh: true }); @@ -502,6 +546,351 @@ describe('RampsController', () => { }); }); }); + + describe('getCountries', () => { + const mockCountries: Country[] = [ + { + isoCode: 'US', + flag: 'πŸ‡ΊπŸ‡Έ', + name: 'United States of America', + phone: { + prefix: '+1', + placeholder: '(555) 123-4567', + template: '(XXX) XXX-XXXX', + }, + currency: 'USD', + supported: true, + recommended: true, + }, + { + isoCode: 'AT', + flag: 'πŸ‡¦πŸ‡Ή', + name: 'Austria', + phone: { + prefix: '+43', + placeholder: '660 1234567', + template: 'XXX XXXXXXX', + }, + currency: 'EUR', + supported: true, + }, + ]; + + it('fetches countries from the service', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async () => mockCountries, + ); + + const countries = await controller.getCountries('buy'); + + expect(countries).toMatchInlineSnapshot(` + Array [ + Object { + "currency": "USD", + "flag": "πŸ‡ΊπŸ‡Έ", + "isoCode": "US", + "name": "United States of America", + "phone": Object { + "placeholder": "(555) 123-4567", + "prefix": "+1", + "template": "(XXX) XXX-XXXX", + }, + "recommended": true, + "supported": true, + }, + Object { + "currency": "EUR", + "flag": "πŸ‡¦πŸ‡Ή", + "isoCode": "AT", + "name": "Austria", + "phone": Object { + "placeholder": "660 1234567", + "prefix": "+43", + "template": "XXX XXXXXXX", + }, + "supported": true, + }, + ] + `); + }); + }); + + it('caches countries response', async () => { + await withController(async ({ controller, rootMessenger }) => { + let callCount = 0; + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async () => { + callCount += 1; + return mockCountries; + }, + ); + + await controller.getCountries('buy'); + await controller.getCountries('buy'); + + expect(callCount).toBe(1); + }); + }); + + it('fetches countries with sell action', async () => { + await withController(async ({ controller, rootMessenger }) => { + let receivedAction: string | undefined; + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async (action) => { + receivedAction = action; + return mockCountries; + }, + ); + + await controller.getCountries('sell'); + + expect(receivedAction).toBe('sell'); + }); + }); + + it('uses default buy action when no argument is provided', async () => { + await withController(async ({ controller, rootMessenger }) => { + let receivedAction: string | undefined; + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async (action) => { + receivedAction = action; + return mockCountries; + }, + ); + + await controller.getCountries(); + + expect(receivedAction).toBe('buy'); + }); + }); + }); + + describe('updateEligibility', () => { + it('fetches and stores eligibility for a region', async () => { + await withController(async ({ controller, rootMessenger }) => { + const mockEligibility = { + aggregator: true, + deposit: true, + global: true, + }; + + rootMessenger.registerActionHandler( + 'RampsService:getEligibility', + async () => mockEligibility, + ); + + expect(controller.state.eligibility).toBeNull(); + + const eligibility = await controller.updateEligibility('fr'); + + expect(controller.state.eligibility).toStrictEqual(mockEligibility); + expect(eligibility).toStrictEqual(mockEligibility); + }); + }); + + it('handles state codes in ISO format', async () => { + await withController(async ({ controller, rootMessenger }) => { + const mockEligibility = { + aggregator: true, + deposit: false, + global: true, + }; + + rootMessenger.registerActionHandler( + 'RampsService:getEligibility', + async (isoCode) => { + expect(isoCode).toBe('us-ny'); + return mockEligibility; + }, + ); + + await controller.updateEligibility('us-ny'); + + expect(controller.state.eligibility).toStrictEqual(mockEligibility); + }); + }); + + it('normalizes isoCode case for cache key consistency', async () => { + await withController(async ({ controller, rootMessenger }) => { + const mockEligibility = { + aggregator: true, + deposit: true, + global: true, + }; + + let callCount = 0; + rootMessenger.registerActionHandler( + 'RampsService:getEligibility', + async (isoCode) => { + callCount += 1; + expect(isoCode).toBe('fr'); + return mockEligibility; + }, + ); + + await controller.updateEligibility('FR'); + expect(callCount).toBe(1); + + const eligibility1 = await controller.updateEligibility('fr'); + expect(callCount).toBe(1); + expect(eligibility1).toStrictEqual(mockEligibility); + + const eligibility2 = await controller.updateEligibility('Fr'); + expect(callCount).toBe(1); + expect(eligibility2).toStrictEqual(mockEligibility); + + const cacheKey = createCacheKey('updateEligibility', ['fr']); + const requestState = controller.getRequestState(cacheKey); + expect(requestState?.status).toBe('success'); + }); + }); + }); + + describe('updateGeolocation with automatic eligibility', () => { + it('automatically fetches eligibility after getting geolocation', async () => { + await withController(async ({ controller, rootMessenger }) => { + const mockEligibility = { + aggregator: true, + deposit: true, + global: true, + }; + + rootMessenger.registerActionHandler( + 'RampsService:getGeolocation', + async () => 'fr', + ); + rootMessenger.registerActionHandler( + 'RampsService:getEligibility', + async (isoCode) => { + expect(isoCode).toBe('fr'); + return mockEligibility; + }, + ); + + expect(controller.state.geolocation).toBeNull(); + expect(controller.state.eligibility).toBeNull(); + + await controller.updateGeolocation(); + + expect(controller.state.geolocation).toBe('fr'); + expect(controller.state.eligibility).toStrictEqual(mockEligibility); + }); + }); + + it('updates geolocation state even when eligibility fetch fails', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getGeolocation', + async () => 'us-ny', + ); + rootMessenger.registerActionHandler( + 'RampsService:getEligibility', + async () => { + throw new Error('Eligibility API error'); + }, + ); + + expect(controller.state.geolocation).toBeNull(); + expect(controller.state.eligibility).toBeNull(); + + await controller.updateGeolocation(); + + expect(controller.state.geolocation).toBe('us-ny'); + expect(controller.state.eligibility).toBeNull(); + }); + }); + + it('clears stale eligibility when new geolocation is fetched but eligibility fails', async () => { + await withController(async ({ controller, rootMessenger }) => { + const usEligibility = { + aggregator: true, + deposit: true, + global: true, + }; + + let geolocationCallCount = 0; + let eligibilityCallCount = 0; + + rootMessenger.registerActionHandler( + 'RampsService:getGeolocation', + async () => { + geolocationCallCount += 1; + return geolocationCallCount === 1 ? 'us' : 'fr'; + }, + ); + rootMessenger.registerActionHandler( + 'RampsService:getEligibility', + async () => { + eligibilityCallCount += 1; + if (eligibilityCallCount === 1) { + return usEligibility; + } + throw new Error('Eligibility API error'); + }, + ); + + await controller.updateGeolocation(); + + expect(controller.state.geolocation).toBe('us'); + expect(controller.state.eligibility).toStrictEqual(usEligibility); + + await controller.updateGeolocation({ forceRefresh: true }); + + expect(controller.state.geolocation).toBe('fr'); + expect(controller.state.eligibility).toBeNull(); + }); + }); + + it('prevents stale eligibility from overwriting current eligibility in race condition', async () => { + await withController(async ({ controller, rootMessenger }) => { + const usEligibility = { + aggregator: true, + deposit: true, + global: false, + }; + const frEligibility = { + aggregator: true, + deposit: true, + global: true, + }; + + let geolocationCallCount = 0; + + rootMessenger.registerActionHandler( + 'RampsService:getGeolocation', + async () => { + geolocationCallCount += 1; + return geolocationCallCount === 1 ? 'us' : 'fr'; + }, + ); + rootMessenger.registerActionHandler( + 'RampsService:getEligibility', + async (isoCode) => { + if (isoCode === 'us') { + await new Promise((resolve) => setTimeout(resolve, 100)); + return usEligibility; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + return frEligibility; + }, + ); + + const promise1 = controller.updateGeolocation(); + await new Promise((resolve) => setTimeout(resolve, 20)); + const promise2 = controller.updateGeolocation({ forceRefresh: true }); + + await Promise.all([promise1, promise2]); + + expect(controller.state.geolocation).toBe('fr'); + expect(controller.state.eligibility).toStrictEqual(frEligibility); + }); + }); + }); }); /** @@ -510,7 +899,10 @@ describe('RampsController', () => { */ type RootMessenger = Messenger< MockAnyNamespace, - MessengerActions | RampsServiceGetGeolocationAction, + | MessengerActions + | RampsServiceGetGeolocationAction + | RampsServiceGetCountriesAction + | RampsServiceGetEligibilityAction, MessengerEvents >; @@ -554,7 +946,11 @@ function getMessenger(rootMessenger: RootMessenger): RampsControllerMessenger { }); rootMessenger.delegate({ messenger, - actions: ['RampsService:getGeolocation'], + actions: [ + 'RampsService:getGeolocation', + 'RampsService:getCountries', + 'RampsService:getEligibility', + ], }); return messenger; } diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 9b72f9dfdbb..1e855443fe5 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -7,7 +7,12 @@ import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; import type { Json } from '@metamask/utils'; -import type { RampsServiceGetGeolocationAction } from './RampsService-method-action-types'; +import type { Country, Eligibility } from './RampsService'; +import type { + RampsServiceGetGeolocationAction, + RampsServiceGetCountriesAction, + RampsServiceGetEligibilityAction, +} from './RampsService-method-action-types'; import type { RequestCache as RequestCacheType, RequestState, @@ -43,6 +48,10 @@ export type RampsControllerState = { * The user's country code determined by geolocation. */ geolocation: string | null; + /** + * Eligibility information for the user's current region. + */ + eligibility: Eligibility | null; /** * Cache of request states, keyed by cache key. * This stores loading, success, and error states for API requests. @@ -60,6 +69,12 @@ const rampsControllerMetadata = { includeInStateLogs: true, usedInUi: true, }, + eligibility: { + persist: true, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: true, + }, requests: { persist: false, includeInDebugSnapshot: true, @@ -79,6 +94,7 @@ const rampsControllerMetadata = { export function getDefaultRampsControllerState(): RampsControllerState { return { geolocation: null, + eligibility: null, requests: {}, }; } @@ -101,7 +117,10 @@ export type RampsControllerActions = RampsControllerGetStateAction; /** * Actions from other messengers that {@link RampsController} calls. */ -type AllowedActions = RampsServiceGetGeolocationAction; +type AllowedActions = + | RampsServiceGetGeolocationAction + | RampsServiceGetCountriesAction + | RampsServiceGetEligibilityAction; /** * Published when the state of {@link RampsController} changes. @@ -368,9 +387,9 @@ export class RampsController extends BaseController< } /** - * Updates the user's geolocation. - * This method calls the RampsService to get the geolocation - * and stores the result in state. + * Updates the user's geolocation and eligibility. + * This method calls the RampsService to get the geolocation, + * then automatically fetches eligibility for that region. * * @param options - Options for cache behavior. * @returns The geolocation string. @@ -391,6 +410,78 @@ export class RampsController extends BaseController< state.geolocation = geolocation; }); + if (geolocation) { + try { + await this.updateEligibility(geolocation, options); + } catch { + // Eligibility fetch failed, but geolocation was successfully fetched and cached. + // Don't let eligibility errors prevent geolocation state from being updated. + // Clear eligibility state to avoid showing stale data from a previous location. + this.update((state) => { + state.eligibility = null; + }); + } + } + return geolocation; } + + /** + * Updates the eligibility information for a given region. + * + * @param isoCode - The ISO code for the region (e.g., "us", "fr", "us-ny"). + * @param options - Options for cache behavior. + * @returns The eligibility information. + */ + async updateEligibility( + isoCode: string, + options?: ExecuteRequestOptions, + ): Promise { + const normalizedIsoCode = isoCode.toLowerCase().trim(); + const cacheKey = createCacheKey('updateEligibility', [normalizedIsoCode]); + + const eligibility = await this.executeRequest( + cacheKey, + async () => { + return this.messenger.call( + 'RampsService:getEligibility', + normalizedIsoCode, + ); + }, + options, + ); + + this.update((state) => { + if ( + state.geolocation === null || + state.geolocation.toLowerCase().trim() === normalizedIsoCode + ) { + state.eligibility = eligibility; + } + }); + + return eligibility; + } + + /** + * Fetches the list of supported countries for a given ramp action. + * + * @param action - The ramp action type ('buy' or 'sell'). + * @param options - Options for cache behavior. + * @returns An array of countries with their eligibility information. + */ + async getCountries( + action: 'buy' | 'sell' = 'buy', + options?: ExecuteRequestOptions, + ): Promise { + const cacheKey = createCacheKey('getCountries', [action]); + + return this.executeRequest( + cacheKey, + async () => { + return this.messenger.call('RampsService:getCountries', action); + }, + options, + ); + } } diff --git a/packages/ramps-controller/src/RampsService-method-action-types.ts b/packages/ramps-controller/src/RampsService-method-action-types.ts index c6a0dffbd75..57a35bbdd66 100644 --- a/packages/ramps-controller/src/RampsService-method-action-types.ts +++ b/packages/ramps-controller/src/RampsService-method-action-types.ts @@ -16,7 +16,33 @@ export type RampsServiceGetGeolocationAction = { handler: RampsService['getGeolocation']; }; +/** + * Makes a request to the cached API to retrieve the list of supported countries. + * 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 = { + type: `RampsService:getCountries`; + handler: RampsService['getCountries']; +}; + +/** + * Fetches eligibility information for a specific region. + * + * @param isoCode - The ISO code for the region (e.g., "us", "fr", "us-ny"). + * @returns Eligibility information for the region. + */ +export type RampsServiceGetEligibilityAction = { + type: `RampsService:getEligibility`; + handler: RampsService['getEligibility']; +}; + /** * Union of all RampsService action types. */ -export type RampsServiceMethodActions = RampsServiceGetGeolocationAction; +export type RampsServiceMethodActions = + | RampsServiceGetGeolocationAction + | RampsServiceGetCountriesAction + | RampsServiceGetEligibilityAction; diff --git a/packages/ramps-controller/src/RampsService.test.ts b/packages/ramps-controller/src/RampsService.test.ts index 31fda495ca9..e03a3e18f5b 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -26,20 +26,22 @@ describe('RampsService', () => { it('returns the geolocation from the API', async () => { nock('https://on-ramp.uat-api.cx.metamask.io') .get('/geolocation') - .reply(200, 'US-TX'); + .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) + .reply(200, 'us-tx'); const { rootMessenger } = getService(); const geolocationResponse = await rootMessenger.call( 'RampsService:getGeolocation', ); - expect(geolocationResponse).toBe('US-TX'); + expect(geolocationResponse).toBe('us-tx'); }); it('uses the production URL when environment is Production', async () => { nock('https://on-ramp.api.cx.metamask.io') .get('/geolocation') - .reply(200, 'US-TX'); + .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) + .reply(200, 'us-tx'); const { rootMessenger } = getService({ options: { environment: RampsEnvironment.Production }, }); @@ -48,11 +50,14 @@ describe('RampsService', () => { 'RampsService:getGeolocation', ); - expect(geolocationResponse).toBe('US-TX'); + expect(geolocationResponse).toBe('us-tx'); }); - it('uses localhost URL when environment is Development', async () => { - nock('http://localhost:3000').get('/geolocation').reply(200, 'US-TX'); + it('uses staging URL when environment is Development', async () => { + nock('https://on-ramp.uat-api.cx.metamask.io') + .get('/geolocation') + .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) + .reply(200, 'us-tx'); const { rootMessenger } = getService({ options: { environment: RampsEnvironment.Development }, }); @@ -61,20 +66,13 @@ describe('RampsService', () => { 'RampsService:getGeolocation', ); - expect(geolocationResponse).toBe('US-TX'); - }); - - it('throws if the environment is invalid', () => { - expect(() => - getService({ - options: { environment: 'invalid' as RampsEnvironment }, - }), - ).toThrow('Invalid environment: invalid'); + expect(geolocationResponse).toBe('us-tx'); }); it('throws if the API returns an empty response', async () => { nock('https://on-ramp.uat-api.cx.metamask.io') .get('/geolocation') + .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) .reply(200, ''); const { rootMessenger } = getService(); @@ -83,9 +81,28 @@ describe('RampsService', () => { ).rejects.toThrow('Malformed response received from geolocation API'); }); + it('throws when primary API fails', async () => { + nock('https://on-ramp.uat-api.cx.metamask.io') + .get('/geolocation') + .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) + .times(4) + .reply(500, 'Internal Server Error'); + const { service, rootMessenger } = getService(); + service.onRetry(() => { + clock.nextAsync().catch(() => undefined); + }); + + await expect( + rootMessenger.call('RampsService:getGeolocation'), + ).rejects.toThrow( + "Fetching 'https://on-ramp.uat-api.cx.metamask.io/geolocation?sdk=2.1.6&controller=2.0.0&context=mobile-ios' failed with status '500'", + ); + }); + it('calls onDegraded listeners if the request takes longer than 5 seconds to resolve', async () => { nock('https://on-ramp.uat-api.cx.metamask.io') .get('/geolocation') + .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) .reply(200, () => { clock.tick(6000); return 'US-TX'; @@ -102,6 +119,7 @@ describe('RampsService', () => { it('attempts a request that responds with non-200 up to 4 times, throwing if it never succeeds', async () => { nock('https://on-ramp.uat-api.cx.metamask.io') .get('/geolocation') + .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) .times(4) .reply(500); const { service, rootMessenger } = getService(); @@ -112,7 +130,7 @@ describe('RampsService', () => { await expect( rootMessenger.call('RampsService:getGeolocation'), ).rejects.toThrow( - "Fetching 'https://on-ramp.uat-api.cx.metamask.io/geolocation' failed with status '500'", + "Fetching 'https://on-ramp.uat-api.cx.metamask.io/geolocation?sdk=2.1.6&controller=2.0.0&context=mobile-ios' failed with status '500'", ); }); @@ -125,18 +143,649 @@ describe('RampsService', () => { expect(subscription).toBeDefined(); expect(subscription).toHaveProperty('dispose'); }); + + it('throws error for invalid environment', async () => { + const { service, rootMessenger } = getService({ + options: { + environment: 'invalid' as unknown as RampsEnvironment, + }, + }); + service.onRetry(() => { + clock.nextAsync().catch(() => undefined); + }); + + await expect( + rootMessenger.call('RampsService:getGeolocation'), + ).rejects.toThrow('Invalid environment: invalid'); + }); }); describe('getGeolocation', () => { it('does the same thing as the messenger action', async () => { nock('https://on-ramp.uat-api.cx.metamask.io') .get('/geolocation') - .reply(200, 'US-TX'); + .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) + .reply(200, 'us-tx'); const { service } = getService(); const geolocationResponse = await service.getGeolocation(); - expect(geolocationResponse).toBe('US-TX'); + expect(geolocationResponse).toBe('us-tx'); + }); + }); + + describe('RampsService:getCountries', () => { + const mockCountriesResponse = [ + { + isoCode: 'US', + flag: 'πŸ‡ΊπŸ‡Έ', + name: 'United States of America', + phone: { + prefix: '+1', + placeholder: '(555) 123-4567', + template: '(XXX) XXX-XXXX', + }, + currency: 'USD', + supported: true, + recommended: true, + }, + { + isoCode: 'AT', + flag: 'πŸ‡¦πŸ‡Ή', + name: 'Austria', + phone: { + prefix: '+43', + placeholder: '660 1234567', + template: 'XXX XXXXXXX', + }, + currency: 'EUR', + supported: true, + }, + ]; + + it('returns the countries from the cache API filtered by support', async () => { + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/countries') + .query({ + action: 'buy', + sdk: '2.1.6', + controller: '2.0.0', + context: 'mobile-ios', + }) + .reply(200, mockCountriesResponse); + const { rootMessenger } = getService(); + + const countriesResponse = await rootMessenger.call( + 'RampsService:getCountries', + 'buy', + ); + + expect(countriesResponse).toMatchInlineSnapshot(` + Array [ + Object { + "currency": "USD", + "flag": "πŸ‡ΊπŸ‡Έ", + "isoCode": "US", + "name": "United States of America", + "phone": Object { + "placeholder": "(555) 123-4567", + "prefix": "+1", + "template": "(XXX) XXX-XXXX", + }, + "recommended": true, + "supported": true, + }, + Object { + "currency": "EUR", + "flag": "πŸ‡¦πŸ‡Ή", + "isoCode": "AT", + "name": "Austria", + "phone": Object { + "placeholder": "660 1234567", + "prefix": "+43", + "template": "XXX XXXXXXX", + }, + "supported": true, + }, + ] + `); + }); + + it('uses the production cache URL when environment is Production', async () => { + nock('https://on-ramp-cache.api.cx.metamask.io') + .get('/regions/countries') + .query({ + action: 'buy', + sdk: '2.1.6', + controller: '2.0.0', + context: 'mobile-ios', + }) + .reply(200, mockCountriesResponse); + const { rootMessenger } = getService({ + options: { environment: RampsEnvironment.Production }, + }); + + const countriesResponse = await rootMessenger.call( + 'RampsService:getCountries', + 'buy', + ); + + expect(countriesResponse).toMatchInlineSnapshot(` + Array [ + Object { + "currency": "USD", + "flag": "πŸ‡ΊπŸ‡Έ", + "isoCode": "US", + "name": "United States of America", + "phone": Object { + "placeholder": "(555) 123-4567", + "prefix": "+1", + "template": "(XXX) XXX-XXXX", + }, + "recommended": true, + "supported": true, + }, + Object { + "currency": "EUR", + "flag": "πŸ‡¦πŸ‡Ή", + "isoCode": "AT", + "name": "Austria", + "phone": Object { + "placeholder": "660 1234567", + "prefix": "+43", + "template": "XXX XXXXXXX", + }, + "supported": true, + }, + ] + `); + }); + + it('uses staging cache URL when environment is Development', async () => { + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/countries') + .query({ + action: 'buy', + sdk: '2.1.6', + controller: '2.0.0', + context: 'mobile-ios', + }) + .reply(200, mockCountriesResponse); + const { rootMessenger } = getService({ + options: { environment: RampsEnvironment.Development }, + }); + + const countriesResponse = await rootMessenger.call( + 'RampsService:getCountries', + 'buy', + ); + + expect(countriesResponse).toMatchInlineSnapshot(` + Array [ + Object { + "currency": "USD", + "flag": "πŸ‡ΊπŸ‡Έ", + "isoCode": "US", + "name": "United States of America", + "phone": Object { + "placeholder": "(555) 123-4567", + "prefix": "+1", + "template": "(XXX) XXX-XXXX", + }, + "recommended": true, + "supported": true, + }, + Object { + "currency": "EUR", + "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('/regions/countries') + .query({ + action: 'sell', + sdk: '2.1.6', + controller: '2.0.0', + context: 'mobile-ios', + }) + .reply(200, mockCountriesResponse); + nock('https://on-ramp.uat-api.cx.metamask.io') + .get('/geolocation') + .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) + .reply(200, 'us'); + const { rootMessenger } = getService(); + + const countriesResponse = await rootMessenger.call( + 'RampsService:getCountries', + 'sell', + ); + + expect(countriesResponse).toMatchInlineSnapshot(` + Array [ + Object { + "currency": "USD", + "flag": "πŸ‡ΊπŸ‡Έ", + "isoCode": "US", + "name": "United States of America", + "phone": Object { + "placeholder": "(555) 123-4567", + "prefix": "+1", + "template": "(XXX) XXX-XXXX", + }, + "recommended": true, + "supported": true, + }, + Object { + "currency": "EUR", + "flag": "πŸ‡¦πŸ‡Ή", + "isoCode": "AT", + "name": "Austria", + "phone": Object { + "placeholder": "660 1234567", + "prefix": "+43", + "template": "XXX XXXXXXX", + }, + "supported": true, + }, + ] + `); + }); + + it('includes country with unsupported country but supported state for sell action', async () => { + const mockCountriesWithUnsupportedCountry = [ + { + isoCode: 'US', + id: '/regions/us', + flag: 'πŸ‡ΊπŸ‡Έ', + name: 'United States', + phone: { prefix: '+1', placeholder: '', template: '' }, + currency: 'USD', + supported: false, + states: [ + { + id: '/regions/us-tx', + stateId: 'TX', + name: 'Texas', + supported: true, + }, + { + id: '/regions/us-ny', + stateId: 'NY', + name: 'New York', + supported: false, + }, + ], + }, + ]; + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/countries') + .query({ + action: 'sell', + sdk: '2.1.6', + controller: '2.0.0', + context: 'mobile-ios', + }) + .reply(200, mockCountriesWithUnsupportedCountry); + const { service } = getService(); + + const countriesResponse = await service.getCountries('sell'); + + expect(countriesResponse).toHaveLength(1); + expect(countriesResponse[0]?.isoCode).toBe('US'); + expect(countriesResponse[0]?.supported).toBe(false); + expect(countriesResponse[0]?.states?.[0]?.supported).toBe(true); + }); + + it('includes country with unsupported country but supported state for buy action', async () => { + const mockCountriesWithUnsupportedCountry = [ + { + isoCode: 'US', + id: '/regions/us', + flag: 'πŸ‡ΊπŸ‡Έ', + name: 'United States', + phone: { prefix: '+1', placeholder: '', template: '' }, + currency: 'USD', + supported: false, + states: [ + { + id: '/regions/us-tx', + stateId: 'TX', + name: 'Texas', + supported: true, + }, + { + id: '/regions/us-ny', + stateId: 'NY', + name: 'New York', + supported: false, + }, + ], + }, + ]; + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/countries') + .query({ + action: 'buy', + sdk: '2.1.6', + controller: '2.0.0', + context: 'mobile-ios', + }) + .reply(200, mockCountriesWithUnsupportedCountry); + const { service } = getService(); + + const countriesResponse = await service.getCountries('buy'); + + expect(countriesResponse).toHaveLength(1); + expect(countriesResponse[0]?.isoCode).toBe('US'); + expect(countriesResponse[0]?.supported).toBe(false); + expect(countriesResponse[0]?.states?.[0]?.supported).toBe(true); + }); + + it('throws if the countries API returns an error', async () => { + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/countries') + .query({ + action: 'buy', + sdk: '2.1.6', + controller: '2.0.0', + context: 'mobile-ios', + }) + .times(4) + .reply(500); + const { service, rootMessenger } = getService(); + service.onRetry(() => { + clock.nextAsync().catch(() => undefined); + }); + + await expect( + rootMessenger.call('RampsService:getCountries', 'buy'), + ).rejects.toThrow( + "Fetching 'https://on-ramp-cache.uat-api.cx.metamask.io/regions/countries?action=buy&sdk=2.1.6&controller=2.0.0&context=mobile-ios' failed with status '500'", + ); + }); + + it('throws if the API returns a non-array response', async () => { + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/countries') + .query({ + action: 'buy', + sdk: '2.1.6', + controller: '2.0.0', + context: 'mobile-ios', + }) + .reply(200, () => null); + const { rootMessenger } = getService(); + + await expect( + rootMessenger.call('RampsService:getCountries', 'buy'), + ).rejects.toThrow('Malformed response received from countries API'); + }); + + it('throws if the API returns an object instead of an array', async () => { + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/countries') + .query({ + action: 'buy', + sdk: '2.1.6', + controller: '2.0.0', + context: 'mobile-ios', + }) + .reply(200, { error: 'Something went wrong' }); + const { rootMessenger } = getService(); + + await expect( + rootMessenger.call('RampsService:getCountries', 'buy'), + ).rejects.toThrow('Malformed response received from countries API'); + }); + }); + + describe('getEligibility', () => { + it('fetches eligibility for a country code', async () => { + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/countries/fr') + .query({ + sdk: '2.1.6', + controller: '2.0.0', + context: 'mobile-ios', + }) + .reply(200, { + aggregator: true, + deposit: true, + global: true, + }); + const { service } = getService(); + + const eligibility = await service.getEligibility('fr'); + + expect(eligibility).toStrictEqual({ + aggregator: true, + deposit: true, + global: true, + }); + }); + + it('fetches eligibility for a state code', async () => { + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/countries/us-ny') + .query({ + sdk: '2.1.6', + controller: '2.0.0', + context: 'mobile-ios', + }) + .reply(200, { + aggregator: false, + deposit: true, + global: false, + }); + const { service } = getService(); + + const eligibility = await service.getEligibility('us-ny'); + + expect(eligibility).toStrictEqual({ + aggregator: false, + deposit: true, + global: false, + }); + }); + + it('normalizes ISO code to lowercase', async () => { + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/countries/fr') + .query({ + sdk: '2.1.6', + controller: '2.0.0', + context: 'mobile-ios', + }) + .reply(200, { + aggregator: true, + deposit: true, + global: true, + }); + const { service } = getService(); + + const eligibility = await service.getEligibility('FR'); + + expect(eligibility).toStrictEqual({ + aggregator: true, + deposit: true, + global: true, + }); + }); + }); + + describe('getCountries', () => { + it('does the same thing as the messenger action', async () => { + const mockCountries = [ + { + isoCode: 'US', + flag: 'πŸ‡ΊπŸ‡Έ', + name: 'United States', + phone: { prefix: '+1', placeholder: '', template: '' }, + currency: 'USD', + supported: true, + }, + ]; + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/countries') + .query({ + action: 'buy', + sdk: '2.1.6', + controller: '2.0.0', + context: 'mobile-ios', + }) + .reply(200, mockCountries); + nock('https://on-ramp.uat-api.cx.metamask.io') + .get('/geolocation') + .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) + .reply(200, 'us'); + const { service } = getService(); + + const countriesResponse = await service.getCountries('buy'); + + expect(countriesResponse).toMatchInlineSnapshot(` + Array [ + Object { + "currency": "USD", + "flag": "πŸ‡ΊπŸ‡Έ", + "isoCode": "US", + "name": "United States", + "phone": Object { + "placeholder": "", + "prefix": "+1", + "template": "", + }, + "supported": true, + }, + ] + `); + }); + + it('uses default buy action when no argument is provided', async () => { + const mockCountries = [ + { + isoCode: 'US', + flag: 'πŸ‡ΊπŸ‡Έ', + name: 'United States', + phone: { prefix: '+1', placeholder: '', template: '' }, + currency: 'USD', + supported: true, + }, + ]; + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/countries') + .query({ + action: 'buy', + sdk: '2.1.6', + controller: '2.0.0', + context: 'mobile-ios', + }) + .reply(200, mockCountries); + nock('https://on-ramp.uat-api.cx.metamask.io') + .get('/geolocation') + .query({ sdk: '2.1.6', controller: '2.0.0', context: 'mobile-ios' }) + .reply(200, 'us'); + const { service } = getService(); + + const countriesResponse = await service.getCountries(); + + expect(countriesResponse[0]?.isoCode).toBe('US'); + }); + + it('filters countries with states by support', async () => { + const mockCountriesWithStates = [ + { + isoCode: 'US', + id: '/regions/us', + flag: 'πŸ‡ΊπŸ‡Έ', + name: 'United States of America', + phone: { + prefix: '+1', + placeholder: '(555) 123-4567', + template: '(XXX) XXX-XXXX', + }, + currency: 'USD', + supported: true, + states: [ + { + id: '/regions/us-tx', + stateId: 'TX', + name: 'Texas', + supported: true, + }, + { + id: '/regions/us-ny', + stateId: 'NY', + name: 'New York', + supported: false, + }, + ], + }, + ]; + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/countries') + .query({ + action: 'buy', + sdk: '2.1.6', + controller: '2.0.0', + context: 'mobile-ios', + }) + .reply(200, mockCountriesWithStates); + const { service } = getService(); + + const countriesResponse = await service.getCountries('buy'); + + expect(countriesResponse[0]?.supported).toBe(true); + expect(countriesResponse[0]?.states?.[0]?.supported).toBe(true); + expect(countriesResponse[0]?.states?.[1]?.supported).toBe(false); + }); + + it('filters countries with states correctly', async () => { + const mockCountries = [ + { + isoCode: 'US', + id: '/regions/us', + flag: 'πŸ‡ΊπŸ‡Έ', + name: 'United States', + phone: { prefix: '+1', placeholder: '', template: '' }, + currency: 'USD', + supported: true, + states: [ + { + id: '/regions/us-tx', + stateId: 'TX', + name: 'Texas', + supported: true, + }, + ], + }, + ]; + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/countries') + .query({ + action: 'buy', + sdk: '2.1.6', + controller: '2.0.0', + context: 'mobile-ios', + }) + .reply(200, mockCountries); + const { service } = getService(); + + const countriesResponse = await service.getCountries('buy'); + + expect(countriesResponse[0]?.supported).toBe(true); + expect(countriesResponse[0]?.states?.[0]?.supported).toBe(true); }); }); }); @@ -198,6 +847,7 @@ function getService({ const service = new RampsService({ fetch, messenger, + context: 'mobile-ios', ...options, }); diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index 3d0d88ab187..549caf83a54 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -6,6 +6,109 @@ import { createServicePolicy, HttpError } from '@metamask/controller-utils'; import type { Messenger } from '@metamask/messenger'; import type { RampsServiceMethodActions } from './RampsService-method-action-types'; +import packageJson from '../package.json'; + +/** + * Represents phone number information for a country. + */ +export type CountryPhone = { + prefix: string; + placeholder: string; + template: string; +}; + +/** + * Represents a state/province within a country. + */ +export type State = { + /** + * State identifier. Can be in path format (e.g., "/regions/us-ut") or ISO code format (e.g., "us-ut"). + */ + id?: string; + /** + * State name. + */ + name?: string; + /** + * ISO state code (e.g., "UT", "NY"). + */ + stateId?: string; + /** + * Whether this state is supported for ramps. + */ + supported?: boolean; + /** + * Whether this state is recommended. + */ + recommended?: boolean; +}; + +/** + * Represents eligibility information for a region. + * Returned from the /regions/countries/{isoCode} endpoint. + */ +export type Eligibility = { + /** + * Whether aggregator providers are available. + */ + aggregator?: boolean; + /** + * Whether deposit (buy) is available. + */ + deposit?: boolean; + /** + * Whether global providers are available. + */ + global?: boolean; +}; + +/** + * Represents a country returned from the regions/countries API. + */ +export type Country = { + /** + * ISO-2 country code (e.g., "US", "GB"). + */ + isoCode: string; + /** + * Country identifier. Can be in path format (e.g., "/regions/us") or ISO code format. + * If not provided, defaults to isoCode. + */ + id?: string; + /** + * Country flag emoji or code. + */ + flag: string; + /** + * Country name. + */ + name: string; + /** + * Phone number information. + */ + phone: CountryPhone; + /** + * Default currency code. + */ + currency: string; + /** + * Whether this country is supported for ramps. + */ + supported: boolean; + /** + * Whether this country is recommended. + */ + recommended?: boolean; + /** + * Array of state objects. + */ + states?: State[]; +}; + +/** + * The SDK version to send with API requests. (backwards-compatibility) + */ +export const RAMPS_SDK_VERSION = '2.1.6'; // === GENERAL === @@ -24,9 +127,22 @@ export enum RampsEnvironment { Development = 'development', } +/** + * The type of ramps API service. + * Determines which base URL to use (cache vs standard). + */ +export enum RampsApiService { + Regions = 'regions', + Orders = 'orders', +} + // === MESSENGER === -const MESSENGER_EXPOSED_METHODS = ['getGeolocation'] as const; +const MESSENGER_EXPOSED_METHODS = [ + 'getGeolocation', + 'getCountries', + 'getEligibility', +] as const; /** * Actions that {@link RampsService} exposes to other consumers. @@ -61,19 +177,25 @@ export type RampsServiceMessenger = Messenger< // === SERVICE DEFINITION === /** - * Gets the base URL for API requests based on the environment. + * Gets the base URL for API requests based on the environment and service type. + * The Regions service uses a cache URL, while other services use the standard URL. * * @param environment - The environment to use. + * @param service - The API service type (determines if cache URL is used). * @returns The base URL for API requests. */ -function getBaseUrl(environment: RampsEnvironment): string { +function getBaseUrl( + environment: RampsEnvironment, + service: RampsApiService, +): string { + const cache = service === RampsApiService.Regions ? '-cache' : ''; + switch (environment) { case RampsEnvironment.Production: - return 'https://on-ramp.api.cx.metamask.io'; + return `https://on-ramp${cache}.api.cx.metamask.io`; case RampsEnvironment.Staging: - return 'https://on-ramp.uat-api.cx.metamask.io'; case RampsEnvironment.Development: - return 'http://localhost:3000'; + return `https://on-ramp${cache}.uat-api.cx.metamask.io`; default: throw new Error(`Invalid environment: ${String(environment)}`); } @@ -109,6 +231,7 @@ function getBaseUrl(environment: RampsEnvironment): string { * new RampsService({ * messenger: rampsServiceMessenger, * environment: RampsEnvironment.Production, + * context: 'mobile-ios', * fetch, * }); * @@ -146,9 +269,14 @@ export class RampsService { readonly #policy: ServicePolicy; /** - * The base URL for API requests. + * The environment used for API requests. + */ + readonly #environment: RampsEnvironment; + + /** + * The context for API requests (e.g., 'mobile-ios', 'mobile-android'). */ - readonly #baseUrl: string; + readonly #context: string; /** * Constructs a new RampsService object. @@ -156,6 +284,7 @@ export class RampsService { * @param args - The constructor arguments. * @param args.messenger - The messenger suited for this service. * @param args.environment - The environment to use for API requests. + * @param args.context - The context for API requests (e.g., 'mobile-ios', 'mobile-android'). * @param args.fetch - A function that can be used to make an HTTP request. If * your JavaScript environment supports `fetch` natively, you'll probably want * to pass that; otherwise you can pass an equivalent (such as `fetch` via @@ -166,11 +295,13 @@ export class RampsService { constructor({ messenger, environment = RampsEnvironment.Staging, + context, fetch: fetchFunction, policyOptions = {}, }: { messenger: RampsServiceMessenger; environment?: RampsEnvironment; + context: string; fetch: typeof fetch; policyOptions?: CreateServicePolicyOptions; }) { @@ -178,7 +309,8 @@ export class RampsService { this.#messenger = messenger; this.#fetch = fetchFunction; this.#policy = createServicePolicy(policyOptions); - this.#baseUrl = getBaseUrl(environment); + this.#environment = environment; + this.#context = context; this.#messenger.registerMethodActionHandlers( this, @@ -241,33 +373,120 @@ export class RampsService { } /** - * Makes a request to the API in order to retrieve the user's geolocation - * based on their IP address. + * Adds common request parameters to a URL. * - * @returns The user's country/region code (e.g., "US-UT" for Utah, USA). + * @param url - The URL to add parameters to. + * @param action - The ramp action type (optional, not all endpoints require it). */ - async getGeolocation(): Promise { - const responseData = await this.#policy.execute(async () => { - const url = new URL('geolocation', this.#baseUrl); - const localResponse = await this.#fetch(url); - if (!localResponse.ok) { + #addCommonParams(url: URL, action?: 'buy' | 'sell'): void { + if (action) { + url.searchParams.set('action', action); + } + url.searchParams.set('sdk', RAMPS_SDK_VERSION); + url.searchParams.set('controller', packageJson.version); + url.searchParams.set('context', this.#context); + } + + /** + * Makes an API request with retry policy and error handling. + * + * @param service - The API service type (determines base URL). + * @param path - The endpoint path. + * @param options - Request options. + * @param options.action - The ramp action type (optional). + * @param options.responseType - How to parse the response ('json' or 'text'). + * @returns The parsed response data. + */ + async #request( + service: RampsApiService, + path: string, + options: { + action?: 'buy' | 'sell'; + responseType: 'json' | 'text'; + }, + ): Promise { + return this.#policy.execute(async () => { + const baseUrl = getBaseUrl(this.#environment, service); + const url = new URL(path, baseUrl); + this.#addCommonParams(url, options.action); + + const response = await this.#fetch(url); + if (!response.ok) { throw new HttpError( - localResponse.status, - `Fetching '${url.toString()}' failed with status '${localResponse.status}'`, + response.status, + `Fetching '${url.toString()}' failed with status '${response.status}'`, ); } - const textResponse = await localResponse.text(); - // Return both response and text content since we consumed the body - return { response: localResponse, text: textResponse }; + + return options.responseType === 'json' + ? (response.json() as Promise) + : (response.text() as Promise); }); + } - const textResponse = responseData.text; - const trimmedResponse = textResponse.trim(); + /** + * Makes a request to the API in order to retrieve the user's geolocation + * based on their IP address. + * + * @returns The user's country/region code (e.g., "US-UT" for Utah, USA). + */ + async getGeolocation(): Promise { + const textResponse = await this.#request( + RampsApiService.Orders, + 'geolocation', + { responseType: 'text' }, + ); + const trimmedResponse = textResponse.trim(); if (trimmedResponse.length > 0) { return trimmedResponse; } throw new Error('Malformed response received from geolocation API'); } + + /** + * Makes a request to the cached API to retrieve the list of supported countries. + * 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: 'buy' | 'sell' = 'buy'): Promise { + const countries = await this.#request( + RampsApiService.Regions, + 'regions/countries', + { action, responseType: 'json' }, + ); + + if (!Array.isArray(countries)) { + throw new Error('Malformed response received from countries API'); + } + + return countries.filter((country) => { + if (country.states && country.states.length > 0) { + const hasSupportedState = country.states.some( + (state) => state.supported !== false, + ); + return country.supported || hasSupportedState; + } + + return country.supported; + }); + } + + /** + * Fetches eligibility information for a specific region. + * + * @param isoCode - The ISO code for the region (e.g., "us", "fr", "us-ny"). + * @returns Eligibility information for the region. + */ + async getEligibility(isoCode: string): Promise { + const normalizedIsoCode = isoCode.toLowerCase().trim(); + return this.#request( + RampsApiService.Regions, + `regions/countries/${normalizedIsoCode}`, + { responseType: 'json' }, + ); + } } diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 81b794a428f..390e650887b 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -15,9 +15,22 @@ export type { RampsServiceActions, RampsServiceEvents, RampsServiceMessenger, + Country, + State, + Eligibility, + CountryPhone, } from './RampsService'; -export { RampsService, RampsEnvironment } from './RampsService'; -export type { RampsServiceGetGeolocationAction } from './RampsService-method-action-types'; +export { + RampsService, + RampsEnvironment, + RampsApiService, + RAMPS_SDK_VERSION, +} from './RampsService'; +export type { + RampsServiceGetGeolocationAction, + RampsServiceGetCountriesAction, + RampsServiceGetEligibilityAction, +} from './RampsService-method-action-types'; export type { RequestCache, RequestState, diff --git a/packages/ramps-controller/src/selectors.test.ts b/packages/ramps-controller/src/selectors.test.ts index 090665e35fa..034d6b79012 100644 --- a/packages/ramps-controller/src/selectors.test.ts +++ b/packages/ramps-controller/src/selectors.test.ts @@ -25,6 +25,7 @@ describe('createRequestSelector', () => { const state: TestRootState = { ramps: { geolocation: null, + eligibility: null, requests: { 'getCryptoCurrencies:["US"]': loadingRequest, }, @@ -53,6 +54,7 @@ describe('createRequestSelector', () => { const state: TestRootState = { ramps: { geolocation: null, + eligibility: null, requests: { 'getCryptoCurrencies:["US"]': successRequest, }, @@ -84,6 +86,7 @@ describe('createRequestSelector', () => { const state: TestRootState = { ramps: { geolocation: null, + eligibility: null, requests: { 'getCryptoCurrencies:["US"]': errorRequest, }, @@ -111,6 +114,7 @@ describe('createRequestSelector', () => { const state: TestRootState = { ramps: { geolocation: null, + eligibility: null, requests: {}, }, }; @@ -161,6 +165,7 @@ describe('createRequestSelector', () => { const state: TestRootState = { ramps: { geolocation: null, + eligibility: null, requests: { 'getCryptoCurrencies:["US"]': successRequest, }, @@ -184,6 +189,7 @@ describe('createRequestSelector', () => { const state1: TestRootState = { ramps: { geolocation: null, + eligibility: null, requests: { 'getCryptoCurrencies:["US"]': successRequest1, }, @@ -196,6 +202,7 @@ describe('createRequestSelector', () => { const state2: TestRootState = { ramps: { geolocation: null, + eligibility: null, requests: { 'getCryptoCurrencies:["US"]': successRequest2, }, @@ -220,6 +227,7 @@ describe('createRequestSelector', () => { const state: TestRootState = { ramps: { geolocation: null, + eligibility: null, requests: { 'getCryptoCurrencies:["US"]': successRequest, }, @@ -247,6 +255,7 @@ describe('createRequestSelector', () => { const state: TestRootState = { ramps: { geolocation: null, + eligibility: null, requests: { 'getData:[]': successRequest, }, @@ -273,6 +282,7 @@ describe('createRequestSelector', () => { const loadingState: TestRootState = { ramps: { geolocation: null, + eligibility: null, requests: { 'getCryptoCurrencies:["US"]': loadingRequest, }, @@ -287,6 +297,7 @@ describe('createRequestSelector', () => { const successState: TestRootState = { ramps: { geolocation: null, + eligibility: null, requests: { 'getCryptoCurrencies:["US"]': successRequest, }, @@ -309,6 +320,7 @@ describe('createRequestSelector', () => { const successState: TestRootState = { ramps: { geolocation: null, + eligibility: null, requests: { 'getCryptoCurrencies:["US"]': successRequest, }, @@ -322,6 +334,7 @@ describe('createRequestSelector', () => { const errorState: TestRootState = { ramps: { geolocation: null, + eligibility: null, requests: { 'getCryptoCurrencies:["US"]': errorRequest, }, @@ -350,6 +363,7 @@ describe('createRequestSelector', () => { const state: TestRootState = { ramps: { geolocation: null, + eligibility: null, requests: { 'getCryptoCurrencies:["US"]': createSuccessState( ['ETH'], @@ -382,6 +396,7 @@ describe('createRequestSelector', () => { const state: TestRootState = { ramps: { geolocation: null, + eligibility: null, requests: { 'getCryptoCurrencies:["US"]': createSuccessState( ['ETH'], diff --git a/packages/ramps-controller/tsconfig.build.json b/packages/ramps-controller/tsconfig.build.json index df01f3c175b..a1560be7379 100644 --- a/packages/ramps-controller/tsconfig.build.json +++ b/packages/ramps-controller/tsconfig.build.json @@ -3,7 +3,8 @@ "compilerOptions": { "baseUrl": "./", "outDir": "./dist", - "rootDir": "./src" + "rootDir": "./src", + "resolveJsonModule": true }, "references": [ { diff --git a/packages/ramps-controller/tsconfig.json b/packages/ramps-controller/tsconfig.json index 68c3ddfc2cd..af410fec63d 100644 --- a/packages/ramps-controller/tsconfig.json +++ b/packages/ramps-controller/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "../../tsconfig.packages.json", "compilerOptions": { - "baseUrl": "./" + "baseUrl": "./", + "resolveJsonModule": true }, "references": [{ "path": "../base-controller" }, { "path": "../messenger" }], "include": ["../../types", "./src"]