Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/assets-controllers/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,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 `ignoreAssets` to allow ignoring assets for non-EVM chains ([#6981](https://github.com/MetaMask/core/pull/6981))

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

### Changed

- Bump `@metamask/controller-utils` from `^11.14.1` to `^11.15.0` ([#7003](https://github.com/MetaMask/core/pull/7003))
Expand Down
1 change: 1 addition & 0 deletions packages/assets-controllers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
272 changes: 272 additions & 0 deletions packages/assets-controllers/src/token-service.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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: [

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to send this over? Could we minimize the data we return to the UI?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant aggregators

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is required since it’s also part of the tokenList state. However, it’s not actually used in the UI , I just wanted to keep it consistent with the existing tokenList state structure.

'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', () => {
Expand Down Expand Up @@ -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,
});
});
});
});
59 changes: 58 additions & 1 deletion packages/assets-controllers/src/token-service.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -77,6 +94,46 @@ 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,
Comment thread
salimtb marked this conversation as resolved.
data: result.data,
Comment thread
salimtb marked this conversation as resolved.
};
}

// 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.
Expand Down
Loading