diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 40415f1b2c4..bd8f5854844 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Migrate TokenRatesController to use multichain v3 Price API endpoint with SLIP44 support for native tokens ([#7033](https://github.com/MetaMask/core/pull/7033)) + ## [86.0.0] ### Changed diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index 78d684ce462..ccc51a5752d 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -63,6 +63,7 @@ "@metamask/metamask-eth-abis": "^3.1.1", "@metamask/polling-controller": "^15.0.0", "@metamask/rpc-errors": "^7.0.2", + "@metamask/slip44": "^4.3.0", "@metamask/snaps-sdk": "^9.0.0", "@metamask/snaps-utils": "^11.0.0", "@metamask/utils": "^11.8.1", diff --git a/packages/assets-controllers/src/CurrencyRateController.test.ts b/packages/assets-controllers/src/CurrencyRateController.test.ts index ddec0853ae6..9f56e4fd979 100644 --- a/packages/assets-controllers/src/CurrencyRateController.test.ts +++ b/packages/assets-controllers/src/CurrencyRateController.test.ts @@ -49,6 +49,9 @@ function buildMockTokenPricesService( async fetchExchangeRates() { return {}; }, + async fetchMultichainTokenPrices() { + return {}; + }, validateChainIdSupported(_chainId: unknown): _chainId is Hex { return true; }, diff --git a/packages/assets-controllers/src/TokenRatesController.test.ts b/packages/assets-controllers/src/TokenRatesController.test.ts index 813789d7bac..7fb86e70310 100644 --- a/packages/assets-controllers/src/TokenRatesController.test.ts +++ b/packages/assets-controllers/src/TokenRatesController.test.ts @@ -30,8 +30,10 @@ import { useFakeTimers } from 'sinon'; import { TOKEN_PRICES_BATCH_SIZE } from './assetsUtil'; import type { AbstractTokenPricesService, + MultichainTokenRequest, TokenPrice, TokenPricesByTokenAddress, + TokenPricesByCaipAssetId, } from './token-prices-service/abstract-token-prices-service'; import { controllerName, TokenRatesController } from './TokenRatesController'; import type { @@ -1319,7 +1321,7 @@ describe('TokenRatesController', () => { it('should poll and update rate in the right interval', async () => { const interval = 100; const tokenPricesService = buildMockTokenPricesService(); - jest.spyOn(tokenPricesService, 'fetchTokenPrices'); + jest.spyOn(tokenPricesService, 'fetchMultichainTokenPrices'); await withController( { options: { @@ -1344,17 +1346,17 @@ describe('TokenRatesController', () => { async ({ controller }) => { await controller.start(ChainId.mainnet, 'ETH'); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes( + expect(tokenPricesService.fetchMultichainTokenPrices).toHaveBeenCalledTimes( 1, ); await advanceTime({ clock, duration: interval }); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes( + expect(tokenPricesService.fetchMultichainTokenPrices).toHaveBeenCalledTimes( 2, ); await advanceTime({ clock, duration: interval }); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes( + expect(tokenPricesService.fetchMultichainTokenPrices).toHaveBeenCalledTimes( 3, ); }, @@ -1366,7 +1368,7 @@ describe('TokenRatesController', () => { it('should stop polling', async () => { const interval = 100; const tokenPricesService = buildMockTokenPricesService(); - jest.spyOn(tokenPricesService, 'fetchTokenPrices'); + jest.spyOn(tokenPricesService, 'fetchMultichainTokenPrices'); await withController( { options: { @@ -1391,14 +1393,14 @@ describe('TokenRatesController', () => { async ({ controller }) => { await controller.start(ChainId.mainnet, 'ETH'); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes( + expect(tokenPricesService.fetchMultichainTokenPrices).toHaveBeenCalledTimes( 1, ); controller.stop(); await advanceTime({ clock, duration: interval }); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes( + expect(tokenPricesService.fetchMultichainTokenPrices).toHaveBeenCalledTimes( 1, ); }, @@ -1421,7 +1423,7 @@ describe('TokenRatesController', () => { it('should poll on the right interval', async () => { const interval = 100; const tokenPricesService = buildMockTokenPricesService(); - jest.spyOn(tokenPricesService, 'fetchTokenPrices'); + jest.spyOn(tokenPricesService, 'fetchMultichainTokenPrices'); await withController( { options: { @@ -1449,13 +1451,13 @@ describe('TokenRatesController', () => { }); await advanceTime({ clock, duration: 0 }); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(1); + expect(tokenPricesService.fetchMultichainTokenPrices).toHaveBeenCalledTimes(1); await advanceTime({ clock, duration: interval }); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(2); + expect(tokenPricesService.fetchMultichainTokenPrices).toHaveBeenCalledTimes(2); await advanceTime({ clock, duration: interval }); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(3); + expect(tokenPricesService.fetchMultichainTokenPrices).toHaveBeenCalledTimes(3); }, ); }); @@ -1464,7 +1466,7 @@ describe('TokenRatesController', () => { describe('when the native currency is supported', () => { it('returns the exchange rates directly', async () => { const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: fetchTokenPricesWithIncreasingPriceForEachToken, + fetchMultichainTokenPrices: fetchMultichainTokenPricesWithIncreasingPriceForEachToken, validateCurrencySupported(currency: unknown): currency is string { return currency === 'ETH'; }, @@ -1564,7 +1566,7 @@ describe('TokenRatesController', () => { .get('/data/price?fsym=ETH&tsyms=LOL') .reply(200, { LOL: fallbackRate }); const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: fetchTokenPricesWithIncreasingPriceForEachToken, + fetchMultichainTokenPrices: fetchMultichainTokenPricesWithIncreasingPriceForEachToken, validateCurrencySupported(currency: unknown): currency is string { return currency !== 'LOL'; }, @@ -1726,7 +1728,7 @@ describe('TokenRatesController', () => { it('should stop polling', async () => { const interval = 100; const tokenPricesService = buildMockTokenPricesService(); - jest.spyOn(tokenPricesService, 'fetchTokenPrices'); + jest.spyOn(tokenPricesService, 'fetchMultichainTokenPrices'); await withController( { options: { @@ -1752,14 +1754,14 @@ describe('TokenRatesController', () => { chainIds: [ChainId.mainnet], }); await advanceTime({ clock, duration: 0 }); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes( + expect(tokenPricesService.fetchMultichainTokenPrices).toHaveBeenCalledTimes( 1, ); controller.stopPollingByPollingToken(pollingToken); await advanceTime({ clock, duration: interval }); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes( + expect(tokenPricesService.fetchMultichainTokenPrices).toHaveBeenCalledTimes( 1, ); }, @@ -1864,7 +1866,7 @@ describe('TokenRatesController', () => { it('does not update state if the price update fails', async () => { const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: jest + fetchMultichainTokenPrices: jest .fn() .mockRejectedValue(new Error('Failed to fetch')), }); @@ -1912,11 +1914,11 @@ describe('TokenRatesController', () => { .map(buildAddress) .sort(); const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: fetchTokenPricesWithIncreasingPriceForEachToken, + fetchMultichainTokenPrices: fetchMultichainTokenPricesWithIncreasingPriceForEachToken, }); - const fetchTokenPricesSpy = jest.spyOn( + const fetchMultichainTokenPricesSpy = jest.spyOn( tokenPricesService, - 'fetchTokenPrices', + 'fetchMultichainTokenPrices', ); const tokens = tokenAddresses.map((tokenAddress) => { return buildToken({ address: tokenAddress }); @@ -1953,10 +1955,10 @@ describe('TokenRatesController', () => { const numBatches = Math.ceil( tokenAddresses.length / TOKEN_PRICES_BATCH_SIZE, ); - expect(fetchTokenPricesSpy).toHaveBeenCalledTimes(numBatches); + expect(fetchMultichainTokenPricesSpy).toHaveBeenCalledTimes(numBatches); for (let i = 1; i <= numBatches; i++) { - expect(fetchTokenPricesSpy).toHaveBeenNthCalledWith(i, { + expect(fetchMultichainTokenPricesSpy).toHaveBeenNthCalledWith(i, { chainId, tokenAddresses: tokenAddresses.slice( (i - 1) * TOKEN_PRICES_BATCH_SIZE, @@ -1976,7 +1978,7 @@ describe('TokenRatesController', () => { '0x0000000000000000000000000000000000000003', ]; const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: jest.fn().mockResolvedValue({ + fetchMultichainTokenPrices: jest.fn().mockResolvedValue({ [tokenAddresses[0]]: { currency: 'ETH', tokenAddress: tokenAddresses[0], @@ -2072,7 +2074,7 @@ describe('TokenRatesController', () => { '0x0000000000000000000000000000000000000002', ]; const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: jest.fn().mockResolvedValue({ + fetchMultichainTokenPrices: jest.fn().mockResolvedValue({ [tokenAddresses[0]]: { currency: 'ETH', tokenAddress: tokenAddresses[0], @@ -2155,7 +2157,7 @@ describe('TokenRatesController', () => { '0x0000000000000000000000000000000000000002', ]; const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: jest.fn().mockResolvedValue({ + fetchMultichainTokenPrices: jest.fn().mockResolvedValue({ [tokenAddresses[0]]: { currency: 'ETH', tokenAddress: tokenAddresses[0], @@ -2267,16 +2269,16 @@ describe('TokenRatesController', () => { .map(buildAddress) .sort(); const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: fetchTokenPricesWithIncreasingPriceForEachToken, + fetchMultichainTokenPrices: fetchMultichainTokenPricesWithIncreasingPriceForEachToken, validateCurrencySupported: ( currency: unknown, ): currency is string => { return currency !== selectedNetworkClientConfiguration.ticker; }, }); - const fetchTokenPricesSpy = jest.spyOn( + const fetchMultichainTokenPricesSpy = jest.spyOn( tokenPricesService, - 'fetchTokenPrices', + 'fetchMultichainTokenPrices', ); const tokens = tokenAddresses.map((tokenAddress) => { return buildToken({ address: tokenAddress }); @@ -2333,10 +2335,10 @@ describe('TokenRatesController', () => { const numBatches = Math.ceil( tokenAddresses.length / TOKEN_PRICES_BATCH_SIZE, ); - expect(fetchTokenPricesSpy).toHaveBeenCalledTimes(numBatches); + expect(fetchMultichainTokenPricesSpy).toHaveBeenCalledTimes(numBatches); for (let i = 1; i <= numBatches; i++) { - expect(fetchTokenPricesSpy).toHaveBeenNthCalledWith(i, { + expect(fetchMultichainTokenPricesSpy).toHaveBeenNthCalledWith(i, { chainId: selectedNetworkClientConfiguration.chainId, tokenAddresses: tokenAddresses.slice( (i - 1) * TOKEN_PRICES_BATCH_SIZE, @@ -2361,7 +2363,7 @@ describe('TokenRatesController', () => { '0x0000000000000000000000000000000000000002', ]; const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: jest.fn().mockResolvedValue({ + fetchMultichainTokenPrices: jest.fn().mockResolvedValue({ [tokenAddresses[0]]: { currency: 'ETH', tokenAddress: tokenAddresses[0], @@ -2433,7 +2435,7 @@ describe('TokenRatesController', () => { it('correctly calls the Price API with unqiue native token addresses (e.g. MATIC)', async () => { const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: jest.fn().mockResolvedValue({ + fetchMultichainTokenPrices: jest.fn().mockResolvedValue({ '0x0000000000000000000000000000000000001010': { currency: 'MATIC', tokenAddress: '0x0000000000000000000000000000000000001010', @@ -2485,7 +2487,7 @@ describe('TokenRatesController', () => { '0x0000000000000000000000000000000000000001', '0x0000000000000000000000000000000000000002', ]; - const fetchTokenPricesMock = jest.fn().mockResolvedValue({ + const fetchMultichainTokenPricesMock = jest.fn().mockResolvedValue({ [tokenAddresses[0]]: { currency: 'ETH', tokenAddress: tokenAddresses[0], @@ -2498,7 +2500,7 @@ describe('TokenRatesController', () => { }, }); const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: fetchTokenPricesMock, + fetchMultichainTokenPrices: fetchMultichainTokenPricesMock, }); await withController( { options: { tokenPricesService } }, @@ -2538,7 +2540,7 @@ describe('TokenRatesController', () => { await Promise.all([updateExchangeRates(), updateExchangeRates()]); - expect(fetchTokenPricesMock).toHaveBeenCalledTimes(1); + expect(fetchMultichainTokenPricesMock).toHaveBeenCalledTimes(1); expect(controller.state).toMatchInlineSnapshot(` Object { @@ -2567,7 +2569,7 @@ describe('TokenRatesController', () => { '0x0000000000000000000000000000000000000001', '0x0000000000000000000000000000000000000002', ]; - const fetchTokenPricesMock = jest.fn().mockResolvedValue({ + const fetchMultichainTokenPricesMock = jest.fn().mockResolvedValue({ [tokenAddresses[0]]: { currency: 'ETH', tokenAddress: tokenAddresses[0], @@ -2580,7 +2582,7 @@ describe('TokenRatesController', () => { }, }); const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: fetchTokenPricesMock, + fetchMultichainTokenPrices: fetchMultichainTokenPricesMock, }); await withController( { options: { tokenPricesService } }, @@ -2634,14 +2636,14 @@ describe('TokenRatesController', () => { updateExchangeRates(request2Payload), ]); - expect(fetchTokenPricesMock).toHaveBeenCalledTimes(2); - expect(fetchTokenPricesMock).toHaveBeenNthCalledWith( + expect(fetchMultichainTokenPricesMock).toHaveBeenCalledTimes(2); + expect(fetchMultichainTokenPricesMock).toHaveBeenNthCalledWith( 1, expect.objectContaining({ tokenAddresses: [tokenAddresses[0]], }), ); - expect(fetchTokenPricesMock).toHaveBeenNthCalledWith( + expect(fetchMultichainTokenPricesMock).toHaveBeenNthCalledWith( 2, expect.objectContaining({ tokenAddresses: [tokenAddresses[0], tokenAddresses[1]], @@ -2999,6 +3001,9 @@ function buildMockTokenPricesService( async fetchExchangeRates() { return {}; }, + async fetchMultichainTokenPrices() { + return {}; + }, validateChainIdSupported(_chainId: unknown): _chainId is Hex { return true; }, @@ -3010,54 +3015,67 @@ function buildMockTokenPricesService( } /** - * A version of the token prices service `fetchTokenPrices` method where the + * A version of the token prices service `fetchMultichainTokenPrices` method where the * price of each given token is incremented by one. * * @param args - The arguments to this function. - * @param args.tokenAddresses - The token addresses. * @param args.currency - The currency. + * @param args.tokenRequests - The token requests. * @returns The token prices. */ -async function fetchTokenPricesWithIncreasingPriceForEachToken< - TokenAddress extends Hex, - Currency extends string, ->({ - tokenAddresses, +async function fetchMultichainTokenPricesWithIncreasingPriceForEachToken({ + tokenRequests, currency, }: { - tokenAddresses: TokenAddress[]; - currency: Currency; -}) { - return tokenAddresses.reduce< - Partial> - >((obj, tokenAddress, i) => { - const tokenPrice: TokenPrice = { - tokenAddress, - currency, - pricePercentChange1d: 0, - priceChange1d: 0, - allTimeHigh: 4000, - allTimeLow: 900, - circulatingSupply: 2000, - dilutedMarketCap: 100, - high1d: 200, - low1d: 100, - marketCap: 1000, - marketCapPercentChange1d: 100, - price: (i + 1) / 1000, - pricePercentChange14d: 100, - pricePercentChange1h: 1, - pricePercentChange1y: 200, - pricePercentChange200d: 300, - pricePercentChange30d: 200, - pricePercentChange7d: 100, - totalVolume: 100, - }; - return { - ...obj, - [tokenAddress]: tokenPrice, - }; - }, {}) as TokenPricesByTokenAddress; + tokenRequests: MultichainTokenRequest[]; + currency: string; +}): Promise> { + const result: TokenPricesByCaipAssetId = {}; + + for (const request of tokenRequests) { + const { chainId, tokenAddresses } = request; + const chainIdAsNumber = parseInt(chainId.slice(2), 16); + + tokenAddresses.forEach((tokenAddress, i) => { + // Mock CAIP asset ID creation similar to the real implementation + let caipAssetId: string; + const isNativeToken = tokenAddress.toLowerCase() === '0x0000000000000000000000000000000000000000'; + + if (isNativeToken) { + // Use SLIP44 format for native tokens (mock Ethereum for simplicity) + const slip44CoinType = chainIdAsNumber === 1 ? 60 : 60; // Mock ETH for all chains + caipAssetId = `eip155:${chainIdAsNumber}/slip44:${slip44CoinType}`; + } else { + // Use token address for ERC20 tokens + caipAssetId = `eip155:${chainIdAsNumber}:${tokenAddress}`; + } + + result[caipAssetId] = { + tokenAddress, + currency, + price: (i + 1) / 1000, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 4000, + allTimeLow: 900, + circulatingSupply: 2000, + dilutedMarketCap: 100, + high1d: 200, + low1d: 100, + marketCap: 1000, + marketCapPercentChange1d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange7d: 100, + pricePercentChange14d: 100, + pricePercentChange30d: 200, + pricePercentChange200d: 300, + totalVolume: 100, + }; + }); + } + + return result; } /** diff --git a/packages/assets-controllers/src/TokenRatesController.ts b/packages/assets-controllers/src/TokenRatesController.ts index e4791e4ee63..bc0277bdbef 100644 --- a/packages/assets-controllers/src/TokenRatesController.ts +++ b/packages/assets-controllers/src/TokenRatesController.ts @@ -20,13 +20,14 @@ import type { NetworkControllerStateChangeEvent, } from '@metamask/network-controller'; import { StaticIntervalPollingController } from '@metamask/polling-controller'; -import { createDeferredPromise, type Hex } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import { isEqual } from 'lodash'; import { reduceInBatchesSerially, TOKEN_PRICES_BATCH_SIZE } from './assetsUtil'; import { fetchExchangeRate as fetchNativeCurrencyExchangeRate } from './crypto-compare-service'; import type { AbstractTokenPricesService } from './token-prices-service/abstract-token-prices-service'; import { getNativeTokenAddress } from './token-prices-service/codefi-v2'; +import type { SupportedCurrency } from './token-prices-service/codefi-v2'; import type { TokensControllerGetStateAction, TokensControllerStateChangeEvent, @@ -242,7 +243,6 @@ export class TokenRatesController extends StaticIntervalPollingController> = {}; #disabled: boolean; @@ -522,73 +522,137 @@ export class TokenRatesController extends StaticIntervalPollingController { - const tokenAddresses = this.#getTokenAddresses(chainId); - // Build a unique key based on chainId, nativeCurrency, and the number of token addresses. - const updateKey: `${Hex}:${string}` = `${chainId}:${nativeCurrency}:${tokenAddresses.length}`; - - if (updateKey in this.#inProcessExchangeRateUpdates) { - // Await any ongoing update to avoid redundant work. - await this.#inProcessExchangeRateUpdates[updateKey]; - return null; - } + // Use multichain approach only - no fallback + const chainAndTokenRequests = chainIdAndNativeCurrency.map( + ({ chainId, nativeCurrency }) => ({ + chainId, + tokenAddresses: this.#getTokenAddresses(chainId), + nativeCurrency, + }), + ); - // Create a deferred promise to track this update. - const { - promise: inProgressUpdate, - resolve: updateSucceeded, - reject: updateFailed, - } = createDeferredPromise({ suppressUnhandledRejection: true }); - this.#inProcessExchangeRateUpdates[updateKey] = inProgressUpdate; - - try { - const contractInformations = await this.#fetchAndMapExchangeRates({ - tokenAddresses, - chainId, - nativeCurrency, - }); + const multichainResults = await this.#fetchMultichainExchangeRates({ + chainAndTokenRequests, + }); - // Each promise returns an object with the market data for the chain. - const marketData = { - [chainId]: { - ...(contractInformations ?? {}), - }, + // Update with multichain results + if (Object.keys(multichainResults).length > 0) { + this.update((state) => { + for (const [chainId, contractExchangeRates] of Object.entries( + multichainResults, + )) { + const chainIdHex = chainId as Hex; + if (!state.marketData[chainIdHex]) { + state.marketData[chainIdHex] = {}; + } + state.marketData[chainIdHex] = { + ...state.marketData[chainIdHex], + ...contractExchangeRates, }; + } + }); + } + } - updateSucceeded(); - return marketData; - } catch (error: unknown) { - updateFailed(error); - throw error; - } finally { - // Cleanup the tracking for this update. - delete this.#inProcessExchangeRateUpdates[updateKey]; + /** + * Uses the multichain token prices service to retrieve exchange rates for tokens + * across multiple chains in a single request. This is more efficient than making + * multiple single-chain requests. + * + * @param args - The arguments to this function. + * @param args.chainAndTokenRequests - Array of objects containing chainId, tokenAddresses, and nativeCurrency. + * @returns A map from chain ID to token addresses to their prices. + */ + async #fetchMultichainExchangeRates({ + chainAndTokenRequests, + }: { + chainAndTokenRequests: { + chainId: Hex; + tokenAddresses: Hex[]; + nativeCurrency: string; + }[]; + }): Promise> { + // fetchMultichainTokenPrices is now required, no need to check + + // Helper function to parse CAIP asset IDs back to chain ID and token address + const parseCaipAssetId = (caipAssetId: string): { chainId: Hex; tokenAddress: Hex } | null => { + try { + // Handle SLIP44 format: eip155:1/slip44:60 + if (caipAssetId.includes('/slip44:')) { + const [namespaceChain, slip44Part] = caipAssetId.split('/slip44:'); + const [namespace, chainIdStr] = namespaceChain.split(':'); + if (namespace === 'eip155' && chainIdStr) { + const chainId = `0x${parseInt(chainIdStr, 10).toString(16)}` as Hex; + const nativeTokenAddress = getNativeTokenAddress(chainId); + return { chainId, tokenAddress: nativeTokenAddress }; + } + } else { + // Handle regular format: eip155:1:0x... + const [namespace, chainIdStr, tokenAddress] = caipAssetId.split(':'); + if (namespace === 'eip155' && chainIdStr && tokenAddress) { + const chainId = `0x${parseInt(chainIdStr, 10).toString(16)}` as Hex; + return { chainId, tokenAddress: tokenAddress as Hex }; + } } - }, - ); + } catch (error) { + console.error(`Failed to parse CAIP asset ID: ${caipAssetId}`, error); + } + return null; + }; + + // Group requests by currency to batch them efficiently + const requestsByCurrency: Record< + string, + { chainId: Hex; tokenAddresses: Hex[] }[] + > = {}; + const chainToCurrency: Record = {}; - // Wait for all update promises to settle. - const results = await Promise.allSettled(updatePromises); + for (const request of chainAndTokenRequests) { + const { chainId, tokenAddresses, nativeCurrency } = request; + chainToCurrency[chainId] = nativeCurrency; - // Merge all successful market data updates into one object. - const combinedMarketData = results.reduce((acc, result) => { - if (result.status === 'fulfilled' && result.value) { - acc = { ...acc, ...result.value }; + if (!requestsByCurrency[nativeCurrency]) { + requestsByCurrency[nativeCurrency] = []; } - return acc; - }, {}); + requestsByCurrency[nativeCurrency].push({ chainId, tokenAddresses }); + } - // Call this.update only once with the combined market data to reduce the number of state changes and re-renders - if (Object.keys(combinedMarketData).length > 0) { - this.update((state) => { - state.marketData = { - ...state.marketData, - ...combinedMarketData, - }; - }); + const results: Record = {}; + + // Process each currency group + for (const [currency, requests] of Object.entries(requestsByCurrency)) { + try { + const caipPrices = await this.#tokenPricesService.fetchMultichainTokenPrices({ + tokenRequests: requests, + currency: currency as SupportedCurrency, + }); + + // Convert CAIP results back to chain-based structure + for (const request of requests) { + const { chainId } = request; + results[chainId] = {}; + } + + // Map CAIP asset IDs back to chain + token structure + for (const [caipAssetId, tokenPrice] of Object.entries(caipPrices)) { + const parsedAsset = parseCaipAssetId(caipAssetId); + if (parsedAsset) { + const { chainId, tokenAddress } = parsedAsset; + if (results[chainId]) { + results[chainId][tokenAddress] = tokenPrice; + } + } + } + } catch (error) { + console.error( + `Failed to fetch multichain prices for currency ${currency}:`, + error, + ); + throw error; + } } + + return results; } /** diff --git a/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.test.ts b/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.test.ts index 9861cfd7ec3..92cc6ad4bf7 100644 --- a/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.test.ts +++ b/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.test.ts @@ -153,6 +153,9 @@ function buildMockTokenPricesService( async fetchTokenPrices() { return {}; }, + async fetchMultichainTokenPrices() { + return {}; + }, validateChainIdSupported(_chainId: unknown): _chainId is Hex { return true; }, diff --git a/packages/assets-controllers/src/assetsUtil.test.ts b/packages/assets-controllers/src/assetsUtil.test.ts index f93298b44cb..8454a686499 100644 --- a/packages/assets-controllers/src/assetsUtil.test.ts +++ b/packages/assets-controllers/src/assetsUtil.test.ts @@ -786,5 +786,8 @@ function createMockPriceService(): AbstractTokenPricesService { async fetchExchangeRates() { return {}; }, + async fetchMultichainTokenPrices() { + return {}; + }, }; } diff --git a/packages/assets-controllers/src/token-prices-service/abstract-token-prices-service.ts b/packages/assets-controllers/src/token-prices-service/abstract-token-prices-service.ts index ddc7a3e159b..1fa91c1066a 100644 --- a/packages/assets-controllers/src/token-prices-service/abstract-token-prices-service.ts +++ b/packages/assets-controllers/src/token-prices-service/abstract-token-prices-service.ts @@ -1,6 +1,26 @@ import type { ServicePolicy } from '@metamask/controller-utils'; import type { Hex } from '@metamask/utils'; +/** + * Represents a CAIP asset ID for multichain token identification + */ +export type CaipAssetId = string; + +/** + * Represents token information for multichain requests + */ +export type MultichainTokenRequest = { + chainId: Hex; + tokenAddresses: Hex[]; +}; + +/** + * A map of CAIP asset ID to its price for multichain responses + */ +export type TokenPricesByCaipAssetId = { + [caipAssetId: CaipAssetId]: TokenPrice; +}; + /** * Represents the price of a token in a currency. */ @@ -129,4 +149,21 @@ export type AbstractTokenPricesService< * @returns True if the API supports the currency, false otherwise. */ validateCurrencySupported(currency: unknown): currency is Currency; + + /** + * Retrieves prices for multiple tokens across multiple chains in a single request. + * This is more efficient than making multiple single-chain requests. + * + * @param args - The arguments to this function. + * @param args.tokenRequests - Array of chain IDs and their token addresses. + * @param args.currency - The desired currency of the token prices. + * @returns A map of CAIP asset IDs to their prices. + */ + fetchMultichainTokenPrices({ + tokenRequests, + currency, + }: { + tokenRequests: MultichainTokenRequest[]; + currency: Currency; + }): Promise>; }; diff --git a/packages/assets-controllers/src/token-prices-service/codefi-v2.ts b/packages/assets-controllers/src/token-prices-service/codefi-v2.ts index 1b96e9e1aac..f30a8485a8c 100644 --- a/packages/assets-controllers/src/token-prices-service/codefi-v2.ts +++ b/packages/assets-controllers/src/token-prices-service/codefi-v2.ts @@ -12,11 +12,22 @@ import { hexToNumber } from '@metamask/utils'; import type { AbstractTokenPricesService, + CaipAssetId, ExchangeRatesByCurrency, + MultichainTokenRequest, TokenPrice, + TokenPricesByCaipAssetId, TokenPricesByTokenAddress, } from './abstract-token-prices-service'; +// SLIP44 coin type constants +const SLIP44_COIN_TYPES = { + ETH: 60, // Ethereum + MATIC: 966, // Polygon + BNB: 714, // BNB Smart Chain + AVAX: 9000, // Avalanche +}; + /** * The list of currencies that can be supplied as the `vsCurrency` parameter to * the `/spot-prices` endpoint, in lowercase form. @@ -167,6 +178,7 @@ const chainIdToNativeTokenAddress: Record = { * Returns the address that should be used to query the price api for the * chain's native token. On most chains, this is signified by the zero address. * But on some chains, the native token has a specific address. + * * @param chainId - The hexadecimal chain id. * @returns The address of the chain's native token. */ @@ -177,7 +189,7 @@ export const getNativeTokenAddress = (chainId: Hex): Hex => * A currency that can be supplied as the `vsCurrency` parameter to * the `/spot-prices` endpoint. Covers both uppercase and lowercase versions. */ -type SupportedCurrency = +export type SupportedCurrency = | (typeof SUPPORTED_CURRENCIES)[number] | Uppercase<(typeof SUPPORTED_CURRENCIES)[number]>; @@ -185,6 +197,7 @@ type SupportedCurrency = * The list of chain IDs that can be supplied in the URL for the `/spot-prices` * endpoint, but in hexadecimal form (for consistency with how we represent * chain IDs in other places). + * * @see Used by {@link CodefiTokenPricesServiceV2} to validate that a given chain ID is supported by V2 of the Codefi Price API. */ export const SUPPORTED_CHAIN_IDS = [ @@ -357,6 +370,63 @@ type MarketData = { }; type MarketDataByTokenAddress = { [address: Hex]: MarketData }; + +/** + * Creates a CAIP asset ID from chain ID and token address. + * For native tokens, uses SLIP44 coin type format: eip155:{chainId}/slip44:{coinType} + * For ERC20 tokens, uses address format: eip155:{chainId}:{tokenAddress} + * + * @param chainId - The EIP-155 chain ID (hex format). + * @param tokenAddress - The token contract address (or native token placeholder). + * @returns The CAIP asset ID string. + */ +function createCaipAssetId(chainId: Hex, tokenAddress: Hex): CaipAssetId { + const chainIdAsNumber = hexToNumber(chainId); + const nativeTokenAddress = getNativeTokenAddress(chainId); + + // Check if this is a native token + if (tokenAddress.toLowerCase() === nativeTokenAddress.toLowerCase()) { + // Use SLIP44 coin type for native tokens + const slip44CoinType = getSlip44CoinType(chainId); + if (slip44CoinType !== null) { + return `eip155:${chainIdAsNumber}/slip44:${slip44CoinType}`; + } + } + + // Use token address for ERC20 tokens + return `eip155:${chainIdAsNumber}:${tokenAddress}`; +} + +/** + * Gets the SLIP44 coin type for a given chain ID. + * + * @param chainId - The EIP-155 chain ID (hex format). + * @returns The SLIP44 coin type number, or null if not found. + */ +function getSlip44CoinType(chainId: Hex): number | null { + const chainIdAsNumber = hexToNumber(chainId); + + // Use SLIP44 constants where available, with fallbacks for EVM chains + switch (chainIdAsNumber) { + case 1: // Ethereum Mainnet + return SLIP44_COIN_TYPES.ETH; + case 137: // Polygon + return SLIP44_COIN_TYPES.MATIC; + case 56: // BSC + return SLIP44_COIN_TYPES.BNB; + case 43114: // Avalanche + return SLIP44_COIN_TYPES.AVAX; + // EVM chains that use ETH as native token + case 8453: // Base + case 42161: // Arbitrum One + case 10: // Optimism + case 59144: // Linea + return SLIP44_COIN_TYPES.ETH; + default: + return null; + } +} + /** * This version of the token prices service uses V2 of the Codefi Price API to * fetch token prices. @@ -532,6 +602,77 @@ export class CodefiTokenPricesServiceV2 ) as Partial>; } + /** + * Retrieves prices for multiple tokens across multiple chains in a single request. + * Uses the v3 CAIP asset IDs endpoint for improved performance. + * + * @param args - The arguments to this function. + * @param args.tokenRequests - Array of chain IDs and their token addresses. + * @param args.currency - The desired currency of the token prices. + * @returns A map of CAIP asset IDs to their prices. + */ + async fetchMultichainTokenPrices({ + tokenRequests, + currency, + }: { + tokenRequests: MultichainTokenRequest[]; + currency: SupportedCurrency; + }): Promise> { + // Create CAIP asset IDs for all tokens across all chains + const caipAssetIds: CaipAssetId[] = []; + const assetIdToOriginal: Record< + CaipAssetId, + { chainId: Hex; tokenAddress: Hex } + > = {}; + + for (const request of tokenRequests) { + const { chainId, tokenAddresses } = request; + + // Include native token + const nativeTokenAddress = getNativeTokenAddress(chainId); + const nativeCaipAssetId = createCaipAssetId(chainId, nativeTokenAddress); + caipAssetIds.push(nativeCaipAssetId); + assetIdToOriginal[nativeCaipAssetId] = { + chainId, + tokenAddress: nativeTokenAddress, + }; + + // Include all other tokens + for (const tokenAddress of tokenAddresses) { + const caipAssetId = createCaipAssetId(chainId, tokenAddress); + caipAssetIds.push(caipAssetId); + assetIdToOriginal[caipAssetId] = { chainId, tokenAddress }; + } + } + + // Build the v3 API URL + const url = new URL(`${BASE_URL}/spot-prices`); + url.searchParams.append('caipAssetIds', caipAssetIds.join(',')); + url.searchParams.append('vsCurrency', currency); + url.searchParams.append('includeMarketData', 'true'); + + const response: Record = await this.#policy.execute( + () => handleFetch(url, { headers: { 'Cache-Control': 'no-cache' } }), + ); + + // Transform response back to expected format + const result: TokenPricesByCaipAssetId = {}; + + for (const [caipAssetId, marketData] of Object.entries(response)) { + const original = assetIdToOriginal[caipAssetId]; + if (original && marketData) { + const token: TokenPrice = { + tokenAddress: original.tokenAddress, + currency, + ...marketData, + }; + result[caipAssetId] = token; + } + } + + return result; + } + /** * Retrieves exchange rates in the given base currency. * diff --git a/yarn.lock b/yarn.lock index 700bcfaeb58..ccac13732e0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2792,6 +2792,7 @@ __metadata: "@metamask/preferences-controller": "npm:^21.0.0" "@metamask/providers": "npm:^22.1.0" "@metamask/rpc-errors": "npm:^7.0.2" + "@metamask/slip44": "npm:^4.3.0" "@metamask/snaps-controllers": "npm:^14.0.1" "@metamask/snaps-sdk": "npm:^9.0.0" "@metamask/snaps-utils": "npm:^11.0.0" @@ -4902,6 +4903,13 @@ __metadata: languageName: node linkType: hard +"@metamask/slip44@npm:^4.3.0": + version: 4.3.0 + resolution: "@metamask/slip44@npm:4.3.0" + checksum: 10/508983a48911f2be8d9de117d390ecfb5b949a6032f5d6c5cc63f7f23302b87468be6ff08dee4881d39e8f5f66b5545eab15e6fc0511acea10fd4c99852a8212 + languageName: node + linkType: hard + "@metamask/snaps-controllers@npm:^14.0.1": version: 14.0.1 resolution: "@metamask/snaps-controllers@npm:14.0.1"