From b86994e50b7df19e8449d22ad7449496bc90f206 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Tue, 11 Nov 2025 14:16:52 -0800 Subject: [PATCH 1/6] chore: add chainRanking LD config chainranking --- packages/bridge-controller/src/constants/bridge.ts | 1 + packages/bridge-controller/src/selectors.test.ts | 3 +++ .../bridge-controller/src/utils/feature-flags.test.ts | 1 + .../bridge-controller/src/utils/validators.test.ts | 10 ++++++++++ packages/bridge-controller/src/utils/validators.ts | 5 +++++ 5 files changed, 20 insertions(+) diff --git a/packages/bridge-controller/src/constants/bridge.ts b/packages/bridge-controller/src/constants/bridge.ts index 3612f5becd2..6eeeb3ac5e4 100644 --- a/packages/bridge-controller/src/constants/bridge.ts +++ b/packages/bridge-controller/src/constants/bridge.ts @@ -53,6 +53,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/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/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/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 6c6ffec0180..ab207935d2f 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -127,6 +127,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 */ @@ -140,6 +144,7 @@ export const PlatformConfigSchema = type({ 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 From 9357726e2f4829a5a1fcba71b862fb19225231d8 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Tue, 11 Nov 2025 14:17:30 -0800 Subject: [PATCH 2/6] chore: validate v2 tokens --- .../bridge-controller/src/utils/validators.ts | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index ab207935d2f..832b269795e 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,36 @@ export const BridgeAssetSchema = type({ iconUrl: optional(nullable(string())), }); +export const BridgeAssetV2Schema = type({ + /** + * The chainId of the token + */ + chainId: CaipChainIdStruct, + /** + * The assetId of the token + */ + assetId: CaipAssetTypeStruct, + /** + * The symbol of token object + */ + symbol: string(), + /** + * The name for the network + */ + name: string(), + decimals: number(), + /** + * URL for token icon + */ + image: optional(nullable(string())), + noFee: optional( + type({ + isDestination: optional(boolean()), + isSource: optional(boolean()), + }), + ), +}); + const DefaultPairSchema = type({ /** * The standard default pairs. Use this if the pair is only set once. @@ -173,6 +207,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, From 4a10c25ddf80e9295b9a1c0aa4226ada5c85d421 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Tue, 11 Nov 2025 14:17:50 -0800 Subject: [PATCH 3/6] feat: add popular and search getToken apis fetch fetch --- .../src/bridge-controller.sse.test.ts | 7 + packages/bridge-controller/src/index.ts | 14 +- packages/bridge-controller/src/types.ts | 19 + .../bridge-controller/src/utils/fetch.test.ts | 475 ++++++++++++++++++ packages/bridge-controller/src/utils/fetch.ts | 179 ++++++- 5 files changed, 689 insertions(+), 5 deletions(-) diff --git a/packages/bridge-controller/src/bridge-controller.sse.test.ts b/packages/bridge-controller/src/bridge-controller.sse.test.ts index 667ca253f47..a0f1b51cb85 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/index.ts b/packages/bridge-controller/src/index.ts index b406d33a9fa..7fe957309a9 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -51,6 +51,11 @@ export type { export { StatusTypes } from './types'; +export type { + BridgeAssetV2, + BridgeControllerGetStateAction, + BridgeControllerStateChangeEvent, +} from './types'; export { AssetType, SortOrder, @@ -58,8 +63,6 @@ export { RequestStatus, BridgeUserAction, BridgeBackgroundAction, - type BridgeControllerGetStateAction, - type BridgeControllerStateChangeEvent, } from './types'; export { @@ -122,7 +125,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/types.ts b/packages/bridge-controller/src/types.ts index 728090aa7ab..15e3aa8e2d5 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -29,6 +29,7 @@ import type { BRIDGE_CONTROLLER_NAME } from './constants/bridge'; import type { BitcoinTradeDataSchema, BridgeAssetSchema, + BridgeAssetV2Schema, ChainConfigurationSchema, FeatureId, FeeDataSchema, @@ -156,6 +157,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; + /** * 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/fetch.test.ts b/packages/bridge-controller/src/utils/fetch.test.ts index 2411edeef49..bdd6069a853 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,479 @@ 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', + chainId: 'eip155:10', + balance: '1000000000000000000', + tokenFiatAmount: '5', + }, + { + 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', + balance: '0', + tokenFiatAmount: '0', + }, + ], + 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","chainId":"eip155:10","balance":"1000000000000000000","tokenFiatAmount":"5"},{"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","balance":"0","tokenFiatAmount":"0"}]}', + 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", + "balance": "0", + "chainId": "eip155:10", + "decimals": 18, + "image": "https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/10/native/614.png", + "name": "Ether", + "symbol": "ETH", + "tokenFiatAmount": "0", + }, + 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", + "balance": "1000000000000000000", + "chainId": "eip155:10", + "decimals": 8, + "name": "DEF", + "symbol": "DEF", + "tokenFiatAmount": "5", + }, + 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', + chainId: 'eip155:10', + balance: '1000000000000000000', + tokenFiatAmount: '5', + }, + { + 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', + balance: '0', + tokenFiatAmount: '0', + }, + ], + 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","chainId":"eip155:10","balance":"1000000000000000000","tokenFiatAmount":"5"},{"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","balance":"0","tokenFiatAmount":"0"}],"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", + "balance": "0", + "chainId": "eip155:10", + "decimals": 18, + "image": "https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/10/native/614.png", + "name": "Ether", + "symbol": "ETH", + "tokenFiatAmount": "0", + }, + 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", + "balance": "1000000000000000000", + "chainId": "eip155:10", + "decimals": 8, + "name": "DEF", + "symbol": "DEF", + "tokenFiatAmount": "5", + }, + 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..34b1285b800 100644 --- a/packages/bridge-controller/src/utils/fetch.ts +++ b/packages/bridge-controller/src/utils/fetch.ts @@ -7,13 +7,19 @@ 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, } from '../types'; export const getClientHeaders = (clientId: string, clientVersion?: string) => ({ @@ -38,7 +44,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 +62,176 @@ 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?: (BridgeAssetV2 & AdditionalTokenFields)[]; +}): Promise<(BridgeAssetV2 & AdditionalTokenFields)[]> { + const url = `${bridgeApiBaseUrl}/getTokens/popular`; + + const balancesByAssetId = assetsWithBalances?.reduce( + (acc, asset) => { + acc[asset.assetId.toLowerCase()] = asset; + return acc; + }, + {} as Record, + ); + + 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) + .map((token: BridgeAssetV2) => { + return { + ...(balancesByAssetId?.[token.assetId.toLowerCase()] ?? {}), + ...token, + }; + }); +} + +/** + * 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); + * } + * return searchResults; + */ +export async function* fetchTokensBySearchQuery< + AdditionalTokenFields = TokenBalance, +>({ + signal, + chainIds, + query, + clientId, + fetchFn, + bridgeApiBaseUrl, + clientVersion, + assetsWithBalances, + after, +}: { + signal: AbortSignal; + chainIds: CaipChainId[]; + query: string; + clientId: string; + fetchFn: FetchFunction; + bridgeApiBaseUrl: string; + clientVersion?: string; + assetsWithBalances?: (BridgeAssetV2 & AdditionalTokenFields)[]; + after?: string; +}): AsyncGenerator<(BridgeAssetV2 & AdditionalTokenFields)[]> { + const url = `${bridgeApiBaseUrl}/getTokens/search`; + const balancesByAssetId = assetsWithBalances?.reduce( + (acc, asset) => { + acc[asset.assetId.toLowerCase()] = asset; + return acc; + }, + {} as Record, + ); + + 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) + .map((token: BridgeAssetV2) => { + return { + ...(balancesByAssetId?.[token.assetId.toLowerCase()] ?? {}), + ...token, + }; + }); + + 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 * From 1537b433f47adf8618d5f301ec381fbc09507a9a Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Fri, 14 Nov 2025 11:38:58 -0800 Subject: [PATCH 4/6] chore: update types --- packages/bridge-controller/src/types.ts | 3 +- .../bridge-controller/src/utils/fetch.test.ts | 28 +---------- packages/bridge-controller/src/utils/fetch.ts | 49 ++++--------------- .../bridge-controller/src/utils/validators.ts | 37 ++++++++------ 4 files changed, 36 insertions(+), 81 deletions(-) diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts index d5b0e93965e..bf702e8457e 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -33,6 +33,7 @@ import type { ChainConfigurationSchema, FeatureId, FeeDataSchema, + MinimalAssetSchema, PlatformConfigSchema, ProtocolSchema, QuoteResponseSchema, @@ -175,7 +176,7 @@ export type TokenBalance = { * 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/fetch.test.ts b/packages/bridge-controller/src/utils/fetch.test.ts index bdd6069a853..73888e1db69 100644 --- a/packages/bridge-controller/src/utils/fetch.test.ts +++ b/packages/bridge-controller/src/utils/fetch.test.ts @@ -240,20 +240,12 @@ describe('fetch', () => { decimals: 8, symbol: 'DEF', name: 'DEF', - chainId: 'eip155:10', - balance: '1000000000000000000', - tokenFiatAmount: '5', }, { 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', - balance: '0', - tokenFiatAmount: '0', }, ], clientId: BridgeClientId.EXTENSION, @@ -266,7 +258,7 @@ describe('fetch', () => { '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","chainId":"eip155:10","balance":"1000000000000000000","tokenFiatAmount":"5"},{"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","balance":"0","tokenFiatAmount":"0"}]}', + 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', @@ -280,13 +272,11 @@ describe('fetch', () => { Array [ Object { "assetId": "eip155:10/slip44:614", - "balance": "0", "chainId": "eip155:10", "decimals": 18, "image": "https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/10/native/614.png", "name": "Ether", "symbol": "ETH", - "tokenFiatAmount": "0", }, Object { "assetId": "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984", @@ -304,12 +294,10 @@ describe('fetch', () => { }, Object { "assetId": "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f986", - "balance": "1000000000000000000", "chainId": "eip155:10", "decimals": 8, "name": "DEF", "symbol": "DEF", - "tokenFiatAmount": "5", }, Object { "assetId": "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f987", @@ -518,20 +506,12 @@ describe('fetch', () => { decimals: 8, symbol: 'DEF', name: 'DEF', - chainId: 'eip155:10', - balance: '1000000000000000000', - tokenFiatAmount: '5', }, { 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', - balance: '0', - tokenFiatAmount: '0', }, ], clientId: BridgeClientId.EXTENSION, @@ -548,7 +528,7 @@ describe('fetch', () => { '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","chainId":"eip155:10","balance":"1000000000000000000","tokenFiatAmount":"5"},{"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","balance":"0","tokenFiatAmount":"0"}],"query":"ABC"}', + 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', @@ -562,13 +542,11 @@ describe('fetch', () => { Array [ Object { "assetId": "eip155:10/slip44:614", - "balance": "0", "chainId": "eip155:10", "decimals": 18, "image": "https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/10/native/614.png", "name": "Ether", "symbol": "ETH", - "tokenFiatAmount": "0", }, Object { "assetId": "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984", @@ -586,12 +564,10 @@ describe('fetch', () => { }, Object { "assetId": "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f986", - "balance": "1000000000000000000", "chainId": "eip155:10", "decimals": 8, "name": "DEF", "symbol": "DEF", - "tokenFiatAmount": "5", }, Object { "assetId": "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f987", diff --git a/packages/bridge-controller/src/utils/fetch.ts b/packages/bridge-controller/src/utils/fetch.ts index 34b1285b800..6502ca83c48 100644 --- a/packages/bridge-controller/src/utils/fetch.ts +++ b/packages/bridge-controller/src/utils/fetch.ts @@ -20,6 +20,7 @@ import type { BridgeAsset, BridgeAssetV2, TokenBalance, + MinimalAsset, } from '../types'; export const getClientHeaders = (clientId: string, clientVersion?: string) => ({ @@ -75,7 +76,7 @@ export async function fetchBridgeTokens( * @param params.signal - The abort signal * @returns A list of sorted tokens */ -export async function fetchPopularTokens({ +export async function fetchPopularTokens({ signal, chainIds, clientId, @@ -90,18 +91,10 @@ export async function fetchPopularTokens({ fetchFn: FetchFunction; bridgeApiBaseUrl: string; clientVersion?: string; - assetsWithBalances?: (BridgeAssetV2 & AdditionalTokenFields)[]; -}): Promise<(BridgeAssetV2 & AdditionalTokenFields)[]> { + assetsWithBalances?: MinimalAsset[]; +}): Promise { const url = `${bridgeApiBaseUrl}/getTokens/popular`; - const balancesByAssetId = assetsWithBalances?.reduce( - (acc, asset) => { - acc[asset.assetId.toLowerCase()] = asset; - return acc; - }, - {} as Record, - ); - const tokens = await fetchFn(url, { signal, method: 'POST', @@ -117,13 +110,7 @@ export async function fetchPopularTokens({ return tokens .map((token: unknown) => (validateSwapsAssetV2Object(token) ? token : null)) - .filter(Boolean) - .map((token: BridgeAssetV2) => { - return { - ...(balancesByAssetId?.[token.assetId.toLowerCase()] ?? {}), - ...token, - }; - }); + .filter(Boolean); } /** @@ -155,13 +142,11 @@ export async function fetchPopularTokens({ * signal: abortController.signal, * }); * for await (const tokens of tokens) { - * searchResults.push(. ..tokens); + * searchResults.push(...tokens.map(addBalanceDisplayData)); * } * return searchResults; */ -export async function* fetchTokensBySearchQuery< - AdditionalTokenFields = TokenBalance, ->({ +export async function* fetchTokensBySearchQuery({ signal, chainIds, query, @@ -179,18 +164,10 @@ export async function* fetchTokensBySearchQuery< fetchFn: FetchFunction; bridgeApiBaseUrl: string; clientVersion?: string; - assetsWithBalances?: (BridgeAssetV2 & AdditionalTokenFields)[]; + assetsWithBalances?: MinimalAsset[]; after?: string; -}): AsyncGenerator<(BridgeAssetV2 & AdditionalTokenFields)[]> { +}): AsyncGenerator { const url = `${bridgeApiBaseUrl}/getTokens/search`; - const balancesByAssetId = assetsWithBalances?.reduce( - (acc, asset) => { - acc[asset.assetId.toLowerCase()] = asset; - return acc; - }, - {} as Record, - ); - const { data: tokens, pageInfo } = await fetchFn(url, { method: 'POST', body: JSON.stringify({ @@ -209,13 +186,7 @@ export async function* fetchTokensBySearchQuery< yield tokens .map((token: unknown) => (validateSwapsAssetV2Object(token) ? token : null)) - .filter(Boolean) - .map((token: BridgeAssetV2) => { - return { - ...(balancesByAssetId?.[token.assetId.toLowerCase()] ?? {}), - ...token, - }; - }); + .filter(Boolean); if (hasNextPage) { yield* fetchTokensBySearchQuery({ diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index fa1a6a2a57b..3f5ad790c6b 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -92,11 +92,7 @@ export const BridgeAssetSchema = type({ iconUrl: optional(nullable(string())), }); -export const BridgeAssetV2Schema = type({ - /** - * The chainId of the token - */ - chainId: CaipChainIdStruct, +export const MinimalAssetSchema = type({ /** * The assetId of the token */ @@ -110,18 +106,28 @@ export const BridgeAssetV2Schema = type({ */ name: string(), decimals: number(), - /** - * URL for token icon - */ - image: optional(nullable(string())), - noFee: optional( - type({ - isDestination: optional(boolean()), - isSource: optional(boolean()), - }), - ), }); +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. @@ -173,6 +179,7 @@ export const PlatformConfigSchema = type({ quoteRequestOverrides: optional( record(FeatureIdSchema, optional(GenericQuoteRequestSchema)), ), + minimumVersion: string(), refreshRate: number(), maxRefreshCount: number(), From f4f4dc1730b5c4f95029f4db80b178c04efb0982 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Fri, 14 Nov 2025 14:24:10 -0800 Subject: [PATCH 5/6] chore: update comment --- packages/bridge-controller/src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts index bf702e8457e..c2a864d0afb 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -83,7 +83,7 @@ export type TokenAmountValues = { /** * The amount of the token * - * @example "1000000000000000000" + * @example "1.000143200000000000" */ amount: string; /** From d869f287361c43eebb249598d2ae157780a989d1 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Fri, 14 Nov 2025 14:42:19 -0800 Subject: [PATCH 6/6] chore: deprecate unused chains LD fields --- .../bridge-controller/src/utils/validators.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index 3f5ad790c6b..3c3691bc715 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -142,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), });