From d1ddbee3cbf2b245384bba69b1b6eba6667a4dba Mon Sep 17 00:00:00 2001 From: Francis Nepomuceno Date: Thu, 14 May 2026 20:36:47 -0400 Subject: [PATCH 1/4] feat(core-backend): add token search API client --- packages/core-backend/CHANGELOG.md | 5 + packages/core-backend/src/api/index.ts | 4 + .../core-backend/src/api/token/client.test.ts | 90 ++++++++++++++++- packages/core-backend/src/api/token/client.ts | 98 +++++++++++++++++++ packages/core-backend/src/api/token/index.ts | 4 + packages/core-backend/src/api/token/types.ts | 58 +++++++++++ packages/core-backend/src/index.ts | 4 + 7 files changed, 261 insertions(+), 2 deletions(-) diff --git a/packages/core-backend/CHANGELOG.md b/packages/core-backend/CHANGELOG.md index ef66ee9b21..9a3613bf56 100644 --- a/packages/core-backend/CHANGELOG.md +++ b/packages/core-backend/CHANGELOG.md @@ -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. +- Export `TokenSearchResult`, `TokenSearchPageInfo`, `TokenSearchResponse`, and `TokenSearchQueryOptions` types. + ## [6.3.0] ### Added diff --git a/packages/core-backend/src/api/index.ts b/packages/core-backend/src/api/index.ts index c2b57cb085..17f76d0f06 100644 --- a/packages/core-backend/src/api/index.ts +++ b/packages/core-backend/src/api/index.ts @@ -61,6 +61,10 @@ export { TokenApiClient } from './token'; export type { TokenMetadata, V1TokenDescriptionResponse, + TokenSearchResult, + TokenSearchPageInfo, + TokenSearchResponse, + TokenSearchQueryOptions, NetworkInfo, TopAsset, TrendingSortBy, diff --git a/packages/core-backend/src/api/token/client.test.ts b/packages/core-backend/src/api/token/client.test.ts index d09900257f..4877b1ecb6 100644 --- a/packages/core-backend/src/api/token/client.test.ts +++ b/packages/core-backend/src/api/token/client.test.ts @@ -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; @@ -134,6 +134,92 @@ 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); + }); + }); + describe('Token Metadata', () => { it('fetches v1 token metadata', async () => { const mockResponse: TokenMetadata = { diff --git a/packages/core-backend/src/api/token/client.ts b/packages/core-backend/src/api/token/client.ts index 1194c0df48..418ff91446 100644 --- a/packages/core-backend/src/api/token/client.ts +++ b/packages/core-backend/src/api/token/client.ts @@ -23,6 +23,8 @@ import type { FetchOptions } from '../shared-types'; import type { TokenMetadata, V1TokenDescriptionResponse, + TokenSearchQueryOptions, + TokenSearchResponse, NetworkInfo, TopAsset, TrendingToken, @@ -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. @@ -219,6 +246,77 @@ 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 { + const normalizedQueryOptions = + normalizeTokenSearchQueryOptions(queryOptions); + + return { + queryKey: ['token', 'search', normalizedQueryOptions], + queryFn: async ({ + signal, + }: QueryFunctionContext): Promise => { + if (!normalizedQueryOptions.query) { + return getEmptyTokenSearchResponse(); + } + + return this.fetch(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 { + const normalizedQueryOptions = + normalizeTokenSearchQueryOptions(queryOptions); + + if (!normalizedQueryOptions.query) { + return getEmptyTokenSearchResponse(); + } + + return this.queryClient.fetchQuery( + this.getTokenSearchQueryOptions(normalizedQueryOptions, options), + ); + } + // ========================================================================== // TOKEN METADATA // ========================================================================== diff --git a/packages/core-backend/src/api/token/index.ts b/packages/core-backend/src/api/token/index.ts index dbf9047e99..d016cca348 100644 --- a/packages/core-backend/src/api/token/index.ts +++ b/packages/core-backend/src/api/token/index.ts @@ -6,6 +6,10 @@ export { TokenApiClient } from './client'; export type { TokenMetadata, V1TokenDescriptionResponse, + TokenSearchResult, + TokenSearchPageInfo, + TokenSearchResponse, + TokenSearchQueryOptions, NetworkInfo, TopAsset, TrendingSortBy, diff --git a/packages/core-backend/src/api/token/types.ts b/packages/core-backend/src/api/token/types.ts index 9bc9760761..7ab1bbffea 100644 --- a/packages/core-backend/src/api/token/types.ts +++ b/packages/core-backend/src/api/token/types.ts @@ -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 // ============================================================================ diff --git a/packages/core-backend/src/index.ts b/packages/core-backend/src/index.ts index 6197c068f8..2604b7443b 100644 --- a/packages/core-backend/src/index.ts +++ b/packages/core-backend/src/index.ts @@ -170,6 +170,10 @@ export type { // Token API types TokenMetadata, V1TokenDescriptionResponse, + TokenSearchResult, + TokenSearchPageInfo, + TokenSearchResponse, + TokenSearchQueryOptions, NetworkInfo, TopAsset, TrendingSortBy, From 4273658c3fd0e430e343dffb81ab33d0c0d41ab6 Mon Sep 17 00:00:00 2001 From: Francis Nepomuceno Date: Thu, 14 May 2026 20:41:11 -0400 Subject: [PATCH 2/4] docs(core-backend): link token search changelog --- packages/core-backend/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core-backend/CHANGELOG.md b/packages/core-backend/CHANGELOG.md index 9a3613bf56..49f71d71b2 100644 --- a/packages/core-backend/CHANGELOG.md +++ b/packages/core-backend/CHANGELOG.md @@ -9,8 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `fetchTokenSearch` and `getTokenSearchQueryOptions` to `TokenApiClient` for querying the Token API search endpoint. -- Export `TokenSearchResult`, `TokenSearchPageInfo`, `TokenSearchResponse`, and `TokenSearchQueryOptions` types. +- 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] From 2b7eaeddb9650d1ebd2b1224c0154811888f3521 Mon Sep 17 00:00:00 2001 From: Francis Nepomuceno Date: Thu, 14 May 2026 20:45:45 -0400 Subject: [PATCH 3/4] style(core-backend): format token search client --- packages/core-backend/src/api/token/client.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/core-backend/src/api/token/client.ts b/packages/core-backend/src/api/token/client.ts index 418ff91446..b2dd9cdc54 100644 --- a/packages/core-backend/src/api/token/client.ts +++ b/packages/core-backend/src/api/token/client.ts @@ -278,10 +278,14 @@ export class TokenApiClient extends BaseApiClient { return getEmptyTokenSearchResponse(); } - return this.fetch(API_URLS.TOKEN, '/tokens/search', { - signal, - params: normalizedQueryOptions, - }); + return this.fetch( + API_URLS.TOKEN, + '/tokens/search', + { + signal, + params: normalizedQueryOptions, + }, + ); }, ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.DEFAULT, From 77572bb946ddb67e7069417d8c1d7d1522482da5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 15 May 2026 01:52:26 +0000 Subject: [PATCH 4/4] test(core-backend): cover token search queryFn empty short-circuit The queryFn branch for normalized empty queries was only reachable via getTokenSearchQueryOptions; fetchTokenSearch returns early without invoking it, which dropped global branch coverage below the 99% Jest threshold. Co-authored-by: Francis Nepomuceno --- .../core-backend/src/api/token/client.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/core-backend/src/api/token/client.test.ts b/packages/core-backend/src/api/token/client.test.ts index 4877b1ecb6..3bd0344bc0 100644 --- a/packages/core-backend/src/api/token/client.test.ts +++ b/packages/core-backend/src/api/token/client.test.ts @@ -218,6 +218,31 @@ describe('TokenApiClient', () => { 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', () => {