diff --git a/packages/assets-controllers/src/TokenRatesController.test.ts b/packages/assets-controllers/src/TokenRatesController.test.ts index 78fcb2a051a..10d6122f6c7 100644 --- a/packages/assets-controllers/src/TokenRatesController.test.ts +++ b/packages/assets-controllers/src/TokenRatesController.test.ts @@ -1,3 +1,5 @@ +import type { AddApprovalRequest } from '@metamask/approval-controller'; +import { ControllerMessenger } from '@metamask/base-controller'; import { ChainId, InfuraNetworkType, @@ -6,19 +8,22 @@ import { toHex, } from '@metamask/controller-utils'; import type { - NetworkClientConfiguration, NetworkClientId, NetworkState, } from '@metamask/network-controller'; import { defaultState as defaultNetworkState } from '@metamask/network-controller'; -import type { PreferencesState } from '@metamask/preferences-controller'; +import type { NetworkClientConfiguration } from '@metamask/network-controller/src/types'; +import { + getDefaultPreferencesState, + type PreferencesState, +} from '@metamask/preferences-controller'; import type { Hex } from '@metamask/utils'; import { add0x } from '@metamask/utils'; import assert from 'assert'; import nock from 'nock'; import { useFakeTimers } from 'sinon'; -import { advanceTime, flushPromises } from '../../../tests/helpers'; +import { advanceTime } from '../../../tests/helpers'; import { buildCustomNetworkClientConfiguration, buildMockGetNetworkClientById, @@ -29,17 +34,50 @@ import type { TokenPrice, TokenPricesByTokenAddress, } from './token-prices-service/abstract-token-prices-service'; -import { TokenRatesController } from './TokenRatesController'; +import { controllerName, TokenRatesController } from './TokenRatesController'; import type { - TokenRatesConfig, + AllowedActions, + AllowedEvents, Token, - TokenRatesState, + TokenRatesControllerMessenger, } from './TokenRatesController'; +import { getDefaultTokensState } from './TokensController'; import type { TokensControllerState } from './TokensController'; const defaultSelectedAddress = '0x0000000000000000000000000000000000000001'; const mockTokenAddress = '0x0000000000000000000000000000000000000010'; +const defaultSelectedNetworkClientId = 'AAAA-BBBB-CCCC-DDDD'; + +type MainControllerMessenger = ControllerMessenger< + AllowedActions | AddApprovalRequest, + AllowedEvents +>; + +/** + * Builds a messenger that `TokenRatesController` can use to communicate with other controllers. + * @param controllerMessenger - The main controller messenger. + * @returns The restricted messenger. + */ +function buildTokenRatesControllerMessenger( + controllerMessenger: MainControllerMessenger = new ControllerMessenger(), +): TokenRatesControllerMessenger { + return controllerMessenger.getRestricted({ + name: controllerName, + allowedActions: [ + 'TokensController:getState', + 'NetworkController:getNetworkClientById', + 'NetworkController:getState', + 'PreferencesController:getState', + ], + allowedEvents: [ + 'PreferencesController:stateChange', + 'TokensController:stateChange', + 'NetworkController:stateChange', + ], + }); +} + describe('TokenRatesController', () => { afterEach(() => { jest.restoreAllMocks(); @@ -56,59 +94,28 @@ describe('TokenRatesController', () => { clock.restore(); }); - it('should set default state', () => { - const controller = new TokenRatesController({ - getNetworkClientById: jest.fn(), - chainId: '0x1', - ticker: NetworksTicker.mainnet, - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: jest.fn(), - onTokensStateChange: jest.fn(), - onNetworkStateChange: jest.fn(), - tokenPricesService: buildMockTokenPricesService(), - }); - expect(controller.state).toStrictEqual({ - marketData: {}, - }); - }); - - it('should initialize with the default config', () => { - const controller = new TokenRatesController({ - getNetworkClientById: jest.fn(), - chainId: '0x1', - ticker: NetworksTicker.mainnet, - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: jest.fn(), - onTokensStateChange: jest.fn(), - onNetworkStateChange: jest.fn(), - tokenPricesService: buildMockTokenPricesService(), - }); - expect(controller.config).toStrictEqual({ - interval: 180000, - threshold: 21600000, - allDetectedTokens: {}, - allTokens: {}, - disabled: false, - nativeCurrency: NetworksTicker.mainnet, - chainId: '0x1', - selectedAddress: defaultSelectedAddress, + it('should set default state', async () => { + await withController(async ({ controller }) => { + expect(controller.state).toStrictEqual({ + marketData: {}, + }); }); }); it('should not poll by default', async () => { const fetchSpy = jest.spyOn(globalThis, 'fetch'); - new TokenRatesController({ - interval: 100, - getNetworkClientById: jest.fn(), - chainId: '0x1', - ticker: NetworksTicker.mainnet, - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: jest.fn(), - onTokensStateChange: jest.fn(), - onNetworkStateChange: jest.fn(), - tokenPricesService: buildMockTokenPricesService(), - }); - + await withController( + { + options: { + interval: 100, + }, + }, + async ({ controller }) => { + expect(controller.state).toStrictEqual({ + marketData: {}, + }); + }, + ); await advanceTime({ clock, duration: 500 }); expect(fetchSpy).not.toHaveBeenCalled(); @@ -128,19 +135,13 @@ describe('TokenRatesController', () => { describe('when legacy polling is active', () => { it('should update exchange rates when any of the addresses in the "all tokens" collection change', async () => { - const chainId = '0xC'; - const selectedAddress = '0xA'; const tokenAddresses = ['0xE1', '0xE2']; await withController( { - options: { - chainId, - selectedAddress, - }, - config: { + mockTokensControllerState: { allTokens: { - [chainId]: { - [selectedAddress]: [ + [ChainId.mainnet]: { + [defaultSelectedAddress]: [ { address: tokenAddresses[0], decimals: 0, @@ -150,20 +151,18 @@ describe('TokenRatesController', () => { ], }, }, - allDetectedTokens: {}, }, }, - async ({ controller, controllerEvents }) => { + async ({ controller, triggerTokensStateChange }) => { const updateExchangeRatesSpy = jest .spyOn(controller, 'updateExchangeRates') .mockResolvedValue(); await controller.start(); - - // @ts-expect-error Intentionally incomplete state - controllerEvents.tokensStateChange({ + triggerTokensStateChange({ + ...getDefaultTokensState(), allTokens: { - [chainId]: { - [selectedAddress]: [ + [ChainId.mainnet]: { + [defaultSelectedAddress]: [ { address: tokenAddresses[1], decimals: 0, @@ -173,7 +172,6 @@ describe('TokenRatesController', () => { ], }, }, - allDetectedTokens: {}, }); // Once when starting, and another when tokens state changes @@ -183,20 +181,13 @@ describe('TokenRatesController', () => { }); it('should update exchange rates when any of the addresses in the "all detected tokens" collection change', async () => { - const chainId = '0xC'; - const selectedAddress = '0xA'; const tokenAddresses = ['0xE1', '0xE2']; await withController( { - options: { - chainId, - selectedAddress, - }, - config: { - allTokens: {}, + mockTokensControllerState: { allDetectedTokens: { - [chainId]: { - [selectedAddress]: [ + [ChainId.mainnet]: { + [defaultSelectedAddress]: [ { address: tokenAddresses[0], decimals: 0, @@ -208,18 +199,16 @@ describe('TokenRatesController', () => { }, }, }, - async ({ controller, controllerEvents }) => { + async ({ controller, triggerTokensStateChange }) => { const updateExchangeRatesSpy = jest .spyOn(controller, 'updateExchangeRates') .mockResolvedValue(); await controller.start(); - - // @ts-expect-error Intentionally incomplete state - controllerEvents.tokensStateChange({ - allTokens: {}, + triggerTokensStateChange({ + ...getDefaultTokensState(), allDetectedTokens: { - [chainId]: { - [selectedAddress]: [ + [ChainId.mainnet]: { + [defaultSelectedAddress]: [ { address: tokenAddresses[1], decimals: 0, @@ -230,7 +219,6 @@ describe('TokenRatesController', () => { }, }, }); - // Once when starting, and another when tokens state changes expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(2); }, @@ -238,12 +226,10 @@ describe('TokenRatesController', () => { }); it('should not update exchange rates if both the "all tokens" or "all detected tokens" are exactly the same', async () => { - const chainId = '0xC'; - const selectedAddress = '0xA'; const tokensState = { allTokens: { - [chainId]: { - [selectedAddress]: [ + [ChainId.mainnet]: { + [defaultSelectedAddress]: [ { address: mockTokenAddress, decimals: 0, @@ -253,24 +239,22 @@ describe('TokenRatesController', () => { ], }, }, - allDetectedTokens: {}, }; await withController( { - options: { - chainId, - selectedAddress, + mockTokensControllerState: { + ...tokensState, }, - config: tokensState, }, - async ({ controller, controllerEvents }) => { + async ({ controller, triggerTokensStateChange }) => { const updateExchangeRatesSpy = jest .spyOn(controller, 'updateExchangeRates') .mockResolvedValue(); await controller.start(); - - // @ts-expect-error Intentionally incomplete state - controllerEvents.tokensStateChange(tokensState); + triggerTokensStateChange({ + ...getDefaultTokensState(), + ...tokensState, + }); // Once when starting, and that's it expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(1); @@ -279,11 +263,9 @@ describe('TokenRatesController', () => { }); it('should not update exchange rates if all of the tokens in "all tokens" just move to "all detected tokens"', async () => { - const chainId = '0xC'; - const selectedAddress = '0xA'; const tokens = { - [chainId]: { - [selectedAddress]: [ + [ChainId.mainnet]: { + [defaultSelectedAddress]: [ { address: mockTokenAddress, decimals: 0, @@ -295,24 +277,17 @@ describe('TokenRatesController', () => { }; await withController( { - options: { - chainId, - selectedAddress, - }, - config: { + mockTokensControllerState: { allTokens: tokens, - allDetectedTokens: {}, }, }, - async ({ controller, controllerEvents }) => { + async ({ controller, triggerTokensStateChange }) => { const updateExchangeRatesSpy = jest .spyOn(controller, 'updateExchangeRates') .mockResolvedValue(); await controller.start(); - - // @ts-expect-error Intentionally incomplete state - controllerEvents.tokensStateChange({ - allTokens: {}, + triggerTokensStateChange({ + ...getDefaultTokensState(), allDetectedTokens: tokens, }); @@ -323,62 +298,33 @@ describe('TokenRatesController', () => { }); 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 chainId = '0xC'; - const selectedAddress = '0xA'; + const tokens = { + [ChainId.mainnet]: { + [defaultSelectedAddress]: [ + { + address: mockTokenAddress, + decimals: 0, + symbol: '', + aggregators: [], + }, + ], + }, + }; await withController( { - options: { - chainId, - selectedAddress, - }, - config: { - allTokens: { - [chainId]: { - [selectedAddress]: [ - { - address: mockTokenAddress, - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, - allDetectedTokens: {}, + mockTokensControllerState: { + allTokens: tokens, }, }, - async ({ controller, controllerEvents }) => { + async ({ controller, triggerTokensStateChange }) => { const updateExchangeRatesSpy = jest .spyOn(controller, 'updateExchangeRates') .mockResolvedValue(); await controller.start(); - - // @ts-expect-error Intentionally incomplete state - controllerEvents.tokensStateChange({ - allTokens: { - [chainId]: { - [selectedAddress]: [ - { - address: mockTokenAddress, - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, - allDetectedTokens: { - [chainId]: { - [selectedAddress]: [ - { - address: mockTokenAddress, - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, + triggerTokensStateChange({ + ...getDefaultTokensState(), + allTokens: tokens, + allDetectedTokens: tokens, }); // Once when starting, and that's it @@ -388,62 +334,33 @@ describe('TokenRatesController', () => { }); 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 chainId = '0xC'; - const selectedAddress = '0xA'; + const tokens = { + [ChainId.mainnet]: { + [defaultSelectedAddress]: [ + { + address: mockTokenAddress, + decimals: 0, + symbol: '', + aggregators: [], + }, + ], + }, + }; await withController( { - options: { - chainId, - selectedAddress, - }, - config: { - allTokens: {}, - allDetectedTokens: { - [chainId]: { - [selectedAddress]: [ - { - address: mockTokenAddress, - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, + mockTokensControllerState: { + allDetectedTokens: tokens, }, }, - async ({ controller, controllerEvents }) => { + async ({ controller, triggerTokensStateChange }) => { const updateExchangeRatesSpy = jest .spyOn(controller, 'updateExchangeRates') .mockResolvedValue(); await controller.start(); - - // @ts-expect-error Intentionally incomplete state - controllerEvents.tokensStateChange({ - allTokens: { - [chainId]: { - [selectedAddress]: [ - { - address: mockTokenAddress, - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, - allDetectedTokens: { - [chainId]: { - [selectedAddress]: [ - { - address: mockTokenAddress, - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, - }, + triggerTokensStateChange({ + ...getDefaultTokensState(), + allTokens: tokens, + allDetectedTokens: tokens, }); // Once when starting, and that's it @@ -453,19 +370,13 @@ describe('TokenRatesController', () => { }); 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 () => { - const chainId = '0xC'; - const selectedAddress = '0xA'; await withController( { - options: { - chainId, - selectedAddress, - }, - config: { - allTokens: {}, + mockTokensControllerState: { + ...getDefaultTokensState(), allDetectedTokens: { - [chainId]: { - [selectedAddress]: [ + [ChainId.mainnet]: { + [defaultSelectedAddress]: [ { address: mockTokenAddress, decimals: 3, @@ -477,18 +388,16 @@ describe('TokenRatesController', () => { }, }, }, - async ({ controller, controllerEvents }) => { + async ({ controller, triggerTokensStateChange }) => { const updateExchangeRatesSpy = jest .spyOn(controller, 'updateExchangeRates') .mockResolvedValue(); await controller.start(); - - // @ts-expect-error Intentionally incomplete state - controllerEvents.tokensStateChange({ - allTokens: {}, + triggerTokensStateChange({ + ...getDefaultTokensState(), allDetectedTokens: { - [chainId]: { - [selectedAddress]: [ + [ChainId.mainnet]: { + [defaultSelectedAddress]: [ { address: mockTokenAddress, decimals: 7, @@ -507,19 +416,12 @@ describe('TokenRatesController', () => { }); 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 () => { - const chainId = '0xC'; - const selectedAddress = '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; await withController( { - options: { - chainId, - selectedAddress, - }, - config: { - allTokens: {}, + mockTokensControllerState: { allDetectedTokens: { - [chainId]: { - [selectedAddress]: [ + [ChainId.mainnet]: { + [defaultSelectedAddress]: [ { address: '0x0EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE2', decimals: 3, @@ -531,18 +433,16 @@ describe('TokenRatesController', () => { }, }, }, - async ({ controller, controllerEvents }) => { + async ({ controller, triggerTokensStateChange }) => { const updateExchangeRatesSpy = jest .spyOn(controller, 'updateExchangeRates') .mockResolvedValue(); await controller.start(); - - // @ts-expect-error Intentionally incomplete state - controllerEvents.tokensStateChange({ - allTokens: {}, + triggerTokensStateChange({ + ...getDefaultTokensState(), allDetectedTokens: { - [chainId]: { - [selectedAddress]: [ + [ChainId.mainnet]: { + [defaultSelectedAddress]: [ { address: '0x0eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee2', decimals: 7, @@ -561,19 +461,12 @@ describe('TokenRatesController', () => { }); it('should not update exchange rates if any of the addresses in "all tokens" or "all detected tokens" merely change order', async () => { - const chainId = '0xC'; - const selectedAddress = '0xA'; await withController( { - options: { - chainId, - selectedAddress, - }, - config: { - allTokens: {}, + mockTokensControllerState: { allDetectedTokens: { - [chainId]: { - [selectedAddress]: [ + [ChainId.mainnet]: { + [defaultSelectedAddress]: [ { address: '0xE1', decimals: 0, @@ -591,18 +484,16 @@ describe('TokenRatesController', () => { }, }, }, - async ({ controller, controllerEvents }) => { + async ({ controller, triggerTokensStateChange }) => { const updateExchangeRatesSpy = jest .spyOn(controller, 'updateExchangeRates') .mockResolvedValue(); await controller.start(); - - // @ts-expect-error Intentionally incomplete state - controllerEvents.tokensStateChange({ - allTokens: {}, + triggerTokensStateChange({ + ...getDefaultTokensState(), allDetectedTokens: { - [chainId]: { - [selectedAddress]: [ + [ChainId.mainnet]: { + [defaultSelectedAddress]: [ { address: '0xE2', decimals: 0, @@ -629,19 +520,13 @@ describe('TokenRatesController', () => { 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 chainId = '0xC'; - const selectedAddress = '0xA'; const tokenAddresses = ['0xE1', '0xE2']; await withController( { - options: { - chainId, - selectedAddress, - }, - config: { + mockTokensControllerState: { allTokens: { - [chainId]: { - [selectedAddress]: [ + [ChainId.mainnet]: { + [defaultSelectedAddress]: [ { address: tokenAddresses[0], decimals: 0, @@ -651,19 +536,17 @@ describe('TokenRatesController', () => { ], }, }, - allDetectedTokens: {}, }, }, - async ({ controller, controllerEvents }) => { + async ({ controller, triggerTokensStateChange }) => { const updateExchangeRatesSpy = jest .spyOn(controller, 'updateExchangeRates') .mockResolvedValue(); - - // @ts-expect-error Intentionally incomplete state - controllerEvents.tokensStateChange({ + triggerTokensStateChange({ + ...getDefaultTokensState(), allTokens: { - [chainId]: { - [selectedAddress]: [ + [ChainId.mainnet]: { + [defaultSelectedAddress]: [ { address: tokenAddresses[1], decimals: 0, @@ -673,7 +556,6 @@ describe('TokenRatesController', () => { ], }, }, - allDetectedTokens: {}, }); expect(updateExchangeRatesSpy).not.toHaveBeenCalled(); @@ -682,20 +564,13 @@ describe('TokenRatesController', () => { }); it('should not update exchange rates when any of the addresses in the "all detected tokens" collection change', async () => { - const chainId = '0xC'; - const selectedAddress = '0xA'; const tokenAddresses = ['0xE1', '0xE2']; await withController( { - options: { - chainId, - selectedAddress, - }, - config: { - allTokens: {}, + mockTokensControllerState: { allDetectedTokens: { - [chainId]: { - [selectedAddress]: [ + [ChainId.mainnet]: { + [defaultSelectedAddress]: [ { address: tokenAddresses[0], decimals: 0, @@ -707,17 +582,15 @@ describe('TokenRatesController', () => { }, }, }, - async ({ controller, controllerEvents }) => { + async ({ controller, triggerTokensStateChange }) => { const updateExchangeRatesSpy = jest .spyOn(controller, 'updateExchangeRates') .mockResolvedValue(); - - // @ts-expect-error Intentionally incomplete state - controllerEvents.tokensStateChange({ - allTokens: {}, + triggerTokensStateChange({ + ...getDefaultTokensState(), allDetectedTokens: { - [chainId]: { - [selectedAddress]: [ + [ChainId.mainnet]: { + [defaultSelectedAddress]: [ { address: tokenAddresses[1], decimals: 0, @@ -749,335 +622,362 @@ describe('TokenRatesController', () => { describe('when polling is active', () => { it('should update exchange rates when ticker changes', async () => { - const getNetworkClientById = buildMockGetNetworkClientById({ - 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ - chainId: toHex(1337), - ticker: 'NEW', - }), - }); - let networkStateChangeListener: (state: NetworkState) => Promise; - const onNetworkStateChange = jest - .fn() - .mockImplementation((listener) => { - networkStateChangeListener = listener; - }); - const controller = new TokenRatesController({ - interval: 100, - getNetworkClientById, - chainId: toHex(1337), - ticker: 'TEST', - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: jest.fn(), - onTokensStateChange: jest.fn(), - onNetworkStateChange, - tokenPricesService: buildMockTokenPricesService(), - }); - await controller.start(); - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await networkStateChangeListener!({ - ...defaultNetworkState, - selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', - }); + await withController( + { + options: { + interval: 100, + }, + mockNetworkClientConfigurationsByNetworkClientId: { + 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ + chainId: toHex(1337), + ticker: 'NEW', + }), + }, + }, + async ({ controller, triggerNetworkStateChange }) => { + await controller.start(); + const updateExchangeRatesSpy = jest + .spyOn(controller, 'updateExchangeRates') + .mockResolvedValue(); + triggerNetworkStateChange({ + ...defaultNetworkState, + providerConfig: { + ...defaultNetworkState.providerConfig, + chainId: ChainId.mainnet, + ticker: 'NEW', + }, + selectedNetworkClientId: defaultSelectedNetworkClientId, + }); - expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(1); + expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(1); + }, + ); }); it('should update exchange rates when chain ID changes', async () => { - const getNetworkClientById = buildMockGetNetworkClientById({ - 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ - chainId: toHex(1338), - ticker: 'TEST', - }), - }); - let networkStateChangeListener: (state: NetworkState) => Promise; - const onNetworkStateChange = jest - .fn() - .mockImplementation((listener) => { - networkStateChangeListener = listener; - }); - const controller = new TokenRatesController({ - interval: 100, - getNetworkClientById, - chainId: toHex(1337), - ticker: 'TEST', - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: jest.fn(), - onTokensStateChange: jest.fn(), - onNetworkStateChange, - tokenPricesService: buildMockTokenPricesService(), - }); - await controller.start(); - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await networkStateChangeListener!({ - ...defaultNetworkState, - selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', - }); + await withController( + { + options: { + interval: 100, + }, + mockNetworkClientConfigurationsByNetworkClientId: { + 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ + chainId: toHex(1338), + ticker: 'TEST', + }), + }, + }, + async ({ controller, triggerNetworkStateChange }) => { + await controller.start(); + const updateExchangeRatesSpy = jest + .spyOn(controller, 'updateExchangeRates') + .mockResolvedValue(); + triggerNetworkStateChange({ + ...defaultNetworkState, + selectedNetworkClientId: defaultSelectedNetworkClientId, + }); - expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(1); + expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(1); + }, + ); }); - it('should clear contractExchangeRates state when ticker changes', async () => { - const getNetworkClientById = buildMockGetNetworkClientById({ - 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ - chainId: toHex(1337), - ticker: 'NEW', - }), - }); - let networkStateChangeListener: (state: NetworkState) => Promise; - const onNetworkStateChange = jest - .fn() - .mockImplementation((listener) => { - networkStateChangeListener = listener; - }); - const controller = new TokenRatesController({ - interval: 100, - getNetworkClientById, - chainId: toHex(1337), - ticker: 'TEST', - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: jest.fn(), - onTokensStateChange: jest.fn(), - onNetworkStateChange, - tokenPricesService: buildMockTokenPricesService(), - }); - await controller.start(); - jest.spyOn(controller, 'updateExchangeRates').mockResolvedValue(); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await networkStateChangeListener!({ - ...defaultNetworkState, - selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', - }); + 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, + }, + }, + }, + }, + }, + mockNetworkClientConfigurationsByNetworkClientId: { + 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ + chainId: toHex(1337), + ticker: 'NEW', + }), + }, + }, + async ({ controller, triggerNetworkStateChange }) => { + await controller.start(); + jest.spyOn(controller, 'updateExchangeRates').mockResolvedValue(); + triggerNetworkStateChange({ + ...defaultNetworkState, + selectedNetworkClientId: defaultSelectedNetworkClientId, + }); - expect(controller.state.marketData).toStrictEqual({}); + expect(controller.state.marketData).toStrictEqual({}); + }, + ); }); - it('should clear contractExchangeRates state when chain ID changes', async () => { - const getNetworkClientById = buildMockGetNetworkClientById({ - 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ - chainId: toHex(1338), - ticker: 'TEST', - }), - }); - let networkStateChangeListener: (state: NetworkState) => Promise; - const onNetworkStateChange = jest - .fn() - .mockImplementation((listener) => { - networkStateChangeListener = listener; - }); - const controller = new TokenRatesController({ - interval: 100, - getNetworkClientById, - chainId: toHex(1337), - ticker: 'TEST', - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: jest.fn(), - onTokensStateChange: jest.fn(), - onNetworkStateChange, - tokenPricesService: buildMockTokenPricesService(), - }); - await controller.start(); - jest.spyOn(controller, 'updateExchangeRates').mockResolvedValue(); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await networkStateChangeListener!({ - ...defaultNetworkState, - selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', - }); + it('should 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, + }, + }, + }, + }, + }, + mockNetworkClientConfigurationsByNetworkClientId: { + 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ + chainId: toHex(1338), + ticker: 'TEST', + }), + }, + }, + async ({ controller, triggerNetworkStateChange }) => { + await controller.start(); + jest.spyOn(controller, 'updateExchangeRates').mockResolvedValue(); + triggerNetworkStateChange({ + ...defaultNetworkState, + selectedNetworkClientId: defaultSelectedNetworkClientId, + }); - expect(controller.state.marketData).toStrictEqual({}); + expect(controller.state.marketData).toStrictEqual({}); + }, + ); }); it('should not update exchange rates when network state changes without a ticker/chain id change', async () => { - const getNetworkClientById = buildMockGetNetworkClientById({ - 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ - chainId: toHex(1337), - ticker: 'TEST', - }), - }); - let networkStateChangeListener: (state: NetworkState) => Promise; - const onNetworkStateChange = jest - .fn() - .mockImplementation((listener) => { - networkStateChangeListener = listener; - }); - const controller = new TokenRatesController({ - interval: 100, - getNetworkClientById, - chainId: toHex(1337), - ticker: 'TEST', - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: jest.fn(), - onTokensStateChange: jest.fn(), - onNetworkStateChange, - tokenPricesService: buildMockTokenPricesService(), - }); - await controller.start(); - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await networkStateChangeListener!({ - ...defaultNetworkState, - selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', - }); + await withController( + { + options: { + interval: 100, + }, + mockNetworkClientConfigurationsByNetworkClientId: { + 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ + chainId: ChainId.mainnet, + ticker: NetworksTicker.mainnet, + }), + }, + }, + async ({ controller, triggerNetworkStateChange }) => { + await controller.start(); + const updateExchangeRatesSpy = jest + .spyOn(controller, 'updateExchangeRates') + .mockResolvedValue(); + triggerNetworkStateChange({ + ...defaultNetworkState, + selectedNetworkClientId: defaultSelectedNetworkClientId, + }); - expect(updateExchangeRatesSpy).not.toHaveBeenCalled(); + expect(updateExchangeRatesSpy).not.toHaveBeenCalled(); + }, + ); }); }); describe('when polling is inactive', () => { it('should not update exchange rates when ticker changes', async () => { - const getNetworkClientById = buildMockGetNetworkClientById({ - 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ - chainId: toHex(1337), - ticker: 'NEW', - }), - }); - let networkStateChangeListener: (state: NetworkState) => Promise; - const onNetworkStateChange = jest - .fn() - .mockImplementation((listener) => { - networkStateChangeListener = listener; - }); - const controller = new TokenRatesController({ - interval: 100, - getNetworkClientById, - chainId: toHex(1337), - ticker: 'TEST', - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: jest.fn(), - onTokensStateChange: jest.fn(), - onNetworkStateChange, - tokenPricesService: buildMockTokenPricesService(), - }); - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await networkStateChangeListener!({ - ...defaultNetworkState, - selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', - }); + 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({ + ...defaultNetworkState, + selectedNetworkClientId: defaultSelectedNetworkClientId, + }); - expect(updateExchangeRatesSpy).not.toHaveBeenCalled(); + expect(updateExchangeRatesSpy).not.toHaveBeenCalled(); + }, + ); }); it('should not update exchange rates when chain ID changes', async () => { - const getNetworkClientById = buildMockGetNetworkClientById({ - 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ - chainId: toHex(1338), - ticker: 'TEST', - }), - }); - let networkStateChangeListener: (state: NetworkState) => Promise; - const onNetworkStateChange = jest - .fn() - .mockImplementation((listener) => { - networkStateChangeListener = listener; - }); - const controller = new TokenRatesController({ - interval: 100, - getNetworkClientById, - chainId: toHex(1337), - ticker: 'TEST', - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: jest.fn(), - onTokensStateChange: jest.fn(), - onNetworkStateChange, - tokenPricesService: buildMockTokenPricesService(), - }); - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await networkStateChangeListener!({ - ...defaultNetworkState, - selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', - }); + await withController( + { + options: { + interval: 100, + }, + mockNetworkClientConfigurationsByNetworkClientId: { + 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ + chainId: toHex(1338), + ticker: 'TEST', + }), + }, + }, + async ({ controller, triggerNetworkStateChange }) => { + const updateExchangeRatesSpy = jest + .spyOn(controller, 'updateExchangeRates') + .mockResolvedValue(); + triggerNetworkStateChange({ + ...defaultNetworkState, + selectedNetworkClientId: defaultSelectedNetworkClientId, + }); - expect(updateExchangeRatesSpy).not.toHaveBeenCalled(); + expect(updateExchangeRatesSpy).not.toHaveBeenCalled(); + }, + ); }); - it('should clear contractExchangeRates state when ticker changes', async () => { - const getNetworkClientById = buildMockGetNetworkClientById({ - 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ - chainId: toHex(1337), - ticker: 'NEW', - }), - }); - let networkStateChangeListener: (state: NetworkState) => Promise; - const onNetworkStateChange = jest - .fn() - .mockImplementation((listener) => { - networkStateChangeListener = listener; - }); - const controller = new TokenRatesController({ - interval: 100, - getNetworkClientById, - chainId: toHex(1337), - ticker: 'TEST', - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: jest.fn(), - onTokensStateChange: jest.fn(), - onNetworkStateChange, - tokenPricesService: buildMockTokenPricesService(), - }); - jest.spyOn(controller, 'updateExchangeRates').mockResolvedValue(); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await networkStateChangeListener!({ - ...defaultNetworkState, - selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', - }); + it('should 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, + }, + }, + }, + }, + }, + mockNetworkClientConfigurationsByNetworkClientId: { + 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ + chainId: toHex(1337), + ticker: 'NEW', + }), + }, + }, + async ({ controller, triggerNetworkStateChange }) => { + jest.spyOn(controller, 'updateExchangeRates').mockResolvedValue(); + triggerNetworkStateChange({ + ...defaultNetworkState, + selectedNetworkClientId: defaultSelectedNetworkClientId, + }); - expect(controller.state.marketData).toStrictEqual({}); + expect(controller.state.marketData).toStrictEqual({}); + }, + ); }); - it('should clear contractExchangeRates state when chain ID changes', async () => { - const getNetworkClientById = buildMockGetNetworkClientById({ - 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ - chainId: toHex(1338), - ticker: 'TEST', - }), - }); - let networkStateChangeListener: (state: NetworkState) => Promise; - const onNetworkStateChange = jest - .fn() - .mockImplementation((listener) => { - networkStateChangeListener = listener; - }); - const controller = new TokenRatesController({ - interval: 100, - getNetworkClientById, - chainId: toHex(1337), - ticker: 'TEST', - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: jest.fn(), - onTokensStateChange: jest.fn(), - onNetworkStateChange, - tokenPricesService: buildMockTokenPricesService(), - }); - jest.spyOn(controller, 'updateExchangeRates').mockResolvedValue(); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await networkStateChangeListener!({ - ...defaultNetworkState, - selectedNetworkClientId: 'AAAA-BBBB-CCCC-DDDD', - }); + it('should 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, + }, + }, + }, + }, + }, + mockNetworkClientConfigurationsByNetworkClientId: { + 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ + chainId: toHex(1338), + ticker: 'TEST', + }), + }, + }, + async ({ controller, triggerNetworkStateChange }) => { + jest.spyOn(controller, 'updateExchangeRates').mockResolvedValue(); + triggerNetworkStateChange({ + ...defaultNetworkState, + selectedNetworkClientId: defaultSelectedNetworkClientId, + }); - expect(controller.state.marketData).toStrictEqual({}); + expect(controller.state.marketData).toStrictEqual({}); + }, + ); }); }); }); @@ -1095,144 +995,135 @@ describe('TokenRatesController', () => { describe('when polling is active', () => { it('should update exchange rates when selected address changes', async () => { - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let preferencesStateChangeListener: (state: any) => Promise; - const onPreferencesStateChange = jest - .fn() - .mockImplementation((listener) => { - preferencesStateChangeListener = listener; - }); const alternateSelectedAddress = '0x0000000000000000000000000000000000000002'; - const controller = new TokenRatesController( - { - interval: 100, - getNetworkClientById: jest.fn(), - chainId: '0x1', - ticker: NetworksTicker.mainnet, - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange, - onTokensStateChange: jest.fn(), - onNetworkStateChange: jest.fn(), - tokenPricesService: buildMockTokenPricesService(), - }, + await withController( { - allTokens: { - '0x1': { - [alternateSelectedAddress]: [ - { address: '0x02', decimals: 0, symbol: '', aggregators: [] }, - { address: '0x03', decimals: 0, symbol: '', aggregators: [] }, - ], + options: { + interval: 100, + }, + mockTokensControllerState: { + allTokens: { + '0x1': { + [alternateSelectedAddress]: [ + { + address: '0x02', + decimals: 0, + symbol: '', + aggregators: [], + }, + { + address: '0x03', + decimals: 0, + symbol: '', + aggregators: [], + }, + ], + }, }, }, }, - ); - await controller.start(); - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await preferencesStateChangeListener!({ - selectedAddress: alternateSelectedAddress, - }); + async ({ controller, triggerPreferencesStateChange }) => { + await controller.start(); + const updateExchangeRatesSpy = jest + .spyOn(controller, 'updateExchangeRates') + .mockResolvedValue(); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + selectedAddress: alternateSelectedAddress, + }); - expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(1); + expect(updateExchangeRatesSpy).toHaveBeenCalledTimes(1); + }, + ); }); it('should not update exchange rates when preferences state changes without selected address changing', async () => { - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let preferencesStateChangeListener: (state: any) => Promise; - const onPreferencesStateChange = jest - .fn() - .mockImplementation((listener) => { - preferencesStateChangeListener = listener; - }); - const controller = new TokenRatesController( - { - interval: 100, - getNetworkClientById: jest.fn(), - chainId: '0x1', - ticker: NetworksTicker.mainnet, - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange, - onTokensStateChange: jest.fn(), - onNetworkStateChange: jest.fn(), - tokenPricesService: buildMockTokenPricesService(), - }, + await withController( { - allTokens: { - '0x1': { - [defaultSelectedAddress]: [ - { address: '0x02', decimals: 0, symbol: '', aggregators: [] }, - { address: '0x03', decimals: 0, symbol: '', aggregators: [] }, - ], + options: { + interval: 100, + }, + mockTokensControllerState: { + allTokens: { + '0x1': { + [defaultSelectedAddress]: [ + { + address: '0x02', + decimals: 0, + symbol: '', + aggregators: [], + }, + { + address: '0x03', + decimals: 0, + symbol: '', + aggregators: [], + }, + ], + }, }, }, }, - ); - await controller.start(); - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await preferencesStateChangeListener!({ - selectedAddress: defaultSelectedAddress, - exampleConfig: 'exampleValue', - }); + async ({ controller, triggerPreferencesStateChange }) => { + await controller.start(); + const updateExchangeRatesSpy = jest + .spyOn(controller, 'updateExchangeRates') + .mockResolvedValue(); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + selectedAddress: defaultSelectedAddress, + openSeaEnabled: false, + }); - expect(updateExchangeRatesSpy).not.toHaveBeenCalled(); + expect(updateExchangeRatesSpy).not.toHaveBeenCalled(); + }, + ); }); }); describe('when polling is inactive', () => { it('should not update exchange rates when selected address changes', async () => { - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let preferencesStateChangeListener: (state: any) => Promise; - const onPreferencesStateChange = jest - .fn() - .mockImplementation((listener) => { - preferencesStateChangeListener = listener; - }); const alternateSelectedAddress = '0x0000000000000000000000000000000000000002'; - const controller = new TokenRatesController( - { - interval: 100, - getNetworkClientById: jest.fn(), - chainId: '0x1', - ticker: NetworksTicker.mainnet, - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange, - onTokensStateChange: jest.fn(), - onNetworkStateChange: jest.fn(), - tokenPricesService: buildMockTokenPricesService(), - }, + await withController( { - allTokens: { - '0x1': { - [alternateSelectedAddress]: [ - { address: '0x02', decimals: 0, symbol: '', aggregators: [] }, - { address: '0x03', decimals: 0, symbol: '', aggregators: [] }, - ], + options: { + interval: 100, + }, + mockTokensControllerState: { + allTokens: { + '0x1': { + [alternateSelectedAddress]: [ + { + address: '0x02', + decimals: 0, + symbol: '', + aggregators: [], + }, + { + address: '0x03', + decimals: 0, + symbol: '', + aggregators: [], + }, + ], + }, }, }, }, - ); - const updateExchangeRatesSpy = jest - .spyOn(controller, 'updateExchangeRates') - .mockResolvedValue(); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await preferencesStateChangeListener!({ - selectedAddress: alternateSelectedAddress, - }); + async ({ controller, triggerPreferencesStateChange }) => { + const updateExchangeRatesSpy = jest + .spyOn(controller, 'updateExchangeRates') + .mockResolvedValue(); + triggerPreferencesStateChange({ + ...getDefaultPreferencesState(), + selectedAddress: alternateSelectedAddress, + }); - expect(updateExchangeRatesSpy).not.toHaveBeenCalled(); + expect(updateExchangeRatesSpy).not.toHaveBeenCalled(); + }, + ); }); }); }); @@ -1253,43 +1144,46 @@ describe('TokenRatesController', () => { const interval = 100; const tokenPricesService = buildMockTokenPricesService(); jest.spyOn(tokenPricesService, 'fetchTokenPrices'); - const controller = new TokenRatesController( - { - interval, - getNetworkClientById: jest.fn(), - chainId: '0x1', - ticker: NetworksTicker.mainnet, - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: jest.fn(), - onTokensStateChange: jest.fn(), - onNetworkStateChange: jest.fn(), - tokenPricesService, - }, + await withController( { - allTokens: { - '0x1': { - [defaultSelectedAddress]: [ - { - address: mockTokenAddress, - decimals: 0, - symbol: '', - aggregators: [], - }, - ], - }, + options: { + interval, + tokenPricesService, }, - }, - ); - - await controller.start(); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(1); - - await advanceTime({ clock, duration: interval }); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(2); - - await advanceTime({ clock, duration: interval }); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(3); - }); + mockTokensControllerState: { + allTokens: { + [ChainId.mainnet]: { + [defaultSelectedAddress]: [ + { + address: mockTokenAddress, + decimals: 0, + symbol: '', + aggregators: [], + }, + ], + }, + }, + }, + }, + async ({ controller }) => { + await controller.start(); + + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes( + 1, + ); + + await advanceTime({ clock, duration: interval }); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes( + 2, + ); + + await advanceTime({ clock, duration: interval }); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes( + 3, + ); + }, + ); + }); }); describe('stop', () => { @@ -1297,41 +1191,42 @@ describe('TokenRatesController', () => { const interval = 100; const tokenPricesService = buildMockTokenPricesService(); jest.spyOn(tokenPricesService, 'fetchTokenPrices'); - const controller = new TokenRatesController( - { - interval, - getNetworkClientById: jest.fn(), - chainId: '0x1', - ticker: NetworksTicker.mainnet, - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: jest.fn(), - onTokensStateChange: jest.fn(), - onNetworkStateChange: jest.fn(), - tokenPricesService, - }, + await withController( { - allTokens: { - '0x1': { - [defaultSelectedAddress]: [ - { - address: mockTokenAddress, - decimals: 0, - symbol: '', - aggregators: [], - }, - ], + options: { + interval, + tokenPricesService, + }, + mockTokensControllerState: { + allTokens: { + [ChainId.mainnet]: { + [defaultSelectedAddress]: [ + { + address: mockTokenAddress, + decimals: 0, + symbol: '', + aggregators: [], + }, + ], + }, }, }, }, - ); + async ({ controller }) => { + await controller.start(); - await controller.start(); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(1); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes( + 1, + ); - controller.stop(); + controller.stop(); - await advanceTime({ clock, duration: interval }); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(1); + await advanceTime({ clock, duration: interval }); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes( + 1, + ); + }, + ); }); }); }); @@ -1351,48 +1246,40 @@ describe('TokenRatesController', () => { const interval = 100; const tokenPricesService = buildMockTokenPricesService(); jest.spyOn(tokenPricesService, 'fetchTokenPrices'); - const controller = new TokenRatesController( - { - interval, - chainId: '0x2', - ticker: 'ticker', - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: jest.fn(), - onTokensStateChange: jest.fn(), - onNetworkStateChange: jest.fn(), - getNetworkClientById: jest.fn().mockReturnValue({ - configuration: { - chainId: '0x1', - ticker: NetworksTicker.mainnet, - }, - }), - tokenPricesService, - }, + await withController( { - allTokens: { - '0x1': { - [defaultSelectedAddress]: [ - { - address: mockTokenAddress, - decimals: 0, - symbol: '', - aggregators: [], - }, - ], + options: { + interval, + tokenPricesService, + }, + mockTokensControllerState: { + allTokens: { + [ChainId.mainnet]: { + [defaultSelectedAddress]: [ + { + address: mockTokenAddress, + decimals: 0, + symbol: '', + aggregators: [], + }, + ], + }, }, }, }, - ); + async ({ controller }) => { + controller.startPollingByNetworkClientId('mainnet'); - controller.startPollingByNetworkClientId('mainnet'); - await advanceTime({ clock, duration: 0 }); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(1); + await advanceTime({ clock, duration: 0 }); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(1); - await advanceTime({ clock, duration: interval }); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(2); + await advanceTime({ clock, duration: interval }); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(2); - await advanceTime({ clock, duration: interval }); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(3); + await advanceTime({ clock, duration: interval }); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(3); + }, + ); }); describe('updating state on poll', () => { @@ -1404,139 +1291,276 @@ describe('TokenRatesController', () => { return currency === 'ETH'; }, }); - const controller = new TokenRatesController( + const interval = 100; + await withController( { - chainId: '0x2', - ticker: 'ticker', - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: jest.fn(), - onTokensStateChange: jest.fn(), - onNetworkStateChange: jest.fn(), - getNetworkClientById: jest.fn().mockReturnValue({ - configuration: { - chainId: '0x1', - ticker: NetworksTicker.mainnet, + options: { + interval, + tokenPricesService, + }, + mockTokensControllerState: { + allTokens: { + [ChainId.mainnet]: { + [defaultSelectedAddress]: [ + { + address: '0x02', + decimals: 0, + symbol: '', + aggregators: [], + }, + { + address: '0x03', + decimals: 0, + symbol: '', + aggregators: [], + }, + ], + }, }, - }), - tokenPricesService, + }, }, - { - allTokens: { - '0x1': { - [defaultSelectedAddress]: [ - { - address: '0x02', - decimals: 0, - symbol: '', - aggregators: [], + async ({ controller }) => { + controller.startPollingByNetworkClientId('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, }, - { - address: '0x03', - decimals: 0, - symbol: '', - aggregators: [], + '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, }, - ], + }, }, - }, + }); }, ); + }); - controller.startPollingByNetworkClientId('mainnet'); - await advanceTime({ clock, duration: 0 }); + describe('when the native currency is not supported', () => { + it('returns the exchange rates using ETH as a fallback currency', async () => { + nock('https://min-api.cryptocompare.com') + .get('/data/price?fsym=ETH&tsyms=LOL') + .reply(200, { LOL: 0.5 }); + const tokenPricesService = buildMockTokenPricesService({ + fetchTokenPrices: fetchTokenPricesWithIncreasingPriceForEachToken, + validateCurrencySupported(currency: unknown): currency is string { + return currency !== 'LOL'; + }, + }); + const selectedNetworkClientConfiguration = + buildCustomNetworkClientConfiguration({ + chainId: ChainId.mainnet, + ticker: 'LOL', + }); + await withController( + { + options: { + tokenPricesService, + }, + mockNetworkClientConfigurationsByNetworkClientId: { + mainnet: selectedNetworkClientConfiguration, + }, + mockTokensControllerState: { + allTokens: { + [ChainId.mainnet]: { + [defaultSelectedAddress]: [ + { + address: '0x02', + decimals: 0, + symbol: '', + aggregators: [], + }, + { + address: '0x03', + decimals: 0, + symbol: '', + aggregators: [], + }, + ], + }, + }, + }, + mockNetworkState: { + providerConfig: { + ...defaultNetworkState.providerConfig, + chainId: toHex(2), + ticker: 'ticker', + }, + }, + }, + async ({ controller }) => { + controller.startPollingByNetworkClientId('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: 'ETH', + pricePercentChange1d: 0, + priceChange1d: 0, + allTimeHigh: 4000, + allTimeLow: 900, + circulatingSupply: 2000, + dilutedMarketCap: 100, + high1d: 200, + low1d: 100, + marketCap: 1000, + marketCapPercentChange1d: 100, + price: 0.0005, + pricePercentChange14d: 100, + pricePercentChange1h: 1, + pricePercentChange1y: 200, + pricePercentChange200d: 300, + pricePercentChange30d: 200, + pricePercentChange7d: 100, + totalVolume: 100, + }, + '0x03': { + tokenAddress: '0x03', + currency: 'ETH', + pricePercentChange1d: 0, + priceChange1d: 0, + 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, + }, + }, + }); + controller.stopAllPolling(); + }, + ); + }); - expect(controller.state).toStrictEqual({ - marketData: { - '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, + it('returns the an empty object when market does not exist for pair', async () => { + nock('https://min-api.cryptocompare.com') + .get('/data/price?fsym=ETH&tsyms=LOL') + .replyWithError( + new Error('market does not exist for this coin pair'), + ); + + const tokenPricesService = buildMockTokenPricesService(); + await withController( + { + options: { + tokenPricesService, }, - '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, + mockNetworkClientConfigurationsByNetworkClientId: { + 'AAAA-BBBB-CCCC-DDDD': buildCustomNetworkClientConfiguration({ + chainId: ChainId.mainnet, + ticker: 'LOL', + }), + }, + mockTokensControllerState: { + allTokens: { + '0x1': { + [defaultSelectedAddress]: [ + { + address: '0x02', + decimals: 0, + symbol: '', + aggregators: [], + }, + { + address: '0x03', + decimals: 0, + symbol: '', + aggregators: [], + }, + ], + }, + }, }, }, - }, + async ({ controller }) => { + controller.startPollingByNetworkClientId('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(); + }, + ); }); }); }); - describe('when the native currency is not supported', () => { - it('returns the exchange rates using ETH as a fallback currency', async () => { - nock('https://min-api.cryptocompare.com') - .get('/data/price?fsym=ETH&tsyms=LOL') - .reply(200, { LOL: 0.5 }); - const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: fetchTokenPricesWithIncreasingPriceForEachToken, - validateCurrencySupported(currency: unknown): currency is string { - return currency !== 'LOL'; - }, - }); - const controller = new TokenRatesController( - { - chainId: '0x2', - ticker: 'ticker', - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: jest.fn(), - onTokensStateChange: jest.fn(), - onNetworkStateChange: jest.fn(), - getNetworkClientById: jest.fn().mockReturnValue({ - configuration: { - chainId: '0x1', - ticker: 'LOL', - }, - }), + it('should stop polling', async () => { + const interval = 100; + const tokenPricesService = buildMockTokenPricesService(); + jest.spyOn(tokenPricesService, 'fetchTokenPrices'); + await withController( + { + options: { tokenPricesService, }, - { + mockTokensControllerState: { allTokens: { '0x1': { [defaultSelectedAddress]: [ { - address: '0x02', - decimals: 0, - symbol: '', - aggregators: [], - }, - { - address: '0x03', + address: mockTokenAddress, decimals: 0, symbol: '', aggregators: [], @@ -1545,436 +1569,581 @@ describe('TokenRatesController', () => { }, }, }, - ); + }, + async ({ controller }) => { + const pollingToken = + controller.startPollingByNetworkClientId('mainnet'); + await advanceTime({ clock, duration: 0 }); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes( + 1, + ); - controller.startPollingByNetworkClientId('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({ - '0x1': { - // token price in LOL = (token price in ETH) * (ETH value in LOL) - '0x02': { - tokenAddress: '0x02', - currency: 'ETH', - pricePercentChange1d: 0, - priceChange1d: 0, - allTimeHigh: 4000, - allTimeLow: 900, - circulatingSupply: 2000, - dilutedMarketCap: 100, - high1d: 200, - low1d: 100, - marketCap: 1000, - marketCapPercentChange1d: 100, - price: 0.0005, - pricePercentChange14d: 100, - pricePercentChange1h: 1, - pricePercentChange1y: 200, - pricePercentChange200d: 300, - pricePercentChange30d: 200, - pricePercentChange7d: 100, - totalVolume: 100, - }, - '0x03': { - tokenAddress: '0x03', - currency: 'ETH', - pricePercentChange1d: 0, - priceChange1d: 0, - 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, - }, - }, - }); - controller.stopAllPolling(); - }); + controller.stopPollingByPollingToken(pollingToken); - it('returns the an empty object when market does not exist for pair', async () => { - nock('https://min-api.cryptocompare.com') - .get('/data/price?fsym=ETH&tsyms=LOL') - .replyWithError( - new Error('market does not exist for this coin pair'), + await advanceTime({ clock, duration: interval }); + expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes( + 1, ); + }, + ); + }); + }); - const tokenPricesService = buildMockTokenPricesService(); - const controller = new TokenRatesController( - { - chainId: '0x2', - ticker: 'ETH', - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: jest.fn(), - onTokensStateChange: jest.fn(), - onNetworkStateChange: jest.fn(), - getNetworkClientById: jest.fn().mockReturnValue({ - configuration: { - chainId: '0x1', - ticker: 'LOL', - }, - }), - tokenPricesService, - }, - { + // 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: { - '0x1': { + [ChainId.mainnet]: { [defaultSelectedAddress]: [ { - address: '0x02', - decimals: 0, - symbol: '', + address: tokenAddress, + decimals: 18, + symbol: 'TST', + aggregators: [], + }, + ], + }, + }, + chainId: ChainId.mainnet, + controller, + triggerTokensStateChange, + triggerNetworkStateChange, + method, + nativeCurrency: 'ETH', + selectedNetworkClientId: InfuraNetworkType.mainnet, + }); + + expect(controller.state.marketData).toStrictEqual({}); + }, + ); + }); + + it('does not update state if there are no tokens for the given chain and address', async () => { + await withController( + async ({ + controller, + triggerTokensStateChange, + triggerNetworkStateChange, + }) => { + const tokenAddress = '0x0000000000000000000000000000000000000001'; + const differentAccount = + '0x1000000000000000000000000000000000000000'; + controller.enable(); + await callUpdateExchangeRatesMethod({ + allTokens: { + // These tokens are for the right chain but wrong account + [ChainId.mainnet]: { + [differentAccount]: [ + { + address: tokenAddress, + decimals: 18, + symbol: 'TST', aggregators: [], }, + ], + }, + // These tokens are for the right account but wrong chain + [toHex(2)]: { + [defaultSelectedAddress]: [ { - address: '0x03', - decimals: 0, - symbol: '', + address: tokenAddress, + decimals: 18, + symbol: 'TST', aggregators: [], }, ], }, }, - }, - ); - - controller.startPollingByNetworkClientId('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 }); + chainId: ChainId.mainnet, + controller, + triggerTokensStateChange, + triggerNetworkStateChange, + method, + nativeCurrency: 'ETH', + selectedNetworkClientId: InfuraNetworkType.mainnet, + }); - expect(controller.state.marketData).toStrictEqual({ - '0x1': {}, - }); - controller.stopAllPolling(); - }); - }); - }); - - it('should stop polling', async () => { - const interval = 100; - const tokenPricesService = buildMockTokenPricesService(); - jest.spyOn(tokenPricesService, 'fetchTokenPrices'); - const controller = new TokenRatesController( - { - interval, - chainId: '0x2', - ticker: 'ticker', - selectedAddress: defaultSelectedAddress, - onPreferencesStateChange: jest.fn(), - onTokensStateChange: jest.fn(), - onNetworkStateChange: jest.fn(), - getNetworkClientById: jest.fn().mockReturnValue({ - configuration: { - chainId: '0x1', - ticker: NetworksTicker.mainnet, - }, - }), - tokenPricesService, - }, - { - allTokens: { - '0x1': { - [defaultSelectedAddress]: [ - { - address: mockTokenAddress, - decimals: 0, - symbol: '', - aggregators: [], + expect(controller.state).toStrictEqual({ + marketData: { + [ChainId.mainnet]: { + '0x0000000000000000000000000000000000000000': { + currency: 'ETH', + }, }, - ], - }, + }, + }); }, - }, - ); - - const pollingToken = controller.startPollingByNetworkClientId('mainnet'); - await advanceTime({ clock, duration: 0 }); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(1); - - controller.stopPollingByPollingToken(pollingToken); - - await advanceTime({ clock, duration: interval }); - expect(tokenPricesService.fetchTokenPrices).toHaveBeenCalledTimes(1); - }); - }); - - // 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( - { config: { disabled: true } }, - async ({ controller, controllerEvents }) => { - const tokenAddress = '0x0000000000000000000000000000000000000001'; + ); + }); - await callUpdateExchangeRatesMethod({ - allTokens: { - [ChainId.mainnet]: { - [controller.config.selectedAddress]: [ - { - address: tokenAddress, - decimals: 18, - symbol: 'TST', - aggregators: [], + 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'; + + await expect( + async () => + 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, + }), + ).rejects.toThrow('Failed to fetch'); + 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, }, - chainId: ChainId.mainnet, + }, + async ({ controller, - controllerEvents, - method, - nativeCurrency: 'ETH', - selectedNetworkClientId: InfuraNetworkType.mainnet, - }); - - expect(controller.state.marketData).toStrictEqual({}); - }, - ); - }); + triggerTokensStateChange, + triggerNetworkStateChange, + }) => { + await callUpdateExchangeRatesMethod({ + allTokens: { + [chainId]: { + [defaultSelectedAddress]: tokens, + }, + }, + chainId, + controller, + triggerTokensStateChange, + triggerNetworkStateChange, + method, + nativeCurrency: ticker, + selectedNetworkClientId: InfuraNetworkType.mainnet, + }); - it('does not update state if there are no tokens for the given chain and address', async () => { - await withController(async ({ controller, controllerEvents }) => { - const tokenAddress = '0x0000000000000000000000000000000000000001'; - const differentAccount = '0x1000000000000000000000000000000000000000'; + 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, + }); + } + }, + ); + }); - await callUpdateExchangeRatesMethod({ - allTokens: { - // These tokens are for the right chain but wrong account - [ChainId.mainnet]: { - [differentAccount]: [ - { - address: tokenAddress, - decimals: 18, - symbol: 'TST', - aggregators: [], - }, - ], + it('updates all rates', async () => { + const tokenAddresses = [ + '0x0000000000000000000000000000000000000001', + '0x0000000000000000000000000000000000000002', + ]; + const tokenPricesService = buildMockTokenPricesService({ + fetchTokenPrices: jest.fn().mockResolvedValue({ + [tokenAddresses[0]]: { + currency: 'ETH', + tokenAddress: tokenAddresses[0], + value: 0.001, }, - // These tokens are for the right account but wrong chain - [toHex(2)]: { - [controller.config.selectedAddress]: [ - { - address: tokenAddress, - decimals: 18, - symbol: 'TST', - aggregators: [], - }, - ], + [tokenAddresses[1]]: { + currency: 'ETH', + tokenAddress: tokenAddresses[1], + value: 0.002, }, - }, - chainId: ChainId.mainnet, - controller, - controllerEvents, - method, - nativeCurrency: 'ETH', - selectedNetworkClientId: InfuraNetworkType.mainnet, + }), }); + 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: [], + }, + ], + }, + }, + chainId: ChainId.mainnet, + controller, + triggerTokensStateChange, + triggerNetworkStateChange, + method, + nativeCurrency: 'ETH', + selectedNetworkClientId: InfuraNetworkType.mainnet, + }); - expect(controller.state).toStrictEqual({ - marketData: { - '0x1': { - '0x0000000000000000000000000000000000000000': { currency: 'ETH' }, + 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, + }, + }, }, + } + `); }, - }); - }); - }); - - 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, controllerEvents }) => { - const tokenAddress = '0x0000000000000000000000000000000000000001'; - await expect( - async () => + 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: { - [ChainId.mainnet]: { - [controller.config.selectedAddress]: [ + [toHex(2)]: { + [defaultSelectedAddress]: [ + { + address: tokenAddresses[0], + decimals: 18, + symbol: 'TST1', + aggregators: [], + }, { - address: tokenAddress, + address: tokenAddresses[1], decimals: 18, - symbol: 'TST', + symbol: 'TST2', aggregators: [], }, ], }, }, - chainId: ChainId.mainnet, + chainId: toHex(2), controller, - controllerEvents, + triggerTokensStateChange, + triggerNetworkStateChange, method, nativeCurrency: 'ETH', - selectedNetworkClientId: InfuraNetworkType.mainnet, - }), - ).rejects.toThrow('Failed to fetch'); - expect(controller.state.marketData).toStrictEqual({}); - }, - ); - }); + setChainAsCurrent: false, + }); - it('fetches rates for all tokens in batches', async () => { - const chainId = ChainId.mainnet; - const ticker = 'ETH'; - 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: { - ticker, - tokenPricesService, - }, - }, - async ({ controller, controllerEvents }) => { - await callUpdateExchangeRatesMethod({ - allTokens: { - [chainId]: { - [controller.config.selectedAddress]: tokens, + 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, + }, + }, }, + } + `); }, - chainId, - controller, - controllerEvents, - 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 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 tokenPricesService = buildMockTokenPricesService({ + fetchTokenPrices: jest.fn().mockResolvedValue({ + [tokenAddresses[0]]: { + currency: 'ETH', + tokenAddress: tokenAddresses[0], + price: 0.001, + }, + [tokenAddresses[1]]: { + currency: 'ETH', + tokenAddress: tokenAddresses[1], + price: 0.002, + }, + }), + validateCurrencySupported: jest.fn().mockReturnValue( + false, + // Cast used because this method has an assertion in the return + // value that I don't know how to type properly with Jest's mock. + ) as unknown as AbstractTokenPricesService['validateCurrencySupported'], + }); + nock('https://min-api.cryptocompare.com') + .get('/data/price') + .query({ + fsym: 'ETH', + tsyms: selectedNetworkClientConfiguration.ticker, + }) + .reply(200, { [selectedNetworkClientConfiguration.ticker]: 0.5 }); // .5 eth to 1 matic - it('updates all rates', 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, controllerEvents }) => { - await callUpdateExchangeRatesMethod({ - allTokens: { - [ChainId.mainnet]: { - [controller.config.selectedAddress]: [ - { - address: tokenAddresses[0], - decimals: 18, - symbol: 'TST1', - aggregators: [], - }, - { - address: tokenAddresses[1], - decimals: 18, - symbol: 'TST2', - aggregators: [], - }, - ], - }, + await withController( + { + options: { tokenPricesService }, + mockNetworkClientConfigurationsByNetworkClientId: { + [selectedNetworkClientId]: selectedNetworkClientConfiguration, }, - chainId: ChainId.mainnet, + }, + async ({ controller, - controllerEvents, - method, - nativeCurrency: 'ETH', - selectedNetworkClientId: InfuraNetworkType.mainnet, - }); + 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, + }); - expect(controller.state).toMatchInlineSnapshot(` + // token value in terms of matic should be (token value in eth) * (eth value in matic) + expect(controller.state).toMatchInlineSnapshot(` Object { "marketData": Object { - "0x1": Object { + "0x89": Object { "0x0000000000000000000000000000000000000001": Object { "currency": "ETH", + "price": 0.0005, "tokenAddress": "0x0000000000000000000000000000000000000001", - "value": 0.001, }, "0x0000000000000000000000000000000000000002": Object { "currency": "ETH", + "price": 0.001, "tokenAddress": "0x0000000000000000000000000000000000000002", - "value": 0.002, }, }, - }, - } - `); - }, - ); - }); + }, + } + `); + }, + ); + }); + + 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(); + const tokenPricesService = buildMockTokenPricesService({ + fetchTokenPrices: fetchTokenPricesWithIncreasingPriceForEachToken, + validateCurrencySupported: ( + currency: unknown, + ): currency is string => { + return currency !== selectedNetworkClientConfiguration.ticker; + }, + }); + const fetchTokenPricesSpy = jest.spyOn( + tokenPricesService, + 'fetchTokenPrices', + ); + const tokens = tokenAddresses.map((tokenAddress) => { + return buildToken({ address: tokenAddress }); + }); + nock('https://min-api.cryptocompare.com') + .get('/data/price') + .query({ + fsym: 'ETH', + tsyms: selectedNetworkClientConfiguration.ticker, + }) + .reply(200, { [selectedNetworkClientConfiguration.ticker]: 0.5 }); + await withController( + { + options: { + tokenPricesService, + }, + mockNetworkClientConfigurationsByNetworkClientId: { + [selectedNetworkClientId]: selectedNetworkClientConfiguration, + }, + }, + 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, + ); + expect(fetchTokenPricesSpy).toHaveBeenCalledTimes(numBatches); + + for (let i = 1; i <= numBatches; i++) { + expect(fetchTokenPricesSpy).toHaveBeenNthCalledWith(i, { + chainId: selectedNetworkClientConfiguration.chainId, + tokenAddresses: tokenAddresses.slice( + (i - 1) * TOKEN_PRICES_BATCH_SIZE, + i * TOKEN_PRICES_BATCH_SIZE, + ), + currency: 'ETH', + }); + } + }, + ); + }); - if (method === 'updateExchangeRatesByChainId') { - it('updates rates only for a non-selected chain', async () => { + 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', + }); const tokenAddresses = [ '0x0000000000000000000000000000000000000001', '0x0000000000000000000000000000000000000002', @@ -1992,14 +2161,28 @@ describe('TokenRatesController', () => { value: 0.002, }, }), + validateChainIdSupported: jest.fn().mockReturnValue( + false, + // Cast used because this method has an assertion in the return + // value that I don't know how to type properly with Jest's mock. + ) as unknown as AbstractTokenPricesService['validateChainIdSupported'], }); await withController( - { options: { tokenPricesService } }, - async ({ controller, controllerEvents }) => { + { + options: { tokenPricesService }, + mockNetworkClientConfigurationsByNetworkClientId: { + [selectedNetworkClientId]: selectedNetworkClientConfiguration, + }, + }, + async ({ + controller, + triggerTokensStateChange, + triggerNetworkStateChange, + }) => { await callUpdateExchangeRatesMethod({ allTokens: { - [toHex(2)]: { - [controller.config.selectedAddress]: [ + [toHex(999)]: { + [defaultSelectedAddress]: [ { address: tokenAddresses[0], decimals: 18, @@ -2015,221 +2198,35 @@ describe('TokenRatesController', () => { ], }, }, - chainId: toHex(2), + chainId: selectedNetworkClientConfiguration.chainId, controller, - controllerEvents, + triggerTokensStateChange, + triggerNetworkStateChange, method, - nativeCurrency: 'ETH', - setChainAsCurrent: false, + nativeCurrency: selectedNetworkClientConfiguration.ticker, + selectedNetworkClientId, }); 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, - }, - }, - }, - } - `); - }, - ); - }); - } - - 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 tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: jest.fn().mockResolvedValue({ - [tokenAddresses[0]]: { - currency: 'ETH', - tokenAddress: tokenAddresses[0], - price: 0.001, - }, - [tokenAddresses[1]]: { - currency: 'ETH', - tokenAddress: tokenAddresses[1], - price: 0.002, - }, - }), - validateCurrencySupported: jest.fn().mockReturnValue( - false, - // Cast used because this method has an assertion in the return - // value that I don't know how to type properly with Jest's mock. - ) as unknown as AbstractTokenPricesService['validateCurrencySupported'], - }); - nock('https://min-api.cryptocompare.com') - .get('/data/price') - .query({ - fsym: 'ETH', - tsyms: selectedNetworkClientConfiguration.ticker, - }) - .reply(200, { [selectedNetworkClientConfiguration.ticker]: 0.5 }); // .5 eth to 1 matic - - await withController( - { - options: { - tokenPricesService, - }, - mockNetworkClientConfigurationsByNetworkClientId: { - [selectedNetworkClientId]: selectedNetworkClientConfiguration, - }, - }, - async ({ controller, controllerEvents }) => { - await callUpdateExchangeRatesMethod({ - allTokens: { - [selectedNetworkClientConfiguration.chainId]: { - [controller.config.selectedAddress]: [ - { - address: tokenAddresses[0], - decimals: 18, - symbol: 'TST1', - aggregators: [], - }, - { - address: tokenAddresses[1], - decimals: 18, - symbol: 'TST2', - aggregators: [], - }, - ], - }, - }, - chainId: selectedNetworkClientConfiguration.chainId, - controller, - controllerEvents, - 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 { - "currency": "ETH", - "price": 0.0005, - "tokenAddress": "0x0000000000000000000000000000000000000001", - }, - "0x0000000000000000000000000000000000000002": Object { - "currency": "ETH", - "price": 0.001, - "tokenAddress": "0x0000000000000000000000000000000000000002", - }, + "0x3e7": Object { + "0x0000000000000000000000000000000000000001": undefined, + "0x0000000000000000000000000000000000000002": undefined, }, }, } - `); - }, - ); - }); - - 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(); - const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: fetchTokenPricesWithIncreasingPriceForEachToken, - validateCurrencySupported: (currency: unknown): currency is string => { - return currency !== selectedNetworkClientConfiguration.ticker; - }, - }); - const fetchTokenPricesSpy = jest.spyOn( - tokenPricesService, - 'fetchTokenPrices', - ); - const tokens = tokenAddresses.map((tokenAddress) => { - return buildToken({ address: tokenAddress }); - }); - nock('https://min-api.cryptocompare.com') - .get('/data/price') - .query({ - fsym: 'ETH', - tsyms: selectedNetworkClientConfiguration.ticker, - }) - .reply(200, { [selectedNetworkClientConfiguration.ticker]: 0.5 }); - await withController( - { - options: { - tokenPricesService, - }, - mockNetworkClientConfigurationsByNetworkClientId: { - [selectedNetworkClientId]: selectedNetworkClientConfiguration, + `); }, - }, - async ({ controller, controllerEvents }) => { - await callUpdateExchangeRatesMethod({ - allTokens: { - [selectedNetworkClientConfiguration.chainId]: { - [controller.config.selectedAddress]: tokens, - }, - }, - chainId: selectedNetworkClientConfiguration.chainId, - controller, - controllerEvents, - method, - nativeCurrency: selectedNetworkClientConfiguration.ticker, - selectedNetworkClientId, - }); - - 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: selectedNetworkClientConfiguration.chainId, - tokenAddresses: tokenAddresses.slice( - (i - 1) * TOKEN_PRICES_BATCH_SIZE, - i * TOKEN_PRICES_BATCH_SIZE, - ), - currency: 'ETH', - }); - } - }, - ); - }); + ); + }); - 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', - }); - const tokenAddresses = [ - '0x0000000000000000000000000000000000000001', - '0x0000000000000000000000000000000000000002', - ]; - const tokenPricesService = buildMockTokenPricesService({ - fetchTokenPrices: jest.fn().mockResolvedValue({ + 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], @@ -2240,120 +2237,51 @@ describe('TokenRatesController', () => { tokenAddress: tokenAddresses[1], value: 0.002, }, - }), - validateChainIdSupported: jest.fn().mockReturnValue( - false, - // Cast used because this method has an assertion in the return - // value that I don't know how to type properly with Jest's mock. - ) as unknown as AbstractTokenPricesService['validateChainIdSupported'], - }); - await withController( - { - options: { - tokenPricesService, - }, - mockNetworkClientConfigurationsByNetworkClientId: { - [selectedNetworkClientId]: selectedNetworkClientConfiguration, - }, - }, - async ({ controller, controllerEvents }) => { - await callUpdateExchangeRatesMethod({ - allTokens: { - [selectedNetworkClientConfiguration.chainId]: { - [controller.config.selectedAddress]: [ - { - address: tokenAddresses[0], - decimals: 18, - symbol: 'TST1', - aggregators: [], - }, - { - address: tokenAddresses[1], - decimals: 18, - symbol: 'TST2', - aggregators: [], - }, - ], - }, - }, - chainId: selectedNetworkClientConfiguration.chainId, + }); + const tokenPricesService = buildMockTokenPricesService({ + fetchTokenPrices: fetchTokenPricesMock, + }); + await withController( + { options: { tokenPricesService } }, + async ({ controller, - controllerEvents, - method, - nativeCurrency: selectedNetworkClientConfiguration.ticker, - selectedNetworkClientId, - }); - - expect(controller.state).toMatchInlineSnapshot(` - Object { - "marketData": Object { - "0x3e7": Object { - "0x0000000000000000000000000000000000000001": undefined, - "0x0000000000000000000000000000000000000002": undefined, - }, - }, - } - `); - }, - ); - }); - - 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, controllerEvents }) => { - const updateExchangeRates = async () => - await callUpdateExchangeRatesMethod({ - allTokens: { - [toHex(1)]: { - [controller.config.selectedAddress]: [ - { - address: tokenAddresses[0], - decimals: 18, - symbol: 'TST1', - aggregators: [], - }, - { - address: tokenAddresses[1], - decimals: 18, - symbol: 'TST2', - aggregators: [], - }, - ], + 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, - controllerEvents, - method, - nativeCurrency: 'ETH', - }); + chainId: ChainId.mainnet, + selectedNetworkClientId: InfuraNetworkType.mainnet, + controller, + triggerTokensStateChange, + triggerNetworkStateChange, + method, + nativeCurrency: 'ETH', + }); - await Promise.all([updateExchangeRates(), updateExchangeRates()]); + await Promise.all([updateExchangeRates(), updateExchangeRates()]); - expect(fetchTokenPricesMock).toHaveBeenCalledTimes(1); + expect(fetchTokenPricesMock).toHaveBeenCalledTimes(1); - expect(controller.state).toMatchInlineSnapshot(` + expect(controller.state).toMatchInlineSnapshot(` Object { "marketData": Object { "0x1": Object { @@ -2371,21 +2299,12 @@ describe('TokenRatesController', () => { }, } `); - }, - ); + }, + ); + }); }); }); }); - -/** - * A collection of mock external controller events. - */ -type ControllerEvents = { - networkStateChange: (state: NetworkState) => void; - preferencesStateChange: (state: PreferencesState) => void; - tokensStateChange: (state: TokensControllerState) => void; -}; - /** * A callback for the `withController` helper function. * @@ -2396,85 +2315,114 @@ type ControllerEvents = { */ type WithControllerCallback = ({ controller, - controllerEvents, + triggerPreferencesStateChange, + triggerTokensStateChange, + triggerNetworkStateChange, }: { controller: TokenRatesController; - controllerEvents: ControllerEvents; + triggerPreferencesStateChange: (state: PreferencesState) => void; + triggerTokensStateChange: (state: TokensControllerState) => void; + triggerNetworkStateChange: (state: NetworkState) => void; }) => Promise | ReturnValue; -type PartialConstructorParameters = { +type WithControllerOptions = { options?: Partial[0]>; - config?: Partial; - state?: Partial; + messenger?: ControllerMessenger; mockNetworkClientConfigurationsByNetworkClientId?: Record< NetworkClientId, NetworkClientConfiguration >; + mockTokensControllerState?: Partial; + mockNetworkState?: Partial; }; type WithControllerArgs = | [WithControllerCallback] - | [PartialConstructorParameters, WithControllerCallback]; + | [WithControllerOptions, WithControllerCallback]; /** * Builds a controller based on the given options, and calls the given function * with that controller. * - * @param args - Either a function, or a set of partial constructor parameters - * plus a function. The function will be called with the built controller and a - * collection of controller event handlers. + * @param args - Either a function, or an options bag + a function. The options + * bag is equivalent to the controller options; the function will be called + * with the built controller. * @returns Whatever the callback returns. */ async function withController( ...args: WithControllerArgs -) { - const [ - { - options = {}, - config = {}, - state = {}, - mockNetworkClientConfigurationsByNetworkClientId = {}, - }, - testFunction, - ] = args.length === 2 ? args : [{}, args[0]]; - - // explit cast used here because we know the `on____` functions are always - // set in the constructor. - const controllerEvents = {} as ControllerEvents; +): Promise { + const [{ ...rest }, fn] = args.length === 2 ? args : [{}, args[0]]; + const { + options, + messenger, + mockNetworkClientConfigurationsByNetworkClientId, + mockTokensControllerState, + mockNetworkState, + } = rest; + const controllerMessenger = + messenger ?? new ControllerMessenger(); + + const mockTokensState = jest.fn(); + controllerMessenger.registerActionHandler( + 'TokensController:getState', + mockTokensState.mockReturnValue({ + ...getDefaultTokensState(), + ...mockTokensControllerState, + }), + ); const getNetworkClientById = buildMockGetNetworkClientById( mockNetworkClientConfigurationsByNetworkClientId, ); - - const controllerOptions: ConstructorParameters< - typeof TokenRatesController - >[0] = { - chainId: toHex(1), + controllerMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', getNetworkClientById, - onNetworkStateChange: (listener) => { - controllerEvents.networkStateChange = listener; - }, - onPreferencesStateChange: (listener) => { - controllerEvents.preferencesStateChange = listener; - }, - onTokensStateChange: (listener) => { - controllerEvents.tokensStateChange = listener; - }, - selectedAddress: defaultSelectedAddress, - ticker: NetworksTicker.mainnet, + ); + + const networkStateMock = jest.fn(); + controllerMessenger.registerActionHandler( + 'NetworkController:getState', + networkStateMock.mockReturnValue({ + ...defaultNetworkState, + ...mockNetworkState, + }), + ); + + const mockPreferencesState = jest.fn(); + controllerMessenger.registerActionHandler( + 'PreferencesController:getState', + mockPreferencesState.mockReturnValue({ + ...getDefaultPreferencesState(), + selectedAddress: defaultSelectedAddress, + }), + ); + + const controller = new TokenRatesController({ tokenPricesService: buildMockTokenPricesService(), + messenger: buildTokenRatesControllerMessenger(controllerMessenger), ...options, - }; - - const controller = new TokenRatesController(controllerOptions, config, state); + }); try { - return await testFunction({ + return await fn({ controller, - controllerEvents, + triggerPreferencesStateChange: (state: PreferencesState) => { + controllerMessenger.publish( + 'PreferencesController:stateChange', + state, + [], + ); + }, + triggerTokensStateChange: (state: TokensControllerState) => { + controllerMessenger.publish('TokensController:stateChange', state, []); + }, + triggerNetworkStateChange: (state: NetworkState) => { + controllerMessenger.publish('NetworkController:stateChange', state, []); + }, }); } finally { controller.stop(); - await flushPromises(); + controller.stopAllPolling(); } } @@ -2495,7 +2443,9 @@ async function withController( * @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.controllerEvents - Controller event handlers, used to + * @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 @@ -2509,18 +2459,20 @@ async function callUpdateExchangeRatesMethod({ allTokens, chainId, controller, - controllerEvents, + triggerTokensStateChange, + triggerNetworkStateChange, method, nativeCurrency, selectedNetworkClientId, setChainAsCurrent = true, }: { - allTokens: TokenRatesConfig['allTokens']; + allTokens: TokensControllerState['allTokens']; chainId: Hex; controller: TokenRatesController; - controllerEvents: ControllerEvents; + triggerTokensStateChange: (state: TokensControllerState) => void; + triggerNetworkStateChange: (state: NetworkState) => void; method: 'updateExchangeRates' | 'updateExchangeRatesByChainId'; - nativeCurrency: TokenRatesConfig['nativeCurrency']; + nativeCurrency: string; selectedNetworkClientId?: NetworkClientId; setChainAsCurrent?: boolean; }) { @@ -2529,12 +2481,12 @@ async function callUpdateExchangeRatesMethod({ 'The "setChainAsCurrent" flag cannot be enabled when calling the "updateExchangeRates" method', ); } - // Note that the state given here is intentionally incomplete because the - // controller only uses these two properties, and the tests are written to - // only consider these two. We want this to break if we start relying on - // more, as we'd need to update the tests accordingly. - // @ts-expect-error Intentionally incomplete state - controllerEvents.tokensStateChange({ allDetectedTokens: {}, allTokens }); + + triggerTokensStateChange({ + ...getDefaultTokensState(), + allDetectedTokens: {}, + allTokens, + }); if (setChainAsCurrent) { assert( @@ -2546,12 +2498,8 @@ async function callUpdateExchangeRatesMethod({ // 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. - // - // @ts-expect-error Note that the state given here is intentionally - // incomplete because the controller only uses this one property, and the - // tests are written to only consider it. We want this to break if we start - // relying on more properties, as we'd need to update the tests accordingly. - controllerEvents.networkStateChange({ + triggerNetworkStateChange({ + ...defaultNetworkState, selectedNetworkClientId, }); } diff --git a/packages/assets-controllers/src/TokenRatesController.ts b/packages/assets-controllers/src/TokenRatesController.ts index 71d4d8b7d24..40e87593872 100644 --- a/packages/assets-controllers/src/TokenRatesController.ts +++ b/packages/assets-controllers/src/TokenRatesController.ts @@ -1,4 +1,8 @@ -import type { BaseConfig, BaseState } from '@metamask/base-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + RestrictedControllerMessenger, +} from '@metamask/base-controller'; import { safelyExecute, toChecksumHexAddress, @@ -7,11 +11,15 @@ import { } from '@metamask/controller-utils'; import type { NetworkClientId, - NetworkController, - NetworkState, + NetworkControllerGetNetworkClientByIdAction, + NetworkControllerGetStateAction, + NetworkControllerStateChangeEvent, } from '@metamask/network-controller'; -import { StaticIntervalPollingControllerV1 } from '@metamask/polling-controller'; -import type { PreferencesState } from '@metamask/preferences-controller'; +import { StaticIntervalPollingController } from '@metamask/polling-controller'; +import type { + PreferencesControllerGetStateAction, + PreferencesControllerStateChangeEvent, +} from '@metamask/preferences-controller'; import { createDeferredPromise, type Hex } from '@metamask/utils'; import { isEqual } from 'lodash'; @@ -19,7 +27,11 @@ import { reduceInBatchesSerially, TOKEN_PRICES_BATCH_SIZE } from './assetsUtil'; import { fetchExchangeRate as fetchNativeCurrencyExchangeRate } from './crypto-compare-service'; import type { AbstractTokenPricesService } from './token-prices-service/abstract-token-prices-service'; import { ZERO_ADDRESS } from './token-prices-service/codefi-v2'; -import type { TokensControllerState } from './TokensController'; +import type { + TokensControllerGetStateAction, + TokensControllerStateChangeEvent, + TokensControllerState, +} from './TokensController'; /** * @type Token @@ -28,9 +40,12 @@ import type { TokensControllerState } from './TokensController'; * @property address - Hex address of the token contract * @property decimals - Number of decimals the token uses * @property symbol - Symbol of the token + * @property aggregators - An array containing the token's aggregators * @property image - Image of the token, url or bit32 image + * @property hasBalanceError - 'true' if there is an error while updating the token balance + * @property isERC721 - 'true' if the token is a ERC721 token + * @property name - Name of the token */ - export type Token = { address: string; decimals: number; @@ -42,35 +57,11 @@ export type Token = { name?: string; }; -/** - * @type TokenRatesConfig - * - * Token rates controller configuration - * @property interval - Polling interval used to fetch new token rates - * @property nativeCurrency - Current native currency selected to use base of rates - * @property chainId - Current network chainId - * @property tokens - List of tokens to track exchange rates for - * @property threshold - Threshold to invalidate the supportedChains - */ -// This interface was created before this ESLint rule was added. -// Convert to a `type` in a future major version. -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export interface TokenRatesConfig extends BaseConfig { - interval: number; - nativeCurrency: string; - chainId: Hex; - selectedAddress: string; - allTokens: { [chainId: Hex]: { [key: string]: Token[] } }; - allDetectedTokens: { [chainId: Hex]: { [key: string]: Token[] } }; - threshold: number; -} +const DEFAULT_INTERVAL = 180000; -// This interface was created before this ESLint rule was added. -// Convert to a `type` in a future major version. -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export interface ContractExchangeRates { +export type ContractExchangeRates = { [address: string]: number | undefined; -} +}; type MarketDataDetails = { tokenAddress: `0x${string}`; @@ -95,6 +86,9 @@ type MarketDataDetails = { totalVolume: number; }; +/** + * Represents a mapping of token contract addresses to their market data. + */ export type ContractMarketData = Record; enum PollState { @@ -102,18 +96,74 @@ enum PollState { Inactive = 'Inactive', } +/** + * The external actions available to the {@link TokenRatesController}. + */ +export type AllowedActions = + | TokensControllerGetStateAction + | NetworkControllerGetNetworkClientByIdAction + | NetworkControllerGetStateAction + | PreferencesControllerGetStateAction; + +/** + * The external events available to the {@link TokenRatesController}. + */ +export type AllowedEvents = + | PreferencesControllerStateChangeEvent + | TokensControllerStateChangeEvent + | NetworkControllerStateChangeEvent; + +/** + * The name of the {@link TokenRatesController}. + */ +export const controllerName = 'TokenRatesController'; + /** * @type TokenRatesState * * Token rates controller state * @property marketData - Market data for tokens, keyed by chain ID and then token contract address. */ -// This interface was created before this ESLint rule was added. -// Convert to a `type` in a future major version. -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export interface TokenRatesState extends BaseState { +export type TokenRatesControllerState = { marketData: Record>; -} +}; + +/** + * The action that can be performed to get the state of the {@link TokenRatesController}. + */ +export type TokenRatesControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + TokenRatesControllerState +>; + +/** + * The actions that can be performed using the {@link TokenRatesController}. + */ +export type TokenRatesControllerActions = TokenRatesControllerGetStateAction; + +/** + * The event that {@link TokenRatesController} can emit. + */ +export type TokenRatesControllerStateChangeEvent = ControllerStateChangeEvent< + typeof controllerName, + TokenRatesControllerState +>; + +/** + * The events that {@link TokenRatesController} can emit. + */ +export type TokenRatesControllerEvents = TokenRatesControllerStateChangeEvent; + +/** + * The messenger of the {@link TokenRatesController} for communication. + */ +export type TokenRatesControllerMessenger = RestrictedControllerMessenger< + typeof controllerName, + TokenRatesControllerActions | AllowedActions, + TokenRatesControllerEvents | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] +>; /** * Uses the CryptoCompare API to fetch the exchange rate between one currency @@ -152,15 +202,32 @@ async function getCurrencyConversionRate({ } } +const tokenRatesControllerMetadata = { + marketData: { persist: true, anonymous: false }, +}; + +/** + * Get the default {@link TokenRatesController} state. + * + * @returns The default {@link TokenRatesController} state. + */ +export const getDefaultTokenRatesControllerState = + (): TokenRatesControllerState => { + return { + marketData: {}, + }; + }; + /** * Controller that passively polls on a set interval for token-to-fiat exchange rates * for tokens stored in the TokensController */ -export class TokenRatesController extends StaticIntervalPollingControllerV1< - TokenRatesConfig, - TokenRatesState +export class TokenRatesController extends StaticIntervalPollingController< + typeof controllerName, + TokenRatesControllerState, + TokenRatesControllerMessenger > { - private handle?: ReturnType; + #handle?: ReturnType; #pollState = PollState.Inactive; @@ -168,127 +235,135 @@ export class TokenRatesController extends StaticIntervalPollingControllerV1< #inProcessExchangeRateUpdates: Record<`${Hex}:${string}`, Promise> = {}; - /** - * Name of this controller used during composition - */ - override name = 'TokenRatesController' as const; + #selectedAddress: string; + + #disabled: boolean; - private readonly getNetworkClientById: NetworkController['getNetworkClientById']; + #chainId: Hex; + + #ticker: string; + + #interval: number; + + #allTokens: TokensControllerState['allTokens']; + + #allDetectedTokens: TokensControllerState['allDetectedTokens']; /** * Creates a TokenRatesController instance. * * @param options - The controller options. * @param options.interval - The polling interval in ms - * @param options.threshold - The duration in ms before metadata fetched from CoinGecko is considered stale - * @param options.getNetworkClientById - Gets the network client with the given id from the NetworkController. - * @param options.chainId - The chain ID of the current network. - * @param options.ticker - The ticker for the current network. - * @param options.selectedAddress - The current selected address. - * @param options.onPreferencesStateChange - Allows subscribing to preference controller state changes. - * @param options.onTokensStateChange - Allows subscribing to token controller state changes. - * @param options.onNetworkStateChange - Allows subscribing to network state changes. - * @param options.tokenPricesService - An object in charge of retrieving token prices. - * @param config - Initial options used to configure this controller. - * @param state - Initial state to set on this controller. + * @param options.disabled - Boolean to track if network requests are blocked + * @param options.tokenPricesService - An object in charge of retrieving token price + * @param options.messenger - The controller messenger instance for communication + * @param options.state - Initial state to set on this controller */ - constructor( - { - interval = 3 * 60 * 1000, - threshold = 6 * 60 * 60 * 1000, - getNetworkClientById, - chainId: initialChainId, - ticker: initialTicker, - selectedAddress: initialSelectedAddress, - onPreferencesStateChange, - onTokensStateChange, - onNetworkStateChange, - tokenPricesService, - }: { - interval?: number; - threshold?: number; - getNetworkClientById: NetworkController['getNetworkClientById']; - chainId: Hex; - ticker: string; - selectedAddress: string; - onPreferencesStateChange: ( - listener: (preferencesState: PreferencesState) => void, - ) => void; - onTokensStateChange: ( - listener: (tokensState: TokensControllerState) => void, - ) => void; - onNetworkStateChange: ( - listener: (networkState: NetworkState) => void, - ) => void; - tokenPricesService: AbstractTokenPricesService; - }, - config?: Partial, - state?: Partial, - ) { - super(config, state); - this.defaultConfig = { - interval, - threshold, - disabled: false, - nativeCurrency: initialTicker, - chainId: initialChainId, - selectedAddress: initialSelectedAddress, - allTokens: {}, // TODO: initialize these correctly, maybe as part of BaseControllerV2 migration - allDetectedTokens: {}, - }; + constructor({ + interval = DEFAULT_INTERVAL, + disabled = false, + tokenPricesService, + messenger, + state, + }: { + interval?: number; + disabled?: boolean; + tokenPricesService: AbstractTokenPricesService; + messenger: TokenRatesControllerMessenger; + state?: Partial; + }) { + super({ + name: controllerName, + messenger, + state: { ...getDefaultTokenRatesControllerState(), ...state }, + metadata: tokenRatesControllerMetadata, + }); - this.defaultState = { - marketData: {}, - }; - this.initialize(); this.setIntervalLength(interval); - this.getNetworkClientById = getNetworkClientById; this.#tokenPricesService = tokenPricesService; + this.#disabled = disabled; + this.#interval = interval; - if (config?.disabled) { - this.configure({ disabled: true }, false, false); - } + const { chainId: currentChainId, ticker: currentTicker } = + this.#getChainIdAndTicker(); + this.#chainId = currentChainId; + this.#ticker = currentTicker; - onPreferencesStateChange(async ({ selectedAddress }) => { - if (this.config.selectedAddress !== selectedAddress) { - this.configure({ selectedAddress }); - if (this.#pollState === PollState.Active) { - await this.updateExchangeRates(); - } - } - }); + this.#selectedAddress = this.#getSelectedAddress(); - onTokensStateChange(async ({ allTokens, allDetectedTokens }) => { - const previousTokenAddresses = this.#getTokenAddresses( - this.config.chainId, - ); - this.configure({ allTokens, allDetectedTokens }); - const newTokenAddresses = this.#getTokenAddresses(this.config.chainId); - if ( - !isEqual(previousTokenAddresses, newTokenAddresses) && - this.#pollState === PollState.Active - ) { - await this.updateExchangeRates(); - } - }); + const { allTokens, allDetectedTokens } = this.#getTokensControllerState(); + this.#allTokens = allTokens; + this.#allDetectedTokens = allDetectedTokens; + + this.#subscribeToPreferencesStateChange(); + + this.#subscribeToTokensStateChange(); - onNetworkStateChange(async ({ selectedNetworkClientId }) => { - const selectedNetworkClient = getNetworkClientById( - selectedNetworkClientId, - ); - const { chainId, ticker } = selectedNetworkClient.configuration; - - if ( - this.config.chainId !== chainId || - this.config.nativeCurrency !== ticker - ) { - this.update({ ...this.defaultState }); - this.configure({ chainId, nativeCurrency: ticker }); - if (this.#pollState === PollState.Active) { + this.#subscribeToNetworkStateChange(); + } + + #subscribeToPreferencesStateChange() { + this.messagingSystem.subscribe( + 'PreferencesController:stateChange', + async (selectedAddress: string) => { + if (this.#selectedAddress !== selectedAddress) { + this.#selectedAddress = selectedAddress; + if (this.#pollState === PollState.Active) { + await this.updateExchangeRates(); + } + } + }, + ({ selectedAddress }) => { + return selectedAddress; + }, + ); + } + + #subscribeToTokensStateChange() { + this.messagingSystem.subscribe( + 'TokensController:stateChange', + async ({ allTokens, allDetectedTokens }) => { + const previousTokenAddresses = this.#getTokenAddresses(this.#chainId); + this.#allTokens = allTokens; + this.#allDetectedTokens = allDetectedTokens; + + const newTokenAddresses = this.#getTokenAddresses(this.#chainId); + if ( + !isEqual(previousTokenAddresses, newTokenAddresses) && + this.#pollState === PollState.Active + ) { await this.updateExchangeRates(); } - } - }); + }, + ({ allTokens, allDetectedTokens }) => { + return { allTokens, allDetectedTokens }; + }, + ); + } + + #subscribeToNetworkStateChange() { + this.messagingSystem.subscribe( + 'NetworkController:stateChange', + async ({ selectedNetworkClientId }) => { + const { + configuration: { chainId, ticker }, + } = this.messagingSystem.call( + 'NetworkController:getNetworkClientById', + selectedNetworkClientId, + ); + + if (this.#chainId !== chainId || this.#ticker !== ticker) { + this.update((state) => { + state.marketData = {}; + }); + this.#chainId = chainId; + this.#ticker = ticker; + if (this.#pollState === PollState.Active) { + await this.updateExchangeRates(); + } + } + }, + ); } /** @@ -298,10 +373,9 @@ export class TokenRatesController extends StaticIntervalPollingControllerV1< * @returns The list of tokens addresses for the current chain */ #getTokenAddresses(chainId: Hex): Hex[] { - const { allTokens, allDetectedTokens } = this.config; - const tokens = allTokens[chainId]?.[this.config.selectedAddress] || []; + const tokens = this.#allTokens[chainId]?.[this.#selectedAddress] || []; const detectedTokens = - allDetectedTokens[chainId]?.[this.config.selectedAddress] || []; + this.#allDetectedTokens[chainId]?.[this.#selectedAddress] || []; return [ ...new Set( @@ -312,6 +386,20 @@ export class TokenRatesController extends StaticIntervalPollingControllerV1< ].sort(); } + /** + * Allows controller to make active and passive polling requests + */ + enable(): void { + this.#disabled = false; + } + + /** + * Blocks controller from making network calls + */ + disable(): void { + this.#disabled = true; + } + /** * Start (or restart) polling. */ @@ -329,12 +417,47 @@ export class TokenRatesController extends StaticIntervalPollingControllerV1< this.#pollState = PollState.Inactive; } + #getSelectedAddress(): string { + const { selectedAddress } = this.messagingSystem.call( + 'PreferencesController:getState', + ); + + return selectedAddress; + } + + #getChainIdAndTicker(): { + chainId: Hex; + ticker: string; + } { + const { providerConfig } = this.messagingSystem.call( + 'NetworkController:getState', + ); + return { + chainId: providerConfig.chainId, + ticker: providerConfig.ticker, + }; + } + + #getTokensControllerState(): { + allTokens: TokensControllerState['allTokens']; + allDetectedTokens: TokensControllerState['allDetectedTokens']; + } { + const { allTokens, allDetectedTokens } = this.messagingSystem.call( + 'TokensController:getState', + ); + + return { + allTokens, + allDetectedTokens, + }; + } + /** * Clear the active polling timer, if present. */ #stopPoll() { - if (this.handle) { - clearTimeout(this.handle); + if (this.#handle) { + clearTimeout(this.#handle); } } @@ -346,19 +469,18 @@ export class TokenRatesController extends StaticIntervalPollingControllerV1< // Poll using recursive `setTimeout` instead of `setInterval` so that // requests don't stack if they take longer than the polling interval - this.handle = setTimeout(() => { + this.#handle = setTimeout(() => { this.#poll(); - }, this.config.interval); + }, this.#interval); } /** * Updates exchange rates for all tokens. */ async updateExchangeRates() { - const { chainId, nativeCurrency } = this.config; await this.updateExchangeRatesByChainId({ - chainId, - nativeCurrency, + chainId: this.#chainId, + nativeCurrency: this.#ticker, }); } @@ -376,7 +498,7 @@ export class TokenRatesController extends StaticIntervalPollingControllerV1< chainId: Hex; nativeCurrency: string; }) { - if (this.disabled) { + if (this.#disabled) { return; } @@ -411,8 +533,8 @@ export class TokenRatesController extends StaticIntervalPollingControllerV1< }, }; - this.update({ - marketData, + this.update((state) => { + state.marketData = marketData; }); updateSucceeded(); } catch (error: unknown) { @@ -470,6 +592,7 @@ export class TokenRatesController extends StaticIntervalPollingControllerV1< nativeCurrency, }); } + return await this.#fetchAndMapExchangeRatesForUnsupportedNativeCurrency({ tokenAddresses, nativeCurrency, @@ -483,7 +606,10 @@ export class TokenRatesController extends StaticIntervalPollingControllerV1< * @returns The controller state. */ async _executePoll(networkClientId: NetworkClientId): Promise { - const networkClient = this.getNetworkClientById(networkClientId); + const networkClient = this.messagingSystem.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); await this.updateExchangeRatesByChainId({ chainId: networkClient.configuration.chainId, nativeCurrency: networkClient.configuration.ticker, @@ -589,7 +715,7 @@ export class TokenRatesController extends StaticIntervalPollingControllerV1< ] = await Promise.all([ this.#fetchAndMapExchangeRatesForSupportedNativeCurrency({ tokenAddresses, - chainId: this.config.chainId, + chainId: this.#chainId, nativeCurrency: FALL_BACK_VS_CURRENCY, }), getCurrencyConversionRate({ diff --git a/packages/assets-controllers/src/index.ts b/packages/assets-controllers/src/index.ts index 030ead94ccc..88cf60275f2 100644 --- a/packages/assets-controllers/src/index.ts +++ b/packages/assets-controllers/src/index.ts @@ -65,12 +65,20 @@ export type { } from './TokenListController'; export { TokenListController } from './TokenListController'; export type { - Token, - TokenRatesConfig, ContractExchangeRates, - TokenRatesState, + ContractMarketData, + Token, + TokenRatesControllerActions, + TokenRatesControllerEvents, + TokenRatesControllerGetStateAction, + TokenRatesControllerMessenger, + TokenRatesControllerState, + TokenRatesControllerStateChangeEvent, +} from './TokenRatesController'; +export { + getDefaultTokenRatesControllerState, + TokenRatesController, } from './TokenRatesController'; -export { TokenRatesController } from './TokenRatesController'; export type { TokensControllerState, TokensControllerActions,