diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index b6ce122cb47..3ee4f959405 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `getTokens()` method to RampsController for fetching available tokens by region and action ([#7607](https://github.com/MetaMask/core/pull/7607)) + ### Changed - **BREAKING:** Rename `geolocation` to `userRegion` and `updateGeolocation()` to `updateUserRegion()` in RampsController ([#7563](https://github.com/MetaMask/core/pull/7563)) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 45f76c1f232..f432bf90f7d 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -8,11 +8,12 @@ import type { import type { RampsControllerMessenger } from './RampsController'; import { RampsController } from './RampsController'; -import type { Country } from './RampsService'; +import type { Country, TokensResponse } from './RampsService'; import type { RampsServiceGetGeolocationAction, RampsServiceGetCountriesAction, RampsServiceGetEligibilityAction, + RampsServiceGetTokensAction, } from './RampsService-method-action-types'; import { RequestStatus, createCacheKey } from './RequestCache'; @@ -24,6 +25,7 @@ describe('RampsController', () => { Object { "eligibility": null, "requests": Object {}, + "tokens": null, "userRegion": null, } `); @@ -40,6 +42,7 @@ describe('RampsController', () => { ({ controller }) => { expect(controller.state).toStrictEqual({ eligibility: null, + tokens: null, userRegion: 'US', requests: {}, }); @@ -53,6 +56,7 @@ describe('RampsController', () => { Object { "eligibility": null, "requests": Object {}, + "tokens": null, "userRegion": null, } `); @@ -95,6 +99,7 @@ describe('RampsController', () => { Object { "eligibility": null, "requests": Object {}, + "tokens": null, "userRegion": null, } `); @@ -112,6 +117,7 @@ describe('RampsController', () => { ).toMatchInlineSnapshot(` Object { "eligibility": null, + "tokens": null, "userRegion": null, } `); @@ -129,6 +135,7 @@ describe('RampsController', () => { ).toMatchInlineSnapshot(` Object { "eligibility": null, + "tokens": null, "userRegion": null, } `); @@ -147,6 +154,7 @@ describe('RampsController', () => { Object { "eligibility": null, "requests": Object {}, + "tokens": null, "userRegion": null, } `); @@ -852,8 +860,13 @@ describe('RampsController', () => { }); describe('init', () => { - it('initializes controller by fetching user region', async () => { + it('initializes controller by fetching user region, eligibility, and tokens', async () => { await withController(async ({ controller, rootMessenger }) => { + const mockTokens: TokensResponse = { + topTokens: [], + allTokens: [], + }; + rootMessenger.registerActionHandler( 'RampsService:getGeolocation', async () => 'US', @@ -866,10 +879,20 @@ describe('RampsController', () => { global: true, }), ); + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async (_region: string, _action?: 'buy' | 'sell') => mockTokens, + ); await controller.init(); expect(controller.state.userRegion).toBe('us'); + expect(controller.state.eligibility).toStrictEqual({ + aggregator: true, + deposit: true, + global: true, + }); + expect(controller.state.tokens).toStrictEqual(mockTokens); }); }); @@ -885,6 +908,40 @@ describe('RampsController', () => { await controller.init(); expect(controller.state.userRegion).toBeNull(); + expect(controller.state.tokens).toBeNull(); + }); + }); + + it('handles token fetch failure gracefully when region is set', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getGeolocation', + async () => 'US', + ); + rootMessenger.registerActionHandler( + 'RampsService:getEligibility', + async () => ({ + aggregator: true, + deposit: true, + global: true, + }), + ); + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async (_region: string, _action?: 'buy' | 'sell') => { + throw new Error('Token fetch error'); + }, + ); + + await controller.init(); + + expect(controller.state.userRegion).toBe('us'); + expect(controller.state.eligibility).toStrictEqual({ + aggregator: true, + deposit: true, + global: true, + }); + expect(controller.state.tokens).toBeNull(); }); }); }); @@ -966,6 +1023,71 @@ describe('RampsController', () => { expect(controller.state.eligibility).toBeNull(); }); }); + + it('clears tokens when user region changes', async () => { + await withController(async ({ controller, rootMessenger }) => { + const mockTokens: TokensResponse = { + topTokens: [], + allTokens: [], + }; + + rootMessenger.registerActionHandler( + 'RampsService:getEligibility', + async () => ({ + aggregator: true, + deposit: true, + global: true, + }), + ); + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async (_region: string, _action?: 'buy' | 'sell') => mockTokens, + ); + + await controller.setUserRegion('US'); + await controller.getTokens('us', 'buy'); + expect(controller.state.tokens).toStrictEqual(mockTokens); + + await controller.setUserRegion('FR'); + expect(controller.state.tokens).toBeNull(); + }); + }); + + it('clears tokens when user region changes and eligibility fetch fails', async () => { + await withController(async ({ controller, rootMessenger }) => { + const mockTokens: TokensResponse = { + topTokens: [], + allTokens: [], + }; + + rootMessenger.registerActionHandler( + 'RampsService:getEligibility', + async (isoCode) => { + if (isoCode === 'us') { + return { + aggregator: true, + deposit: true, + global: true, + }; + } + throw new Error('Eligibility API error'); + }, + ); + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async (_region: string, _action?: 'buy' | 'sell') => mockTokens, + ); + + await controller.setUserRegion('US'); + await controller.getTokens('us', 'buy'); + expect(controller.state.tokens).toStrictEqual(mockTokens); + + await expect(controller.setUserRegion('FR')).rejects.toThrow( + 'Eligibility API error', + ); + expect(controller.state.tokens).toBeNull(); + }); + }); }); describe('updateUserRegion with automatic eligibility', () => { @@ -1107,6 +1229,400 @@ describe('RampsController', () => { expect(controller.state.eligibility).toStrictEqual(frEligibility); }); }); + + it('clears tokens when user region changes', async () => { + await withController(async ({ controller, rootMessenger }) => { + const mockTokens: TokensResponse = { + topTokens: [], + allTokens: [], + }; + + let geolocationResult = 'us'; + rootMessenger.registerActionHandler( + 'RampsService:getGeolocation', + async () => geolocationResult, + ); + rootMessenger.registerActionHandler( + 'RampsService:getEligibility', + async () => ({ + aggregator: true, + deposit: true, + global: true, + }), + ); + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async (_region: string, _action?: 'buy' | 'sell') => mockTokens, + ); + + await controller.updateUserRegion(); + await controller.getTokens('us', 'buy'); + expect(controller.state.tokens).toStrictEqual(mockTokens); + + geolocationResult = 'fr'; + + await controller.updateUserRegion({ forceRefresh: true }); + expect(controller.state.tokens).toBeNull(); + }); + }); + + it('clears tokens when user region changes and eligibility fetch fails', async () => { + await withController(async ({ controller, rootMessenger }) => { + const mockTokens: TokensResponse = { + topTokens: [], + allTokens: [], + }; + + let geolocationResult = 'us'; + let shouldThrowEligibilityError = false; + + rootMessenger.registerActionHandler( + 'RampsService:getGeolocation', + async () => geolocationResult, + ); + rootMessenger.registerActionHandler( + 'RampsService:getEligibility', + async () => { + if (shouldThrowEligibilityError) { + throw new Error('Eligibility API error'); + } + return { + aggregator: true, + deposit: true, + global: true, + }; + }, + ); + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async (_region: string, _action?: 'buy' | 'sell') => mockTokens, + ); + + await controller.updateUserRegion(); + await controller.getTokens('us', 'buy'); + expect(controller.state.tokens).toStrictEqual(mockTokens); + + geolocationResult = 'fr'; + shouldThrowEligibilityError = true; + + await controller.updateUserRegion({ forceRefresh: true }); + expect(controller.state.tokens).toBeNull(); + }); + }); + }); + + describe('getTokens', () => { + const mockTokens: TokensResponse = { + topTokens: [ + { + assetId: 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 'eip155:1', + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + iconUrl: 'https://example.com/usdc.png', + tokenSupported: true, + }, + ], + allTokens: [ + { + assetId: 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 'eip155:1', + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + iconUrl: 'https://example.com/usdc.png', + tokenSupported: true, + }, + { + assetId: 'eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7', + chainId: 'eip155:1', + name: 'Tether USD', + symbol: 'USDT', + decimals: 6, + iconUrl: 'https://example.com/usdt.png', + tokenSupported: true, + }, + ], + }; + + it('fetches tokens from the service', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async (_region: string, _action?: 'buy' | 'sell') => mockTokens, + ); + + expect(controller.state.tokens).toBeNull(); + + const tokens = await controller.getTokens('us', 'buy'); + + expect(tokens).toMatchInlineSnapshot(` + Object { + "allTokens": Array [ + Object { + "assetId": "eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "chainId": "eip155:1", + "decimals": 6, + "iconUrl": "https://example.com/usdc.png", + "name": "USD Coin", + "symbol": "USDC", + "tokenSupported": true, + }, + Object { + "assetId": "eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7", + "chainId": "eip155:1", + "decimals": 6, + "iconUrl": "https://example.com/usdt.png", + "name": "Tether USD", + "symbol": "USDT", + "tokenSupported": true, + }, + ], + "topTokens": Array [ + Object { + "assetId": "eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "chainId": "eip155:1", + "decimals": 6, + "iconUrl": "https://example.com/usdc.png", + "name": "USD Coin", + "symbol": "USDC", + "tokenSupported": true, + }, + ], + } + `); + expect(controller.state.tokens).toStrictEqual(mockTokens); + }); + }); + + it('caches tokens response', async () => { + await withController(async ({ controller, rootMessenger }) => { + let callCount = 0; + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async (_region: string, _action?: 'buy' | 'sell') => { + callCount += 1; + return mockTokens; + }, + ); + + await controller.getTokens('us', 'buy'); + await controller.getTokens('us', 'buy'); + + expect(callCount).toBe(1); + }); + }); + + it('fetches tokens with sell action', async () => { + await withController(async ({ controller, rootMessenger }) => { + let receivedAction: string | undefined; + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async (_region: string, action?: 'buy' | 'sell') => { + receivedAction = action; + return mockTokens; + }, + ); + + await controller.getTokens('us', '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:getTokens', + async (_region: string, action?: 'buy' | 'sell') => { + receivedAction = action; + return mockTokens; + }, + ); + + await controller.getTokens('us'); + + expect(receivedAction).toBe('buy'); + }); + }); + + it('normalizes region case for cache key consistency', async () => { + await withController(async ({ controller, rootMessenger }) => { + let callCount = 0; + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async (region: string, _action?: 'buy' | 'sell') => { + callCount += 1; + expect(region).toBe('us'); + return mockTokens; + }, + ); + + await controller.getTokens('US', 'buy'); + await controller.getTokens('us', 'buy'); + + expect(callCount).toBe(1); + }); + }); + + it('creates separate cache entries for different actions', async () => { + await withController(async ({ controller, rootMessenger }) => { + let callCount = 0; + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async (_region: string, _action?: 'buy' | 'sell') => { + callCount += 1; + return mockTokens; + }, + ); + + await controller.getTokens('us', 'buy'); + await controller.getTokens('us', 'sell'); + + expect(callCount).toBe(2); + }); + }); + + it('creates separate cache entries for different regions', async () => { + await withController(async ({ controller, rootMessenger }) => { + let callCount = 0; + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async (_region: string, _action?: 'buy' | 'sell') => { + callCount += 1; + return mockTokens; + }, + ); + + await controller.getTokens('us', 'buy'); + await controller.getTokens('fr', 'buy'); + + expect(callCount).toBe(2); + }); + }); + + it('uses userRegion from state when region is not provided', async () => { + await withController( + { options: { state: { userRegion: 'fr' } } }, + async ({ controller, rootMessenger }) => { + let receivedRegion: string | undefined; + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async (region: string, _action?: 'buy' | 'sell') => { + receivedRegion = region; + return mockTokens; + }, + ); + + await controller.getTokens(undefined, 'buy'); + + expect(receivedRegion).toBe('fr'); + }, + ); + }); + + it('throws error when region is not provided and userRegion is not set', async () => { + await withController(async ({ controller }) => { + await expect(controller.getTokens(undefined, 'buy')).rejects.toThrow( + 'Region is required. Either provide a region parameter or ensure userRegion is set in controller state.', + ); + }); + }); + + it('prefers provided region over userRegion in state', async () => { + await withController( + { options: { state: { userRegion: 'fr' } } }, + async ({ controller, rootMessenger }) => { + let receivedRegion: string | undefined; + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async (region: string, _action?: 'buy' | 'sell') => { + receivedRegion = region; + return mockTokens; + }, + ); + + await controller.getTokens('us', 'buy'); + + expect(receivedRegion).toBe('us'); + }, + ); + }); + + it('updates tokens when userRegion matches the requested region', async () => { + await withController( + { options: { state: { userRegion: 'us' } } }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async (region: string, _action?: 'buy' | 'sell') => { + expect(region).toBe('us'); + return mockTokens; + }, + ); + + expect(controller.state.userRegion).toBe('us'); + expect(controller.state.tokens).toBeNull(); + + await controller.getTokens('US'); + + expect(controller.state.tokens).toStrictEqual(mockTokens); + }, + ); + }); + + it('does not update tokens when userRegion does not match the requested region', async () => { + const existingTokens: TokensResponse = { + topTokens: [ + { + assetId: 'eip155:1/erc20:0xExisting', + chainId: 'eip155:1', + name: 'Existing Token', + symbol: 'EXIST', + decimals: 18, + iconUrl: 'https://example.com/exist.png', + tokenSupported: true, + }, + ], + allTokens: [ + { + assetId: 'eip155:1/erc20:0xExisting', + chainId: 'eip155:1', + name: 'Existing Token', + symbol: 'EXIST', + decimals: 18, + iconUrl: 'https://example.com/exist.png', + tokenSupported: true, + }, + ], + }; + + await withController( + { + options: { + state: { userRegion: 'us', tokens: existingTokens }, + }, + }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async (region: string, _action?: 'buy' | 'sell') => { + expect(region).toBe('fr'); + return mockTokens; + }, + ); + + expect(controller.state.userRegion).toBe('us'); + expect(controller.state.tokens).toStrictEqual(existingTokens); + + await controller.getTokens('fr'); + + expect(controller.state.tokens).toStrictEqual(existingTokens); + }, + ); + }); }); }); @@ -1119,7 +1635,8 @@ type RootMessenger = Messenger< | MessengerActions | RampsServiceGetGeolocationAction | RampsServiceGetCountriesAction - | RampsServiceGetEligibilityAction, + | RampsServiceGetEligibilityAction + | RampsServiceGetTokensAction, MessengerEvents >; @@ -1167,6 +1684,7 @@ function getMessenger(rootMessenger: RootMessenger): RampsControllerMessenger { 'RampsService:getGeolocation', 'RampsService:getCountries', 'RampsService:getEligibility', + 'RampsService:getTokens', ], }); return messenger; diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index f13955daa73..35e3576f8da 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -7,11 +7,12 @@ import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; import type { Json } from '@metamask/utils'; -import type { Country, Eligibility } from './RampsService'; +import type { Country, Eligibility, TokensResponse } from './RampsService'; import type { RampsServiceGetGeolocationAction, RampsServiceGetCountriesAction, RampsServiceGetEligibilityAction, + RampsServiceGetTokensAction, } from './RampsService-method-action-types'; import type { RequestCache as RequestCacheType, @@ -53,6 +54,11 @@ export type RampsControllerState = { * Eligibility information for the user's current region. */ eligibility: Eligibility | null; + /** + * Tokens fetched for the current region and action. + * Contains topTokens and allTokens arrays. + */ + tokens: TokensResponse | null; /** * Cache of request states, keyed by cache key. * This stores loading, success, and error states for API requests. @@ -76,6 +82,12 @@ const rampsControllerMetadata = { includeInStateLogs: true, usedInUi: true, }, + tokens: { + persist: true, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: true, + }, requests: { persist: false, includeInDebugSnapshot: true, @@ -96,6 +108,7 @@ export function getDefaultRampsControllerState(): RampsControllerState { return { userRegion: null, eligibility: null, + tokens: null, requests: {}, }; } @@ -121,7 +134,8 @@ export type RampsControllerActions = RampsControllerGetStateAction; type AllowedActions = | RampsServiceGetGeolocationAction | RampsServiceGetCountriesAction - | RampsServiceGetEligibilityAction; + | RampsServiceGetEligibilityAction + | RampsServiceGetTokensAction; /** * Published when the state of {@link RampsController} changes. @@ -413,6 +427,7 @@ export class RampsController extends BaseController< this.update((state) => { state.userRegion = normalizedRegion; + state.tokens = null; }); if (normalizedRegion) { @@ -423,6 +438,7 @@ export class RampsController extends BaseController< const currentUserRegion = state.userRegion?.toLowerCase().trim(); if (currentUserRegion === normalizedRegion) { state.eligibility = null; + state.tokens = null; } }); } @@ -447,6 +463,7 @@ export class RampsController extends BaseController< this.update((state) => { state.userRegion = normalizedRegion; + state.tokens = null; }); try { @@ -461,6 +478,7 @@ export class RampsController extends BaseController< const currentUserRegion = state.userRegion?.toLowerCase().trim(); if (currentUserRegion === normalizedRegion) { state.eligibility = null; + state.tokens = null; } }); throw error; @@ -470,14 +488,25 @@ 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 and eligibility is determined, tokens are fetched + * and saved to state. * * @param options - Options for cache behavior. * @returns Promise that resolves when initialization is complete. */ async init(options?: ExecuteRequestOptions): Promise { - await this.updateUserRegion(options).catch(() => { + const userRegion = await this.updateUserRegion(options).catch(() => { // User region fetch failed - error state will be available via selectors + return null; }); + + if (userRegion) { + try { + await this.getTokens(userRegion, 'buy', options); + } catch { + // Token fetch failed - error state will be available via selectors + } + } } /** @@ -537,4 +566,52 @@ export class RampsController extends BaseController< options, ); } + + /** + * Fetches the list of available tokens for a given region and action. + * The tokens are saved in the controller state once fetched. + * + * @param region - The region code (e.g., "us", "fr", "us-ny"). If not provided, uses the user's region from controller state. + * @param action - The ramp action type ('buy' or 'sell'). + * @param options - Options for cache behavior. + * @returns The tokens response containing topTokens and allTokens. + */ + async getTokens( + region?: string, + action: 'buy' | 'sell' = 'buy', + options?: ExecuteRequestOptions, + ): Promise { + const regionToUse = region ?? this.state.userRegion; + + if (!regionToUse) { + throw new Error( + 'Region is required. Either provide a region parameter or ensure userRegion is set in controller state.', + ); + } + + const normalizedRegion = regionToUse.toLowerCase().trim(); + const cacheKey = createCacheKey('getTokens', [normalizedRegion, action]); + + const tokens = await this.executeRequest( + cacheKey, + async () => { + return this.messenger.call( + 'RampsService:getTokens', + normalizedRegion, + action, + ); + }, + options, + ); + + this.update((state) => { + const userRegion = state.userRegion?.toLowerCase().trim(); + + if (userRegion === undefined || userRegion === normalizedRegion) { + state.tokens = tokens; + } + }); + + return tokens; + } } diff --git a/packages/ramps-controller/src/RampsService-method-action-types.ts b/packages/ramps-controller/src/RampsService-method-action-types.ts index 57a35bbdd66..51dcac442a5 100644 --- a/packages/ramps-controller/src/RampsService-method-action-types.ts +++ b/packages/ramps-controller/src/RampsService-method-action-types.ts @@ -39,10 +39,23 @@ export type RampsServiceGetEligibilityAction = { handler: RampsService['getEligibility']; }; +/** + * Fetches the list of available tokens for a given region and action. + * + * @param region - The region code (e.g., "us", "fr", "us-ny"). + * @param action - The ramp action type ('buy' or 'sell'). + * @returns The tokens response containing topTokens and allTokens. + */ +export type RampsServiceGetTokensAction = { + type: `RampsService:getTokens`; + handler: RampsService['getTokens']; +}; + /** * Union of all RampsService action types. */ export type RampsServiceMethodActions = | RampsServiceGetGeolocationAction | RampsServiceGetCountriesAction - | RampsServiceGetEligibilityAction; + | RampsServiceGetEligibilityAction + | RampsServiceGetTokensAction; diff --git a/packages/ramps-controller/src/RampsService.test.ts b/packages/ramps-controller/src/RampsService.test.ts index 34113af2d0f..0edf7962923 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -924,6 +924,302 @@ describe('RampsService', () => { expect(countriesResponse[0]?.states?.[0]?.supported).toBe(true); }); }); + + describe('getTokens', () => { + it('does the same thing as the messenger action', async () => { + const mockTokens = { + topTokens: [ + { + assetId: + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 'eip155:1', + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + iconUrl: 'https://example.com/usdc.png', + tokenSupported: true, + }, + ], + allTokens: [ + { + assetId: + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 'eip155:1', + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + iconUrl: 'https://example.com/usdc.png', + tokenSupported: true, + }, + ], + }; + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/us/tokens') + .query({ + action: 'buy', + sdk: '2.1.6', + controller: CONTROLLER_VERSION, + context: 'mobile-ios', + }) + .reply(200, mockTokens); + 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 tokensPromise = service.getTokens('us', 'buy'); + await clock.runAllAsync(); + await flushPromises(); + const tokensResponse = await tokensPromise; + + expect(tokensResponse).toMatchInlineSnapshot(` + Object { + "allTokens": Array [ + Object { + "assetId": "eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "chainId": "eip155:1", + "decimals": 6, + "iconUrl": "https://example.com/usdc.png", + "name": "USD Coin", + "symbol": "USDC", + "tokenSupported": true, + }, + ], + "topTokens": Array [ + Object { + "assetId": "eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "chainId": "eip155:1", + "decimals": 6, + "iconUrl": "https://example.com/usdc.png", + "name": "USD Coin", + "symbol": "USDC", + "tokenSupported": true, + }, + ], + } + `); + }); + + it('uses default buy action when no argument is provided', async () => { + const mockTokens = { + topTokens: [], + allTokens: [], + }; + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/us/tokens') + .query({ + action: 'buy', + sdk: '2.1.6', + controller: CONTROLLER_VERSION, + context: 'mobile-ios', + }) + .reply(200, mockTokens); + 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 tokensPromise = service.getTokens('us'); + await clock.runAllAsync(); + await flushPromises(); + const tokensResponse = await tokensPromise; + + expect(tokensResponse.topTokens).toStrictEqual([]); + expect(tokensResponse.allTokens).toStrictEqual([]); + }); + + it('normalizes region case', async () => { + const mockTokens = { + topTokens: [], + allTokens: [], + }; + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/us/tokens') + .query({ + action: 'buy', + sdk: '2.1.6', + controller: CONTROLLER_VERSION, + context: 'mobile-ios', + }) + .reply(200, mockTokens); + 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 tokensPromise = service.getTokens('US', 'buy'); + await clock.runAllAsync(); + await flushPromises(); + const tokensResponse = await tokensPromise; + + expect(tokensResponse.topTokens).toStrictEqual([]); + expect(tokensResponse.allTokens).toStrictEqual([]); + }); + + it('handles sell action', async () => { + const mockTokens = { + topTokens: [], + allTokens: [], + }; + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/us/tokens') + .query({ + action: 'sell', + sdk: '2.1.6', + controller: CONTROLLER_VERSION, + context: 'mobile-ios', + }) + .reply(200, mockTokens); + 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 tokensPromise = service.getTokens('us', 'sell'); + await clock.runAllAsync(); + await flushPromises(); + const tokensResponse = await tokensPromise; + + expect(tokensResponse.topTokens).toStrictEqual([]); + expect(tokensResponse.allTokens).toStrictEqual([]); + }); + + it('throws error for malformed response', async () => { + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/us/tokens') + .query({ + action: 'buy', + sdk: '2.1.6', + controller: CONTROLLER_VERSION, + context: 'mobile-ios', + }) + .reply(200, { invalid: 'response' }); + 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 tokensPromise = service.getTokens('us', 'buy'); + await clock.runAllAsync(); + await flushPromises(); + + await expect(tokensPromise).rejects.toThrow( + 'Malformed response received from tokens API', + ); + }); + + it('throws error when response is null', async () => { + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/us/tokens') + .query({ + action: 'buy', + sdk: '2.1.6', + controller: CONTROLLER_VERSION, + context: 'mobile-ios', + }) + .reply(200, () => null); + 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 tokensPromise = service.getTokens('us', 'buy'); + await clock.runAllAsync(); + await flushPromises(); + + await expect(tokensPromise).rejects.toThrow( + 'Malformed response received from tokens API', + ); + }); + + it('throws error when topTokens is not an array', async () => { + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/us/tokens') + .query({ + action: 'buy', + sdk: '2.1.6', + controller: CONTROLLER_VERSION, + context: 'mobile-ios', + }) + .reply(200, { topTokens: 'not an array', allTokens: [] }); + 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 tokensPromise = service.getTokens('us', 'buy'); + await clock.runAllAsync(); + await flushPromises(); + + await expect(tokensPromise).rejects.toThrow( + 'Malformed response received from tokens API', + ); + }); + + it('throws error when allTokens is not an array', async () => { + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/us/tokens') + .query({ + action: 'buy', + sdk: '2.1.6', + controller: CONTROLLER_VERSION, + context: 'mobile-ios', + }) + .reply(200, { topTokens: [], allTokens: 'not an array' }); + 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 tokensPromise = service.getTokens('us', 'buy'); + await clock.runAllAsync(); + await flushPromises(); + + await expect(tokensPromise).rejects.toThrow( + 'Malformed response received from tokens API', + ); + }); + }); }); /** diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index 549caf83a54..060406b8797 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -105,6 +105,54 @@ export type Country = { states?: State[]; }; +/** + * Represents a token returned from the regions/{region}/tokens API. + */ +export type RampsToken = { + /** + * The asset identifier in CAIP-19 format (e.g., "eip155:1/erc20:0x..."). + */ + assetId: string; + /** + * The chain identifier in CAIP-2 format (e.g., "eip155:1"). + */ + chainId: string; + /** + * Token name (e.g., "USD Coin"). + */ + name: string; + /** + * Token symbol (e.g., "USDC"). + */ + symbol: string; + /** + * Number of decimals for the token. + */ + decimals: number; + /** + * URL to the token icon. + */ + iconUrl: string; + /** + * Whether this token is supported. + */ + tokenSupported: boolean; +}; + +/** + * Response from the regions/{region}/tokens API. + */ +export type TokensResponse = { + /** + * Top/popular tokens for the region. + */ + topTokens: RampsToken[]; + /** + * All available tokens for the region. + */ + allTokens: RampsToken[]; +}; + /** * The SDK version to send with API requests. (backwards-compatibility) */ @@ -142,6 +190,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'getGeolocation', 'getCountries', 'getEligibility', + 'getTokens', ] as const; /** @@ -489,4 +538,36 @@ export class RampsService { { responseType: 'json' }, ); } + + /** + * Fetches the list of available tokens for a given region and action. + * + * @param region - The region code (e.g., "us", "fr", "us-ny"). + * @param action - The ramp action type ('buy' or 'sell'). + * @returns The tokens response containing topTokens and allTokens. + */ + async getTokens( + region: string, + action: 'buy' | 'sell' = 'buy', + ): Promise { + const normalizedRegion = region.toLowerCase().trim(); + const response = await this.#request( + RampsApiService.Regions, + `regions/${normalizedRegion}/tokens`, + { action, responseType: 'json' }, + ); + + if (!response || typeof response !== 'object') { + throw new Error('Malformed response received from tokens API'); + } + + if ( + !Array.isArray(response.topTokens) || + !Array.isArray(response.allTokens) + ) { + throw new Error('Malformed response received from tokens API'); + } + + return response; + } } diff --git a/packages/ramps-controller/src/selectors.test.ts b/packages/ramps-controller/src/selectors.test.ts index dfba6a4f472..07645b78069 100644 --- a/packages/ramps-controller/src/selectors.test.ts +++ b/packages/ramps-controller/src/selectors.test.ts @@ -26,6 +26,7 @@ describe('createRequestSelector', () => { ramps: { userRegion: null, eligibility: null, + tokens: null, requests: { 'getCryptoCurrencies:["US"]': loadingRequest, }, @@ -55,6 +56,7 @@ describe('createRequestSelector', () => { ramps: { userRegion: null, eligibility: null, + tokens: null, requests: { 'getCryptoCurrencies:["US"]': successRequest, }, @@ -87,6 +89,7 @@ describe('createRequestSelector', () => { ramps: { userRegion: null, eligibility: null, + tokens: null, requests: { 'getCryptoCurrencies:["US"]': errorRequest, }, @@ -115,6 +118,7 @@ describe('createRequestSelector', () => { ramps: { userRegion: null, eligibility: null, + tokens: null, requests: {}, }, }; @@ -166,6 +170,7 @@ describe('createRequestSelector', () => { ramps: { userRegion: null, eligibility: null, + tokens: null, requests: { 'getCryptoCurrencies:["US"]': successRequest, }, @@ -190,6 +195,7 @@ describe('createRequestSelector', () => { ramps: { userRegion: null, eligibility: null, + tokens: null, requests: { 'getCryptoCurrencies:["US"]': successRequest1, }, @@ -203,6 +209,7 @@ describe('createRequestSelector', () => { ramps: { userRegion: null, eligibility: null, + tokens: null, requests: { 'getCryptoCurrencies:["US"]': successRequest2, }, @@ -228,6 +235,7 @@ describe('createRequestSelector', () => { ramps: { userRegion: null, eligibility: null, + tokens: null, requests: { 'getCryptoCurrencies:["US"]': successRequest, }, @@ -256,6 +264,7 @@ describe('createRequestSelector', () => { ramps: { userRegion: null, eligibility: null, + tokens: null, requests: { 'getData:[]': successRequest, }, @@ -283,6 +292,7 @@ describe('createRequestSelector', () => { ramps: { userRegion: null, eligibility: null, + tokens: null, requests: { 'getCryptoCurrencies:["US"]': loadingRequest, }, @@ -298,6 +308,7 @@ describe('createRequestSelector', () => { ramps: { userRegion: null, eligibility: null, + tokens: null, requests: { 'getCryptoCurrencies:["US"]': successRequest, }, @@ -321,6 +332,7 @@ describe('createRequestSelector', () => { ramps: { userRegion: null, eligibility: null, + tokens: null, requests: { 'getCryptoCurrencies:["US"]': successRequest, }, @@ -335,6 +347,7 @@ describe('createRequestSelector', () => { ramps: { userRegion: null, eligibility: null, + tokens: null, requests: { 'getCryptoCurrencies:["US"]': errorRequest, }, @@ -364,6 +377,7 @@ describe('createRequestSelector', () => { ramps: { userRegion: null, eligibility: null, + tokens: null, requests: { 'getCryptoCurrencies:["US"]': createSuccessState( ['ETH'], @@ -397,6 +411,7 @@ describe('createRequestSelector', () => { ramps: { userRegion: null, eligibility: null, + tokens: null, requests: { 'getCryptoCurrencies:["US"]': createSuccessState( ['ETH'],