diff --git a/android/.project b/android/.project
index 3cf8618bf4c..e6990dbd1ce 100644
--- a/android/.project
+++ b/android/.project
@@ -14,4 +14,15 @@
org.eclipse.buildship.core.gradleprojectnature
+
+
+ 1731607498998
+
+ 30
+
+ org.eclipse.core.resources.regexFilterMatcher
+ node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__
+
+
+
diff --git a/android/.settings/org.eclipse.buildship.core.prefs b/android/.settings/org.eclipse.buildship.core.prefs
index e8895216fd3..e479558406c 100644
--- a/android/.settings/org.eclipse.buildship.core.prefs
+++ b/android/.settings/org.eclipse.buildship.core.prefs
@@ -1,2 +1,13 @@
+arguments=
+auto.sync=false
+build.scans.enabled=false
+connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER)
connection.project.dir=
eclipse.preferences.version=1
+gradle.user.home=
+java.home=
+jvm.arguments=
+offline.mode=false
+override.workspace.settings=false
+show.console.view=false
+show.executions.view=false
diff --git a/app/components/UI/AccountFromToInfoCard/AccountFromToInfoCard.test.tsx b/app/components/UI/AccountFromToInfoCard/AccountFromToInfoCard.test.tsx
index 82014a8debc..c650e8640f5 100644
--- a/app/components/UI/AccountFromToInfoCard/AccountFromToInfoCard.test.tsx
+++ b/app/components/UI/AccountFromToInfoCard/AccountFromToInfoCard.test.tsx
@@ -38,8 +38,12 @@ const mockInitialState: DeepPartial = {
},
},
TokenBalancesController: {
- contractBalances: {
- '0x326836cc6cd09B5aa59B81A7F72F25FcC0136b95': '0x5',
+ tokenBalances: {
+ '0x326836cc6cd09B5aa59B81A7F72F25FcC0136b95': {
+ '0x5': {
+ '0x326836cc6cd09B5aa59B81A7F72F25FcC0136b95': '0x2b46',
+ },
+ },
},
},
AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE,
diff --git a/app/components/UI/AssetElement/index.tsx b/app/components/UI/AssetElement/index.tsx
index ff39a4eac1a..e2337d0092d 100644
--- a/app/components/UI/AssetElement/index.tsx
+++ b/app/components/UI/AssetElement/index.tsx
@@ -94,8 +94,7 @@ const AssetElement: React.FC = ({
{balance && (
) => void;
@@ -69,8 +75,9 @@ const AssetOverview: React.FC = ({
displaySwapsButton,
}: AssetOverviewProps) => {
const [timePeriod, setTimePeriod] = React.useState('1d');
- const currentCurrency = useSelector(selectCurrentCurrency);
const conversionRate = useSelector(selectConversionRate);
+ const conversionRateByTicker = useSelector(selectCurrencyRates);
+ const currentCurrency = useSelector(selectCurrentCurrency);
const accountsByChainId = useSelector(selectAccountsByChainId);
const primaryCurrency = useSelector(
(state: RootState) => state.settings.primaryCurrency,
@@ -81,12 +88,20 @@ const AssetOverview: React.FC = ({
);
const { trackEvent } = useMetrics();
const tokenExchangeRates = useSelector(selectContractExchangeRates);
+ const tokenExchangeRateByChainId = 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 chainId = isPortfolioViewEnabled
+ ? (asset.chainId as Hex)
+ : selectedChainId;
+ const ticker = isPortfolioViewEnabled ? asset.symbol : selectedTicker;
const { data: prices = [], isLoading } = useTokenHistoricalPrices({
- address: asset.isETH ? zeroAddress() : asset.address,
+ address: asset.address,
chainId,
timePeriod,
vsCurrency: currentCurrency,
@@ -201,55 +216,84 @@ const AssetOverview: React.FC = ({
);
const itemAddress = safeToChecksumAddress(asset.address);
- const exchangeRate = itemAddress
- ? tokenExchangeRates?.[itemAddress]?.price
- : undefined;
+
+ let exchangeRate;
+ if (!isPortfolioViewEnabled) {
+ exchangeRate = itemAddress
+ ? tokenExchangeRates?.[itemAddress]?.price
+ : undefined;
+ } else {
+ exchangeRate =
+ tokenExchangeRateByChainId?.[chainId]?.[itemAddress as Hex]?.price;
+ }
let balance, balanceFiat;
- if (asset.isETH) {
- balance = renderFromWei(
- //@ts-expect-error - This should be fixed at the accountsController selector level, ongoing discussion
- accountsByChainId[toHexadecimal(chainId)][selectedAddress]?.balance,
- );
- balanceFiat = weiToFiat(
- hexToBN(
+ if (!isPortfolioViewEnabled) {
+ if (asset.isETH) {
+ balance = renderFromWei(
//@ts-expect-error - This should be fixed at the accountsController selector level, ongoing discussion
accountsByChainId[toHexadecimal(chainId)][selectedAddress]?.balance,
- ),
- conversionRate,
- currentCurrency,
- );
+ );
+ balanceFiat = weiToFiat(
+ hexToBN(
+ //@ts-expect-error - This should be fixed at the accountsController selector level, ongoing discussion
+ accountsByChainId[toHexadecimal(chainId)][selectedAddress]?.balance,
+ ),
+ conversionRate,
+ currentCurrency,
+ );
+ } else {
+ balance =
+ itemAddress && tokenBalances?.[itemAddress]
+ ? renderFromTokenMinimalUnit(
+ tokenBalances[itemAddress],
+ asset.decimals,
+ )
+ : 0;
+ balanceFiat = balanceToFiat(
+ balance,
+ conversionRateByTicker[asset.symbol].conversionRate,
+ exchangeRate,
+ currentCurrency,
+ );
+ }
} else {
- balance =
- itemAddress && tokenBalances?.[itemAddress]
- ? renderFromTokenMinimalUnit(tokenBalances[itemAddress], asset.decimals)
- : 0;
- balanceFiat = balanceToFiat(
- balance,
- conversionRate,
- exchangeRate,
- currentCurrency,
- );
+ balance = asset.balance;
+ balanceFiat = asset.balanceFiat;
}
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.symbol}`;
+ secondaryBalance = 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[asset.symbol].conversionRate;
+ 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 fed53bd539a..fe81320c418 100644
--- a/app/components/UI/AssetOverview/Balance/Balance.tsx
+++ b/app/components/UI/AssetOverview/Balance/Balance.tsx
@@ -1,5 +1,6 @@
-import React from 'react';
+import React, { useCallback } from 'react';
import { View } from 'react-native';
+import { Hex } from '@metamask/utils';
import { strings } from '../../../../../locales/i18n';
import { useStyles } from '../../../../component-library/hooks';
import styleSheet from './Balance.styles';
@@ -9,6 +10,7 @@ import { selectNetworkName } from '../../../../selectors/networkInfos';
import { selectChainId } from '../../../../selectors/networkController';
import {
getTestNetImageByChainId,
+ getDefaultNetworkByChainId,
isLineaMainnetByChainId,
isMainnetByChainId,
isTestNet,
@@ -20,6 +22,7 @@ import Badge from '../../../../component-library/components/Badges/Badge/Badge';
import NetworkMainAssetLogo from '../../NetworkMainAssetLogo';
import AvatarToken from '../../../../component-library/components/Avatars/Avatar/variants/AvatarToken';
import { AvatarSize } from '../../../../component-library/components/Avatars/Avatar';
+import NetworkAssetLogo from '../../NetworkAssetLogo';
import Text, {
TextVariant,
} from '../../../../component-library/components/Texts/Text';
@@ -27,6 +30,11 @@ import { TokenI } from '../../Tokens/types';
import { useNavigation } from '@react-navigation/native';
import { isPooledStakingFeatureEnabled } from '../../Stake/constants';
import StakingBalance from '../../Stake/components/StakingBalance/StakingBalance';
+import {
+ PopularList,
+ UnpopularNetworkList,
+ CustomNetworkImgMapping,
+} from '../../../../util/networks/customNetworks';
interface BalanceProps {
asset: TokenI;
@@ -34,6 +42,8 @@ interface BalanceProps {
secondaryBalance?: string;
}
+const isPortfolioViewEnabled = process.env.PORTFOLIO_VIEW === 'true';
+
export const NetworkBadgeSource = (chainId: string, ticker: string) => {
const isMainnet = isMainnetByChainId(chainId);
const isLineaMainnet = isLineaMainnetByChainId(chainId);
@@ -51,7 +61,93 @@ const Balance = ({ asset, mainBalance, secondaryBalance }: BalanceProps) => {
const { styles } = useStyles(styleSheet, {});
const navigation = useNavigation();
const networkName = useSelector(selectNetworkName);
- const chainId = useSelector(selectChainId);
+ const selectedChainId = useSelector(selectChainId);
+
+ const chainId = isPortfolioViewEnabled
+ ? (asset.chainId as Hex)
+ : selectedChainId;
+
+ const isMainnet = isMainnetByChainId(chainId);
+ const isLineaMainnet = isLineaMainnetByChainId(chainId);
+ const ticker = asset.symbol;
+
+ const renderNetworkAvatar = useCallback(() => {
+ if (!isPortfolioViewEnabled && asset.isETH) {
+ return ;
+ }
+
+ if (isPortfolioViewEnabled && asset.isNative) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ }, [
+ asset.isETH,
+ asset.image,
+ asset.symbol,
+ asset.isNative,
+ asset.chainId,
+ styles.ethLogo,
+ ]);
+
+ const networkBadgeSource = useCallback(
+ (currentChainId: Hex) => {
+ if (!isPortfolioViewEnabled) {
+ if (isTestNet(chainId)) return getTestNetImageByChainId(chainId);
+ if (isMainnet) return images.ETHEREUM;
+
+ if (isLineaMainnet) return images['LINEA-MAINNET'];
+
+ if (CustomNetworkImgMapping[chainId]) {
+ return CustomNetworkImgMapping[chainId];
+ }
+
+ return ticker ? images[ticker as keyof typeof images] : undefined;
+ }
+ if (isTestNet(currentChainId))
+ return getTestNetImageByChainId(currentChainId);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const defaultNetwork = getDefaultNetworkByChainId(currentChainId) as any;
+
+ 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],
+ );
return (
@@ -69,20 +165,12 @@ const Balance = ({ asset, mainBalance, secondaryBalance }: BalanceProps) => {
badgeElement={
}
>
- {asset.isETH ? (
-
- ) : (
-
- )}
+ {renderNetworkAvatar()}
{asset.name || asset.symbol}
diff --git a/app/components/UI/AssetOverview/Price/Price.tsx b/app/components/UI/AssetOverview/Price/Price.tsx
index 9d95b9e17b5..a6145a7dcd8 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/NetworkAssetLogo/index.tsx b/app/components/UI/NetworkAssetLogo/index.tsx
new file mode 100644
index 00000000000..90629c5f871
--- /dev/null
+++ b/app/components/UI/NetworkAssetLogo/index.tsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import { ChainId } from '@metamask/controller-utils';
+import TokenIcon from '../Swaps/components/TokenIcon';
+
+interface NetworkAssetLogoProps {
+ chainId: string;
+ ticker: string;
+ style: object;
+ big: boolean;
+ biggest: boolean;
+ testID: string;
+}
+
+function NetworkAssetLogo({
+ chainId,
+ ticker,
+ style,
+ big,
+ biggest,
+ testID,
+}: NetworkAssetLogoProps) {
+ if (chainId === ChainId.mainnet) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+}
+
+export default NetworkAssetLogo;
diff --git a/app/components/UI/NetworkModal/index.tsx b/app/components/UI/NetworkModal/index.tsx
index 3c58495c9f5..8b7e7f588d5 100644
--- a/app/components/UI/NetworkModal/index.tsx
+++ b/app/components/UI/NetworkModal/index.tsx
@@ -109,6 +109,23 @@ const NetworkModals = (props: NetworkProps) => {
return true;
};
+ const customNetworkInformation = {
+ chainId,
+ blockExplorerUrl,
+ chainName: nickname,
+ rpcUrl,
+ icon: imageUrl,
+ ticker,
+ alerts,
+ };
+
+ const onUpdateNetworkFilter = useCallback(() => {
+ const { PreferencesController } = Engine.context;
+ PreferencesController.setTokenNetworkFilter({
+ [customNetworkInformation.chainId]: true,
+ });
+ }, [customNetworkInformation.chainId]);
+
const addNetwork = async () => {
const isValidUrl = validateRpcUrl(rpcUrl);
if (showPopularNetworkModal) {
@@ -172,16 +189,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(
@@ -245,6 +252,7 @@ const NetworkModals = (props: NetworkProps) => {
}
if (networkClientId) {
+ onUpdateNetworkFilter();
await NetworkController.setActiveNetwork(networkClientId);
}
@@ -270,7 +278,7 @@ const NetworkModals = (props: NetworkProps) => {
const { networkClientId } =
updatedNetwork?.rpcEndpoints?.[updatedNetwork.defaultRpcEndpointIndex] ??
{};
-
+ onUpdateNetworkFilter();
await NetworkController.setActiveNetwork(networkClientId);
};
@@ -339,6 +347,7 @@ const NetworkModals = (props: NetworkProps) => {
addedNetwork?.rpcEndpoints?.[addedNetwork.defaultRpcEndpointIndex] ??
{};
+ onUpdateNetworkFilter();
NetworkController.setActiveNetwork(networkClientId);
}
onClose();
diff --git a/app/components/UI/Tokens/TokenList/TokenListFooter/index.tsx b/app/components/UI/Tokens/TokenList/TokenListFooter/index.tsx
index 92093347577..4e2a3edb7f6 100644
--- a/app/components/UI/Tokens/TokenList/TokenListFooter/index.tsx
+++ b/app/components/UI/Tokens/TokenList/TokenListFooter/index.tsx
@@ -8,7 +8,10 @@ import Text, {
import { WalletViewSelectorsIDs } from '../../../../../../e2e/selectors/wallet/WalletView.selectors';
import { strings } from '../../../../../../locales/i18n';
import { useSelector } from 'react-redux';
-import { selectDetectedTokens } from '../../../../../selectors/tokensController';
+import {
+ selectDetectedTokens,
+ selectAllDetectedTokensFlat,
+} from '../../../../../selectors/tokensController';
import { isZero } from '../../../../../util/lodash';
import useRampNetwork from '../../../Ramp/hooks/useRampNetwork';
import { createBuyNavigationDetails } from '../../../Ramp/routes/utils';
@@ -23,9 +26,15 @@ import {
useMetrics,
} from '../../../../../components/hooks/useMetrics';
import { getDecimalChainId } from '../../../../../util/networks';
-import { selectChainId } from '../../../../../selectors/networkController';
+import {
+ selectChainId,
+ selectNetworkConfigurations,
+} from '../../../../../selectors/networkController';
import { TokenI } from '../../types';
-import { selectUseTokenDetection } from '../../../../../selectors/preferencesController';
+import {
+ selectUseTokenDetection,
+ selectTokenNetworkFilter,
+} from '../../../../../selectors/preferencesController';
interface TokenListFooterProps {
tokens: TokenI[];
@@ -34,6 +43,21 @@ interface TokenListFooterProps {
isAddTokenEnabled: boolean;
}
+const isPortfolioViewEnabled = process.env.PORTFOLIO_VIEW === 'true';
+
+const getDetectedTokensCount = (
+ isPortfolioEnabled: boolean,
+ isAllNetworksSelected: boolean,
+ allTokens: TokenI[],
+ filteredTokens: TokenI[] | undefined,
+): number => {
+ if (!isPortfolioEnabled) {
+ return filteredTokens?.length ?? 0;
+ }
+
+ return isAllNetworksSelected ? allTokens.length : filteredTokens?.length ?? 0;
+};
+
export const TokenListFooter = ({
tokens,
goToAddToken,
@@ -45,9 +69,19 @@ export const TokenListFooter = ({
const { trackEvent } = useMetrics();
const [isNetworkRampSupported, isNativeTokenRampSupported] = useRampNetwork();
- const detectedTokens = useSelector(selectDetectedTokens);
+ const detectedTokens = useSelector(selectDetectedTokens) as TokenI[];
+ const allDetectedTokens = useSelector(
+ selectAllDetectedTokensFlat,
+ ) as TokenI[];
+
const isTokenDetectionEnabled = useSelector(selectUseTokenDetection);
const chainId = useSelector(selectChainId);
+ // TODO: Can probably create "isAllNetworks" selector for these
+ // since they are re-used in multiple places
+ const tokenNetworkFilter = useSelector(selectTokenNetworkFilter); // X
+ const allNetworks = useSelector(selectNetworkConfigurations); // X
+ const isAllNetworks =
+ Object.keys(tokenNetworkFilter).length === Object.keys(allNetworks).length; // X
const styles = createStyles(colors);
@@ -67,10 +101,19 @@ export const TokenListFooter = ({
});
};
+ const tokenCount = getDetectedTokensCount(
+ isPortfolioViewEnabled,
+ isAllNetworks,
+ allDetectedTokens,
+ detectedTokens,
+ );
+
+ const areTokensDetected = tokenCount > 0;
+
return (
<>
{/* renderTokensDetectedSection */}
- {detectedTokens?.length !== 0 && isTokenDetectionEnabled && (
+ {areTokensDetected && isTokenDetectionEnabled && (
{strings('wallet.tokens_detected_in_account', {
- tokenCount: detectedTokens.length,
- tokensLabel: detectedTokens.length > 1 ? 'tokens' : 'token',
+ tokenCount,
+ tokensLabel: tokenCount > 1 ? 'tokens' : 'token',
})}
diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx
index 68c53c0576c..bbdfc332877 100644
--- a/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx
+++ b/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useCallback } from 'react';
import { View } from 'react-native';
import { Hex } from '@metamask/utils';
import { zeroAddress } from 'ethereumjs-util';
@@ -14,7 +14,10 @@ import {
selectProviderConfig,
selectTicker,
} from '../../../../../selectors/networkController';
-import { selectContractExchangeRates } from '../../../../../selectors/tokenRatesController';
+import {
+ selectContractExchangeRates,
+ selectTokenMarketData,
+} from '../../../../../selectors/tokenRatesController';
import {
selectConversionRate,
selectCurrentCurrency,
@@ -27,6 +30,7 @@ import {
isLineaMainnetByChainId,
isMainnetByChainId,
isTestNet,
+ getDefaultNetworkByChainId,
} from '../../../../../util/networks';
import createStyles from '../../styles';
import BadgeWrapper from '../../../../../component-library/components/Badges/BadgeWrapper';
@@ -41,14 +45,21 @@ import Text, {
import PercentageChange from '../../../../../component-library/components-temp/Price/PercentageChange';
import AssetElement from '../../../AssetElement';
import NetworkMainAssetLogo from '../../../NetworkMainAssetLogo';
+import NetworkAssetLogo from '../../../NetworkAssetLogo';
import images from 'images/image-icons';
import { TokenI } from '../../types';
import { strings } from '../../../../../../locales/i18n';
import { ScamWarningIcon } from '../ScamWarningIcon';
import { ScamWarningModal } from '../ScamWarningModal';
import { StakeButton } from '../../../Stake/components/StakeButton';
-import { CustomNetworkImgMapping } from '../../../../../util/networks/customNetworks';
import useStakingChain from '../../../Stake/hooks/useStakingChain';
+import {
+ PopularList,
+ UnpopularNetworkList,
+ CustomNetworkImgMapping,
+} from '../../../../../util/networks/customNetworks';
+
+const isPortfolioViewEnabled = process.env.PORTFOLIO_VIEW === 'true';
interface TokenListItemProps {
asset: TokenI;
@@ -84,6 +95,7 @@ export const TokenListItem = ({
const primaryCurrency = useSelector(
(state: RootState) => state.settings.primaryCurrency,
);
+ const multiChainMarketData = useSelector(selectTokenMarketData);
const styles = createStyles(colors);
@@ -98,92 +110,175 @@ export const TokenListItem = ({
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?.[asset.chainId as Hex]?.[asset.address as Hex]
+ ?.pricePercentChange1d
+ : 0;
+
+ pricePercentChange1d = asset.isNative
+ ? multiChainMarketData?.[asset.chainId as Hex]?.[zeroAddress() as Hex]
+ ?.pricePercentChange1d
+ : tokenPercentageChange;
+ } else {
+ pricePercentChange1d = itemAddress
+ ? tokenExchangeRates?.[itemAddress as Hex]?.pricePercentChange1d
+ : tokenExchangeRates?.[zeroAddress() as Hex]?.pricePercentChange1d;
+ }
// render balances according to primary currency
let mainBalance;
let secondaryBalance;
- // 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.
+ if (!isPortfolioViewEnabled) {
+ // 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;
- // 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
- } else {
- mainBalance = ''; // Otherwise, set to an empty string
+ 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;
}
- // Secondary balance mirrors the main balance logic for consistency.
+ } 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
+ } else {
+ mainBalance = ''; // Otherwise, set to an empty string
+ }
+ // Secondary balance mirrors the main balance logic for consistency.
+ secondaryBalance = !balanceFiat ? balanceFiat : balanceValueFormatted;
+ }
}
- }
- if (asset?.hasBalanceError) {
- mainBalance = asset.symbol;
- secondaryBalance = strings('wallet.unable_to_load');
- }
+ if (asset?.hasBalanceError) {
+ mainBalance = asset.symbol;
+ secondaryBalance = strings('wallet.unable_to_load');
+ }
- if (balanceFiat === TOKEN_RATE_UNDEFINED) {
- mainBalance = balanceValueFormatted;
- secondaryBalance = strings('wallet.unable_to_find_conversion_rate');
- }
+ if (balanceFiat === TOKEN_RATE_UNDEFINED) {
+ mainBalance = balanceValueFormatted;
+ secondaryBalance = strings('wallet.unable_to_find_conversion_rate');
+ }
- asset = { ...asset, balanceFiat };
+ asset = { ...asset, balanceFiat };
+ } else {
+ mainBalance = asset.balance;
+ secondaryBalance = asset.balanceFiat;
+ }
const isMainnet = isMainnetByChainId(chainId);
const isLineaMainnet = isLineaMainnetByChainId(chainId);
const { isStakingSupportedChain } = useStakingChain();
- 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);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const defaultNetwork = getDefaultNetworkByChainId(currentChainId) as any;
- 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.isETH,
+ asset.image,
+ asset.symbol,
+ asset.isNative,
+ asset.chainId,
+ styles.ethLogo,
+ ]);
+
return (
}
>
- {asset.isETH ? (
-
- ) : (
-
- )}
+ {renderNetworkAvatar()}
@@ -222,7 +311,9 @@ export const TokenListItem = ({
{asset.name || asset.symbol}
{/** Add button link to Portfolio Stake if token is supported ETH chain and not a staked asset */}
- {asset.isETH && isStakingSupportedChain && !asset.isStaked && }
+ {asset.isETH && isStakingSupportedChain && !asset.isStaked && (
+
+ )}
{!isTestNet(chainId) ? (
diff --git a/app/components/UI/Tokens/TokensBottomSheet/TokenFilterBottomSheet.tsx b/app/components/UI/Tokens/TokensBottomSheet/TokenFilterBottomSheet.tsx
index 4720c09660b..82eeffc8ddd 100644
--- a/app/components/UI/Tokens/TokensBottomSheet/TokenFilterBottomSheet.tsx
+++ b/app/components/UI/Tokens/TokensBottomSheet/TokenFilterBottomSheet.tsx
@@ -1,6 +1,9 @@
-import React, { useRef } from 'react';
+import React, { useRef, useMemo } from 'react';
import { useSelector } from 'react-redux';
-import { selectChainId } from '../../../../selectors/networkController';
+import {
+ selectChainId,
+ selectNetworkConfigurations,
+} from '../../../../selectors/networkController';
import { selectTokenNetworkFilter } from '../../../../selectors/preferencesController';
import BottomSheet, {
BottomSheetRef,
@@ -15,25 +18,31 @@ import Text, {
import ListItemSelect from '../../../../component-library/components/List/ListItemSelect';
import { VerticalAlignment } from '../../../../component-library/components/List/ListItem';
import { strings } from '../../../../../locales/i18n';
+import { enableAllNetworksFilter } from '../util/enableAllNetworksFilter';
enum FilterOption {
- AllNetworks = 0,
- CurrentNetwork = 1,
+ AllNetworks,
+ CurrentNetwork,
}
const TokenFilterBottomSheet = () => {
const sheetRef = useRef(null);
+ const allNetworks = useSelector(selectNetworkConfigurations);
const { colors } = useTheme();
const styles = createStyles(colors);
const chainId = useSelector(selectChainId);
const tokenNetworkFilter = useSelector(selectTokenNetworkFilter);
+ const allNetworksEnabled = useMemo(
+ () => enableAllNetworksFilter(allNetworks),
+ [allNetworks],
+ );
const onFilterControlsBottomSheetPress = (option: FilterOption) => {
const { PreferencesController } = Engine.context;
switch (option) {
case FilterOption.AllNetworks:
- PreferencesController.setTokenNetworkFilter({});
+ PreferencesController.setTokenNetworkFilter(allNetworksEnabled);
sheetRef.current?.onCloseBottomSheet();
break;
case FilterOption.CurrentNetwork:
@@ -47,7 +56,11 @@ const TokenFilterBottomSheet = () => {
}
};
- const isSelectedNetwork = Boolean(tokenNetworkFilter?.[chainId]);
+ const isCurrentNetwork = Boolean(
+ tokenNetworkFilter[chainId] && Object.keys(tokenNetworkFilter).length === 1,
+ );
+ const isAllNetworks =
+ Object.keys(tokenNetworkFilter).length === Object.keys(allNetworks).length;
return (
@@ -59,7 +72,7 @@ const TokenFilterBottomSheet = () => {
onPress={() =>
onFilterControlsBottomSheetPress(FilterOption.AllNetworks)
}
- isSelected={!isSelectedNetwork}
+ isSelected={isAllNetworks}
gap={8}
verticalAlignment={VerticalAlignment.Center}
>
@@ -71,7 +84,7 @@ const TokenFilterBottomSheet = () => {
onPress={() =>
onFilterControlsBottomSheetPress(FilterOption.CurrentNetwork)
}
- isSelected={isSelectedNetwork}
+ isSelected={isCurrentNetwork}
gap={8}
verticalAlignment={VerticalAlignment.Center}
>
diff --git a/app/components/UI/Tokens/index.test.tsx b/app/components/UI/Tokens/index.test.tsx
index 401c3c8ed9d..12cef311341 100644
--- a/app/components/UI/Tokens/index.test.tsx
+++ b/app/components/UI/Tokens/index.test.tsx
@@ -40,7 +40,7 @@ jest.mock('../../../core/Engine', () => ({
updateExchangeRate: jest.fn(() => Promise.resolve()),
},
TokenRatesController: {
- updateExchangeRates: jest.fn(() => Promise.resolve()),
+ updateExchangeRatesByChainId: jest.fn(() => Promise.resolve()),
},
NetworkController: {
getNetworkClientById: () => ({
@@ -359,7 +359,7 @@ describe('Tokens', () => {
Engine.context.CurrencyRateController.updateExchangeRate,
).toHaveBeenCalled();
expect(
- Engine.context.TokenRatesController.updateExchangeRates,
+ Engine.context.TokenRatesController.updateExchangeRatesByChainId,
).toHaveBeenCalled();
});
});
diff --git a/app/components/UI/Tokens/index.tsx b/app/components/UI/Tokens/index.tsx
index a19a8cbaa92..22a43c58c8b 100644
--- a/app/components/UI/Tokens/index.tsx
+++ b/app/components/UI/Tokens/index.tsx
@@ -1,4 +1,5 @@
import React, { useRef, useState, LegacyRef, useMemo } 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';
@@ -41,6 +42,9 @@ 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 { enableAllNetworksFilter } from './util/enableAllNetworksFilter';
+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
@@ -73,6 +77,8 @@ interface TokenListNavigationParamList {
[key: string]: undefined | object;
}
+const isPortfolioViewEnabled = process.env.PORTFOLIO_VIEW === 'true';
+
const Tokens: React.FC = ({ tokens }) => {
const navigation =
useNavigation<
@@ -102,23 +108,69 @@ const Tokens: React.FC = ({ tokens }) => {
),
),
];
+ const allNetworks = useSelector(selectNetworkConfigurations);
+ const selectedAccountTokensChains = useSelector(
+ selectAccountTokensAcrossChains,
+ );
const actionSheet = useRef();
const [tokenToRemove, setTokenToRemove] = useState();
const [refreshing, setRefreshing] = useState(false);
const [isAddTokenEnabled, setIsAddTokenEnabled] = useState(true);
+ const allNetworksEnabled = useMemo(
+ () => enableAllNetworksFilter(allNetworks),
+ [allNetworks],
+ );
const styles = createStyles(colors);
const tokensList = useMemo(() => {
- // Filter tokens based on hideZeroBalanceTokens flag
+ if (isPortfolioViewEnabled) {
+ // MultiChain implementation
+ const allTokens = Object.values(selectedAccountTokensChains).flat();
+
+ // First filter zero balance tokens if setting is enabled
+ const tokensWithBalance = hideZeroBalanceTokens
+ ? allTokens.filter((token) => !isZero(token.balance) || token.isNative)
+ : allTokens;
+
+ // Then apply network filters
+ const filteredAssets = filterAssets(tokensWithBalance, [
+ {
+ key: 'chainId',
+ opts: tokenNetworkFilter,
+ filterCallback: 'inclusive',
+ },
+ ]);
+
+ const { nativeTokens, nonNativeTokens } = filteredAssets.reduce<{
+ nativeTokens: TokenI[];
+ nonNativeTokens: TokenI[];
+ }>(
+ (
+ acc: { nativeTokens: TokenI[]; nonNativeTokens: TokenI[] },
+ currToken: unknown,
+ ) => {
+ 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];
+ return sortAssets(assets, tokenSortConfig);
+ }
+
+ // Previous implementation
const tokensToDisplay = hideZeroBalanceTokens
? tokens.filter(
({ address, isETH }) => !isZero(tokenBalances[address]) || isETH,
)
: tokens;
- // Calculate fiat balances for tokens
const tokenFiatBalances = conversionRate
? tokensToDisplay.map((asset) =>
asset.isETH
@@ -133,16 +185,11 @@ const Tokens: React.FC = ({ tokens }) => {
)
: [];
- // Combine tokens with their fiat balances
- // tokenFiatAmount is the key in PreferencesController to sort by when sorting by declining fiat balance
- // this key in the controller is also used by extension, so this is for consistency in syntax and config
- // actual balance rendering for each token list item happens in TokenListItem component
const tokensWithBalances = tokensToDisplay.map((token, i) => ({
...token,
tokenFiatAmount: tokenFiatBalances[i],
}));
- // Sort the tokens based on tokenSortConfig
return sortAssets(tokensWithBalances, tokenSortConfig);
}, [
conversionRate,
@@ -152,6 +199,9 @@ const Tokens: React.FC = ({ tokens }) => {
tokenExchangeRates,
tokenSortConfig,
tokens,
+ // Dependencies for multichain implementation
+ selectedAccountTokensChains,
+ tokenNetworkFilter,
]);
const showRemoveMenu = (token: TokenI) => {
@@ -178,9 +228,21 @@ const Tokens: React.FC = ({ tokens }) => {
AccountTrackerController,
CurrencyRateController,
TokenRatesController,
+ NetworkController,
+ AccountsController,
} = Engine.context;
+
+ const networkConfigurations =
+ NetworkController.state.networkConfigurationsByChainId;
+ const chainIds = Object.keys(networkConfigurations);
+ const selectedAddress =
+ AccountsController.state.internalAccounts.selectedAccount;
+
const actions = [
- TokenDetectionController.detectTokens(),
+ TokenDetectionController.detectTokens({
+ chainIds: chainIds as Hex[],
+ selectedAddress,
+ }),
AccountTrackerController.refresh(),
CurrencyRateController.updateExchangeRate(nativeCurrencies),
TokenRatesController.updateExchangeRates(),
@@ -239,7 +301,9 @@ const Tokens: React.FC = ({ tokens }) => {
const onActionSheetPress = (index: number) =>
index === 0 ? removeToken() : null;
- const isTokenFilterEnabled = process.env.PORTFOLIO_VIEW === 'true';
+ const allNetworksFilterShown =
+ Object.keys(tokenNetworkFilter).length !==
+ Object.keys(allNetworksEnabled).length;
return (
= ({ tokens }) => {
testID={WalletViewSelectorsIDs.TOKENS_CONTAINER}
>
- {isTokenFilterEnabled ? (
+ {isPortfolioViewEnabled ? (
- {tokenNetworkFilter[chainId]
+ {allNetworksFilterShown
? networkName ?? strings('wallet.current_network')
: strings('wallet.all_networks')}
diff --git a/app/components/UI/Tokens/types.ts b/app/components/UI/Tokens/types.ts
index 76bdef78718..90fa5ad8b49 100644
--- a/app/components/UI/Tokens/types.ts
+++ b/app/components/UI/Tokens/types.ts
@@ -12,7 +12,6 @@ export interface TokensI {
export interface TokenI {
address: string;
aggregators: string[];
- hasBalanceError?: boolean;
decimals: number;
image: string;
name: string;
@@ -21,6 +20,9 @@ export interface TokenI {
balanceFiat: string;
logo: string | undefined;
isETH: boolean | undefined;
+ hasBalanceError?: boolean;
isStaked?: boolean | undefined;
nativeAsset?: TokenI | undefined;
+ chainId?: string;
+ isNative?: boolean;
}
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/enableAllNetworksFilter.ts b/app/components/UI/Tokens/util/enableAllNetworksFilter.ts
new file mode 100644
index 00000000000..2d77801aa7c
--- /dev/null
+++ b/app/components/UI/Tokens/util/enableAllNetworksFilter.ts
@@ -0,0 +1,18 @@
+import { NetworkConfiguration } from '@metamask/network-controller';
+import { Hex } from '@metamask/utils';
+import { NETWORK_CHAIN_ID } from '../../../../util/networks/customNetworks';
+
+export type KnownNetworkConfigurations = {
+ [K in (typeof NETWORK_CHAIN_ID)[keyof typeof NETWORK_CHAIN_ID]]: NetworkConfiguration;
+};
+
+export function enableAllNetworksFilter(
+ networks: Partial,
+) {
+ const allOpts: Record = {};
+ Object.keys(networks).forEach((chainId) => {
+ const hexChainId = chainId as Hex;
+ allOpts[hexChainId] = true;
+ });
+ return allOpts;
+}
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..b6b04c3404a
--- /dev/null
+++ b/app/components/UI/Tokens/util/filterAssets.test.ts
@@ -0,0 +1,98 @@
+import { filterAssets, FilterCriteria } from './filterAssets';
+
+describe('filterAssets function - balance and chainId filtering', () => {
+ interface MockToken {
+ name: string;
+ symbol: string;
+ chainId: string; // Updated to string (e.g., '0x01', '0x89')
+ 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('filters by inclusive chainId', () => {
+ const criteria: FilterCriteria[] = [
+ {
+ key: 'chainId',
+ opts: { '0x01': true, '0x89': true }, // ChainId must be '0x01' or '0x89'
+ filterCallback: 'inclusive',
+ },
+ ];
+
+ const filtered = filterAssets(mockTokens, criteria);
+
+ expect(filtered.length).toBe(3); // Should include 3 tokens with chainId '0x01' and '0x89'
+ 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 }, // Balance between 100 and 150
+ filterCallback: 'range',
+ },
+ ];
+
+ const filtered = filterAssets(mockTokens, criteria);
+
+ expect(filtered.length).toBe(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 }, // ChainId must be '0x01' or '0x89'
+ filterCallback: 'inclusive',
+ },
+ {
+ key: 'balance',
+ opts: { min: 100, max: 150 }, // Balance between 100 and 150
+ filterCallback: 'range',
+ },
+ ];
+
+ const filtered = filterAssets(mockTokens, criteria);
+
+ expect(filtered.length).toBe(2); // Token1 and Token4 meet both criteria
+ });
+
+ test('returns no tokens if no chainId matches', () => {
+ const criteria: FilterCriteria[] = [
+ {
+ key: 'chainId',
+ opts: { '0x04': true }, // No token with chainId '0x04'
+ filterCallback: 'inclusive',
+ },
+ ];
+
+ const filtered = filterAssets(mockTokens, criteria);
+
+ expect(filtered.length).toBe(0); // No matching tokens
+ });
+
+ test('returns no tokens if balance is not within range', () => {
+ const criteria: FilterCriteria[] = [
+ {
+ key: 'balance',
+ opts: { min: 300, max: 400 }, // No token with balance between 300 and 400
+ filterCallback: 'range',
+ },
+ ];
+
+ const filtered = filterAssets(mockTokens, criteria);
+
+ expect(filtered.length).toBe(0); // No matching tokens
+ });
+});
diff --git a/app/components/UI/Tokens/util/filterAssets.ts b/app/components/UI/Tokens/util/filterAssets.ts
new file mode 100644
index 00000000000..d7b7eaab20e
--- /dev/null
+++ b/app/components/UI/Tokens/util/filterAssets.ts
@@ -0,0 +1,62 @@
+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;
+}
+
+const filterCallbacks: FilterCallbacksT = {
+ inclusive: (value: string, opts: Record) => {
+ if (Object.entries(opts).length === 0) {
+ return false;
+ }
+ return opts[value];
+ },
+ range: (value: number, opts: Record) =>
+ value >= opts.min && value <= opts.max,
+};
+
+function getNestedValue(obj: T, keyPath: string): FilterType {
+ return get(obj, keyPath);
+}
+
+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/Tokens/util/organizeTokensByChainId.test.ts b/app/components/UI/Tokens/util/organizeTokensByChainId.test.ts
new file mode 100644
index 00000000000..12da972cb97
--- /dev/null
+++ b/app/components/UI/Tokens/util/organizeTokensByChainId.test.ts
@@ -0,0 +1,104 @@
+import { TokenI } from '../types';
+import { organizeTokensByChainId } from './organizeTokensByChainId';
+
+describe('organizeTokensByChainId', () => {
+ const mockTokens: TokenI[] = [
+ {
+ address: '0x1',
+ chainId: '0x1',
+ name: 'Token1',
+ symbol: 'TK1',
+ decimals: 18,
+ balance: '100',
+ balanceFiat: '1000',
+ image: 'image1.png',
+ aggregators: ['agg1'],
+ logo: 'logo1.png',
+ isETH: false,
+ },
+ {
+ address: '0x2',
+ chainId: '0x1',
+ name: 'Token2',
+ symbol: 'TK2',
+ decimals: 18,
+ balance: '200',
+ balanceFiat: '2000',
+ image: 'image2.png',
+ aggregators: ['agg2'],
+ logo: 'logo2.png',
+ isETH: false,
+ },
+ {
+ address: '0x3',
+ chainId: '0x89',
+ name: 'Token3',
+ symbol: 'TK3',
+ decimals: 18,
+ balance: '300',
+ balanceFiat: '3000',
+ image: 'image3.png',
+ aggregators: ['agg3'],
+ logo: 'logo3.png',
+ isETH: false,
+ },
+ ];
+
+ it('should organize tokens by chainId', () => {
+ const result = organizeTokensByChainId(mockTokens);
+
+ expect(Object.keys(result)).toHaveLength(2);
+ expect(result['0x1']).toHaveLength(2);
+ expect(result['0x89']).toHaveLength(1);
+
+ expect(result['0x1'][0].name).toBe('Token1');
+ expect(result['0x1'][1].name).toBe('Token2');
+ expect(result['0x89'][0].name).toBe('Token3');
+ });
+
+ it('should handle empty array', () => {
+ const result = organizeTokensByChainId([]);
+
+ expect(result).toEqual({});
+ });
+
+ it('should skip tokens without chainId', () => {
+ const tokensWithMissingChainId: TokenI[] = [
+ {
+ address: '0x1',
+ name: 'Token1',
+ symbol: 'TK1',
+ decimals: 18,
+ balance: '100',
+ balanceFiat: '1000',
+ image: 'image1.png',
+ aggregators: ['agg1'],
+ logo: 'logo1.png',
+ isETH: false,
+ },
+ ...mockTokens,
+ ];
+
+ const result = organizeTokensByChainId(tokensWithMissingChainId);
+
+ expect(Object.keys(result)).toHaveLength(2);
+ expect(result['0x1']).toHaveLength(2);
+ expect(result['0x89']).toHaveLength(1);
+ });
+
+ it('should maintain token properties', () => {
+ const result = organizeTokensByChainId(mockTokens);
+ const firstToken = result['0x1'][0];
+
+ expect(firstToken).toEqual(mockTokens[0]);
+ });
+
+ it('should handle array with single token', () => {
+ const singleToken = [mockTokens[0]];
+ const result = organizeTokensByChainId(singleToken);
+
+ expect(Object.keys(result)).toHaveLength(1);
+ expect(result['0x1']).toHaveLength(1);
+ expect(result['0x1'][0]).toEqual(singleToken[0]);
+ });
+});
diff --git a/app/components/UI/Tokens/util/organizeTokensByChainId.ts b/app/components/UI/Tokens/util/organizeTokensByChainId.ts
new file mode 100644
index 00000000000..e5d041d8bcd
--- /dev/null
+++ b/app/components/UI/Tokens/util/organizeTokensByChainId.ts
@@ -0,0 +1,23 @@
+import { TokenI } from '../types';
+
+/**
+ * Organizes an array of tokens into groups by chainId
+ * @param tokens Array of tokens to organize
+ * @returns Object with chainId keys and arrays of tokens as values
+ */
+export const organizeTokensByChainId = (
+ tokens: TokenI[],
+): { [chainId: string]: TokenI[] } =>
+ tokens.reduce<{ [chainId: string]: TokenI[] }>((acc, token) => {
+ if (!token.chainId) {
+ return acc;
+ }
+
+ if (!acc[token.chainId]) {
+ acc[token.chainId] = [];
+ }
+
+ acc[token.chainId].push(token);
+
+ return acc;
+ }, {});
diff --git a/app/components/Views/DetectedTokens/components/Token.tsx b/app/components/Views/DetectedTokens/components/Token.tsx
index 7f14122fc5b..839c7f7a09b 100644
--- a/app/components/Views/DetectedTokens/components/Token.tsx
+++ b/app/components/Views/DetectedTokens/components/Token.tsx
@@ -10,19 +10,27 @@ import { fontStyles } from '../../../../styles/common';
import { useDispatch, useSelector } from 'react-redux';
import { showAlert } from '../../../../actions/alert';
import ClipboardManager from '../../../../core/ClipboardManager';
+import { selectChainId } from '../../../../selectors/networkController';
import {
balanceToFiat,
renderFromTokenMinimalUnit,
} from '../../../../util/number';
import { useTheme } from '../../../../util/theme';
import {
- selectConversionRate,
+ selectConversionRateFoAllChains,
selectCurrentCurrency,
} from '../../../../selectors/currencyRateController';
-import { selectContractExchangeRates } from '../../../../selectors/tokenRatesController';
-import { selectContractBalances } from '../../../../selectors/tokenBalancesController';
+import { selectTokenMarketData } from '../../../../selectors/tokenRatesController';
+import { selectTokensBalances } from '../../../../selectors/tokenBalancesController';
import { Colors } from '../../../../util/theme/models';
import { Hex } from '@metamask/utils';
+import BadgeWrapper from '../../../../component-library/components/Badges/BadgeWrapper';
+import Badge, {
+ BadgeVariant,
+} from '../../../../component-library/components/Badges/Badge';
+import { NetworkBadgeSource } from '../../../UI/AssetOverview/Balance/Balance';
+import { CURRENCY_SYMBOL_BY_CHAIN_ID } from '../../../../constants/network';
+import { selectSelectedInternalAccountAddress } from '../../../../selectors/accountsController';
// Replace this interface by importing from TokenRatesController when it exports it
interface MarketDataDetails {
@@ -109,25 +117,37 @@ const createStyles = (colors: Colors) =>
});
interface Props {
- token: TokenType;
+ token: TokenType & { chainId: Hex };
selected: boolean;
toggleSelected: (selected: boolean) => void;
}
const Token = ({ token, selected, toggleSelected }: Props) => {
const { address, symbol, aggregators = [], decimals } = token;
+ const accountAddress = useSelector(selectSelectedInternalAccountAddress);
const { colors } = useTheme();
const styles = createStyles(colors);
const [expandTokenList, setExpandTokenList] = useState(false);
- const tokenExchangeRates = useSelector(selectContractExchangeRates);
- const tokenBalances = useSelector(selectContractBalances);
- const conversionRate = useSelector(selectConversionRate);
+ const tokenExchangeRatesAllChains = useSelector(selectTokenMarketData);
+ const currentChainId = useSelector(selectChainId);
+ const tokenExchangeRates = tokenExchangeRatesAllChains[token.chainId];
+ const tokenBalancesAllChains = useSelector(selectTokensBalances);
+ const balanceAllChainsForAccount =
+ tokenBalancesAllChains[accountAddress as Hex];
+ const tokenBalances =
+ balanceAllChainsForAccount[(token.chainId as Hex) ?? currentChainId];
+ const conversionRateByChainId = useSelector(selectConversionRateFoAllChains);
+
+ const conversionRate =
+ conversionRateByChainId[CURRENCY_SYMBOL_BY_CHAIN_ID[token.chainId]]
+ ?.conversionRate;
+
const currentCurrency = useSelector(selectCurrentCurrency);
const tokenMarketData =
(tokenExchangeRates as Record)?.[address as Hex] ??
null;
const tokenBalance = renderFromTokenMinimalUnit(
- tokenBalances[address],
+ tokenBalances[address as Hex],
decimals,
);
const tokenBalanceWithSymbol = `${
@@ -168,11 +188,32 @@ const Token = ({ token, selected, toggleSelected }: Props) => {
return (
-
+ {process.env.PORTFOLIO_VIEW === 'true' ? (
+
+ }
+ >
+
+
+ ) : (
+
+ )}
+
{tokenBalanceWithSymbol}
{fiatBalance ? (
diff --git a/app/components/Views/DetectedTokens/index.tsx b/app/components/Views/DetectedTokens/index.tsx
index 4df619a584f..d7c76f170e3 100644
--- a/app/components/Views/DetectedTokens/index.tsx
+++ b/app/components/Views/DetectedTokens/index.tsx
@@ -1,11 +1,17 @@
// Third party dependencies
import React, { useRef, useState, useCallback, useMemo } from 'react';
-import { StyleSheet, View, Text, InteractionManager } from 'react-native';
+import {
+ StyleSheet,
+ View,
+ Text,
+ InteractionManager,
+ ViewStyle,
+} from 'react-native';
import { useSelector } from 'react-redux';
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';
@@ -19,16 +25,23 @@ import { useTheme } from '../../../util/theme';
import { getDecimalChainId } from '../../../util/networks';
import { createNavigationDetails } from '../../../util/navigation/navUtils';
import Routes from '../../../constants/navigation/Routes';
-import { selectDetectedTokens } from '../../../selectors/tokensController';
+import {
+ selectDetectedTokens,
+ selectAllDetectedTokensFlat,
+} from '../../../selectors/tokensController';
import {
selectChainId,
selectNetworkClientId,
+ selectNetworkConfigurations,
} from '../../../selectors/networkController';
import BottomSheet, {
BottomSheetRef,
} from '../../../component-library/components/BottomSheets/BottomSheet';
import { useMetrics } from '../../../components/hooks/useMetrics';
import { DetectedTokensSelectorIDs } from '../../../../e2e/selectors/wallet/DetectedTokensView.selectors';
+import { TokenI } from '../../UI/Tokens/types';
+import { selectTokenNetworkFilter } from '../../../selectors/preferencesController';
+import { organizeTokensByChainId } from '../../UI/Tokens/util/organizeTokensByChainId';
// TODO: Replace "any" with type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -53,9 +66,7 @@ const createStyles = (colors: any) =>
},
headerLabel: {
textAlign: 'center',
- // TODO: Replace "any" with type
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- ...(fontStyles.normal as any),
+ ...(fontStyles.normal as ViewStyle),
fontSize: 18,
paddingVertical: 16,
color: colors.text.default,
@@ -74,22 +85,42 @@ interface IgnoredTokensByAddress {
[address: string]: true;
}
+const isPortfolioViewEnabled = process.env.PORTFOLIO_VIEW === 'true';
+
const DetectedTokens = () => {
const navigation = useNavigation();
const { trackEvent } = useMetrics();
const sheetRef = useRef(null);
const detectedTokens = useSelector(selectDetectedTokens);
+ const allDetectedTokens = useSelector(
+ selectAllDetectedTokensFlat,
+ ) as (TokenI & { chainId: Hex })[];
const chainId = useSelector(selectChainId);
const networkClientId = useSelector(selectNetworkClientId);
const [ignoredTokens, setIgnoredTokens] = useState(
{},
);
+ // TODO: Can probably create "isAllNetworks" selector for these
+ // since they are re-used in multiple places
+ const tokenNetworkFilter = useSelector(selectTokenNetworkFilter); // X
+ const allNetworks = useSelector(selectNetworkConfigurations); // X
+ const isAllNetworks =
+ Object.keys(tokenNetworkFilter).length === Object.keys(allNetworks).length; // X
+
const { colors } = useTheme();
const styles = createStyles(colors);
+ const currentDetectedTokens =
+ isPortfolioViewEnabled && isAllNetworks
+ ? allDetectedTokens
+ : detectedTokens;
+
const detectedTokensForAnalytics = useMemo(
- () => detectedTokens.map((token) => `${token.symbol} - ${token.address}`),
- [detectedTokens],
+ () =>
+ currentDetectedTokens.map(
+ (token) => `${token.symbol} - ${token.address}`,
+ ),
+ [currentDetectedTokens],
);
const dismissModalAndTriggerAction = useCallback(
@@ -101,7 +132,7 @@ const DetectedTokens = () => {
let description = '';
let errorMsg = '';
const tokensToIgnore: string[] = [];
- const tokensToImport = detectedTokens.filter((token) => {
+ const tokensToImport = currentDetectedTokens.filter((token) => {
const isIgnored = ignoreAllTokens || ignoredTokens[token.address];
if (isIgnored) {
tokensToIgnore.push(token.address);
@@ -134,7 +165,44 @@ const DetectedTokens = () => {
tokensToIgnore.length > 0 &&
(await TokensController.ignoreTokens(tokensToIgnore));
if (tokensToImport.length > 0) {
- await TokensController.addTokens(tokensToImport, networkClientId);
+ if (isPortfolioViewEnabled) {
+ const tokensByChainId = tokensToImport.reduce