diff --git a/packages/bridge-controller/src/bridge-controller.sse.test.ts b/packages/bridge-controller/src/bridge-controller.sse.test.ts index 75520fe174b..648d50dc2dd 100644 --- a/packages/bridge-controller/src/bridge-controller.sse.test.ts +++ b/packages/bridge-controller/src/bridge-controller.sse.test.ts @@ -115,6 +115,13 @@ describe('BridgeController SSE', function () { isActiveDest: true, }, }, + chainRanking: [ + { chainId: 'eip155:10' }, + { chainId: 'eip155:534352' }, + { chainId: 'eip155:137' }, + { chainId: 'eip155:42161' }, + { chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' }, + ], }); bridgeController = new BridgeController({ diff --git a/packages/bridge-controller/src/constants/bridge.ts b/packages/bridge-controller/src/constants/bridge.ts index 3d7f9fb2b4e..7f73d09d301 100644 --- a/packages/bridge-controller/src/constants/bridge.ts +++ b/packages/bridge-controller/src/constants/bridge.ts @@ -54,6 +54,7 @@ export const DEFAULT_FEATURE_FLAG_CONFIG: FeatureFlagsPlatformConfig = { maxRefreshCount: DEFAULT_MAX_REFRESH_COUNT, support: false, chains: {}, + chainRanking: [], }; export const DEFAULT_BRIDGE_CONTROLLER_STATE: BridgeControllerState = { diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index a1e8bef2b9e..a55235a72c0 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -52,6 +52,11 @@ export type { export { StatusTypes } from './types'; +export type { + BridgeAssetV2, + BridgeControllerGetStateAction, + BridgeControllerStateChangeEvent, +} from './types'; export { AssetType, SortOrder, @@ -59,8 +64,6 @@ export { RequestStatus, BridgeUserAction, BridgeBackgroundAction, - type BridgeControllerGetStateAction, - type BridgeControllerStateChangeEvent, } from './types'; export { @@ -123,7 +126,12 @@ export { export { calcLatestSrcBalance } from './utils/balance'; -export { fetchBridgeTokens, getClientHeaders } from './utils/fetch'; +export { + fetchBridgeTokens, + getClientHeaders, + fetchTokensBySearchQuery, + fetchPopularTokens, +} from './utils/fetch'; export { formatChainIdToCaip, diff --git a/packages/bridge-controller/src/selectors.test.ts b/packages/bridge-controller/src/selectors.test.ts index e46d2e843a8..6a368cb5c19 100644 --- a/packages/bridge-controller/src/selectors.test.ts +++ b/packages/bridge-controller/src/selectors.test.ts @@ -1081,6 +1081,7 @@ describe('Bridge Selectors', () => { refreshRate: 30000, chains: {}, support: false, + chainRanking: [], }); }); @@ -1096,6 +1097,7 @@ describe('Bridge Selectors', () => { refreshRate: 30000, chains: {}, support: false, + chainRanking: [], }); }); @@ -1111,6 +1113,7 @@ describe('Bridge Selectors', () => { maxRefreshCount: 5, refreshRate: 30000, chains: {}, + chainRanking: [], support: false, }); }); diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts index daf51b06fae..c2a864d0afb 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -29,9 +29,11 @@ import type { BRIDGE_CONTROLLER_NAME } from './constants/bridge'; import type { BitcoinTradeDataSchema, BridgeAssetSchema, + BridgeAssetV2Schema, ChainConfigurationSchema, FeatureId, FeeDataSchema, + MinimalAssetSchema, PlatformConfigSchema, ProtocolSchema, QuoteResponseSchema, @@ -81,7 +83,7 @@ export type TokenAmountValues = { /** * The amount of the token * - * @example "1000000000000000000" + * @example "1.000143200000000000" */ amount: string; /** @@ -157,6 +159,24 @@ export enum SortOrder { */ export type BridgeAsset = Infer; +export type TokenBalance = { + /** + * The users balance displayed in decimal-normalized form + * i.e. 1100000000000000000 is shown as `1.1` + */ + balance?: string; + /** + * The users balance in the user's selected currency + */ + tokenFiatAmount?: number | null; +}; + +/** + * This is the interface for the asset object returned by the bridge-api popular and search token endpoings + * This type is used in the fetchPopularTokens and fetchSearchTokens responses + */ +export type BridgeAssetV2 = Infer; +export type MinimalAsset = Infer; /** * This is the interface for the token object used in the extension client * In addition to the {@link BridgeAsset} fields, it includes balance information diff --git a/packages/bridge-controller/src/utils/feature-flags.test.ts b/packages/bridge-controller/src/utils/feature-flags.test.ts index 07bf3542190..5052eb095eb 100644 --- a/packages/bridge-controller/src/utils/feature-flags.test.ts +++ b/packages/bridge-controller/src/utils/feature-flags.test.ts @@ -357,6 +357,7 @@ describe('feature-flags', () => { support: false, minimumVersion: '0.0.0', chains: {}, + chainRanking: [], }; expect(result).toStrictEqual(expectedBridgeConfig); }); diff --git a/packages/bridge-controller/src/utils/fetch.test.ts b/packages/bridge-controller/src/utils/fetch.test.ts index 2411edeef49..73888e1db69 100644 --- a/packages/bridge-controller/src/utils/fetch.test.ts +++ b/packages/bridge-controller/src/utils/fetch.test.ts @@ -5,6 +5,8 @@ import { fetchBridgeQuotes, fetchBridgeTokens, fetchAssetPrices, + fetchPopularTokens, + fetchTokensBySearchQuery, } from './fetch'; import { FeatureId } from './validators'; import mockBridgeQuotesErc20Erc20 from '../../tests/mock-quotes-erc20-erc20.json'; @@ -148,6 +150,455 @@ describe('fetch', () => { }); }); + describe('fetchPopularTokens', () => { + const mockResponse = [ + { + assetId: 'eip155:10/slip44:614', + symbol: 'ETH', + decimals: 18, + name: 'Ether', + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/10/native/614.png', + chainId: 'eip155:10', + }, + { + assetId: 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + symbol: 'ABC', + name: 'ABC', + decimals: 16, + chainId: 'eip155:10', + }, + { + assetId: 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f985', + symbol: 'XYZ', + name: 'XYZ', + decimals: 6, + chainId: 'eip155:10', + }, + { + assetId: 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f986', + decimals: 8, + symbol: 'DEF', + name: 'DEF', + chainId: 'eip155:10', + }, + { + assetId: 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f987', + decimals: 9, + symbol: 'GHI', + name: 'GHI', + chainId: 'eip155:10', + }, + { + symbol: 'JKL', + decimals: 16, + chainId: 10, + }, + ]; + + it('should fetch popular tokens successfully', async () => { + mockFetchFn.mockResolvedValue(mockResponse); + const { signal } = new AbortController(); + const result = await fetchPopularTokens({ + signal, + chainIds: ['eip155:10'], + clientId: BridgeClientId.EXTENSION, + fetchFn: mockFetchFn, + bridgeApiBaseUrl: BRIDGE_PROD_API_BASE_URL, + clientVersion: '1.0.0', + }); + + expect(mockFetchFn).toHaveBeenCalledWith( + 'https://bridge.api.cx.metamask.io/getTokens/popular', + { + method: 'POST', + body: JSON.stringify({ + chainIds: ['eip155:10'], + }), + signal, + headers: { + 'X-Client-Id': 'extension', + 'Client-Version': '1.0.0', + 'Content-Type': 'application/json', + }, + }, + ); + + expect(result).toStrictEqual(mockResponse.slice(0, -1)); + }); + + it('should fetch popular tokens with balances successfully', async () => { + mockFetchFn.mockResolvedValue(mockResponse); + const { signal } = new AbortController(); + const result = await fetchPopularTokens({ + signal, + chainIds: ['eip155:10'], + assetsWithBalances: [ + { + assetId: + 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201F986', + decimals: 8, + symbol: 'DEF', + name: 'DEF', + }, + { + assetId: 'eip155:10/slip44:614', + symbol: 'ETH', + decimals: 18, + name: 'Ether', + }, + ], + clientId: BridgeClientId.EXTENSION, + fetchFn: mockFetchFn, + bridgeApiBaseUrl: BRIDGE_PROD_API_BASE_URL, + clientVersion: '1.0.0', + }); + + expect(mockFetchFn).toHaveBeenCalledWith( + 'https://bridge.api.cx.metamask.io/getTokens/popular', + { + method: 'POST', + body: '{"chainIds":["eip155:10"],"includeAssets":[{"assetId":"eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201F986","decimals":8,"symbol":"DEF","name":"DEF"},{"assetId":"eip155:10/slip44:614","symbol":"ETH","decimals":18,"name":"Ether"}]}', + headers: { + 'Client-Version': '1.0.0', + 'Content-Type': 'application/json', + 'X-Client-Id': 'extension', + }, + signal, + }, + ); + + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "assetId": "eip155:10/slip44:614", + "chainId": "eip155:10", + "decimals": 18, + "image": "https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/10/native/614.png", + "name": "Ether", + "symbol": "ETH", + }, + Object { + "assetId": "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984", + "chainId": "eip155:10", + "decimals": 16, + "name": "ABC", + "symbol": "ABC", + }, + Object { + "assetId": "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f985", + "chainId": "eip155:10", + "decimals": 6, + "name": "XYZ", + "symbol": "XYZ", + }, + Object { + "assetId": "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f986", + "chainId": "eip155:10", + "decimals": 8, + "name": "DEF", + "symbol": "DEF", + }, + Object { + "assetId": "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f987", + "chainId": "eip155:10", + "decimals": 9, + "name": "GHI", + "symbol": "GHI", + }, + ] + `); + }); + + it('should handle fetch error', async () => { + const mockError = new Error('Failed to fetch'); + + mockFetchFn.mockRejectedValue(mockError); + + await expect( + fetchPopularTokens({ + signal: new AbortController().signal, + chainIds: ['eip155:10'], + clientId: BridgeClientId.EXTENSION, + fetchFn: mockFetchFn, + bridgeApiBaseUrl: BRIDGE_PROD_API_BASE_URL, + clientVersion: '1.0.0', + }), + ).rejects.toThrow(mockError); + }); + }); + + describe('fetchTokensBySearchQuery', () => { + const mockResponse = [ + { + assetId: 'eip155:10/slip44:614', + symbol: 'ETH', + decimals: 18, + name: 'Ether', + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/10/native/614.png', + chainId: 'eip155:10', + }, + { + assetId: 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + symbol: 'ABC', + name: 'ABC', + decimals: 16, + chainId: 'eip155:10', + }, + { + assetId: 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f985', + symbol: 'XYZ', + name: 'XYZ', + decimals: 6, + chainId: 'eip155:10', + }, + { + assetId: 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f986', + decimals: 8, + symbol: 'DEF', + name: 'DEF', + chainId: 'eip155:10', + }, + { + assetId: 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f987', + decimals: 9, + symbol: 'GHI', + name: 'GHI', + chainId: 'eip155:10', + }, + { + symbol: 'JKL', + decimals: 16, + chainId: 10, + }, + ]; + + it('should fetch popular tokens successfully', async () => { + mockFetchFn.mockResolvedValueOnce({ + data: mockResponse.slice(0, 2), + pageInfo: { hasNextPage: true, endCursor: '123' }, + headers: { + 'Content-Type': 'application/json', + }, + status: 200, + }); + mockFetchFn.mockResolvedValueOnce({ + data: mockResponse.slice(2, 4), + pageInfo: { hasNextPage: true, endCursor: 'BA==' }, + }); + mockFetchFn.mockResolvedValueOnce({ + data: mockResponse.slice(4), + pageInfo: { hasNextPage: false, endCursor: 'CB==' }, + }); + + let result: unknown[] = []; + const request = fetchTokensBySearchQuery({ + signal: new AbortController().signal, + query: 'ABC', + chainIds: ['eip155:10'], + clientId: BridgeClientId.EXTENSION, + fetchFn: mockFetchFn, + bridgeApiBaseUrl: BRIDGE_PROD_API_BASE_URL, + clientVersion: '1.0.0', + }); + for await (const tokens of request) { + result = result.concat(tokens); + } + + expect(mockFetchFn.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "https://bridge.api.cx.metamask.io/getTokens/search", + Object { + "body": "{\\"chainIds\\":[\\"eip155:10\\"],\\"query\\":\\"ABC\\"}", + "headers": Object { + "Client-Version": "1.0.0", + "Content-Type": "application/json", + "X-Client-Id": "extension", + }, + "method": "POST", + "signal": AbortSignal { + Symbol(kEvents): Map {}, + Symbol(events.maxEventTargetListeners): 0, + Symbol(events.maxEventTargetListenersWarned): false, + Symbol(kHandlers): Map {}, + Symbol(kAborted): false, + Symbol(kReason): undefined, + Symbol(kComposite): false, + }, + }, + ], + Array [ + "https://bridge.api.cx.metamask.io/getTokens/search", + Object { + "body": "{\\"chainIds\\":[\\"eip155:10\\"],\\"after\\":\\"123\\",\\"query\\":\\"ABC\\"}", + "headers": Object { + "Client-Version": "1.0.0", + "Content-Type": "application/json", + "X-Client-Id": "extension", + }, + "method": "POST", + "signal": AbortSignal { + Symbol(kEvents): Map {}, + Symbol(events.maxEventTargetListeners): 0, + Symbol(events.maxEventTargetListenersWarned): false, + Symbol(kHandlers): Map {}, + Symbol(kAborted): false, + Symbol(kReason): undefined, + Symbol(kComposite): false, + }, + }, + ], + Array [ + "https://bridge.api.cx.metamask.io/getTokens/search", + Object { + "body": "{\\"chainIds\\":[\\"eip155:10\\"],\\"after\\":\\"BA==\\",\\"query\\":\\"ABC\\"}", + "headers": Object { + "Client-Version": "1.0.0", + "Content-Type": "application/json", + "X-Client-Id": "extension", + }, + "method": "POST", + "signal": AbortSignal { + Symbol(kEvents): Map {}, + Symbol(events.maxEventTargetListeners): 0, + Symbol(events.maxEventTargetListenersWarned): false, + Symbol(kHandlers): Map {}, + Symbol(kAborted): false, + Symbol(kReason): undefined, + Symbol(kComposite): false, + }, + }, + ], + ] + `); + expect(result).toStrictEqual(mockResponse.slice(0, -1)); + }); + + it('should fetch popular tokens with balances successfully', async () => { + mockFetchFn.mockResolvedValueOnce({ + data: mockResponse.slice(0, 2), + pageInfo: { hasNextPage: true, endCursor: '123' }, + headers: { + 'Content-Type': 'application/json', + }, + status: 200, + }); + mockFetchFn.mockResolvedValueOnce({ + data: mockResponse.slice(2, 4), + pageInfo: { hasNextPage: true, endCursor: 'BA==' }, + }); + mockFetchFn.mockResolvedValueOnce({ + data: mockResponse.slice(4), + pageInfo: { hasNextPage: false, endCursor: 'CB==' }, + }); + + let result: unknown[] = []; + const request = fetchTokensBySearchQuery({ + signal: new AbortController().signal, + query: 'ABC', + chainIds: ['eip155:10'], + assetsWithBalances: [ + { + assetId: + 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201F986', + decimals: 8, + symbol: 'DEF', + name: 'DEF', + }, + { + assetId: 'eip155:10/slip44:614', + symbol: 'ETH', + decimals: 18, + name: 'Ether', + }, + ], + clientId: BridgeClientId.EXTENSION, + fetchFn: mockFetchFn, + bridgeApiBaseUrl: BRIDGE_PROD_API_BASE_URL, + clientVersion: '1.0.0', + }); + for await (const tokens of request) { + result = result.concat(tokens); + } + + expect(mockFetchFn.mock.calls).toHaveLength(3); + expect(mockFetchFn).toHaveBeenCalledWith( + 'https://bridge.api.cx.metamask.io/getTokens/search', + { + method: 'POST', + body: '{"chainIds":["eip155:10"],"includeAssets":[{"assetId":"eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201F986","decimals":8,"symbol":"DEF","name":"DEF"},{"assetId":"eip155:10/slip44:614","symbol":"ETH","decimals":18,"name":"Ether"}],"query":"ABC"}', + headers: { + 'Client-Version': '1.0.0', + 'Content-Type': 'application/json', + 'X-Client-Id': 'extension', + }, + signal: expect.anything(), + }, + ); + + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "assetId": "eip155:10/slip44:614", + "chainId": "eip155:10", + "decimals": 18, + "image": "https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/10/native/614.png", + "name": "Ether", + "symbol": "ETH", + }, + Object { + "assetId": "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984", + "chainId": "eip155:10", + "decimals": 16, + "name": "ABC", + "symbol": "ABC", + }, + Object { + "assetId": "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f985", + "chainId": "eip155:10", + "decimals": 6, + "name": "XYZ", + "symbol": "XYZ", + }, + Object { + "assetId": "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f986", + "chainId": "eip155:10", + "decimals": 8, + "name": "DEF", + "symbol": "DEF", + }, + Object { + "assetId": "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f987", + "chainId": "eip155:10", + "decimals": 9, + "name": "GHI", + "symbol": "GHI", + }, + ] + `); + }); + + it('should handle fetch error', async () => { + const mockError = new Error('Failed to fetch'); + + mockFetchFn.mockRejectedValue(mockError); + + await expect( + fetchTokensBySearchQuery({ + signal: new AbortController().signal, + chainIds: ['eip155:10'], + query: '', + clientId: BridgeClientId.EXTENSION, + fetchFn: mockFetchFn, + bridgeApiBaseUrl: BRIDGE_PROD_API_BASE_URL, + clientVersion: '1.0.0', + }).next(), + ).rejects.toThrow(mockError); + }); + }); + describe('fetchBridgeQuotes', () => { it('should fetch bridge quotes successfully, no approvals', async () => { const mockConsoleWarn = jest diff --git a/packages/bridge-controller/src/utils/fetch.ts b/packages/bridge-controller/src/utils/fetch.ts index 95f994d14de..6502ca83c48 100644 --- a/packages/bridge-controller/src/utils/fetch.ts +++ b/packages/bridge-controller/src/utils/fetch.ts @@ -7,13 +7,20 @@ import { } from './caip-formatters'; import { fetchServerEvents } from './fetch-server-events'; import type { FeatureId } from './validators'; -import { validateQuoteResponse, validateSwapsTokenObject } from './validators'; +import { + validateQuoteResponse, + validateSwapsAssetV2Object, + validateSwapsTokenObject, +} from './validators'; import type { QuoteResponse, FetchFunction, GenericQuoteRequest, QuoteRequest, BridgeAsset, + BridgeAssetV2, + TokenBalance, + MinimalAsset, } from '../types'; export const getClientHeaders = (clientId: string, clientVersion?: string) => ({ @@ -38,7 +45,6 @@ export async function fetchBridgeTokens( bridgeApiBaseUrl: string, clientVersion?: string, ): Promise> { - // TODO make token api v2 call const url = `${bridgeApiBaseUrl}/getTokens?chainId=${formatChainIdToDec(chainId)}`; // TODO we will need to cache these. In Extension fetchWithCache is used. This is due to the following: @@ -57,6 +63,146 @@ export async function fetchBridgeTokens( return transformedTokens; } +/** + * Fetches a list of tokens sorted by balance, popularity and other criteria from the bridge-api + * + * @param params - The parameters for the fetchPopularTokens function + * @param params.chainIds - The chain IDs to fetch tokens for + * @param params.assetsWithBalances - The user's balances sorted by amount. This is used to add balance information to the returned tokens. These assets are returned first in the list in the same order as the input. + * @param params.clientId - The client ID for metrics + * @param params.fetchFn - The fetch function to use + * @param params.bridgeApiBaseUrl - The base URL for the bridge API + * @param params.clientVersion - The client version for metrics (optional) + * @param params.signal - The abort signal + * @returns A list of sorted tokens + */ +export async function fetchPopularTokens({ + signal, + chainIds, + clientId, + fetchFn, + bridgeApiBaseUrl, + clientVersion, + assetsWithBalances, +}: { + signal: AbortSignal; + chainIds: CaipChainId[]; + clientId: string; + fetchFn: FetchFunction; + bridgeApiBaseUrl: string; + clientVersion?: string; + assetsWithBalances?: MinimalAsset[]; +}): Promise { + const url = `${bridgeApiBaseUrl}/getTokens/popular`; + + const tokens = await fetchFn(url, { + signal, + method: 'POST', + body: JSON.stringify({ + chainIds, + includeAssets: assetsWithBalances, + }), + headers: { + ...getClientHeaders(clientId, clientVersion), + 'Content-Type': 'application/json', + }, + }); + + return tokens + .map((token: unknown) => (validateSwapsAssetV2Object(token) ? token : null)) + .filter(Boolean); +} + +/** + * Yields a list of matching tokens sorted by balance, popularity and other criteria from the bridge-api + * + * @param params - The parameters for the fetchTokensBySearchQuery function + * @param params.chainIds - The chain IDs to fetch tokens for + * @param params.query - The search query + * @param params.clientId - The client ID for metrics + * @param params.fetchFn - The fetch function to use + * @param params.bridgeApiBaseUrl - The base URL for the bridge API + * @param params.clientVersion - The client version for metrics (optional) + * @param params.assetsWithBalances - The assets to include in the search + * @param params.after - The cursor to start from + * @param params.signal - The abort signal + * @yields A list of sorted tokens + * + * @example + * const abortController = new AbortController(); + * const searchResults = []; + * const tokens = fetchTokensBySearchQuery({ + * chainIds, + * query, + * clientId, + * fetchFn, + * bridgeApiBaseUrl, + * clientVersion, + * assetsWithBalances, + * signal: abortController.signal, + * }); + * for await (const tokens of tokens) { + * searchResults.push(...tokens.map(addBalanceDisplayData)); + * } + * return searchResults; + */ +export async function* fetchTokensBySearchQuery({ + signal, + chainIds, + query, + clientId, + fetchFn, + bridgeApiBaseUrl, + clientVersion, + assetsWithBalances, + after, +}: { + signal: AbortSignal; + chainIds: CaipChainId[]; + query: string; + clientId: string; + fetchFn: FetchFunction; + bridgeApiBaseUrl: string; + clientVersion?: string; + assetsWithBalances?: MinimalAsset[]; + after?: string; +}): AsyncGenerator { + const url = `${bridgeApiBaseUrl}/getTokens/search`; + const { data: tokens, pageInfo } = await fetchFn(url, { + method: 'POST', + body: JSON.stringify({ + chainIds, + includeAssets: assetsWithBalances, + after, + query, + }), + signal, + headers: { + ...getClientHeaders(clientId, clientVersion), + 'Content-Type': 'application/json', + }, + }); + const { hasNextPage, endCursor } = pageInfo; + + yield tokens + .map((token: unknown) => (validateSwapsAssetV2Object(token) ? token : null)) + .filter(Boolean); + + if (hasNextPage) { + yield* fetchTokensBySearchQuery({ + chainIds, + query, + clientId, + fetchFn, + bridgeApiBaseUrl, + clientVersion, + assetsWithBalances, + after: endCursor, + signal, + }); + } +} + /** * Converts the generic quote request to the type that the bridge-api expects * diff --git a/packages/bridge-controller/src/utils/validators.test.ts b/packages/bridge-controller/src/utils/validators.test.ts index 24298e11d52..61dc19f9584 100644 --- a/packages/bridge-controller/src/utils/validators.test.ts +++ b/packages/bridge-controller/src/utils/validators.test.ts @@ -5,6 +5,16 @@ describe('validators', () => { it.each([ { response: { + chainRanking: [ + { chainId: 'eip155:1' }, + { chainId: 'eip155:10' }, + { chainId: 'eip155:137' }, + { chainId: 'eip155:324' }, + { chainId: 'eip155:42161' }, + { chainId: 'eip155:43114' }, + { chainId: 'eip155:56' }, + { chainId: 'eip155:59144' }, + ], chains: { '1': { isActiveDest: true, diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index abe67a1a4fd..3c3691bc715 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -17,7 +17,11 @@ import { pattern, intersection, } from '@metamask/superstruct'; -import { CaipAssetTypeStruct, isStrictHexString } from '@metamask/utils'; +import { + CaipAssetTypeStruct, + CaipChainIdStruct, + isStrictHexString, +} from '@metamask/utils'; export enum FeeType { METABRIDGE = 'metabridge', @@ -88,6 +92,42 @@ export const BridgeAssetSchema = type({ iconUrl: optional(nullable(string())), }); +export const MinimalAssetSchema = type({ + /** + * The assetId of the token + */ + assetId: CaipAssetTypeStruct, + /** + * The symbol of token object + */ + symbol: string(), + /** + * The name for the network + */ + name: string(), + decimals: number(), +}); + +export const BridgeAssetV2Schema = intersection([ + MinimalAssetSchema, + type({ + /** + * The chainId of the token + */ + chainId: CaipChainIdStruct, + /** + * URL for token icon + */ + image: optional(nullable(string())), + noFee: optional( + type({ + isDestination: nullable(optional(boolean())), + isSource: nullable(optional(boolean())), + }), + ), + }), +]); + const DefaultPairSchema = type({ /** * The standard default pairs. Use this if the pair is only set once. @@ -102,14 +142,35 @@ const DefaultPairSchema = type({ }); export const ChainConfigurationSchema = type({ + /** + * @deprecated This field is no longer used, use chainRanking instead + */ isActiveSrc: boolean(), + /** + * @deprecated This field is no longer used + */ isActiveDest: boolean(), refreshRate: optional(number()), + /** + * @deprecated This field is no longer used + */ topAssets: optional(array(string())), stablecoins: optional(array(string())), + /** + * @deprecated This field is no longer used, clients should default to true + */ isUnifiedUIEnabled: optional(boolean()), + /** + * @deprecated This field is no longer used, clients should default to true + */ isSingleSwapBridgeButtonEnabled: optional(boolean()), + /** + * @deprecated This field should not be used + */ isGaslessSwapEnabled: optional(boolean()), + /** + * @deprecated Use `noFee` object in `BridgeAssetV2Schema` instead + */ noFeeAssets: optional(array(string())), defaultPairs: optional(DefaultPairSchema), }); @@ -127,6 +188,10 @@ const GenericQuoteRequestSchema = type({ const FeatureIdSchema = enums(Object.values(FeatureId)); +const ChainRankingSchema = type({ + chainId: CaipChainIdStruct, +}); + /** * This is the schema for the feature flags response from the RemoteFeatureFlagController */ @@ -135,11 +200,13 @@ export const PlatformConfigSchema = type({ quoteRequestOverrides: optional( record(FeatureIdSchema, optional(GenericQuoteRequestSchema)), ), + minimumVersion: string(), refreshRate: number(), maxRefreshCount: number(), support: boolean(), chains: record(string(), ChainConfigurationSchema), + chainRanking: optional(array(ChainRankingSchema)), /** * The bip44 default pairs for the chains * Key is the CAIP chainId namespace @@ -168,6 +235,12 @@ export const validateSwapsTokenObject = ( return is(data, BridgeAssetSchema); }; +export const validateSwapsAssetV2Object = ( + data: unknown, +): data is Infer => { + return is(data, BridgeAssetV2Schema); +}; + export const FeeDataSchema = type({ amount: TruthyDigitStringSchema, asset: BridgeAssetSchema,