diff --git a/app/components/UI/AccountApproval/index.test.tsx b/app/components/UI/AccountApproval/index.test.tsx index 0afe79dd39e..29e438ead16 100644 --- a/app/components/UI/AccountApproval/index.test.tsx +++ b/app/components/UI/AccountApproval/index.test.tsx @@ -48,6 +48,13 @@ const mockInitialState = { }, }, }, + TokensController: { + allTokens: { + '0x1': { + '0xc4966c0d659d99699bfd7eb54d8fafee40e4a756': [], + }, + }, + }, }, }, }; diff --git a/app/components/UI/AccountFromToInfoCard/AccountFromToInfoCard.test.tsx b/app/components/UI/AccountFromToInfoCard/AccountFromToInfoCard.test.tsx index 68db9cb046b..c650e8640f5 100644 --- a/app/components/UI/AccountFromToInfoCard/AccountFromToInfoCard.test.tsx +++ b/app/components/UI/AccountFromToInfoCard/AccountFromToInfoCard.test.tsx @@ -38,7 +38,13 @@ const mockInitialState: DeepPartial = { }, }, TokenBalancesController: { - tokenBalances: { }, + tokenBalances: { + '0x326836cc6cd09B5aa59B81A7F72F25FcC0136b95': { + '0x5': { + '0x326836cc6cd09B5aa59B81A7F72F25FcC0136b95': '0x2b46', + }, + }, + }, }, AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE, }, diff --git a/app/components/UI/AssetOverview/AssetOverview.test.tsx b/app/components/UI/AssetOverview/AssetOverview.test.tsx index e84206b8c13..8be0735dace 100644 --- a/app/components/UI/AssetOverview/AssetOverview.test.tsx +++ b/app/components/UI/AssetOverview/AssetOverview.test.tsx @@ -10,8 +10,13 @@ import { MOCK_ADDRESS_2, } from '../../../util/test/accountsControllerTestUtils'; import { createBuyNavigationDetails } from '../Ramp/routes/utils'; -import { getDecimalChainId } from '../../../util/networks'; +import { + getDecimalChainId, + isPortfolioViewEnabled, +} from '../../../util/networks'; import { TokenOverviewSelectorsIDs } from '../../../../e2e/selectors/wallet/TokenOverview.selectors'; +// eslint-disable-next-line import/no-namespace +import * as networks from '../../../util/networks'; const MOCK_CHAIN_ID = '0x1'; @@ -43,6 +48,15 @@ const mockInitialState = { }, } as const, }, + CurrencyRateController: { + conversionRate: { + ETH: { + conversionDate: 1732572535.47, + conversionRate: 3432.53, + usdConversionRate: 3432.53, + }, + }, + }, }, settings: { primaryCurrency: 'ETH', @@ -51,6 +65,15 @@ const mockInitialState = { const mockNavigate = jest.fn(); const navigate = mockNavigate; +const mockNetworkConfiguration = { + rpcEndpoints: [ + { + networkClientId: 'mockNetworkClientId', + }, + ], + defaultRpcEndpointIndex: 0, +}; + jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); return { @@ -72,9 +95,21 @@ jest.mock('../../hooks/useStyles', () => ({ }), })); +jest.mock('../../../core/Engine', () => ({ + context: { + NetworkController: { + getNetworkConfigurationByChainId: jest + .fn() + .mockReturnValue(mockNetworkConfiguration), + setActiveNetwork: jest.fn().mockResolvedValue(undefined), + }, + }, +})); + const asset = { balance: '400', balanceFiat: '1500', + chainId: MOCK_CHAIN_ID, logo: 'https://upload.wikimedia.org/wikipedia/commons/0/05/Ethereum_logo_2014.svg', symbol: 'ETH', name: 'Ethereum', @@ -87,6 +122,10 @@ const asset = { }; describe('AssetOverview', () => { + beforeEach(() => { + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(false); + }); + it('should render correctly', async () => { const container = renderWithProvider( , @@ -95,6 +134,16 @@ describe('AssetOverview', () => { expect(container).toMatchSnapshot(); }); + it('should render correctly when portfolio view is enabled', async () => { + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(true); + + const container = renderWithProvider( + , + { state: mockInitialState }, + ); + expect(container).toMatchSnapshot(); + }); + it('should handle buy button press', async () => { const { getByTestId } = renderWithProvider( , @@ -133,13 +182,34 @@ describe('AssetOverview', () => { const swapButton = getByTestId('token-swap-button'); fireEvent.press(swapButton); - expect(navigate).toHaveBeenCalledWith('Swaps', { - params: { - sourcePage: 'MainView', - sourceToken: asset.address, - }, - screen: 'SwapsAmountView', - }); + if (isPortfolioViewEnabled()) { + expect(navigate).toHaveBeenCalledTimes(3); + expect(navigate).toHaveBeenNthCalledWith(1, 'RampBuy', { + screen: 'GetStarted', + params: { + address: asset.address, + chainId: getDecimalChainId(MOCK_CHAIN_ID), + }, + }); + expect(navigate).toHaveBeenNthCalledWith(2, 'SendFlowView', {}); + expect(navigate).toHaveBeenNthCalledWith(3, 'Swaps', { + screen: 'SwapsAmountView', + params: { + sourcePage: 'MainView', + address: asset.address, + chainId: MOCK_CHAIN_ID, + }, + }); + } else { + expect(navigate).toHaveBeenCalledWith('Swaps', { + screen: 'SwapsAmountView', + params: { + sourcePage: 'MainView', + sourceToken: asset.address, + chainId: '0x1', + }, + }); + } }); it('should not render swap button if displaySwapsButton is false', async () => { diff --git a/app/components/UI/AssetOverview/AssetOverview.tsx b/app/components/UI/AssetOverview/AssetOverview.tsx index 55ce2b8f222..dcb345b1904 100644 --- a/app/components/UI/AssetOverview/AssetOverview.tsx +++ b/app/components/UI/AssetOverview/AssetOverview.tsx @@ -1,8 +1,9 @@ -import { zeroAddress } from 'ethereumjs-util'; import React, { useCallback, useEffect } from 'react'; import { TouchableOpacity, View } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { useDispatch, useSelector } from 'react-redux'; +import { Hex } from '@metamask/utils'; +import { getNativeTokenAddress } from '@metamask/assets-controllers'; import { strings } from '../../../../locales/i18n'; import { TokenOverviewSelectorsIDs } from '../../../../e2e/selectors/wallet/TokenOverview.selectors'; import { newAssetTransaction } from '../../../actions/transaction'; @@ -11,15 +12,26 @@ import Engine from '../../../core/Engine'; import { selectChainId, selectTicker, + selectNativeCurrencyByChainId, } from '../../../selectors/networkController'; import { selectConversionRate, selectCurrentCurrency, + selectCurrencyRates, } from '../../../selectors/currencyRateController'; -import { selectContractExchangeRates } from '../../../selectors/tokenRatesController'; +import { + selectContractExchangeRates, + selectTokenMarketData, +} from '../../../selectors/tokenRatesController'; import { selectAccountsByChainId } from '../../../selectors/accountTrackerController'; -import { selectContractBalances } from '../../../selectors/tokenBalancesController'; -import { selectSelectedInternalAccountFormattedAddress } from '../../../selectors/accountsController'; +import { + selectContractBalances, + selectTokensBalances, +} from '../../../selectors/tokenBalancesController'; +import { + selectSelectedInternalAccountAddress, + selectSelectedInternalAccountFormattedAddress, +} from '../../../selectors/accountsController'; import Logger from '../../../util/Logger'; import { safeToChecksumAddress } from '../../../util/address'; import { @@ -46,9 +58,12 @@ import Routes from '../../../constants/navigation/Routes'; import TokenDetails from './TokenDetails'; import { RootState } from '../../../reducers'; import useGoToBridge from '../Bridge/utils/useGoToBridge'; -import SwapsController from '@metamask/swaps-controller'; +import SwapsController, { swapsUtils } from '@metamask/swaps-controller'; import { MetaMetricsEvents } from '../../../core/Analytics'; -import { getDecimalChainId } from '../../../util/networks'; +import { + getDecimalChainId, + isPortfolioViewEnabled, +} from '../../../util/networks'; import { useMetrics } from '../../../components/hooks/useMetrics'; import { createBuyNavigationDetails } from '../Ramp/routes/utils'; import { TokenI } from '../Tokens/types'; @@ -67,8 +82,12 @@ const AssetOverview: React.FC = ({ }: AssetOverviewProps) => { const navigation = useNavigation(); const [timePeriod, setTimePeriod] = React.useState('1d'); - const currentCurrency = useSelector(selectCurrentCurrency); + const selectedInternalAccountAddress = useSelector( + selectSelectedInternalAccountAddress, + ); const conversionRate = useSelector(selectConversionRate); + const conversionRateByTicker = useSelector(selectCurrencyRates); + const currentCurrency = useSelector(selectCurrentCurrency); const accountsByChainId = useSelector(selectAccountsByChainId); const primaryCurrency = useSelector( (state: RootState) => state.settings.primaryCurrency, @@ -79,12 +98,35 @@ const AssetOverview: React.FC = ({ ); const { trackEvent, createEventBuilder } = useMetrics(); const tokenExchangeRates = useSelector(selectContractExchangeRates); + const allTokenMarketData = useSelector(selectTokenMarketData); const tokenBalances = useSelector(selectContractBalances); - const chainId = useSelector((state: RootState) => selectChainId(state)); - const ticker = useSelector((state: RootState) => selectTicker(state)); + const selectedChainId = useSelector((state: RootState) => + selectChainId(state), + ); + const selectedTicker = useSelector((state: RootState) => selectTicker(state)); + + const nativeCurrency = useSelector((state: RootState) => + selectNativeCurrencyByChainId(state, asset.chainId as Hex), + ); + + const multiChainTokenBalance = useSelector(selectTokensBalances); + const chainId = isPortfolioViewEnabled() + ? (asset.chainId as Hex) + : selectedChainId; + const ticker = isPortfolioViewEnabled() ? nativeCurrency : selectedTicker; + + let currentAddress: Hex; + + if (isPortfolioViewEnabled()) { + currentAddress = asset.address as Hex; + } else { + currentAddress = asset.isETH + ? getNativeTokenAddress(chainId as Hex) + : (asset.address as Hex); + } const { data: prices = [], isLoading } = useTokenHistoricalPrices({ - address: asset.isETH ? zeroAddress() : asset.address, + address: currentAddress, chainId, timePeriod, vsCurrency: currentCurrency, @@ -119,7 +161,41 @@ const AssetOverview: React.FC = ({ }); }; + const handleSwapNavigation = useCallback(() => { + navigation.navigate('Swaps', { + screen: 'SwapsAmountView', + params: { + sourceToken: asset.address ?? swapsUtils.NATIVE_SWAPS_TOKEN_ADDRESS, + sourcePage: 'MainView', + chainId: asset.chainId, + }, + }); + }, [navigation, asset.address, asset.chainId]); + const onSend = async () => { + if (isPortfolioViewEnabled()) { + navigation.navigate(Routes.WALLET.HOME, { + screen: Routes.WALLET.TAB_STACK_FLOW, + params: { + screen: Routes.WALLET_VIEW, + }, + }); + + if (asset.chainId !== selectedChainId) { + const { NetworkController } = Engine.context; + const networkConfiguration = + NetworkController.getNetworkConfigurationByChainId( + asset.chainId as Hex, + ); + + const networkClientId = + networkConfiguration?.rpcEndpoints?.[ + networkConfiguration.defaultRpcEndpointIndex + ]?.networkClientId; + + await NetworkController.setActiveNetwork(networkClientId as string); + } + } if (asset.isETH && ticker) { dispatch(newAssetTransaction(getEther(ticker))); } else { @@ -128,25 +204,58 @@ const AssetOverview: React.FC = ({ navigation.navigate('SendFlowView', {}); }; - const goToSwaps = () => { - navigation.navigate('Swaps', { - screen: 'SwapsAmountView', - params: { - sourceToken: asset.address, - sourcePage: 'MainView', - }, - }); - trackEvent( - createEventBuilder(MetaMetricsEvents.SWAP_BUTTON_CLICKED) - .addProperties({ - text: 'Swap', - tokenSymbol: '', - location: 'TokenDetails', - chain_id: getDecimalChainId(chainId), - }) - .build(), - ); - }; + const goToSwaps = useCallback(() => { + if (isPortfolioViewEnabled()) { + navigation.navigate(Routes.WALLET.HOME, { + screen: Routes.WALLET.TAB_STACK_FLOW, + params: { + screen: Routes.WALLET_VIEW, + }, + }); + if (asset.chainId !== selectedChainId) { + const { NetworkController } = Engine.context; + const networkConfiguration = + NetworkController.getNetworkConfigurationByChainId( + asset.chainId as Hex, + ); + + const networkClientId = + networkConfiguration?.rpcEndpoints?.[ + networkConfiguration.defaultRpcEndpointIndex + ]?.networkClientId; + + NetworkController.setActiveNetwork(networkClientId as string).then( + () => { + setTimeout(() => { + handleSwapNavigation(); + }, 500); + }, + ); + } else { + handleSwapNavigation(); + } + } else { + handleSwapNavigation(); + trackEvent( + createEventBuilder(MetaMetricsEvents.SWAP_BUTTON_CLICKED) + .addProperties({ + text: 'Swap', + tokenSymbol: '', + location: 'TokenDetails', + chain_id: getDecimalChainId(asset.chainId), + }) + .build(), + ); + } + }, [ + navigation, + asset.chainId, + selectedChainId, + trackEvent, + createEventBuilder, + handleSwapNavigation, + ]); + const onBuy = () => { navigation.navigate( ...createBuyNavigationDetails({ @@ -209,14 +318,21 @@ const AssetOverview: React.FC = ({ )), [handleSelectTimePeriod, timePeriod], ); - const itemAddress = safeToChecksumAddress(asset.address); - const exchangeRate = itemAddress - ? tokenExchangeRates?.[itemAddress]?.price - : undefined; + + let exchangeRate: number | undefined; + if (!isPortfolioViewEnabled()) { + exchangeRate = itemAddress + ? tokenExchangeRates?.[itemAddress as Hex]?.price + : undefined; + } else { + const currentChainId = chainId as Hex; + exchangeRate = + allTokenMarketData?.[currentChainId]?.[itemAddress as Hex]?.price; + } let balance, balanceFiat; - if (asset.isETH) { + if (asset.isETH || asset.isNative) { balance = renderFromWei( //@ts-expect-error - This should be fixed at the accountsController selector level, ongoing discussion accountsByChainId[toHexadecimal(chainId)][selectedAddress]?.balance, @@ -230,9 +346,22 @@ const AssetOverview: React.FC = ({ currentCurrency, ); } else { + const multiChainTokenBalanceHex = + itemAddress && + multiChainTokenBalance?.[selectedInternalAccountAddress as Hex]?.[ + chainId as Hex + ]?.[itemAddress as Hex]; + + const selectedTokenBalanceHex = + itemAddress && tokenBalances?.[itemAddress as Hex]; + + const tokenBalanceHex = isPortfolioViewEnabled() + ? multiChainTokenBalanceHex + : selectedTokenBalanceHex; + balance = - itemAddress && tokenBalances?.[itemAddress] - ? renderFromTokenMinimalUnit(tokenBalances[itemAddress], asset.decimals) + itemAddress && tokenBalanceHex + ? renderFromTokenMinimalUnit(tokenBalanceHex, asset.decimals) : 0; balanceFiat = balanceToFiat( balance, @@ -243,23 +372,37 @@ const AssetOverview: React.FC = ({ } let mainBalance, secondaryBalance; - if (primaryCurrency === 'ETH') { - mainBalance = `${balance} ${asset.symbol}`; - secondaryBalance = balanceFiat; + if (!isPortfolioViewEnabled()) { + if (primaryCurrency === 'ETH') { + mainBalance = `${balance} ${asset.symbol}`; + secondaryBalance = balanceFiat; + } else { + mainBalance = !balanceFiat ? `${balance} ${asset.symbol}` : balanceFiat; + secondaryBalance = !balanceFiat + ? balanceFiat + : `${balance} ${asset.symbol}`; + } } else { - mainBalance = !balanceFiat ? `${balance} ${asset.symbol}` : balanceFiat; - secondaryBalance = !balanceFiat - ? balanceFiat - : `${balance} ${asset.symbol}`; + mainBalance = `${balance} ${asset.ticker}`; + secondaryBalance = exchangeRate ? asset.balanceFiat : ''; } let currentPrice = 0; let priceDiff = 0; - if (asset.isETH) { - currentPrice = conversionRate || 0; - } else if (exchangeRate && conversionRate) { - currentPrice = exchangeRate * conversionRate; + if (!isPortfolioViewEnabled()) { + if (asset.isETH) { + currentPrice = conversionRate || 0; + } else if (exchangeRate && conversionRate) { + currentPrice = exchangeRate * conversionRate; + } + } else { + const tickerConversionRate = + conversionRateByTicker?.[nativeCurrency]?.conversionRate ?? 0; + currentPrice = + exchangeRate && tickerConversionRate + ? exchangeRate * tickerConversionRate + : 0; } const comparePrice = prices[0]?.[1] || 0; diff --git a/app/components/UI/AssetOverview/Balance/Balance.tsx b/app/components/UI/AssetOverview/Balance/Balance.tsx index afc9b9379af..82662417e44 100644 --- a/app/components/UI/AssetOverview/Balance/Balance.tsx +++ b/app/components/UI/AssetOverview/Balance/Balance.tsx @@ -14,7 +14,7 @@ import { isLineaMainnetByChainId, isMainnetByChainId, isTestNet, - isPortfolioViewEnabledFunction, + isPortfolioViewEnabled, } from '../../../../util/networks'; import images from '../../../../images/image-icons'; import BadgeWrapper from '../../../../component-library/components/Badges/BadgeWrapper'; @@ -46,7 +46,7 @@ interface BalanceProps { export const NetworkBadgeSource = (chainId: Hex, ticker: string) => { const isMainnet = isMainnetByChainId(chainId); const isLineaMainnet = isLineaMainnetByChainId(chainId); - if (!isPortfolioViewEnabledFunction()) { + if (!isPortfolioViewEnabled()) { if (isTestNet(chainId)) return getTestNetImageByChainId(chainId); if (isMainnet) return images.ETHEREUM; @@ -95,14 +95,16 @@ const Balance = ({ asset, mainBalance, secondaryBalance }: BalanceProps) => { const networkName = useSelector(selectNetworkName); const chainId = useSelector(selectChainId); + const tokenChainId = isPortfolioViewEnabled() ? asset.chainId : chainId; + const ticker = asset.symbol; const renderNetworkAvatar = useCallback(() => { - if (!isPortfolioViewEnabledFunction() && asset.isETH) { + if (!isPortfolioViewEnabled() && asset.isETH) { return ; } - if (isPortfolioViewEnabledFunction() && asset.isNative) { + if (isPortfolioViewEnabled() && asset.isNative) { return ( { balance={secondaryBalance} onPress={() => !asset.isETH && + !asset.isNative && navigation.navigate('AssetDetails', { chainId: asset.chainId, address: asset.address, @@ -153,8 +156,8 @@ const Balance = ({ asset, mainBalance, secondaryBalance }: BalanceProps) => { badgeElement={ } > diff --git a/app/components/UI/AssetOverview/Balance/index.test.tsx b/app/components/UI/AssetOverview/Balance/index.test.tsx index f3070b52a3f..ba5dd5f2fc3 100644 --- a/app/components/UI/AssetOverview/Balance/index.test.tsx +++ b/app/components/UI/AssetOverview/Balance/index.test.tsx @@ -8,7 +8,7 @@ import { Provider, useSelector } from 'react-redux'; import configureMockStore from 'redux-mock-store'; import { backgroundState } from '../../../../util/test/initial-root-state'; import { NetworkBadgeSource } from './Balance'; -import { isPortfolioViewEnabledFunction } from '../../../../util/networks'; +import { isPortfolioViewEnabled } from '../../../../util/networks'; jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), @@ -37,6 +37,8 @@ const mockDAI = { symbol: 'DAI', isETH: false, logo: 'image-path', + chainId: '0x1', + isNative: false, }; const mockETH = { @@ -52,6 +54,8 @@ const mockETH = { symbol: 'ETH', isETH: true, logo: 'image-path', + chainId: '0x1', + isNative: true, }; const mockInitialState = { @@ -67,7 +71,7 @@ jest.mock('../../../../util/networks', () => ({ jest.mock('../../../../util/networks', () => ({ ...jest.requireActual('../../../../util/networks'), - isPortfolioViewEnabledFunction: jest.fn(), + isPortfolioViewEnabled: jest.fn(), })); describe('Balance', () => { @@ -95,23 +99,27 @@ describe('Balance', () => { jest.clearAllMocks(); }); - it('should render correctly with a fiat balance', () => { - const wrapper = render( - , - ); - expect(wrapper).toMatchSnapshot(); - }); - - it('should render correctly without a fiat balance', () => { - const wrapper = render( - , - ); - expect(wrapper).toMatchSnapshot(); - }); + if (!isPortfolioViewEnabled()) { + it('should render correctly with a fiat balance', () => { + const wrapper = render( + , + ); + expect(wrapper).toMatchSnapshot(); + }); + } + + if (!isPortfolioViewEnabled()) { + it('should render correctly without a fiat balance', () => { + const wrapper = render( + , + ); + expect(wrapper).toMatchSnapshot(); + }); + } it('should fire navigation event for non native tokens', () => { const { queryByTestId } = render( @@ -155,11 +163,39 @@ describe('Balance', () => { }); it('returns Linea Mainnet image for Linea mainnet chainId isPortfolioViewEnabled is true', () => { - (isPortfolioViewEnabledFunction as jest.Mock).mockImplementation( - () => true, - ); + if (isPortfolioViewEnabled()) { + const result = NetworkBadgeSource('0xe708', 'LINEA'); + expect(result).toBeDefined(); + } + }); + }); +}); + +describe('NetworkBadgeSource', () => { + it('returns testnet image for a testnet chainId', () => { + const result = NetworkBadgeSource('0xaa36a7', 'ETH'); + expect(result).toBeDefined(); + }); + + it('returns mainnet Ethereum image for mainnet chainId', () => { + const result = NetworkBadgeSource('0x1', 'ETH'); + expect(result).toBeDefined(); + }); + + it('returns Linea Mainnet image for Linea mainnet chainId', () => { + const result = NetworkBadgeSource('0xe708', 'LINEA'); + expect(result).toBeDefined(); + }); + + it('returns undefined if no image is found', () => { + const result = NetworkBadgeSource('0x999', 'UNKNOWN'); + expect(result).toBeUndefined(); + }); + + it('returns Linea Mainnet image for Linea mainnet chainId isPortfolioViewEnabled is true', () => { + if (isPortfolioViewEnabled()) { const result = NetworkBadgeSource('0xe708', 'LINEA'); expect(result).toBeDefined(); - }); + } }); }); diff --git a/app/components/UI/AssetOverview/Price/Price.tsx b/app/components/UI/AssetOverview/Price/Price.tsx index 867441a9a87..9e65e259d4c 100644 --- a/app/components/UI/AssetOverview/Price/Price.tsx +++ b/app/components/UI/AssetOverview/Price/Price.tsx @@ -90,7 +90,10 @@ const Price = ({ {asset.symbol} )} {!isNaN(price) && ( - + {isLoading ? ( diff --git a/app/components/UI/AssetOverview/TokenDetails/TokenDetails.test.tsx b/app/components/UI/AssetOverview/TokenDetails/TokenDetails.test.tsx index 7e8d341492d..881977207bb 100644 --- a/app/components/UI/AssetOverview/TokenDetails/TokenDetails.test.tsx +++ b/app/components/UI/AssetOverview/TokenDetails/TokenDetails.test.tsx @@ -1,4 +1,6 @@ import React from 'react'; +import { Hex } from '@metamask/utils'; +import { MarketDataDetails } from '@metamask/assets-controllers'; import renderWithProvider from '../../../../util/test/renderWithProvider'; import { backgroundState } from '../../../../util/test/initial-root-state'; import TokenDetails from './'; @@ -8,9 +10,12 @@ import { selectConversionRate, selectCurrentCurrency, } from '../../../../selectors/currencyRateController'; +import { + selectProviderConfig, + selectTicker, +} from '../../../../selectors/networkController'; // eslint-disable-next-line import/no-namespace import * as reactRedux from 'react-redux'; - jest.mock('../../../../core/Engine', () => ({ getTotalFiatAccountBalance: jest.fn(), context: { @@ -80,14 +85,60 @@ const mockContractExchangeRates = { }, }; +const mockTokenMarketDataByChainId: Record< + Hex, + Record +> = { + '0x1': { + '0x6B175474E89094C44Da98b954EedeAC495271d0F': { + allTimeHigh: 0.00045049491236145674, + allTimeLow: 0.00032567089582484455, + circulatingSupply: 5210102796.32321, + currency: 'ETH', + dilutedMarketCap: 1923097.9291743594, + high1d: 0.0003703658992610993, + low1d: 0.00036798603064620616, + marketCap: 1923097.9291743594, + marketCapPercentChange1d: -0.03026, + price: 0.00036902069191213795, + priceChange1d: 0.00134711, + pricePercentChange14d: -0.01961306580879152, + pricePercentChange1d: 0.13497913251736524, + pricePercentChange1h: -0.15571963819527113, + pricePercentChange1y: -0.01608509228365429, + pricePercentChange200d: -0.0287692372426721, + pricePercentChange30d: -0.08401729203937018, + pricePercentChange7d: 0.019578202262256407, + tokenAddress: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + totalVolume: 54440.464606773865, + }, + }, +}; + describe('TokenDetails', () => { beforeAll(() => { jest.resetAllMocks(); }); it('should render correctly', () => { const useSelectorSpy = jest.spyOn(reactRedux, 'useSelector'); - useSelectorSpy.mockImplementation((selector) => { - switch (selector) { + useSelectorSpy.mockImplementation((selectorOrCallback) => { + const SELECTOR_MOCKS = { + selectTokenMarketDataByChainId: mockTokenMarketDataByChainId['0x1'], + selectConversionRateBySymbol: mockExchangeRate, + selectNativeCurrencyByChainId: 'ETH', + } as const; + + if (typeof selectorOrCallback === 'function') { + const selectorString = selectorOrCallback.toString(); + const matchedSelector = Object.keys(SELECTOR_MOCKS).find((key) => + selectorString.includes(key), + ); + if (matchedSelector) { + return SELECTOR_MOCKS[matchedSelector as keyof typeof SELECTOR_MOCKS]; + } + } + + switch (selectorOrCallback) { case selectTokenList: return mockAssets; case selectContractExchangeRates: @@ -133,10 +184,26 @@ describe('TokenDetails', () => { expect(toJSON()).toMatchSnapshot(); }); - it('should render TokenDetils without MarketDetails when marketData is null', () => { + it('should render Token Details without Market Details when marketData is null', () => { const useSelectorSpy = jest.spyOn(reactRedux, 'useSelector'); - useSelectorSpy.mockImplementation((selector) => { - switch (selector) { + const SELECTOR_MOCKS = { + selectTokenMarketDataByChainId: {}, + selectConversionRateBySymbol: mockExchangeRate, + selectNativeCurrencyByChainId: 'ETH', + } as const; + + useSelectorSpy.mockImplementation((selectorOrCallback) => { + if (typeof selectorOrCallback === 'function') { + const selectorString = selectorOrCallback.toString(); + const matchedSelector = Object.keys(SELECTOR_MOCKS).find((key) => + selectorString.includes(key), + ); + if (matchedSelector) { + return SELECTOR_MOCKS[matchedSelector as keyof typeof SELECTOR_MOCKS]; + } + } + + switch (selectorOrCallback) { case selectTokenList: return mockAssets; case selectContractExchangeRates: @@ -145,6 +212,10 @@ describe('TokenDetails', () => { return mockExchangeRate; case selectCurrentCurrency: return mockCurrentCurrency; + case selectProviderConfig: + return { ticker: 'ETH' }; + case selectTicker: + return 'ETH'; default: return undefined; } @@ -162,8 +233,24 @@ describe('TokenDetails', () => { it('should render MarketDetails without TokenDetails when tokenList is null', () => { const useSelectorSpy = jest.spyOn(reactRedux, 'useSelector'); - useSelectorSpy.mockImplementation((selector) => { - switch (selector) { + useSelectorSpy.mockImplementation((selectorOrCallback) => { + const SELECTOR_MOCKS = { + selectTokenMarketDataByChainId: mockTokenMarketDataByChainId['0x1'], + selectConversionRateBySymbol: mockExchangeRate, + selectNativeCurrencyByChainId: 'ETH', + } as const; + + if (typeof selectorOrCallback === 'function') { + const selectorString = selectorOrCallback.toString(); + const matchedSelector = Object.keys(SELECTOR_MOCKS).find((key) => + selectorString.includes(key), + ); + if (matchedSelector) { + return SELECTOR_MOCKS[matchedSelector as keyof typeof SELECTOR_MOCKS]; + } + } + + switch (selectorOrCallback) { case selectTokenList: return {}; case selectContractExchangeRates: @@ -179,9 +266,7 @@ describe('TokenDetails', () => { const { getByText, queryByText } = renderWithProvider( , - { - state: initialState, - }, + { state: initialState }, ); expect(queryByText('Token details')).toBeNull(); expect(getByText('Market details')).toBeDefined(); diff --git a/app/components/UI/AssetOverview/TokenDetails/TokenDetails.tsx b/app/components/UI/AssetOverview/TokenDetails/TokenDetails.tsx index 368e2352d23..54df1781873 100644 --- a/app/components/UI/AssetOverview/TokenDetails/TokenDetails.tsx +++ b/app/components/UI/AssetOverview/TokenDetails/TokenDetails.tsx @@ -1,4 +1,6 @@ import { zeroAddress } from 'ethereumjs-util'; +import { Hex } from '@metamask/utils'; +import { RootState } from '../../../../reducers'; import React from 'react'; import { View } from 'react-native'; import { useSelector } from 'react-redux'; @@ -7,11 +9,16 @@ import { useStyles } from '../../../../component-library/hooks'; import styleSheet from './TokenDetails.styles'; import { safeToChecksumAddress } from '../../../../util/address'; import { selectTokenList } from '../../../../selectors/tokenListController'; -import { selectContractExchangeRates } from '../../../../selectors/tokenRatesController'; import { - selectConversionRate, + selectTokenMarketDataByChainId, + selectContractExchangeRates, +} from '../../../../selectors/tokenRatesController'; +import { + selectConversionRateBySymbol, selectCurrentCurrency, + selectConversionRate, } from '../../../../selectors/currencyRateController'; +import { selectNativeCurrencyByChainId } from '../../../../selectors/networkController'; import { convertDecimalToPercentage, localizeLargeNumber, @@ -23,6 +30,7 @@ import MarketDetailsList from './MarketDetailsList'; import { TokenI } from '../../Tokens/types'; import { isPooledStakingFeatureEnabled } from '../../Stake/constants'; import StakingEarnings from '../../Stake/components/StakingEarnings'; +import { isPortfolioViewEnabled } from '../../../../util/networks'; export interface TokenDetails { contractAddress: string | null; @@ -46,20 +54,36 @@ interface TokenDetailsProps { const TokenDetails: React.FC = ({ asset }) => { const { styles } = useStyles(styleSheet, {}); - const tokenList = useSelector(selectTokenList); - const tokenExchangeRates = useSelector(selectContractExchangeRates); - const conversionRate = useSelector(selectConversionRate); + const tokenExchangeRatesByChainId = useSelector((state: RootState) => + selectTokenMarketDataByChainId(state, asset.chainId as Hex), + ); + const nativeCurrency = useSelector((state: RootState) => + selectNativeCurrencyByChainId(state, asset.chainId as Hex), + ); + const tokenExchangeRatesLegacy = useSelector(selectContractExchangeRates); + const conversionRateLegacy = useSelector(selectConversionRate); + const conversionRateBySymbol = useSelector((state: RootState) => + selectConversionRateBySymbol(state, nativeCurrency), + ); const currentCurrency = useSelector(selectCurrentCurrency); const tokenContractAddress = safeToChecksumAddress(asset.address); + const tokenList = useSelector(selectTokenList); + + const conversionRate = isPortfolioViewEnabled() + ? conversionRateBySymbol + : conversionRateLegacy; + const tokenExchangeRates = isPortfolioViewEnabled() + ? tokenExchangeRatesByChainId + : tokenExchangeRatesLegacy; let tokenMetadata; let marketData; if (asset.isETH) { - marketData = tokenExchangeRates?.[zeroAddress() as `0x${string}`]; - } else if (!asset.isETH && tokenContractAddress) { + marketData = tokenExchangeRates?.[zeroAddress() as Hex]; + } else if (tokenContractAddress) { tokenMetadata = tokenList?.[tokenContractAddress.toLowerCase()]; - marketData = tokenExchangeRates?.[tokenContractAddress]; + marketData = tokenExchangeRates?.[tokenContractAddress as Hex]; } else { Logger.log('cannot find contract address'); return null; diff --git a/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.test.tsx b/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.test.tsx index 1b0c37923d0..64f1e8da9d0 100644 --- a/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.test.tsx +++ b/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.test.tsx @@ -19,6 +19,7 @@ describe('TokenDetails', () => { beforeAll(() => { jest.resetAllMocks(); }); + it('should render correctly', () => { const useDispatchSpy = jest.spyOn(reactRedux, 'useDispatch'); useDispatchSpy.mockImplementation(() => jest.fn()); diff --git a/app/components/UI/AssetOverview/__snapshots__/AssetOverview.test.tsx.snap b/app/components/UI/AssetOverview/__snapshots__/AssetOverview.test.tsx.snap index cc3cbd21a25..4ec605677fb 100644 --- a/app/components/UI/AssetOverview/__snapshots__/AssetOverview.test.tsx.snap +++ b/app/components/UI/AssetOverview/__snapshots__/AssetOverview.test.tsx.snap @@ -1133,3 +1133,1139 @@ exports[`AssetOverview should render correctly 1`] = ` `; + +exports[`AssetOverview should render correctly when portfolio view is enabled 1`] = ` + + + + + Ethereum + ( + ETH + ) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1D + + + + + 1W + + + + + 1M + + + + + 3M + + + + + 1Y + + + + + 3Y + + + + + + + + + + + + + + + + Buy + + + + + + + + + + + + + + Swap + + + + + + + + + + + + + + Bridge + + + + + + + + + + + + + + Send + + + + + + + + + + + + + + Receive + + + + + + Your balance + + + + + + + + + + + + + + + + + + Ethereum + + + + 0 undefined + + + + + + + +`; diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js index 0ce39e4eb0a..b7763524b3e 100644 --- a/app/components/UI/Navbar/index.js +++ b/app/components/UI/Navbar/index.js @@ -1361,6 +1361,7 @@ export function getNetworkNavbarOptions( onRightPress = undefined, disableNetwork = false, contentOffset = 0, + networkName = '', ) { const innerStyles = StyleSheet.create({ headerStyle: { @@ -1385,6 +1386,7 @@ export function getNetworkNavbarOptions( disableNetwork={disableNetwork} title={title} translate={translate} + networkName={networkName} /> ), headerLeft: () => ( diff --git a/app/components/UI/NavbarTitle/index.js b/app/components/UI/NavbarTitle/index.js index 7cc2846d099..d1ce5408209 100644 --- a/app/components/UI/NavbarTitle/index.js +++ b/app/components/UI/NavbarTitle/index.js @@ -6,7 +6,6 @@ import { TouchableOpacity, View, StyleSheet } from 'react-native'; import { fontStyles, colors as importedColors } from '../../../styles/common'; import Networks, { getDecimalChainId } from '../../../util/networks'; import { strings } from '../../../../locales/i18n'; -import Device from '../../../util/device'; import { ThemeContext, mockTheme } from '../../../util/theme'; import Routes from '../../../constants/navigation/Routes'; import { MetaMetricsEvents } from '../../../core/Analytics'; diff --git a/app/components/UI/NetworkModal/__snapshots__/index.test.tsx.snap b/app/components/UI/NetworkModal/__snapshots__/index.test.tsx.snap index fa6b7d2bf5b..fb2c30e33a1 100644 --- a/app/components/UI/NetworkModal/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/NetworkModal/__snapshots__/index.test.tsx.snap @@ -237,9 +237,8 @@ exports[`NetworkDetails renders correctly 1`] = ` style={ { "alignItems": "center", - "backgroundColor": "#f2f4f6", + "backgroundColor": "#ffffff", "borderRadius": 8, - "borderWidth": 1, "height": 16, "justifyContent": "center", "overflow": "hidden", @@ -248,21 +247,24 @@ exports[`NetworkDetails renders correctly 1`] = ` } testID="network-avatar-picker" > - - T - + testID="network-avatar-image" + /> ({ + context: { + PreferencesController: { + setTokenNetworkFilter: jest.fn(), + }, + NetworkController: { + updateNetwork: jest.fn(), + addNetwork: jest.fn(), + setActiveNetwork: jest.fn(), + }, + }, +})); + interface NetworkProps { isVisible: boolean; onClose: () => void; @@ -18,27 +36,46 @@ interface NetworkProps { showPopularNetworkModal: boolean; } +const mockDispatch = jest.fn(); jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), - useDispatch: jest.fn(), + useDispatch: () => mockDispatch, useSelector: jest.fn(), })); + describe('NetworkDetails', () => { const props: NetworkProps = { isVisible: true, - onClose: () => ({}), + onClose: jest.fn(), networkConfiguration: { - chainId: '1', + chainId: '0x1', nickname: 'Test Network', - ticker: 'Test', + ticker: 'TEST', rpcUrl: 'https://localhost:8545', formattedRpcUrl: 'https://localhost:8545', rpcPrefs: { blockExplorerUrl: 'https://test.com', imageUrl: 'image' }, }, - navigation: 'navigation', + navigation: { navigate: jest.fn(), goBack: jest.fn() }, shouldNetworkSwitchPopToWallet: true, showPopularNetworkModal: true, }; + + beforeEach(() => { + jest.clearAllMocks(); + (useSelector as jest.Mock).mockImplementation((selector) => { + if (selector === selectNetworkName) return 'Ethereum Main Network'; + if (selector === selectUseSafeChainsListValidation) return true; + return {}; + }); + }); + + const renderWithTheme = (component: React.ReactNode) => + render( + + {component} + , + ); + it('renders correctly', () => { (useSelector as jest.MockedFn).mockImplementation( (selector) => { @@ -46,8 +83,31 @@ describe('NetworkDetails', () => { if (selector === selectUseSafeChainsListValidation) return true; }, ); - const { toJSON } = render(); + const { toJSON } = renderWithTheme(); expect(toJSON()).toMatchSnapshot(); }); + + it('should call setTokenNetworkFilter when switching networks', async () => { + const { getByTestId } = renderWithTheme(); + + const approveButton = getByTestId( + NetworkApprovalBottomSheetSelectorsIDs.APPROVE_BUTTON, + ); + fireEvent.press(approveButton); + + const switchButton = getByTestId( + NetworkAddedBottomSheetSelectorsIDs.SWITCH_NETWORK_BUTTON, + ); + await act(async () => { + fireEvent.press(switchButton); + }); + + expect( + Engine.context.PreferencesController.setTokenNetworkFilter, + ).toHaveBeenCalledWith({ + [props.networkConfiguration.chainId]: true, + }); + expect(mockDispatch).toHaveBeenCalled(); + }); }); diff --git a/app/components/UI/NetworkModal/index.tsx b/app/components/UI/NetworkModal/index.tsx index 53c37e95c45..30d7d4e1efd 100644 --- a/app/components/UI/NetworkModal/index.tsx +++ b/app/components/UI/NetworkModal/index.tsx @@ -22,7 +22,10 @@ import { import { useTheme } from '../../../util/theme'; import { networkSwitched } from '../../../actions/onboardNetwork'; import { NetworkApprovalBottomSheetSelectorsIDs } from '../../../../e2e/selectors/Network/NetworkApprovalBottomSheet.selectors'; -import { selectUseSafeChainsListValidation } from '../../../selectors/preferencesController'; +import { + selectTokenNetworkFilter, + selectUseSafeChainsListValidation, +} from '../../../selectors/preferencesController'; import BottomSheetFooter, { ButtonsAlignment, } from '../../../component-library/components/BottomSheets/BottomSheetFooter'; @@ -34,7 +37,10 @@ import { useMetrics } from '../../../components/hooks/useMetrics'; import { toHex } from '@metamask/controller-utils'; import { rpcIdentifierUtility } from '../../../components/hooks/useSafeChains'; import Logger from '../../../util/Logger'; -import { selectNetworkConfigurations } from '../../../selectors/networkController'; +import { + selectNetworkConfigurations, + selectIsAllNetworks, +} from '../../../selectors/networkController'; import { NetworkConfiguration, RpcEndpointType, @@ -85,6 +91,7 @@ const NetworkModals = (props: NetworkProps) => { const [showDetails, setShowDetails] = React.useState(false); const [networkAdded, setNetworkAdded] = React.useState(false); const [showCheckNetwork, setShowCheckNetwork] = React.useState(false); + const tokenNetworkFilter = useSelector(selectTokenNetworkFilter); const [alerts, setAlerts] = React.useState< { alertError: string; @@ -96,6 +103,7 @@ const NetworkModals = (props: NetworkProps) => { const isCustomNetwork = true; const showDetailsModal = () => setShowDetails(!showDetails); const showCheckNetworkModal = () => setShowCheckNetwork(!showCheckNetwork); + const isAllNetworks = useSelector(selectIsAllNetworks); const { colors } = useTheme(); const styles = createNetworkModalStyles(colors); @@ -107,6 +115,30 @@ const NetworkModals = (props: NetworkProps) => { return true; }; + const customNetworkInformation = { + chainId, + blockExplorerUrl, + chainName: nickname, + rpcUrl, + icon: imageUrl, + ticker, + alerts, + }; + + const onUpdateNetworkFilter = useCallback(() => { + const { PreferencesController } = Engine.context; + if (!isAllNetworks) { + PreferencesController.setTokenNetworkFilter({ + [customNetworkInformation.chainId]: true, + }); + } else { + PreferencesController.setTokenNetworkFilter({ + ...tokenNetworkFilter, + [customNetworkInformation.chainId]: true, + }); + } + }, [customNetworkInformation.chainId, isAllNetworks, tokenNetworkFilter]); + const addNetwork = async () => { const isValidUrl = validateRpcUrl(rpcUrl); if (showPopularNetworkModal) { @@ -170,16 +202,6 @@ const NetworkModals = (props: NetworkProps) => { selectNetworkConfigurations, ); - const customNetworkInformation = { - chainId, - blockExplorerUrl, - chainName: nickname, - rpcUrl, - icon: imageUrl, - ticker, - alerts, - }; - const checkNetwork = useCallback(async () => { if (useSafeChainsListValidation) { const alertsNetwork = await checkSafeNetwork( @@ -243,6 +265,7 @@ const NetworkModals = (props: NetworkProps) => { } if (networkClientId) { + onUpdateNetworkFilter(); await NetworkController.setActiveNetwork(networkClientId); } @@ -268,7 +291,7 @@ const NetworkModals = (props: NetworkProps) => { const { networkClientId } = updatedNetwork?.rpcEndpoints?.[updatedNetwork.defaultRpcEndpointIndex] ?? {}; - + onUpdateNetworkFilter(); await NetworkController.setActiveNetwork(networkClientId); }; @@ -337,6 +360,7 @@ const NetworkModals = (props: NetworkProps) => { addedNetwork?.rpcEndpoints?.[addedNetwork.defaultRpcEndpointIndex] ?? {}; + onUpdateNetworkFilter(); NetworkController.setActiveNetwork(networkClientId); } onClose(); diff --git a/app/components/UI/PaymentRequest/index.test.tsx b/app/components/UI/PaymentRequest/index.test.tsx index 391fe7e3b89..a01c0814d2f 100644 --- a/app/components/UI/PaymentRequest/index.test.tsx +++ b/app/components/UI/PaymentRequest/index.test.tsx @@ -39,6 +39,11 @@ const initialState = { }, }, tokens: [], + allTokens: { + '0x1': { + '0xc4955c0d639d99699bfd7ec54d9fafee40e4d272': [], + }, + }, }, NetworkController: { provider: { @@ -50,7 +55,7 @@ const initialState = { ...MOCK_ACCOUNTS_CONTROLLER_STATE, internalAccounts: { ...MOCK_ACCOUNTS_CONTROLLER_STATE.internalAccounts, - selectedAccount: {}, + selectedAccount: '30786334-3935-4563-b064-363339643939', }, }, TokenListController: { diff --git a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.test.tsx b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.test.tsx index 53621dcd7e0..650343c3958 100644 --- a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.test.tsx +++ b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.test.tsx @@ -57,6 +57,23 @@ jest.mock('../../../../../selectors/currencyRateController.ts', () => ({ selectCurrentCurrency: jest.fn(() => 'USD'), })); +// Add mock for multichain selectors +jest.mock('../../../../../selectors/multichain', () => ({ + selectAccountTokensAcrossChains: jest.fn(() => ({ + '0x1': [ + { + address: '0x0', + symbol: 'ETH', + decimals: 18, + balance: '1.5', + balanceFiat: '$3000', + isNative: true, + isETH: true, + }, + ], + })), +})); + const mockBalanceBN = toWei('1.5'); // 1.5 ETH const mockPooledStakingContractService: PooledStakingContract = { diff --git a/app/components/UI/Stake/Views/UnstakeInputView/UnstakeInputView.test.tsx b/app/components/UI/Stake/Views/UnstakeInputView/UnstakeInputView.test.tsx index 02c92e51339..b2f83588cf4 100644 --- a/app/components/UI/Stake/Views/UnstakeInputView/UnstakeInputView.test.tsx +++ b/app/components/UI/Stake/Views/UnstakeInputView/UnstakeInputView.test.tsx @@ -10,6 +10,22 @@ import { MOCK_STAKED_ETH_ASSET, } from '../../__mocks__/mockData'; +jest.mock('../../../../../selectors/multichain', () => ({ + selectAccountTokensAcrossChains: jest.fn(() => ({ + '0x1': [ + { + address: '0x0', + symbol: 'ETH', + decimals: 18, + balance: '1.5', + balanceFiat: '$3000', + isNative: true, + isETH: true, + }, + ], + })), +})); + function render(Component: React.ComponentType) { return renderScreen( Component, diff --git a/app/components/UI/Stake/components/StakingBalance/StakingBalance.test.tsx b/app/components/UI/Stake/components/StakingBalance/StakingBalance.test.tsx index 09abd65ab7f..39e0dd2084b 100644 --- a/app/components/UI/Stake/components/StakingBalance/StakingBalance.test.tsx +++ b/app/components/UI/Stake/components/StakingBalance/StakingBalance.test.tsx @@ -12,6 +12,8 @@ import { } from '../../__mocks__/mockData'; import { createMockAccountsControllerState } from '../../../../../util/test/accountsControllerTestUtils'; import { backgroundState } from '../../../../../util/test/initial-root-state'; +// eslint-disable-next-line import/no-namespace +import * as networks from '../../../../../util/networks'; const MOCK_ADDRESS_1 = '0x0'; @@ -130,6 +132,15 @@ describe('StakingBalance', () => { expect(toJSON()).toMatchSnapshot(); }); + it('should match the snapshot when portfolio view is enabled ', () => { + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(true); + const { toJSON } = renderWithProvider( + , + { state: mockInitialState }, + ); + expect(toJSON()).toMatchSnapshot(); + }); + it('redirects to StakeInputView on stake button click', () => { const { getByText } = renderWithProvider( , diff --git a/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap b/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap index c87de236d68..8095e5ad753 100644 --- a/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap +++ b/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap @@ -465,3 +465,471 @@ exports[`StakingBalance render matches snapshot 1`] = ` `; + +exports[`StakingBalance should match the snapshot when portfolio view is enabled 1`] = ` + + + + + + + + + + + + + + + + + + + + + Staked Ethereum + + + + + + + + + + + Unstaking 0.0010 ETH in progress. Come back in a few days to claim it. + + + + + + + + + + You can claim 0.00214 ETH. Once claimed, you'll get ETH back in your wallet. + + + + Claim + ETH + + + + + + + + Unstake + + + + + Stake more + + + + + +`; diff --git a/app/components/UI/Stake/hooks/useStakingChain.test.tsx b/app/components/UI/Stake/hooks/useStakingChain.test.tsx index c29df592c0a..a53380a458b 100644 --- a/app/components/UI/Stake/hooks/useStakingChain.test.tsx +++ b/app/components/UI/Stake/hooks/useStakingChain.test.tsx @@ -1,8 +1,9 @@ import { backgroundState } from '../../../../util/test/initial-root-state'; import { renderHookWithProvider } from '../../../../util/test/renderWithProvider'; import { toHex } from '@metamask/controller-utils'; -import useStakingChain from './useStakingChain'; +import useStakingChain, { useStakingChainByChainId } from './useStakingChain'; import { mockNetworkState } from '../../../../util/test/network'; +import { Hex } from '@metamask/utils'; const buildStateWithNetwork = (chainId: string, nickname: string) => ({ engine: { @@ -57,3 +58,41 @@ describe('useStakingChain', () => { }); }); }); + +describe('useStakingChainByChainId', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.resetAllMocks(); + }); + + it('returns true for a supported chainId (mainnet)', () => { + const { result } = renderHookWithProvider(() => + useStakingChainByChainId(toHex('1')), + ); + expect(result.current.isStakingSupportedChain).toBe(true); + }); + + it('returns true for a supported chainId (Holesky)', () => { + const { result } = renderHookWithProvider(() => + useStakingChainByChainId(toHex('17000')), + ); + expect(result.current.isStakingSupportedChain).toBe(true); + }); + + it('returns false for an unsupported chainId', () => { + const { result } = renderHookWithProvider(() => + useStakingChainByChainId(toHex('11')), + ); + expect(result.current.isStakingSupportedChain).toBe(false); + }); + + it('handles invalid chainId gracefully', () => { + const { result } = renderHookWithProvider(() => + useStakingChainByChainId('invalid-chain-id' as Hex), + ); + expect(result.current.isStakingSupportedChain).toBe(false); + }); +}); diff --git a/app/components/UI/Stake/hooks/useStakingChain.ts b/app/components/UI/Stake/hooks/useStakingChain.ts index d5da33c504e..934d7901023 100644 --- a/app/components/UI/Stake/hooks/useStakingChain.ts +++ b/app/components/UI/Stake/hooks/useStakingChain.ts @@ -1,3 +1,4 @@ +import { Hex } from '@metamask/utils'; import { useSelector } from 'react-redux'; import { getDecimalChainId } from '../../../../util/networks'; import { selectChainId } from '../../../../selectors/networkController'; @@ -13,4 +14,12 @@ const useStakingChain = () => { }; }; +export const useStakingChainByChainId = (chainId: Hex) => { + const isStakingSupportedChain = isSupportedChain(getDecimalChainId(chainId)); + + return { + isStakingSupportedChain, + }; +}; + export default useStakingChain; diff --git a/app/components/UI/Tokens/TokenList/PortfolioBalance/index.tsx b/app/components/UI/Tokens/TokenList/PortfolioBalance/index.tsx index 43257ab4d1e..92e72f96ab2 100644 --- a/app/components/UI/Tokens/TokenList/PortfolioBalance/index.tsx +++ b/app/components/UI/Tokens/TokenList/PortfolioBalance/index.tsx @@ -110,14 +110,14 @@ export const PortfolioBalance = () => { let total; if (isOriginalNativeTokenSymbol) { - if (isPortfolioViewEnabled) { + if (isPortfolioViewEnabled()) { total = totalFiatBalance ?? 0; } else { const tokenFiatTotal = balance?.tokenFiat ?? 0; const ethFiatTotal = balance?.ethFiat ?? 0; total = tokenFiatTotal + ethFiatTotal; } - } else if (isPortfolioViewEnabled) { + } else if (isPortfolioViewEnabled()) { total = totalTokenFiat ?? 0; } else { total = balance?.tokenFiat ?? 0; @@ -175,7 +175,7 @@ export const PortfolioBalance = () => { return null; } - if (isPortfolioViewEnabled) { + if (isPortfolioViewEnabled()) { return ( { const navigation = useNavigation(); const { colors } = useTheme(); - const { data: tokenBalances } = useTokenBalancesController(); + const selectedInternalAccountAddress = useSelector( + selectSelectedInternalAccountAddress, + ); + const { data: selectedChainTokenBalance } = useTokenBalancesController(); const { type } = useSelector(selectProviderConfig); - const chainId = useSelector(selectChainId); + const selectedChainId = useSelector(selectChainId); + const chainId = isPortfolioViewEnabled() + ? (asset.chainId as Hex) + : selectedChainId; const ticker = useSelector(selectTicker); const isOriginalNativeTokenSymbol = useIsOriginalNativeTokenSymbol( chainId, ticker, type, ); - const tokenExchangeRates = useSelector(selectContractExchangeRates); - const currentCurrency = useSelector(selectCurrentCurrency); - const conversionRate = useSelector(selectConversionRate); const networkName = useSelector(selectNetworkName); const primaryCurrency = useSelector( (state: RootState) => state.settings.primaryCurrency, ); + const currentCurrency = useSelector(selectCurrentCurrency); + const networkConfigurations = useSelector(selectNetworkConfigurations); + const showFiatOnTestnets = useSelector(selectShowFiatInTestnets); + + // single chain + const singleTokenExchangeRates = useSelector(selectContractExchangeRates); + const singleTokenConversionRate = useSelector(selectConversionRate); + + // multi chain + const multiChainTokenBalance = useSelector(selectTokensBalances); + const multiChainMarketData = useSelector(selectTokenMarketData); + const multiChainCurrencyRates = useSelector(selectCurrencyRates); const styles = createStyles(colors); const itemAddress = safeToChecksumAddress(asset.address); + // Choose values based on multichain or legacy + const exchangeRates = isPortfolioViewEnabled() + ? multiChainMarketData?.[chainId as Hex] + : singleTokenExchangeRates; + const tokenBalances = isPortfolioViewEnabled() + ? multiChainTokenBalance?.[selectedInternalAccountAddress as Hex]?.[ + chainId as Hex + ] + : selectedChainTokenBalance; + const nativeCurrency = + networkConfigurations?.[chainId as Hex]?.nativeCurrency; + + const conversionRate = isPortfolioViewEnabled() + ? multiChainCurrencyRates?.[nativeCurrency]?.conversionRate || 0 + : singleTokenConversionRate; + const { balanceFiat, balanceValueFormatted } = deriveBalanceFromAssetMarketDetails( asset, - tokenExchangeRates, - tokenBalances, - conversionRate, - currentCurrency, + exchangeRates || {}, + tokenBalances || {}, + conversionRate || 0, + currentCurrency || '', ); - const pricePercentChange1d = itemAddress - ? tokenExchangeRates?.[itemAddress as `0x${string}`]?.pricePercentChange1d - : tokenExchangeRates?.[zeroAddress() as Hex]?.pricePercentChange1d; + let pricePercentChange1d: number; + + if (isPortfolioViewEnabled()) { + const tokenPercentageChange = asset.address + ? multiChainMarketData?.[chainId as Hex]?.[asset.address as Hex] + ?.pricePercentChange1d + : 0; + + pricePercentChange1d = asset.isNative + ? multiChainMarketData?.[chainId as Hex]?.[zeroAddress() as Hex] + ?.pricePercentChange1d + : tokenPercentageChange; + } else { + pricePercentChange1d = itemAddress + ? exchangeRates?.[itemAddress as Hex]?.pricePercentChange1d + : exchangeRates?.[zeroAddress() as Hex]?.pricePercentChange1d; + } // render balances according to primary currency let mainBalance; let secondaryBalance; + const shouldNotShowBalanceOnTestnets = + isTestNet(chainId) && !showFiatOnTestnets; // Set main and secondary balances based on the primary currency and asset type. if (primaryCurrency === 'ETH') { // Default to displaying the formatted balance value and its fiat equivalent. mainBalance = balanceValueFormatted; secondaryBalance = balanceFiat; - // For ETH as a native currency, adjust display based on network safety. if (asset.isETH) { // Main balance always shows the formatted balance value for ETH. mainBalance = balanceValueFormatted; // Display fiat value as secondary balance only for original native tokens on safe networks. - secondaryBalance = isOriginalNativeTokenSymbol ? balanceFiat : null; - } - } else { - // For non-ETH currencies, determine balances based on the presence of fiat value. - mainBalance = !balanceFiat ? balanceValueFormatted : balanceFiat; - secondaryBalance = !balanceFiat ? balanceFiat : balanceValueFormatted; - - // Adjust balances for native currencies in non-ETH scenarios. - if (asset.isETH) { - // Main balance logic: Show crypto value if fiat is absent or fiat value on safe networks. - if (!balanceFiat) { - mainBalance = balanceValueFormatted; // Show crypto value if fiat is not preferred - } else if (isOriginalNativeTokenSymbol) { - mainBalance = balanceFiat; // Show fiat value if it's a safe network + if (isPortfolioViewEnabled()) { + secondaryBalance = shouldNotShowBalanceOnTestnets + ? undefined + : balanceFiat; } else { - mainBalance = ''; // Otherwise, set to an empty string + secondaryBalance = isOriginalNativeTokenSymbol ? balanceFiat : null; } - // Secondary balance mirrors the main balance logic for consistency. - secondaryBalance = !balanceFiat ? balanceFiat : balanceValueFormatted; + } + } else { + secondaryBalance = balanceValueFormatted; + if (shouldNotShowBalanceOnTestnets && !balanceFiat) { + mainBalance = undefined; + } else { + mainBalance = + balanceFiat ?? strings('wallet.unable_to_find_conversion_rate'); } } @@ -154,38 +211,108 @@ export const TokenListItem = ({ const isMainnet = isMainnetByChainId(chainId); const isLineaMainnet = isLineaMainnetByChainId(chainId); - const { isStakingSupportedChain } = useStakingChain(); + const { isStakingSupportedChain } = useStakingChainByChainId(chainId); - const NetworkBadgeSource = () => { - if (isTestNet(chainId)) return getTestNetImageByChainId(chainId); + const networkBadgeSource = useCallback( + (currentChainId: Hex) => { + if (!isPortfolioViewEnabled()) { + if (isTestNet(chainId)) return getTestNetImageByChainId(chainId); + if (isMainnet) return images.ETHEREUM; - if (isMainnet) return images.ETHEREUM; + if (isLineaMainnet) return images['LINEA-MAINNET']; - if (isLineaMainnet) return images['LINEA-MAINNET']; + if (CustomNetworkImgMapping[chainId]) { + return CustomNetworkImgMapping[chainId]; + } - if (CustomNetworkImgMapping[chainId]) { - return CustomNetworkImgMapping[chainId]; - } + return ticker ? images[ticker] : undefined; + } + if (isTestNet(currentChainId)) + return getTestNetImageByChainId(currentChainId); + const defaultNetwork = getDefaultNetworkByChainId(currentChainId) as + | { + imageSource: string; + } + | undefined; - return ticker ? images[ticker] : undefined; - }; + if (defaultNetwork) { + return defaultNetwork.imageSource; + } + + const unpopularNetwork = UnpopularNetworkList.find( + (networkConfig) => networkConfig.chainId === currentChainId, + ); + + const customNetworkImg = CustomNetworkImgMapping[currentChainId]; + + const popularNetwork = PopularList.find( + (networkConfig) => networkConfig.chainId === currentChainId, + ); + + const network = unpopularNetwork || popularNetwork; + if (network) { + return network.rpcPrefs.imageSource; + } + if (customNetworkImg) { + return customNetworkImg; + } + }, + [chainId, isLineaMainnet, isMainnet, ticker], + ); const onItemPress = (token: TokenI) => { // if the asset is staked, navigate to the native asset details if (asset.isStaked) { - return navigation.navigate('Asset', { ...token.nativeAsset }); + return navigation.navigate('Asset', { + ...token.nativeAsset, + }); } navigation.navigate('Asset', { ...token, }); }; + const renderNetworkAvatar = useCallback(() => { + if (!isPortfolioViewEnabled() && asset.isETH) { + return ; + } + + if (isPortfolioViewEnabled() && asset.isNative) { + return ( + + ); + } + + return ( + + ); + }, [ + asset.ticker, + asset.isETH, + asset.image, + asset.symbol, + asset.isNative, + styles.ethLogo, + chainId, + ]); + return ( } > - {asset.isETH ? ( - - ) : ( - - )} + {renderNetworkAvatar()} diff --git a/app/components/UI/Tokens/TokensBottomSheet/TokenFilterBottomSheet.tsx b/app/components/UI/Tokens/TokensBottomSheet/TokenFilterBottomSheet.tsx index 82eeffc8ddd..0a8871988c9 100644 --- a/app/components/UI/Tokens/TokensBottomSheet/TokenFilterBottomSheet.tsx +++ b/app/components/UI/Tokens/TokensBottomSheet/TokenFilterBottomSheet.tsx @@ -3,6 +3,7 @@ import { useSelector } from 'react-redux'; import { selectChainId, selectNetworkConfigurations, + selectIsAllNetworks, } from '../../../../selectors/networkController'; import { selectTokenNetworkFilter } from '../../../../selectors/preferencesController'; import BottomSheet, { @@ -33,6 +34,7 @@ const TokenFilterBottomSheet = () => { const chainId = useSelector(selectChainId); const tokenNetworkFilter = useSelector(selectTokenNetworkFilter); + const isAllNetworks = useSelector(selectIsAllNetworks); const allNetworksEnabled = useMemo( () => enableAllNetworksFilter(allNetworks), [allNetworks], @@ -59,8 +61,6 @@ const TokenFilterBottomSheet = () => { const isCurrentNetwork = Boolean( tokenNetworkFilter[chainId] && Object.keys(tokenNetworkFilter).length === 1, ); - const isAllNetworks = - Object.keys(tokenNetworkFilter).length === Object.keys(allNetworks).length; return ( diff --git a/app/components/UI/Tokens/__snapshots__/index.test.tsx.snap b/app/components/UI/Tokens/__snapshots__/index.test.tsx.snap index b1fa6c4ca1f..773a9b7e4e1 100644 --- a/app/components/UI/Tokens/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/Tokens/__snapshots__/index.test.tsx.snap @@ -1,5 +1,1766 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Tokens Portfolio View should match the snapshot when portfolio view is enabled 1`] = ` + + + + + + + + + + + + + Amount + + + + + + + + + + + + + + + + + + + + + + Ethereum Main Network + + + + + + + + + + + + + + + + You don't have any tokens! + + + + + + + + + + + + + +`; + +exports[`Tokens render matches snapshot 1`] = ` + + + + + + + + + + + + + Amount + + + + + + + + + + + + + + + + + + + + + Sort by + + + + + + + Import + + + + + } + data={ + [ + { + "address": "0x0", + "balanceFiat": "< $0.01", + "decimals": 18, + "iconUrl": "", + "isETH": true, + "isStaked": false, + "name": "Ethereum", + "symbol": "ETH", + "tokenFiatAmount": NaN, + }, + { + "address": "0x01", + "balanceFiat": "$0", + "decimals": 18, + "iconUrl": "", + "name": "Bat", + "symbol": "BAT", + "tokenFiatAmount": NaN, + }, + ] + } + getItem={[Function]} + getItemCount={[Function]} + keyExtractor={[Function]} + onContentSizeChange={[Function]} + onLayout={[Function]} + onMomentumScrollBegin={[Function]} + onMomentumScrollEnd={[Function]} + onScroll={[Function]} + onScrollBeginDrag={[Function]} + onScrollEndDrag={[Function]} + refreshControl={ + + } + removeClippedSubviews={false} + renderItem={[Function]} + scrollEventThrottle={50} + stickyHeaderIndices={[]} + testID="token-list" + viewabilityConfigCallbackPairs={[]} + > + + + + + + + + + + + + + + + + + + + + + + + + + Ethereum + + + + • + + Earn + + + + + + + + + + + + + ETH + + + < $0.01 + + + + + + + + + + + + + + + + + + + + + + + + Bat + + + + + + + + + + < 0.00001 BAT + + + < $0.01 + + + + + + + + + + Don't see your token? + + + + Import tokens + + + + + + + + + + + + + + + + + + +`; + exports[`Tokens should hide zero balance tokens when setting is on 1`] = ` ({ showSimpleNotification: jest.fn(() => Promise.resolve()), })); +const selectedAddress = '0x123'; + jest.mock('./TokensBottomSheet', () => ({ createTokensBottomSheetNavDetails: jest.fn(() => ['BottomSheetScreen', {}]), })); @@ -53,11 +57,53 @@ jest.mock('../../../core/Engine', () => ({ }), findNetworkClientIdByChainId: () => 'mainnet', }, + AccountsController: { + state: { + internalAccounts: { + selectedAccount: '1', + accounts: { + '1': { + address: selectedAddress, + }, + }, + }, + }, + }, }, })); -const selectedAddress = '0x123'; - +const mockTokens = { + '0x1': { + [selectedAddress]: [ + { + name: 'Ethereum', + symbol: 'ETH', + address: '0x0', + decimals: 18, + isETH: true, + isStaked: false, + balanceFiat: '< $0.01', + iconUrl: '', + }, + { + name: 'Bat', + symbol: 'BAT', + address: '0x01', + decimals: 18, + balanceFiat: '$0', + iconUrl: '', + }, + { + name: 'Link', + symbol: 'LINK', + address: '0x02', + decimals: 18, + balanceFiat: '$0', + iconUrl: '', + }, + ], + }, +}; const initialState = { engine: { backgroundState: { @@ -80,7 +126,7 @@ const initialState = { address: '0x0', decimals: 18, isETH: true, - + isStaked: false, balanceFiat: '< $0.01', iconUrl: '', }, @@ -101,6 +147,38 @@ const initialState = { iconUrl: '', }, ], + allTokens: { + '0x1': { + [selectedAddress]: [ + { + name: 'Ethereum', + symbol: 'ETH', + address: '0x0', + decimals: 18, + isETH: true, + + balanceFiat: '< $0.01', + iconUrl: '', + }, + { + name: 'Bat', + symbol: 'BAT', + address: '0x01', + decimals: 18, + balanceFiat: '$0', + iconUrl: '', + }, + { + name: 'Link', + symbol: 'LINK', + address: '0x02', + decimals: 18, + balanceFiat: '$0', + iconUrl: '', + }, + ], + }, + }, detectedTokens: [], }, TokenRatesController: { @@ -172,11 +250,10 @@ jest.mock('../../UI/Stake/hooks/useStakingEligibility', () => ({ })), })); -const mockIsPortfolioViewEnabled = jest.fn(); - -jest.mock('../../../util/networks', () => ({ - ...jest.requireActual('../../../util/networks'), - isPortfolioViewEnabled: mockIsPortfolioViewEnabled, +jest.mock('../Stake/hooks/useStakingChain', () => ({ + useStakingChainByChainId: () => ({ + isStakingSupportedChain: true, + }), })); const Stack = createStackNavigator(); @@ -199,7 +276,7 @@ const renderComponent = (state: any = {}) => describe('Tokens', () => { beforeEach(() => { - mockIsPortfolioViewEnabled.mockReturnValue(false); + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(false); }); afterEach(() => { @@ -212,6 +289,11 @@ describe('Tokens', () => { expect(toJSON()).toMatchSnapshot(); }); + it('render matches snapshot', () => { + const { toJSON } = renderComponent(initialState); + expect(toJSON()).toMatchSnapshot(); + }); + it('should hide zero balance tokens when setting is on', async () => { const { toJSON, getByText, queryByText } = renderComponent(initialState); @@ -264,6 +346,7 @@ describe('Tokens', () => { ...backgroundState, TokensController: { detectedTokens: [], + allTokens: mockTokens, tokens: [ { name: 'Link', @@ -277,7 +360,7 @@ describe('Tokens', () => { }, TokenRatesController: { marketData: { - 0x1: { + '0x1': { '0x02': undefined, }, }, @@ -299,6 +382,16 @@ describe('Tokens', () => { }, }, }, + state: { + internalAccounts: { + selectedAccount: '1', + accounts: { + '1': { + address: selectedAddress, + }, + }, + }, + }, }, TokenBalancesController: { tokenBalances: { @@ -389,20 +482,23 @@ describe('Tokens', () => { }, ); - await waitFor(() => { - expect( - Engine.context.TokenDetectionController.detectTokens, - ).toHaveBeenCalled(); - expect( - Engine.context.AccountTrackerController.refresh, - ).toHaveBeenCalled(); - expect( - Engine.context.CurrencyRateController.updateExchangeRate, - ).toHaveBeenCalled(); - expect( - Engine.context.TokenRatesController.updateExchangeRatesByChainId, - ).toHaveBeenCalled(); - }); + await waitFor( + () => { + expect( + Engine.context.TokenDetectionController.detectTokens, + ).toHaveBeenCalled(); + expect( + Engine.context.AccountTrackerController.refresh, + ).toHaveBeenCalled(); + expect( + Engine.context.CurrencyRateController.updateExchangeRate, + ).toHaveBeenCalled(); + expect( + Engine.context.TokenRatesController.updateExchangeRatesByChainId, + ).toHaveBeenCalled(); + }, + { timeout: 3000 }, + ); }); it('triggers bottom sheet when sort controls are pressed', async () => { @@ -456,4 +552,149 @@ describe('Tokens', () => { }); }); }); + + it('calls onRefresh and updates state', async () => { + const { getByTestId } = renderComponent(initialState); + + fireEvent( + getByTestId(WalletViewSelectorsIDs.TOKENS_CONTAINER_LIST), + 'refresh', + { + refreshing: true, + }, + ); + + await waitFor(() => { + expect( + Engine.context.TokenDetectionController.detectTokens, + ).toHaveBeenCalled(); + expect( + Engine.context.AccountTrackerController.refresh, + ).toHaveBeenCalled(); + expect( + Engine.context.CurrencyRateController.updateExchangeRate, + ).toHaveBeenCalled(); + expect( + Engine.context.TokenRatesController.updateExchangeRatesByChainId, + ).toHaveBeenCalled(); + }); + }); + + it('hides zero balance tokens when hideZeroBalanceTokens is enabled', () => { + const { queryByText } = renderComponent(initialState); + + expect(queryByText('Link')).toBeNull(); // Zero balance token should not be visible + }); + + it('triggers sort controls when sort button is pressed', async () => { + const { getByTestId } = renderComponent(initialState); + + fireEvent.press(getByTestId(WalletViewSelectorsIDs.SORT_BY)); + + await waitFor(() => { + expect(createTokensBottomSheetNavDetails).toHaveBeenCalledWith({}); + }); + }); + + describe('Portfolio View', () => { + beforeEach(() => { + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(true); + }); + + it('should match the snapshot when portfolio view is enabled ', () => { + const { toJSON } = renderComponent(initialState); + expect(toJSON()).toMatchSnapshot(); + }); + + it('should handle network filtering correctly', () => { + const multiNetworkState = { + ...initialState, + engine: { + backgroundState: { + ...initialState.engine.backgroundState, + PreferencesController: { + selectedAddress, + tokenSortConfig: { key: 'symbol', order: 'asc' }, + tokenNetworkFilter: { + '0x1': true, + '0x89': false, + }, + }, + }, + selectedAccountTokensChains: { + '0x1': [ + { + address: '0x123', + symbol: 'ETH', + decimals: 18, + balance: '1000000000000000000', + balanceFiat: '$100', + isNative: true, + chainId: '0x1', + }, + ], + '0x89': [ + { + address: '0x456', + symbol: 'MATIC', + decimals: 18, + balance: '2000000000000000000', + balanceFiat: '$200', + isNative: true, + chainId: '0x89', + }, + ], + }, + }, + }; + + const { queryByText } = renderComponent(multiNetworkState); + expect(queryByText('ETH')).toBeDefined(); + expect(queryByText('MATIC')).toBeNull(); + }); + + it('should filter zero balance tokens when hideZeroBalanceTokens is enabled', () => { + const stateWithZeroBalances = { + ...initialState, + settings: { + hideZeroBalanceTokens: true, + }, + engine: { + backgroundState: { + ...initialState.engine.backgroundState, + TokensController: { + allTokens: { + '0x1': { + [selectedAddress]: [ + { + address: '0x123', + symbol: 'ZERO', + decimals: 18, + balance: '0', + balanceFiat: '$0', + isNative: false, + chainId: '0x1', + }, + { + address: '0x456', + symbol: 'NON_ZERO', + decimals: 18, + balance: '1000000000000000000', + balanceFiat: '$100', + isNative: false, + chainId: '0x1', + }, + ], + }, + }, + }, + }, + }, + }; + + const { queryByText } = renderComponent(stateWithZeroBalances); + expect(queryByText('ZERO')).toBeNull(); + expect(queryByText('NON_ZERO')).toBeDefined(); + }); + }); }); diff --git a/app/components/UI/Tokens/index.tsx b/app/components/UI/Tokens/index.tsx index 0c28821fc34..b4d895b45f9 100644 --- a/app/components/UI/Tokens/index.tsx +++ b/app/components/UI/Tokens/index.tsx @@ -1,8 +1,11 @@ -import React, { useRef, useState, LegacyRef, useMemo } from 'react'; +import React, { useRef, useState, LegacyRef, useMemo, useEffect } from 'react'; +import { Hex } from '@metamask/utils'; import { View, Text } from 'react-native'; import ActionSheet from '@metamask/react-native-actionsheet'; import { useSelector } from 'react-redux'; import useTokenBalancesController from '../../hooks/useTokenBalancesController/useTokenBalancesController'; +import { selectTokensBalances } from '../../../selectors/tokenBalancesController'; +import { selectSelectedInternalAccountAddress } from '../../../selectors/accountsController'; import { useTheme } from '../../../util/theme'; import { useMetrics } from '../../../components/hooks/useMetrics'; import Engine from '../../../core/Engine'; @@ -11,12 +14,13 @@ import { MetaMetricsEvents } from '../../../core/Analytics'; import Logger from '../../../util/Logger'; import { selectChainId, + selectIsAllNetworks, selectNetworkConfigurations, } from '../../../selectors/networkController'; import { getDecimalChainId, - isPortfolioViewEnabled, isTestNet, + isPortfolioViewEnabled, } from '../../../util/networks'; import { isZero } from '../../../util/lodash'; import createStyles from './styles'; @@ -33,10 +37,14 @@ import { deriveBalanceFromAssetMarketDetails, sortAssets } from './util'; import { useNavigation } from '@react-navigation/native'; import { StackNavigationProp } from '@react-navigation/stack'; import { RootState } from '../../../reducers'; -import { selectContractExchangeRates } from '../../../selectors/tokenRatesController'; +import { + selectContractExchangeRates, + selectTokenMarketData, +} from '../../../selectors/tokenRatesController'; import { selectConversionRate, selectCurrentCurrency, + selectCurrencyRates, } from '../../../selectors/currencyRateController'; import { createTokenBottomSheetFilterNavDetails, @@ -45,7 +53,8 @@ import { import ButtonBase from '../../../component-library/components/Buttons/Button/foundation/ButtonBase'; import { selectNetworkName } from '../../../selectors/networkInfos'; import ButtonIcon from '../../../component-library/components/Buttons/ButtonIcon'; -import { Hex } from '@metamask/utils'; +import { selectAccountTokensAcrossChains } from '../../../selectors/multichain'; +import { filterAssets } from './util/filterAssets'; // this will be imported from TokenRatesController when it is exported from there // PR: https://github.com/MetaMask/core/pull/4622 @@ -88,7 +97,7 @@ const Tokens: React.FC = ({ tokens }) => { const { data: tokenBalances } = useTokenBalancesController(); const tokenSortConfig = useSelector(selectTokenSortConfig); const tokenNetworkFilter = useSelector(selectTokenNetworkFilter); - const chainId = useSelector(selectChainId); + const selectedChainId = useSelector(selectChainId); const networkConfigurationsByChainId = useSelector( selectNetworkConfigurations, ); @@ -100,6 +109,7 @@ const Tokens: React.FC = ({ tokens }) => { const currentCurrency = useSelector(selectCurrentCurrency); const conversionRate = useSelector(selectConversionRate); const networkName = useSelector(selectNetworkName); + const currentChainId = useSelector(selectChainId); const nativeCurrencies = [ ...new Set( Object.values(networkConfigurationsByChainId).map( @@ -107,15 +117,110 @@ const Tokens: React.FC = ({ tokens }) => { ), ), ]; + const selectedAccountTokensChains = useSelector( + selectAccountTokensAcrossChains, + ); const actionSheet = useRef(); const [tokenToRemove, setTokenToRemove] = useState(); const [refreshing, setRefreshing] = useState(false); const [isAddTokenEnabled, setIsAddTokenEnabled] = useState(true); + const isAllNetworks = useSelector(selectIsAllNetworks); + + // multi chain + const selectedInternalAccountAddress = useSelector( + selectSelectedInternalAccountAddress, + ); + const multiChainMarketData = useSelector(selectTokenMarketData); + const multiChainTokenBalance = useSelector(selectTokensBalances); + const multiChainCurrencyRates = useSelector(selectCurrencyRates); const styles = createStyles(colors); - const tokensList = useMemo(() => { + const tokensList = useMemo((): TokenI[] => { + if (isPortfolioViewEnabled()) { + // MultiChain implementation + const allTokens = Object.values( + selectedAccountTokensChains, + ).flat() as TokenI[]; + + // First filter zero balance tokens if setting is enabled + const tokensToDisplay = hideZeroBalanceTokens + ? allTokens.filter( + (curToken) => + !isZero(curToken.balance) || + curToken.isNative || + curToken.isStaked, + ) + : allTokens; + + // Then apply network filters + const filteredAssets = filterAssets(tokensToDisplay, [ + { + key: 'chainId', + opts: tokenNetworkFilter, + filterCallback: 'inclusive', + }, + ]); + + const { nativeTokens, nonNativeTokens } = filteredAssets.reduce<{ + nativeTokens: TokenI[]; + nonNativeTokens: TokenI[]; + }>( + ( + acc: { nativeTokens: TokenI[]; nonNativeTokens: TokenI[] }, + currToken: unknown, + ) => { + if ( + isTestNet((currToken as TokenI & { chainId: string }).chainId) && + !isTestNet(currentChainId) + ) { + return acc; + } + if ((currToken as TokenI).isNative) { + acc.nativeTokens.push(currToken as TokenI); + } else { + acc.nonNativeTokens.push(currToken as TokenI); + } + return acc; + }, + { nativeTokens: [], nonNativeTokens: [] }, + ); + + const assets = [...nativeTokens, ...nonNativeTokens]; + + // Calculate fiat balances for tokens + const tokenFiatBalances = assets.map((token) => { + const chainId = token.chainId as Hex; + const multiChainExchangeRates = multiChainMarketData?.[chainId]; + const multiChainTokenBalances = + multiChainTokenBalance?.[selectedInternalAccountAddress as Hex]?.[ + chainId + ]; + const nativeCurrency = + networkConfigurationsByChainId[chainId].nativeCurrency; + const multiChainConversionRate = + multiChainCurrencyRates?.[nativeCurrency]?.conversionRate || 0; + + return token.isETH || token.isNative + ? parseFloat(token.balance) * multiChainConversionRate + : deriveBalanceFromAssetMarketDetails( + token, + multiChainExchangeRates || {}, + multiChainTokenBalances || {}, + multiChainConversionRate || 0, + currentCurrency || '', + ).balanceFiatCalculation; + }); + + const tokensWithBalances = assets.map((token, i) => ({ + ...token, + tokenFiatAmount: tokenFiatBalances[i], + })); + + return sortAssets(tokensWithBalances, tokenSortConfig); + } + // Previous implementation // Filter tokens based on hideZeroBalanceTokens flag const tokensToDisplay = hideZeroBalanceTokens ? tokens.filter( @@ -130,10 +235,10 @@ const Tokens: React.FC = ({ tokens }) => { ? parseFloat(asset.balance) * conversionRate : deriveBalanceFromAssetMarketDetails( asset, - tokenExchangeRates, - tokenBalances, - conversionRate, - currentCurrency, + tokenExchangeRates || {}, + tokenBalances || {}, + conversionRate || 0, + currentCurrency || '', ).balanceFiatCalculation, ) : []; @@ -157,6 +262,15 @@ const Tokens: React.FC = ({ tokens }) => { tokenExchangeRates, tokenSortConfig, tokens, + // Dependencies for multichain implementation + selectedAccountTokensChains, + tokenNetworkFilter, + currentChainId, + multiChainCurrencyRates, + multiChainMarketData, + multiChainTokenBalance, + networkConfigurationsByChainId, + selectedInternalAccountAddress, ]); const showRemoveMenu = (token: TokenI) => { @@ -184,17 +298,18 @@ const Tokens: React.FC = ({ tokens }) => { CurrencyRateController, TokenRatesController, } = Engine.context; + const actions = [ TokenDetectionController.detectTokens({ - chainIds: isPortfolioViewEnabled + chainIds: isPortfolioViewEnabled() ? (Object.keys(networkConfigurationsByChainId) as Hex[]) - : [chainId], + : [selectedChainId], }), AccountTrackerController.refresh(), CurrencyRateController.updateExchangeRate(nativeCurrencies), - ...(isPortfolioViewEnabled + ...(isPortfolioViewEnabled() ? Object.values(networkConfigurationsByChainId) - : [networkConfigurationsByChainId[chainId]] + : [networkConfigurationsByChainId[selectedChainId]] ).map((network) => TokenRatesController.updateExchangeRatesByChainId({ chainId: network.chainId, @@ -210,11 +325,18 @@ const Tokens: React.FC = ({ tokens }) => { }; const removeToken = async () => { - const { TokensController } = Engine.context; + const { TokensController, NetworkController } = Engine.context; + const chainId = isPortfolioViewEnabled() + ? tokenToRemove?.chainId + : selectedChainId; + const networkClientId = NetworkController.findNetworkClientIdByChainId( + chainId as Hex, + ); const tokenAddress = tokenToRemove?.address || ''; + const symbol = tokenToRemove?.symbol; try { - await TokensController.ignoreTokens([tokenAddress]); + await TokensController.ignoreTokens([tokenAddress], networkClientId); NotificationManager.showSimpleNotification({ status: `simple_notification`, duration: 5000, @@ -230,7 +352,7 @@ const Tokens: React.FC = ({ tokens }) => { token_standard: 'ERC20', asset_type: 'token', tokens: [`${symbol} - ${tokenAddress}`], - chain_id: getDecimalChainId(chainId), + chain_id: getDecimalChainId(selectedChainId), }) .build(), ); @@ -246,7 +368,7 @@ const Tokens: React.FC = ({ tokens }) => { createEventBuilder(MetaMetricsEvents.TOKEN_IMPORT_CLICKED) .addProperties({ source: 'manual', - chain_id: getDecimalChainId(chainId), + chain_id: getDecimalChainId(selectedChainId), }) .build(), ); @@ -256,26 +378,40 @@ const Tokens: React.FC = ({ tokens }) => { const onActionSheetPress = (index: number) => index === 0 ? removeToken() : null; + useEffect(() => { + const { PreferencesController } = Engine.context; + if (isTestNet(currentChainId)) { + PreferencesController.setTokenNetworkFilter({ + [currentChainId]: true, + }); + } + }, [currentChainId]); + return ( - {isPortfolioViewEnabled ? ( + {isPortfolioViewEnabled() ? ( - {tokenNetworkFilter[chainId] - ? networkName ?? strings('wallet.current_network') - : strings('wallet.all_networks')} + {isAllNetworks + ? strings('wallet.all_networks') + : networkName ?? strings('wallet.current_network')} } + isDisabled={isTestNet(currentChainId)} onPress={showFilterControls} endIconName={IconName.ArrowDown} - style={styles.controlButton} - disabled={isTestNet(chainId)} + style={ + isTestNet(currentChainId) + ? styles.controlButtonDisabled + : styles.controlButton + } + disabled={isTestNet(currentChainId)} /> marginRight: 5, maxWidth: '60%', }, + controlButtonDisabled: { + backgroundColor: colors.background.default, + borderColor: colors.border.default, + borderStyle: 'solid', + borderWidth: 1, + marginLeft: 5, + marginRight: 5, + maxWidth: '60%', + opacity: 0.5, + }, controlButtonText: { color: colors.text.default, }, diff --git a/app/components/UI/Tokens/types.ts b/app/components/UI/Tokens/types.ts index 90fa5ad8b49..a1efce4ff09 100644 --- a/app/components/UI/Tokens/types.ts +++ b/app/components/UI/Tokens/types.ts @@ -25,4 +25,5 @@ export interface TokenI { nativeAsset?: TokenI | undefined; chainId?: string; isNative?: boolean; + ticker?: string; } diff --git a/app/components/UI/Tokens/util/deriveBalanceFromAssetMarketDetails.ts b/app/components/UI/Tokens/util/deriveBalanceFromAssetMarketDetails.ts index 6f9d642b4f2..5f23998dae1 100644 --- a/app/components/UI/Tokens/util/deriveBalanceFromAssetMarketDetails.ts +++ b/app/components/UI/Tokens/util/deriveBalanceFromAssetMarketDetails.ts @@ -42,8 +42,10 @@ export const deriveBalanceFromAssetMarketDetails = ( balanceValueFormatted: TOKEN_BALANCE_LOADING, }; } - - const balanceValueFormatted = `${balance} ${asset.symbol}`; + let balanceValueFormatted = `${balance} ${asset.symbol}`; + if (asset.isNative) { + balanceValueFormatted = `${balance} ${asset.ticker}`; + } if (!conversionRate) return { @@ -53,10 +55,12 @@ export const deriveBalanceFromAssetMarketDetails = ( if (!tokenMarketData || tokenMarketData === TOKEN_RATE_UNDEFINED) return { - balanceFiat: asset.isETH ? asset.balanceFiat : TOKEN_RATE_UNDEFINED, + balanceFiat: + asset.isETH || asset.isNative + ? asset.balanceFiat + : TOKEN_RATE_UNDEFINED, balanceValueFormatted, }; - const balanceFiatCalculation = Number( asset.balanceFiat || balanceToFiatNumber(balance, conversionRate, tokenMarketData.price), diff --git a/app/components/UI/Tokens/util/enableAllNetworksFilter.test.ts b/app/components/UI/Tokens/util/enableAllNetworksFilter.test.ts new file mode 100644 index 00000000000..0a41d9c6db7 --- /dev/null +++ b/app/components/UI/Tokens/util/enableAllNetworksFilter.test.ts @@ -0,0 +1,164 @@ +import { RpcEndpointType } from '@metamask/network-controller'; +import { NETWORK_CHAIN_ID } from '../../../../util/networks/customNetworks'; +import { + enableAllNetworksFilter, + KnownNetworkConfigurations, +} from './enableAllNetworksFilter'; + +type TestNetworkConfigurations = Pick< + KnownNetworkConfigurations, + '0x1' | '0x89' +>; + +type FlareTestNetworkConfigurations = Pick< + KnownNetworkConfigurations, + '0xe' | '0x13' +>; + +type MultiNetworkConfigurations = Pick< + KnownNetworkConfigurations, + '0x1' | '0x89' | typeof NETWORK_CHAIN_ID.BASE +>; + +describe('enableAllNetworksFilter', () => { + it('should create a record with all network chain IDs mapped to true', () => { + const mockNetworks: TestNetworkConfigurations = { + [NETWORK_CHAIN_ID.MAINNET]: { + chainId: NETWORK_CHAIN_ID.MAINNET, + name: 'Ethereum Mainnet', + blockExplorerUrls: ['https://etherscan.io'], + defaultRpcEndpointIndex: 0, + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + type: RpcEndpointType.Custom, + networkClientId: NETWORK_CHAIN_ID.MAINNET, + url: 'https://mainnet.infura.io/v3/{infuraProjectId}', + }, + ], + }, + [NETWORK_CHAIN_ID.POLYGON]: { + chainId: NETWORK_CHAIN_ID.POLYGON, + name: 'Polygon', + blockExplorerUrls: ['https://polygonscan.com'], + defaultRpcEndpointIndex: 0, + nativeCurrency: 'MATIC', + rpcEndpoints: [ + { + type: RpcEndpointType.Custom, + networkClientId: NETWORK_CHAIN_ID.POLYGON, + url: 'https://polygon-rpc.com', + }, + ], + }, + }; + + const result = enableAllNetworksFilter(mockNetworks); + + expect(result).toEqual({ + [NETWORK_CHAIN_ID.MAINNET]: true, + [NETWORK_CHAIN_ID.POLYGON]: true, + }); + }); + + it('should handle empty networks object', () => { + const result = enableAllNetworksFilter({}); + expect(result).toEqual({}); + }); + + it('should work with NETWORK_CHAIN_ID constants', () => { + const mockNetworks: FlareTestNetworkConfigurations = { + [NETWORK_CHAIN_ID.FLARE_MAINNET]: { + chainId: NETWORK_CHAIN_ID.FLARE_MAINNET, + name: 'Flare Mainnet', + blockExplorerUrls: ['https://flare.network'], + defaultRpcEndpointIndex: 0, + nativeCurrency: 'FLR', + rpcEndpoints: [ + { + type: RpcEndpointType.Custom, + networkClientId: NETWORK_CHAIN_ID.FLARE_MAINNET, + url: 'https://flare-rpc.com', + }, + ], + }, + [NETWORK_CHAIN_ID.SONGBIRD_TESTNET]: { + chainId: NETWORK_CHAIN_ID.SONGBIRD_TESTNET, + name: 'Songbird Testnet', + blockExplorerUrls: ['https://songbird.flare.network'], + defaultRpcEndpointIndex: 0, + nativeCurrency: 'SGB', + rpcEndpoints: [ + { + type: RpcEndpointType.Custom, + networkClientId: NETWORK_CHAIN_ID.SONGBIRD_TESTNET, + url: 'https://songbird-rpc.flare.network', + }, + ], + }, + }; + + const result = enableAllNetworksFilter(mockNetworks); + + expect(result).toEqual({ + [NETWORK_CHAIN_ID.FLARE_MAINNET]: true, + [NETWORK_CHAIN_ID.SONGBIRD_TESTNET]: true, + }); + }); + + it('should handle networks with different property values', () => { + const mockNetworks: MultiNetworkConfigurations = { + [NETWORK_CHAIN_ID.MAINNET]: { + chainId: NETWORK_CHAIN_ID.MAINNET, + name: 'Network 1', + blockExplorerUrls: ['https://etherscan.io'], + defaultRpcEndpointIndex: 0, + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + type: RpcEndpointType.Custom, + networkClientId: NETWORK_CHAIN_ID.MAINNET, + url: 'https://mainnet.infura.io/v3/your-api-key', + }, + ], + }, + [NETWORK_CHAIN_ID.POLYGON]: { + chainId: NETWORK_CHAIN_ID.POLYGON, + name: 'Network 2', + blockExplorerUrls: ['https://polygonscan.com'], + defaultRpcEndpointIndex: 0, + nativeCurrency: 'MATIC', + rpcEndpoints: [ + { + type: RpcEndpointType.Custom, + networkClientId: NETWORK_CHAIN_ID.POLYGON, + url: 'https://polygon-rpc.com', + }, + ], + }, + [NETWORK_CHAIN_ID.BASE]: { + chainId: NETWORK_CHAIN_ID.BASE, + name: 'Network 3', + blockExplorerUrls: ['https://base.network'], + defaultRpcEndpointIndex: 0, + nativeCurrency: 'BASE', + rpcEndpoints: [ + { + type: RpcEndpointType.Custom, + networkClientId: NETWORK_CHAIN_ID.BASE, + url: 'https://base-rpc.com', + }, + ], + }, + }; + + const result = enableAllNetworksFilter(mockNetworks); + + expect(Object.values(result).every((value) => value === true)).toBe(true); + expect(Object.keys(result)).toEqual([ + NETWORK_CHAIN_ID.MAINNET, + NETWORK_CHAIN_ID.POLYGON, + NETWORK_CHAIN_ID.BASE, + ]); + }); +}); diff --git a/app/components/UI/Tokens/util/filterAssets.test.ts b/app/components/UI/Tokens/util/filterAssets.test.ts new file mode 100644 index 00000000000..4c23fe54815 --- /dev/null +++ b/app/components/UI/Tokens/util/filterAssets.test.ts @@ -0,0 +1,183 @@ +import { filterAssets, FilterCriteria } from './filterAssets'; + +describe('filterAssets function', () => { + interface MockToken { + name: string; + symbol: string; + chainId: string; + balance: number; + } + + const mockTokens: MockToken[] = [ + { name: 'Token1', symbol: 'T1', chainId: '0x01', balance: 100 }, + { name: 'Token2', symbol: 'T2', chainId: '0x02', balance: 50 }, + { name: 'Token3', symbol: 'T3', chainId: '0x01', balance: 200 }, + { name: 'Token4', symbol: 'T4', chainId: '0x89', balance: 150 }, + ]; + + test('returns all assets if no criteria are provided', () => { + const criteria: FilterCriteria[] = []; + + const filtered = filterAssets(mockTokens, criteria); + + expect(filtered).toEqual(mockTokens); // No filtering occurs + }); + + test('returns all assets if filterCallback is undefined', () => { + const criteria: FilterCriteria[] = [ + { + key: 'chainId', + opts: { '0x01': true, '0x89': true }, // Valid opts + filterCallback: undefined as unknown as 'inclusive', // Undefined callback + }, + ]; + + const filtered = filterAssets(mockTokens, criteria); + + expect(filtered).toEqual(mockTokens); // No filtering occurs due to missing filterCallback + }); + + test('filters by inclusive chainId', () => { + const criteria: FilterCriteria[] = [ + { + key: 'chainId', + opts: { '0x01': true, '0x89': true }, + filterCallback: 'inclusive', + }, + ]; + + const filtered = filterAssets(mockTokens, criteria); + + expect(filtered).toHaveLength(3); + expect(filtered.map((token) => token.chainId)).toEqual([ + '0x01', + '0x01', + '0x89', + ]); + }); + + test('filters tokens with balance between 100 and 150 inclusive', () => { + const criteria: FilterCriteria[] = [ + { + key: 'balance', + opts: { min: 100, max: 150 }, + filterCallback: 'range', + }, + ]; + + const filtered = filterAssets(mockTokens, criteria); + + expect(filtered).toHaveLength(2); // Token1 and Token4 + expect(filtered.map((token) => token.balance)).toEqual([100, 150]); + }); + + test('filters by inclusive chainId and balance range', () => { + const criteria: FilterCriteria[] = [ + { + key: 'chainId', + opts: { '0x01': true, '0x89': true }, + filterCallback: 'inclusive', + }, + { + key: 'balance', + opts: { min: 100, max: 150 }, + filterCallback: 'range', + }, + ]; + + const filtered = filterAssets(mockTokens, criteria); + + expect(filtered).toHaveLength(2); // Token1 and Token4 + }); + + test('returns no tokens if no chainId matches', () => { + const criteria: FilterCriteria[] = [ + { + key: 'chainId', + opts: { '0x04': true }, + filterCallback: 'inclusive', + }, + ]; + + const filtered = filterAssets(mockTokens, criteria); + + expect(filtered).toHaveLength(0); // No matching tokens + }); + + test('returns no tokens if balance is not within range', () => { + const criteria: FilterCriteria[] = [ + { + key: 'balance', + opts: { min: 300, max: 400 }, + filterCallback: 'range', + }, + ]; + + const filtered = filterAssets(mockTokens, criteria); + + expect(filtered).toHaveLength(0); // No matching tokens + }); + + test('handles empty opts in inclusive callback', () => { + const criteria: FilterCriteria[] = [ + { + key: 'chainId', + opts: {}, // Empty opts + filterCallback: 'inclusive', + }, + ]; + + const filtered = filterAssets(mockTokens, criteria); + + expect(filtered).toHaveLength(0); // No tokens match empty opts + }); + + test('handles invalid range opts', () => { + const criteria: FilterCriteria[] = [ + { + key: 'balance', + opts: { min: undefined, max: undefined } as unknown as { + min: number; + max: number; + }, + filterCallback: 'range', + }, + ]; + + const filtered = filterAssets(mockTokens, criteria); + + expect(filtered).toHaveLength(0); // No tokens match invalid range + }); + + test('handles missing values in assets gracefully', () => { + const incompleteTokens = [ + { name: 'Token1', symbol: 'T1', chainId: '0x01' }, // Missing balance + ]; + + const criteria: FilterCriteria[] = [ + { + key: 'balance', + opts: { min: 100, max: 150 }, + filterCallback: 'range', + }, + ]; + + const filtered = filterAssets(incompleteTokens, criteria); + + expect(filtered).toHaveLength(0); // Incomplete token doesn't match + }); + + test('ignores unknown filterCallback types', () => { + const criteria: FilterCriteria[] = [ + { + key: 'balance', + opts: { min: 100, max: 150 }, + filterCallback: 'unknown' as unknown as 'inclusive', + }, + ]; + + const filtered = filterAssets(mockTokens, criteria); + + expect(filtered).toEqual(mockTokens); // Unknown callback doesn't filter + }); +}); diff --git a/app/components/UI/Tokens/util/filterAssets.ts b/app/components/UI/Tokens/util/filterAssets.ts new file mode 100644 index 00000000000..7d201831b57 --- /dev/null +++ b/app/components/UI/Tokens/util/filterAssets.ts @@ -0,0 +1,91 @@ +import { get } from 'lodash'; + +export interface FilterCriteria { + key: string; + opts: Record; // Use opts for range, inclusion, etc. + filterCallback: FilterCallbackKeys; // Specify the type of filter: 'range', 'inclusive', etc. +} + +export type FilterType = string | number | boolean | Date; +type FilterCallbackKeys = keyof FilterCallbacksT; + +export interface FilterCallbacksT { + inclusive: (value: string, opts: Record) => boolean; + range: (value: number, opts: Record) => boolean; +} + +/** + * A collection of filter callback functions used for various filtering operations. + */ +const filterCallbacks: FilterCallbacksT = { + /** + * Checks if a given value exists as a key in the provided options object + * and returns its corresponding boolean value. + * + * @param value - The key to check in the options object. + * @param opts - A record object containing boolean values for keys. + * @returns `false` if the options object is empty, otherwise returns the boolean value associated with the key. + */ + inclusive: (value: string, opts: Record) => { + if (Object.entries(opts).length === 0) { + return false; + } + return opts[value]; + }, + /** + * Checks if a given numeric value falls within a specified range. + * + * @param value - The number to check. + * @param opts - A record object with `min` and `max` properties defining the range. + * @returns `true` if the value is within the range [opts.min, opts.max], otherwise `false`. + */ + range: (value: number, opts: Record) => + value >= opts.min && value <= opts.max, +}; + +function getNestedValue(obj: T, keyPath: string): FilterType { + return get(obj, keyPath); +} + +/** + * Filters an array of assets based on a set of criteria. + * + * @template T - The type of the assets in the array. + * @param assets - The array of assets to be filtered. + * @param criteria - An array of filter criteria objects. Each criterion contains: + * - `key`: A string representing the key to be accessed within the asset (supports nested keys). + * - `opts`: An object specifying the options for the filter. The structure depends on the `filterCallback` type. + * - `filterCallback`: The filtering method to apply, such as `'inclusive'` or `'range'`. + * @returns A new array of assets that match all the specified criteria. + */ +export function filterAssets(assets: T[], criteria: FilterCriteria[]): T[] { + if (criteria.length === 0) { + return assets; + } + + return assets.filter((asset) => + criteria.every(({ key, opts, filterCallback }) => { + const nestedValue = getNestedValue(asset, key); + + // If there's no callback or options, exit early and don't filter based on this criterion. + if (!filterCallback || !opts) { + return true; + } + + switch (filterCallback) { + case 'inclusive': + return filterCallbacks.inclusive( + nestedValue as string, + opts as Record, + ); + case 'range': + return filterCallbacks.range( + nestedValue as number, + opts as { min: number; max: number }, + ); + default: + return true; + } + }), + ); +} diff --git a/app/components/UI/Transactions/index.js b/app/components/UI/Transactions/index.js index 3e251d4cfd7..3cc18c5725a 100644 --- a/app/components/UI/Transactions/index.js +++ b/app/components/UI/Transactions/index.js @@ -104,6 +104,12 @@ const createStyles = (colors, typography) => color: colors.text.muted, ...fontStyles.normal, }, + textTransactions: { + fontSize: 20, + color: colors.text.muted, + textAlign: 'center', + ...fontStyles.normal, + }, viewMoreWrapper: { padding: 16, }, @@ -572,7 +578,7 @@ class Transactions extends PureComponent { const onConfirmation = (isComplete) => { if (isComplete) { transaction.speedUpParams && - transaction.speedUpParams?.type === 'SpeedUp' + transaction.speedUpParams?.type === 'SpeedUp' ? this.onSpeedUpCompleted() : this.onCancelCompleted(); } @@ -758,8 +764,8 @@ class Transactions extends PureComponent { const transactions = submittedTransactions && submittedTransactions.length ? submittedTransactions - .sort((a, b) => b.time - a.time) - .concat(confirmedTransactions) + .sort((a, b) => b.time - a.time) + .concat(confirmedTransactions) : this.props.transactions; const renderRetryGas = (rate) => { diff --git a/app/components/Views/Asset/index.js b/app/components/Views/Asset/index.js index 2c22b4307bb..bc6e24b35cd 100644 --- a/app/components/Views/Asset/index.js +++ b/app/components/Views/Asset/index.js @@ -19,6 +19,7 @@ import { import AppConstants from '../../../core/AppConstants'; import { swapsLivenessSelector, + swapsTokensMultiChainObjectSelector, swapsTokensObjectSelector, } from '../../../reducers/swaps'; import { @@ -34,6 +35,7 @@ import { toLowerCaseEquals } from '../../../util/general'; import { findBlockExplorerForRpc, isMainnetByChainId, + isPortfolioViewEnabled, } from '../../../util/networks'; import { mockTheme, ThemeContext } from '../../../util/theme'; import { addAccountTimeFlagFilter } from '../../../util/transactions'; @@ -192,8 +194,14 @@ class Asset extends PureComponent { ); updateNavBar = (contentOffset = 0) => { - const { navigation, route, chainId, rpcUrl, networkConfigurations } = - this.props; + const { + route: { params }, + navigation, + route, + chainId, + rpcUrl, + networkConfigurations, + } = this.props; const colors = this.context.colors || mockTheme.colors; const isNativeToken = route.params.isETH; const isMainnet = isMainnetByChainId(chainId); @@ -204,7 +212,9 @@ class Asset extends PureComponent { const shouldShowMoreOptionsInNavBar = isMainnet || !isNativeToken || (isNativeToken && blockExplorer); - + const asset = navigation && params; + const currentNetworkName = + this.props.networkConfigurations[asset.chainId]?.name; navigation.setOptions( getNetworkNavbarOptions( route.params?.symbol ?? '', @@ -224,6 +234,7 @@ class Asset extends PureComponent { : undefined, true, contentOffset, + currentNetworkName, ), ); }; @@ -470,6 +481,7 @@ class Asset extends PureComponent { const asset = navigation && params; const isSwapsFeatureLive = this.props.swapsIsLive; const isNetworkAllowed = isSwapsAllowed(chainId); + const isAssetAllowed = asset.isETH || asset.address?.toLowerCase() in this.props.swapsTokens; @@ -511,6 +523,7 @@ class Asset extends PureComponent { loading={!transactionsUpdated} headerHeight={280} onScrollThroughContent={this.onScrollThroughContent} + tokenChainId={asset.chainId} /> )} @@ -522,7 +535,9 @@ Asset.contextType = ThemeContext; const mapStateToProps = (state) => ({ swapsIsLive: swapsLivenessSelector(state), - swapsTokens: swapsTokensObjectSelector(state), + swapsTokens: isPortfolioViewEnabled() + ? swapsTokensMultiChainObjectSelector(state) + : swapsTokensObjectSelector(state), swapsTransactions: selectSwapsTransactions(state), conversionRate: selectConversionRate(state), currentCurrency: selectCurrentCurrency(state), diff --git a/app/components/Views/Asset/index.test.js b/app/components/Views/Asset/index.test.js index 968ed7f4b69..a7461510901 100644 --- a/app/components/Views/Asset/index.test.js +++ b/app/components/Views/Asset/index.test.js @@ -9,6 +9,13 @@ const mockInitialState = { backgroundState: { ...backgroundState, AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE, + TokensController: { + allTokens: { + '0x1': { + '0xc4966c0d659d99699bfd7eb54d8fafee40e4a756': [], + }, + }, + }, }, }, }; diff --git a/app/components/Views/AssetDetails/AssetsDetails.test.tsx b/app/components/Views/AssetDetails/AssetsDetails.test.tsx index 7dfb2cea993..ce066448c97 100644 --- a/app/components/Views/AssetDetails/AssetsDetails.test.tsx +++ b/app/components/Views/AssetDetails/AssetsDetails.test.tsx @@ -62,8 +62,8 @@ const initialState = { TokenBalancesController: { tokenBalances: { [MOCK_ADDRESS_1]: { - '0x1': { - '0xAddress': '0xde0b6B3A7640000', + [CHAIN_IDS.MAINNET]: { + '0xAddress': '0xde0b6b3a7640000', }, }, }, @@ -77,6 +77,28 @@ const initialState = { aggregators: ['Metamask', 'CMC'], }, ], + tokensByChainId: { + [CHAIN_IDS.MAINNET]: [ + { + address: '0xAddress', + symbol: 'TKN', + decimals: 18, + aggregators: ['Metamask', 'CMC'], + }, + ], + }, + allTokens: { + [CHAIN_IDS.MAINNET]: { + [MOCK_ADDRESS_1]: [ + { + address: '0xAddress', + symbol: 'TKN', + decimals: 18, + aggregators: ['Metamask', 'CMC'], + }, + ], + }, + }, }, AccountsController: { internalAccounts: { @@ -117,6 +139,7 @@ describe('AssetDetails', () => { route={{ params: { address: '0xAddress', + chainId: CHAIN_IDS.MAINNET, }, }} /> @@ -146,6 +169,7 @@ describe('AssetDetails', () => { route={{ params: { address: '0xAddress', + chainId: CHAIN_IDS.MAINNET, }, }} /> @@ -195,6 +219,7 @@ describe('AssetDetails', () => { route={{ params: { address: '0xAddress', + chainId: CHAIN_IDS.MAINNET, }, }} /> @@ -224,6 +249,7 @@ describe('AssetDetails', () => { route={{ params: { address: '0xAddress', + chainId: CHAIN_IDS.MAINNET, }, }} /> diff --git a/app/components/Views/AssetDetails/index.tsx b/app/components/Views/AssetDetails/index.tsx index c4e7d682c09..905113db79e 100644 --- a/app/components/Views/AssetDetails/index.tsx +++ b/app/components/Views/AssetDetails/index.tsx @@ -17,7 +17,10 @@ import { useDispatch, useSelector } from 'react-redux'; import EthereumAddress from '../../UI/EthereumAddress'; import Icon from 'react-native-vector-icons/Feather'; import TokenImage from '../../UI/TokenImage'; -import Networks, { getDecimalChainId } from '../../../util/networks'; +import Networks, { + getDecimalChainId, + isPortfolioViewEnabled, +} from '../../../util/networks'; import Engine from '../../../core/Engine'; import Logger from '../../../util/Logger'; import NotificationManager from '../../../core/NotificationManager'; @@ -34,18 +37,30 @@ import Routes from '../../../constants/navigation/Routes'; import { selectChainId, selectProviderConfig, + selectNetworkConfigurationByChainId, } from '../../../selectors/networkController'; import { selectConversionRate, selectCurrentCurrency, + selectConversionRateBySymbol, } from '../../../selectors/currencyRateController'; -import { selectTokens } from '../../../selectors/tokensController'; -import { selectContractExchangeRates } from '../../../selectors/tokenRatesController'; -import { selectContractBalances } from '../../../selectors/tokenBalancesController'; +import { + selectAllTokens, + selectTokens, +} from '../../../selectors/tokensController'; +import { + selectContractExchangeRates, + selectTokenMarketDataByChainId, +} from '../../../selectors/tokenRatesController'; +import { + selectContractBalances, + selectTokensBalances, +} from '../../../selectors/tokenBalancesController'; import { useMetrics } from '../../../components/hooks/useMetrics'; import { RootState } from 'app/reducers'; import { Colors } from '../../../util/theme/models'; import { Hex } from '@metamask/utils'; +import { selectSelectedInternalAccountAddress } from '../../../selectors/accountsController'; const createStyles = (colors: Colors) => StyleSheet.create({ @@ -100,31 +115,68 @@ interface Props { route: { params: { address: Hex; + chainId: Hex; }; }; } const AssetDetails = (props: Props) => { - const { address } = props.route.params; + const { address, chainId: networkId } = props.route.params; + const { colors } = useTheme(); const { trackEvent, createEventBuilder } = useMetrics(); const styles = createStyles(colors); const navigation = useNavigation(); const dispatch = useDispatch(); const providerConfig = useSelector(selectProviderConfig); + const allTokens = useSelector(selectAllTokens); + const selectedAccountAddress = useSelector( + selectSelectedInternalAccountAddress, + ); + const selectedChainId = useSelector(selectChainId); + const chainId = isPortfolioViewEnabled() ? networkId : selectedChainId; const tokens = useSelector(selectTokens); - const conversionRate = useSelector(selectConversionRate); + + const tokensByChain = useMemo( + () => allTokens?.[chainId as Hex]?.[selectedAccountAddress as Hex] ?? [], + [allTokens, chainId, selectedAccountAddress], + ); + + const conversionRateLegacy = useSelector(selectConversionRate); + const networkConfigurationByChainId = useSelector((state: RootState) => + selectNetworkConfigurationByChainId(state, chainId), + ); + const conversionRateBySymbol = useSelector((state: RootState) => + selectConversionRateBySymbol( + state, + networkConfigurationByChainId?.nativeCurrency, + ), + ); const currentCurrency = useSelector(selectCurrentCurrency); - const chainId = useSelector(selectChainId); const primaryCurrency = useSelector( (state: RootState) => state.settings.primaryCurrency, ); - const tokenExchangeRates = useSelector(selectContractExchangeRates); - const tokenBalances = useSelector(selectContractBalances); - const token = useMemo( + const tokenExchangeRatesLegacy = useSelector(selectContractExchangeRates); + const tokenExchangeRatesByChainId = useSelector((state: RootState) => + selectTokenMarketDataByChainId(state, chainId), + ); + const tokenBalancesLegacy = useSelector(selectContractBalances); + const allTokenBalances = useSelector(selectTokensBalances); + + const portfolioToken = useMemo( + () => tokensByChain.find((rawToken) => rawToken.address === address), + [tokensByChain, address], + ); + + const legacyToken = useMemo( () => tokens.find((rawToken) => rawToken.address === address), [tokens, address], ); + + const token: TokenType | undefined = isPortfolioViewEnabled() + ? portfolioToken + : legacyToken; + const { symbol, decimals, aggregators = [] } = token as TokenType; const getNetworkName = () => { @@ -172,8 +224,11 @@ const AssetDetails = (props: Props) => { onConfirm: () => { navigation.navigate('WalletView'); InteractionManager.runAfterInteractions(() => { + const { NetworkController } = Engine.context; + const networkClientId = + NetworkController.findNetworkClientIdByChainId(chainId); try { - TokensController.ignoreTokens([address]); + TokensController.ignoreTokens([address], networkClientId); NotificationManager.showSimpleNotification({ status: `simple_notification`, duration: 5000, @@ -259,14 +314,39 @@ const AssetDetails = (props: Props) => { const renderTokenBalance = () => { let balanceDisplay = ''; + const tokenExchangeRates = isPortfolioViewEnabled() + ? tokenExchangeRatesByChainId + : tokenExchangeRatesLegacy; + const tokenBalances = isPortfolioViewEnabled() + ? allTokenBalances + : tokenBalancesLegacy; + + const multiChainTokenBalance = + Object.keys(allTokenBalances).length > 0 + ? allTokenBalances[selectedAccountAddress as Hex]?.[chainId as Hex]?.[ + address as Hex + ] + : undefined; + + const tokenBalance = isPortfolioViewEnabled() + ? multiChainTokenBalance + : tokenBalancesLegacy[address]; + + const conversionRate = isPortfolioViewEnabled() + ? conversionRateBySymbol + : conversionRateLegacy; + const exchangeRate = tokenExchangeRates && address in tokenExchangeRates ? tokenExchangeRates[address]?.price : undefined; - const balance = - address in tokenBalances - ? renderFromTokenMinimalUnit(tokenBalances[address], decimals) - : undefined; + + const balance = tokenBalance + ? address in tokenBalances || isPortfolioViewEnabled() || !tokenBalance + ? renderFromTokenMinimalUnit(tokenBalance.toString(), decimals) + : undefined + : undefined; + const balanceFiat = balance ? balanceToFiat(balance, conversionRate, exchangeRate, currentCurrency) : undefined; diff --git a/app/components/Views/AssetOptions/AssetOptions.test.tsx b/app/components/Views/AssetOptions/AssetOptions.test.tsx index 5a430f103ba..d7e298bf86a 100644 --- a/app/components/Views/AssetOptions/AssetOptions.test.tsx +++ b/app/components/Views/AssetOptions/AssetOptions.test.tsx @@ -3,6 +3,85 @@ import { render, fireEvent } from '@testing-library/react-native'; import { useNavigation } from '@react-navigation/native'; import { useSelector } from 'react-redux'; import AssetOptions from './AssetOptions'; +// eslint-disable-next-line import/no-namespace +import * as networks from '../../../util/networks'; + +import { + createProviderConfig, + selectNetworkConfigurations, +} from '../../../selectors/networkController'; + +jest.mock('../../../core/Engine', () => ({ + context: { + TokensController: { + ignoreTokens: jest.fn(() => Promise.resolve()), + }, + NetworkController: { + findNetworkClientIdByChainId: jest.fn(() => 'test-network'), + getNetworkClientById: jest.fn(() => ({ + configuration: { + chainId: '0x1', + rpcUrl: 'https://mainnet.example.com', + ticker: 'ETH', + type: 'mainnet', + }, + })), + state: { + providerConfig: { + chainId: '0x1', + type: 'mainnet', + }, + networkConfigurations: { + '0x1': { + chainId: '0x1', + rpcEndpoints: [{ url: 'https://mainnet.example.com' }], + defaultRpcEndpointIndex: 0, + }, + }, + }, + }, + TokenDetectionController: { + detectTokens: jest.fn(() => Promise.resolve()), + }, + AccountTrackerController: { + refresh: jest.fn(() => Promise.resolve()), + }, + CurrencyRateController: { + updateExchangeRate: jest.fn(() => Promise.resolve()), + }, + TokenRatesController: { + updateExchangeRatesByChainId: jest.fn(() => Promise.resolve()), + }, + }, + getTotalFiatAccountBalance: jest.fn(), +})); + +jest.mock('../../../selectors/networkController', () => ({ + selectChainId: jest.fn(() => '1'), + selectProviderConfig: jest.fn(() => ({})), + selectNetworkConfigurations: jest.fn(() => ({ + '0x1': { + chainId: '0x1', + rpcEndpoints: [{ url: 'https://mainnet.example.com' }], + defaultRpcEndpointIndex: 0, + }, + '0x89': { + chainId: '0x89', + rpcEndpoints: [{ url: 'https://polygon.example.com' }], + defaultRpcEndpointIndex: 0, + }, + })), + createProviderConfig: jest.fn((networkConfig, rpcEndpoint) => ({ + chainId: networkConfig.chainId, + rpcUrl: rpcEndpoint.url, + chainName: 'Example Chain', + nativeCurrency: { + name: 'Example Token', + symbol: 'EXAMPLE', + decimals: 18, + }, + })), +})); // Mock dependencies jest.mock('@react-navigation/native', () => ({ @@ -68,6 +147,16 @@ jest.mock('../../../selectors/networkController', () => ({ selectChainId: jest.fn(() => '1'), selectProviderConfig: jest.fn(() => ({})), selectNetworkConfigurations: jest.fn(() => ({})), + createProviderConfig: jest.fn(() => ({ + chainId: '1', + rpcUrl: 'https://example.com', + chainName: 'Example Chain', + nativeCurrency: { + name: 'Example Token', + symbol: 'EXAMPLE', + decimals: 18, + }, + })), })); jest.mock('../../../selectors/tokenListController', () => ({ @@ -89,10 +178,21 @@ describe('AssetOptions Component', () => { return { '0x123': { symbol: 'ABC' } }; return {}; }); + jest.clearAllMocks(); + jest.useRealTimers(); + jest.useFakeTimers(); }); afterEach(() => { + jest.runAllTimers(); + jest.clearAllTimers(); + jest.clearAllMocks(); + jest.clearAllTimers(); + }); + + afterAll(() => { jest.clearAllMocks(); + jest.clearAllTimers(); }); it('matches the snapshot', () => { @@ -101,6 +201,24 @@ describe('AssetOptions Component', () => { route={{ params: { address: '0x123', + chainId: '0x1', + isNativeCurrency: false, + }, + }} + />, + ); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('should match the snapshot when portfolio view is enabled ', () => { + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(true); + const { toJSON } = render( + { route={{ params: { address: '0x123', + chainId: '0x1', isNativeCurrency: false, }, }} @@ -134,6 +253,7 @@ describe('AssetOptions Component', () => { route={{ params: { address: '0x123', + chainId: '0x1', isNativeCurrency: false, }, }} @@ -141,6 +261,7 @@ describe('AssetOptions Component', () => { ); fireEvent.press(getByText('View on block explorer')); + jest.runAllTimers(); expect(mockNavigation.navigate).toHaveBeenCalledWith('Webview', { screen: 'SimpleWebview', params: { @@ -156,6 +277,7 @@ describe('AssetOptions Component', () => { route={{ params: { address: '0x123', + chainId: '0x1', isNativeCurrency: false, }, }} @@ -163,6 +285,7 @@ describe('AssetOptions Component', () => { ); fireEvent.press(getByText('Remove token')); + jest.runAllTimers(); expect(mockNavigation.navigate).toHaveBeenCalledWith('RootModalFlow', { screen: 'AssetHideConfirmation', params: expect.anything(), @@ -170,18 +293,64 @@ describe('AssetOptions Component', () => { }); it('handles "Token Details" press', () => { - const { getByText } = render( - , - ); + const mockParams = { + params: { + address: '0x123', + chainId: '0x1', + isNativeCurrency: false, + }, + }; + const { getByText } = render(); fireEvent.press(getByText('Token details')); - expect(mockNavigation.navigate).toHaveBeenCalledWith('AssetDetails'); + jest.runAllTimers(); + expect(mockNavigation.navigate).toHaveBeenCalledWith( + 'AssetDetails', + expect.anything(), + ); + }); + + describe('Portfolio and Network Configuration', () => { + const mockNetworkConfigurations = { + '0x1': { + chainId: '0x1', + rpcEndpoints: [{ url: 'https://mainnet.example.com' }], + defaultRpcEndpointIndex: 0, + }, + '0x89': { + chainId: '0x89', + rpcEndpoints: [{ url: 'https://polygon.example.com' }], + defaultRpcEndpointIndex: 0, + }, + }; + + beforeEach(() => { + (useSelector as jest.Mock).mockImplementation((selector) => { + if (selector === selectNetworkConfigurations) + return mockNetworkConfigurations; + return {}; + }); + }); + + it('should use correct provider config when portfolio view is enabled', () => { + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(true); + + render( + , + ); + + expect(createProviderConfig).toHaveBeenCalledWith( + mockNetworkConfigurations['0x1'], + mockNetworkConfigurations['0x1'].rpcEndpoints[0], + ); + }); }); }); diff --git a/app/components/Views/AssetOptions/AssetOptions.tsx b/app/components/Views/AssetOptions/AssetOptions.tsx index d19a4d4c749..83606f2f692 100644 --- a/app/components/Views/AssetOptions/AssetOptions.tsx +++ b/app/components/Views/AssetOptions/AssetOptions.tsx @@ -1,5 +1,5 @@ import { useNavigation } from '@react-navigation/native'; -import React, { useRef } from 'react'; +import React, { useMemo, useRef } from 'react'; import { Text, TouchableOpacity, View, InteractionManager } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSelector } from 'react-redux'; @@ -14,6 +14,7 @@ import Icon, { } from '../../../component-library/components/Icons/Icon'; import useBlockExplorer from '../../../components/UI/Swaps/utils/useBlockExplorer'; import { + createProviderConfig, selectChainId, selectNetworkConfigurations, selectProviderConfig, @@ -24,10 +25,14 @@ import { selectTokenList } from '../../../selectors/tokenListController'; import Logger from '../../../util/Logger'; import { MetaMetricsEvents } from '../../../core/Analytics'; import AppConstants from '../../../core/AppConstants'; -import { getDecimalChainId } from '../../../util/networks'; +import { + getDecimalChainId, + isPortfolioViewEnabled, +} from '../../../util/networks'; import { isPortfolioUrl } from '../../../util/url'; import { BrowserTab } from '../../../components/UI/Tokens/types'; import { RootState } from '../../../reducers'; +import { Hex } from '../../../util/smart-transactions/smart-publish-hook'; interface Option { label: string; onPress: () => void; @@ -39,12 +44,13 @@ interface Props { params: { address: string; isNativeCurrency: boolean; + chainId: string; }; }; } const AssetOptions = (props: Props) => { - const { address, isNativeCurrency } = props.route.params; + const { address, isNativeCurrency, chainId: networkId } = props.route.params; const { styles } = useStyles(styleSheet, {}); const safeAreaInsets = useSafeAreaInsets(); const navigation = useNavigation(); @@ -58,7 +64,33 @@ const AssetOptions = (props: Props) => { const isDataCollectionForMarketingEnabled = useSelector( (state: RootState) => state.security.dataCollectionForMarketing, ); - const explorer = useBlockExplorer(providerConfig, networkConfigurations); + + // Memoize the provider config for the token explorer + const { providerConfigTokenExplorer } = useMemo(() => { + const tokenNetworkConfig = networkConfigurations[networkId as Hex]; + const tokenRpcEndpoint = + networkConfigurations[networkId as Hex]?.rpcEndpoints?.[ + networkConfigurations[networkId as Hex]?.defaultRpcEndpointIndex + ]; + + const providerConfigToken = createProviderConfig( + tokenNetworkConfig, + tokenRpcEndpoint, + ); + + const providerConfigTokenExplorerToken = isPortfolioViewEnabled() + ? providerConfigToken + : providerConfig; + + return { + providerConfigTokenExplorer: providerConfigTokenExplorerToken, + }; + }, [networkId, networkConfigurations, providerConfig]); + + const explorer = useBlockExplorer( + providerConfigTokenExplorer, + networkConfigurations, + ); const { trackEvent, isEnabled, createEventBuilder } = useMetrics(); const goToBrowserUrl = (url: string, title: string) => { @@ -88,7 +120,10 @@ const AssetOptions = (props: Props) => { const openTokenDetails = () => { modalRef.current?.dismissModal(() => { - navigation.navigate('AssetDetails'); + navigation.navigate('AssetDetails', { + address, + chainId: networkId, + }); }); }; @@ -146,7 +181,18 @@ const AssetOptions = (props: Props) => { navigation.navigate('WalletView'); InteractionManager.runAfterInteractions(async () => { try { - await TokensController.ignoreTokens([address]); + const { NetworkController } = Engine.context; + + const chainIdToUse = isPortfolioViewEnabled() + ? networkId + : chainId; + + const networkClientId = + NetworkController.findNetworkClientIdByChainId( + chainIdToUse as Hex, + ); + + await TokensController.ignoreTokens([address], networkClientId); NotificationManager.showSimpleNotification({ status: `simple_notification`, duration: 5000, diff --git a/app/components/Views/AssetOptions/__snapshots__/AssetOptions.test.tsx.snap b/app/components/Views/AssetOptions/__snapshots__/AssetOptions.test.tsx.snap index fdb892243bb..ff4e041021f 100644 --- a/app/components/Views/AssetOptions/__snapshots__/AssetOptions.test.tsx.snap +++ b/app/components/Views/AssetOptions/__snapshots__/AssetOptions.test.tsx.snap @@ -135,3 +135,139 @@ exports[`AssetOptions Component matches the snapshot 1`] = ` `; + +exports[`AssetOptions Component should match the snapshot when portfolio view is enabled 1`] = ` + + + + + + + + + + + View on Portfolio + + + + + + + + View on block explorer + + + + + + + + Token details + + + + + + + + Remove token + + + + + + +`; diff --git a/app/components/Views/DetectedTokens/components/Token.tsx b/app/components/Views/DetectedTokens/components/Token.tsx index b79049b80b7..1c113c6ef3f 100644 --- a/app/components/Views/DetectedTokens/components/Token.tsx +++ b/app/components/Views/DetectedTokens/components/Token.tsx @@ -20,7 +20,7 @@ import { } from '../../../../util/number'; import { useTheme } from '../../../../util/theme'; import { - selectConversionRateFoAllChains, + selectCurrencyRates, selectCurrentCurrency, } from '../../../../selectors/currencyRateController'; import { selectTokenMarketData } from '../../../../selectors/tokenRatesController'; @@ -115,7 +115,7 @@ const Token = ({ token, selected, toggleSelected }: Props) => { tokenBalancesAllChains[accountAddress as Hex]; const tokenBalances = balanceAllChainsForAccount[(token.chainId as Hex) ?? currentChainId]; - const conversionRateByChainId = useSelector(selectConversionRateFoAllChains); + const conversionRateByChainId = useSelector(selectCurrencyRates); const chainIdToUse = token.chainId ?? currentChainId; const conversionRate = diff --git a/app/components/Views/DetectedTokens/index.tsx b/app/components/Views/DetectedTokens/index.tsx index 61d3f557beb..4078647508d 100644 --- a/app/components/Views/DetectedTokens/index.tsx +++ b/app/components/Views/DetectedTokens/index.tsx @@ -12,6 +12,7 @@ import { Token as TokenType } from '@metamask/assets-controllers'; import { useNavigation } from '@react-navigation/native'; import { FlatList } from 'react-native-gesture-handler'; import { Hex } from '@metamask/utils'; + // External Dependencies import { MetaMetricsEvents } from '../../../core/Analytics'; import { fontStyles } from '../../../styles/common'; @@ -22,7 +23,10 @@ import NotificationManager from '../../../core/NotificationManager'; import { strings } from '../../../../locales/i18n'; import Logger from '../../../util/Logger'; import { useTheme } from '../../../util/theme'; -import { getDecimalChainId } from '../../../util/networks'; +import { + getDecimalChainId, + isPortfolioViewEnabled, +} from '../../../util/networks'; import { createNavigationDetails } from '../../../util/navigation/navUtils'; import Routes from '../../../constants/navigation/Routes'; import { @@ -84,8 +88,6 @@ interface IgnoredTokensByAddress { [address: string]: true; } -const isPortfolioViewEnabled = process.env.PORTFOLIO_VIEW === 'true'; - const DetectedTokens = () => { const navigation = useNavigation(); const { trackEvent, createEventBuilder } = useMetrics(); @@ -100,14 +102,13 @@ const DetectedTokens = () => { const [ignoredTokens, setIgnoredTokens] = useState( {}, ); - const isAllNetworks = useSelector(selectIsAllNetworks); const { colors } = useTheme(); const styles = createStyles(colors); const currentDetectedTokens = - isPortfolioViewEnabled && isAllNetworks + isPortfolioViewEnabled() && isAllNetworks ? allDetectedTokens : detectedTokens; @@ -193,7 +194,7 @@ const DetectedTokens = () => { await Promise.all(ignorePromises); } if (tokensToImport.length > 0) { - if (isPortfolioViewEnabled) { + if (isPortfolioViewEnabled()) { // Group tokens by their `chainId` using a plain object const tokensByChainId: Record = {}; diff --git a/app/components/Views/NetworkSelector/NetworkSelector.test.tsx b/app/components/Views/NetworkSelector/NetworkSelector.test.tsx index 9e4c7ffefde..3866801562d 100644 --- a/app/components/Views/NetworkSelector/NetworkSelector.test.tsx +++ b/app/components/Views/NetworkSelector/NetworkSelector.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { createStackNavigator } from '@react-navigation/stack'; import { fireEvent, waitFor } from '@testing-library/react-native'; + // External dependencies import renderWithProvider from '../../../util/test/renderWithProvider'; import Engine from '../../../core/Engine'; @@ -57,6 +58,15 @@ jest.mock('../../../core/Engine', () => ({ }, PreferencesController: { setShowTestNetworks: jest.fn(), + setTokenNetworkFilter: jest.fn(), + tokenNetworkFilter: { + '0x1': true, + '0xe708': true, + '0xa86a': true, + '0x89': true, + '0xa': true, + '0x64': true, + }, }, CurrencyRateController: { updateExchangeRate: jest.fn() }, AccountTrackerController: { refresh: jest.fn() }, @@ -205,6 +215,14 @@ const initialState = { }, PreferencesController: { showTestNetworks: false, + tokenNetworkFilter: { + '0x1': true, + '0xe708': true, + '0xa86a': true, + '0x89': true, + '0xa': true, + '0x64': true, + }, }, NftController: { allNfts: { '0x': { '0x1': [] } }, @@ -349,6 +367,14 @@ describe('Network Selector', () => { ...initialState.engine.backgroundState, PreferencesController: { showTestNetworks: true, + tokenNetworkFilter: { + '0x1': true, + '0xe708': true, + '0xa86a': true, + '0x89': true, + '0xa': true, + '0x64': true, + }, }, NetworkController: { selectedNetworkClientId: 'sepolia', diff --git a/app/components/Views/NetworkSelector/NetworkSelector.tsx b/app/components/Views/NetworkSelector/NetworkSelector.tsx index 3567d9db256..444daa341b1 100644 --- a/app/components/Views/NetworkSelector/NetworkSelector.tsx +++ b/app/components/Views/NetworkSelector/NetworkSelector.tsx @@ -26,7 +26,10 @@ import BottomSheet, { } from '../../../component-library/components/BottomSheets/BottomSheet'; import { IconName } from '../../../component-library/components/Icons/Icon'; import { useSelector } from 'react-redux'; -import { selectNetworkConfigurations } from '../../../selectors/networkController'; +import { + selectNetworkConfigurations, + selectIsAllNetworks, +} from '../../../selectors/networkController'; import { selectShowTestNetworks } from '../../../selectors/preferencesController'; import Networks, { getAllNetworks, @@ -123,6 +126,7 @@ const NetworkSelector = () => { const styles = createStyles(colors); const sheetRef = useRef(null); const showTestNetworks = useSelector(selectShowTestNetworks); + const isAllNetworks = useSelector(selectIsAllNetworks); const networkConfigurations = useSelector(selectNetworkConfigurations); @@ -173,6 +177,18 @@ const NetworkSelector = () => { isReadOnly: false, }); + const setTokenNetworkFilter = useCallback( + (chainId: string) => { + const { PreferencesController } = Engine.context; + if (!isAllNetworks) { + PreferencesController.setTokenNetworkFilter({ + [chainId]: true, + }); + } + }, + [isAllNetworks], + ); + const onRpcSelect = useCallback( async (clientId: string, chainId: `0x${string}`) => { const { NetworkController } = Engine.context; @@ -262,6 +278,7 @@ const NetworkSelector = () => { await NetworkController.setActiveNetwork(networkClientId); } + setTokenNetworkFilter(chainId); sheetRef.current?.onCloseBottomSheet(); endTrace({ name: TraceName.SwitchCustomNetwork }); endTrace({ name: TraceName.NetworkSwitch }); @@ -376,6 +393,7 @@ const NetworkSelector = () => { networkConfiguration.defaultRpcEndpointIndex ].networkClientId ?? type; + setTokenNetworkFilter(networkConfiguration.chainId); NetworkController.setActiveNetwork(clientId); closeRpcModal(); AccountTrackerController.refresh(); @@ -430,10 +448,10 @@ const NetworkSelector = () => { const renderMainnet = () => { const { name: mainnetName, chainId } = Networks.mainnet; const rpcEndpoints = networkConfigurations?.[chainId]?.rpcEndpoints; - const rpcUrl = - rpcEndpoints?.[networkConfigurations?.[chainId]?.defaultRpcEndpointIndex] - .url; + networkConfigurations?.[chainId]?.rpcEndpoints?.[ + networkConfigurations?.[chainId]?.defaultRpcEndpointIndex + ].url; const name = networkConfigurations?.[chainId]?.name ?? mainnetName; if (isNetworkUiRedesignEnabled() && isNoSearchResults(MAINNET)) return null; @@ -497,8 +515,9 @@ const NetworkSelector = () => { const name = networkConfigurations?.[chainId]?.name ?? lineaMainnetName; const rpcEndpoints = networkConfigurations?.[chainId]?.rpcEndpoints; const rpcUrl = - rpcEndpoints?.[networkConfigurations?.[chainId]?.defaultRpcEndpointIndex] - .url; + networkConfigurations?.[chainId]?.rpcEndpoints?.[ + networkConfigurations?.[chainId]?.defaultRpcEndpointIndex + ].url; if (isNetworkUiRedesignEnabled() && isNoSearchResults('linea-mainnet')) return null; diff --git a/app/components/Views/QRTabSwitcher/QRTabSwitcher.test.tsx b/app/components/Views/QRTabSwitcher/QRTabSwitcher.test.tsx index d3f6db25b6c..0545e0cb756 100644 --- a/app/components/Views/QRTabSwitcher/QRTabSwitcher.test.tsx +++ b/app/components/Views/QRTabSwitcher/QRTabSwitcher.test.tsx @@ -35,8 +35,15 @@ jest.mock('@react-navigation/compat', () => { jest.mock('../QRScanner', () => jest.fn(() => null)); jest.mock('../../UI/ReceiveRequest', () => jest.fn(() => null)); +jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper'); + describe('QRTabSwitcher', () => { - beforeAll(() => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runAllTimers(); jest.useFakeTimers(); }); @@ -46,6 +53,7 @@ describe('QRTabSwitcher', () => { it('renders QRScanner by default', () => { const { getByText } = render(); + jest.runAllTimers(); expect(getByText(strings('qr_tab_switcher.scanner_tab'))).toBeTruthy(); }); @@ -57,6 +65,7 @@ describe('QRTabSwitcher', () => { }, }); const { queryByText } = render(); + jest.runAllTimers(); expect(queryByText(strings('qr_tab_switcher.scanner_tab'))).toBeNull(); expect(queryByText(strings('qr_tab_switcher.receive_tab'))).toBeNull(); }); diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx index 15f495955b6..df2a6c4fef2 100644 --- a/app/components/Views/Wallet/index.tsx +++ b/app/components/Views/Wallet/index.tsx @@ -501,7 +501,7 @@ const Wallet = ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any let stakedBalance: any = 0; - const assets = isPortfolioViewEnabled + const assets = isPortfolioViewEnabled() ? [...(tokensByChainIdAndAddress || [])] : [...(tokens || [])]; diff --git a/app/components/Views/confirmations/ApproveView/Approve/index.test.tsx b/app/components/Views/confirmations/ApproveView/Approve/index.test.tsx index 91b67a3beba..2dc54176d7d 100644 --- a/app/components/Views/confirmations/ApproveView/Approve/index.test.tsx +++ b/app/components/Views/confirmations/ApproveView/Approve/index.test.tsx @@ -97,6 +97,34 @@ describe('Approve', () => { alert: { isVisible: false, }, + engine: { + backgroundState: { + ...initialRootState.engine.backgroundState, + AccountsController: { + ...initialRootState.engine.backgroundState.AccountsController, + internalAccounts: { + ...initialRootState.engine.backgroundState.AccountsController + .internalAccounts, + selectedAccount: '30786334-3935-4563-b064-363339643939', + accounts: { + '30786334-3935-4563-b064-363339643939': { + address: '0xc4955c0d639d99699bfd7ec54d9fafee40e4d272', + }, + }, + }, + }, + TokensController: { + ...initialRootState.engine.backgroundState.TokensController, + allTokens: { + ...initialRootState.engine.backgroundState.TokensController + .allTokens, + '0x1': { + '0xc4955c0d639d99699bfd7ec54d9fafee40e4d272': [], + }, + }, + }, + }, + }, }); }); @@ -131,6 +159,29 @@ describe('Approve', () => { }, ], }, + AccountsController: { + ...initialRootState.engine.backgroundState.AccountsController, + internalAccounts: { + ...initialRootState.engine.backgroundState.AccountsController + .internalAccounts, + selectedAccount: '30786334-3935-4563-b064-363339643939', + accounts: { + '30786334-3935-4563-b064-363339643939': { + address: '0xc4955c0d639d99699bfd7ec54d9fafee40e4d272', + }, + }, + }, + }, + TokensController: { + ...initialRootState.engine.backgroundState.TokensController, + allTokens: { + ...initialRootState.engine.backgroundState.TokensController + .allTokens, + '0x1': { + '0xc4955c0d639d99699bfd7ec54d9fafee40e4d272': [], + }, + }, + }, }, }, }); diff --git a/app/components/Views/confirmations/SendFlow/Amount/index.test.tsx b/app/components/Views/confirmations/SendFlow/Amount/index.test.tsx index 5ffa1f47f4c..4f57005c85d 100644 --- a/app/components/Views/confirmations/SendFlow/Amount/index.test.tsx +++ b/app/components/Views/confirmations/SendFlow/Amount/index.test.tsx @@ -9,7 +9,6 @@ import TransactionTypes from '../../../../../core/TransactionTypes'; import { AmountViewSelectorsIDs } from '../../../../../../e2e/selectors/SendFlow/AmountView.selectors'; import { backgroundState } from '../../../../../util/test/initial-root-state'; -import { createMockAccountsControllerState } from '../../../../../util/test/accountsControllerTestUtils'; const mockTransactionTypes = TransactionTypes; @@ -73,10 +72,6 @@ const mockNavigate = jest.fn(); const CURRENT_ACCOUNT = '0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3'; const RECEIVER_ACCOUNT = '0x2a'; -const MOCK_ACCOUNTS_CONTROLLER_STATE = createMockAccountsControllerState([ - CURRENT_ACCOUNT, -]); - const initialState = { engine: { backgroundState: { @@ -111,11 +106,33 @@ const initialState = { AccountTrackerController: { accounts: { [CURRENT_ACCOUNT]: { balance: '0' } }, }, - AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE, + AccountsController: { + internalAccounts: { + selectedAccount: CURRENT_ACCOUNT, + accounts: { + [CURRENT_ACCOUNT]: { + address: CURRENT_ACCOUNT, + }, + }, + }, + }, NftController: { allNfts: { [CURRENT_ACCOUNT]: { '0x1': [] } }, allNftContracts: { [CURRENT_ACCOUNT]: { '0x1': [] } }, }, + TokensController: { + allTokens: { + '0x1': { + [CURRENT_ACCOUNT]: [ + { + address: '0x514910771AF9Ca656af840dff83E8264EcF986CA', + symbol: 'LINK', + decimals: 18, + }, + ], + }, + }, + }, }, }, settings: { @@ -177,6 +194,23 @@ describe('Amount', () => { }, }, }, + TokensController: { + allTokens: { + '0x1': { + [CURRENT_ACCOUNT]: [], + }, + }, + }, + AccountsController: { + internalAccounts: { + selectedAccount: CURRENT_ACCOUNT, + accounts: { + [CURRENT_ACCOUNT]: { + address: CURRENT_ACCOUNT, + }, + }, + }, + }, }, }, transaction: { @@ -223,6 +257,23 @@ describe('Amount', () => { }, }, }, + AccountsController: { + internalAccounts: { + selectedAccount: CURRENT_ACCOUNT, + accounts: { + [CURRENT_ACCOUNT]: { + address: CURRENT_ACCOUNT, + }, + }, + }, + }, + TokensController: { + allTokens: { + '0x1': { + [CURRENT_ACCOUNT]: [], + }, + }, + }, }, }, transaction: { @@ -287,6 +338,23 @@ describe('Amount', () => { }, }, }, + AccountsController: { + internalAccounts: { + selectedAccount: CURRENT_ACCOUNT, + accounts: { + [CURRENT_ACCOUNT]: { + address: CURRENT_ACCOUNT, + }, + }, + }, + }, + TokensController: { + allTokens: { + '0x1': { + [CURRENT_ACCOUNT]: [], + }, + }, + }, }, }, transaction: { @@ -347,6 +415,29 @@ describe('Amount', () => { }, }, }, + AccountsController: { + internalAccounts: { + selectedAccount: CURRENT_ACCOUNT, + accounts: { + [CURRENT_ACCOUNT]: { + address: CURRENT_ACCOUNT, + }, + }, + }, + }, + TokensController: { + allTokens: { + '0x1': { + [CURRENT_ACCOUNT]: [ + { + address: '0x514910771AF9Ca656af840dff83E8264EcF986CA', + symbol: 'LINK', + decimals: 18, + }, + ], + }, + }, + }, }, }, transaction: { @@ -402,6 +493,29 @@ describe('Amount', () => { }, }, }, + TokensController: { + allTokens: { + '0x1': { + [CURRENT_ACCOUNT]: [ + { + address: '0x514910771AF9Ca656af840dff83E8264EcF986CA', + symbol: 'LINK', + decimals: 18, + }, + ], + }, + }, + }, + AccountsController: { + internalAccounts: { + selectedAccount: CURRENT_ACCOUNT, + accounts: { + [CURRENT_ACCOUNT]: { + address: CURRENT_ACCOUNT, + }, + }, + }, + }, }, }, transaction: { @@ -450,6 +564,23 @@ describe('Amount', () => { }, }, }, + AccountsController: { + internalAccounts: { + selectedAccount: CURRENT_ACCOUNT, + accounts: { + [CURRENT_ACCOUNT]: { + address: CURRENT_ACCOUNT, + }, + }, + }, + }, + TokensController: { + allTokens: { + '0x1': { + [CURRENT_ACCOUNT]: [], + }, + }, + }, }, }, settings: { @@ -499,6 +630,29 @@ describe('Amount', () => { }, }, }, + AccountsController: { + internalAccounts: { + selectedAccount: CURRENT_ACCOUNT, + accounts: { + [CURRENT_ACCOUNT]: { + address: CURRENT_ACCOUNT, + }, + }, + }, + }, + TokensController: { + allTokens: { + '0x1': { + [CURRENT_ACCOUNT]: [ + { + address: '0x514910771AF9Ca656af840dff83E8264EcF986CA', + symbol: 'LINK', + decimals: 18, + }, + ], + }, + }, + }, CurrencyRateController: { currentCurrency: 'usd', currencyRates: { @@ -552,6 +706,29 @@ describe('Amount', () => { TokenRatesController: { marketData: {}, }, + AccountsController: { + internalAccounts: { + accounts: { + [CURRENT_ACCOUNT]: { + address: CURRENT_ACCOUNT, + }, + }, + selectedAccount: CURRENT_ACCOUNT, + }, + }, + TokensController: { + allTokens: { + '0x1': { + [CURRENT_ACCOUNT]: [ + { + address: '0x514910771AF9Ca656af840dff83E8264EcF986CA', + symbol: 'LINK', + decimals: 18, + }, + ], + }, + }, + }, CurrencyRateController: {}, }, }, @@ -597,6 +774,33 @@ describe('Amount', () => { }, }, }, + AccountsController: { + internalAccounts: { + ...initialState.engine.backgroundState.AccountsController + .internalAccounts, + accounts: { + ...initialState.engine.backgroundState.AccountsController + .internalAccounts.accounts, + [CURRENT_ACCOUNT]: { + address: CURRENT_ACCOUNT, + }, + }, + selectedAccount: CURRENT_ACCOUNT, + }, + }, + TokensController: { + allTokens: { + '0x1': { + [CURRENT_ACCOUNT]: [ + { + address: '0x514910771AF9Ca656af840dff83E8264EcF986CA', + symbol: 'LINK', + decimals: 18, + }, + ], + }, + }, + }, CurrencyRateController: {}, }, }, @@ -638,6 +842,24 @@ describe('Amount', () => { ...initialState.engine, backgroundState: { ...initialState.engine.backgroundState, + TokensController: { + tokens: [], + allTokens: { + '0x1': { + '0xAddress1': [], + }, + }, + }, + AccountsController: { + internalAccounts: { + selectedAccount: '0xAddress1', + accounts: { + '0xAddress1': { + address: '0xAddress1', + }, + }, + }, + }, TokenRatesController: { marketData: { '0x1': { diff --git a/app/components/Views/confirmations/components/ApproveTransactionReview/VerifyContractDetails/VerifyContractDetails.test.tsx b/app/components/Views/confirmations/components/ApproveTransactionReview/VerifyContractDetails/VerifyContractDetails.test.tsx index 371d9cd9b48..d8e93bfc710 100644 --- a/app/components/Views/confirmations/components/ApproveTransactionReview/VerifyContractDetails/VerifyContractDetails.test.tsx +++ b/app/components/Views/confirmations/components/ApproveTransactionReview/VerifyContractDetails/VerifyContractDetails.test.tsx @@ -5,7 +5,28 @@ import { backgroundState } from '../../../../../../util/test/initial-root-state' const initialState = { engine: { - backgroundState, + backgroundState: { + ...backgroundState, + AccountsController: { + ...backgroundState.AccountsController, + internalAccounts: { + ...backgroundState.AccountsController.internalAccounts, + selectedAccount: '30786334-3935-4563-b064-363339643939', + accounts: { + '30786334-3935-4563-b064-363339643939': { + address: '0xc4955c0d639d99699bfd7ec54d9fafee40e4d272', + }, + }, + }, + }, + TokensController: { + allTokens: { + '0x1': { + '0xc4955c0d639d99699bfd7ec54d9fafee40e4d272': [], + }, + }, + }, + }, }, settings: { primaryCurrency: 'ETH', diff --git a/app/components/Views/confirmations/components/ApproveTransactionReview/index.test.tsx b/app/components/Views/confirmations/components/ApproveTransactionReview/index.test.tsx index 465c57fd55a..f6a56d3d462 100644 --- a/app/components/Views/confirmations/components/ApproveTransactionReview/index.test.tsx +++ b/app/components/Views/confirmations/components/ApproveTransactionReview/index.test.tsx @@ -63,7 +63,27 @@ const transaction = { const initialState = { engine: { - backgroundState, + backgroundState: { + ...backgroundState, + TokensController: { + tokens: [], + allTokens: { + '0x1': { + '0xAddress1': [], + }, + }, + }, + AccountsController: { + internalAccounts: { + selectedAccount: '0xAddress1', + accounts: { + '0xAddress1': { + address: '0xAddress1', + }, + }, + }, + }, + }, }, transaction, settings: { diff --git a/app/components/hooks/AssetPolling/AssetPollingProvider.test.tsx b/app/components/hooks/AssetPolling/AssetPollingProvider.test.tsx index 2f25159f33b..c954398b018 100644 --- a/app/components/hooks/AssetPolling/AssetPollingProvider.test.tsx +++ b/app/components/hooks/AssetPolling/AssetPollingProvider.test.tsx @@ -8,14 +8,14 @@ jest.mock('./useTokenRatesPolling', () => jest.fn()); jest.mock('./useTokenDetectionPolling', () => jest.fn()); jest.mock('./useTokenListPolling', () => jest.fn()); jest.mock('./useTokenBalancesPolling', () => jest.fn()); +jest.mock('./useAccountTrackerPolling', () => jest.fn()); describe('AssetPollingProvider', () => { it('should call all polling hooks', () => { - render(
-
+ , ); expect(jest.requireMock('./useCurrencyRatePolling')).toHaveBeenCalled(); @@ -23,5 +23,6 @@ describe('AssetPollingProvider', () => { expect(jest.requireMock('./useTokenDetectionPolling')).toHaveBeenCalled(); expect(jest.requireMock('./useTokenListPolling')).toHaveBeenCalled(); expect(jest.requireMock('./useTokenBalancesPolling')).toHaveBeenCalled(); + expect(jest.requireMock('./useAccountTrackerPolling')).toHaveBeenCalled(); }); }); diff --git a/app/components/hooks/AssetPolling/AssetPollingProvider.tsx b/app/components/hooks/AssetPolling/AssetPollingProvider.tsx index 33fc54e753e..4cc7f880aec 100644 --- a/app/components/hooks/AssetPolling/AssetPollingProvider.tsx +++ b/app/components/hooks/AssetPolling/AssetPollingProvider.tsx @@ -4,6 +4,7 @@ import useTokenRatesPolling from './useTokenRatesPolling'; import useTokenDetectionPolling from './useTokenDetectionPolling'; import useTokenListPolling from './useTokenListPolling'; import useTokenBalancesPolling from './useTokenBalancesPolling'; +import useAccountTrackerPolling from './useAccountTrackerPolling'; // This provider is a step towards making controller polling fully UI based. // Eventually, individual UI components will call the use*Polling hooks to @@ -12,6 +13,7 @@ export const AssetPollingProvider = ({ children }: { children: ReactNode }) => { useCurrencyRatePolling(); useTokenRatesPolling(); useTokenDetectionPolling(); + useAccountTrackerPolling(); useTokenListPolling(); useTokenBalancesPolling(); diff --git a/app/components/hooks/AssetPolling/useAccountTrackerPolling.ts b/app/components/hooks/AssetPolling/useAccountTrackerPolling.ts new file mode 100644 index 00000000000..195d1226c6a --- /dev/null +++ b/app/components/hooks/AssetPolling/useAccountTrackerPolling.ts @@ -0,0 +1,54 @@ +import { useSelector } from 'react-redux'; +import usePolling from '../usePolling'; +import { + selectNetworkConfigurations, + selectSelectedNetworkClientId, +} from '../../../selectors/networkController'; +import Engine from '../../../core/Engine'; +import { isPortfolioViewEnabled } from '../../../util/networks'; +import { selectAccountsByChainId } from '../../../selectors/accountTrackerController'; + +// Polls native currency prices across networks. +const useAccountTrackerPolling = ({ + networkClientIds, +}: { networkClientIds?: { networkClientId: string }[] } = {}) => { + // Selectors to determine polling input + const networkConfigurations = useSelector(selectNetworkConfigurations); + const selectedNetworkClientId = useSelector(selectSelectedNetworkClientId); + + const accountsByChainId = useSelector(selectAccountsByChainId); + const networkClientIdsConfig = Object.values(networkConfigurations).map( + (network) => ({ + networkClientId: + network?.rpcEndpoints?.[network?.defaultRpcEndpointIndex] + ?.networkClientId, + }), + ); + + const chainIdsToPoll = isPortfolioViewEnabled() + ? networkClientIds ?? networkClientIdsConfig + : [ + { + networkClientId: selectedNetworkClientId, + }, + ]; + + const { AccountTrackerController } = Engine.context; + + usePolling({ + startPolling: AccountTrackerController.startPolling.bind( + AccountTrackerController, + ), + stopPollingByPollingToken: + AccountTrackerController.stopPollingByPollingToken.bind( + AccountTrackerController, + ), + input: chainIdsToPoll, + }); + + return { + accountsByChainId, + }; +}; + +export default useAccountTrackerPolling; diff --git a/app/components/hooks/AssetPolling/useTokenBalancesPolling.test.ts b/app/components/hooks/AssetPolling/useTokenBalancesPolling.test.ts index e2a3862f7e0..2411b4c6f61 100644 --- a/app/components/hooks/AssetPolling/useTokenBalancesPolling.test.ts +++ b/app/components/hooks/AssetPolling/useTokenBalancesPolling.test.ts @@ -1,6 +1,8 @@ import { renderHookWithProvider } from '../../../util/test/renderWithProvider'; import Engine from '../../../core/Engine'; import useTokenBalancesPolling from './useTokenBalancesPolling'; +// eslint-disable-next-line import/no-namespace +import * as networks from '../../../util/networks'; jest.mock('../../../core/Engine', () => ({ context: { @@ -12,7 +14,6 @@ jest.mock('../../../core/Engine', () => ({ })); describe('useTokenBalancesPolling', () => { - beforeEach(() => { jest.resetAllMocks(); }); @@ -29,9 +30,11 @@ describe('useTokenBalancesPolling', () => { networkConfigurationsByChainId: { [selectedChainId]: { chainId: selectedChainId, - rpcEndpoints: [{ - networkClientId: 'selectedNetworkClientId', - }] + rpcEndpoints: [ + { + networkClientId: 'selectedNetworkClientId', + }, + ], }, '0x89': {}, }, @@ -40,19 +43,80 @@ describe('useTokenBalancesPolling', () => { }, }; - it('Should poll by selected chain id, and stop polling on dismount', async () => { + it('should poll by selected chain id when portfolio view is disabled', () => { + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(false); - const { unmount } = renderHookWithProvider(() => useTokenBalancesPolling(), {state}); + const { unmount } = renderHookWithProvider( + () => useTokenBalancesPolling(), + { + state, + }, + ); - const mockedTokenBalancesController = jest.mocked(Engine.context.TokenBalancesController); + const mockedTokenBalancesController = jest.mocked( + Engine.context.TokenBalancesController, + ); expect(mockedTokenBalancesController.startPolling).toHaveBeenCalledTimes(1); + expect(mockedTokenBalancesController.startPolling).toHaveBeenCalledWith({ + chainId: selectedChainId, + }); + + unmount(); + expect( + mockedTokenBalancesController.stopPollingByPollingToken, + ).toHaveBeenCalledTimes(1); + }); + + it('should poll all network configurations when portfolio view is enabled', () => { + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(true); + + const { unmount } = renderHookWithProvider( + () => useTokenBalancesPolling(), + { + state, + }, + ); + + const mockedTokenBalancesController = jest.mocked( + Engine.context.TokenBalancesController, + ); + + expect(mockedTokenBalancesController.startPolling).toHaveBeenCalledTimes(2); // For both chain IDs + expect(mockedTokenBalancesController.startPolling).toHaveBeenCalledWith({ + chainId: selectedChainId, + }); + expect(mockedTokenBalancesController.startPolling).toHaveBeenCalledWith({ + chainId: '0x89', + }); + + unmount(); expect( - mockedTokenBalancesController.startPolling - ).toHaveBeenCalledWith({chainId: selectedChainId}); + mockedTokenBalancesController.stopPollingByPollingToken, + ).toHaveBeenCalledTimes(2); + }); + + it('should use provided chainIds when specified, even with portfolio view enabled', () => { + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(true); + + const specificChainIds = ['0x5' as const]; + const { unmount } = renderHookWithProvider( + () => useTokenBalancesPolling({ chainIds: specificChainIds }), + { state }, + ); + + const mockedTokenBalancesController = jest.mocked( + Engine.context.TokenBalancesController, + ); + + expect(mockedTokenBalancesController.startPolling).toHaveBeenCalledTimes(1); + expect(mockedTokenBalancesController.startPolling).toHaveBeenCalledWith({ + chainId: '0x5', + }); - expect(mockedTokenBalancesController.stopPollingByPollingToken).toHaveBeenCalledTimes(0); unmount(); - expect(mockedTokenBalancesController.stopPollingByPollingToken).toHaveBeenCalledTimes(1); + expect( + mockedTokenBalancesController.stopPollingByPollingToken, + ).toHaveBeenCalledTimes(1); }); }); diff --git a/app/components/hooks/AssetPolling/useTokenBalancesPolling.ts b/app/components/hooks/AssetPolling/useTokenBalancesPolling.ts index 2b67d4ce124..d2f0ca1cc1c 100644 --- a/app/components/hooks/AssetPolling/useTokenBalancesPolling.ts +++ b/app/components/hooks/AssetPolling/useTokenBalancesPolling.ts @@ -1,13 +1,15 @@ import { useSelector } from 'react-redux'; import usePolling from '../usePolling'; import Engine from '../../../core/Engine'; -import { selectChainId, selectNetworkConfigurations } from '../../../selectors/networkController'; +import { + selectChainId, + selectNetworkConfigurations, +} from '../../../selectors/networkController'; import { Hex } from '@metamask/utils'; import { isPortfolioViewEnabled } from '../../../util/networks'; import { selectAllTokenBalances } from '../../../selectors/tokenBalancesController'; const useTokenBalancesPolling = ({ chainIds }: { chainIds?: Hex[] } = {}) => { - // Selectors to determine polling input const networkConfigurations = useSelector(selectNetworkConfigurations); const currentChainId = useSelector(selectChainId); @@ -15,22 +17,25 @@ const useTokenBalancesPolling = ({ chainIds }: { chainIds?: Hex[] } = {}) => { // Selectors returning state updated by the polling const tokenBalances = useSelector(selectAllTokenBalances); - const chainIdsToPoll = isPortfolioViewEnabled - ? (chainIds ?? Object.keys(networkConfigurations)) + const chainIdsToPoll = isPortfolioViewEnabled() + ? chainIds ?? Object.keys(networkConfigurations) : [currentChainId]; const { TokenBalancesController } = Engine.context; usePolling({ - startPolling: - TokenBalancesController.startPolling.bind(TokenBalancesController), + startPolling: TokenBalancesController.startPolling.bind( + TokenBalancesController, + ), stopPollingByPollingToken: - TokenBalancesController.stopPollingByPollingToken.bind(TokenBalancesController), - input: chainIdsToPoll.map((chainId) => ({chainId: chainId as Hex})), + TokenBalancesController.stopPollingByPollingToken.bind( + TokenBalancesController, + ), + input: chainIdsToPoll.map((chainId) => ({ chainId: chainId as Hex })), }); return { - tokenBalances + tokenBalances, }; }; diff --git a/app/components/hooks/AssetPolling/useTokenDetectionPolling.test.ts b/app/components/hooks/AssetPolling/useTokenDetectionPolling.test.ts index 1294d23cad8..b454c1be5e8 100644 --- a/app/components/hooks/AssetPolling/useTokenDetectionPolling.test.ts +++ b/app/components/hooks/AssetPolling/useTokenDetectionPolling.test.ts @@ -1,6 +1,8 @@ import { renderHookWithProvider } from '../../../util/test/renderWithProvider'; import Engine from '../../../core/Engine'; import useTokenDetectionPolling from './useTokenDetectionPolling'; +// eslint-disable-next-line import/no-namespace +import * as networks from '../../../util/networks'; jest.mock('../../../core/Engine', () => ({ context: { @@ -12,7 +14,6 @@ jest.mock('../../../core/Engine', () => ({ })); describe('useTokenDetectionPolling', () => { - beforeEach(() => { jest.resetAllMocks(); }); @@ -28,8 +29,8 @@ describe('useTokenDetectionPolling', () => { selectedAccount: '1', accounts: { '1': { - address: selectedAddress - } + address: selectedAddress, + }, }, }, }, @@ -41,11 +42,12 @@ describe('useTokenDetectionPolling', () => { networkConfigurationsByChainId: { [selectedChainId]: { chainId: selectedChainId, - rpcEndpoints: [{ - networkClientId: 'selectedNetworkClientId', - }] + rpcEndpoints: [ + { + networkClientId: 'selectedNetworkClientId', + }, + ], }, - '0x89': {}, }, }, }, @@ -53,40 +55,212 @@ describe('useTokenDetectionPolling', () => { }; it('Should poll by current chain ids/address, and stop polling on dismount', async () => { + const { unmount } = renderHookWithProvider( + () => useTokenDetectionPolling(), + { state }, + ); - const { unmount } = renderHookWithProvider(() => useTokenDetectionPolling(), {state}); + const mockedTokenDetectionController = jest.mocked( + Engine.context.TokenDetectionController, + ); - const mockedTokenDetectionController = jest.mocked(Engine.context.TokenDetectionController); + expect(mockedTokenDetectionController.startPolling).toHaveBeenCalledTimes( + 1, + ); + expect(mockedTokenDetectionController.startPolling).toHaveBeenCalledWith({ + chainIds: [selectedChainId], + address: selectedAddress, + }); - expect(mockedTokenDetectionController.startPolling).toHaveBeenCalledTimes(1); expect( - mockedTokenDetectionController.startPolling - ).toHaveBeenCalledWith({chainIds: [selectedChainId], address: selectedAddress}); - - expect(mockedTokenDetectionController.stopPollingByPollingToken).toHaveBeenCalledTimes(0); + mockedTokenDetectionController.stopPollingByPollingToken, + ).toHaveBeenCalledTimes(0); unmount(); - expect(mockedTokenDetectionController.stopPollingByPollingToken).toHaveBeenCalledTimes(1); - + expect( + mockedTokenDetectionController.stopPollingByPollingToken, + ).toHaveBeenCalledTimes(1); }); it('Should not poll when token detection is disabled', async () => { + renderHookWithProvider( + () => useTokenDetectionPolling({ chainIds: ['0x1'] }), + { + state: { + ...state, + engine: { + ...state.engine, + backgroundState: { + ...state.engine.backgroundState, + PreferencesController: { + ...state.engine.backgroundState.PreferencesController, + useTokenDetection: false, + }, + }, + }, + }, + }, + ); + + const mockedTokenDetectionController = jest.mocked( + Engine.context.TokenDetectionController, + ); + expect(mockedTokenDetectionController.startPolling).toHaveBeenCalledTimes( + 0, + ); + expect( + mockedTokenDetectionController.stopPollingByPollingToken, + ).toHaveBeenCalledTimes(0); + }); + + it('Should poll with specific chainIds when provided', async () => { + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(true); + + const specificChainIds = ['0x5' as const]; + const { unmount } = renderHookWithProvider( + () => useTokenDetectionPolling({ chainIds: specificChainIds }), + { + state: { + ...state, + engine: { + ...state.engine, + backgroundState: { + ...state.engine.backgroundState, + NetworkController: { + selectedNetworkClientId: 'selectedNetworkClientId', + networkConfigurationsByChainId: { + '0x5': { + chainId: '0x5', + rpcEndpoints: [ + { + networkClientId: 'selectedNetworkClientId', + }, + ], + }, + }, + }, + }, + }, + }, + }, + ); + + const mockedTokenDetectionController = jest.mocked( + Engine.context.TokenDetectionController, + ); + + expect(mockedTokenDetectionController.startPolling).toHaveBeenCalledTimes( + 1, + ); + expect(mockedTokenDetectionController.startPolling).toHaveBeenCalledWith({ + chainIds: ['0x5'], + address: selectedAddress, + }); + + unmount(); + expect( + mockedTokenDetectionController.stopPollingByPollingToken, + ).toHaveBeenCalledTimes(1); + }); + + it('Should poll with network configurations when no chainIds provided', async () => { + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(false); + + const currentChainId = '0x1'; + const { unmount } = renderHookWithProvider( + () => useTokenDetectionPolling(), + { + state: { + ...state, + engine: { + ...state.engine, + backgroundState: { + ...state.engine.backgroundState, + NetworkController: { + selectedNetworkClientId: 'selectedNetworkClientId', + networkConfigurationsByChainId: { + [currentChainId]: { + chainId: currentChainId, + rpcEndpoints: [ + { + networkClientId: 'selectedNetworkClientId', + }, + ], + }, + '0x89': { + chainId: '0x89', + rpcEndpoints: [ + { + networkClientId: 'otherNetworkClientId', + }, + ], + }, + }, + }, + }, + }, + }, + }, + ); + + const mockedTokenDetectionController = jest.mocked( + Engine.context.TokenDetectionController, + ); - renderHookWithProvider(() => useTokenDetectionPolling({chainIds: ['0x1']}), {state:{ - ...state, - engine: { - ...state.engine, - backgroundState: { - ...state.engine.backgroundState, - PreferencesController: { - ...state.engine.backgroundState.PreferencesController, - useTokenDetection: false, + expect(mockedTokenDetectionController.startPolling).toHaveBeenCalledTimes( + 1, + ); + expect(mockedTokenDetectionController.startPolling).toHaveBeenCalledWith({ + chainIds: [currentChainId], + address: selectedAddress, + }); + + unmount(); + expect( + mockedTokenDetectionController.stopPollingByPollingToken, + ).toHaveBeenCalledTimes(1); + }); + + it('Should handle missing account address gracefully', async () => { + const { unmount } = renderHookWithProvider( + () => useTokenDetectionPolling(), + { + state: { + ...state, + engine: { + ...state.engine, + backgroundState: { + ...state.engine.backgroundState, + AccountsController: { + internalAccounts: { + selectedAccount: '1', + accounts: { + '1': { + address: undefined, + }, + }, + }, + }, + }, }, }, }, - }}); + ); + + const mockedTokenDetectionController = jest.mocked( + Engine.context.TokenDetectionController, + ); + + expect(mockedTokenDetectionController.startPolling).toHaveBeenCalledTimes( + 1, + ); + expect(mockedTokenDetectionController.startPolling).toHaveBeenCalledWith({ + chainIds: [selectedChainId], + address: undefined, + }); - const mockedTokenDetectionController = jest.mocked(Engine.context.TokenDetectionController); - expect(mockedTokenDetectionController.startPolling).toHaveBeenCalledTimes(0); - expect(mockedTokenDetectionController.stopPollingByPollingToken).toHaveBeenCalledTimes(0); + unmount(); + expect( + mockedTokenDetectionController.stopPollingByPollingToken, + ).toHaveBeenCalledTimes(1); }); }); diff --git a/app/components/hooks/AssetPolling/useTokenDetectionPolling.ts b/app/components/hooks/AssetPolling/useTokenDetectionPolling.ts index d07ba5c46f9..dccdf39acbd 100644 --- a/app/components/hooks/AssetPolling/useTokenDetectionPolling.ts +++ b/app/components/hooks/AssetPolling/useTokenDetectionPolling.ts @@ -1,37 +1,46 @@ import { useSelector } from 'react-redux'; import usePolling from '../usePolling'; import Engine from '../../../core/Engine'; -import { selectChainId, selectNetworkConfigurations } from '../../../selectors/networkController'; +import { + selectChainId, + selectNetworkConfigurations, +} from '../../../selectors/networkController'; import { Hex } from '@metamask/utils'; import { isPortfolioViewEnabled } from '../../../util/networks'; import { selectSelectedInternalAccount } from '../../../selectors/accountsController'; import { selectUseTokenDetection } from '../../../selectors/preferencesController'; const useTokenDetectionPolling = ({ chainIds }: { chainIds?: Hex[] } = {}) => { - const networkConfigurations = useSelector(selectNetworkConfigurations); const currentChainId = useSelector(selectChainId); const selectedAccount = useSelector(selectSelectedInternalAccount); const useTokenDetection = useSelector(selectUseTokenDetection); - const chainIdsToPoll = isPortfolioViewEnabled - ? (chainIds ?? Object.keys(networkConfigurations)) + const chainIdsToPoll = isPortfolioViewEnabled() + ? chainIds ?? Object.keys(networkConfigurations) : [currentChainId]; const { TokenDetectionController } = Engine.context; usePolling({ - startPolling: - TokenDetectionController.startPolling.bind(TokenDetectionController), + startPolling: TokenDetectionController.startPolling.bind( + TokenDetectionController, + ), stopPollingByPollingToken: - TokenDetectionController.stopPollingByPollingToken.bind(TokenDetectionController), - input: useTokenDetection ? [{ - chainIds: chainIdsToPoll as Hex[], - address: selectedAccount?.address as Hex - }] : [] + TokenDetectionController.stopPollingByPollingToken.bind( + TokenDetectionController, + ), + input: useTokenDetection + ? [ + { + chainIds: chainIdsToPoll as Hex[], + address: selectedAccount?.address as Hex, + }, + ] + : [], }); - return { }; + return {}; }; export default useTokenDetectionPolling; diff --git a/app/components/hooks/AssetPolling/useTokenListPolling.test.ts b/app/components/hooks/AssetPolling/useTokenListPolling.test.ts index cbf1ff805e5..6ffc511088e 100644 --- a/app/components/hooks/AssetPolling/useTokenListPolling.test.ts +++ b/app/components/hooks/AssetPolling/useTokenListPolling.test.ts @@ -1,6 +1,8 @@ import { renderHookWithProvider } from '../../../util/test/renderWithProvider'; import Engine from '../../../core/Engine'; import useTokenListPolling from './useTokenListPolling'; +// eslint-disable-next-line import/no-namespace +import * as networks from '../../../util/networks'; jest.mock('../../../core/Engine', () => ({ context: { @@ -12,7 +14,6 @@ jest.mock('../../../core/Engine', () => ({ })); describe('useTokenListPolling', () => { - beforeEach(() => { jest.resetAllMocks(); }); @@ -26,9 +27,11 @@ describe('useTokenListPolling', () => { networkConfigurationsByChainId: { [selectedChainId]: { chainId: selectedChainId, - rpcEndpoints: [{ - networkClientId: 'selectedNetworkClientId', - }] + rpcEndpoints: [ + { + networkClientId: 'selectedNetworkClientId', + }, + ], }, '0x89': {}, }, @@ -38,18 +41,52 @@ describe('useTokenListPolling', () => { }; it('Should poll by selected chain id, and stop polling on dismount', async () => { + const { unmount } = renderHookWithProvider(() => useTokenListPolling(), { + state, + }); - const { unmount } = renderHookWithProvider(() => useTokenListPolling(), {state}); - - const mockedTokenListController = jest.mocked(Engine.context.TokenListController); + const mockedTokenListController = jest.mocked( + Engine.context.TokenListController, + ); + const calledAmount = networks.isPortfolioViewEnabled() ? 2 : 1; + expect(mockedTokenListController.startPolling).toHaveBeenCalledTimes( + calledAmount, + ); + expect(mockedTokenListController.startPolling).toHaveBeenCalledWith({ + chainId: selectedChainId, + }); - expect(mockedTokenListController.startPolling).toHaveBeenCalledTimes(1); expect( - mockedTokenListController.startPolling - ).toHaveBeenCalledWith({chainId: selectedChainId}); + mockedTokenListController.stopPollingByPollingToken, + ).toHaveBeenCalledTimes(0); + unmount(); + expect( + mockedTokenListController.stopPollingByPollingToken, + ).toHaveBeenCalledTimes(calledAmount); + }); + + it('Should poll all networks when portfolio view is enabled', async () => { + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(true); + + const { unmount } = renderHookWithProvider(() => useTokenListPolling(), { + state, + }); + + const mockedTokenListController = jest.mocked( + Engine.context.TokenListController, + ); + + expect(mockedTokenListController.startPolling).toHaveBeenCalledTimes(2); + expect(mockedTokenListController.startPolling).toHaveBeenCalledWith({ + chainId: selectedChainId, + }); + expect(mockedTokenListController.startPolling).toHaveBeenCalledWith({ + chainId: '0x89', + }); - expect(mockedTokenListController.stopPollingByPollingToken).toHaveBeenCalledTimes(0); unmount(); - expect(mockedTokenListController.stopPollingByPollingToken).toHaveBeenCalledTimes(1); + expect( + mockedTokenListController.stopPollingByPollingToken, + ).toHaveBeenCalledTimes(2); }); }); diff --git a/app/components/hooks/AssetPolling/useTokenListPolling.ts b/app/components/hooks/AssetPolling/useTokenListPolling.ts index 13bc408efd8..cccce3f4c15 100644 --- a/app/components/hooks/AssetPolling/useTokenListPolling.ts +++ b/app/components/hooks/AssetPolling/useTokenListPolling.ts @@ -1,13 +1,18 @@ import { useSelector } from 'react-redux'; import usePolling from '../usePolling'; import Engine from '../../../core/Engine'; -import { selectChainId, selectNetworkConfigurations } from '../../../selectors/networkController'; +import { + selectChainId, + selectNetworkConfigurations, +} from '../../../selectors/networkController'; import { Hex } from '@metamask/utils'; import { isPortfolioViewEnabled } from '../../../util/networks'; -import { selectERC20TokensByChain, selectTokenList } from '../../../selectors/tokenListController'; +import { + selectERC20TokensByChain, + selectTokenList, +} from '../../../selectors/tokenListController'; const useTokenListPolling = ({ chainIds }: { chainIds?: Hex[] } = {}) => { - // Selectors to determine polling input const networkConfigurations = useSelector(selectNetworkConfigurations); const currentChainId = useSelector(selectChainId); @@ -16,18 +21,17 @@ const useTokenListPolling = ({ chainIds }: { chainIds?: Hex[] } = {}) => { const tokenList = useSelector(selectTokenList); const tokenListByChain = useSelector(selectERC20TokensByChain); - const chainIdsToPoll = isPortfolioViewEnabled - ? (chainIds ?? Object.keys(networkConfigurations)) + const chainIdsToPoll = isPortfolioViewEnabled() + ? chainIds ?? Object.keys(networkConfigurations) : [currentChainId]; const { TokenListController } = Engine.context; usePolling({ - startPolling: - TokenListController.startPolling.bind(TokenListController), + startPolling: TokenListController.startPolling.bind(TokenListController), stopPollingByPollingToken: TokenListController.stopPollingByPollingToken.bind(TokenListController), - input: chainIdsToPoll.map((chainId) => ({ chainId: chainId as Hex })) + input: chainIdsToPoll.map((chainId) => ({ chainId: chainId as Hex })), }); return { diff --git a/app/components/hooks/AssetPolling/useTokenRatesPolling.ts b/app/components/hooks/AssetPolling/useTokenRatesPolling.ts index 404295a94e4..25351bccd5b 100644 --- a/app/components/hooks/AssetPolling/useTokenRatesPolling.ts +++ b/app/components/hooks/AssetPolling/useTokenRatesPolling.ts @@ -21,7 +21,7 @@ const useTokenRatesPolling = ({ chainIds }: { chainIds?: Hex[] } = {}) => { const contractExchangeRates = useSelector(selectContractExchangeRates); const tokenMarketData = useSelector(selectTokenMarketData); - const chainIdsToPoll = isPortfolioViewEnabled + const chainIdsToPoll = isPortfolioViewEnabled() ? chainIds ?? Object.keys(networkConfigurations) : [currentChainId]; diff --git a/app/components/hooks/useAccounts/useAccounts.test.ts b/app/components/hooks/useAccounts/useAccounts.test.ts index f156a7ff974..ad33a8da426 100644 --- a/app/components/hooks/useAccounts/useAccounts.test.ts +++ b/app/components/hooks/useAccounts/useAccounts.test.ts @@ -6,6 +6,8 @@ import { backgroundState } from '../../../util/test/initial-root-state'; import { MOCK_ACCOUNTS_CONTROLLER_STATE } from '../../../util/test/accountsControllerTestUtils'; import { Account } from './useAccounts.types'; import { Hex } from '@metamask/utils'; +// eslint-disable-next-line import/no-namespace +import * as networks from '../../../util/networks'; import { getAccountBalances } from './utils'; const mockReturnGetAccountBalances = getAccountBalances as jest.Mock; @@ -123,6 +125,7 @@ describe('useAccounts', () => { }); it('returns internal accounts', async () => { + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(false); mockReturnGetAccountBalances.mockReturnValueOnce({ balanceWeiHex: '0x0', balanceETH: '0', diff --git a/app/components/hooks/useAccounts/utils.ts b/app/components/hooks/useAccounts/utils.ts index 1aa05ca94f1..248f9d8d704 100644 --- a/app/components/hooks/useAccounts/utils.ts +++ b/app/components/hooks/useAccounts/utils.ts @@ -9,7 +9,7 @@ import { } from '../../../util/number'; import { AccountInformation } from '@metamask/assets-controllers'; import { TotalFiatBalancesCrossChains } from '../useGetTotalFiatBalanceCrossChains'; -import { isPortfolioViewEnabledFunction } from '../../../util/networks'; +import { isPortfolioViewEnabled } from '../../../util/networks'; interface AccountInfo { [address: string]: AccountInformation; @@ -40,10 +40,14 @@ export const getAccountBalances = ({ const balanceETH = renderFromWei(totalBalanceWeiHex); // Gives ETH // IF portfolio view is active, display aggregated fiat balance cross chains let balanceFiat; - if (isPortfolioViewEnabledFunction()) { - const { totalFiatBalance } = - totalFiatBalancesCrossChain[internalAccount.address]; - balanceFiat = `${renderFiat(totalFiatBalance, currentCurrency)}`; + if (isPortfolioViewEnabled()) { + const totalFiatBalance = + totalFiatBalancesCrossChain[internalAccount?.address as string] + ?.totalFiatBalance; + balanceFiat = + totalFiatBalance !== undefined + ? `${renderFiat(totalFiatBalance, currentCurrency)}` + : ''; } else { balanceFiat = weiToFiat(hexToBN(totalBalanceWeiHex), conversionRate, currentCurrency) || diff --git a/app/components/hooks/useGetFormattedTokensPerChain.test.ts b/app/components/hooks/useGetFormattedTokensPerChain.test.ts index 75f7123dd99..84424b8820b 100644 --- a/app/components/hooks/useGetFormattedTokensPerChain.test.ts +++ b/app/components/hooks/useGetFormattedTokensPerChain.test.ts @@ -20,13 +20,11 @@ const mockInitialState: DeepPartial = { address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', symbol: 'USDC', decimals: 6, - name: 'USDC', }, { address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', symbol: 'DAI', decimals: 18, - name: 'Dai Stablecoin', }, ], }, @@ -39,7 +37,6 @@ const mockInitialState: DeepPartial = { image: 'https://static.cx.metamask.io/api/v1/tokenIcons/59144/0x0d1e753a25ebda689453309112904807625befbe.png', aggregators: ['CoinGecko', 'Lifi', 'Rubic'], - name: 'PancakeSwap', }, ], }, diff --git a/app/reducers/swaps/index.js b/app/reducers/swaps/index.js index 84674aee8ca..31aa743daa6 100644 --- a/app/reducers/swaps/index.js +++ b/app/reducers/swaps/index.js @@ -5,11 +5,15 @@ import { toLowerCaseEquals } from '../../util/general'; import Engine from '../../core/Engine'; import { lte } from '../../util/lodash'; import { selectChainId } from '../../selectors/networkController'; -import { selectTokens } from '../../selectors/tokensController'; +import { + selectAllTokens, + selectTokens, +} from '../../selectors/tokensController'; import { selectContractBalances } from '../../selectors/tokenBalancesController'; import { getChainFeatureFlags, getSwapsLiveness } from './utils'; import { allowedTestnetChainIds } from '../../components/UI/Swaps/utils'; import { NETWORKS_CHAIN_ID } from '../../constants/network'; +import { selectSelectedInternalAccountAddress } from '../../selectors/accountsController'; // If we are in dev and on a testnet, just use mainnet feature flags, // since we don't have feature flags for testnets in the API @@ -190,6 +194,39 @@ const swapsControllerAndUserTokens = createSelector( }, ); +const swapsControllerAndUserTokensMultichain = createSelector( + swapsControllerTokens, + selectAllTokens, + selectSelectedInternalAccountAddress, + (swapsTokens, allTokens, currentUserAddress) => { + const allTokensArr = Object.values(allTokens); + const allUserTokensCrossChains = allTokensArr.reduce( + (acc, tokensElement) => { + const found = tokensElement[currentUserAddress] || []; + return [...acc, ...found.flat()]; + }, + [], + ); + const values = [...(swapsTokens || []), ...(allUserTokensCrossChains || [])] + .filter(Boolean) + .reduce((map, { hasBalanceError, image, ...token }) => { + const key = token.address.toLowerCase(); + + if (!map.has(key)) { + map.set(key, { + occurrences: 0, + ...token, + decimals: Number(token.decimals), + address: key, + }); + } + return map; + }, new Map()) + .values(); + return [...values]; + }, +); + export const swapsTokensSelector = createSelector( chainIdSelector, swapsControllerAndUserTokens, @@ -220,6 +257,21 @@ export const swapsTokensObjectSelector = createSelector( : {}, ); +/** + * Returns a memoized object that only has the addesses cross chains of the tokens as keys + * and undefined as value. Useful to check if a token is supported by swaps. + */ +export const swapsTokensMultiChainObjectSelector = createSelector( + swapsControllerAndUserTokensMultichain, + (tokens) => + tokens?.length > 0 + ? tokens.reduce( + (acc, token) => ({ ...acc, [token.address]: undefined }), + {}, + ) + : {}, +); + /** * Returns an array of tokens to display by default on the selector modal * based on the current account's balances. diff --git a/app/selectors/accountTrackerController.ts b/app/selectors/accountTrackerController.ts index 1673c613172..0798142afa0 100644 --- a/app/selectors/accountTrackerController.ts +++ b/app/selectors/accountTrackerController.ts @@ -1,3 +1,4 @@ + import { createSelector } from 'reselect'; import { AccountTrackerControllerState, diff --git a/app/selectors/accountsController.ts b/app/selectors/accountsController.ts index 1cf9c757be5..3dbf2580f7b 100644 --- a/app/selectors/accountsController.ts +++ b/app/selectors/accountsController.ts @@ -55,6 +55,7 @@ export const selectSelectedInternalAccount = createDeepEqualSelector( const accountId = accountsControllerState.internalAccounts.selectedAccount; const account = accountsControllerState.internalAccounts.accounts[accountId]; + if (!account) { const err = new Error( `selectSelectedInternalAccount: Account with ID ${accountId} not found.`, diff --git a/app/selectors/currencyRateController.test.ts b/app/selectors/currencyRateController.test.ts index 1505a1b4f47..e48f6b957a4 100644 --- a/app/selectors/currencyRateController.test.ts +++ b/app/selectors/currencyRateController.test.ts @@ -2,7 +2,6 @@ import { selectConversionRate, selectCurrentCurrency, selectCurrencyRates, - selectConversionRateFoAllChains, } from './currencyRateController'; import { isTestNet } from '../../app/util/networks'; import { CurrencyRateState } from '@metamask/assets-controllers'; @@ -84,31 +83,15 @@ describe('CurrencyRateController Selectors', () => { }); describe('selectCurrencyRates', () => { - it('returns the currency rates from the state', () => { - const result = selectCurrencyRates.resultFunc( - mockCurrencyRateState as unknown as CurrencyRateState, - ); - expect(result).toStrictEqual(mockCurrencyRateState.currencyRates); - }); - - it('returns undefined if currency rates are not set', () => { - const result = selectCurrencyRates.resultFunc( - {} as unknown as CurrencyRateState, - ); - expect(result).toBeUndefined(); - }); - }); - - describe('selectConversionRateFoAllChains', () => { it('returns all conversion rates from the state', () => { - const result = selectConversionRateFoAllChains.resultFunc( + const result = selectCurrencyRates.resultFunc( mockCurrencyRateState as unknown as CurrencyRateState, ); expect(result).toStrictEqual(mockCurrencyRateState.currencyRates); }); it('returns undefined if conversion rates are not set', () => { - const result = selectConversionRateFoAllChains.resultFunc( + const result = selectCurrencyRates.resultFunc( {} as unknown as CurrencyRateState, ); expect(result).toBeUndefined(); diff --git a/app/selectors/currencyRateController.ts b/app/selectors/currencyRateController.ts index 03bc2624ea7..715ebeb4e8b 100644 --- a/app/selectors/currencyRateController.ts +++ b/app/selectors/currencyRateController.ts @@ -27,6 +27,12 @@ export const selectConversionRate = createSelector( }, ); +export const selectCurrencyRates = createSelector( + selectCurrencyRateControllerState, + (currencyRateControllerState: CurrencyRateState) => + currencyRateControllerState?.currencyRates, +); + export const selectCurrentCurrency = createSelector( selectCurrencyRateControllerState, selectTicker, @@ -35,10 +41,14 @@ export const selectCurrentCurrency = createSelector( currencyRateControllerState?.currentCurrency, ); -export const selectCurrencyRates = createSelector( +export const selectConversionRateBySymbol = createSelector( selectCurrencyRateControllerState, - (currencyRateControllerState: CurrencyRateState) => - currencyRateControllerState?.currencyRates, + (_: RootState, symbol: string) => symbol, + (currencyRateControllerState: CurrencyRateState, symbol: string) => + symbol + ? currencyRateControllerState?.currencyRates?.[symbol]?.conversionRate || + 0 + : 0, ); export const selectConversionRateFoAllChains = createSelector( diff --git a/app/selectors/multichain.test.ts b/app/selectors/multichain.test.ts new file mode 100644 index 00000000000..276517a18cd --- /dev/null +++ b/app/selectors/multichain.test.ts @@ -0,0 +1,188 @@ +import { RootState } from '../reducers'; +import { + selectedAccountNativeTokenCachedBalanceByChainId, + selectAccountTokensAcrossChains, + selectIsBitcoinSupportEnabled, + selectIsBitcoinTestnetSupportEnabled, +} from './multichain'; + +describe('Multichain Selectors', () => { + const mockState: RootState = { + engine: { + backgroundState: { + NetworkController: { + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + }, + '0x89': { + chainId: '0x89', + name: 'Polygon', + nativeCurrency: 'MATIC', + }, + }, + }, + AccountTrackerController: { + accountsByChainId: { + '0x1': { + '0xAddress1': { + balance: '0x1', + stakedBalance: '0x2', + }, + }, + '0x89': { + '0xAddress1': { + balance: '0x3', + stakedBalance: '0x0', + }, + }, + }, + }, + TokensController: { + allTokens: { + '0x1': { + '0xAddress1': [ + { + address: '0xToken1', + symbol: 'TK1', + decimals: 18, + balance: '1000000000000000000', + }, + ], + }, + }, + }, + TokenBalancesController: { + tokenBalances: { + '0xAddress1': { + '0x1': { + '0xToken1': '0x1', + }, + }, + }, + }, + TokenRatesController: { + marketData: { + '0x1': { + '0xToken1': { price: 100 }, + }, + }, + }, + CurrencyRateController: { + currentCurrency: 'USD', + conversionRates: { + ETH: 2000, + MATIC: 1, + }, + }, + AccountsController: { + internalAccounts: { + selectedAccount: '0xAddress1', + accounts: { + '0xAddress1': { + address: '0xAddress1', + }, + }, + }, + }, + }, + }, + multichainSettings: { + bitcoinSupportEnabled: true, + bitcoinTestnetSupportEnabled: false, + }, + } as unknown as RootState; + + describe('selectedAccountNativeTokenCachedBalanceByChainId', () => { + it('should return native token balances for all chains', () => { + const result = + selectedAccountNativeTokenCachedBalanceByChainId(mockState); + expect(result).toEqual({ + '0x1': { + balance: '0x1', + stakedBalance: '0x2', + isStaked: true, + name: '', + }, + '0x89': { + balance: '0x3', + stakedBalance: '0x0', + isStaked: false, + name: '', + }, + }); + }); + + it('should return empty object when no account is selected', () => { + const stateWithoutAccount = { + ...mockState, + engine: { + ...mockState.engine, + backgroundState: { + ...mockState.engine.backgroundState, + AccountsController: { + internalAccounts: { + selectedAccount: undefined, + accounts: {}, + }, + }, + }, + }, + } as unknown as RootState; + + const result = + selectedAccountNativeTokenCachedBalanceByChainId(stateWithoutAccount); + expect(result).toEqual({}); + }); + }); + + describe('selectAccountTokensAcrossChains', () => { + it('should return tokens across all chains for selected account', () => { + const result = selectAccountTokensAcrossChains(mockState); + expect(result).toHaveProperty('0x1'); + + const chain1Tokens = result['0x1'] || []; + expect(chain1Tokens.length).toBeGreaterThan(0); + + const ethToken = chain1Tokens.find( + (token) => token.symbol === 'Ethereum' && !token.isStaked, + ); + expect(ethToken).toBeDefined(); + expect(ethToken?.isNative).toBe(true); + expect(ethToken?.isETH).toBe(true); + + const stakedEthToken = chain1Tokens.find( + (token) => token.symbol === 'Ethereum' && token.isStaked, + ); + expect(stakedEthToken).toBeDefined(); + expect(stakedEthToken?.isNative).toBe(true); + expect(stakedEthToken?.isStaked).toBe(true); + + const tk1Token = chain1Tokens.find((token) => token.symbol === 'TK1'); + expect(tk1Token).toBeDefined(); + expect(tk1Token?.isNative).toBe(false); + }); + + it('should handle multiple chains correctly', () => { + const result = selectAccountTokensAcrossChains(mockState); + expect(result).toHaveProperty('0x89'); + const polygonTokens = result['0x89']; + expect(polygonTokens.length).toBeGreaterThan(0); + expect(polygonTokens.some((token) => token.symbol === 'MATIC')).toBe( + true, + ); + }); + }); + + describe('Bitcoin Support Flags', () => { + it('should return bitcoin support enabled state', () => { + expect(selectIsBitcoinSupportEnabled(mockState)).toBe(true); + }); + + it('should return bitcoin testnet support enabled state', () => { + expect(selectIsBitcoinTestnetSupportEnabled(mockState)).toBe(false); + }); + }); +}); diff --git a/app/selectors/multichain.ts b/app/selectors/multichain.ts index e26741e6cd1..08b6436874f 100644 --- a/app/selectors/multichain.ts +++ b/app/selectors/multichain.ts @@ -1,4 +1,217 @@ +import { createSelector } from 'reselect'; +import { Hex } from '@metamask/utils'; +import { Token, getNativeTokenAddress } from '@metamask/assets-controllers'; import { RootState } from '../reducers'; +import { + selectSelectedInternalAccountFormattedAddress, + selectSelectedInternalAccount, +} from './accountsController'; +import { selectAllTokens } from './tokensController'; +import { selectAccountsByChainId } from './accountTrackerController'; +import { selectNetworkConfigurations } from './networkController'; +import { TokenI } from '../components/UI/Tokens/types'; +import { renderFromWei } from '../util/number'; +import { toHex } from '@metamask/controller-utils'; +import { + selectCurrencyRates, + selectCurrentCurrency, +} from './currencyRateController'; +import { selectTokenMarketData } from './tokenRatesController'; + +interface NativeTokenBalance { + balance: string; + stakedBalance: string; + isStaked: boolean; + name: string; +} + +type ChainBalances = Record; + +/** + * Get the cached native token balance for the selected account by chainId. + * + * @param {RootState} state - The root state. + * @returns {ChainBalances} The cached native token balance for the selected account by chainId. + */ +export const selectedAccountNativeTokenCachedBalanceByChainId = createSelector( + [selectSelectedInternalAccountFormattedAddress, selectAccountsByChainId], + (selectedAddress, accountsByChainId): ChainBalances => { + if (!selectedAddress || !accountsByChainId) { + return {}; + } + + const result: ChainBalances = {}; + for (const chainId in accountsByChainId) { + const accounts = accountsByChainId[chainId]; + const account = accounts[selectedAddress]; + if (account) { + result[chainId] = { + balance: account.balance, + stakedBalance: account.stakedBalance ?? '0x0', + isStaked: account.stakedBalance !== '0x0', + name: '', + }; + } + } + + return result; + }, +); + +/** + * Selector to get native tokens for the selected account across all chains. + */ +export const selectNativeTokensAcrossChains = createSelector( + [ + selectNetworkConfigurations, + selectedAccountNativeTokenCachedBalanceByChainId, + selectCurrencyRates, + selectCurrentCurrency, + selectTokenMarketData, + ], + ( + networkConfigurations, + nativeTokenBalancesByChainId, + currencyRates, + currentCurrency, + tokenMarketData, + ) => { + const tokensByChain: { [chainId: string]: TokenI[] } = {}; + for (const token of Object.values(networkConfigurations)) { + const nativeChainId = token.chainId as Hex; + const nativeTokenInfoByChainId = + nativeTokenBalancesByChainId[nativeChainId]; + const isETH = ['ETH', 'GOETH', 'SepoliaETH', 'LineaETH'].includes( + token.nativeCurrency || '', + ); + + const name = isETH ? 'Ethereum' : token.nativeCurrency; + const logo = isETH ? '../images/eth-logo-new.png' : ''; + tokensByChain[nativeChainId] = []; + + if ( + nativeTokenInfoByChainId && + nativeTokenInfoByChainId.isStaked && + nativeTokenInfoByChainId.stakedBalance !== '0x00' && + nativeTokenInfoByChainId.stakedBalance !== toHex(0) + ) { + // Staked tokens + tokensByChain[nativeChainId].push({ + ...nativeTokenInfoByChainId, + chainId: nativeChainId, + address: getNativeTokenAddress(nativeChainId), + balance: renderFromWei(nativeTokenInfoByChainId.stakedBalance), + balanceFiat: '', + isNative: true, + aggregators: [], + image: '', + logo, + isETH, + decimals: 18, + name: 'Staked Ethereum', + symbol: name, + isStaked: true, + ticker: token.nativeCurrency, + }); + } + + const nativeBalanceFormatted = renderFromWei( + nativeTokenInfoByChainId?.balance, + ); + + const tokenMarketDataByChainId = tokenMarketData?.[nativeChainId]; + let balanceFiat = ''; + + if ( + tokenMarketDataByChainId && + Object.keys(tokenMarketDataByChainId).length === 0 + ) { + const balanceFiatValue = + parseFloat(nativeBalanceFormatted) * + (currencyRates?.[token.nativeCurrency]?.conversionRate ?? 0); + + balanceFiat = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currentCurrency, + }).format(balanceFiatValue); + } + + // Non-staked tokens + tokensByChain[nativeChainId].push({ + ...nativeTokenInfoByChainId, + name, + address: getNativeTokenAddress(nativeChainId), + balance: nativeBalanceFormatted, + chainId: nativeChainId, + isNative: true, + aggregators: [], + balanceFiat, + image: '', + logo, + isETH, + decimals: 18, + symbol: name, + isStaked: false, + ticker: token.nativeCurrency, + }); + } + + return tokensByChain; + }, +); + +/** + * Get the tokens for the selected account across all chains. + * + * @param {RootState} state - The root state. + * @returns {TokensByChain} The tokens for the selected account across all chains. + */ +export const selectAccountTokensAcrossChains = createSelector( + [ + selectSelectedInternalAccount, + selectAllTokens, + selectNetworkConfigurations, + selectNativeTokensAcrossChains, + ], + (selectedAccount, allTokens, networkConfigurations, nativeTokens) => { + const selectedAddress = selectedAccount?.address; + const tokensByChain: { + [chainId: string]: ( + | TokenI + | (Token & { isStaked?: boolean; isNative?: boolean; isETH?: boolean }) + )[]; + } = {}; + + if (!selectedAddress) { + return tokensByChain; + } + + // Create a list of available chainIds + const chainIds = Object.keys(networkConfigurations); + + for (const chainId of chainIds) { + const currentChainId = chainId as Hex; + const nonNativeTokens = + allTokens[currentChainId]?.[selectedAddress]?.map((token) => ({ + ...token, + token: token.name, + chainId, + isETH: false, + isNative: false, + balanceFiat: '', + isStaked: false, + })) || []; + + // Add both native and non-native tokens + tokensByChain[currentChainId] = [ + ...(nativeTokens[currentChainId] || []), + ...nonNativeTokens, + ]; + } + + return tokensByChain; + }, +); /** * Get the state of the `bitcoinSupportEnabled` flag. diff --git a/app/selectors/networkController.test.ts b/app/selectors/networkController.test.ts new file mode 100644 index 00000000000..4c636397dc5 --- /dev/null +++ b/app/selectors/networkController.test.ts @@ -0,0 +1,155 @@ +import { + selectNetworkControllerState, + selectProviderConfig, + selectTicker, + selectChainId, + selectProviderType, + selectNickname, + selectRpcUrl, + selectNetworkStatus, + selectNetworkConfigurations, + selectNetworkClientId, + selectIsAllNetworks, + selectNetworkConfigurationByChainId, + selectNativeCurrencyByChainId, +} from './networkController'; +import { RootState } from '../reducers'; + +describe('networkSelectors', () => { + const mockState = { + engine: { + backgroundState: { + NetworkController: { + selectedNetworkClientId: 'custom-network', + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + nativeCurrency: 'ETH', + name: 'Ethereum Mainnet', + rpcEndpoints: [ + { + networkClientId: 'infura-mainnet', + type: 'infura', + url: 'https://mainnet.infura.io/v3/{infuraProjectId}', + }, + ], + blockExplorerUrls: ['https://etherscan.io'], + }, + '0x2': { + chainId: '0x2', + nativeCurrency: 'MATIC', + name: 'Polygon', + rpcEndpoints: [ + { + networkClientId: 'custom-network', + type: 'custom', + url: 'https://polygon-rpc.com', + }, + ], + blockExplorerUrls: ['https://polygonscan.com'], + }, + }, + networksMetadata: { + 'custom-network': { status: 'active' }, + }, + }, + }, + }, + } as unknown as RootState; + + it('selectNetworkControllerState should return the network controller state', () => { + expect(selectNetworkControllerState(mockState)).toEqual( + mockState.engine.backgroundState.NetworkController, + ); + }); + + it('selectProviderConfig should return the provider config for the selected network', () => { + expect(selectProviderConfig(mockState)).toEqual({ + chainId: '0x2', + ticker: 'MATIC', + rpcPrefs: { blockExplorerUrl: 'https://polygonscan.com' }, + type: 'rpc', + id: 'custom-network', + nickname: 'Polygon', + rpcUrl: 'https://polygon-rpc.com', + }); + }); + + it('selectTicker should return the ticker of the provider config', () => { + expect(selectTicker(mockState)).toBe('MATIC'); + }); + + it('selectChainId should return the chainId of the provider config', () => { + expect(selectChainId(mockState)).toBe('0x2'); + }); + + it('selectProviderType should return the type of the provider config', () => { + expect(selectProviderType(mockState)).toBe('rpc'); + }); + + it('selectNickname should return the nickname of the provider config', () => { + expect(selectNickname(mockState)).toBe('Polygon'); + }); + + it('selectRpcUrl should return the rpcUrl of the provider config', () => { + expect(selectRpcUrl(mockState)).toBe('https://polygon-rpc.com'); + }); + + it('selectNetworkStatus should return the network status for the selected network', () => { + expect(selectNetworkStatus(mockState)).toBe('active'); + }); + + it('selectNetworkConfigurations should return the network configurations by chainId', () => { + expect(selectNetworkConfigurations(mockState)).toEqual( + mockState.engine.backgroundState.NetworkController + .networkConfigurationsByChainId, + ); + }); + + it('selectNetworkClientId should return the selected network client ID', () => { + expect(selectNetworkClientId(mockState)).toBe('custom-network'); + }); + + it('selectIsAllNetworks should return false if tokenNetworkFilter length does not match networkConfigurations length', () => { + const tokenNetworkFilter = { '0x1': 'true' }; + expect( + selectIsAllNetworks.resultFunc( + mockState.engine.backgroundState.NetworkController + .networkConfigurationsByChainId, + tokenNetworkFilter, + ), + ).toBe(false); + }); + + it('selectNetworkConfigurationByChainId should return the network configuration for a given chainId', () => { + expect(selectNetworkConfigurationByChainId(mockState, '0x2')).toEqual( + mockState.engine.backgroundState.NetworkController + .networkConfigurationsByChainId['0x2'], + ); + }); + + it('selectNativeCurrencyByChainId should return the native currency for a given chainId', () => { + expect(selectNativeCurrencyByChainId(mockState, '0x1')).toBe('ETH'); + }); + + it('should return the default provider config if no matching network is found', () => { + const noMatchState = { ...mockState }; + noMatchState.engine.backgroundState.NetworkController.selectedNetworkClientId = + 'unknown-network'; + expect(selectProviderConfig(noMatchState)).toEqual({ + chainId: '0x2', + id: 'custom-network', + nickname: 'Polygon', + rpcPrefs: { + blockExplorerUrl: 'https://polygonscan.com', + }, + rpcUrl: 'https://polygon-rpc.com', + ticker: 'MATIC', + type: 'rpc', + }); + }); + + it('selectNetworkConfigurationByChainId should return null if the chainId does not exist', () => { + expect(selectNetworkConfigurationByChainId(mockState, '0x9999')).toBeNull(); + }); +}); diff --git a/app/selectors/networkController.ts b/app/selectors/networkController.ts index d37258c979e..c73222939d0 100644 --- a/app/selectors/networkController.ts +++ b/app/selectors/networkController.ts @@ -1,3 +1,4 @@ +import { Hex } from '@metamask/utils'; import { createSelector } from 'reselect'; import { InfuraNetworkType } from '@metamask/controller-utils'; import { @@ -50,7 +51,7 @@ const getDefaultProviderConfig = (): ProviderConfig => ({ }); // Helper function to create the provider config based on the network and endpoint -const createProviderConfig = ( +export const createProviderConfig = ( networkConfig: NetworkConfiguration, rpcEndpoint: RpcEndpoint, ): ProviderConfig => { @@ -80,7 +81,7 @@ const createProviderConfig = ( }; }; -const selectNetworkControllerState = (state: RootState) => +export const selectNetworkControllerState = (state: RootState) => state?.engine?.backgroundState?.NetworkController; export const selectSelectedNetworkClientId = createSelector( @@ -149,7 +150,7 @@ export const selectNetworkStatus = createSelector( export const selectNetworkConfigurations = createSelector( selectNetworkControllerState, (networkControllerState: NetworkState) => - networkControllerState.networkConfigurationsByChainId, + networkControllerState?.networkConfigurationsByChainId, ); export const selectNetworkClientId = createSelector( @@ -173,3 +174,14 @@ export const selectIsAllNetworks = createSelector( Object.keys(tokenNetworkFilter).length === Object.keys(networkConfigurations).length, ); + +export const selectNetworkConfigurationByChainId = createSelector( + [selectNetworkConfigurations, (_state: RootState, chainId: Hex) => chainId], + (networkConfigurations, chainId) => networkConfigurations?.[chainId] || null, +); + +export const selectNativeCurrencyByChainId = createSelector( + [selectNetworkConfigurations, (_state: RootState, chainId: Hex) => chainId], + (networkConfigurations, chainId) => + networkConfigurations?.[chainId]?.nativeCurrency, +); diff --git a/app/selectors/tokenBalancesController.test.ts b/app/selectors/tokenBalancesController.test.ts index 67371ba848d..c49b72af6a1 100644 --- a/app/selectors/tokenBalancesController.test.ts +++ b/app/selectors/tokenBalancesController.test.ts @@ -1,3 +1,4 @@ +import { Hex } from '@metamask/utils'; import { RootState } from '../reducers'; import { selectContractBalances, @@ -30,14 +31,30 @@ describe('TokenBalancesController Selectors', () => { engine: { backgroundState: { TokenBalancesController: mockTokenBalancesControllerState, + NetworkController: { + providerConfig: { + chainId: '0x1', + }, + }, + AccountsController: { + internalAccounts: { + selectedAccount: 'account1', + accounts: { + account1: { + id: 'account1', + address: '0xAccount1', + }, + }, + }, + }, }, }, } as unknown as RootState; describe('selectContractBalances', () => { it('returns token balances for the selected account and chain ID', () => { - const selectedAccount = '0xAccount1'; - const chainId = '0x1'; + const selectedAccount: Hex = '0xAccount1'; + const chainId: Hex = '0x1'; const result = selectContractBalances.resultFunc( mockTokenBalancesControllerState, @@ -52,8 +69,8 @@ describe('TokenBalancesController Selectors', () => { }); it('returns an empty object if no balances exist for the selected account', () => { - const selectedAccount = '0xUnknownAccount'; - const chainId = '0x1'; + const selectedAccount: Hex = '0xUnknownAccount'; + const chainId: Hex = '0x1'; const result = selectContractBalances.resultFunc( mockTokenBalancesControllerState, @@ -65,8 +82,8 @@ describe('TokenBalancesController Selectors', () => { }); it('returns an empty object if no balances exist for the selected chain ID', () => { - const selectedAccount = '0xAccount1'; - const chainId = '0xUnknownChain'; + const selectedAccount: Hex = '0xAccount1'; + const chainId: Hex = '0xUnknownChain'; const result = selectContractBalances.resultFunc( mockTokenBalancesControllerState, @@ -78,12 +95,12 @@ describe('TokenBalancesController Selectors', () => { }); it('returns an empty object if the selected account is undefined', () => { - const selectedAccount = undefined; - const chainId = '0x1'; + const selectedAccount: Hex | string = ''; + const chainId: Hex = '0x1'; const result = selectContractBalances.resultFunc( mockTokenBalancesControllerState, - selectedAccount, + selectedAccount as `0x${string}`, chainId, ); diff --git a/app/selectors/tokenBalancesController.ts b/app/selectors/tokenBalancesController.ts index c663901490e..dc7385e7182 100644 --- a/app/selectors/tokenBalancesController.ts +++ b/app/selectors/tokenBalancesController.ts @@ -1,14 +1,20 @@ /* eslint-disable import/prefer-default-export */ +import { Hex } from '@metamask/utils'; import { createSelector } from 'reselect'; import { RootState } from '../reducers'; import { TokenBalancesControllerState } from '@metamask/assets-controllers'; -import { Hex } from '@metamask/utils'; import { selectSelectedInternalAccountAddress } from './accountsController'; import { selectChainId } from './networkController'; const selectTokenBalancesControllerState = (state: RootState) => state.engine.backgroundState.TokenBalancesController; +export const selectTokensBalances = createSelector( + selectTokenBalancesControllerState, + (tokenBalancesControllerState: TokenBalancesControllerState) => + tokenBalancesControllerState.tokenBalances, +); + export const selectContractBalances = createSelector( selectTokenBalancesControllerState, selectSelectedInternalAccountAddress, @@ -28,9 +34,3 @@ export const selectAllTokenBalances = createSelector( (tokenBalancesControllerState: TokenBalancesControllerState) => tokenBalancesControllerState.tokenBalances, ); - -export const selectTokensBalances = createSelector( - selectTokenBalancesControllerState, - (tokenBalancesControllerState: TokenBalancesControllerState) => - tokenBalancesControllerState.tokenBalances, -); diff --git a/app/selectors/tokenRatesController.ts b/app/selectors/tokenRatesController.ts index 995e5988fd4..33ff578505d 100644 --- a/app/selectors/tokenRatesController.ts +++ b/app/selectors/tokenRatesController.ts @@ -20,3 +20,8 @@ export const selectTokenMarketData = createSelector( (tokenRatesControllerState: TokenRatesControllerState) => tokenRatesControllerState.marketData, ); + +export const selectTokenMarketDataByChainId = createSelector( + [selectTokenMarketData, (_state: RootState, chainId: Hex) => chainId], + (marketData, chainId) => marketData?.[chainId] || {}, +); diff --git a/app/selectors/tokensController.test.ts b/app/selectors/tokensController.test.ts index eaa963409e8..b29fddd260e 100644 --- a/app/selectors/tokensController.test.ts +++ b/app/selectors/tokensController.test.ts @@ -24,8 +24,8 @@ describe('TokensController Selectors', () => { ignoredTokens: ['0xToken2'], detectedTokens: [mockToken], allTokens: { - '0xAddress1': { - '1': [mockToken], + '0x1': { + '0xAddress1': [mockToken], }, }, allDetectedTokens: { @@ -42,6 +42,16 @@ describe('TokensController Selectors', () => { engine: { backgroundState: { TokensController: mockTokensControllerState, + AccountsController: { + internalAccounts: { + selectedAccount: '0xAddress1', + accounts: { + '0xAddress1': { + address: '0xAddress1', + }, + }, + }, + }, }, }, } as unknown as RootState; @@ -58,14 +68,34 @@ describe('TokensController Selectors', () => { backgroundState: { TokensController: { ...mockTokensControllerState, + allTokens: { + '0x1': { + '0xAddress1': [], + }, + }, tokens: [], }, + AccountsController: { + internalAccounts: { + selectedAccount: '0xAddress1', + accounts: { + '0xAddress1': { + address: '0xAddress1', + }, + }, + }, + }, }, }, } as unknown as RootState; expect(selectTokens(stateWithoutTokens)).toStrictEqual([]); }); + + it('returns tokens from TokensController state if portfolio view is enabled', () => { + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(true); + expect(selectTokens(mockRootState)).toStrictEqual([mockToken]); + }); }); describe('selectTokensByAddress', () => { @@ -82,8 +112,23 @@ describe('TokensController Selectors', () => { backgroundState: { TokensController: { ...mockTokensControllerState, + allTokens: { + '0x1': { + '0xAddress1': [], + }, + }, tokens: [], }, + AccountsController: { + internalAccounts: { + selectedAccount: '0xAddress1', + accounts: { + '0xAddress1': { + address: '0xAddress1', + }, + }, + }, + }, }, }, } as unknown as RootState; @@ -105,6 +150,21 @@ describe('TokensController Selectors', () => { TokensController: { ...mockTokensControllerState, tokens: [], + allTokens: { + '0x1': { + '0xAddress1': [], + }, + }, + }, + AccountsController: { + internalAccounts: { + selectedAccount: '0xAddress1', + accounts: { + '0xAddress1': { + address: '0xAddress1', + }, + }, + }, }, }, }, @@ -255,9 +315,7 @@ describe('TokensController Selectors', () => { }; it('returns only the current chain ID if PORTFOLIO_VIEW is not set', () => { - jest - .spyOn(networks, 'isPortfolioViewEnabledFunction') - .mockReturnValue(false); + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(false); const chainIds = getChainIdsToPoll.resultFunc( mockNetworkConfigurations, '0x1', @@ -266,9 +324,7 @@ describe('TokensController Selectors', () => { }); it('returns only the current chain ID if PORTFOLIO_VIEW is set', () => { - jest - .spyOn(networks, 'isPortfolioViewEnabledFunction') - .mockReturnValue(true); + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(true); const chainIds = getChainIdsToPoll.resultFunc( mockNetworkConfigurations, '0x1', diff --git a/app/selectors/tokensController.ts b/app/selectors/tokensController.ts index e95e24d82e1..c76f03a3373 100644 --- a/app/selectors/tokensController.ts +++ b/app/selectors/tokensController.ts @@ -1,13 +1,10 @@ +import { Hex } from '@metamask/utils'; import { createSelector } from 'reselect'; import { TokensControllerState, Token } from '@metamask/assets-controllers'; import { RootState } from '../reducers'; import { createDeepEqualSelector } from './util'; import { selectSelectedInternalAccountAddress } from './accountsController'; -import { Hex } from '@metamask/utils'; -import { - isPortfolioViewEnabledFunction, - TESTNET_CHAIN_IDS, -} from '../util/networks'; +import { isPortfolioViewEnabled, TESTNET_CHAIN_IDS } from '../util/networks'; import { selectChainId, selectNetworkConfigurations, @@ -18,8 +15,21 @@ const selectTokensControllerState = (state: RootState) => export const selectTokens = createDeepEqualSelector( selectTokensControllerState, - (tokensControllerState: TokensControllerState) => - tokensControllerState?.tokens, + selectChainId, + selectSelectedInternalAccountAddress, + ( + tokensControllerState: TokensControllerState, + chainId: Hex, + selectedAddress: string | undefined, + ) => { + if (isPortfolioViewEnabled()) { + return ( + tokensControllerState?.allTokens[chainId]?.[selectedAddress as Hex] || + [] + ); + } + return tokensControllerState?.tokens || []; + }, ); export const selectTokensByChainIdAndAddress = createDeepEqualSelector( @@ -36,7 +46,7 @@ export const selectTokensByChainIdAndAddress = createDeepEqualSelector( export const selectTokensByAddress = createSelector( selectTokens, (tokens: Token[]) => - tokens.reduce((tokensMap: { [address: string]: Token }, token: Token) => { + tokens?.reduce((tokensMap: { [address: string]: Token }, token: Token) => { tokensMap[token.address] = token; return tokensMap; }, {}), @@ -69,7 +79,7 @@ export const getChainIdsToPoll = createDeepEqualSelector( selectNetworkConfigurations, selectChainId, (networkConfigurations, currentChainId) => { - if (!isPortfolioViewEnabledFunction()) { + if (!isPortfolioViewEnabled()) { return [currentChainId]; } @@ -84,16 +94,18 @@ export const getChainIdsToPoll = createDeepEqualSelector( export const selectAllTokensFlat = createSelector( selectAllTokens, - (tokensByAccountByChain) => { + (tokensByAccountByChain: { + [account: string]: { [chainId: string]: Token[] }; + }): Token[] => { if (Object.values(tokensByAccountByChain).length === 0) { return []; } const tokensByAccountArray = Object.values(tokensByAccountByChain); - return tokensByAccountArray.reduce((acc, tokensByAccount) => { - const tokensArray = Object.values(tokensByAccount); + return tokensByAccountArray.reduce((acc, tokensByAccount) => { + const tokensArray = Object.values(tokensByAccount).flat(); return acc.concat(...tokensArray); - }, [] as Token[]); + }, []); }, ); @@ -123,9 +135,6 @@ export const selectAllDetectedTokensForSelectedAddress = createSelector( }, ); -// TODO: This isn't working fully, once a network has been selected then it -// can detect all tokens in that network. But by default it only shows -// detected tokens if the user has chosen it in the past export const selectAllDetectedTokensFlat = createSelector( selectAllDetectedTokensForSelectedAddress, (detectedTokensByChain: { [chainId: string]: Token[] }) => { diff --git a/app/util/networks/index.js b/app/util/networks/index.js index 80b6d9144a2..461f8ea6b87 100644 --- a/app/util/networks/index.js +++ b/app/util/networks/index.js @@ -497,7 +497,5 @@ export const isChainPermissionsFeatureEnabled = export const isPermissionsSettingsV1Enabled = process.env.MM_PERMISSIONS_SETTINGS_V1_ENABLED === 'true'; -export const isPortfolioViewEnabled = process.env.PORTFOLIO_VIEW === 'true'; - -export const isPortfolioViewEnabledFunction = () => +export const isPortfolioViewEnabled = () => process.env.PORTFOLIO_VIEW === 'true'; diff --git a/e2e/specs/settings/fiat-on-testnets.spec.js b/e2e/specs/settings/fiat-on-testnets.spec.js index d70eb7cfb5a..5ab22fa855b 100644 --- a/e2e/specs/settings/fiat-on-testnets.spec.js +++ b/e2e/specs/settings/fiat-on-testnets.spec.js @@ -33,6 +33,7 @@ describe(SmokeAssets('Fiat On Testnets Setting'), () => { // Switch to Sepolia await WalletView.tapNetworksButtonOnNavBar(); + await NetworkListModal.scrollToBottomOfNetworkList(); await NetworkListModal.changeNetworkTo(SEPOLIA); await NetworkEducationModal.tapGotItButton(); diff --git a/patches/@metamask+assets-controllers+45.1.1.patch b/patches/@metamask+assets-controllers+45.1.1.patch index 43cd9a9f607..106e984e11c 100644 --- a/patches/@metamask+assets-controllers+45.1.1.patch +++ b/patches/@metamask+assets-controllers+45.1.1.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@metamask/assets-controllers/dist/NftController.cjs b/node_modules/@metamask/assets-controllers/dist/NftController.cjs -index 6ccbe9c..f725852 100644 +index 6ccbe9c..49270d6 100644 --- a/node_modules/@metamask/assets-controllers/dist/NftController.cjs +++ b/node_modules/@metamask/assets-controllers/dist/NftController.cjs @@ -13,7 +13,7 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function ( @@ -150,7 +150,7 @@ index 6ccbe9c..f725852 100644 } } diff --git a/node_modules/@metamask/assets-controllers/dist/NftController.d.cts b/node_modules/@metamask/assets-controllers/dist/NftController.d.cts -index a34725f..12487d6 100644 +index a34725f..21e9d20 100644 --- a/node_modules/@metamask/assets-controllers/dist/NftController.d.cts +++ b/node_modules/@metamask/assets-controllers/dist/NftController.d.cts @@ -108,6 +108,7 @@ export type NftMetadata = { @@ -161,3 +161,109 @@ index a34725f..12487d6 100644 collection?: Collection; address?: string; attributes?: Attributes[]; +diff --git a/node_modules/@metamask/assets-controllers/dist/TokenDetectionController.cjs b/node_modules/@metamask/assets-controllers/dist/TokenDetectionController.cjs +index c5aa814..83c0664 100644 +--- a/node_modules/@metamask/assets-controllers/dist/TokenDetectionController.cjs ++++ b/node_modules/@metamask/assets-controllers/dist/TokenDetectionController.cjs +@@ -220,50 +220,57 @@ _TokenDetectionController_intervalId = new WeakMap(), _TokenDetectionController_ + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-misused-promises + this.messagingSystem.subscribe('KeyringController:unlock', async () => { +- __classPrivateFieldSet(this, _TokenDetectionController_isUnlocked, true, "f"); +- await __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_restartTokenDetection).call(this); +- }); +- this.messagingSystem.subscribe('KeyringController:lock', () => { +- __classPrivateFieldSet(this, _TokenDetectionController_isUnlocked, false, "f"); +- __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_stopPolling).call(this); +- }); +- this.messagingSystem.subscribe('TokenListController:stateChange', +- // TODO: Either fix this lint violation or explain why it's necessary to ignore. +- // eslint-disable-next-line @typescript-eslint/no-misused-promises +- async ({ tokensChainsCache }) => { +- const isEqualValues = __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_compareTokensChainsCache).call(this, tokensChainsCache, __classPrivateFieldGet(this, _TokenDetectionController_tokensChainsCache, "f")); +- if (!isEqualValues) { +- await __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_restartTokenDetection).call(this); +- } +- }); +- this.messagingSystem.subscribe('PreferencesController:stateChange', +- // TODO: Either fix this lint violation or explain why it's necessary to ignore. +- // eslint-disable-next-line @typescript-eslint/no-misused-promises +- async ({ useTokenDetection }) => { +- const selectedAccount = __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_getSelectedAccount).call(this); +- const isDetectionChangedFromPreferences = __classPrivateFieldGet(this, _TokenDetectionController_isDetectionEnabledFromPreferences, "f") !== useTokenDetection; +- __classPrivateFieldSet(this, _TokenDetectionController_isDetectionEnabledFromPreferences, useTokenDetection, "f"); +- if (isDetectionChangedFromPreferences) { +- await __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_restartTokenDetection).call(this, { +- selectedAddress: selectedAccount.address, +- }); +- } +- }); +- this.messagingSystem.subscribe('AccountsController:selectedEvmAccountChange', +- // TODO: Either fix this lint violation or explain why it's necessary to ignore. +- // eslint-disable-next-line @typescript-eslint/no-misused-promises +- async (selectedAccount) => { +- const { networkConfigurationsByChainId } = this.messagingSystem.call('NetworkController:getState'); +- const chainIds = Object.keys(networkConfigurationsByChainId); +- const isSelectedAccountIdChanged = __classPrivateFieldGet(this, _TokenDetectionController_selectedAccountId, "f") !== selectedAccount.id; +- if (isSelectedAccountIdChanged) { +- __classPrivateFieldSet(this, _TokenDetectionController_selectedAccountId, selectedAccount.id, "f"); +- await __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_restartTokenDetection).call(this, { +- selectedAddress: selectedAccount.address, +- chainIds, +- }); +- } +- }); ++ const { networkConfigurationsByChainId } = this.messagingSystem.call('NetworkController:getState'); ++ const chainIds = Object.keys(networkConfigurationsByChainId); ++ __classPrivateFieldSet(this, _TokenDetectionController_isUnlocked, true, "f"); ++ await __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_restartTokenDetection).call(this, { chainIds }); ++ }); ++ this.messagingSystem.subscribe('KeyringController:lock', () => { ++ __classPrivateFieldSet(this, _TokenDetectionController_isUnlocked, false, "f"); ++ __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_stopPolling).call(this); ++ }); ++ this.messagingSystem.subscribe('TokenListController:stateChange', ++ // TODO: Either fix this lint violation or explain why it's necessary to ignore. ++ // eslint-disable-next-line @typescript-eslint/no-misused-promises ++ async ({ tokensChainsCache }) => { ++ const isEqualValues = __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_compareTokensChainsCache).call(this, tokensChainsCache, __classPrivateFieldGet(this, _TokenDetectionController_tokensChainsCache, "f")); ++ if (!isEqualValues) { ++ const { networkConfigurationsByChainId } = this.messagingSystem.call('NetworkController:getState'); ++ const chainIds = Object.keys(networkConfigurationsByChainId); ++ await __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_restartTokenDetection).call(this, { chainIds }); ++ } ++ }); ++ this.messagingSystem.subscribe('PreferencesController:stateChange', ++ // TODO: Either fix this lint violation or explain why it's necessary to ignore. ++ // eslint-disable-next-line @typescript-eslint/no-misused-promises ++ async ({ useTokenDetection }) => { ++ const { networkConfigurationsByChainId } = this.messagingSystem.call('NetworkController:getState'); ++ const selectedAccount = __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_getSelectedAccount).call(this); ++ const isDetectionChangedFromPreferences = __classPrivateFieldGet(this, _TokenDetectionController_isDetectionEnabledFromPreferences, "f") !== useTokenDetection; ++ __classPrivateFieldSet(this, _TokenDetectionController_isDetectionEnabledFromPreferences, useTokenDetection, "f"); ++ const chainIds = Object.keys(networkConfigurationsByChainId); ++ if (isDetectionChangedFromPreferences) { ++ await __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_restartTokenDetection).call(this, { ++ selectedAddress: selectedAccount.address, ++ chainIds, ++ }); ++ } ++ }); ++ this.messagingSystem.subscribe('AccountsController:selectedEvmAccountChange', ++ // TODO: Either fix this lint violation or explain why it's necessary to ignore. ++ // eslint-disable-next-line @typescript-eslint/no-misused-promises ++ async (selectedAccount) => { ++ const { networkConfigurationsByChainId } = this.messagingSystem.call('NetworkController:getState'); ++ const chainIds = Object.keys(networkConfigurationsByChainId); ++ const isSelectedAccountIdChanged = __classPrivateFieldGet(this, _TokenDetectionController_selectedAccountId, "f") !== selectedAccount.id; ++ if (isSelectedAccountIdChanged) { ++ __classPrivateFieldSet(this, _TokenDetectionController_selectedAccountId, selectedAccount.id, "f"); ++ await __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_restartTokenDetection).call(this, { ++ selectedAddress: selectedAccount.address, ++ chainIds, ++ }); ++ } ++ }); + }, _TokenDetectionController_stopPolling = function _TokenDetectionController_stopPolling() { + if (__classPrivateFieldGet(this, _TokenDetectionController_intervalId, "f")) { + clearInterval(__classPrivateFieldGet(this, _TokenDetectionController_intervalId, "f"));