Skip to content

Commit 70aeacd

Browse files
committed
Merge branch 'main' into release/653.0.0
2 parents b6c68f2 + 6aedb66 commit 70aeacd

File tree

15 files changed

+696
-51
lines changed

15 files changed

+696
-51
lines changed

packages/assets-controllers/CHANGELOG.md

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

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

3234
- 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)