diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index e1b6f57ae53..e5038978e1d 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **BREAKING:** Update `spot-prices` endpoint to use Price API v3 ([#7119](https://github.com/MetaMask/core/pull/7119)) + - Update `AbstractTokenPricesService.fetchTokenPrices` arguments and return type + - Update `CodefiTokenPricesServiceV2` list of supported currencies + - Update `TokenRatesController` to fetch prices by native currency instead of by chain + - Remove legacy polling code and unused events from `TokenRatesController` + ## [90.0.0] ### Added diff --git a/packages/assets-controllers/src/CurrencyRateController.test.ts b/packages/assets-controllers/src/CurrencyRateController.test.ts index ed84bdd47dd..4b5e75c67ed 100644 --- a/packages/assets-controllers/src/CurrencyRateController.test.ts +++ b/packages/assets-controllers/src/CurrencyRateController.test.ts @@ -44,7 +44,7 @@ function buildMockTokenPricesService( ): AbstractTokenPricesService { return { async fetchTokenPrices() { - return {}; + return []; }, async fetchExchangeRates() { return {}; @@ -1138,12 +1138,15 @@ describe('CurrencyRateController', () => { // Mock fetchTokenPrices to return token prices jest .spyOn(tokenPricesService, 'fetchTokenPrices') - .mockImplementation(async ({ chainId }) => { - if (chainId === '0x1') { - return { - '0x0000000000000000000000000000000000000000': { + .mockImplementation(async ({ assets }) => { + // eslint-disable-next-line jest/no-conditional-in-test + if (assets.some((asset) => asset.chainId === '0x1')) { + return [ + { currency: 'usd', tokenAddress: '0x0000000000000000000000000000000000000000', + chainId: assets[0].chainId, + assetId: 'xx:yy/aa:bb', price: 2500.5, pricePercentChange1d: 0, priceChange1d: 0, @@ -1163,13 +1166,16 @@ describe('CurrencyRateController', () => { pricePercentChange7d: 100, totalVolume: 100, }, - }; + ]; } - if (chainId === '0x89') { - return { - '0x0000000000000000000000000000000000001010': { + // eslint-disable-next-line jest/no-conditional-in-test + if (assets.some((asset) => asset.chainId === '0x89')) { + return [ + { currency: 'usd', tokenAddress: '0x0000000000000000000000000000000000001010', + chainId: assets[0].chainId, + assetId: 'xx:yy/aa:bb', price: 0.85, pricePercentChange1d: 0, priceChange1d: 0, @@ -1189,9 +1195,9 @@ describe('CurrencyRateController', () => { pricePercentChange7d: 100, totalVolume: 100, }, - }; + ]; } - return {}; + return []; }); // Make crypto compare also fail by not mocking it (no nock setup) @@ -1255,12 +1261,21 @@ describe('CurrencyRateController', () => { const fetchTokenPricesSpy = jest .spyOn(tokenPricesService, 'fetchTokenPrices') - .mockImplementation(async ({ chainId }) => { - if (chainId === '0x1' || chainId === '0xaa36a7') { - return { - '0x0000000000000000000000000000000000000000': { + .mockImplementation(async ({ assets }) => { + // eslint-disable-next-line jest/no-conditional-in-test + if ( + assets.some( + (asset) => + // eslint-disable-next-line jest/no-conditional-in-test + asset.chainId === '0x1' || asset.chainId === '0xaa36a7', + ) + ) { + return [ + { currency: 'usd', tokenAddress: '0x0000000000000000000000000000000000000000', + chainId: assets[0].chainId, + assetId: 'xx:yy/aa:bb', price: 2500.5, pricePercentChange1d: 0, priceChange1d: 0, @@ -1280,9 +1295,9 @@ describe('CurrencyRateController', () => { pricePercentChange7d: 100, totalVolume: 100, }, - }; + ]; } - return {}; + return []; }); const controller = new CurrencyRateController({ @@ -1296,8 +1311,12 @@ describe('CurrencyRateController', () => { // Should only call fetchTokenPrices once, using first matching chainId (line 255) expect(fetchTokenPricesSpy).toHaveBeenCalledTimes(1); expect(fetchTokenPricesSpy).toHaveBeenCalledWith({ - chainId: '0x1', // First chainId with ETH as native currency - tokenAddresses: [], + assets: [ + { + chainId: '0x1', + tokenAddress: '0x0000000000000000000000000000000000000000', + }, + ], currency: 'usd', }); @@ -1338,13 +1357,16 @@ describe('CurrencyRateController', () => { jest .spyOn(tokenPricesService, 'fetchTokenPrices') - .mockImplementation(async ({ chainId }) => { - if (chainId === '0x1') { + .mockImplementation(async ({ assets }) => { + // eslint-disable-next-line jest/no-conditional-in-test + if (assets.some((asset) => asset.chainId === '0x1')) { // ETH succeeds - return { - '0x0000000000000000000000000000000000000000': { + return [ + { currency: 'usd', tokenAddress: '0x0000000000000000000000000000000000000000', + chainId: assets[0].chainId, + assetId: 'xx:yy/aa:bb', price: 2500.5, pricePercentChange1d: 0, priceChange1d: 0, @@ -1364,7 +1386,7 @@ describe('CurrencyRateController', () => { pricePercentChange7d: 100, totalVolume: 100, }, - }; + ]; } // POL fails throw new Error('Failed to fetch POL price'); @@ -1427,7 +1449,7 @@ describe('CurrencyRateController', () => { .mockRejectedValue(new Error('Price API failed')); // Return empty object (no token price) - jest.spyOn(tokenPricesService, 'fetchTokenPrices').mockResolvedValue({}); + jest.spyOn(tokenPricesService, 'fetchTokenPrices').mockResolvedValue([]); const controller = new CurrencyRateController({ messenger, @@ -1475,12 +1497,15 @@ describe('CurrencyRateController', () => { const fetchTokenPricesSpy = jest .spyOn(tokenPricesService, 'fetchTokenPrices') - .mockImplementation(async ({ chainId }) => { - if (chainId === '0x1') { - return { - '0x0000000000000000000000000000000000000000': { + .mockImplementation(async ({ assets }) => { + // eslint-disable-next-line jest/no-conditional-in-test + if (assets.some((asset) => asset.chainId === '0x1')) { + return [ + { currency: 'usd', tokenAddress: '0x0000000000000000000000000000000000000000', + chainId: assets[0].chainId, + assetId: 'xx:yy/aa:bb', price: 2500.5, pricePercentChange1d: 0, priceChange1d: 0, @@ -1500,9 +1525,9 @@ describe('CurrencyRateController', () => { pricePercentChange7d: 100, totalVolume: 100, }, - }; + ]; } - return {}; + return []; }); const controller = new CurrencyRateController({ @@ -1517,8 +1542,12 @@ describe('CurrencyRateController', () => { // Should only call fetchTokenPrices for ETH, not BNB (line 252: if chainIds.length > 0) expect(fetchTokenPricesSpy).toHaveBeenCalledTimes(1); expect(fetchTokenPricesSpy).toHaveBeenCalledWith({ - chainId: '0x1', - tokenAddresses: [], + assets: [ + { + chainId: '0x1', + tokenAddress: '0x0000000000000000000000000000000000000000', + }, + ], currency: 'usd', }); @@ -1562,10 +1591,11 @@ describe('CurrencyRateController', () => { const fetchTokenPricesSpy = jest .spyOn(tokenPricesService, 'fetchTokenPrices') - .mockResolvedValue({ - '0x0000000000000000000000000000000000001010': { + .mockResolvedValue([ + { currency: 'usd', tokenAddress: '0x0000000000000000000000000000000000001010', + chainId: '0x89', price: 0.85, pricePercentChange1d: 0, priceChange1d: 0, @@ -1585,7 +1615,7 @@ describe('CurrencyRateController', () => { pricePercentChange7d: 100, totalVolume: 100, }, - }); + ]); const controller = new CurrencyRateController({ messenger, @@ -1597,8 +1627,12 @@ describe('CurrencyRateController', () => { // Should use Polygon's native token address (line 269) expect(fetchTokenPricesSpy).toHaveBeenCalledWith({ - chainId: '0x89', - tokenAddresses: [], + assets: [ + { + chainId: '0x89', + tokenAddress: '0x0000000000000000000000000000000000001010', + }, + ], currency: 'usd', }); diff --git a/packages/assets-controllers/src/CurrencyRateController.ts b/packages/assets-controllers/src/CurrencyRateController.ts index ef8a43d9598..d26df49b196 100644 --- a/packages/assets-controllers/src/CurrencyRateController.ts +++ b/packages/assets-controllers/src/CurrencyRateController.ts @@ -259,12 +259,15 @@ export class CurrencyRateController extends StaticIntervalPollingController + item.tokenAddress.toLowerCase() === + nativeTokenAddress.toLowerCase(), + ); return { nativeCurrency, diff --git a/packages/assets-controllers/src/TokenRatesController.test.ts b/packages/assets-controllers/src/TokenRatesController.test.ts index 0ed2dea39d3..ab4b3f42e1d 100644 --- a/packages/assets-controllers/src/TokenRatesController.test.ts +++ b/packages/assets-controllers/src/TokenRatesController.test.ts @@ -1,12 +1,5 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; -import { - ChainId, - InfuraNetworkType, - NetworksTicker, - toChecksumHexAddress, - toHex, -} from '@metamask/controller-utils'; -import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { ChainId, toChecksumHexAddress } from '@metamask/controller-utils'; import { Messenger, MOCK_ANY_NAMESPACE, @@ -17,42 +10,32 @@ import { import type { NetworkClientConfiguration, NetworkClientId, + NetworkConfiguration, NetworkState, } from '@metamask/network-controller'; import { getDefaultNetworkControllerState } from '@metamask/network-controller'; -import type { Hex } from '@metamask/utils'; -import { add0x } from '@metamask/utils'; -import assert from 'assert'; +import type { CaipAssetType, Hex } from '@metamask/utils'; +import { add0x, KnownCaipNamespace } from '@metamask/utils'; import type { Patch } from 'immer'; -import { useFakeTimers } from 'sinon'; import { TOKEN_PRICES_BATCH_SIZE } from './assetsUtil'; import type { AbstractTokenPricesService, - TokenPrice, - TokenPricesByTokenAddress, + EvmAssetWithMarketData, } from './token-prices-service/abstract-token-prices-service'; +import { ZERO_ADDRESS } from './token-prices-service/codefi-v2'; import { controllerName, TokenRatesController } from './TokenRatesController'; import type { + MarketDataDetails, Token, TokenRatesControllerMessenger, TokenRatesControllerState, } from './TokenRatesController'; import { getDefaultTokensState } from './TokensController'; import type { TokensControllerState } from './TokensController'; -import { advanceTime } from '../../../tests/helpers'; -import { createMockInternalAccount } from '../../accounts-controller/src/tests/mocks'; -import { - buildCustomNetworkClientConfiguration, - buildMockGetNetworkClientById, - buildNetworkConfiguration, -} from '../../network-controller/tests/helpers'; - -const defaultSelectedAddress = '0x0000000000000000000000000000000000000001'; -const defaultSelectedAccount = createMockInternalAccount({ - address: defaultSelectedAddress, -}); -const mockTokenAddress = '0x0000000000000000000000000000000000000010'; +import { flushPromises } from '../../../tests/helpers'; + +const defaultSelectedAddress = '0x1111111111111111111111111111111111111111'; type AllTokenRatesControllerActions = MessengerActions; @@ -86,34 +69,14 @@ function buildTokenRatesControllerMessenger( }); messenger.delegate({ messenger: tokenRatesControllerMessenger, - actions: [ - 'TokensController:getState', - 'NetworkController:getNetworkClientById', - 'NetworkController:getState', - 'AccountsController:getAccount', - 'AccountsController:getSelectedAccount', - ], - events: [ - 'TokensController:stateChange', - 'NetworkController:stateChange', - 'AccountsController:selectedEvmAccountChange', - ], + actions: ['TokensController:getState', 'NetworkController:getState'], + events: ['TokensController:stateChange', 'NetworkController:stateChange'], }); return tokenRatesControllerMessenger; } describe('TokenRatesController', () => { describe('constructor', () => { - let clock: sinon.SinonFakeTimers; - - beforeEach(() => { - clock = useFakeTimers({ now: Date.now() }); - }); - - afterEach(() => { - clock.restore(); - }); - it('should set default state', async () => { await withController(async ({ controller }) => { expect(controller.state).toStrictEqual({ @@ -121,2703 +84,957 @@ describe('TokenRatesController', () => { }); }); }); + }); + + describe('updateExchangeRates', () => { + it('does not fetch when disabled', async () => { + const tokenPricesService = buildMockTokenPricesService(); + jest.spyOn(tokenPricesService, 'fetchTokenPrices'); - it('should not poll by default', async () => { - const fetchSpy = jest.spyOn(globalThis, 'fetch'); await withController( { options: { - interval: 100, + tokenPricesService, + disabled: true, }, }, async ({ controller }) => { - expect(controller.state).toStrictEqual({ - marketData: {}, - }); + await controller.updateExchangeRates([ + { + chainId: '0x1', + nativeCurrency: 'ETH', + }, + ]); + + expect(tokenPricesService.fetchTokenPrices).not.toHaveBeenCalled(); }, ); - await advanceTime({ clock, duration: 500 }); - - expect(fetchSpy).not.toHaveBeenCalled(); - }); - }); - - describe('TokensController::stateChange', () => { - let clock: sinon.SinonFakeTimers; - - beforeEach(() => { - clock = useFakeTimers({ now: Date.now() }); - }); - - afterEach(() => { - clock.restore(); }); - describe('when legacy polling is active', () => { - it('should update exchange rates when any of the addresses in the "all tokens" collection change', async () => { - const tokenAddresses = ['0xE1', '0xE2']; - await withController( - { - mockTokensControllerState: { - allTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: tokenAddresses[0], - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }, - }, - async ({ controller, triggerTokensStateChange }) => { - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRatesByChainId') - .mockResolvedValue(); - await controller.start(ChainId.mainnet, 'ETH'); - triggerTokensStateChange({ - ...getDefaultTokensState(), - allTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: tokenAddresses[1], - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }); - - // Once when starting, and another when tokens state changes - expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(2); - }, - ); - }); - - it('should update exchange rates when any of the addresses in the "all tokens" collection change with invalid addresses', async () => { - const tokenAddresses = ['0xinvalidAddress']; - await withController( - { - mockTokensControllerState: { - allTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: tokenAddresses[0], - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }, - }, - async ({ controller, triggerTokensStateChange }) => { - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRatesByChainId') - .mockResolvedValue(); - await controller.start(ChainId.mainnet, 'ETH'); - triggerTokensStateChange({ - ...getDefaultTokensState(), - allTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: tokenAddresses[1], - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }); + it('fetches rates for tokens in one batch', async () => { + const chainId = '0x1'; + const nativeCurrency = 'ETH'; - // Once when starting, and another when tokens state changes - expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(2); - }, - ); + const tokenPricesService = buildMockTokenPricesService({ + fetchTokenPrices: fetchTokenPricesWithIncreasingPriceForEachToken, }); + jest.spyOn(tokenPricesService, 'fetchTokenPrices'); - it('should update exchange rates when any of the addresses in the "all detected tokens" collection change', async () => { - const tokenAddresses = ['0xE1', '0xE2']; - await withController( - { - mockTokensControllerState: { - allDetectedTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: tokenAddresses[0], - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }, + await withController( + { + options: { + tokenPricesService, }, - async ({ controller, triggerTokensStateChange }) => { - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRatesByChainId') - .mockResolvedValue(); - await controller.start(ChainId.mainnet, 'ETH'); - triggerTokensStateChange({ - ...getDefaultTokensState(), - allDetectedTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: tokenAddresses[1], - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, + mockTokensControllerState: { + allTokens: { + [chainId]: { + [defaultSelectedAddress]: [ + { + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', + }, + ], }, - }); - // Once when starting, and another when tokens state changes - expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(2); - }, - ); - }); - - it('should not update exchange rates if both the "all tokens" or "all detected tokens" are exactly the same', async () => { - const tokensState = { - allTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: mockTokenAddress, - decimals: 0, - symbol: '', - aggregators: [], - }, - ], }, }, - }; - await withController( - { - mockTokensControllerState: { - ...tokensState, + }, + async ({ controller }) => { + await controller.updateExchangeRates([ + { + chainId, + nativeCurrency, }, - }, - async ({ controller, triggerTokensStateChange }) => { - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - await controller.start(ChainId.mainnet, 'ETH'); - triggerTokensStateChange({ - ...getDefaultTokensState(), - ...tokensState, - }); - - // Once when starting, and that's it - expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(1); - }, - ); - }); + ]); - it('should not update exchange rates if all of the tokens in "all tokens" just move to "all detected tokens"', async () => { - const tokens = { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(1); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledWith({ + assets: [ { - address: mockTokenAddress, - decimals: 0, - symbol: '', - aggregators: [], + chainId, + tokenAddress: '0x0000000000000000000000000000000000000000', }, - ], - }, - }; - await withController( - { - mockTokensControllerState: { - allTokens: tokens, - }, - }, - async ({ controller, triggerTokensStateChange }) => { - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - await controller.start(ChainId.mainnet, 'ETH'); - triggerTokensStateChange({ - ...getDefaultTokensState(), - allDetectedTokens: tokens, - }); - - // Once when starting, and that's it - expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(1); - }, - ); - }); - - it('should not update exchange rates if a new token is added to "all detected tokens" but is already present in "all tokens"', async () => { - const tokens = { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ { - address: mockTokenAddress, - decimals: 0, - symbol: '', - aggregators: [], + chainId, + tokenAddress: '0x0000000000000000000000000000000000000001', }, ], - }, - }; - await withController( - { - mockTokensControllerState: { - allTokens: tokens, - }, - }, - async ({ controller, triggerTokensStateChange }) => { - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - await controller.start(ChainId.mainnet, 'ETH'); - triggerTokensStateChange({ - ...getDefaultTokensState(), - allTokens: tokens, - allDetectedTokens: tokens, - }); - - // Once when starting, and that's it - expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(1); - }, - ); - }); + currency: nativeCurrency, + }); - it('should not update exchange rates if a new token is added to "all tokens" but is already present in "all detected tokens"', async () => { - const tokens = { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: mockTokenAddress, - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }; - await withController( - { - mockTokensControllerState: { - allDetectedTokens: tokens, + expect(controller.state.marketData).toStrictEqual({ + '0x1': { + '0x0000000000000000000000000000000000000000': + expect.objectContaining({ + currency: nativeCurrency, + price: 0.001, + }), + '0x0000000000000000000000000000000000000001': + expect.objectContaining({ + currency: nativeCurrency, + price: 0.002, + }), }, - }, - async ({ controller, triggerTokensStateChange }) => { - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - await controller.start(ChainId.mainnet, 'ETH'); - triggerTokensStateChange({ - ...getDefaultTokensState(), - allTokens: tokens, - allDetectedTokens: tokens, - }); - - // Once when starting, and that's it - expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(1); - }, - ); - }); + }); + }, + ); + }); - it('should not update exchange rates if none of the addresses in "all tokens" or "all detected tokens" change, even if other parts of the token change', async () => { - await withController( - { - mockTokensControllerState: { - ...getDefaultTokensState(), - allDetectedTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: mockTokenAddress, - decimals: 3, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }, - }, - async ({ controller, triggerTokensStateChange }) => { - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - await controller.start(ChainId.mainnet, 'ETH'); - triggerTokensStateChange({ - ...getDefaultTokensState(), - allDetectedTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: mockTokenAddress, - decimals: 7, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }); + it('fetches rates for all tokens in batches', async () => { + const chainId = '0x1'; + const nativeCurrency = 'ETH'; - // Once when starting, and that's it - expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(1); - }, - ); + const tokenPricesService = buildMockTokenPricesService({ + fetchTokenPrices: fetchTokenPricesWithIncreasingPriceForEachToken, }); + jest.spyOn(tokenPricesService, 'fetchTokenPrices'); - it('should not update exchange rates if none of the addresses in "all tokens" or "all detected tokens" change, when normalized to checksum addresses', async () => { - await withController( - { - mockTokensControllerState: { - allDetectedTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: '0x0EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE2', - decimals: 3, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }, - }, - async ({ controller, triggerTokensStateChange }) => { - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - await controller.start(ChainId.mainnet, 'ETH'); - triggerTokensStateChange({ - ...getDefaultTokensState(), - allDetectedTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: '0x0eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee2', - decimals: 7, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }); - - // Once when starting, and that's it - expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(1); - }, - ); + const tokenAddresses = [...new Array(200).keys()] + .map(buildAddress) + .sort(); + const tokens = tokenAddresses.map((tokenAddress) => { + return buildToken({ address: tokenAddress }); }); - - it('should not update exchange rates if any of the addresses in "all tokens" or "all detected tokens" merely change order', async () => { - await withController( - { - mockTokensControllerState: { - allDetectedTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: '0xE1', - decimals: 0, - symbol: '', - aggregators: [], - }, - { - address: '0xE2', - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, + await withController( + { + options: { + tokenPricesService, + }, + mockTokensControllerState: { + allTokens: { + [chainId]: { + [defaultSelectedAddress]: tokens, }, }, }, - async ({ controller, triggerTokensStateChange }) => { - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - await controller.start(ChainId.mainnet, 'ETH'); - triggerTokensStateChange({ - ...getDefaultTokensState(), - allDetectedTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: '0xE2', - decimals: 0, - symbol: '', - aggregators: [], - }, - { - address: '0xE1', - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }); + }, + async ({ controller }) => { + await controller.updateExchangeRates([ + { + chainId, + nativeCurrency, + }, + ]); + const numBatches = Math.ceil( + tokenAddresses.length / TOKEN_PRICES_BATCH_SIZE, + ); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes( + numBatches, + ); - // Once when starting, and that's it - expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(1); - }, - ); - }); + for (let i = 1; i <= numBatches; i++) { + expect(tokenPricesService.fetchTokenPrices).toHaveBeenNthCalledWith( + i, + { + assets: tokenAddresses + .slice( + (i - 1) * TOKEN_PRICES_BATCH_SIZE, + i * TOKEN_PRICES_BATCH_SIZE, + ) + .map((tokenAddress) => ({ + chainId, + tokenAddress, + })), + currency: nativeCurrency, + }, + ); + } + }, + ); }); - describe('when legacy polling is inactive', () => { - it('should not update exchange rates when any of the addresses in the "all tokens" collection change', async () => { - const tokenAddresses = ['0xE1', '0xE2']; - await withController( - { - mockTokensControllerState: { - allTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: tokenAddresses[0], - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }, - }, - async ({ controller, triggerTokensStateChange }) => { - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - triggerTokensStateChange({ - ...getDefaultTokensState(), - allTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: tokenAddresses[1], - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }); + it('leaves unsupported chain state keys empty', async () => { + const chainId = '0x1'; + const nativeCurrency = 'ETH'; - expect(updateExchangeRatesSpy).not.toHaveBeenCalled(); - }, - ); + const tokenPricesService = buildMockTokenPricesService({ + fetchTokenPrices: fetchTokenPricesWithIncreasingPriceForEachToken, + validateChainIdSupported: (_chainId: unknown): _chainId is Hex => false, }); + jest.spyOn(tokenPricesService, 'fetchTokenPrices'); - it('should not update exchange rates when any of the addresses in the "all detected tokens" collection change', async () => { - const tokenAddresses = ['0xE1', '0xE2']; - await withController( - { - mockTokensControllerState: { - allDetectedTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: tokenAddresses[0], - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }, - }, - async ({ controller, triggerTokensStateChange }) => { - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - triggerTokensStateChange({ - ...getDefaultTokensState(), - allDetectedTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: tokenAddresses[1], - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }); - - expect(updateExchangeRatesSpy).not.toHaveBeenCalled(); + await withController( + { + options: { + tokenPricesService, }, - ); - }); - }); - }); - - describe('NetworkController::stateChange', () => { - let clock: sinon.SinonFakeTimers; + }, + async ({ controller }) => { + await controller.updateExchangeRates([ + { + chainId, + nativeCurrency, + }, + ]); - beforeEach(() => { - clock = useFakeTimers({ now: Date.now() }); + expect(tokenPricesService.fetchTokenPrices).not.toHaveBeenCalled(); + expect(controller.state.marketData).toStrictEqual({ + [chainId]: {}, + }); + }, + ); }); - afterEach(() => { - clock.restore(); - }); + it('fetches rates for unsupported native currencies', async () => { + const chainId = '0x1'; + const nativeCurrency = 'ETH'; - describe('when polling is active', () => { - it('should update exchange rates when ticker changes', async () => { - await withController( - { - options: { - interval: 100, + const tokenPricesService = buildMockTokenPricesService({ + fetchTokenPrices: async ({ currency }) => { + return [ + { + tokenAddress: ZERO_ADDRESS, + chainId, + assetId: `${KnownCaipNamespace.Eip155}:1/slip44:60`, + currency, + price: 50, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 60, + allTimeLow: 40, + circulatingSupply: 2000, + dilutedMarketCap: 1000, + high1d: 55, + low1d: 45, + marketCap: 2000, + marketCapPercentChange1d: 100, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 100, }, - mockNetworkClientConfigurationsByNetworkClientId: { - 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ - chainId: toHex(1337), - ticker: 'NEW', - }), + { + tokenAddress: '0x0000000000000000000000000000000000000001', + chainId, + assetId: `${KnownCaipNamespace.Eip155}:1/erc20:0x0000000000000000000000000000000000000001`, + currency, + price: 100, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 200, + allTimeLow: 80, + circulatingSupply: 2000, + dilutedMarketCap: 500, + high1d: 110, + low1d: 95, + marketCap: 1000, + marketCapPercentChange1d: 100, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 100, }, - }, - async ({ controller, triggerNetworkStateChange }) => { - await controller.start(ChainId.mainnet, 'ETH'); - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - triggerNetworkStateChange({ - ...getDefaultNetworkControllerState(), - selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', - }); - - expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(1); - }, - ); + ]; + }, + validateCurrencySupported: (_currency: unknown): _currency is string => + false, }); + jest.spyOn(tokenPricesService, 'fetchTokenPrices'); - it('should not update exchange rates when chain ID changes', async () => { - await withController( - { - options: { - interval: 100, - }, - mockNetworkClientConfigurationsByNetworkClientId: { - 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ - chainId: toHex(1338), - ticker: 'TEST', - }), - }, - }, - async ({ controller, triggerNetworkStateChange }) => { - await controller.start(ChainId.mainnet, 'ETH'); - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - triggerNetworkStateChange({ - ...getDefaultNetworkControllerState(), - selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', - }); - - expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(1); + await withController( + { + options: { + tokenPricesService, }, - ); - }); - - it('should clear marketData in state when ticker changes', async () => { - await withController( - { - options: { - interval: 100, - state: { - marketData: { - [ChainId.mainnet]: { - '0x02': { - currency: 'ETH', - priceChange1d: 0, - pricePercentChange1d: 0, - tokenAddress: '0x02', - allTimeHigh: 4000, - allTimeLow: 900, - circulatingSupply: 2000, - dilutedMarketCap: 100, - high1d: 200, - low1d: 100, - marketCap: 1000, - marketCapPercentChange1d: 100, - price: 0.001, - pricePercentChange14d: 100, - pricePercentChange1h: 1, - pricePercentChange1y: 200, - pricePercentChange200d: 300, - pricePercentChange30d: 200, - pricePercentChange7d: 100, - totalVolume: 100, - }, + mockTokensControllerState: { + allTokens: { + [chainId]: { + [defaultSelectedAddress]: [ + { + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', }, - }, + ], }, }, - mockNetworkClientConfigurationsByNetworkClientId: { - 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ - chainId: toHex(1337), - ticker: 'NEW', - }), - }, }, - async ({ controller, triggerNetworkStateChange }) => { - await controller.start(ChainId.mainnet, 'ETH'); - jest.spyOn(controller, 'updateExchangeRates').mockResolvedValue(); - triggerNetworkStateChange({ - ...getDefaultNetworkControllerState(), - selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', - }); + }, + async ({ controller }) => { + await controller.updateExchangeRates([ + { + chainId, + nativeCurrency, + }, + ]); - expect(controller.state.marketData).toStrictEqual({ - '0x1': { - '0x0000000000000000000000000000000000000000': { - currency: 'ETH', - }, + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(1); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledWith({ + assets: [ + { + chainId, + tokenAddress: '0x0000000000000000000000000000000000000000', }, - }); - }, - ); - }); - - it('should not clear marketData state when chain ID changes', async () => { - await withController( - { - options: { - interval: 100, - state: { - marketData: { - [ChainId.mainnet]: { - '0x02': { - currency: 'ETH', - priceChange1d: 0, - pricePercentChange1d: 0, - tokenAddress: '0x02', - allTimeHigh: 4000, - allTimeLow: 900, - circulatingSupply: 2000, - dilutedMarketCap: 100, - high1d: 200, - low1d: 100, - marketCap: 1000, - marketCapPercentChange1d: 100, - price: 0.001, - pricePercentChange14d: 100, - pricePercentChange1h: 1, - pricePercentChange1y: 200, - pricePercentChange200d: 300, - pricePercentChange30d: 200, - pricePercentChange7d: 100, - totalVolume: 100, - }, - }, - }, + { + chainId, + tokenAddress: '0x0000000000000000000000000000000000000001', }, - }, - mockNetworkClientConfigurationsByNetworkClientId: { - 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ - chainId: toHex(1338), - ticker: 'TEST', - }), - }, - }, - async ({ controller, triggerNetworkStateChange }) => { - await controller.start(ChainId.mainnet, 'ETH'); - jest.spyOn(controller, 'updateExchangeRates').mockResolvedValue(); - triggerNetworkStateChange({ - ...getDefaultNetworkControllerState(), - selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', - }); + ], + currency: 'usd', + }); - expect(controller.state.marketData).toStrictEqual({ - '0x1': { - '0x0000000000000000000000000000000000000000': { - currency: 'ETH', - }, + expect(controller.state.marketData).toStrictEqual({ + '0x1': { + '0x0000000000000000000000000000000000000000': { + tokenAddress: '0x0000000000000000000000000000000000000000', + chainId: '0x1', + assetId: 'eip155:1/slip44:60', + currency: 'ETH', + price: 1, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 1.2, + allTimeLow: 0.8, + circulatingSupply: 2000, + dilutedMarketCap: 20, + high1d: 1.1, + low1d: 0.9, + marketCap: 40, + marketCapPercentChange1d: 100, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 2, }, - }); - }, - ); - }); - - it('should update exchange rates when network state changes without adding a new network', async () => { - await withController( - { - options: { - interval: 100, - }, - mockNetworkClientConfigurationsByNetworkClientId: { - 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ - chainId: ChainId.mainnet, - ticker: NetworksTicker.mainnet, - }), - }, - }, - async ({ controller, triggerNetworkStateChange }) => { - await controller.start(ChainId.mainnet, 'ETH'); - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - triggerNetworkStateChange( - { - ...getDefaultNetworkControllerState(), - selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', + '0x0000000000000000000000000000000000000001': { + tokenAddress: '0x0000000000000000000000000000000000000001', + chainId: '0x1', + assetId: + 'eip155:1/erc20:0x0000000000000000000000000000000000000001', + currency: 'ETH', + price: 2, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 4, + allTimeLow: 1.6, + circulatingSupply: 2000, + dilutedMarketCap: 10, + high1d: 2.2, + low1d: 1.9, + marketCap: 20, + marketCapPercentChange1d: 100, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 2, }, - [ - { - op: 'add', - path: ['networkConfigurationsByChainId', ChainId.mainnet], - }, - ], - ); - expect(updateExchangeRatesSpy).toHaveBeenCalled(); - }, - ); - }); - }); - - describe('when polling is inactive', () => { - it('should not update exchange rates when ticker changes', async () => { - await withController( - { - options: { - interval: 100, - }, - mockNetworkClientConfigurationsByNetworkClientId: { - 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ - chainId: toHex(1337), - ticker: 'NEW', - }), }, - }, - async ({ controller, triggerNetworkStateChange }) => { - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - triggerNetworkStateChange({ - ...getDefaultNetworkControllerState(), - selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', - }); + }); + }, + ); + }); - expect(updateExchangeRatesSpy).not.toHaveBeenCalled(); - }, - ); - }); + it('does not convert prices when the native currency fallback price is 0', async () => { + const chainId = '0x1'; + const nativeCurrency = 'ETH'; - it('should not update exchange rates when chain ID changes', async () => { - await withController( - { - options: { - interval: 100, + const tokenPricesService = buildMockTokenPricesService({ + fetchTokenPrices: async ({ currency }) => { + return [ + { + tokenAddress: ZERO_ADDRESS, + chainId, + assetId: `${KnownCaipNamespace.Eip155}:1/slip44:60`, + currency, + price: 0, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 60, + allTimeLow: 40, + circulatingSupply: 2000, + dilutedMarketCap: 1000, + high1d: 55, + low1d: 45, + marketCap: 2000, + marketCapPercentChange1d: 100, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 100, }, - mockNetworkClientConfigurationsByNetworkClientId: { - 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ - chainId: toHex(1338), - ticker: 'TEST', - }), + { + tokenAddress: '0x0000000000000000000000000000000000000001', + chainId, + assetId: `${KnownCaipNamespace.Eip155}:1/erc20:0x0000000000000000000000000000000000000001`, + currency, + price: 100, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 200, + allTimeLow: 80, + circulatingSupply: 2000, + dilutedMarketCap: 500, + high1d: 110, + low1d: 95, + marketCap: 1000, + marketCapPercentChange1d: 100, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 100, }, - }, - async ({ controller, triggerNetworkStateChange }) => { - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - triggerNetworkStateChange({ - ...getDefaultNetworkControllerState(), - selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', - }); - - expect(updateExchangeRatesSpy).not.toHaveBeenCalled(); - }, - ); + ]; + }, + validateCurrencySupported: (_currency: unknown): _currency is string => + false, }); + jest.spyOn(tokenPricesService, 'fetchTokenPrices'); - it('should not clear marketData state when ticker changes', async () => { - await withController( - { - options: { - interval: 100, - state: { - marketData: { - [ChainId.mainnet]: { - '0x02': { - currency: 'ETH', - priceChange1d: 0, - pricePercentChange1d: 0, - tokenAddress: '0x02', - allTimeHigh: 4000, - allTimeLow: 900, - circulatingSupply: 2000, - dilutedMarketCap: 100, - high1d: 200, - low1d: 100, - marketCap: 1000, - marketCapPercentChange1d: 100, - price: 0.001, - pricePercentChange14d: 100, - pricePercentChange1h: 1, - pricePercentChange1y: 200, - pricePercentChange200d: 300, - pricePercentChange30d: 200, - pricePercentChange7d: 100, - totalVolume: 100, - }, + await withController( + { + options: { + tokenPricesService, + }, + mockTokensControllerState: { + allTokens: { + [chainId]: { + [defaultSelectedAddress]: [ + { + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', }, - }, + ], }, }, - mockNetworkClientConfigurationsByNetworkClientId: { - 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ - chainId: toHex(1337), - ticker: 'NEW', - }), - }, }, - async ({ controller, triggerNetworkStateChange }) => { - jest.spyOn(controller, 'updateExchangeRates').mockResolvedValue(); - triggerNetworkStateChange({ - ...getDefaultNetworkControllerState(), - selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', - }); - - expect(controller.state.marketData).toStrictEqual({ - '0x1': { - '0x02': { - currency: 'ETH', - priceChange1d: 0, - pricePercentChange1d: 0, - tokenAddress: '0x02', - allTimeHigh: 4000, - allTimeLow: 900, - circulatingSupply: 2000, - dilutedMarketCap: 100, - high1d: 200, - low1d: 100, - marketCap: 1000, - marketCapPercentChange1d: 100, - price: 0.001, - pricePercentChange14d: 100, - pricePercentChange1h: 1, - pricePercentChange1y: 200, - pricePercentChange200d: 300, - pricePercentChange30d: 200, - pricePercentChange7d: 100, - totalVolume: 100, - }, - }, - }); - }, - ); - }); + }, + async ({ controller }) => { + await controller.updateExchangeRates([ + { + chainId, + nativeCurrency, + }, + ]); - it('should not clear marketData state when chain ID changes', async () => { - await withController( - { - options: { - interval: 100, - state: { - marketData: { - [ChainId.mainnet]: { - '0x02': { - currency: 'ETH', - priceChange1d: 0, - pricePercentChange1d: 0, - tokenAddress: '0x02', - allTimeHigh: 4000, - allTimeLow: 900, - circulatingSupply: 2000, - dilutedMarketCap: 100, - high1d: 200, - low1d: 100, - marketCap: 1000, - marketCapPercentChange1d: 100, - price: 0.001, - pricePercentChange14d: 100, - pricePercentChange1h: 1, - pricePercentChange1y: 200, - pricePercentChange200d: 300, - pricePercentChange30d: 200, - pricePercentChange7d: 100, - totalVolume: 100, - }, - }, - }, + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(1); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledWith({ + assets: [ + { + chainId, + tokenAddress: '0x0000000000000000000000000000000000000000', }, - }, - mockNetworkClientConfigurationsByNetworkClientId: { - 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ - chainId: toHex(1338), - ticker: 'TEST', - }), - }, - }, - async ({ controller, triggerNetworkStateChange }) => { - jest.spyOn(controller, 'updateExchangeRates').mockResolvedValue(); - triggerNetworkStateChange({ - ...getDefaultNetworkControllerState(), - selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', - }); - - expect(controller.state.marketData).toStrictEqual({ - '0x1': { - '0x02': { - currency: 'ETH', - priceChange1d: 0, - pricePercentChange1d: 0, - tokenAddress: '0x02', - allTimeHigh: 4000, - allTimeLow: 900, - circulatingSupply: 2000, - dilutedMarketCap: 100, - high1d: 200, - low1d: 100, - marketCap: 1000, - marketCapPercentChange1d: 100, - price: 0.001, - pricePercentChange14d: 100, - pricePercentChange1h: 1, - pricePercentChange1y: 200, - pricePercentChange200d: 300, - pricePercentChange30d: 200, - pricePercentChange7d: 100, - totalVolume: 100, - }, + { + chainId, + tokenAddress: '0x0000000000000000000000000000000000000001', }, - }); - }, - ); - }); - }); + ], + currency: 'usd', + }); - it('removes state when networks are deleted', async () => { - const marketData = { - [ChainId.mainnet]: { - '0x123456': { - currency: 'ETH', - priceChange1d: 0, - pricePercentChange1d: 0, - tokenAddress: '0x02', - allTimeHigh: 4000, - allTimeLow: 900, - circulatingSupply: 2000, - dilutedMarketCap: 100, - high1d: 200, - low1d: 100, - marketCap: 1000, - marketCapPercentChange1d: 100, - price: 0.001, - pricePercentChange14d: 100, - pricePercentChange1h: 1, - pricePercentChange1y: 200, - pricePercentChange200d: 300, - pricePercentChange30d: 200, - pricePercentChange7d: 100, - totalVolume: 100, - }, + expect(controller.state.marketData).toStrictEqual({ + '0x1': {}, + }); }, - [ChainId['linea-mainnet']]: { - '0x789': { - currency: 'ETH', - priceChange1d: 0, - pricePercentChange1d: 0, - tokenAddress: '0x02', - allTimeHigh: 4000, - allTimeLow: 900, - circulatingSupply: 2000, - dilutedMarketCap: 100, - high1d: 200, - low1d: 100, - marketCap: 1000, - marketCapPercentChange1d: 100, - price: 0.001, - pricePercentChange14d: 100, - pricePercentChange1h: 1, - pricePercentChange1y: 200, - pricePercentChange200d: 300, - pricePercentChange30d: 200, - pricePercentChange7d: 100, - totalVolume: 100, - }, + ); + }); + + it('does not convert prices when the native currency fallback price is missing', async () => { + const chainId = '0x1'; + const nativeCurrency = 'ETH'; + + const tokenPricesService = buildMockTokenPricesService({ + fetchTokenPrices: async ({ currency }) => { + return [ + { + tokenAddress: '0x0000000000000000000000000000000000000001', + chainId, + assetId: `${KnownCaipNamespace.Eip155}:1/erc20:0x0000000000000000000000000000000000000001`, + currency, + price: 100, + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 200, + allTimeLow: 80, + circulatingSupply: 2000, + dilutedMarketCap: 500, + high1d: 110, + low1d: 95, + marketCap: 1000, + marketCapPercentChange1d: 100, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 100, + }, + ]; }, - } as const; + validateCurrencySupported: (_currency: unknown): _currency is string => + false, + }); + jest.spyOn(tokenPricesService, 'fetchTokenPrices'); await withController( { options: { - state: { - marketData, + tokenPricesService, + }, + mockTokensControllerState: { + allTokens: { + [chainId]: { + [defaultSelectedAddress]: [ + { + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', + }, + ], + }, }, }, }, - async ({ controller, triggerNetworkStateChange }) => { - // Verify initial state with both networks - expect(controller.state.marketData).toStrictEqual(marketData); - - triggerNetworkStateChange( + async ({ controller }) => { + await controller.updateExchangeRates([ { - selectedNetworkClientId: 'mainnet', - networkConfigurationsByChainId: {}, - } as NetworkState, - [ + chainId, + nativeCurrency, + }, + ]); + + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(1); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledWith({ + assets: [ { - op: 'remove', - path: [ - 'networkConfigurationsByChainId', - ChainId['linea-mainnet'], - ], + chainId, + tokenAddress: '0x0000000000000000000000000000000000000000', + }, + { + chainId, + tokenAddress: '0x0000000000000000000000000000000000000001', }, ], - ); + currency: 'usd', + }); - // Verify linea removed expect(controller.state.marketData).toStrictEqual({ - [ChainId.mainnet]: marketData[ChainId.mainnet], + '0x1': {}, }); }, ); }); }); - describe('PreferencesController::stateChange', () => { - let clock: sinon.SinonFakeTimers; + describe('_executePoll', () => { + it('fetches rates for the given chains', async () => { + await withController({}, async ({ controller }) => { + jest.spyOn(controller, 'updateExchangeRates'); - beforeEach(() => { - clock = useFakeTimers({ now: Date.now() }); - }); - - afterEach(() => { - clock.restore(); - }); + await controller._executePoll({ chainIds: ['0x1'] }); - describe('when polling is active', () => { - it('should not update exchange rates when selected address changes', async () => { - const alternateSelectedAddress = - '0x0000000000000000000000000000000000000002'; - const alternateSelectedAccount = createMockInternalAccount({ - address: alternateSelectedAddress, - }); - await withController( + expect(controller.updateExchangeRates).toHaveBeenCalledWith([ { - options: { - interval: 100, - }, - mockTokensControllerState: { - allTokens: { - '0x1': { - [alternateSelectedAddress]: [ - { - address: '0x02', - decimals: 0, - symbol: '', - aggregators: [], - }, - { - address: '0x03', - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }, - }, - async ({ controller, triggerSelectedAccountChange }) => { - await controller.start(ChainId.mainnet, 'ETH'); - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - triggerSelectedAccountChange(alternateSelectedAccount); - - expect(updateExchangeRatesSpy).not.toHaveBeenCalled(); + chainId: '0x1', + nativeCurrency: 'ETH', }, - ); + ]); }); }); - describe('when polling is inactive', () => { - it('does not update exchange rates when selected account changes', async () => { - const alternateSelectedAddress = - '0x0000000000000000000000000000000000000002'; - const alternateSelectedAccount = createMockInternalAccount({ - address: alternateSelectedAddress, - }); - await withController( - { - options: { - interval: 100, - }, - mockTokensControllerState: { - allTokens: { - '0x1': { - [alternateSelectedAddress]: [ - { - address: '0x02', - decimals: 0, - symbol: '', - aggregators: [], - }, - { - address: '0x03', - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }, - }, - async ({ controller, triggerSelectedAccountChange }) => { - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - triggerSelectedAccountChange(alternateSelectedAccount); - - expect(updateExchangeRatesSpy).not.toHaveBeenCalled(); + it('does not include chains with no network configuration', async () => { + await withController( + { + mockNetworkState: { + networkConfigurationsByChainId: {}, }, - ); - }); - }); - }); - - describe('legacy polling', () => { - let clock: sinon.SinonFakeTimers; + }, + async ({ controller }) => { + jest.spyOn(controller, 'updateExchangeRates'); - beforeEach(() => { - clock = useFakeTimers({ now: Date.now() }); - }); + await controller._executePoll({ chainIds: ['0x1'] }); - afterEach(() => { - clock.restore(); + expect(controller.updateExchangeRates).toHaveBeenCalledWith([]); + }, + ); }); + }); - describe('start', () => { - it('should poll and update rate in the right interval', async () => { - const interval = 100; - const tokenPricesService = buildMockTokenPricesService(); - jest.spyOn(tokenPricesService, 'fetchTokenPrices'); - await withController( - { - options: { - interval, - tokenPricesService, - }, - mockTokensControllerState: { - allTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: mockTokenAddress, - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }, - }, - async ({ controller }) => { - await controller.start(ChainId.mainnet, 'ETH'); - - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes( - 1, - ); - - await advanceTime({ clock, duration: interval }); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes( - 2, - ); + describe('TokensController:stateChange', () => { + it('fetches rates for all updated chains', async () => { + jest.useFakeTimers(); + const chainId = '0x1'; + const nativeCurrency = 'ETH'; - await advanceTime({ clock, duration: interval }); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes( - 3, - ); - }, - ); + const tokenPricesService = buildMockTokenPricesService({ + fetchTokenPrices: fetchTokenPricesWithIncreasingPriceForEachToken, }); - }); + jest.spyOn(tokenPricesService, 'fetchTokenPrices'); - describe('stop', () => { - it('should stop polling', async () => { - const interval = 100; - const tokenPricesService = buildMockTokenPricesService(); - jest.spyOn(tokenPricesService, 'fetchTokenPrices'); - await withController( - { - options: { - interval, - tokenPricesService, + await withController( + { + options: { + tokenPricesService, + }, + }, + async ({ controller, triggerTokensStateChange }) => { + triggerTokensStateChange({ + allTokens: { + [chainId]: { + [defaultSelectedAddress]: [ + { + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', + }, + ], + }, }, - mockTokensControllerState: { - allTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: mockTokenAddress, - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, + allDetectedTokens: { + [chainId]: { + [defaultSelectedAddress]: [ + { + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', + }, + { + address: '0x0000000000000000000000000000000000000002', + decimals: 0, + symbol: 'TOK2', + }, + ], }, }, - }, - async ({ controller }) => { - await controller.start(ChainId.mainnet, 'ETH'); - - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes( - 1, - ); - - controller.stop(); + allIgnoredTokens: {}, + }); - await advanceTime({ clock, duration: interval }); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes( - 1, - ); - }, - ); - }); - }); - }); + jest.advanceTimersToNextTimer(); + await flushPromises(); - describe('polling by networkClientId', () => { - let clock: sinon.SinonFakeTimers; + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(1); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledWith({ + assets: [ + { + chainId, + tokenAddress: '0x0000000000000000000000000000000000000000', + }, + { + chainId, + tokenAddress: '0x0000000000000000000000000000000000000001', + }, + { + chainId, + tokenAddress: '0x0000000000000000000000000000000000000002', + }, + ], + currency: nativeCurrency, + }); - beforeEach(() => { - clock = useFakeTimers({ now: Date.now() }); + expect(controller.state.marketData).toStrictEqual({ + [chainId]: { + '0x0000000000000000000000000000000000000000': + expect.objectContaining({ + currency: nativeCurrency, + price: 0.001, + }), + '0x0000000000000000000000000000000000000001': + expect.objectContaining({ + currency: nativeCurrency, + price: 0.002, + }), + '0x0000000000000000000000000000000000000002': + expect.objectContaining({ + currency: nativeCurrency, + price: 0.003, + }), + }, + }); + }, + ); }); - afterEach(() => { - clock.restore(); - }); + it('does not fetch when disabled', async () => { + jest.useFakeTimers(); + const chainId = '0x1'; - it('should poll on the right interval', async () => { - const interval = 100; - const tokenPricesService = buildMockTokenPricesService(); - jest.spyOn(tokenPricesService, 'fetchTokenPrices'); await withController( { options: { - interval, - tokenPricesService, + disabled: true, }, - mockTokensControllerState: { + }, + async ({ controller, triggerTokensStateChange }) => { + jest.spyOn(controller, 'updateExchangeRates'); + + triggerTokensStateChange({ allTokens: { - [ChainId.mainnet]: { + [chainId]: { [defaultSelectedAddress]: [ { - address: mockTokenAddress, + address: '0x0000000000000000000000000000000000000001', decimals: 0, - symbol: '', - aggregators: [], + symbol: 'TOK1', }, ], }, }, - }, - }, - async ({ controller }) => { - controller.startPolling({ - chainIds: [ChainId.mainnet], + allDetectedTokens: {}, + allIgnoredTokens: {}, }); - await advanceTime({ clock, duration: 0 }); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(1); - - await advanceTime({ clock, duration: interval }); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(2); + jest.advanceTimersToNextTimer(); + await flushPromises(); - await advanceTime({ clock, duration: interval }); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(3); + expect(controller.updateExchangeRates).not.toHaveBeenCalled(); }, ); }); - describe('updating state on poll', () => { - describe('when the native currency is supported', () => { - it('returns the exchange rates directly', async () => { - const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: fetchTokenPricesWithIncreasingPriceForEachToken, - validateCurrencySupported(currency: unknown): currency is string { - return currency === 'ETH'; - }, - }); - const interval = 100; - await withController( - { - options: { - interval, - tokenPricesService, - }, - mockTokensControllerState: { - allTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: '0x02', - decimals: 0, - symbol: '', - aggregators: [], - }, - { - address: '0x03', - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }, - }, - async ({ controller }) => { - controller.startPolling({ - chainIds: [ChainId.mainnet], - }); - await advanceTime({ clock, duration: 0 }); - - expect(controller.state).toStrictEqual({ - marketData: { - [ChainId.mainnet]: { - '0x02': { - currency: 'ETH', - priceChange1d: 0, - pricePercentChange1d: 0, - tokenAddress: '0x02', - allTimeHigh: 4000, - allTimeLow: 900, - circulatingSupply: 2000, - dilutedMarketCap: 100, - high1d: 200, - low1d: 100, - marketCap: 1000, - marketCapPercentChange1d: 100, - price: 0.001, - pricePercentChange14d: 100, - pricePercentChange1h: 1, - pricePercentChange1y: 200, - pricePercentChange200d: 300, - pricePercentChange30d: 200, - pricePercentChange7d: 100, - totalVolume: 100, - }, - '0x03': { - currency: 'ETH', - priceChange1d: 0, - pricePercentChange1d: 0, - tokenAddress: '0x03', - allTimeHigh: 4000, - allTimeLow: 900, - circulatingSupply: 2000, - dilutedMarketCap: 100, - high1d: 200, - low1d: 100, - marketCap: 1000, - marketCapPercentChange1d: 100, - price: 0.002, - pricePercentChange14d: 100, - pricePercentChange1h: 1, - pricePercentChange1y: 200, - pricePercentChange200d: 300, - pricePercentChange30d: 200, - pricePercentChange7d: 100, - totalVolume: 100, - }, - }, - }, - }); - }, - ); - }); - - describe('when the native currency is not supported', () => { - const fallbackRate = 0.5; - it('returns the exchange rates using ETH as a fallback currency', async () => { - const nativeTokenPriceInUSD = 2; - // For mainnet (0x1), native token address is 0x0000...0000 - const nativeTokenAddress = - '0x0000000000000000000000000000000000000000'; - const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: async ({ tokenAddresses, currency }) => { - // Handle native token price request (empty tokenAddresses array) - if (tokenAddresses.length === 0 && currency === 'usd') { - return { - [nativeTokenAddress]: { - tokenAddress: nativeTokenAddress, - currency: 'usd', - pricePercentChange1d: 0, - priceChange1d: 0, - allTimeHigh: 4000, - allTimeLow: 900, - circulatingSupply: 2000, - dilutedMarketCap: 100, - high1d: 200, - low1d: 100, - marketCap: 1000, - marketCapPercentChange1d: 100, - price: nativeTokenPriceInUSD, - pricePercentChange14d: 100, - pricePercentChange1h: 1, - pricePercentChange1y: 200, - pricePercentChange200d: 300, - pricePercentChange30d: 200, - pricePercentChange7d: 100, - totalVolume: 100, - }, - }; - } - // Handle regular token prices - return fetchTokenPricesWithIncreasingPriceForEachToken({ - tokenAddresses, - currency, - }); - }, - validateCurrencySupported(currency: unknown): currency is string { - return currency !== 'LOL'; - }, - }); - await withController( - { - options: { - tokenPricesService, - }, - mockNetworkState: { - networkConfigurationsByChainId: { - [ChainId.mainnet]: buildNetworkConfiguration({ - nativeCurrency: 'LOL', - }), - }, - }, - mockTokensControllerState: { - allTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: '0x02', - decimals: 0, - symbol: '', - aggregators: [], - }, - { - address: '0x03', - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, - }, - }, - async ({ controller }) => { - controller.startPolling({ - chainIds: [ChainId.mainnet], - }); - // flush promises and advance setTimeouts they enqueue 3 times - // needed because fetch() doesn't resolve immediately, so any - // downstream promises aren't flushed until the next advanceTime loop - await advanceTime({ clock, duration: 1, stepSize: 1 / 3 }); - expect(controller.state.marketData).toStrictEqual({ - [ChainId.mainnet]: { - // token price in LOL = (token price in ETH) * (ETH value in LOL) - '0x02': { - tokenAddress: '0x02', - currency: 'LOL', - pricePercentChange1d: 0, - priceChange1d: 0, - allTimeHigh: 4000 * fallbackRate, - allTimeLow: 900 * fallbackRate, - circulatingSupply: 2000, - dilutedMarketCap: 100 * fallbackRate, - high1d: 200 * fallbackRate, - low1d: 100 * fallbackRate, - marketCap: 1000 * fallbackRate, - marketCapPercentChange1d: 100, - price: (1 / 1000) * fallbackRate, - pricePercentChange14d: 100, - pricePercentChange1h: 1, - pricePercentChange1y: 200, - pricePercentChange200d: 300, - pricePercentChange30d: 200, - pricePercentChange7d: 100, - totalVolume: 100 * fallbackRate, - }, - '0x03': { - tokenAddress: '0x03', - currency: 'LOL', - pricePercentChange1d: 0, - priceChange1d: 0, - allTimeHigh: 4000 * fallbackRate, - allTimeLow: 900 * fallbackRate, - circulatingSupply: 2000, - dilutedMarketCap: 100 * fallbackRate, - high1d: 200 * fallbackRate, - low1d: 100 * fallbackRate, - marketCap: 1000 * fallbackRate, - marketCapPercentChange1d: 100, - price: (2 / 1000) * fallbackRate, - pricePercentChange14d: 100, - pricePercentChange1h: 1, - pricePercentChange1y: 200, - pricePercentChange200d: 300, - pricePercentChange30d: 200, - pricePercentChange7d: 100, - totalVolume: 100 * fallbackRate, - }, - }, - }); - controller.stopAllPolling(); - }, - ); - }); + it('does not include chains when tokens are not updated', async () => { + jest.useFakeTimers(); + const chainId = '0x1'; - it('returns the an empty object when market does not exist for pair', async () => { - // New implementation returns empty object when native token price is unavailable - const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: async ({ tokenAddresses, currency }) => { - // Return empty for native token price request in USD - // This simulates the case where native token price is unavailable - if (tokenAddresses.length === 0 && currency === 'usd') { - return {}; - } - // For regular token requests, also return empty to simulate failure - if (currency === 'usd') { - return fetchTokenPricesWithIncreasingPriceForEachToken({ - tokenAddresses, - currency, - }); - } - // Should not get here since we use 'usd' as fallback - return {}; - }, - validateCurrencySupported(currency: unknown): currency is string { - return currency !== 'LOL'; - }, - }); - await withController( - { - options: { - tokenPricesService, - }, - mockNetworkState: { - networkConfigurationsByChainId: { - [ChainId.mainnet]: buildNetworkConfiguration({ - nativeCurrency: 'LOL', - }), - }, - }, - mockTokensControllerState: { - allTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: '0x02', - decimals: 0, - symbol: '', - aggregators: [], - }, - { - address: '0x03', - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, + await withController( + { + mockTokensControllerState: { + allTokens: { + [chainId]: { + [defaultSelectedAddress]: [ + { + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', }, - }, - }, - async ({ controller }) => { - controller.startPolling({ - chainIds: [ChainId.mainnet], - }); - // flush promises and advance setTimeouts they enqueue 3 times - // needed because fetch() doesn't resolve immediately, so any - // downstream promises aren't flushed until the next advanceTime loop - await advanceTime({ clock, duration: 1, stepSize: 1 / 3 }); - - expect(controller.state.marketData).toStrictEqual({ - [ChainId.mainnet]: {}, - }); - controller.stopAllPolling(); + ], }, - ); - }); - }); - }); - - it('should stop polling', async () => { - const interval = 100; - const tokenPricesService = buildMockTokenPricesService(); - jest.spyOn(tokenPricesService, 'fetchTokenPrices'); - await withController( - { - options: { - tokenPricesService, }, - mockTokensControllerState: { - allTokens: { - '0x1': { - [defaultSelectedAddress]: [ - { - address: mockTokenAddress, - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, + allDetectedTokens: {}, + allIgnoredTokens: {}, + }, + }, + async ({ controller, triggerTokensStateChange }) => { + jest.spyOn(controller, 'updateExchangeRates'); + + triggerTokensStateChange({ + allTokens: { + [chainId]: { + [defaultSelectedAddress]: [ + { + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', + }, + ], }, }, - }, - async ({ controller }) => { - const pollingToken = controller.startPolling({ - chainIds: [ChainId.mainnet], - }); - await advanceTime({ clock, duration: 0 }); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes( - 1, - ); + allDetectedTokens: {}, + allIgnoredTokens: {}, + }); - controller.stopPollingByPollingToken(pollingToken); + jest.advanceTimersToNextTimer(); + await flushPromises(); - await advanceTime({ clock, duration: interval }); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes( - 1, - ); - }, - ); - }); + expect(controller.updateExchangeRates).toHaveBeenCalledWith([]); + }, + ); }); - // The TokenRatesController has two methods for updating exchange rates: - // `updateExchangeRates` and `updateExchangeRatesByChainId`. They are the same - // except in how the inputs are specified. `updateExchangeRates` gets the - // inputs from controller configuration, whereas `updateExchangeRatesByChainId` - // accepts the inputs as parameters. - // - // Here we test both of these methods using the same test cases. The - // differences between them are abstracted away by the helper function - // `callUpdateExchangeRatesMethod`. - describe.each([ - 'updateExchangeRates' as const, - 'updateExchangeRatesByChainId' as const, - ])('%s', (method) => { - it('does not update state when disabled', async () => { - await withController( - {}, - async ({ - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - }) => { - const tokenAddress = '0x0000000000000000000000000000000000000001'; - controller.disable(); - await callUpdateExchangeRatesMethod({ - allTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: tokenAddress, - decimals: 18, - symbol: 'TST', - aggregators: [], - }, - ], - }, - }, - chainId: ChainId.mainnet, - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - method, - nativeCurrency: 'ETH', - selectedNetworkClientId: InfuraNetworkType.mainnet, - }); + it('does not include chains with no network configuration', async () => { + jest.useFakeTimers(); + const chainId = '0x1'; - expect(controller.state.marketData).toStrictEqual({}); + await withController( + { + mockNetworkState: { + networkConfigurationsByChainId: {}, }, - ); - }); + }, + async ({ controller, triggerTokensStateChange }) => { + jest.spyOn(controller, 'updateExchangeRates'); - it('does not update state if there are no tokens for the given chain', async () => { - await withController( - async ({ - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - }) => { - const tokenAddress = '0x0000000000000000000000000000000000000001'; - controller.enable(); - await callUpdateExchangeRatesMethod({ - allTokens: { - // These tokens are on a different chain - [toHex(2)]: { - [defaultSelectedAddress]: [ - { - address: tokenAddress, - decimals: 18, - symbol: 'TST', - aggregators: [], - }, - ], - }, + triggerTokensStateChange({ + allTokens: { + [chainId]: { + [defaultSelectedAddress]: [ + { + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', + }, + ], }, - chainId: ChainId.mainnet, - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - method, - nativeCurrency: 'ETH', - selectedNetworkClientId: InfuraNetworkType.mainnet, - }); + }, + allDetectedTokens: {}, + allIgnoredTokens: {}, + }); + + jest.advanceTimersToNextTimer(); + await flushPromises(); + + expect(controller.updateExchangeRates).toHaveBeenCalledWith([]); + }, + ); + }); + }); - expect(controller.state).toStrictEqual({ + describe('NetworkController:stateChange', () => { + it('remove state from deleted networks', async () => { + const chainId = '0x1'; + const nativeCurrency = 'ETH'; + + await withController( + { + options: { + disabled: true, + state: { marketData: { - [ChainId.mainnet]: { + [chainId]: { '0x0000000000000000000000000000000000000000': { - currency: 'ETH', - }, + currency: nativeCurrency, + price: 0.001, + } as unknown as MarketDataDetails, }, - }, - }); - }, - ); - }); - - it('does not update state if the price update fails', async () => { - const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: jest - .fn() - .mockRejectedValue(new Error('Failed to fetch')), - }); - await withController( - { options: { tokenPricesService } }, - async ({ - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - }) => { - const tokenAddress = '0x0000000000000000000000000000000000000001'; - - const updateExchangeRates = await callUpdateExchangeRatesMethod({ - allTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: tokenAddress, - decimals: 18, - symbol: 'TST', - aggregators: [], - }, - ], + '0x2': { + '0x0000000000000000000000000000000000000000': { + currency: nativeCurrency, + price: 0.001, + } as unknown as MarketDataDetails, }, }, - chainId: ChainId.mainnet, - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - method, - nativeCurrency: 'ETH', - selectedNetworkClientId: InfuraNetworkType.mainnet, - }); - - expect(updateExchangeRates).toBeUndefined(); - expect(controller.state.marketData).toStrictEqual({}); - }, - ); - }); - - it('fetches rates for all tokens in batches', async () => { - const chainId = ChainId.mainnet; - const ticker = NetworksTicker.mainnet; - const tokenAddresses = [...new Array(200).keys()] - .map(buildAddress) - .sort(); - const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: fetchTokenPricesWithIncreasingPriceForEachToken, - }); - const fetchTokenPricesSpy = jest.spyOn( - tokenPricesService, - 'fetchTokenPrices', - ); - const tokens = tokenAddresses.map((tokenAddress) => { - return buildToken({ address: tokenAddress }); - }); - await withController( - { - options: { - tokenPricesService, }, }, - async ({ - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - }) => { - await callUpdateExchangeRatesMethod({ - allTokens: { + }, + async ({ controller, triggerNetworkStateChange }) => { + jest.spyOn(controller, 'updateExchangeRates'); + + triggerNetworkStateChange( + { + ...getDefaultNetworkControllerState(), + networkConfigurationsByChainId: { [chainId]: { - [defaultSelectedAddress]: tokens.slice(0, 100), - // Include tokens from non selected addresses - '0x0000000000000000000000000000000000000123': - tokens.slice(100), - }, + chainId, + nativeCurrency, + } as unknown as NetworkConfiguration, }, - chainId, - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - method, - nativeCurrency: ticker, - selectedNetworkClientId: InfuraNetworkType.mainnet, - }); - - const numBatches = Math.ceil( - tokenAddresses.length / TOKEN_PRICES_BATCH_SIZE, - ); - expect(fetchTokenPricesSpy).toHaveBeenCalledTimes(numBatches); - - for (let i = 1; i <= numBatches; i++) { - expect(fetchTokenPricesSpy).toHaveBeenNthCalledWith(i, { - chainId, - tokenAddresses: tokenAddresses.slice( - (i - 1) * TOKEN_PRICES_BATCH_SIZE, - i * TOKEN_PRICES_BATCH_SIZE, - ), - currency: ticker, - }); - } - }, - ); - }); - - it('updates all rates', async () => { - const tokenAddresses = [ - '0x0000000000000000000000000000000000000001', - '0x0000000000000000000000000000000000000002', - '0x0000000000000000000000000000000000000003', - ]; - const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: jest.fn().mockResolvedValue({ - [tokenAddresses[0]]: { - currency: 'ETH', - tokenAddress: tokenAddresses[0], - value: 0.001, - }, - [tokenAddresses[1]]: { - currency: 'ETH', - tokenAddress: tokenAddresses[1], - value: 0.002, - }, - [tokenAddresses[2]]: { - currency: 'ETH', - tokenAddress: tokenAddresses[2], - value: 0.003, }, - }), - }); - await withController( - { options: { tokenPricesService } }, - async ({ - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - }) => { - await callUpdateExchangeRatesMethod({ - allTokens: { - [ChainId.mainnet]: { - [defaultSelectedAddress]: [ - { - address: tokenAddresses[0], - decimals: 18, - symbol: 'TST1', - aggregators: [], - }, - { - address: tokenAddresses[1], - decimals: 18, - symbol: 'TST2', - aggregators: [], - }, - ], - // Include tokens from non selected addresses - '0x0000000000000000000000000000000000000123': [ - { - address: tokenAddresses[2], - decimals: 18, - symbol: 'TST1', - aggregators: [], - }, - ], - }, + [ + { + op: 'remove', + path: ['networkConfigurationsByChainId', chainId], }, - chainId: ChainId.mainnet, - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - method, - nativeCurrency: 'ETH', - selectedNetworkClientId: InfuraNetworkType.mainnet, - }); - - expect(controller.state).toMatchInlineSnapshot(` - Object { - "marketData": Object { - "0x1": Object { - "0x0000000000000000000000000000000000000001": Object { - "currency": "ETH", - "tokenAddress": "0x0000000000000000000000000000000000000001", - "value": 0.001, - }, - "0x0000000000000000000000000000000000000002": Object { - "currency": "ETH", - "tokenAddress": "0x0000000000000000000000000000000000000002", - "value": 0.002, - }, - "0x0000000000000000000000000000000000000003": Object { - "currency": "ETH", - "tokenAddress": "0x0000000000000000000000000000000000000003", - "value": 0.003, - }, - }, - }, - } - `); - }, - ); - }); + ], + ); - if (method === 'updateExchangeRatesByChainId') { - it('updates rates only for a non-selected chain', async () => { - const tokenAddresses = [ - '0x0000000000000000000000000000000000000001', - '0x0000000000000000000000000000000000000002', - ]; - const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: jest.fn().mockResolvedValue({ - [tokenAddresses[0]]: { - currency: 'ETH', - tokenAddress: tokenAddresses[0], - value: 0.001, - }, - [tokenAddresses[1]]: { - currency: 'ETH', - tokenAddress: tokenAddresses[1], - value: 0.002, - }, - }), - }); - await withController( - { options: { tokenPricesService } }, - async ({ - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - }) => { - await callUpdateExchangeRatesMethod({ - allTokens: { - [toHex(2)]: { - [defaultSelectedAddress]: [ - { - address: tokenAddresses[0], - decimals: 18, - symbol: 'TST1', - aggregators: [], - }, - { - address: tokenAddresses[1], - decimals: 18, - symbol: 'TST2', - aggregators: [], - }, - ], - }, - }, - chainId: toHex(2), - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - method, - nativeCurrency: 'ETH', - setChainAsCurrent: false, - }); - - expect(controller.state).toMatchInlineSnapshot(` - Object { - "marketData": Object { - "0x2": Object { - "0x0000000000000000000000000000000000000001": Object { - "currency": "ETH", - "tokenAddress": "0x0000000000000000000000000000000000000001", - "value": 0.001, - }, - "0x0000000000000000000000000000000000000002": Object { - "currency": "ETH", - "tokenAddress": "0x0000000000000000000000000000000000000002", - "value": 0.002, - }, - }, - }, - } - `); + jest.advanceTimersToNextTimer(); + await flushPromises(); + + expect(controller.state.marketData).toStrictEqual({ + '0x2': { + '0x0000000000000000000000000000000000000000': { + currency: nativeCurrency, + price: 0.001, + } as unknown as MarketDataDetails, }, - ); - }); - } - - it('updates exchange rates when native currency is not supported by the Price API', async () => { - const selectedNetworkClientId = 'AAAA-BBBB-CCCC-DDDD'; - const selectedNetworkClientConfiguration = - buildCustomNetworkClientConfiguration({ - chainId: toHex(137), - ticker: 'UNSUPPORTED', }); - const tokenAddresses = [ - '0x0000000000000000000000000000000000000001', - '0x0000000000000000000000000000000000000002', - ]; - const nativeTokenAddress = '0x0000000000000000000000000000000000001010'; - const nativeTokenPriceInUSD = 2; - const tokenPricesService = buildMockTokenPricesService({ - // @ts-expect-error - Simplified mock for testing with partial fields - fetchTokenPrices: async ({ tokenAddresses: addrs, currency }) => { - if (addrs.length === 0 && currency === 'usd') { - // Return native token price - return { - [nativeTokenAddress]: { - currency: 'usd', - tokenAddress: nativeTokenAddress, - price: nativeTokenPriceInUSD, - pricePercentChange1d: 0, - priceChange1d: 0, - allTimeHigh: undefined, - allTimeLow: undefined, - circulatingSupply: 0, - dilutedMarketCap: undefined, - high1d: undefined, - low1d: undefined, - marketCap: undefined, - marketCapPercentChange1d: 0, - pricePercentChange14d: 0, - pricePercentChange1h: 0, - pricePercentChange1y: 0, - pricePercentChange200d: 0, - pricePercentChange30d: 0, - pricePercentChange7d: 0, - totalVolume: undefined, - }, - }; - } - // Return token prices in USD - return { - [tokenAddresses[0]]: { - currency: 'usd', - tokenAddress: tokenAddresses[0], - price: 0.001, - pricePercentChange1d: 0, - priceChange1d: 0, - allTimeHigh: undefined, - allTimeLow: undefined, - circulatingSupply: 0, - dilutedMarketCap: undefined, - high1d: undefined, - low1d: undefined, - marketCap: undefined, - marketCapPercentChange1d: 0, - pricePercentChange14d: 0, - pricePercentChange1h: 0, - pricePercentChange1y: 0, - pricePercentChange200d: 0, - pricePercentChange30d: 0, - pricePercentChange7d: 0, - totalVolume: undefined, - }, - [tokenAddresses[1]]: { - currency: 'usd', - tokenAddress: tokenAddresses[1], - price: 0.002, - pricePercentChange1d: 0, - priceChange1d: 0, - allTimeHigh: undefined, - allTimeLow: undefined, - circulatingSupply: 0, - dilutedMarketCap: undefined, - high1d: undefined, - low1d: undefined, - marketCap: undefined, - marketCapPercentChange1d: 0, - pricePercentChange14d: 0, - pricePercentChange1h: 0, - pricePercentChange1y: 0, - pricePercentChange200d: 0, - pricePercentChange30d: 0, - pricePercentChange7d: 0, - totalVolume: undefined, - }, - }; - }, - validateCurrencySupported(_currency: unknown): _currency is string { - return false; - }, - }); + }, + ); + }); + }); - await withController( - { - options: { tokenPricesService }, - mockNetworkClientConfigurationsByNetworkClientId: { - [selectedNetworkClientId]: selectedNetworkClientConfiguration, - }, - }, - async ({ - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - }) => { - await callUpdateExchangeRatesMethod({ - allTokens: { - [selectedNetworkClientConfiguration.chainId]: { - [defaultSelectedAddress]: [ - { - address: tokenAddresses[0], - decimals: 18, - symbol: 'TST1', - aggregators: [], - }, - { - address: tokenAddresses[1], - decimals: 18, - symbol: 'TST2', - aggregators: [], - }, - ], - }, - }, - chainId: selectedNetworkClientConfiguration.chainId, - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - method, - nativeCurrency: selectedNetworkClientConfiguration.ticker, - selectedNetworkClientId, - }); - - // token value in terms of matic should be (token value in eth) * (eth value in matic) - expect(controller.state).toMatchInlineSnapshot(` - Object { - "marketData": Object { - "0x89": Object { - "0x0000000000000000000000000000000000000001": Object { - "allTimeHigh": undefined, - "allTimeLow": undefined, - "circulatingSupply": 0, - "currency": "UNSUPPORTED", - "dilutedMarketCap": undefined, - "high1d": undefined, - "low1d": undefined, - "marketCap": undefined, - "marketCapPercentChange1d": 0, - "price": 0.0005, - "priceChange1d": 0, - "pricePercentChange14d": 0, - "pricePercentChange1d": 0, - "pricePercentChange1h": 0, - "pricePercentChange1y": 0, - "pricePercentChange200d": 0, - "pricePercentChange30d": 0, - "pricePercentChange7d": 0, - "tokenAddress": "0x0000000000000000000000000000000000000001", - "totalVolume": undefined, - }, - "0x0000000000000000000000000000000000000002": Object { - "allTimeHigh": undefined, - "allTimeLow": undefined, - "circulatingSupply": 0, - "currency": "UNSUPPORTED", - "dilutedMarketCap": undefined, - "high1d": undefined, - "low1d": undefined, - "marketCap": undefined, - "marketCapPercentChange1d": 0, - "price": 0.001, - "priceChange1d": 0, - "pricePercentChange14d": 0, - "pricePercentChange1d": 0, - "pricePercentChange1h": 0, - "pricePercentChange1y": 0, - "pricePercentChange200d": 0, - "pricePercentChange30d": 0, - "pricePercentChange7d": 0, - "tokenAddress": "0x0000000000000000000000000000000000000002", - "totalVolume": undefined, - }, - }, - }, - } - `); - }, - ); - }); + describe('enable', () => { + it('enables events', async () => { + jest.useFakeTimers(); - it('fetches rates for all tokens in batches when native currency is not supported by the Price API', async () => { - const selectedNetworkClientId = 'AAAA-BBBB-CCCC-DDDD'; - const selectedNetworkClientConfiguration = - buildCustomNetworkClientConfiguration({ - chainId: toHex(999), - ticker: 'UNSUPPORTED', - }); - const tokenAddresses = [...new Array(200).keys()] - .map(buildAddress) - .sort(); - // New implementation needs native token price in USD - // For chain 999 (0x3e7), native token address is 0x0000...0000 (ZERO_ADDRESS) - const nativeTokenAddress = '0x0000000000000000000000000000000000000000'; - const nativeTokenPriceInUSD = 2; - const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: async ({ tokenAddresses: addrs, currency }) => { - // Handle native token price request - if (addrs.length === 0 && currency === 'usd') { - return { - [nativeTokenAddress]: { - tokenAddress: nativeTokenAddress, - currency: 'usd', - pricePercentChange1d: 0, - priceChange1d: 0, - allTimeHigh: 4000, - allTimeLow: 900, - circulatingSupply: 2000, - dilutedMarketCap: 100, - high1d: 200, - low1d: 100, - marketCap: 1000, - marketCapPercentChange1d: 100, - price: nativeTokenPriceInUSD, - pricePercentChange14d: 100, - pricePercentChange1h: 1, - pricePercentChange1y: 200, - pricePercentChange200d: 300, - pricePercentChange30d: 200, - pricePercentChange7d: 100, - totalVolume: 100, - }, - }; - } - // Handle regular token prices - return fetchTokenPricesWithIncreasingPriceForEachToken({ - tokenAddresses: addrs, - currency, - }); - }, - validateCurrencySupported: ( - currency: unknown, - ): currency is string => { - return currency !== selectedNetworkClientConfiguration.ticker; + const chainId = '0x1'; + await withController( + { + options: { + disabled: true, }, - }); - const fetchTokenPricesSpy = jest.spyOn( - tokenPricesService, - 'fetchTokenPrices', - ); - const tokens = tokenAddresses.map((tokenAddress) => { - return buildToken({ address: tokenAddress }); - }); - await withController( - { - options: { - tokenPricesService, - }, - mockNetworkClientConfigurationsByNetworkClientId: { - [selectedNetworkClientId]: selectedNetworkClientConfiguration, - }, - mockNetworkState: { - networkConfigurationsByChainId: { - [selectedNetworkClientConfiguration.chainId]: { - nativeCurrency: selectedNetworkClientConfiguration.ticker, - chainId: selectedNetworkClientConfiguration.chainId, - name: 'UNSUPPORTED', - rpcEndpoints: [], - blockExplorerUrls: [], - defaultRpcEndpointIndex: 0, - }, + }, + async ({ controller, triggerTokensStateChange }) => { + jest.spyOn(controller, 'updateExchangeRates'); + + controller.enable(); + + triggerTokensStateChange({ + allTokens: { + [chainId]: { + [defaultSelectedAddress]: [ + { + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', + }, + ], }, - selectedNetworkClientId, }, - }, - async ({ - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - }) => { - await callUpdateExchangeRatesMethod({ - allTokens: { - [selectedNetworkClientConfiguration.chainId]: { - [defaultSelectedAddress]: tokens, - }, - }, - chainId: selectedNetworkClientConfiguration.chainId, - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - method, - nativeCurrency: selectedNetworkClientConfiguration.ticker, - selectedNetworkClientId, - }); - - const numBatches = Math.ceil( - tokenAddresses.length / TOKEN_PRICES_BATCH_SIZE, - ); - // New implementation calls fetchTokenPrices once for native token + numBatches for tokens - expect(fetchTokenPricesSpy).toHaveBeenCalledTimes(numBatches + 1); - - // First call is for native token price - expect(fetchTokenPricesSpy).toHaveBeenNthCalledWith(1, { - chainId: selectedNetworkClientConfiguration.chainId, - tokenAddresses: [], - currency: 'usd', - }); - - // Subsequent calls are for token batches in USD - for (let i = 1; i <= numBatches; i++) { - expect(fetchTokenPricesSpy).toHaveBeenNthCalledWith(i + 1, { - chainId: selectedNetworkClientConfiguration.chainId, - tokenAddresses: tokenAddresses.slice( - (i - 1) * TOKEN_PRICES_BATCH_SIZE, - i * TOKEN_PRICES_BATCH_SIZE, - ), - currency: 'usd', - }); - } - }, - ); - }); - - it('sets rates to undefined when chain is not supported by the Price API', async () => { - const selectedNetworkClientId = 'AAAA-BBBB-CCCC-DDDD'; - const selectedNetworkClientConfiguration = - buildCustomNetworkClientConfiguration({ - chainId: toHex(999), - ticker: 'TST', + allDetectedTokens: {}, + allIgnoredTokens: {}, }); - const tokenAddresses = [ - '0x0000000000000000000000000000000000000001', - '0x0000000000000000000000000000000000000002', - ]; - const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: jest.fn().mockResolvedValue({ - [tokenAddresses[0]]: { - currency: 'ETH', - tokenAddress: tokenAddresses[0], - value: 0.001, - }, - [tokenAddresses[1]]: { - currency: 'ETH', - tokenAddress: tokenAddresses[1], - value: 0.002, - }, - }), - validateChainIdSupported(_chainId: unknown): _chainId is Hex { - return false; - }, - }); - await withController( - { - options: { tokenPricesService }, - mockNetworkClientConfigurationsByNetworkClientId: { - [selectedNetworkClientId]: selectedNetworkClientConfiguration, - }, - }, - async ({ - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - }) => { - await callUpdateExchangeRatesMethod({ - allTokens: { - [toHex(999)]: { - [defaultSelectedAddress]: [ - { - address: tokenAddresses[0], - decimals: 18, - symbol: 'TST1', - aggregators: [], - }, - { - address: tokenAddresses[1], - decimals: 18, - symbol: 'TST2', - aggregators: [], - }, - ], - }, - }, - chainId: selectedNetworkClientConfiguration.chainId, - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - method, - nativeCurrency: selectedNetworkClientConfiguration.ticker, - selectedNetworkClientId, - }); - - expect(controller.state).toMatchInlineSnapshot(` - Object { - "marketData": Object { - "0x3e7": Object { - "0x0000000000000000000000000000000000000001": undefined, - "0x0000000000000000000000000000000000000002": undefined, - }, - }, - } - `); - }, - ); - }); - it('correctly calls the Price API with unqiue native token addresses (e.g. MATIC)', async () => { - const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: jest.fn().mockResolvedValue({ - '0x0000000000000000000000000000000000001010': { - currency: 'MATIC', - tokenAddress: '0x0000000000000000000000000000000000001010', - value: 0.001, - }, - }), - }); + jest.advanceTimersToNextTimer(); + await flushPromises(); - await withController( - { - options: { tokenPricesService }, - mockNetworkClientConfigurationsByNetworkClientId: { - 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ - chainId: '0x89', - }), + expect(controller.updateExchangeRates).toHaveBeenCalledWith([ + { + chainId, + nativeCurrency: 'ETH', }, - }, - async ({ - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - }) => { - await callUpdateExchangeRatesMethod({ - allTokens: { - '0x89': { - [defaultSelectedAddress]: [], - }, - }, - chainId: '0x89', - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - method, - nativeCurrency: 'MATIC', - selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', - }); - - expect( - controller.state.marketData['0x89'][ - '0x0000000000000000000000000000000000001010' - ], - ).toBeDefined(); - }, - ); - }); + ]); + }, + ); + }); + }); - it('only updates rates once when called twice', async () => { - const tokenAddresses = [ - '0x0000000000000000000000000000000000000001', - '0x0000000000000000000000000000000000000002', - ]; - const fetchTokenPricesMock = jest.fn().mockResolvedValue({ - [tokenAddresses[0]]: { - currency: 'ETH', - tokenAddress: tokenAddresses[0], - value: 0.001, - }, - [tokenAddresses[1]]: { - currency: 'ETH', - tokenAddress: tokenAddresses[1], - value: 0.002, - }, - }); - const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: fetchTokenPricesMock, - }); - await withController( - { options: { tokenPricesService } }, - async ({ - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - }) => { - const updateExchangeRates = async () => - await callUpdateExchangeRatesMethod({ - allTokens: { - [toHex(1)]: { - [defaultSelectedAddress]: [ - { - address: tokenAddresses[0], - decimals: 18, - symbol: 'TST1', - aggregators: [], - }, - { - address: tokenAddresses[1], - decimals: 18, - symbol: 'TST2', - aggregators: [], - }, - ], - }, - }, - chainId: ChainId.mainnet, - selectedNetworkClientId: InfuraNetworkType.mainnet, - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - method, - nativeCurrency: 'ETH', - }); - - await Promise.all([updateExchangeRates(), updateExchangeRates()]); - - expect(fetchTokenPricesMock).toHaveBeenCalledTimes(1); - - expect(controller.state).toMatchInlineSnapshot(` - Object { - "marketData": Object { - "0x1": Object { - "0x0000000000000000000000000000000000000001": Object { - "currency": "ETH", - "tokenAddress": "0x0000000000000000000000000000000000000001", - "value": 0.001, - }, - "0x0000000000000000000000000000000000000002": Object { - "currency": "ETH", - "tokenAddress": "0x0000000000000000000000000000000000000002", - "value": 0.002, - }, - }, - }, - } - `); - }, - ); - }); + describe('disable', () => { + it('disables events', async () => { + jest.useFakeTimers(); - it('will update rates twice if detected tokens increased during second call', async () => { - const tokenAddresses = [ - '0x0000000000000000000000000000000000000001', - '0x0000000000000000000000000000000000000002', - ]; - const fetchTokenPricesMock = jest.fn().mockResolvedValue({ - [tokenAddresses[0]]: { - currency: 'ETH', - tokenAddress: tokenAddresses[0], - value: 0.001, - }, - [tokenAddresses[1]]: { - currency: 'ETH', - tokenAddress: tokenAddresses[1], - value: 0.002, + const chainId = '0x1'; + await withController( + { + options: { + disabled: false, }, - }); - const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: fetchTokenPricesMock, - }); - await withController( - { options: { tokenPricesService } }, - async ({ - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - }) => { - const request1Payload = [ - { - address: tokenAddresses[0], - decimals: 18, - symbol: 'TST1', - aggregators: [], - }, - ]; - const request2Payload = [ - { - address: tokenAddresses[0], - decimals: 18, - symbol: 'TST1', - aggregators: [], - }, - { - address: tokenAddresses[1], - decimals: 18, - symbol: 'TST2', - aggregators: [], - }, - ]; - const updateExchangeRates = async ( - tokens: typeof request1Payload | typeof request2Payload, - ) => - await callUpdateExchangeRatesMethod({ - allTokens: { - [toHex(1)]: { - [defaultSelectedAddress]: tokens, + }, + async ({ controller, triggerTokensStateChange }) => { + jest.spyOn(controller, 'updateExchangeRates'); + + controller.disable(); + + triggerTokensStateChange({ + allTokens: { + [chainId]: { + [defaultSelectedAddress]: [ + { + address: '0x0000000000000000000000000000000000000001', + decimals: 0, + symbol: 'TOK1', }, - }, - chainId: ChainId.mainnet, - selectedNetworkClientId: InfuraNetworkType.mainnet, - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - method, - nativeCurrency: 'ETH', - }); - - await Promise.all([ - updateExchangeRates(request1Payload), - updateExchangeRates(request2Payload), - ]); - - expect(fetchTokenPricesMock).toHaveBeenCalledTimes(2); - expect(fetchTokenPricesMock).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - tokenAddresses: [tokenAddresses[0]], - }), - ); - expect(fetchTokenPricesMock).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - tokenAddresses: [tokenAddresses[0], tokenAddresses[1]], - }), - ); - }, - ); - }); + ], + }, + }, + allDetectedTokens: {}, + allIgnoredTokens: {}, + }); + + jest.advanceTimersToNextTimer(); + await flushPromises(); + + expect(controller.updateExchangeRates).not.toHaveBeenCalled(); + }, + ); }); }); @@ -2939,12 +1156,10 @@ describe('TokenRatesController', () => { */ type WithControllerCallback = ({ controller, - triggerSelectedAccountChange, triggerTokensStateChange, triggerNetworkStateChange, }: { controller: TokenRatesController; - triggerSelectedAccountChange: (state: InternalAccount) => void; triggerTokensStateChange: (state: TokensControllerState) => void; triggerNetworkStateChange: (state: NetworkState, patches?: Patch[]) => void; }) => Promise | ReturnValue; @@ -2976,12 +1191,7 @@ async function withController( ...args: WithControllerArgs ): Promise { const [{ ...rest }, fn] = args.length === 2 ? args : [{}, args[0]]; - const { - options, - mockNetworkClientConfigurationsByNetworkClientId, - mockTokensControllerState, - mockNetworkState, - } = rest; + const { options, mockTokensControllerState, mockNetworkState } = rest; const messenger: RootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE, }); @@ -2995,14 +1205,6 @@ async function withController( }), ); - const getNetworkClientById = buildMockGetNetworkClientById( - mockNetworkClientConfigurationsByNetworkClientId, - ); - messenger.registerActionHandler( - 'NetworkController:getNetworkClientById', - getNetworkClientById, - ); - const networkStateMock = jest.fn(); messenger.registerActionHandler( 'NetworkController:getState', @@ -3012,18 +1214,6 @@ async function withController( }), ); - const mockGetSelectedAccount = jest.fn(); - messenger.registerActionHandler( - 'AccountsController:getSelectedAccount', - mockGetSelectedAccount.mockReturnValue(defaultSelectedAccount), - ); - - const mockGetAccount = jest.fn(); - messenger.registerActionHandler( - 'AccountsController:getAccount', - mockGetAccount.mockReturnValue(defaultSelectedAccount), - ); - const controller = new TokenRatesController({ tokenPricesService: buildMockTokenPricesService(), messenger: buildTokenRatesControllerMessenger(messenger), @@ -3032,13 +1222,6 @@ async function withController( try { return await fn({ controller, - triggerSelectedAccountChange: (account: InternalAccount) => { - messenger.publish( - 'AccountsController:selectedEvmAccountChange', - account, - ); - }, - triggerTokensStateChange: (state: TokensControllerState) => { messenger.publish('TokensController:stateChange', state, []); }, @@ -3050,106 +1233,10 @@ async function withController( }, }); } finally { - controller.stop(); controller.stopAllPolling(); } } -/** - * Call an "update exchange rates" method with the given parameters. - * - * The TokenRatesController has two methods for updating exchange rates: - * `updateExchangeRates` and `updateExchangeRatesByChainId`. They are the same - * except in how the inputs are specified. `updateExchangeRates` gets the - * inputs from controller configuration, whereas `updateExchangeRatesByChainId` - * accepts the inputs as parameters. - * - * This helper function normalizes between these two functions, so that we can - * test them the same way. - * - * @param args - The arguments. - * @param args.allTokens - The `allTokens` state (from the TokensController) - * @param args.chainId - The chain ID of the chain we want to update the - * exchange rates for. - * @param args.controller - The controller to call the method with. - * @param args.triggerTokensStateChange - Controller event handlers, used to - * update controller configuration. - * @param args.triggerNetworkStateChange - Controller event handlers, used to - * update controller configuration. - * @param args.method - The "update exchange rates" method to call. - * @param args.nativeCurrency - The symbol for the native currency of the - * network we're getting updated exchange rates for. - * @param args.setChainAsCurrent - When calling `updateExchangeRatesByChainId`, - * this determines whether to set the chain as the globally selected chain. - * @param args.selectedNetworkClientId - The network client ID to use if - * `setChainAsCurrent` is true. - */ -async function callUpdateExchangeRatesMethod({ - allTokens, - chainId, - controller, - triggerTokensStateChange, - triggerNetworkStateChange, - method, - nativeCurrency, - selectedNetworkClientId, - setChainAsCurrent = true, -}: { - allTokens: TokensControllerState['allTokens']; - chainId: Hex; - controller: TokenRatesController; - triggerTokensStateChange: (state: TokensControllerState) => void; - triggerNetworkStateChange: (state: NetworkState) => void; - method: 'updateExchangeRates' | 'updateExchangeRatesByChainId'; - nativeCurrency: string; - selectedNetworkClientId?: NetworkClientId; - setChainAsCurrent?: boolean; -}) { - if (method === 'updateExchangeRates' && !setChainAsCurrent) { - throw new Error( - 'The "setChainAsCurrent" flag cannot be enabled when calling the "updateExchangeRates" method', - ); - } - - triggerTokensStateChange({ - ...getDefaultTokensState(), - allDetectedTokens: {}, - allTokens, - }); - - if (setChainAsCurrent) { - assert( - selectedNetworkClientId, - 'The "selectedNetworkClientId" option must be given if the "setChainAsCurrent" flag is also given', - ); - - // We're using controller events here instead of calling `configure` - // because `configure` does not update internal controller state correctly. - // As with many BaseControllerV1-based controllers, runtime config - // modification is allowed by the API but not supported in practice. - triggerNetworkStateChange({ - ...getDefaultNetworkControllerState(), - selectedNetworkClientId, - }); - } - - if (method === 'updateExchangeRates') { - await controller.updateExchangeRates([ - { - chainId, - nativeCurrency, - }, - ]); - } else { - await controller.updateExchangeRatesByChainId([ - { - chainId, - nativeCurrency, - }, - ]); - } -} - /** * Builds a mock token prices service. * @@ -3162,7 +1249,7 @@ function buildMockTokenPricesService( ): AbstractTokenPricesService { return { async fetchTokenPrices() { - return {}; + return []; }, async fetchExchangeRates() { return {}; @@ -3182,50 +1269,44 @@ function buildMockTokenPricesService( * price of each given token is incremented by one. * * @param args - The arguments to this function. - * @param args.tokenAddresses - The token addresses. + * @param args.assets - The token addresses and chainIds. * @param args.currency - The currency. * @returns The token prices. */ async function fetchTokenPricesWithIncreasingPriceForEachToken< - TokenAddress extends Hex, Currency extends string, >({ - tokenAddresses, + assets, currency, }: { - tokenAddresses: TokenAddress[]; + assets: { tokenAddress: Hex; chainId: Hex }[]; 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; +}): Promise[]> { + return assets.map(({ tokenAddress, chainId }, i) => ({ + tokenAddress, + chainId, + assetId: + `${KnownCaipNamespace.Eip155}:1/${tokenAddress === ZERO_ADDRESS ? 'slip44:60' : `erc20:${tokenAddress.toLowerCase()}`}` as CaipAssetType, + 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, + })); } /** diff --git a/packages/assets-controllers/src/TokenRatesController.ts b/packages/assets-controllers/src/TokenRatesController.ts index a9417e39282..b16cde9aab7 100644 --- a/packages/assets-controllers/src/TokenRatesController.ts +++ b/packages/assets-controllers/src/TokenRatesController.ts @@ -1,25 +1,16 @@ -import type { - AccountsControllerGetAccountAction, - AccountsControllerGetSelectedAccountAction, - AccountsControllerSelectedEvmAccountChangeEvent, -} from '@metamask/accounts-controller'; import type { ControllerGetStateAction, ControllerStateChangeEvent, StateMetadata, } from '@metamask/base-controller'; -import { - safelyExecute, - toChecksumHexAddress, -} from '@metamask/controller-utils'; +import { toChecksumHexAddress } from '@metamask/controller-utils'; import type { Messenger } from '@metamask/messenger'; import type { - NetworkControllerGetNetworkClientByIdAction, NetworkControllerGetStateAction, 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'; @@ -90,28 +81,24 @@ export type MarketDataDetails = { */ export type ContractMarketData = Record; -enum PollState { - Active = 'Active', - Inactive = 'Inactive', -} +type ChainIdAndNativeCurrency = { + chainId: Hex; + nativeCurrency: string; +}; /** * The external actions available to the {@link TokenRatesController}. */ export type AllowedActions = | TokensControllerGetStateAction - | NetworkControllerGetNetworkClientByIdAction - | NetworkControllerGetStateAction - | AccountsControllerGetAccountAction - | AccountsControllerGetSelectedAccountAction; + | NetworkControllerGetStateAction; /** * The external events available to the {@link TokenRatesController}. */ export type AllowedEvents = | TokensControllerStateChangeEvent - | NetworkControllerStateChangeEvent - | AccountsControllerSelectedEvmAccountChangeEvent; + | NetworkControllerStateChangeEvent; /** * The name of the {@link TokenRatesController}. @@ -199,18 +186,10 @@ export class TokenRatesController extends StaticIntervalPollingController { - #handle?: ReturnType; - - #pollState = PollState.Inactive; - readonly #tokenPricesService: AbstractTokenPricesService; - #inProcessExchangeRateUpdates: Record<`${Hex}:${string}`, Promise> = {}; - #disabled: boolean; - readonly #interval: number; - #allTokens: TokensControllerState['allTokens']; #allDetectedTokens: TokensControllerState['allDetectedTokens']; @@ -248,7 +227,6 @@ export class TokenRatesController extends StaticIntervalPollingController { return { allTokens, allDetectedTokens }; @@ -320,25 +298,7 @@ export class TokenRatesController extends StaticIntervalPollingController { - const chainIdAndNativeCurrency: { - chainId: Hex; - nativeCurrency: string; - }[] = Object.values(networkConfigurationsByChainId).map( - ({ chainId, nativeCurrency }) => { - return { - chainId: chainId as Hex, - nativeCurrency, - }; - }, - ); - - if (this.#pollState === PollState.Active) { - await this.updateExchangeRates(chainIdAndNativeCurrency); - } - + (_state, patches) => { // Remove state for deleted networks for (const patch of patches) { if ( @@ -370,7 +330,13 @@ export class TokenRatesController extends StaticIntervalPollingController - this.updateExchangeRates([{ chainId, nativeCurrency }]), - ); - - // Poll using recursive `setTimeout` instead of `setInterval` so that - // requests don't stack if they take longer than the polling interval - this.#handle = setTimeout(() => { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.#poll(chainId, nativeCurrency); - }, this.#interval); - } - /** * Updates exchange rates for all tokens. * * @param chainIdAndNativeCurrency - The chain ID and native currency. */ async updateExchangeRates( - chainIdAndNativeCurrency: { - chainId: Hex; - nativeCurrency: string; - }[], - ) { - await this.updateExchangeRatesByChainId(chainIdAndNativeCurrency); - } - - /** - * Updates exchange rates for all tokens. - * - * @param chainIds - The chain IDs. - * @returns A promise that resolves when all chain updates complete. - */ - /** - * Updates exchange rates for all tokens. - * - * @param chainIdAndNativeCurrency - The chain ID and native currency. - */ - async updateExchangeRatesByChainId( - chainIdAndNativeCurrency: { - chainId: Hex; - nativeCurrency: string; - }[], + chainIdAndNativeCurrency: ChainIdAndNativeCurrency[], ): Promise { if (this.#disabled) { return; } - // Create a promise for each chainId to fetch exchange rates. - const updatePromises = chainIdAndNativeCurrency.map( - async ({ chainId, nativeCurrency }) => { - 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; + const marketData: Record> = {}; + const assetsByNativeCurrency: Record< + string, + { + chainId: Hex; + tokenAddress: Hex; + }[] + > = {}; + const unsupportedAssetsByNativeCurrency: Record< + string, + { + chainId: Hex; + tokenAddress: Hex; + }[] + > = {}; + for (const { chainId, nativeCurrency } of chainIdAndNativeCurrency) { + if (this.#tokenPricesService.validateChainIdSupported(chainId)) { + for (const tokenAddress of this.#getTokenAddresses(chainId)) { + if ( + this.#tokenPricesService.validateCurrencySupported(nativeCurrency) + ) { + (assetsByNativeCurrency[nativeCurrency] ??= []).push({ + chainId, + tokenAddress, + }); + } else { + (unsupportedAssetsByNativeCurrency[nativeCurrency] ??= []).push({ + chainId, + tokenAddress, + }); + } } + } + } - // 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, + const promises = [ + ...Object.entries(assetsByNativeCurrency).map( + ([nativeCurrency, assets]) => + this.#fetchAndMapExchangeRatesForSupportedNativeCurrency( + assets, nativeCurrency, - }); + marketData, + ), + ), + ...Object.entries(unsupportedAssetsByNativeCurrency).map( + ([nativeCurrency, assets]) => + this.#fetchAndMapExchangeRatesForUnsupportedNativeCurrency( + assets, + nativeCurrency, + marketData, + ), + ), + ]; - // Each promise returns an object with the market data for the chain. - const marketData = { - [chainId]: { - ...(contractInformations ?? {}), - }, - }; - - updateSucceeded(); - return marketData; - } catch (error: unknown) { - updateFailed(error); - throw error; - } finally { - // Cleanup the tracking for this update. - delete this.#inProcessExchangeRateUpdates[updateKey]; - } - }, - ); + await Promise.allSettled(promises); - // Wait for all update promises to settle. - const results = await Promise.allSettled(updatePromises); + const chainIds = new Set( + Object.values(chainIdAndNativeCurrency).map((chain) => chain.chainId), + ); - // 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 }; + for (const chainId of chainIds) { + if (!marketData[chainId]) { + marketData[chainId] = {}; } - return acc; - }, {}); + } - // 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) { + if (Object.keys(marketData).length > 0) { this.update((state) => { state.marketData = { ...state.marketData, - ...combinedMarketData, + ...marketData, }; }); } } - /** - * Uses the token prices service to retrieve exchange rates for tokens in a - * particular currency. - * - * If the price API does not support the given chain ID, returns an empty - * object. - * - * If the price API does not support the given currency, retrieves exchange - * rates in a known currency instead, then converts those rates using the - * exchange rate between the known currency and desired currency. - * - * @param args - The arguments to this function. - * @param args.tokenAddresses - Addresses for tokens. - * @param args.chainId - The EIP-155 ID of the chain where the tokens live. - * @param args.nativeCurrency - The native currency in which to request - * exchange rates. - * @returns A map from token address to its exchange rate in the native - * currency, or an empty map if no exchange rates can be obtained for the - * chain ID. - */ - async #fetchAndMapExchangeRates({ - tokenAddresses, - chainId, - nativeCurrency, - }: { - tokenAddresses: Hex[]; - chainId: Hex; - nativeCurrency: string; - }): Promise { - if (!this.#tokenPricesService.validateChainIdSupported(chainId)) { - return tokenAddresses.reduce((obj, tokenAddress) => { - obj = { - ...obj, - [tokenAddress]: undefined, - }; - - return obj; - }, {}); - } + async #fetchAndMapExchangeRatesForSupportedNativeCurrency( + assets: { + chainId: Hex; + tokenAddress: Hex; + }[], + currency: string, + marketData: Record> = {}, + ) { + return await reduceInBatchesSerially< + { chainId: Hex; tokenAddress: Hex }, + Record> + >({ + values: assets, + batchSize: TOKEN_PRICES_BATCH_SIZE, + eachBatch: async (partialMarketData, assetsBatch) => { + const batchMarketData = await this.#tokenPricesService.fetchTokenPrices( + { + assets: assetsBatch, + currency, + }, + ); - if (this.#tokenPricesService.validateCurrencySupported(nativeCurrency)) { - return await this.#fetchAndMapExchangeRatesForSupportedNativeCurrency({ - tokenAddresses, - chainId, - nativeCurrency, - }); - } + for (const tokenPrice of batchMarketData) { + (partialMarketData[tokenPrice.chainId] ??= {})[ + tokenPrice.tokenAddress + ] = tokenPrice; + } - return await this.#fetchAndMapExchangeRatesForUnsupportedNativeCurrency({ - chainId, - tokenAddresses, - nativeCurrency, + return partialMarketData; + }, + initialResult: marketData, }); } + async #fetchAndMapExchangeRatesForUnsupportedNativeCurrency( + assets: { + chainId: Hex; + tokenAddress: Hex; + }[], + currency: string, + marketData: Record>, + ) { + // Step -1: Then fetch all tracked tokens priced in USD + const marketDataInUSD = + await this.#fetchAndMapExchangeRatesForSupportedNativeCurrency( + assets, + 'usd', // Fallback currency when the native currency is not supported + ); + + // Formula: price_in_native = token_usd / native_usd + const convertUSDToNative = ( + valueInUSD: number, + nativeTokenPriceInUSD: number, + ) => valueInUSD / nativeTokenPriceInUSD; + + // Step -2: Convert USD prices to native currency + for (const [chainId, marketDataByTokenAddress] of Object.entries( + marketDataInUSD, + ) as [Hex, Record][]) { + const nativeTokenPriceInUSD = + marketDataByTokenAddress[getNativeTokenAddress(chainId)]?.price; + + // Return here if it's null, undefined or 0 + if (!nativeTokenPriceInUSD) { + continue; + } + + for (const [tokenAddress, tokenData] of Object.entries( + marketDataByTokenAddress, + ) as [Hex, MarketDataDetails][]) { + (marketData[chainId] ??= {})[tokenAddress] = { + ...tokenData, + currency, + price: convertUSDToNative(tokenData.price, nativeTokenPriceInUSD), + marketCap: convertUSDToNative( + tokenData.marketCap, + nativeTokenPriceInUSD, + ), + allTimeHigh: convertUSDToNative( + tokenData.allTimeHigh, + nativeTokenPriceInUSD, + ), + allTimeLow: convertUSDToNative( + tokenData.allTimeLow, + nativeTokenPriceInUSD, + ), + totalVolume: convertUSDToNative( + tokenData.totalVolume, + nativeTokenPriceInUSD, + ), + high1d: convertUSDToNative(tokenData.high1d, nativeTokenPriceInUSD), + low1d: convertUSDToNative(tokenData.low1d, nativeTokenPriceInUSD), + dilutedMarketCap: convertUSDToNative( + tokenData.dilutedMarketCap, + nativeTokenPriceInUSD, + ), + }; + } + } + } + /** * Updates token rates for the given networkClientId * @@ -637,163 +584,7 @@ export class TokenRatesController extends StaticIntervalPollingController { - let contractNativeInformations; - const tokenPricesByTokenAddress = await reduceInBatchesSerially< - Hex, - Awaited> - >({ - values: [...tokenAddresses].sort(), - batchSize: TOKEN_PRICES_BATCH_SIZE, - eachBatch: async (allTokenPricesByTokenAddress, batch) => { - const tokenPricesByTokenAddressForBatch = - await this.#tokenPricesService.fetchTokenPrices({ - tokenAddresses: batch, - chainId, - currency: nativeCurrency, - }); - - return { - ...allTokenPricesByTokenAddress, - ...tokenPricesByTokenAddressForBatch, - }; - }, - initialResult: {}, - }); - contractNativeInformations = tokenPricesByTokenAddress; - - // fetch for native token - if (tokenAddresses.length === 0) { - const contractNativeInformationsNative = - await this.#tokenPricesService.fetchTokenPrices({ - tokenAddresses: [], - chainId, - currency: nativeCurrency, - }); - - contractNativeInformations = { - [getNativeTokenAddress(chainId)]: { - currency: nativeCurrency, - ...contractNativeInformationsNative[getNativeTokenAddress(chainId)], - }, - }; - } - return Object.entries(contractNativeInformations).reduce( - (obj, [tokenAddress, token]) => { - obj = { - ...obj, - [tokenAddress]: { ...token }, - }; - - return obj; - }, - {}, - ); - } - - /** - * If the price API does not support a given native currency, then we need to - * convert it to a fallback currency and feed that currency into the price - * API, then convert the prices to our desired native currency. - * - * @param args - The arguments to this function. - * @param args.chainId - The chain id to fetch prices for. - * @param args.tokenAddresses - Addresses for tokens. - * @param args.nativeCurrency - The native currency in which to request - * prices. - * @returns A map of the token addresses (as checksums) to their prices in the - * native currency. - */ - async #fetchAndMapExchangeRatesForUnsupportedNativeCurrency({ - chainId, - tokenAddresses, - nativeCurrency, - }: { - chainId: Hex; - tokenAddresses: Hex[]; - nativeCurrency: string; - }): Promise { - const nativeTokenAddress = getNativeTokenAddress(chainId); - - // Step -1: First fetch native token priced in USD - const nativeTokenPriceMap = - await this.#fetchAndMapExchangeRatesForSupportedNativeCurrency({ - tokenAddresses: [] as Hex[], // special-case: returns only native token - chainId, - nativeCurrency: 'usd', - }); - - // Step -2: Then fetch all tracked tokens priced in USD - const tokenPricesInUSD = - await this.#fetchAndMapExchangeRatesForSupportedNativeCurrency({ - tokenAddresses, - chainId, - nativeCurrency: 'usd', - }); - - const nativeTokenInfo = nativeTokenPriceMap[nativeTokenAddress]; - const nativeTokenPriceInUSD = nativeTokenInfo?.price; - - if (!nativeTokenPriceInUSD || nativeTokenPriceInUSD === 0) { - // If we can't price the native token in the fallback currency, - // we can't safely convert; return empty so callers know there is no data. - return {}; - } - - // Step -3: Convert USD prices to native currency - // Formula: price_in_native = token_usd / native_usd - const convertUSDToNative = (valueInUSD: number | undefined) => - valueInUSD !== undefined && valueInUSD !== null - ? valueInUSD / nativeTokenPriceInUSD - : undefined; - - // Step -4 & -5: Apply conversion to all token fields and return - const tokenPricesInNative = Object.entries(tokenPricesInUSD).reduce( - (acc, [tokenAddress, tokenData]) => { - acc = { - ...acc, - [tokenAddress]: { - ...tokenData, - currency: nativeCurrency, - price: convertUSDToNative(tokenData.price), - marketCap: convertUSDToNative(tokenData.marketCap), - allTimeHigh: convertUSDToNative(tokenData.allTimeHigh), - allTimeLow: convertUSDToNative(tokenData.allTimeLow), - totalVolume: convertUSDToNative(tokenData.totalVolume), - high1d: convertUSDToNative(tokenData.high1d), - low1d: convertUSDToNative(tokenData.low1d), - dilutedMarketCap: convertUSDToNative(tokenData.dilutedMarketCap), - }, - }; - return acc; - }, - {} as ContractMarketData, - ); - - return tokenPricesInNative; + await this.updateExchangeRates(chainIdAndNativeCurrency); } /** diff --git a/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.test.ts b/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.test.ts index 3a0e8d5bbd0..d1a2882d1f1 100644 --- a/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.test.ts +++ b/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.test.ts @@ -7,7 +7,7 @@ import { type MessengerEvents, type MockAnyNamespace, } from '@metamask/messenger'; -import type { Hex } from '@metamask/utils'; +import { type Hex } from '@metamask/utils'; import assert from 'assert'; import { useFakeTimers } from 'sinon'; @@ -23,8 +23,7 @@ import type { NotFoundTokenDisplayData, FoundTokenDisplayData } from './types'; import { advanceTime } from '../../../../tests/helpers'; import type { AbstractTokenPricesService, - TokenPrice, - TokenPricesByTokenAddress, + EvmAssetWithMarketData, } from '../token-prices-service/abstract-token-prices-service'; import { fetchTokenMetadata } from '../token-service'; import type { Token } from '../TokenRatesController'; @@ -79,10 +78,11 @@ function buildFoundTokenDisplayData( name: 'Test Token', }; - const priceData: TokenPrice = { + const priceData: EvmAssetWithMarketData = { price: 10.5, currency: 'USD', tokenAddress: tokenAddress as Hex, + chainId: '0x1', allTimeHigh: 20, allTimeLow: 5, circulatingSupply: 1000000, @@ -151,7 +151,7 @@ function buildMockTokenPricesService( return {}; }, async fetchTokenPrices() { - return {}; + return []; }, validateChainIdSupported(_chainId: unknown): _chainId is Hex { return true; @@ -492,10 +492,11 @@ describe('TokenSearchDiscoveryDataController', () => { Promise.resolve(tokenMetadata), ); - const mockPriceData: TokenPrice = { + const mockPriceData: EvmAssetWithMarketData = { price: 10.5, currency: 'USD', tokenAddress: tokenAddress as Hex, + chainId: '0x1', allTimeHigh: 20, allTimeLow: 5, circulatingSupply: 1000000, @@ -516,9 +517,7 @@ describe('TokenSearchDiscoveryDataController', () => { }; const mockTokenPricesService = { - fetchTokenPrices: jest.fn().mockResolvedValue({ - [tokenAddress as Hex]: mockPriceData, - }), + fetchTokenPrices: jest.fn().mockResolvedValue([mockPriceData]), }; await withController( @@ -643,12 +642,13 @@ describe('TokenSearchDiscoveryDataController', () => { currency, }: { currency: string; - }): Promise> { + }): Promise[]> { const basePrice: Omit< - TokenPrice, + EvmAssetWithMarketData, 'price' | 'currency' > = { tokenAddress: tokenAddress as Hex, + chainId: '0x1', allTimeHigh: 20, allTimeLow: 5, circulatingSupply: 1000000, @@ -668,13 +668,13 @@ describe('TokenSearchDiscoveryDataController', () => { totalVolume: 500000, }; - return { - [tokenAddress as Hex]: { + return [ + { ...basePrice, price: currency === 'USD' ? 10.5 : 9.5, currency, }, - }; + ]; }, }; @@ -713,10 +713,11 @@ describe('TokenSearchDiscoveryDataController', () => { decimals: 18, }); - const mockTokenPrice: TokenPrice = { + const mockTokenPrice: EvmAssetWithMarketData = { price: 10.5, currency: 'USD', tokenAddress: tokenAddress as Hex, + chainId: '0x1', allTimeHigh: 20, allTimeLow: 5, circulatingSupply: 1000000, diff --git a/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.ts b/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.ts index 489a58d29eb..6036f2650b9 100644 --- a/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.ts +++ b/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.ts @@ -11,7 +11,6 @@ import type { TokenDisplayData } from './types'; import { formatIconUrlWithProxy } from '../assetsUtil'; import type { GetCurrencyRateState } from '../CurrencyRateController'; import type { AbstractTokenPricesService } from '../token-prices-service'; -import type { TokenPrice } from '../token-prices-service/abstract-token-prices-service'; import { fetchTokenMetadata, TOKEN_METADATA_NO_SUPPORT_ERROR, @@ -172,22 +171,18 @@ export class TokenSearchDiscoveryDataController extends BaseController< this.#fetchSwapsTokensThresholdMs = fetchSwapsTokensThresholdMs; } - async #fetchPriceData( - chainId: Hex, - address: string, - ): Promise | null> { + async #fetchPriceData(chainId: Hex, address: string) { const { currentCurrency } = this.messenger.call( 'CurrencyRateController:getState', ); try { const pricesData = await this.#tokenPricesService.fetchTokenPrices({ - chainId, - tokenAddresses: [address as Hex], + assets: [{ chainId, tokenAddress: address as Hex }], currency: currentCurrency, }); - return pricesData[address as Hex] ?? null; + return pricesData[0] ?? null; } catch (error) { console.error(error); return null; diff --git a/packages/assets-controllers/src/TokenSearchDiscoveryDataController/types.ts b/packages/assets-controllers/src/TokenSearchDiscoveryDataController/types.ts index 7f092b58bbe..26b482ef141 100644 --- a/packages/assets-controllers/src/TokenSearchDiscoveryDataController/types.ts +++ b/packages/assets-controllers/src/TokenSearchDiscoveryDataController/types.ts @@ -1,6 +1,6 @@ import type { Hex } from '@metamask/utils'; -import type { TokenPrice } from '../token-prices-service/abstract-token-prices-service'; +import type { EvmAssetWithMarketData } from '../token-prices-service/abstract-token-prices-service'; import type { Token } from '../TokenRatesController'; export type NotFoundTokenDisplayData = { @@ -16,7 +16,7 @@ export type FoundTokenDisplayData = { address: string; currency: string; token: Token; - price: TokenPrice | null; + price: EvmAssetWithMarketData | null; }; export type TokenDisplayData = NotFoundTokenDisplayData | FoundTokenDisplayData; diff --git a/packages/assets-controllers/src/assetsUtil.test.ts b/packages/assets-controllers/src/assetsUtil.test.ts index e67bbb89280..fc6e5d7c42c 100644 --- a/packages/assets-controllers/src/assetsUtil.test.ts +++ b/packages/assets-controllers/src/assetsUtil.test.ts @@ -10,7 +10,10 @@ import { add0x, type Hex } from '@metamask/utils'; import * as assetsUtil from './assetsUtil'; import { TOKEN_PRICES_BATCH_SIZE } from './assetsUtil'; import type { Nft, NftMetadata } from './NftController'; -import type { AbstractTokenPricesService } from './token-prices-service'; +import { + getNativeTokenAddress, + type AbstractTokenPricesService, +} from './token-prices-service'; const DEFAULT_IPFS_URL_FORMAT = 'ipfs://'; const ALTERNATIVE_IPFS_URL_FORMAT = 'ipfs://ipfs/'; @@ -622,9 +625,10 @@ describe('assetsUtil', () => { const testChainId = '0x1'; const mockPriceService = createMockPriceService(); - jest.spyOn(mockPriceService, 'fetchTokenPrices').mockResolvedValue({ - [testTokenAddress]: { + jest.spyOn(mockPriceService, 'fetchTokenPrices').mockResolvedValue([ + { tokenAddress: testTokenAddress, + chainId: testChainId, currency: testNativeCurrency, allTimeHigh: 4000, allTimeLow: 900, @@ -645,7 +649,7 @@ describe('assetsUtil', () => { priceChange1d: 100, pricePercentChange1d: 100, }, - }); + ]); const result = await assetsUtil.fetchTokenContractExchangeRates({ tokenPricesService: mockPriceService, @@ -685,13 +689,21 @@ describe('assetsUtil', () => { ); expect(fetchTokenPricesSpy).toHaveBeenCalledTimes(numBatches); + const tokenAddressesWithNativeToken = [ + getNativeTokenAddress(testChainId), + ...tokenAddresses, + ]; for (let i = 1; i <= numBatches; i++) { expect(fetchTokenPricesSpy).toHaveBeenNthCalledWith(i, { - chainId: testChainId, - tokenAddresses: tokenAddresses.slice( - (i - 1) * TOKEN_PRICES_BATCH_SIZE, - i * TOKEN_PRICES_BATCH_SIZE, - ), + assets: tokenAddressesWithNativeToken + .slice( + (i - 1) * TOKEN_PRICES_BATCH_SIZE, + i * TOKEN_PRICES_BATCH_SIZE, + ) + .map((tokenAddress) => ({ + chainId: testChainId, + tokenAddress, + })), currency: testNativeCurrency, }); } @@ -729,13 +741,21 @@ describe('assetsUtil', () => { ); expect(fetchTokenPricesSpy).toHaveBeenCalledTimes(numBatches); + const tokenAddressesWithNativeToken = [ + getNativeTokenAddress(testChainId), + ...tokenAddresses, + ]; for (let i = 1; i <= numBatches; i++) { expect(fetchTokenPricesSpy).toHaveBeenNthCalledWith(i, { - chainId: testChainId, - tokenAddresses: tokenAddresses.slice( - (i - 1) * TOKEN_PRICES_BATCH_SIZE, - i * TOKEN_PRICES_BATCH_SIZE, - ), + assets: tokenAddressesWithNativeToken + .slice( + (i - 1) * TOKEN_PRICES_BATCH_SIZE, + i * TOKEN_PRICES_BATCH_SIZE, + ) + .map((tokenAddress) => ({ + chainId: testChainId, + tokenAddress, + })), currency: testNativeCurrency, }); } @@ -779,7 +799,7 @@ function createMockPriceService(): AbstractTokenPricesService { return true; }, async fetchTokenPrices() { - return {}; + return []; }, async fetchExchangeRates() { return {}; diff --git a/packages/assets-controllers/src/assetsUtil.ts b/packages/assets-controllers/src/assetsUtil.ts index 6827486f631..8cfa5dd2b0f 100644 --- a/packages/assets-controllers/src/assetsUtil.ts +++ b/packages/assets-controllers/src/assetsUtil.ts @@ -14,8 +14,12 @@ import BN from 'bn.js'; import { CID } from 'multiformats/cid'; import type { Nft, NftMetadata } from './NftController'; -import type { AbstractTokenPricesService } from './token-prices-service'; -import { type ContractExchangeRates } from './TokenRatesController'; +import { + getNativeTokenAddress, + type AbstractTokenPricesService, +} from './token-prices-service'; +import type { EvmAssetWithMarketData } from './token-prices-service/abstract-token-prices-service'; +import type { ContractExchangeRates } from './TokenRatesController'; /** * The maximum number of token addresses that should be sent to the Price API in @@ -370,17 +374,26 @@ export async function fetchTokenContractExchangeRates({ const tokenPricesByTokenAddress = await reduceInBatchesSerially< Hex, - Awaited> + Record >({ - values: [...tokenAddresses].sort(), + values: [...tokenAddresses, getNativeTokenAddress(chainId)].sort(), batchSize: TOKEN_PRICES_BATCH_SIZE, eachBatch: async (allTokenPricesByTokenAddress, batch) => { - const tokenPricesByTokenAddressForBatch = + const tokenPricesByTokenAddressForBatch = ( await tokenPricesService.fetchTokenPrices({ - tokenAddresses: batch, - chainId, + assets: batch.map((tokenAddress) => ({ + chainId, + tokenAddress, + })), currency: nativeCurrency, - }); + }) + ).reduce( + (acc, tokenPrice) => { + acc[tokenPrice.tokenAddress] = tokenPrice; + return acc; + }, + {} as Record, + ); return { ...allTokenPricesByTokenAddress, 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..22577107fa3 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,31 +1,7 @@ import type { ServicePolicy } from '@metamask/controller-utils'; -import type { Hex } from '@metamask/utils'; +import type { CaipAssetType, Hex } from '@metamask/utils'; -/** - * Represents the price of a token in a currency. - */ -export type TokenPrice = { - tokenAddress: TokenAddress; - currency: Currency; - allTimeHigh: number; - allTimeLow: number; - circulatingSupply: number; - dilutedMarketCap: number; - high1d: number; - low1d: number; - marketCap: number; - marketCapPercentChange1d: number; - price: number; - priceChange1d: number; - pricePercentChange1d: number; - pricePercentChange1h: number; - pricePercentChange1y: number; - pricePercentChange7d: number; - pricePercentChange14d: number; - pricePercentChange30d: number; - pricePercentChange200d: number; - totalVolume: number; -}; +import type { MarketDataDetails } from '../TokenRatesController'; /** * Represents an exchange rate. @@ -38,16 +14,6 @@ export type ExchangeRate = { usd?: number; }; -/** - * A map of token address to its price. - */ -export type TokenPricesByTokenAddress< - TokenAddress extends Hex, - Currency extends string, -> = { - [A in TokenAddress]: TokenPrice; -}; - /** * A map of currency to its exchange rate. */ @@ -55,22 +21,33 @@ export type ExchangeRatesByCurrency = { [C in Currency]: ExchangeRate; }; +export type EvmAssetAddressWithChain = { + tokenAddress: Hex; + chainId: ChainId; +}; + +export type EvmAssetWithId = + EvmAssetAddressWithChain & { + assetId: CaipAssetType; + }; + +export type EvmAssetWithMarketData< + ChainId extends Hex = Hex, + Currency extends string = string, +> = EvmAssetAddressWithChain & + MarketDataDetails & { currency: Currency }; + /** * An ideal token prices service. All implementations must confirm to this * interface. * * @template ChainId - A type union of valid arguments for the `chainId` * argument to `fetchTokenPrices`. - * @template TokenAddress - A type union of all token addresses. The reason this - * type parameter exists is so that we can guarantee that same addresses that - * `fetchTokenPrices` receives are the same addresses that shown up in the - * return value. * @template Currency - A type union of valid arguments for the `currency` * argument to `fetchTokenPrices`. */ export type AbstractTokenPricesService< ChainId extends Hex = Hex, - TokenAddress extends Hex = Hex, Currency extends string = string, > = Partial> & { /** @@ -78,20 +55,17 @@ export type AbstractTokenPricesService< * given addresses which are expected to live on the given chain. * * @param args - The arguments to this function. - * @param args.chainId - An EIP-155 chain ID. - * @param args.tokenAddresses - Addresses for tokens that live on the chain. + * @param args.assets - The assets to get prices for. * @param args.currency - The desired currency of the token prices. * @returns The prices for the requested tokens. */ fetchTokenPrices({ - chainId, - tokenAddresses, + assets, currency, }: { - chainId: ChainId; - tokenAddresses: TokenAddress[]; + assets: EvmAssetAddressWithChain[]; currency: Currency; - }): Promise>>; + }): Promise[]>; /** * Retrieves exchange rates in the given currency. diff --git a/packages/assets-controllers/src/token-prices-service/codefi-v2.test.ts b/packages/assets-controllers/src/token-prices-service/codefi-v2.test.ts index 964dbe669bc..44da38b1b62 100644 --- a/packages/assets-controllers/src/token-prices-service/codefi-v2.test.ts +++ b/packages/assets-controllers/src/token-prices-service/codefi-v2.test.ts @@ -1,3 +1,4 @@ +import { type Hex, KnownCaipNamespace } from '@metamask/utils'; import nock from 'nock'; import { useFakeTimers } from 'sinon'; @@ -31,10 +32,9 @@ describe('CodefiTokenPricesServiceV2', () => { const maximumConsecutiveFailures = (1 + retries) * 3; // Initial interceptor for failing requests nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') + .get('/v3/spot-prices') .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), vsCurrency: 'ETH', includeMarketData: 'true', }) @@ -42,36 +42,14 @@ describe('CodefiTokenPricesServiceV2', () => { .replyWithError('Failed to fetch'); // This interceptor should not be used nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') + .get('/v3/spot-prices') .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), vsCurrency: 'ETH', includeMarketData: 'true', }) .reply(200, { - '0x0000000000000000000000000000000000000000': { - price: 14, - currency: 'ETH', - pricePercentChange1d: 1, - priceChange1d: 1, - marketCap: 117219.99428314982, - allTimeHigh: 0.00060467892389492, - allTimeLow: 0.00002303954000865728, - totalVolume: 5155.094053542448, - high1d: 0.00008020715848194385, - low1d: 0.00007792083564549064, - circulatingSupply: 1494269733.9526057, - dilutedMarketCap: 117669.5125951733, - marketCapPercentChange1d: 0.76671, - pricePercentChange1h: -1.0736342953259423, - pricePercentChange7d: -7.351582573655089, - pricePercentChange14d: -1.0799098946709822, - pricePercentChange30d: -25.776321124365992, - pricePercentChange200d: 46.091571238599165, - pricePercentChange1y: -2.2992517267242754, - }, - '0xaaa': { + [buildTokenAssetId('0xAAA')]: { price: 148.17205755299946, currency: 'ETH', pricePercentChange1d: 1, @@ -92,7 +70,7 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xbbb': { + [buildTokenAssetId('0xBBB')]: { price: 33689.98134554716, currency: 'ETH', pricePercentChange1d: 1, @@ -113,7 +91,7 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xccc': { + [buildTokenAssetId('0xCCC')]: { price: 148.1344197578456, currency: 'ETH', pricePercentChange1d: 1, @@ -146,8 +124,20 @@ describe('CodefiTokenPricesServiceV2', () => { service.onBreak(onBreakHandler); const fetchTokenPrices = () => service.fetchTokenPrices({ - chainId: '0x1', - tokenAddresses: ['0xAAA', '0xBBB', '0xCCC'], + assets: [ + { + chainId: '0x1', + tokenAddress: '0xAAA', + }, + { + chainId: '0x1', + tokenAddress: '0xBBB', + }, + { + chainId: '0x1', + tokenAddress: '0xCCC', + }, + ], currency: 'ETH', }); expect(onBreakHandler).not.toHaveBeenCalled(); @@ -184,34 +174,27 @@ describe('CodefiTokenPricesServiceV2', () => { const degradedThreshold = 1000; const retries = 0; nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') + .get('/v3/spot-prices') .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), vsCurrency: 'ETH', includeMarketData: 'true', }) .delay(degradedThreshold * 2) .reply(200, { - '0x0000000000000000000000000000000000000000': { - price: 14, - currency: 'ETH', - pricePercentChange1d: 1, - priceChange1d: 1, - }, - '0xaaa': { + [buildTokenAssetId('0xAAA')]: { price: 148.17205755299946, currency: 'ETH', pricePercentChange1d: 1, priceChange1d: 1, }, - '0xbbb': { + [buildTokenAssetId('0xBBB')]: { price: 33689.98134554716, currency: 'ETH', pricePercentChange1d: 1, priceChange1d: 1, }, - '0xccc': { + [buildTokenAssetId('0xCCC')]: { price: 148.1344197578456, currency: 'ETH', pricePercentChange1d: 1, @@ -229,8 +212,20 @@ describe('CodefiTokenPricesServiceV2', () => { clock, fetchTokenPrices: () => service.fetchTokenPrices({ - chainId: '0x1', - tokenAddresses: ['0xAAA', '0xBBB', '0xCCC'], + assets: [ + { + chainId: '0x1', + tokenAddress: '0xAAA', + }, + { + chainId: '0x1', + tokenAddress: '0xBBB', + }, + { + chainId: '0x1', + tokenAddress: '0xCCC', + }, + ], currency: 'ETH', }), retries, @@ -241,18 +236,17 @@ describe('CodefiTokenPricesServiceV2', () => { }); describe('fetchTokenPrices', () => { - it('uses the /spot-prices endpoint of the Codefi Price API to gather prices for the given tokens', async () => { + it('uses the /v2/chains/{chainId}/spot-prices endpoint to gather prices forn chains not supported by v3', async () => { nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') + .get('/v2/chains/0x52/spot-prices') .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', + tokenAddresses: ['0xAAA', '0xBBB'].join(','), vsCurrency: 'ETH', includeMarketData: 'true', }) .reply(200, { - '0x0000000000000000000000000000000000000000': { - price: 14, + '0xaaa': { + price: 148.17205755299946, currency: 'ETH', pricePercentChange1d: 1, priceChange1d: 1, @@ -272,7 +266,104 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xaaa': { + '0xbbb': { + price: 33689.98134554716, + currency: 'ETH', + pricePercentChange1d: 1, + priceChange1d: 1, + marketCap: 117219.99428314982, + allTimeHigh: 0.00060467892389492, + allTimeLow: 0.00002303954000865728, + totalVolume: 5155.094053542448, + high1d: 0.00008020715848194385, + low1d: 0.00007792083564549064, + circulatingSupply: 1494269733.9526057, + dilutedMarketCap: 117669.5125951733, + marketCapPercentChange1d: 0.76671, + pricePercentChange1h: -1.0736342953259423, + pricePercentChange7d: -7.351582573655089, + pricePercentChange14d: -1.0799098946709822, + pricePercentChange30d: -25.776321124365992, + pricePercentChange200d: 46.091571238599165, + pricePercentChange1y: -2.2992517267242754, + }, + }); + + const marketDataTokensByAddress = + await new CodefiTokenPricesServiceV2().fetchTokenPrices({ + assets: [ + { + chainId: '0x52', + tokenAddress: '0xAAA', + }, + { + chainId: '0x52', + tokenAddress: '0xBBB', + }, + ], + currency: 'ETH', + }); + + expect(marketDataTokensByAddress).toStrictEqual([ + { + allTimeHigh: 0.00060467892389492, + allTimeLow: 0.00002303954000865728, + chainId: '0x52', + circulatingSupply: 1494269733.9526057, + currency: 'ETH', + dilutedMarketCap: 117669.5125951733, + high1d: 0.00008020715848194385, + low1d: 0.00007792083564549064, + marketCap: 117219.99428314982, + marketCapPercentChange1d: 0.76671, + price: 148.17205755299946, + priceChange1d: 1, + pricePercentChange14d: -1.0799098946709822, + pricePercentChange1d: 1, + pricePercentChange1h: -1.0736342953259423, + pricePercentChange1y: -2.2992517267242754, + pricePercentChange200d: 46.091571238599165, + pricePercentChange30d: -25.776321124365992, + pricePercentChange7d: -7.351582573655089, + tokenAddress: '0xAAA', + totalVolume: 5155.094053542448, + }, + { + allTimeHigh: 0.00060467892389492, + allTimeLow: 0.00002303954000865728, + chainId: '0x52', + circulatingSupply: 1494269733.9526057, + currency: 'ETH', + dilutedMarketCap: 117669.5125951733, + high1d: 0.00008020715848194385, + low1d: 0.00007792083564549064, + marketCap: 117219.99428314982, + marketCapPercentChange1d: 0.76671, + price: 33689.98134554716, + priceChange1d: 1, + pricePercentChange14d: -1.0799098946709822, + pricePercentChange1d: 1, + pricePercentChange1h: -1.0736342953259423, + pricePercentChange1y: -2.2992517267242754, + pricePercentChange200d: 46.091571238599165, + pricePercentChange30d: -25.776321124365992, + pricePercentChange7d: -7.351582573655089, + tokenAddress: '0xBBB', + totalVolume: 5155.094053542448, + }, + ]); + }); + + it('uses the /spot-prices endpoint of the Codefi Price API to gather prices for the given tokens', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v3/spot-prices') + .query({ + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), + vsCurrency: 'ETH', + includeMarketData: 'true', + }) + .reply(200, { + [buildTokenAssetId('0xAAA')]: { price: 148.17205755299946, currency: 'ETH', pricePercentChange1d: 1, @@ -293,7 +384,7 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xbbb': { + [buildTokenAssetId('0xBBB')]: { price: 33689.98134554716, currency: 'ETH', pricePercentChange1d: 1, @@ -314,7 +405,7 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xccc': { + [buildTokenAssetId('0xCCC')]: { price: 148.1344197578456, currency: 'ETH', pricePercentChange1d: 1, @@ -339,36 +430,28 @@ describe('CodefiTokenPricesServiceV2', () => { const marketDataTokensByAddress = await new CodefiTokenPricesServiceV2().fetchTokenPrices({ - chainId: '0x1', - tokenAddresses: ['0xAAA', '0xBBB', '0xCCC'], + assets: [ + { + chainId: '0x1', + tokenAddress: '0xAAA', + }, + { + chainId: '0x1', + tokenAddress: '0xBBB', + }, + { + chainId: '0x1', + tokenAddress: '0xCCC', + }, + ], currency: 'ETH', }); - expect(marketDataTokensByAddress).toStrictEqual({ - '0x0000000000000000000000000000000000000000': { - tokenAddress: '0x0000000000000000000000000000000000000000', - currency: 'ETH', - pricePercentChange1d: 1, - priceChange1d: 1, - marketCap: 117219.99428314982, - allTimeHigh: 0.00060467892389492, - allTimeLow: 0.00002303954000865728, - totalVolume: 5155.094053542448, - high1d: 0.00008020715848194385, - low1d: 0.00007792083564549064, - price: 14, - circulatingSupply: 1494269733.9526057, - dilutedMarketCap: 117669.5125951733, - marketCapPercentChange1d: 0.76671, - pricePercentChange1h: -1.0736342953259423, - pricePercentChange7d: -7.351582573655089, - pricePercentChange14d: -1.0799098946709822, - pricePercentChange30d: -25.776321124365992, - pricePercentChange200d: 46.091571238599165, - pricePercentChange1y: -2.2992517267242754, - }, - '0xAAA': { + expect(marketDataTokensByAddress).toStrictEqual([ + { tokenAddress: '0xAAA', + assetId: buildTokenAssetId('0xAAA'), + chainId: '0x1', currency: 'ETH', pricePercentChange1d: 1, priceChange1d: 1, @@ -389,8 +472,10 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xBBB': { + { tokenAddress: '0xBBB', + assetId: buildTokenAssetId('0xBBB'), + chainId: '0x1', currency: 'ETH', pricePercentChange1d: 1, priceChange1d: 1, @@ -411,8 +496,10 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xCCC': { + { tokenAddress: '0xCCC', + assetId: buildTokenAssetId('0xCCC'), + chainId: '0x1', currency: 'ETH', pricePercentChange1d: 1, priceChange1d: 1, @@ -433,86 +520,55 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - }); + ]); }); - it('calls the /spot-prices endpoint using the correct native token address', async () => { - const mockPriceAPI = nock('https://price.api.cx.metamask.io') - .get('/v2/chains/137/spot-prices') + it('handles native token addresses', async () => { + nock('https://price.api.cx.metamask.io') + .get('/v3/spot-prices') .query({ - tokenAddresses: '0x0000000000000000000000000000000000001010', + assetIds: buildMultipleAssetIds([ZERO_ADDRESS]), vsCurrency: 'ETH', includeMarketData: 'true', }) .reply(200, { - '0x0000000000000000000000000000000000001010': { - price: 14, + [buildTokenAssetId(ZERO_ADDRESS)]: { + price: 33689.98134554716, currency: 'ETH', - pricePercentChange1d: 1, - priceChange1d: 1, - marketCap: 117219.99428314982, - allTimeHigh: 0.00060467892389492, - allTimeLow: 0.00002303954000865728, - totalVolume: 5155.094053542448, - high1d: 0.00008020715848194385, - low1d: 0.00007792083564549064, - circulatingSupply: 1494269733.9526057, - dilutedMarketCap: 117669.5125951733, - marketCapPercentChange1d: 0.76671, - pricePercentChange1h: -1.0736342953259423, - pricePercentChange7d: -7.351582573655089, - pricePercentChange14d: -1.0799098946709822, - pricePercentChange30d: -25.776321124365992, - pricePercentChange200d: 46.091571238599165, - pricePercentChange1y: -2.2992517267242754, }, }); - const marketData = - await new CodefiTokenPricesServiceV2().fetchTokenPrices({ - chainId: '0x89', - tokenAddresses: [], - currency: 'ETH', - }); + const result = await new CodefiTokenPricesServiceV2().fetchTokenPrices({ + assets: [ + { + chainId: '0x1', + tokenAddress: ZERO_ADDRESS, + }, + ], + currency: 'ETH', + }); - expect(mockPriceAPI.isDone()).toBe(true); - expect( - marketData['0x0000000000000000000000000000000000001010'], - ).toBeDefined(); + expect(result).toStrictEqual([ + { + tokenAddress: ZERO_ADDRESS, + assetId: buildTokenAssetId(ZERO_ADDRESS), + chainId: '0x1', + currency: 'ETH', + price: 33689.98134554716, + }, + ]); }); it('should not include token price object for token address when token price in not included the response data', async () => { nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') + .get('/v3/spot-prices') .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), vsCurrency: 'ETH', includeMarketData: 'true', }) .reply(200, { - '0x0000000000000000000000000000000000000000': { - price: 14, - currency: 'ETH', - pricePercentChange1d: 1, - priceChange1d: 1, - marketCap: 117219.99428314982, - allTimeHigh: 0.00060467892389492, - allTimeLow: 0.00002303954000865728, - totalVolume: 5155.094053542448, - high1d: 0.00008020715848194385, - low1d: 0.00007792083564549064, - circulatingSupply: 1494269733.9526057, - dilutedMarketCap: 117669.5125951733, - marketCapPercentChange1d: 0.76671, - pricePercentChange1h: -1.0736342953259423, - pricePercentChange7d: -7.351582573655089, - pricePercentChange14d: -1.0799098946709822, - pricePercentChange30d: -25.776321124365992, - pricePercentChange200d: 46.091571238599165, - pricePercentChange1y: -2.2992517267242754, - }, - '0xbbb': { + [buildTokenAssetId('0xBBB')]: { price: 33689.98134554716, currency: 'ETH', pricePercentChange1d: 1, @@ -533,7 +589,7 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xccc': { + [buildTokenAssetId('0xCCC')]: { price: 148.1344197578456, currency: 'ETH', pricePercentChange1d: 1, @@ -557,35 +613,27 @@ describe('CodefiTokenPricesServiceV2', () => { }); const result = await new CodefiTokenPricesServiceV2().fetchTokenPrices({ - chainId: '0x1', - tokenAddresses: ['0xAAA', '0xBBB', '0xCCC'], + assets: [ + { + chainId: '0x1', + tokenAddress: '0xAAA', + }, + { + chainId: '0x1', + tokenAddress: '0xBBB', + }, + { + chainId: '0x1', + tokenAddress: '0xCCC', + }, + ], currency: 'ETH', }); - expect(result).toStrictEqual({ - '0x0000000000000000000000000000000000000000': { - tokenAddress: '0x0000000000000000000000000000000000000000', - currency: 'ETH', - pricePercentChange1d: 1, - priceChange1d: 1, - marketCap: 117219.99428314982, - allTimeHigh: 0.00060467892389492, - allTimeLow: 0.00002303954000865728, - totalVolume: 5155.094053542448, - high1d: 0.00008020715848194385, - low1d: 0.00007792083564549064, - price: 14, - circulatingSupply: 1494269733.9526057, - dilutedMarketCap: 117669.5125951733, - marketCapPercentChange1d: 0.76671, - pricePercentChange1h: -1.0736342953259423, - pricePercentChange7d: -7.351582573655089, - pricePercentChange14d: -1.0799098946709822, - pricePercentChange30d: -25.776321124365992, - pricePercentChange200d: 46.091571238599165, - pricePercentChange1y: -2.2992517267242754, - }, - '0xBBB': { + expect(result).toStrictEqual([ + { tokenAddress: '0xBBB', + assetId: buildTokenAssetId('0xBBB'), + chainId: '0x1', currency: 'ETH', pricePercentChange1d: 1, priceChange1d: 1, @@ -606,8 +654,10 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xCCC': { + { tokenAddress: '0xCCC', + assetId: buildTokenAssetId('0xCCC'), + chainId: '0x1', currency: 'ETH', pricePercentChange1d: 1, priceChange1d: 1, @@ -628,21 +678,20 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - }); + ]); }); it('should not include token price object for token address when price is undefined for token response data', async () => { nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') + .get('/v3/spot-prices') .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), vsCurrency: 'ETH', includeMarketData: 'true', }) .reply(200, { - '0xaaa': {}, - '0xbbb': { + [buildTokenAssetId('0xAAA')]: {}, + [buildTokenAssetId('0xBBB')]: { price: 33689.98134554716, pricePercentChange1d: 1, priceChange1d: 1, @@ -662,7 +711,7 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xccc': { + [buildTokenAssetId('0xCCC')]: { price: 148.1344197578456, pricePercentChange1d: 1, priceChange1d: 1, @@ -685,18 +734,34 @@ describe('CodefiTokenPricesServiceV2', () => { }); const result = await new CodefiTokenPricesServiceV2().fetchTokenPrices({ - chainId: '0x1', - tokenAddresses: ['0xAAA', '0xBBB', '0xCCC'], + assets: [ + { + chainId: '0x1', + tokenAddress: '0xAAA', + }, + { + chainId: '0x1', + tokenAddress: '0xBBB', + }, + { + chainId: '0x1', + tokenAddress: '0xCCC', + }, + ], currency: 'ETH', }); - expect(result).toStrictEqual({ - '0xAAA': { - currency: 'ETH', + expect(result).toStrictEqual([ + { tokenAddress: '0xAAA', + assetId: buildTokenAssetId('0xAAA'), + chainId: '0x1', + currency: 'ETH', }, - '0xBBB': { + { tokenAddress: '0xBBB', + assetId: buildTokenAssetId('0xBBB'), + chainId: '0x1', currency: 'ETH', pricePercentChange1d: 1, priceChange1d: 1, @@ -717,8 +782,10 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xCCC': { + { tokenAddress: '0xCCC', + assetId: buildTokenAssetId('0xCCC'), + chainId: '0x1', currency: 'ETH', pricePercentChange1d: 1, priceChange1d: 1, @@ -739,65 +806,70 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - }); + ]); }); it('should correctly handle null market data for a token address', async () => { nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') + .get('/v3/spot-prices') .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), vsCurrency: 'ETH', includeMarketData: 'true', }) .reply(200, { - '0x0000000000000000000000000000000000000000': { - price: 14, - currency: 'ETH', - }, - '0xaaa': null, // Simulating API returning null for market data - '0xbbb': { + [buildTokenAssetId('0xAAA')]: null, // Simulating API returning null for market data + [buildTokenAssetId('0xBBB')]: { price: 33689.98134554716, currency: 'ETH', }, - '0xccc': { + [buildTokenAssetId('0xCCC')]: { price: 148.1344197578456, currency: 'ETH', }, }); const result = await new CodefiTokenPricesServiceV2().fetchTokenPrices({ - chainId: '0x1', - tokenAddresses: ['0xAAA', '0xBBB', '0xCCC'], + assets: [ + { + chainId: '0x1', + tokenAddress: '0xAAA', + }, + { + chainId: '0x1', + tokenAddress: '0xBBB', + }, + { + chainId: '0x1', + tokenAddress: '0xCCC', + }, + ], currency: 'ETH', }); - expect(result).toStrictEqual({ - '0x0000000000000000000000000000000000000000': { - tokenAddress: '0x0000000000000000000000000000000000000000', - currency: 'ETH', - price: 14, - }, - '0xBBB': { + expect(result).toStrictEqual([ + { tokenAddress: '0xBBB', + assetId: buildTokenAssetId('0xBBB'), + chainId: '0x1', currency: 'ETH', price: 33689.98134554716, }, - '0xCCC': { + { tokenAddress: '0xCCC', + assetId: buildTokenAssetId('0xCCC'), + chainId: '0x1', currency: 'ETH', price: 148.1344197578456, }, - }); + ]); }); it('throws if the request fails consistently', async () => { nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') + .get('/v3/spot-prices') .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), vsCurrency: 'ETH', includeMarketData: 'true', }) @@ -806,8 +878,20 @@ describe('CodefiTokenPricesServiceV2', () => { await expect( new CodefiTokenPricesServiceV2().fetchTokenPrices({ - chainId: '0x1', - tokenAddresses: ['0xAAA', '0xBBB', '0xCCC'], + assets: [ + { + chainId: '0x1', + tokenAddress: '0xAAA', + }, + { + chainId: '0x1', + tokenAddress: '0xBBB', + }, + { + chainId: '0x1', + tokenAddress: '0xCCC', + }, + ], currency: 'ETH', }), ).rejects.toThrow('Failed to fetch'); @@ -816,10 +900,9 @@ describe('CodefiTokenPricesServiceV2', () => { it('throws if the initial request and all retries fail', async () => { const retries = 3; nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') + .get('/v3/spot-prices') .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), vsCurrency: 'ETH', includeMarketData: 'true', }) @@ -828,8 +911,20 @@ describe('CodefiTokenPricesServiceV2', () => { await expect( new CodefiTokenPricesServiceV2({ retries }).fetchTokenPrices({ - chainId: '0x1', - tokenAddresses: ['0xAAA', '0xBBB', '0xCCC'], + assets: [ + { + chainId: '0x1', + tokenAddress: '0xAAA', + }, + { + chainId: '0x1', + tokenAddress: '0xBBB', + }, + { + chainId: '0x1', + tokenAddress: '0xCCC', + }, + ], currency: 'ETH', }), ).rejects.toThrow('Failed to fetch'); @@ -839,10 +934,9 @@ describe('CodefiTokenPricesServiceV2', () => { const retries = 3; // Initial interceptor for failing requests nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') + .get('/v3/spot-prices') .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), vsCurrency: 'ETH', includeMarketData: 'true', }) @@ -850,36 +944,14 @@ describe('CodefiTokenPricesServiceV2', () => { .replyWithError('Failed to fetch'); // Interceptor for successful request nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') + .get('/v3/spot-prices') .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), vsCurrency: 'ETH', includeMarketData: 'true', }) .reply(200, { - '0x0000000000000000000000000000000000000000': { - price: 14, - currency: 'ETH', - pricePercentChange1d: 1, - priceChange1d: 1, - marketCap: 117219.99428314982, - allTimeHigh: 0.00060467892389492, - allTimeLow: 0.00002303954000865728, - totalVolume: 5155.094053542448, - high1d: 0.00008020715848194385, - low1d: 0.00007792083564549064, - circulatingSupply: 1494269733.9526057, - dilutedMarketCap: 117669.5125951733, - marketCapPercentChange1d: 0.76671, - pricePercentChange1h: -1.0736342953259423, - pricePercentChange7d: -7.351582573655089, - pricePercentChange14d: -1.0799098946709822, - pricePercentChange30d: -25.776321124365992, - pricePercentChange200d: 46.091571238599165, - pricePercentChange1y: -2.2992517267242754, - }, - '0xaaa': { + [buildTokenAssetId('0xAAA')]: { price: 148.17205755299946, currency: 'ETH', pricePercentChange1d: 1, @@ -900,7 +972,7 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xbbb': { + [buildTokenAssetId('0xBBB')]: { price: 33689.98134554716, currency: 'ETH', pricePercentChange1d: 1, @@ -921,7 +993,7 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xccc': { + [buildTokenAssetId('0xCCC')]: { price: 148.1344197578456, currency: 'ETH', pricePercentChange1d: 1, @@ -947,36 +1019,28 @@ describe('CodefiTokenPricesServiceV2', () => { const marketDataTokensByAddress = await new CodefiTokenPricesServiceV2({ retries, }).fetchTokenPrices({ - chainId: '0x1', - tokenAddresses: ['0xAAA', '0xBBB', '0xCCC'], + assets: [ + { + chainId: '0x1', + tokenAddress: '0xAAA', + }, + { + chainId: '0x1', + tokenAddress: '0xBBB', + }, + { + chainId: '0x1', + tokenAddress: '0xCCC', + }, + ], currency: 'ETH', }); - expect(marketDataTokensByAddress).toStrictEqual({ - '0x0000000000000000000000000000000000000000': { - tokenAddress: '0x0000000000000000000000000000000000000000', - currency: 'ETH', - pricePercentChange1d: 1, - priceChange1d: 1, - marketCap: 117219.99428314982, - allTimeHigh: 0.00060467892389492, - allTimeLow: 0.00002303954000865728, - totalVolume: 5155.094053542448, - high1d: 0.00008020715848194385, - low1d: 0.00007792083564549064, - price: 14, - circulatingSupply: 1494269733.9526057, - dilutedMarketCap: 117669.5125951733, - marketCapPercentChange1d: 0.76671, - pricePercentChange1h: -1.0736342953259423, - pricePercentChange7d: -7.351582573655089, - pricePercentChange14d: -1.0799098946709822, - pricePercentChange30d: -25.776321124365992, - pricePercentChange200d: 46.091571238599165, - pricePercentChange1y: -2.2992517267242754, - }, - '0xAAA': { + expect(marketDataTokensByAddress).toStrictEqual([ + { tokenAddress: '0xAAA', + assetId: buildTokenAssetId('0xAAA'), + chainId: '0x1', currency: 'ETH', pricePercentChange1d: 1, priceChange1d: 1, @@ -997,8 +1061,10 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xBBB': { + { tokenAddress: '0xBBB', + assetId: buildTokenAssetId('0xBBB'), + chainId: '0x1', currency: 'ETH', pricePercentChange1d: 1, priceChange1d: 1, @@ -1019,8 +1085,10 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xCCC': { + { tokenAddress: '0xCCC', + assetId: buildTokenAssetId('0xCCC'), + chainId: '0x1', currency: 'ETH', pricePercentChange1d: 1, priceChange1d: 1, @@ -1041,7 +1109,7 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - }); + ]); }); describe('before circuit break', () => { @@ -1059,34 +1127,27 @@ describe('CodefiTokenPricesServiceV2', () => { const degradedThreshold = 1000; const retries = 0; nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') + .get('/v3/spot-prices') .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), vsCurrency: 'ETH', includeMarketData: 'true', }) .delay(degradedThreshold * 2) .reply(200, { - '0x0000000000000000000000000000000000000000': { - price: 14, - currency: 'ETH', - pricePercentChange1d: 1, - priceChange1d: 1, - }, - '0xaaa': { + [buildTokenAssetId('0xAAA')]: { price: 148.17205755299946, currency: 'ETH', pricePercentChange1d: 1, priceChange1d: 1, }, - '0xbbb': { + [buildTokenAssetId('0xBBB')]: { price: 33689.98134554716, currency: 'ETH', pricePercentChange1d: 1, priceChange1d: 1, }, - '0xccc': { + [buildTokenAssetId('0xCCC')]: { price: 148.1344197578456, currency: 'ETH', pricePercentChange1d: 1, @@ -1104,8 +1165,20 @@ describe('CodefiTokenPricesServiceV2', () => { clock, fetchTokenPrices: () => service.fetchTokenPrices({ - chainId: '0x1', - tokenAddresses: ['0xAAA', '0xBBB', '0xCCC'], + assets: [ + { + chainId: '0x1', + tokenAddress: '0xAAA', + }, + { + chainId: '0x1', + tokenAddress: '0xBBB', + }, + { + chainId: '0x1', + tokenAddress: '0xCCC', + }, + ], currency: 'ETH', }), retries, @@ -1132,10 +1205,9 @@ describe('CodefiTokenPricesServiceV2', () => { const maximumConsecutiveFailures = (1 + retries) * 3; // Initial interceptor for failing requests nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') + .get('/v3/spot-prices') .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), vsCurrency: 'ETH', includeMarketData: 'true', }) @@ -1143,36 +1215,14 @@ describe('CodefiTokenPricesServiceV2', () => { .replyWithError('Failed to fetch'); // This interceptor should not be used nock('https://price.api.cx.metamask.io') - .get('/v2/chains/1/spot-prices') + .get('/v3/spot-prices') .query({ - tokenAddresses: - '0x0000000000000000000000000000000000000000,0xAAA,0xBBB,0xCCC', + assetIds: buildMultipleAssetIds(['0xAAA', '0xBBB', '0xCCC']), vsCurrency: 'ETH', includeMarketData: 'true', }) .reply(200, { - '0x0000000000000000000000000000000000000000': { - price: 14, - currency: 'ETH', - pricePercentChange1d: 1, - priceChange1d: 1, - marketCap: 117219.99428314982, - allTimeHigh: 0.00060467892389492, - allTimeLow: 0.00002303954000865728, - totalVolume: 5155.094053542448, - high1d: 0.00008020715848194385, - low1d: 0.00007792083564549064, - circulatingSupply: 1494269733.9526057, - dilutedMarketCap: 117669.5125951733, - marketCapPercentChange1d: 0.76671, - pricePercentChange1h: -1.0736342953259423, - pricePercentChange7d: -7.351582573655089, - pricePercentChange14d: -1.0799098946709822, - pricePercentChange30d: -25.776321124365992, - pricePercentChange200d: 46.091571238599165, - pricePercentChange1y: -2.2992517267242754, - }, - '0xaaa': { + [buildTokenAssetId('0xAAA')]: { price: 148.17205755299946, currency: 'ETH', pricePercentChange1d: 1, @@ -1193,7 +1243,7 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xbbb': { + [buildTokenAssetId('0xBBB')]: { price: 33689.98134554716, currency: 'ETH', pricePercentChange1d: 1, @@ -1214,7 +1264,7 @@ describe('CodefiTokenPricesServiceV2', () => { pricePercentChange200d: 46.091571238599165, pricePercentChange1y: -2.2992517267242754, }, - '0xccc': { + [buildTokenAssetId('0xCCC')]: { price: 148.1344197578456, currency: 'ETH', pricePercentChange1d: 1, @@ -1247,8 +1297,20 @@ describe('CodefiTokenPricesServiceV2', () => { }); const fetchTokenPrices = () => service.fetchTokenPrices({ - chainId: '0x1', - tokenAddresses: ['0xAAA', '0xBBB', '0xCCC'], + assets: [ + { + chainId: '0x1', + tokenAddress: '0xAAA', + }, + { + chainId: '0x1', + tokenAddress: '0xBBB', + }, + { + chainId: '0x1', + tokenAddress: '0xCCC', + }, + ], currency: 'ETH', }); expect(onBreakHandler).not.toHaveBeenCalled(); @@ -1892,3 +1954,23 @@ async function fetchExchangeRatesWithFakeTimers({ return await pendingUpdate; } + +/** + * + * @param tokenAddress - The token address. + * @returns The token asset id. + */ +function buildTokenAssetId(tokenAddress: Hex): string { + return tokenAddress === ZERO_ADDRESS + ? `${KnownCaipNamespace.Eip155}:1/slip44:60` + : `${KnownCaipNamespace.Eip155}:1/erc20:${tokenAddress.toLowerCase()}`; +} + +/** + * + * @param tokenAddresses - The token addresses. + * @returns The token asset ids. + */ +function buildMultipleAssetIds(tokenAddresses: Hex[]): string { + return tokenAddresses.map(buildTokenAssetId).join(','); +} 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 5ec2ddcee3c..99140b2bf63 100644 --- a/packages/assets-controllers/src/token-prices-service/codefi-v2.ts +++ b/packages/assets-controllers/src/token-prices-service/codefi-v2.ts @@ -7,15 +7,21 @@ import { handleFetch, } from '@metamask/controller-utils'; import type { ServicePolicy } from '@metamask/controller-utils'; -import type { Hex } from '@metamask/utils'; -import { hexToNumber } from '@metamask/utils'; +import type { CaipAssetType, Hex } from '@metamask/utils'; +import { + hexToNumber, + KnownCaipNamespace, + toCaipChainId, +} from '@metamask/utils'; import type { AbstractTokenPricesService, + EvmAssetAddressWithChain, + EvmAssetWithId, + EvmAssetWithMarketData, ExchangeRatesByCurrency, - TokenPrice, - TokenPricesByTokenAddress, } from './abstract-token-prices-service'; +import type { MarketDataDetails } from '../TokenRatesController'; /** * The list of currencies that can be supplied as the `vsCurrency` parameter to @@ -76,6 +82,8 @@ export const SUPPORTED_CURRENCIES = [ 'eur', // British Pound Sterling 'gbp', + // Georgian Lari + 'gel', // Hong Kong Dollar 'hkd', // Hungarian Forint @@ -144,6 +152,52 @@ export const SUPPORTED_CURRENCIES = [ 'bits', // Satoshi 'sats', + // Colombian Peso + 'cop', + // Kenyan Shilling + 'kes', + // Romanian Leu + 'ron', + // Dominican Peso + 'dop', + // Costa Rican Colón + 'crc', + // Honduran Lempira + 'hnl', + // Zambian Kwacha + 'zmw', + // Salvadoran Colón + 'svc', + // Bosnia-Herzegovina Convertible Mark + 'bam', + // Peruvian Sol + 'pen', + // Guatemalan Quetzal + 'gtq', + // Lebanese Pound + 'lbp', + // Armenian Dram + 'amd', + // Solana + 'sol', + // Sei + 'sei', + // Sonic + 'sonic', + // Tron + 'trx', + // Taiko + 'taiko', + // Pepu + 'pepu', + // Polygon + 'pol', + // Mantle + 'mnt', + // Onomy + 'nom', + // Avalanche + 'avax', ] as const; /** @@ -160,7 +214,9 @@ export const ZERO_ADDRESS: Hex = * Only for chains whose native tokens have a specific address. */ const chainIdToNativeTokenAddress: Record = { - '0x89': '0x0000000000000000000000000000000000001010', + '0x89': '0x0000000000000000000000000000000000001010', // Polygon + '0x440': '0xdeaddeaddeaddeaddeaddeaddeaddeaddead0000', // Metis Andromeda + '0x1388': '0xdeaddeaddeaddeaddeaddeaddeaddeaddead0000', // Mantle }; /** @@ -174,6 +230,56 @@ const chainIdToNativeTokenAddress: Record = { export const getNativeTokenAddress = (chainId: Hex): Hex => chainIdToNativeTokenAddress[chainId] ?? ZERO_ADDRESS; +// Source: https://github.com/consensys-vertical-apps/va-mmcx-price-api/blob/main/src/constants/slip44.ts +// We can only support PricesAPI V3 for EVM chains that have a CAIP-19 native asset mapping. +export const SPOT_PRICES_SUPPORT_INFO = { + '0x1': 'eip155:1/slip44:60', // Ethereum Mainnet - Native symbol: ETH + '0xa': 'eip155:10/slip44:60', // OP Mainnet - Native symbol: ETH + '0x19': 'eip155:25/slip44:394', // Cronos Mainnet - Native symbol: CRO + '0x38': 'eip155:56/slip44:714', // BNB Smart Chain Mainnet - Native symbol: BNB + '0x39': 'eip155:57/erc20:0x0000000000000000000000000000000000000000', // 'eip155:57/slip44:57', // Syscoin Mainnet - Native symbol: SYS + '0x52': null, // 'eip155:82/slip44:18000', // Meter Mainnet - Native symbol: MTR + '0x58': 'eip155:88/erc20:0x0000000000000000000000000000000000000000', // 'eip155:88/slip44:889', // TomoChain - Native symbol: TOMO + '0x64': 'eip155:100/slip44:700', // Gnosis (formerly xDAI Chain) - Native symbol: xDAI + '0x6a': 'eip155:106/erc20:0x0000000000000000000000000000000000000000', // 'eip155:106/slip44:5655640', // Velas EVM Mainnet - Native symbol: VLX + '0x80': 'eip155:128/erc20:0x0000000000000000000000000000000000000000', // 'eip155:128/slip44:1010', // Huobi ECO Chain Mainnet - Native symbol: HT + '0x89': 'eip155:137/slip44:966', // Polygon Mainnet - Native symbol: POL + '0x8f': null, // 'eip155:143/slip44:268435779', // Monad Mainnet - Native symbol: MON + '0x92': 'eip155:146/slip44:10007', // Sonic Mainnet - Native symbol: S + '0xfa': 'eip155:250/slip44:1007', // Fantom Opera - Native symbol: FTM + '0x141': 'eip155:321/erc20:0x0000000000000000000000000000000000000000', // 'eip155:321/slip44:641', // KCC Mainnet - Native symbol: KCS + '0x144': 'eip155:324/slip44:60', // zkSync Era Mainnet (Ethereum L2) - Native symbol: ETH + '0x169': 'eip155:361/erc20:0x0000000000000000000000000000000000000000', // 'eip155:361/slip44:589', // Theta Mainnet - Native symbol: TFUEL + '0x3e7': 'eip155:999/slip44:2457', // HyperEVM - Native symbol: ETH + '0x440': 'eip155:1088/erc20:0xdeaddeaddeaddeaddeaddeaddeaddeaddead0000', // 'eip155:1088/slip44:XXX', // Metis Andromeda Mainnet (Ethereum L2) - Native symbol: METIS + '0x44d': 'eip155:1101/slip44:60', // Polygon zkEVM mainnet - Native symbol: ETH + '0x504': 'eip155:1284/slip44:1284', // Moonbeam - Native symbol: GLMR + '0x505': 'eip155:1285/slip44:1285', // Moonriver - Native symbol: MOVR + '0x531': 'eip155:1329/slip44:19000118', // Sei Mainnet - Native symbol: SEI + '0x1388': 'eip155:5000/erc20:0xdeaddeaddeaddeaddeaddeaddeaddeaddead0000', // 'eip155:5000/slip44:XXX', // Mantle - Native symbol: MNT + '0x2105': 'eip155:8453/slip44:60', // Base - Native symbol: ETH + '0x2710': 'eip155:10000/erc20:0x0000000000000000000000000000000000000000', // 'eip155:10000/slip44:145', // Smart Bitcoin Cash - Native symbol: BCH + '0xa4b1': 'eip155:42161/slip44:60', // Arbitrum One - Native symbol: ETH + '0xa4ec': 'eip155:42220/slip44:52752', // Celo Mainnet - Native symbol: CELO + '0xa516': 'eip155:42262/erc20:0x0000000000000000000000000000000000000000', // 'eip155:42262/slip44:474', // Oasis Emerald - Native symbol: ROSE + '0xa86a': 'eip155:43114/slip44:9005', // Avalanche C-Chain - Native symbol: AVAX + '0xe708': 'eip155:59144/slip44:60', // Linea Mainnet - Native symbol: ETH + '0x13c31': 'eip155:81457/erc20:0x0000000000000000000000000000000000000000', // 'eip155:81457/slip44:60', // Blast Mainnet - Native symbol: ETH + '0x17dcd': 'eip155:97741/erc20:0x0000000000000000000000000000000000000000', // 'eip155:97741/slip44:XXX', // Pepe Unchained Mainnet - Native symbol: PEPU + '0x518af': null, // 'eip155:333999/slip44:1997', // Polis Mainnet - Native symbol: POLIS + '0x82750': 'eip155:534352/slip44:60', // Scroll Mainnet - Native symbol: ETH + '0x4e454152': 'eip155:60/slip44:60', // Aurora Mainnet (Ethereum L2 on NEAR) - Native symbol: ETH + '0x63564c40': 'eip155:1666600000/slip44:1023', // Harmony Mainnet Shard 0 - Native symbol: ONE +} as const; + +// MISSING CHAINS WITH NO NATIVE ASSET PRICES IN V2 +// '0x42': 'eip155:66/slip44:996', // OKXChain Mainnet - Native symbol: OKT +// '0x46': 'eip155:70/slip44:1170', // Hoo Smart Chain - Native symbol: HOO +// '0x7a': 'eip155:122/slip44:XXX', // Fuse Mainnet - Native symbol: FUSE +// '0x120': 'eip155:288/slip44:60', // Boba Network (Ethereum L2) - Native symbol: ETH +// '0x150': 'eip155:336/slip44:809', // Shiden - Native symbol: SDN +// '0x28c58': 'eip155:167000/slip44:60', // Taiko Mainnet - Native symbol: ETH + /** * A currency that can be supplied as the `vsCurrency` parameter to * the `/spot-prices` endpoint. Covers both uppercase and lowercase versions. @@ -189,82 +295,9 @@ type SupportedCurrency = * * @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 = [ - // Ethereum Mainnet - '0x1', - // OP Mainnet - '0xa', - // Cronos Mainnet - '0x19', - // BNB Smart Chain Mainnet - '0x38', - // Syscoin Mainnet - '0x39', - // OKXChain Mainnet - '0x42', - // Hoo Smart Chain - '0x46', - // Meter Mainnet - '0x52', - // TomoChain - '0x58', - // Gnosis - '0x64', - // Velas EVM Mainnet - '0x6a', - // Fuse Mainnet - '0x7a', - // Huobi ECO Chain Mainnet - '0x80', - // Polygon Mainnet - '0x89', - // Fantom Opera - '0xfa', - // Boba Network - '0x120', - // KCC Mainnet - '0x141', - // zkSync Era Mainnet - '0x144', - // Theta Mainnet - '0x169', - // Metis Andromeda Mainnet - '0x440', - // Moonbeam - '0x504', - // Moonriver - '0x505', - // Mantle - '0x1388', - // Base - '0x2105', - // Shiden - '0x150', - // Smart Bitcoin Cash - '0x2710', - // Arbitrum One - '0xa4b1', - // Celo Mainnet - '0xa4ec', - // Oasis Emerald - '0xa516', - // Avalanche C-Chain - '0xa86a', - // Polis Mainnet - '0x518af', - // Aurora Mainnet - '0x4e454152', - // Harmony Mainnet Shard 0 - '0x63564c40', - // Linea Mainnet - '0xe708', - // Sei Mainnet - '0x531', - // Sonic Mainnet - '0x92', - // Monad Mainnet - '0x8f', -] as const; +export const SUPPORTED_CHAIN_IDS = Object.keys( + SPOT_PRICES_SUPPORT_INFO, +) as (keyof typeof SPOT_PRICES_SUPPORT_INFO)[]; /** * A chain ID that can be supplied in the URL for the `/spot-prices` endpoint, @@ -274,98 +307,28 @@ export const SUPPORTED_CHAIN_IDS = [ type SupportedChainId = (typeof SUPPORTED_CHAIN_IDS)[number]; /** - * All requests to V2 of the Price API start with this. + * The list of chain IDs that are supported by V3 of the Codefi Price API. + * Only includes chain IDs from SPOT_PRICES_SUPPORT_INFO that have a non-null CAIP-19 value. */ -const BASE_URL = 'https://price.api.cx.metamask.io/v2'; +const SUPPORTED_CHAIN_IDS_V3 = Object.keys(SPOT_PRICES_SUPPORT_INFO).filter( + (chainId) => + SPOT_PRICES_SUPPORT_INFO[ + chainId as keyof typeof SPOT_PRICES_SUPPORT_INFO + ] !== null, +); const BASE_URL_V1 = 'https://price.api.cx.metamask.io/v1'; -/** - * The shape of the data that the /spot-prices endpoint returns. - */ -type MarketData = { - /** - * The all-time highest price of the token. - */ - allTimeHigh: number; - /** - * The all-time lowest price of the token. - */ - allTimeLow: number; - /** - * The number of tokens currently in circulation. - */ - circulatingSupply: number; - /** - * The market cap calculated using the diluted supply. - */ - dilutedMarketCap: number; - /** - * The highest price of the token in the last 24 hours. - */ - high1d: number; - /** - * The lowest price of the token in the last 24 hours. - */ - low1d: number; - /** - * The current market capitalization of the token. - */ - marketCap: number; - /** - * The percentage change in market capitalization over the last 24 hours. - */ - marketCapPercentChange1d: number; - /** - * The current price of the token. - */ - price: number; - /** - * The absolute change in price over the last 24 hours. - */ - priceChange1d: number; - /** - * The percentage change in price over the last 24 hours. - */ - pricePercentChange1d: number; - /** - * The percentage change in price over the last hour. - */ - pricePercentChange1h: number; - /** - * The percentage change in price over the last year. - */ - pricePercentChange1y: number; - /** - * The percentage change in price over the last 7 days. - */ - pricePercentChange7d: number; - /** - * The percentage change in price over the last 14 days. - */ - pricePercentChange14d: number; - /** - * The percentage change in price over the last 30 days. - */ - pricePercentChange30d: number; - /** - * The percentage change in price over the last 200 days. - */ - pricePercentChange200d: number; - /** - * The total trading volume of the token in the last 24 hours. - */ - totalVolume: number; -}; +const BASE_URL_V2 = 'https://price.api.cx.metamask.io/v2'; + +const BASE_URL_V3 = 'https://price.api.cx.metamask.io/v3'; -type MarketDataByTokenAddress = { [address: Hex]: MarketData }; /** * This version of the token prices service uses V2 of the Codefi Price API to * fetch token prices. */ export class CodefiTokenPricesServiceV2 - implements - AbstractTokenPricesService + implements AbstractTokenPricesService { readonly #policy: ServicePolicy; @@ -474,64 +437,149 @@ export class CodefiTokenPricesServiceV2 * given addresses which are expected to live on the given chain. * * @param args - The arguments to function. - * @param args.chainId - An EIP-155 chain ID. - * @param args.tokenAddresses - Addresses for tokens that live on the chain. + * @param args.assets - The assets to get prices for. * @param args.currency - The desired currency of the token prices. * @returns The prices for the requested tokens. */ async fetchTokenPrices({ - chainId, - tokenAddresses, + assets, currency, }: { - chainId: SupportedChainId; - tokenAddresses: Hex[]; + assets: EvmAssetAddressWithChain[]; currency: SupportedCurrency; - }): Promise>> { - const chainIdAsNumber = hexToNumber(chainId); + }): Promise[]> { + const v3Assets = await this.#fetchTokenPricesV3(assets, currency); + const v2Assets = await this.#fetchTokenPricesV2(assets, currency); - const url = new URL(`${BASE_URL}/chains/${chainIdAsNumber}/spot-prices`); + return [...v3Assets, ...v2Assets]; + } + + async #fetchTokenPricesV3( + assets: EvmAssetAddressWithChain[], + currency: SupportedCurrency, + ): Promise[]> { + const assetsWithIds: EvmAssetWithId[] = assets + // Filter out assets that are not supported by V3 of the Price API. + .filter((asset) => SUPPORTED_CHAIN_IDS_V3.includes(asset.chainId)) + .map((asset) => { + const caipChainId = toCaipChainId( + KnownCaipNamespace.Eip155, + hexToNumber(asset.chainId).toString(), + ); + + const nativeAddress = getNativeTokenAddress(asset.chainId); + + return { + ...asset, + assetId: (nativeAddress.toLowerCase() === + asset.tokenAddress.toLowerCase() + ? SPOT_PRICES_SUPPORT_INFO[asset.chainId] + : `${caipChainId}/erc20:${asset.tokenAddress.toLowerCase()}`) as CaipAssetType, + }; + }) + .filter((asset) => asset.assetId); + + if (assetsWithIds.length === 0) { + return []; + } + + const url = new URL(`${BASE_URL_V3}/spot-prices`); url.searchParams.append( - 'tokenAddresses', - [getNativeTokenAddress(chainId), ...tokenAddresses].join(','), + 'assetIds', + assetsWithIds.map((asset) => asset.assetId).join(','), ); url.searchParams.append('vsCurrency', currency); url.searchParams.append('includeMarketData', 'true'); - const addressCryptoDataMap: MarketDataByTokenAddress = - await this.#policy.execute(() => - handleFetch(url, { headers: { 'Cache-Control': 'no-cache' } }), - ); - - return [getNativeTokenAddress(chainId), ...tokenAddresses].reduce( - ( - obj: Partial>, - tokenAddress, - ) => { - // The Price API lowercases both currency and token addresses, so we have - // to keep track of them and make sure we return the original versions. - const lowercasedTokenAddress = - tokenAddress.toLowerCase() as Lowercase; + const addressCryptoDataMap: { + [assetId: CaipAssetType]: Omit< + MarketDataDetails, + 'currency' | 'tokenAddress' + >; + } = await this.#policy.execute(() => + handleFetch(url, { headers: { 'Cache-Control': 'no-cache' } }), + ); - const marketData = addressCryptoDataMap[lowercasedTokenAddress]; + return assetsWithIds + .map((assetWithId) => { + const marketData = addressCryptoDataMap[assetWithId.assetId]; if (!marketData) { - return obj; + return undefined; } - const token: TokenPrice = { - tokenAddress, - currency, + return { ...marketData, + ...assetWithId, + currency, }; + }) + .filter((entry): entry is NonNullable => Boolean(entry)); + } - return { - ...obj, - [tokenAddress]: token, - }; + async #fetchTokenPricesV2( + assets: EvmAssetAddressWithChain[], + currency: SupportedCurrency, + ): Promise[]> { + const v2SupportedAssets = assets.filter( + (asset) => !SUPPORTED_CHAIN_IDS_V3.includes(asset.chainId), + ); + + const assetsByChainId: Record = + v2SupportedAssets.reduce( + (acc, { chainId, tokenAddress }) => { + (acc[chainId] ??= []).push(tokenAddress); + return acc; + }, + {} as Record, + ); + + const promises = Object.entries(assetsByChainId).map( + async ([chainId, tokenAddresses]) => { + if (tokenAddresses.length === 0) { + return []; + } + + const url = new URL(`${BASE_URL_V2}/chains/${chainId}/spot-prices`); + url.searchParams.append('tokenAddresses', tokenAddresses.join(',')); + url.searchParams.append('vsCurrency', currency); + url.searchParams.append('includeMarketData', 'true'); + + const addressCryptoDataMap: { + [tokenAddress: string]: Omit< + MarketDataDetails, + 'currency' | 'tokenAddress' + >; + } = await this.#policy.execute(() => + handleFetch(url, { headers: { 'Cache-Control': 'no-cache' } }), + ); + + return tokenAddresses + .map((tokenAddress) => { + const marketData = addressCryptoDataMap[tokenAddress.toLowerCase()]; + + if (!marketData) { + return undefined; + } + + return { + ...marketData, + tokenAddress, + chainId: chainId as SupportedChainId, + currency, + }; + }) + .filter((entry): entry is NonNullable => + Boolean(entry), + ); }, - {}, - ) as Partial>; + ); + + return await Promise.allSettled(promises).then((results) => + results.flatMap((result) => + result.status === 'fulfilled' ? result.value : [], + ), + ); } /**