Skip to content

Commit 2011648

Browse files
authored
feat(assets-controllers): add searchTokens function (#7004)
## Explanation **What is the current state of things and why does it need to change?** Currently, the assets-controllers package provides functionality to fetch token lists and metadata for individual networks, but lacks the ability to search for tokens across multiple blockchain networks simultaneously. Users and applications need to search for tokens by name, symbol, or address across different chains (Ethereum, Polygon, Solana, etc.) in a unified way, which requires multiple separate API calls with the existing implementation. **What is the solution your changes offer and how does it work?** This PR introduces a new `searchTokens` function that enables cross-chain token search functionality using CAIP (Chain Agnostic Improvement Proposal) format chain IDs. The solution includes: 1. **New `searchTokens` function**: Accepts an array of CAIP chain IDs (e.g., `['eip155:1', 'eip155:137', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp']`) and a search query string 2. **URL construction helper**: `getTokenSearchURL` function that properly encodes chain IDs and query parameters 3. **Structured response**: Returns `{ count: number, data: unknown[] }` format for consistent API responses 4. **Error handling**: Gracefully handles API failures by returning empty results instead of throwing errors 5. **Configurable limits**: Supports customizable result limits (default: 10) The function makes a single API call to the `/tokens/search` endpoint, making it more efficient than multiple individual network searches. **Are there any changes whose purpose might not obvious to those unfamiliar with the domain?** - **CAIP format requirement**: The function specifically requires CAIP format chain IDs rather than hex chain IDs to support multi-chain protocols including non-EVM chains like Solana - **Error handling strategy**: The function returns `{ count: 0, data: [] }` on errors rather than throwing, which prevents search failures from breaking the user experience - **Response normalization**: The function handles both new API response format (object with count/data) and legacy format (direct array) for backwards compatibility ## References * Implements search token service functionality for cross-chain token discovery * Related to MetaMask's multi-chain token support initiative ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [ ] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduces `searchTokens` to query tokens across multiple networks using CAIP chain IDs, exports it, and adds comprehensive tests and changelog entry. > > - **Token service (`src/token-service.ts`)**: > - Add `searchTokens(chainIds: CaipChainId[], query, { limit })` using `/tokens/search` and `handleFetch`; returns `{ count, data }` with graceful error handling. > - Add helper `getTokenSearchURL` and CAIP chain ID support; import `CaipChainId`. > - **Exports**: > - Export `searchTokens` from `src/index.ts`. > - **Tests (`src/token-service.test.ts`)**: > - Add coverage for single/multi-chain searches, custom `limit`, query encoding, empty/invalid responses, and network/HTTP errors. > - **Changelog**: > - Document addition of `searchTokens` for multi-network searches via CAIP IDs. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit bf6224c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent e343f73 commit 2011648

File tree

4 files changed

+333
-1
lines changed

4 files changed

+333
-1
lines changed

packages/assets-controllers/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2525
- Added `fetchExchangeRates` function to fetch exchange rates from price-api ([#6863](https://github.com/MetaMask/core/pull/6863))
2626
- Added `ignoreAssets` to allow ignoring assets for non-EVM chains ([#6981](https://github.com/MetaMask/core/pull/6981))
2727

28+
- Added `searchTokens` function to search for tokens across multiple networks using CAIP format chain IDs ([#7004](https://github.com/MetaMask/core/pull/7004))
29+
2830
### Changed
2931

3032
- Bump `@metamask/controller-utils` from `^11.14.1` to `^11.15.0` ([#7003](https://github.com/MetaMask/core/pull/7003))

packages/assets-controllers/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ export {
142142
SUPPORTED_CHAIN_IDS,
143143
getNativeTokenAddress,
144144
} from './token-prices-service';
145+
export { searchTokens } from './token-service';
145146
export { RatesController, Cryptocurrency } from './RatesController';
146147
export type {
147148
RatesControllerState,

packages/assets-controllers/src/token-service.test.ts

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { toHex } from '@metamask/controller-utils';
2+
import type { CaipChainId } from '@metamask/utils';
23
import nock from 'nock';
34

45
import {
56
fetchTokenListByChainId,
67
fetchTokenMetadata,
8+
searchTokens,
79
TOKEN_END_POINT_API,
810
TOKEN_METADATA_NO_SUPPORT_ERROR,
911
} from './token-service';
@@ -234,8 +236,54 @@ const sampleToken = {
234236
name: 'Chainlink',
235237
};
236238

239+
const sampleSearchResults = [
240+
{
241+
address: '0xa0b86a33e6c166428cf041c73490a6b448b7f2c2',
242+
symbol: 'USDC',
243+
decimals: 6,
244+
name: 'USD Coin',
245+
occurrences: 12,
246+
aggregators: [
247+
'paraswap',
248+
'pmm',
249+
'airswapLight',
250+
'zeroEx',
251+
'bancor',
252+
'coinGecko',
253+
'zapper',
254+
'kleros',
255+
'zerion',
256+
'cmc',
257+
'oneInch',
258+
'uniswap',
259+
],
260+
},
261+
{
262+
address: '0xdac17f958d2ee523a2206206994597c13d831ec7',
263+
symbol: 'USDT',
264+
decimals: 6,
265+
name: 'Tether USD',
266+
occurrences: 11,
267+
aggregators: [
268+
'paraswap',
269+
'pmm',
270+
'airswapLight',
271+
'zeroEx',
272+
'bancor',
273+
'coinGecko',
274+
'zapper',
275+
'kleros',
276+
'zerion',
277+
'cmc',
278+
'oneInch',
279+
],
280+
},
281+
];
282+
237283
const sampleDecimalChainId = 1;
238284
const sampleChainId = toHex(sampleDecimalChainId);
285+
const sampleCaipChainId: CaipChainId = 'eip155:1';
286+
const polygonCaipChainId: CaipChainId = 'eip155:137';
239287

240288
describe('Token service', () => {
241289
describe('fetchTokenListByChainId', () => {
@@ -437,4 +485,228 @@ describe('Token service', () => {
437485
).rejects.toThrow(TOKEN_METADATA_NO_SUPPORT_ERROR);
438486
});
439487
});
488+
489+
describe('searchTokens', () => {
490+
it('should call the search api and return the list of matching tokens for single chain', async () => {
491+
const searchQuery = 'USD';
492+
const mockResponse = {
493+
count: sampleSearchResults.length,
494+
data: sampleSearchResults,
495+
pageInfo: { hasNextPage: false, endCursor: null },
496+
};
497+
498+
nock(TOKEN_END_POINT_API)
499+
.get(
500+
`/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10`,
501+
)
502+
.reply(200, mockResponse)
503+
.persist();
504+
505+
const results = await searchTokens([sampleCaipChainId], searchQuery);
506+
507+
expect(results).toStrictEqual({
508+
count: sampleSearchResults.length,
509+
data: sampleSearchResults,
510+
});
511+
});
512+
513+
it('should call the search api with custom limit parameter', async () => {
514+
const searchQuery = 'USDC';
515+
const customLimit = 5;
516+
const mockResponse = {
517+
count: 1,
518+
data: [sampleSearchResults[0]],
519+
pageInfo: { hasNextPage: false, endCursor: null },
520+
};
521+
522+
nock(TOKEN_END_POINT_API)
523+
.get(
524+
`/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=${customLimit}`,
525+
)
526+
.reply(200, mockResponse)
527+
.persist();
528+
529+
const results = await searchTokens([sampleCaipChainId], searchQuery, {
530+
limit: customLimit,
531+
});
532+
533+
expect(results).toStrictEqual({
534+
count: 1,
535+
data: [sampleSearchResults[0]],
536+
});
537+
});
538+
539+
it('should properly encode search queries with special characters', async () => {
540+
const searchQuery = 'USD Coin & Token';
541+
const encodedQuery = 'USD%20Coin%20%26%20Token';
542+
const mockResponse = {
543+
count: sampleSearchResults.length,
544+
data: sampleSearchResults,
545+
pageInfo: { hasNextPage: false, endCursor: null },
546+
};
547+
548+
nock(TOKEN_END_POINT_API)
549+
.get(
550+
`/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${encodedQuery}&limit=10`,
551+
)
552+
.reply(200, mockResponse)
553+
.persist();
554+
555+
const results = await searchTokens([sampleCaipChainId], searchQuery);
556+
557+
expect(results).toStrictEqual({
558+
count: sampleSearchResults.length,
559+
data: sampleSearchResults,
560+
});
561+
});
562+
563+
it('should search across multiple chains in a single request', async () => {
564+
const searchQuery = 'USD';
565+
const encodedChainIds = [sampleCaipChainId, polygonCaipChainId]
566+
.map((id) => encodeURIComponent(id))
567+
.join(',');
568+
const mockResponse = {
569+
count: sampleSearchResults.length,
570+
data: sampleSearchResults,
571+
pageInfo: { hasNextPage: false, endCursor: null },
572+
};
573+
574+
nock(TOKEN_END_POINT_API)
575+
.get(
576+
`/tokens/search?chainIds=${encodedChainIds}&query=${searchQuery}&limit=10`,
577+
)
578+
.reply(200, mockResponse)
579+
.persist();
580+
581+
const results = await searchTokens(
582+
[sampleCaipChainId, polygonCaipChainId],
583+
searchQuery,
584+
);
585+
586+
expect(results).toStrictEqual({
587+
count: sampleSearchResults.length,
588+
data: sampleSearchResults,
589+
});
590+
});
591+
592+
it('should return empty array if the fetch fails with a network error', async () => {
593+
const searchQuery = 'USD';
594+
nock(TOKEN_END_POINT_API)
595+
.get(
596+
`/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10`,
597+
)
598+
.replyWithError('Example network error')
599+
.persist();
600+
601+
const result = await searchTokens([sampleCaipChainId], searchQuery);
602+
603+
expect(result).toStrictEqual({ count: 0, data: [] });
604+
});
605+
606+
it('should return empty array if the fetch fails with 400 error', async () => {
607+
const searchQuery = 'USD';
608+
nock(TOKEN_END_POINT_API)
609+
.get(
610+
`/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10`,
611+
)
612+
.reply(400, { error: 'Bad Request' })
613+
.persist();
614+
615+
const result = await searchTokens([sampleCaipChainId], searchQuery);
616+
617+
expect(result).toStrictEqual({ count: 0, data: [] });
618+
});
619+
620+
it('should return empty array if the fetch fails with 500 error', async () => {
621+
const searchQuery = 'USD';
622+
nock(TOKEN_END_POINT_API)
623+
.get(
624+
`/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10`,
625+
)
626+
.reply(500)
627+
.persist();
628+
629+
const result = await searchTokens([sampleCaipChainId], searchQuery);
630+
631+
expect(result).toStrictEqual({ count: 0, data: [] });
632+
});
633+
634+
it('should handle empty search results', async () => {
635+
const searchQuery = 'NONEXISTENT';
636+
const mockResponse = {
637+
count: 0,
638+
data: [],
639+
pageInfo: { hasNextPage: false, endCursor: null },
640+
};
641+
642+
nock(TOKEN_END_POINT_API)
643+
.get(
644+
`/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10`,
645+
)
646+
.reply(200, mockResponse)
647+
.persist();
648+
649+
const results = await searchTokens([sampleCaipChainId], searchQuery);
650+
651+
expect(results).toStrictEqual({ count: 0, data: [] });
652+
});
653+
654+
it('should return empty array when no chainIds are provided', async () => {
655+
const searchQuery = 'USD';
656+
const results = await searchTokens([], searchQuery);
657+
658+
expect(results).toStrictEqual({ count: 0, data: [] });
659+
});
660+
661+
it('should handle API error responses in JSON format', async () => {
662+
const searchQuery = 'USD';
663+
const errorResponse = { error: 'Invalid search query' };
664+
nock(TOKEN_END_POINT_API)
665+
.get(
666+
`/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10`,
667+
)
668+
.reply(200, errorResponse)
669+
.persist();
670+
671+
const result = await searchTokens([sampleCaipChainId], searchQuery);
672+
673+
// Non-array responses should be converted to empty object with count 0
674+
expect(result).toStrictEqual({ count: 0, data: [] });
675+
});
676+
677+
it('should handle supported CAIP format chain IDs', async () => {
678+
const searchQuery = 'USD';
679+
const solanaChainId: CaipChainId =
680+
'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp';
681+
const tronChainId: CaipChainId = 'tron:728126428';
682+
683+
const multiChainIds: CaipChainId[] = [
684+
sampleCaipChainId,
685+
solanaChainId,
686+
tronChainId,
687+
];
688+
const encodedChainIds = multiChainIds
689+
.map((id) => encodeURIComponent(id))
690+
.join(',');
691+
const mockResponse = {
692+
count: sampleSearchResults.length,
693+
data: sampleSearchResults,
694+
pageInfo: { hasNextPage: false, endCursor: null },
695+
};
696+
697+
nock(TOKEN_END_POINT_API)
698+
.get(
699+
`/tokens/search?chainIds=${encodedChainIds}&query=${searchQuery}&limit=10`,
700+
)
701+
.reply(200, mockResponse)
702+
.persist();
703+
704+
const result = await searchTokens(multiChainIds, searchQuery);
705+
706+
expect(result).toStrictEqual({
707+
count: sampleSearchResults.length,
708+
data: sampleSearchResults,
709+
});
710+
});
711+
});
440712
});

packages/assets-controllers/src/token-service.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import {
22
ChainId,
33
convertHexToDecimal,
4+
handleFetch,
45
timeoutFetch,
56
} from '@metamask/controller-utils';
6-
import type { Hex } from '@metamask/utils';
7+
import type { CaipChainId, Hex } from '@metamask/utils';
78

89
import { isTokenListSupportedForNetwork } from './assetsUtil';
910

@@ -41,6 +42,22 @@ function getTokenMetadataURL(chainId: Hex, tokenAddress: string) {
4142
)}?address=${tokenAddress}`;
4243
}
4344

45+
/**
46+
* Get the token search URL for the given networks and search query.
47+
*
48+
* @param chainIds - Array of CAIP format chain IDs (e.g., 'eip155:1', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp').
49+
* @param query - The search query (token name, symbol, or address).
50+
* @param limit - Optional limit for the number of results (defaults to 10).
51+
* @returns The token search URL.
52+
*/
53+
function getTokenSearchURL(chainIds: CaipChainId[], query: string, limit = 10) {
54+
const encodedQuery = encodeURIComponent(query);
55+
const encodedChainIds = chainIds
56+
.map((id) => encodeURIComponent(id))
57+
.join(',');
58+
return `${TOKEN_END_POINT_API}/tokens/search?chainIds=${encodedChainIds}&query=${encodedQuery}&limit=${limit}`;
59+
}
60+
4461
const tenSecondsInMilliseconds = 10_000;
4562

4663
// Token list averages 1.6 MB in size
@@ -77,6 +94,46 @@ export async function fetchTokenListByChainId(
7794
return undefined;
7895
}
7996

97+
/**
98+
* Search for tokens across one or more networks by query string using CAIP format chain IDs.
99+
*
100+
* @param chainIds - Array of CAIP format chain IDs (e.g., ['eip155:1', 'eip155:137', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp']).
101+
* @param query - The search query (token name, symbol, or address).
102+
* @param options - Additional fetch options.
103+
* @param options.limit - The maximum number of results to return.
104+
* @returns Object containing count and data array. Returns { count: 0, data: [] } if request fails.
105+
*/
106+
export async function searchTokens(
107+
chainIds: CaipChainId[],
108+
query: string,
109+
{ limit = 10 } = {},
110+
): Promise<{ count: number; data: unknown[] }> {
111+
if (chainIds.length === 0) {
112+
return { count: 0, data: [] };
113+
}
114+
115+
const tokenSearchURL = getTokenSearchURL(chainIds, query, limit);
116+
117+
try {
118+
const result = await handleFetch(tokenSearchURL);
119+
120+
// The API returns an object with structure: { count: number, data: array, pageInfo: object }
121+
if (result && typeof result === 'object' && Array.isArray(result.data)) {
122+
return {
123+
count: result.count || result.data.length,
124+
data: result.data,
125+
};
126+
}
127+
128+
// Handle non-expected responses
129+
return { count: 0, data: [] };
130+
} catch (error) {
131+
// Handle 400 errors and other failures by returning count 0 and empty array
132+
console.log('Search request failed:', error);
133+
return { count: 0, data: [] };
134+
}
135+
}
136+
80137
/**
81138
* Fetch metadata for the token address provided for a given network. This request is cancellable
82139
* using the abort signal passed in.

0 commit comments

Comments
 (0)