Skip to content
Open
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
5 changes: 5 additions & 0 deletions packages/core-backend/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add `fetchTokenSearch` and `getTokenSearchQueryOptions` to `TokenApiClient` for querying the Token API search endpoint ([#8822](https://github.com/MetaMask/core/pull/8822))
- Export `TokenSearchResult`, `TokenSearchPageInfo`, `TokenSearchResponse`, and `TokenSearchQueryOptions` types ([#8822](https://github.com/MetaMask/core/pull/8822))

## [6.3.0]

### Added
Expand Down
4 changes: 4 additions & 0 deletions packages/core-backend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ export { TokenApiClient } from './token';
export type {
TokenMetadata,
V1TokenDescriptionResponse,
TokenSearchResult,
TokenSearchPageInfo,
TokenSearchResponse,
TokenSearchQueryOptions,
NetworkInfo,
TopAsset,
TrendingSortBy,
Expand Down
115 changes: 113 additions & 2 deletions packages/core-backend/src/api/token/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
*/

import type { ApiPlatformClient } from '../ApiPlatformClient';
import { API_URLS } from '../shared-types';
import { API_URLS, GC_TIMES, STALE_TIMES } from '../shared-types';
import {
mockFetch,
createMockResponse,
setupTestEnvironment,
} from '../test-utils';
import type { NetworkInfo, TokenMetadata } from './types';
import type { NetworkInfo, TokenMetadata, TokenSearchResponse } from './types';

describe('TokenApiClient', () => {
let client: ApiPlatformClient;
Expand Down Expand Up @@ -134,6 +134,117 @@ describe('TokenApiClient', () => {
});
});

describe('Token Search', () => {
it('fetches token search results with query options', async () => {
const mockResponse: TokenSearchResponse = {
data: [
{
assetId:
'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
symbol: 'USDC',
decimals: 6,
name: 'USD Coin',
iconUrl: 'https://static.cx.metamask.io/api/v1/tokenIcons/1/usdc',
labels: ['stable_coin'],
},
],
count: 1,
totalCount: 1,
pageInfo: {
hasNextPage: false,
endCursor: '',
},
};
mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse));

const result = await client.token.fetchTokenSearch({
query: ' usdc ',
networks: ['eip155:137', 'eip155:1'],
first: 25,
after: 'MA==',
includeTokenSecurityData: true,
});

expect(result).toStrictEqual(mockResponse);

const calledUrl = new URL(mockFetch.mock.calls[0]?.[0] as string);
expect(calledUrl.origin).toBe(API_URLS.TOKEN);
expect(calledUrl.pathname).toBe('/tokens/search');
expect(calledUrl.searchParams.get('query')).toBe('usdc');
expect(calledUrl.searchParams.get('networks')).toBe(
'eip155:1,eip155:137',
);
expect(calledUrl.searchParams.get('first')).toBe('25');
expect(calledUrl.searchParams.get('after')).toBe('MA==');
expect(calledUrl.searchParams.get('includeTokenSecurityData')).toBe(
'true',
);
});

it('short-circuits empty token search queries', async () => {
const result = await client.token.fetchTokenSearch({
query: ' ',
});

expect(result).toStrictEqual({
data: [],
count: 0,
totalCount: 0,
pageInfo: {
hasNextPage: false,
endCursor: '',
},
});
expect(mockFetch).not.toHaveBeenCalled();
});

it('returns reusable query options for token search', () => {
const queryOptions = client.token.getTokenSearchQueryOptions({
query: ' usdc ',
networks: ['eip155:137', 'eip155:1'],
first: 25,
});

expect(queryOptions.queryKey).toStrictEqual([
'token',
'search',
{
query: 'usdc',
networks: ['eip155:1', 'eip155:137'],
first: 25,
},
]);
expect(typeof queryOptions.queryFn).toBe('function');
expect(queryOptions.staleTime).toBe(STALE_TIMES.DEFAULT);
expect(queryOptions.gcTime).toBe(GC_TIMES.DEFAULT);
});

it('getTokenSearchQueryOptions queryFn short-circuits empty queries without calling fetch', async () => {
const options = client.token.getTokenSearchQueryOptions({
query: ' ',
});
if (!options.queryFn) {
throw new Error('queryFn is required');
}
const result = await options.queryFn({
queryKey: options.queryKey,
signal: new AbortController().signal,
meta: undefined,
});

expect(result).toStrictEqual({
data: [],
count: 0,
totalCount: 0,
pageInfo: {
hasNextPage: false,
endCursor: '',
},
});
expect(mockFetch).not.toHaveBeenCalled();
});
});

describe('Token Metadata', () => {
it('fetches v1 token metadata', async () => {
const mockResponse: TokenMetadata = {
Expand Down
102 changes: 102 additions & 0 deletions packages/core-backend/src/api/token/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import type { FetchOptions } from '../shared-types';
import type {
TokenMetadata,
V1TokenDescriptionResponse,
TokenSearchQueryOptions,
TokenSearchResponse,
NetworkInfo,
TopAsset,
TrendingToken,
Expand All @@ -31,6 +33,31 @@ import type {
V1SuggestedOccurrenceFloorsResponse,
} from './types';

const getEmptyTokenSearchResponse = (): TokenSearchResponse => ({
data: [],
count: 0,
totalCount: 0,
pageInfo: {
hasNextPage: false,
endCursor: '',
},
});

const normalizeTokenSearchQueryOptions = ({
query,
networks,
...options
}: TokenSearchQueryOptions): TokenSearchQueryOptions => {
const sortedNetworks =
networks && networks.length > 0 ? [...networks].sort() : undefined;

return {
...options,
query: query.trim(),
networks: sortedNetworks,
};
};

/**
* Token API Client.
* Provides methods for interacting with the Token API.
Expand Down Expand Up @@ -219,6 +246,81 @@ export class TokenApiClient extends BaseApiClient {
);
}

// ==========================================================================
// TOKEN SEARCH
// ==========================================================================

/**
* Returns the TanStack Query options object for token search.
*
* @param queryOptions - Search query options.
* @param queryOptions.query - User-provided query string.
* @param queryOptions.networks - CAIP-2 chain IDs to constrain the search to.
* @param queryOptions.first - Maximum number of results to return.
* @param queryOptions.after - Cursor returned by a previous response for paging.
* @param queryOptions.includeTokenSecurityData - Whether to include token security data.
* @param options - Fetch options including cache settings.
* @returns Query options object compatible with fetchQuery/useQuery.
*/
getTokenSearchQueryOptions(
queryOptions: TokenSearchQueryOptions,
options?: FetchOptions,
): FetchQueryOptions<TokenSearchResponse> {
const normalizedQueryOptions =
normalizeTokenSearchQueryOptions(queryOptions);

return {
queryKey: ['token', 'search', normalizedQueryOptions],
queryFn: async ({
signal,
}: QueryFunctionContext): Promise<TokenSearchResponse> => {
if (!normalizedQueryOptions.query) {
return getEmptyTokenSearchResponse();
}

return this.fetch<TokenSearchResponse>(
API_URLS.TOKEN,
'/tokens/search',
{
signal,
params: normalizedQueryOptions,
},
);
},
...getQueryOptionsOverrides(options),
staleTime: options?.staleTime ?? STALE_TIMES.DEFAULT,
gcTime: options?.gcTime ?? GC_TIMES.DEFAULT,
};
}

/**
* Search for tokens by symbol, name, or address.
*
* @param queryOptions - Search query options.
* @param queryOptions.query - User-provided query string.
* @param queryOptions.networks - CAIP-2 chain IDs to constrain the search to.
* @param queryOptions.first - Maximum number of results to return.
* @param queryOptions.after - Cursor returned by a previous response for paging.
* @param queryOptions.includeTokenSecurityData - Whether to include token security data.
* @param options - Fetch options including cache settings.
* @returns The token search response.
*/
async fetchTokenSearch(
queryOptions: TokenSearchQueryOptions,
options?: FetchOptions,
): Promise<TokenSearchResponse> {
const normalizedQueryOptions =
normalizeTokenSearchQueryOptions(queryOptions);

if (!normalizedQueryOptions.query) {
return getEmptyTokenSearchResponse();
}

return this.queryClient.fetchQuery(
this.getTokenSearchQueryOptions(normalizedQueryOptions, options),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Double normalization of query options is redundant

Low Severity

fetchTokenSearch calls normalizeTokenSearchQueryOptions on the raw input, then passes the already-normalized result to getTokenSearchQueryOptions, which calls normalizeTokenSearchQueryOptions again. The normalization happens to be idempotent (trim/sort), so this is functionally harmless, but it's unnecessary work and deviates from how every other fetch* method in this class delegates to its corresponding get*QueryOptions — none of the others pre-normalize. The early-return check could use queryOptions.query.trim() inline instead, passing the original queryOptions through to getTokenSearchQueryOptions which already handles normalization.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2b7eaed. Configure here.

);
}

// ==========================================================================
// TOKEN METADATA
// ==========================================================================
Expand Down
4 changes: 4 additions & 0 deletions packages/core-backend/src/api/token/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ export { TokenApiClient } from './client';
export type {
TokenMetadata,
V1TokenDescriptionResponse,
TokenSearchResult,
TokenSearchPageInfo,
TokenSearchResponse,
TokenSearchQueryOptions,
NetworkInfo,
TopAsset,
TrendingSortBy,
Expand Down
58 changes: 58 additions & 0 deletions packages/core-backend/src/api/token/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,64 @@ export type V1TokenDescriptionResponse = {
description: string;
};

// ============================================================================
// TOKEN SEARCH TYPES
// ============================================================================

/**
* A single token returned by the Token API search endpoint.
*/
export type TokenSearchResult = {
/** CAIP-19 asset ID, e.g. "eip155:1/erc20:0x...". */
assetId: string;
/** Asset display name. */
name: string;
/** Asset symbol. */
symbol: string;
/** Decimal places. */
decimals: number;
/** Optional icon URL. */
iconUrl?: string;
/** Asset labels/tags, e.g. "stable_coin". */
labels?: string[];
/** Optional security data when requested with includeTokenSecurityData. */
securityData?: TokenSecurityData;
};

/**
* Cursor-based page information returned by the Token API search endpoint.
*/
export type TokenSearchPageInfo = {
hasNextPage: boolean;
endCursor: string;
};

/**
* Raw response payload from the Token API search endpoint.
*/
export type TokenSearchResponse = {
data: TokenSearchResult[];
count: number;
totalCount: number;
pageInfo: TokenSearchPageInfo;
};

/**
* Query options for the Token API search endpoint.
*/
export type TokenSearchQueryOptions = {
/** User-provided query string, such as a token symbol, name, or address. */
query: string;
/** CAIP-2 chain IDs to constrain the search to. */
networks?: string[];
/** Maximum number of results to return. */
first?: number;
/** Cursor returned by a previous response for paging. */
after?: string;
/** Whether to include token security data in each result. */
includeTokenSecurityData?: boolean;
};

// ============================================================================
// NETWORK TYPES
// ============================================================================
Expand Down
4 changes: 4 additions & 0 deletions packages/core-backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ export type {
// Token API types
TokenMetadata,
V1TokenDescriptionResponse,
TokenSearchResult,
TokenSearchPageInfo,
TokenSearchResponse,
TokenSearchQueryOptions,
NetworkInfo,
TopAsset,
TrendingSortBy,
Expand Down
Loading