From fcdc3bb1e121ad7407f75a87b5cdc092cf0125b5 Mon Sep 17 00:00:00 2001 From: salimtb Date: Wed, 29 Oct 2025 14:18:02 +0100 Subject: [PATCH 1/4] feat(assets-controllers): add searchTokens function and comprehensive tests --- packages/assets-controllers/src/index.ts | 1 + .../src/token-service.test.ts | 272 ++++++++++++++++++ .../assets-controllers/src/token-service.ts | 67 ++++- 3 files changed, 339 insertions(+), 1 deletion(-) diff --git a/packages/assets-controllers/src/index.ts b/packages/assets-controllers/src/index.ts index c7f64f8b2bd..22ed9936b6c 100644 --- a/packages/assets-controllers/src/index.ts +++ b/packages/assets-controllers/src/index.ts @@ -142,6 +142,7 @@ export { SUPPORTED_CHAIN_IDS, getNativeTokenAddress, } from './token-prices-service'; +export { searchTokens } from './token-service'; export { RatesController, Cryptocurrency } from './RatesController'; export type { RatesControllerState, diff --git a/packages/assets-controllers/src/token-service.test.ts b/packages/assets-controllers/src/token-service.test.ts index 05b4fa025eb..67519b45a55 100644 --- a/packages/assets-controllers/src/token-service.test.ts +++ b/packages/assets-controllers/src/token-service.test.ts @@ -1,9 +1,11 @@ import { toHex } from '@metamask/controller-utils'; +import type { CaipChainId } from '@metamask/utils'; import nock from 'nock'; import { fetchTokenListByChainId, fetchTokenMetadata, + searchTokens, TOKEN_END_POINT_API, TOKEN_METADATA_NO_SUPPORT_ERROR, } from './token-service'; @@ -234,8 +236,54 @@ const sampleToken = { name: 'Chainlink', }; +const sampleSearchResults = [ + { + address: '0xa0b86a33e6c166428cf041c73490a6b448b7f2c2', + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + occurrences: 12, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + 'uniswap', + ], + }, + { + address: '0xdac17f958d2ee523a2206206994597c13d831ec7', + symbol: 'USDT', + decimals: 6, + name: 'Tether USD', + occurrences: 11, + aggregators: [ + 'paraswap', + 'pmm', + 'airswapLight', + 'zeroEx', + 'bancor', + 'coinGecko', + 'zapper', + 'kleros', + 'zerion', + 'cmc', + 'oneInch', + ], + }, +]; + const sampleDecimalChainId = 1; const sampleChainId = toHex(sampleDecimalChainId); +const sampleCaipChainId: CaipChainId = 'eip155:1'; +const polygonCaipChainId: CaipChainId = 'eip155:137'; describe('Token service', () => { describe('fetchTokenListByChainId', () => { @@ -437,4 +485,228 @@ describe('Token service', () => { ).rejects.toThrow(TOKEN_METADATA_NO_SUPPORT_ERROR); }); }); + + describe('searchTokens', () => { + it('should call the search api and return the list of matching tokens for single chain', async () => { + const searchQuery = 'USD'; + const mockResponse = { + count: sampleSearchResults.length, + data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10`, + ) + .reply(200, mockResponse) + .persist(); + + const results = await searchTokens([sampleCaipChainId], searchQuery); + + expect(results).toStrictEqual({ + count: sampleSearchResults.length, + data: sampleSearchResults, + }); + }); + + it('should call the search api with custom limit parameter', async () => { + const searchQuery = 'USDC'; + const customLimit = 5; + const mockResponse = { + count: 1, + data: [sampleSearchResults[0]], + pageInfo: { hasNextPage: false, endCursor: null }, + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=${customLimit}`, + ) + .reply(200, mockResponse) + .persist(); + + const results = await searchTokens([sampleCaipChainId], searchQuery, { + limit: customLimit, + }); + + expect(results).toStrictEqual({ + count: 1, + data: [sampleSearchResults[0]], + }); + }); + + it('should properly encode search queries with special characters', async () => { + const searchQuery = 'USD Coin & Token'; + const encodedQuery = 'USD%20Coin%20%26%20Token'; + const mockResponse = { + count: sampleSearchResults.length, + data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${encodedQuery}&limit=10`, + ) + .reply(200, mockResponse) + .persist(); + + const results = await searchTokens([sampleCaipChainId], searchQuery); + + expect(results).toStrictEqual({ + count: sampleSearchResults.length, + data: sampleSearchResults, + }); + }); + + it('should search across multiple chains in a single request', async () => { + const searchQuery = 'USD'; + const encodedChainIds = [sampleCaipChainId, polygonCaipChainId] + .map((id) => encodeURIComponent(id)) + .join(','); + const mockResponse = { + count: sampleSearchResults.length, + data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?chainIds=${encodedChainIds}&query=${searchQuery}&limit=10`, + ) + .reply(200, mockResponse) + .persist(); + + const results = await searchTokens( + [sampleCaipChainId, polygonCaipChainId], + searchQuery, + ); + + expect(results).toStrictEqual({ + count: sampleSearchResults.length, + data: sampleSearchResults, + }); + }); + + it('should return empty array if the fetch fails with a network error', async () => { + const searchQuery = 'USD'; + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10`, + ) + .replyWithError('Example network error') + .persist(); + + const result = await searchTokens([sampleCaipChainId], searchQuery); + + expect(result).toStrictEqual({ count: 0, data: [] }); + }); + + it('should return empty array if the fetch fails with 400 error', async () => { + const searchQuery = 'USD'; + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10`, + ) + .reply(400, { error: 'Bad Request' }) + .persist(); + + const result = await searchTokens([sampleCaipChainId], searchQuery); + + expect(result).toStrictEqual({ count: 0, data: [] }); + }); + + it('should return empty array if the fetch fails with 500 error', async () => { + const searchQuery = 'USD'; + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10`, + ) + .reply(500) + .persist(); + + const result = await searchTokens([sampleCaipChainId], searchQuery); + + expect(result).toStrictEqual({ count: 0, data: [] }); + }); + + it('should handle empty search results', async () => { + const searchQuery = 'NONEXISTENT'; + const mockResponse = { + count: 0, + data: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10`, + ) + .reply(200, mockResponse) + .persist(); + + const results = await searchTokens([sampleCaipChainId], searchQuery); + + expect(results).toStrictEqual({ count: 0, data: [] }); + }); + + it('should return empty array when no chainIds are provided', async () => { + const searchQuery = 'USD'; + const results = await searchTokens([], searchQuery); + + expect(results).toStrictEqual({ count: 0, data: [] }); + }); + + it('should handle API error responses in JSON format', async () => { + const searchQuery = 'USD'; + const errorResponse = { error: 'Invalid search query' }; + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10`, + ) + .reply(200, errorResponse) + .persist(); + + const result = await searchTokens([sampleCaipChainId], searchQuery); + + // Non-array responses should be converted to empty object with count 0 + expect(result).toStrictEqual({ count: 0, data: [] }); + }); + + it('should handle supported CAIP format chain IDs', async () => { + const searchQuery = 'USD'; + const solanaChainId: CaipChainId = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; + const tronChainId: CaipChainId = 'tron:728126428'; + + const multiChainIds: CaipChainId[] = [ + sampleCaipChainId, + solanaChainId, + tronChainId, + ]; + const encodedChainIds = multiChainIds + .map((id) => encodeURIComponent(id)) + .join(','); + const mockResponse = { + count: sampleSearchResults.length, + data: sampleSearchResults, + pageInfo: { hasNextPage: false, endCursor: null }, + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?chainIds=${encodedChainIds}&query=${searchQuery}&limit=10`, + ) + .reply(200, mockResponse) + .persist(); + + const result = await searchTokens(multiChainIds, searchQuery); + + expect(result).toStrictEqual({ + count: sampleSearchResults.length, + data: sampleSearchResults, + }); + }); + }); }); diff --git a/packages/assets-controllers/src/token-service.ts b/packages/assets-controllers/src/token-service.ts index b347079e02d..e108c02c732 100644 --- a/packages/assets-controllers/src/token-service.ts +++ b/packages/assets-controllers/src/token-service.ts @@ -1,9 +1,10 @@ import { ChainId, convertHexToDecimal, + handleFetch, timeoutFetch, } from '@metamask/controller-utils'; -import type { Hex } from '@metamask/utils'; +import type { CaipChainId, Hex } from '@metamask/utils'; import { isTokenListSupportedForNetwork } from './assetsUtil'; @@ -41,6 +42,22 @@ function getTokenMetadataURL(chainId: Hex, tokenAddress: string) { )}?address=${tokenAddress}`; } +/** + * Get the token search URL for the given networks and search query. + * + * @param chainIds - Array of CAIP format chain IDs (e.g., 'eip155:1', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'). + * @param query - The search query (token name, symbol, or address). + * @param limit - Optional limit for the number of results (defaults to 10). + * @returns The token search URL. + */ +function getTokenSearchURL(chainIds: CaipChainId[], query: string, limit = 10) { + const encodedQuery = encodeURIComponent(query); + const encodedChainIds = chainIds + .map((id) => encodeURIComponent(id)) + .join(','); + return `${TOKEN_END_POINT_API}/tokens/search?chainIds=${encodedChainIds}&query=${encodedQuery}&limit=${limit}`; +} + const tenSecondsInMilliseconds = 10_000; // Token list averages 1.6 MB in size @@ -77,6 +94,54 @@ export async function fetchTokenListByChainId( return undefined; } +/** + * Search for tokens across one or more networks by query string using CAIP format chain IDs. + * + * @param chainIds - Array of CAIP format chain IDs (e.g., ['eip155:1', 'eip155:137', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp']). + * @param query - The search query (token name, symbol, or address). + * @param options - Additional fetch options. + * @param options.limit - The maximum number of results to return. + * @returns Object containing count and data array. Returns { count: 0, data: [] } if request fails. + */ +export async function searchTokens( + chainIds: CaipChainId[], + query: string, + { limit = 10 } = {}, +): Promise<{ count: number; data: unknown[] }> { + if (chainIds.length === 0) { + return { count: 0, data: [] }; + } + + const tokenSearchURL = getTokenSearchURL(chainIds, query, limit); + + try { + const result = await handleFetch(tokenSearchURL); + + // The API returns an object with structure: { count: number, data: array, pageInfo: object } + if (result && typeof result === 'object' && Array.isArray(result.data)) { + return { + count: result.count || result.data.length, + data: result.data, + }; + } + + // Fallback: if result is directly an array (for backwards compatibility) + if (Array.isArray(result)) { + return { + count: result.length, + data: result, + }; + } + + // Handle non-expected responses + return { count: 0, data: [] }; + } catch (error) { + // Handle 400 errors and other failures by returning count 0 and empty array + console.log('Search request failed:', error); + return { count: 0, data: [] }; + } +} + /** * Fetch metadata for the token address provided for a given network. This request is cancellable * using the abort signal passed in. From 670029c1348987a9934ca1f31dea223db7812b88 Mon Sep 17 00:00:00 2001 From: salimtb Date: Thu, 30 Oct 2025 10:17:23 +0100 Subject: [PATCH 2/4] fix: add changelog --- packages/assets-controllers/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index f66623a6f08..c9002f4fb61 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `fetchExchangeRates` function to fetch exchange rates from price-api ([#6863](https://github.com/MetaMask/core/pull/6863)) +- Added `searchTokens` function to search for tokens across multiple networks using CAIP format chain IDs ([#7004](https://github.com/MetaMask/core/pull/7004)) + ### Changed - `CurrencyRateController` now fetches exchange rates from price-api and fallback to cryptoCompare ([#6863](https://github.com/MetaMask/core/pull/6863)) From b852b0d86fb5ae0cf1b5a592d3240b3ae8459176 Mon Sep 17 00:00:00 2001 From: salimtb Date: Thu, 30 Oct 2025 10:23:56 +0100 Subject: [PATCH 3/4] fix: fix linter --- packages/assets-controllers/src/token-service.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/assets-controllers/src/token-service.test.ts b/packages/assets-controllers/src/token-service.test.ts index 67519b45a55..78b8ebff6fa 100644 --- a/packages/assets-controllers/src/token-service.test.ts +++ b/packages/assets-controllers/src/token-service.test.ts @@ -494,7 +494,7 @@ describe('Token service', () => { data: sampleSearchResults, pageInfo: { hasNextPage: false, endCursor: null }, }; - + nock(TOKEN_END_POINT_API) .get( `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10`, @@ -518,7 +518,7 @@ describe('Token service', () => { data: [sampleSearchResults[0]], pageInfo: { hasNextPage: false, endCursor: null }, }; - + nock(TOKEN_END_POINT_API) .get( `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=${customLimit}`, @@ -544,7 +544,7 @@ describe('Token service', () => { data: sampleSearchResults, pageInfo: { hasNextPage: false, endCursor: null }, }; - + nock(TOKEN_END_POINT_API) .get( `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${encodedQuery}&limit=10`, @@ -638,7 +638,7 @@ describe('Token service', () => { data: [], pageInfo: { hasNextPage: false, endCursor: null }, }; - + nock(TOKEN_END_POINT_API) .get( `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10`, From 676246526d9d0ec4bce9b5a76f76502de32558cb Mon Sep 17 00:00:00 2001 From: salimtb Date: Thu, 30 Oct 2025 10:55:54 +0100 Subject: [PATCH 4/4] fix: fix PR comments --- packages/assets-controllers/src/token-service.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/assets-controllers/src/token-service.ts b/packages/assets-controllers/src/token-service.ts index e108c02c732..24fc7228e81 100644 --- a/packages/assets-controllers/src/token-service.ts +++ b/packages/assets-controllers/src/token-service.ts @@ -125,14 +125,6 @@ export async function searchTokens( }; } - // Fallback: if result is directly an array (for backwards compatibility) - if (Array.isArray(result)) { - return { - count: result.length, - data: result, - }; - } - // Handle non-expected responses return { count: 0, data: [] }; } catch (error) {