From 85ba9591e049fed1aa65c5b6bed7fd48d8739644 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 12 Jan 2026 15:27:32 -0700 Subject: [PATCH 01/10] feat(ramps): adds a controller method for fetching ramps tokens --- .../src/RampsController.test.ts | 195 ++++++++++++- .../ramps-controller/src/RampsController.ts | 54 +++- .../src/RampsService-method-action-types.ts | 15 +- .../ramps-controller/src/RampsService.test.ts | 267 ++++++++++++++++++ packages/ramps-controller/src/RampsService.ts | 85 +++++- 5 files changed, 610 insertions(+), 6 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 45f76c1f232..c11dac1468d 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -8,7 +8,7 @@ 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, @@ -1108,6 +1108,199 @@ describe('RampsController', () => { }); }); }); + + 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 () => 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 () => { + callCount += 1; + return mockTokens; + }, + ); + + await controller.getTokens('us', 'buy'); + await controller.getTokens('us', 'buy'); + + expect(callCount).toBe(1); + }); + }); + + it('fetches tokens with deposit action', async () => { + await withController(async ({ controller, rootMessenger }) => { + let receivedAction: string | undefined; + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async (_region, action) => { + receivedAction = action; + return mockTokens; + }, + ); + + await controller.getTokens('us', 'deposit'); + + expect(receivedAction).toBe('deposit'); + }); + }); + + 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, action) => { + 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) => { + 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 () => { + callCount += 1; + return mockTokens; + }, + ); + + await controller.getTokens('us', 'buy'); + await controller.getTokens('us', 'deposit'); + + 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 () => { + callCount += 1; + return mockTokens; + }, + ); + + await controller.getTokens('us', 'buy'); + await controller.getTokens('fr', 'buy'); + + expect(callCount).toBe(2); + }); + }); + }); }); /** diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index f13955daa73..9c97ced7637 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. @@ -537,4 +551,40 @@ 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"). + * @param action - The ramp action type ('buy' or 'deposit'). + * @param options - Options for cache behavior. + * @returns The tokens response containing topTokens and allTokens. + */ + async getTokens( + region: string, + action: 'buy' | 'deposit' = 'buy', + options?: ExecuteRequestOptions, + ): Promise { + const normalizedRegion = region.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) => { + 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..07c2abe7255 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 'deposit'). + * @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..709488c7ad4 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -924,6 +924,273 @@ 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 deposit action', async () => { + const mockTokens = { + topTokens: [], + allTokens: [], + }; + nock('https://on-ramp-cache.uat-api.cx.metamask.io') + .get('/regions/us/tokens') + .query({ + action: 'deposit', + 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', 'deposit'); + 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 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..b9bf2be0305 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; /** @@ -378,7 +427,7 @@ export class RampsService { * @param url - The URL to add parameters to. * @param action - The ramp action type (optional, not all endpoints require it). */ - #addCommonParams(url: URL, action?: 'buy' | 'sell'): void { + #addCommonParams(url: URL, action?: 'buy' | 'sell' | 'deposit'): void { if (action) { url.searchParams.set('action', action); } @@ -401,7 +450,7 @@ export class RampsService { service: RampsApiService, path: string, options: { - action?: 'buy' | 'sell'; + action?: 'buy' | 'sell' | 'deposit'; responseType: 'json' | 'text'; }, ): Promise { @@ -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 'deposit'). + * @returns The tokens response containing topTokens and allTokens. + */ + async getTokens( + region: string, + action: 'buy' | 'deposit' = '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; + } } From 2dd4cb8ea072f61b7b5145e888e6733e55f48c71 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 12 Jan 2026 15:35:47 -0700 Subject: [PATCH 02/10] feat: use the controller region when fetching tokens --- .../src/RampsController.test.ts | 48 +++++++++++++++++++ .../ramps-controller/src/RampsController.ts | 14 ++++-- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index c11dac1468d..2187ac19997 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -1300,6 +1300,54 @@ describe('RampsController', () => { 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) => { + 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) => { + receivedRegion = region; + return mockTokens; + }, + ); + + await controller.getTokens('us', 'buy'); + + expect(receivedRegion).toBe('us'); + }, + ); + }); }); }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 9c97ced7637..23f6a031d1f 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -556,17 +556,25 @@ export class RampsController extends BaseController< * 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"). + * @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 'deposit'). * @param options - Options for cache behavior. * @returns The tokens response containing topTokens and allTokens. */ async getTokens( - region: string, + region?: string, action: 'buy' | 'deposit' = 'buy', options?: ExecuteRequestOptions, ): Promise { - const normalizedRegion = region.toLowerCase().trim(); + 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( From 30836ad13cc5c9b5b5d96cf379689cb24f12df15 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 12 Jan 2026 16:25:00 -0700 Subject: [PATCH 03/10] feat: removes deposit support for get tokens method --- packages/ramps-controller/src/RampsController.test.ts | 8 ++++---- packages/ramps-controller/src/RampsController.ts | 4 ++-- .../src/RampsService-method-action-types.ts | 2 +- packages/ramps-controller/src/RampsService.test.ts | 6 +++--- packages/ramps-controller/src/RampsService.ts | 8 ++++---- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 2187ac19997..2e8bd8c678b 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -1212,7 +1212,7 @@ describe('RampsController', () => { }); }); - it('fetches tokens with deposit action', async () => { + it('fetches tokens with sell action', async () => { await withController(async ({ controller, rootMessenger }) => { let receivedAction: string | undefined; rootMessenger.registerActionHandler( @@ -1223,9 +1223,9 @@ describe('RampsController', () => { }, ); - await controller.getTokens('us', 'deposit'); + await controller.getTokens('us', 'sell'); - expect(receivedAction).toBe('deposit'); + expect(receivedAction).toBe('sell'); }); }); @@ -1277,7 +1277,7 @@ describe('RampsController', () => { ); await controller.getTokens('us', 'buy'); - await controller.getTokens('us', 'deposit'); + await controller.getTokens('us', 'sell'); expect(callCount).toBe(2); }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 23f6a031d1f..79425a4470a 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -557,13 +557,13 @@ export class RampsController extends BaseController< * 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 'deposit'). + * @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' | 'deposit' = 'buy', + action: 'buy' | 'sell' = 'buy', options?: ExecuteRequestOptions, ): Promise { const regionToUse = region ?? this.state.userRegion; diff --git a/packages/ramps-controller/src/RampsService-method-action-types.ts b/packages/ramps-controller/src/RampsService-method-action-types.ts index 07c2abe7255..51dcac442a5 100644 --- a/packages/ramps-controller/src/RampsService-method-action-types.ts +++ b/packages/ramps-controller/src/RampsService-method-action-types.ts @@ -43,7 +43,7 @@ export type RampsServiceGetEligibilityAction = { * 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 'deposit'). + * @param action - The ramp action type ('buy' or 'sell'). * @returns The tokens response containing topTokens and allTokens. */ export type RampsServiceGetTokensAction = { diff --git a/packages/ramps-controller/src/RampsService.test.ts b/packages/ramps-controller/src/RampsService.test.ts index 709488c7ad4..1f5c558c5f2 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -1071,7 +1071,7 @@ describe('RampsService', () => { expect(tokensResponse.allTokens).toStrictEqual([]); }); - it('handles deposit action', async () => { + it('handles sell action', async () => { const mockTokens = { topTokens: [], allTokens: [], @@ -1079,7 +1079,7 @@ describe('RampsService', () => { nock('https://on-ramp-cache.uat-api.cx.metamask.io') .get('/regions/us/tokens') .query({ - action: 'deposit', + action: 'sell', sdk: '2.1.6', controller: CONTROLLER_VERSION, context: 'mobile-ios', @@ -1095,7 +1095,7 @@ describe('RampsService', () => { .reply(200, 'us'); const { service } = getService(); - const tokensPromise = service.getTokens('us', 'deposit'); + const tokensPromise = service.getTokens('us', 'sell'); await clock.runAllAsync(); await flushPromises(); const tokensResponse = await tokensPromise; diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index b9bf2be0305..060406b8797 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -427,7 +427,7 @@ export class RampsService { * @param url - The URL to add parameters to. * @param action - The ramp action type (optional, not all endpoints require it). */ - #addCommonParams(url: URL, action?: 'buy' | 'sell' | 'deposit'): void { + #addCommonParams(url: URL, action?: 'buy' | 'sell'): void { if (action) { url.searchParams.set('action', action); } @@ -450,7 +450,7 @@ export class RampsService { service: RampsApiService, path: string, options: { - action?: 'buy' | 'sell' | 'deposit'; + action?: 'buy' | 'sell'; responseType: 'json' | 'text'; }, ): Promise { @@ -543,12 +543,12 @@ export class RampsService { * 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 'deposit'). + * @param action - The ramp action type ('buy' or 'sell'). * @returns The tokens response containing topTokens and allTokens. */ async getTokens( region: string, - action: 'buy' | 'deposit' = 'buy', + action: 'buy' | 'sell' = 'buy', ): Promise { const normalizedRegion = region.toLowerCase().trim(); const response = await this.#request( From 54e34472764a8309783ec9f7918855f35d059ff9 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 12 Jan 2026 16:34:59 -0700 Subject: [PATCH 04/10] Fix: clear tokens on region change and fetch tokens in init --- .../src/RampsController.test.ts | 205 +++++++++++++++++- .../ramps-controller/src/RampsController.ts | 17 +- 2 files changed, 220 insertions(+), 2 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 2e8bd8c678b..ed57a305e21 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -24,6 +24,7 @@ describe('RampsController', () => { Object { "eligibility": null, "requests": Object {}, + "tokens": null, "userRegion": null, } `); @@ -40,6 +41,7 @@ describe('RampsController', () => { ({ controller }) => { expect(controller.state).toStrictEqual({ eligibility: null, + tokens: null, userRegion: 'US', requests: {}, }); @@ -53,6 +55,7 @@ describe('RampsController', () => { Object { "eligibility": null, "requests": Object {}, + "tokens": null, "userRegion": null, } `); @@ -95,6 +98,7 @@ describe('RampsController', () => { Object { "eligibility": null, "requests": Object {}, + "tokens": null, "userRegion": null, } `); @@ -112,6 +116,7 @@ describe('RampsController', () => { ).toMatchInlineSnapshot(` Object { "eligibility": null, + "tokens": null, "userRegion": null, } `); @@ -129,6 +134,7 @@ describe('RampsController', () => { ).toMatchInlineSnapshot(` Object { "eligibility": null, + "tokens": null, "userRegion": null, } `); @@ -147,6 +153,7 @@ describe('RampsController', () => { Object { "eligibility": null, "requests": Object {}, + "tokens": null, "userRegion": null, } `); @@ -852,8 +859,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 +878,20 @@ describe('RampsController', () => { global: true, }), ); + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async (_region, _action) => 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 +907,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 () => { + 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 +1022,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 () => 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 () => 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 +1228,88 @@ 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: [], + }; + + rootMessenger.registerActionHandler( + 'RampsService:getGeolocation', + async () => 'us', + ); + rootMessenger.registerActionHandler( + 'RampsService:getEligibility', + async () => ({ + aggregator: true, + deposit: true, + global: true, + }), + ); + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async () => mockTokens, + ); + + await controller.updateUserRegion(); + await controller.getTokens('us', 'buy'); + expect(controller.state.tokens).toStrictEqual(mockTokens); + + rootMessenger.registerActionHandler( + 'RampsService:getGeolocation', + async () => 'fr', + ); + + await controller.updateUserRegion(); + 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:getGeolocation', + async () => 'us', + ); + rootMessenger.registerActionHandler( + 'RampsService:getEligibility', + async () => ({ + aggregator: true, + deposit: true, + global: true, + }), + ); + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async () => mockTokens, + ); + + await controller.updateUserRegion(); + await controller.getTokens('us', 'buy'); + expect(controller.state.tokens).toStrictEqual(mockTokens); + + rootMessenger.registerActionHandler( + 'RampsService:getGeolocation', + async () => 'fr', + ); + rootMessenger.registerActionHandler( + 'RampsService:getEligibility', + async () => { + throw new Error('Eligibility API error'); + }, + ); + + await controller.updateUserRegion(); + expect(controller.state.tokens).toBeNull(); + }); + }); }); describe('getTokens', () => { diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 79425a4470a..0b05291b370 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -427,6 +427,7 @@ export class RampsController extends BaseController< this.update((state) => { state.userRegion = normalizedRegion; + state.tokens = null; }); if (normalizedRegion) { @@ -437,6 +438,7 @@ export class RampsController extends BaseController< const currentUserRegion = state.userRegion?.toLowerCase().trim(); if (currentUserRegion === normalizedRegion) { state.eligibility = null; + state.tokens = null; } }); } @@ -461,6 +463,7 @@ export class RampsController extends BaseController< this.update((state) => { state.userRegion = normalizedRegion; + state.tokens = null; }); try { @@ -475,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; @@ -484,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 + } + } } /** From cff0f301d54539716fabf55e952c249ba8ff5002 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 12 Jan 2026 16:39:22 -0700 Subject: [PATCH 05/10] Fix getTokens to only update state when region matches userRegion --- .../src/RampsController.test.ts | 73 +++++++++++++++++++ .../ramps-controller/src/RampsController.ts | 6 +- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index ed57a305e21..c338f5e6e70 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -1551,6 +1551,79 @@ describe('RampsController', () => { }, ); }); + + 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) => { + 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) => { + 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); + }, + ); + }); }); }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 0b05291b370..35e3576f8da 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -605,7 +605,11 @@ export class RampsController extends BaseController< ); this.update((state) => { - state.tokens = tokens; + const userRegion = state.userRegion?.toLowerCase().trim(); + + if (userRegion === undefined || userRegion === normalizedRegion) { + state.tokens = tokens; + } }); return tokens; From 78fd258e3096fd01a6d7a84b449ddff9ac4e37ca Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 12 Jan 2026 16:49:33 -0700 Subject: [PATCH 06/10] Fix selectors.test.ts: add missing tokens property to test state objects --- packages/ramps-controller/src/selectors.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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'], From 8a007c55299ff3bf6da75e7653f86130b04723bf Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 12 Jan 2026 16:59:25 -0700 Subject: [PATCH 07/10] chore: test fix --- .../src/RampsController.test.ts | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index c338f5e6e70..b95b04d5e7c 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -880,7 +880,7 @@ describe('RampsController', () => { ); rootMessenger.registerActionHandler( 'RampsService:getTokens', - async (_region, _action) => mockTokens, + async (_region: string, _action?: 'buy' | 'sell') => mockTokens, ); await controller.init(); @@ -927,7 +927,7 @@ describe('RampsController', () => { ); rootMessenger.registerActionHandler( 'RampsService:getTokens', - async () => { + async (_region: string, _action?: 'buy' | 'sell') => { throw new Error('Token fetch error'); }, ); @@ -1040,7 +1040,7 @@ describe('RampsController', () => { ); rootMessenger.registerActionHandler( 'RampsService:getTokens', - async () => mockTokens, + async (_region: string, _action?: 'buy' | 'sell') => mockTokens, ); await controller.setUserRegion('US'); @@ -1074,7 +1074,7 @@ describe('RampsController', () => { ); rootMessenger.registerActionHandler( 'RampsService:getTokens', - async () => mockTokens, + async (_region: string, _action?: 'buy' | 'sell') => mockTokens, ); await controller.setUserRegion('US'); @@ -1250,7 +1250,7 @@ describe('RampsController', () => { ); rootMessenger.registerActionHandler( 'RampsService:getTokens', - async () => mockTokens, + async (_region: string, _action?: 'buy' | 'sell') => mockTokens, ); await controller.updateUserRegion(); @@ -1288,7 +1288,7 @@ describe('RampsController', () => { ); rootMessenger.registerActionHandler( 'RampsService:getTokens', - async () => mockTokens, + async (_region: string, _action?: 'buy' | 'sell') => mockTokens, ); await controller.updateUserRegion(); @@ -1351,7 +1351,7 @@ describe('RampsController', () => { await withController(async ({ controller, rootMessenger }) => { rootMessenger.registerActionHandler( 'RampsService:getTokens', - async () => mockTokens, + async (_region: string, _action?: 'buy' | 'sell') => mockTokens, ); expect(controller.state.tokens).toBeNull(); @@ -1402,7 +1402,7 @@ describe('RampsController', () => { let callCount = 0; rootMessenger.registerActionHandler( 'RampsService:getTokens', - async () => { + async (_region: string, _action?: 'buy' | 'sell') => { callCount += 1; return mockTokens; }, @@ -1420,7 +1420,7 @@ describe('RampsController', () => { let receivedAction: string | undefined; rootMessenger.registerActionHandler( 'RampsService:getTokens', - async (_region, action) => { + async (_region: string, action?: 'buy' | 'sell') => { receivedAction = action; return mockTokens; }, @@ -1437,7 +1437,7 @@ describe('RampsController', () => { let receivedAction: string | undefined; rootMessenger.registerActionHandler( 'RampsService:getTokens', - async (_region, action) => { + async (_region: string, action?: 'buy' | 'sell') => { receivedAction = action; return mockTokens; }, @@ -1454,7 +1454,7 @@ describe('RampsController', () => { let callCount = 0; rootMessenger.registerActionHandler( 'RampsService:getTokens', - async (region) => { + async (region: string, _action?: 'buy' | 'sell') => { callCount += 1; expect(region).toBe('us'); return mockTokens; @@ -1473,7 +1473,7 @@ describe('RampsController', () => { let callCount = 0; rootMessenger.registerActionHandler( 'RampsService:getTokens', - async () => { + async (_region: string, _action?: 'buy' | 'sell') => { callCount += 1; return mockTokens; }, @@ -1491,7 +1491,7 @@ describe('RampsController', () => { let callCount = 0; rootMessenger.registerActionHandler( 'RampsService:getTokens', - async () => { + async (_region: string, _action?: 'buy' | 'sell') => { callCount += 1; return mockTokens; }, @@ -1511,7 +1511,7 @@ describe('RampsController', () => { let receivedRegion: string | undefined; rootMessenger.registerActionHandler( 'RampsService:getTokens', - async (region) => { + async (region: string, _action?: 'buy' | 'sell') => { receivedRegion = region; return mockTokens; }, @@ -1539,7 +1539,7 @@ describe('RampsController', () => { let receivedRegion: string | undefined; rootMessenger.registerActionHandler( 'RampsService:getTokens', - async (region) => { + async (region: string, _action?: 'buy' | 'sell') => { receivedRegion = region; return mockTokens; }, @@ -1558,7 +1558,7 @@ describe('RampsController', () => { async ({ controller, rootMessenger }) => { rootMessenger.registerActionHandler( 'RampsService:getTokens', - async (region) => { + async (region: string, _action?: 'buy' | 'sell') => { expect(region).toBe('us'); return mockTokens; }, @@ -1609,7 +1609,7 @@ describe('RampsController', () => { async ({ controller, rootMessenger }) => { rootMessenger.registerActionHandler( 'RampsService:getTokens', - async (region) => { + async (region: string, _action?: 'buy' | 'sell') => { expect(region).toBe('fr'); return mockTokens; }, From f8c06c1d93df3a50a5f1359d76a204aacf74438c Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 12 Jan 2026 17:02:26 -0700 Subject: [PATCH 08/10] chore: changelog --- packages/ramps-controller/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) 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)) From d7c92cea4b00b944696f7fbbe8f4af0cafd1f52c Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 12 Jan 2026 17:06:18 -0700 Subject: [PATCH 09/10] chore: test fix --- .../src/RampsController.test.ts | 49 ++++++++++--------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index b95b04d5e7c..f432bf90f7d 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -13,6 +13,7 @@ import type { RampsServiceGetGeolocationAction, RampsServiceGetCountriesAction, RampsServiceGetEligibilityAction, + RampsServiceGetTokensAction, } from './RampsService-method-action-types'; import { RequestStatus, createCacheKey } from './RequestCache'; @@ -1236,9 +1237,10 @@ describe('RampsController', () => { allTokens: [], }; + let geolocationResult = 'us'; rootMessenger.registerActionHandler( 'RampsService:getGeolocation', - async () => 'us', + async () => geolocationResult, ); rootMessenger.registerActionHandler( 'RampsService:getEligibility', @@ -1257,12 +1259,9 @@ describe('RampsController', () => { await controller.getTokens('us', 'buy'); expect(controller.state.tokens).toStrictEqual(mockTokens); - rootMessenger.registerActionHandler( - 'RampsService:getGeolocation', - async () => 'fr', - ); + geolocationResult = 'fr'; - await controller.updateUserRegion(); + await controller.updateUserRegion({ forceRefresh: true }); expect(controller.state.tokens).toBeNull(); }); }); @@ -1274,17 +1273,25 @@ describe('RampsController', () => { allTokens: [], }; + let geolocationResult = 'us'; + let shouldThrowEligibilityError = false; + rootMessenger.registerActionHandler( 'RampsService:getGeolocation', - async () => 'us', + async () => geolocationResult, ); rootMessenger.registerActionHandler( 'RampsService:getEligibility', - async () => ({ - aggregator: true, - deposit: true, - global: true, - }), + async () => { + if (shouldThrowEligibilityError) { + throw new Error('Eligibility API error'); + } + return { + aggregator: true, + deposit: true, + global: true, + }; + }, ); rootMessenger.registerActionHandler( 'RampsService:getTokens', @@ -1295,18 +1302,10 @@ describe('RampsController', () => { await controller.getTokens('us', 'buy'); expect(controller.state.tokens).toStrictEqual(mockTokens); - rootMessenger.registerActionHandler( - 'RampsService:getGeolocation', - async () => 'fr', - ); - rootMessenger.registerActionHandler( - 'RampsService:getEligibility', - async () => { - throw new Error('Eligibility API error'); - }, - ); + geolocationResult = 'fr'; + shouldThrowEligibilityError = true; - await controller.updateUserRegion(); + await controller.updateUserRegion({ forceRefresh: true }); expect(controller.state.tokens).toBeNull(); }); }); @@ -1636,7 +1635,8 @@ type RootMessenger = Messenger< | MessengerActions | RampsServiceGetGeolocationAction | RampsServiceGetCountriesAction - | RampsServiceGetEligibilityAction, + | RampsServiceGetEligibilityAction + | RampsServiceGetTokensAction, MessengerEvents >; @@ -1684,6 +1684,7 @@ function getMessenger(rootMessenger: RootMessenger): RampsControllerMessenger { 'RampsService:getGeolocation', 'RampsService:getCountries', 'RampsService:getEligibility', + 'RampsService:getTokens', ], }); return messenger; From 750f506dbe72802a81b891efa5084d2276c95e41 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Tue, 13 Jan 2026 06:34:35 -0700 Subject: [PATCH 10/10] chore: test fix --- .../ramps-controller/src/RampsService.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/ramps-controller/src/RampsService.test.ts b/packages/ramps-controller/src/RampsService.test.ts index 1f5c558c5f2..0edf7962923 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -1133,6 +1133,35 @@ describe('RampsService', () => { ); }); + 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')