From 06f4bfa5f591bbedf5417521e26e3959b4a3c0c1 Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Thu, 23 Jan 2025 13:02:36 -0800 Subject: [PATCH 1/2] chore: Create consolidateTokenBalances utility function --- .../app/assets/token-list/token-list.tsx | 67 +++------------- .../assets/util/calculateTokenFiatAmount.ts | 2 +- .../assets/util/consolidateTokenBalances.ts | 76 +++++++++++++++++++ ui/pages/asset/util.ts | 64 ++++++++++++++++ 4 files changed, 151 insertions(+), 58 deletions(-) create mode 100644 ui/components/app/assets/util/consolidateTokenBalances.ts diff --git a/ui/components/app/assets/token-list/token-list.tsx b/ui/components/app/assets/token-list/token-list.tsx index 5555c8782f8a..1504e89242a2 100644 --- a/ui/components/app/assets/token-list/token-list.tsx +++ b/ui/components/app/assets/token-list/token-list.tsx @@ -23,13 +23,12 @@ import { } from '../../../../selectors'; import { getConversionRate } from '../../../../ducks/metamask/metamask'; import { filterAssets } from '../util/filter'; -import { calculateTokenBalance } from '../util/calculateTokenBalance'; -import { calculateTokenFiatAmount } from '../util/calculateTokenFiatAmount'; import { endTrace, TraceName } from '../../../../../shared/lib/trace'; import { useTokenBalances } from '../../../../hooks/useTokenBalances'; import { setTokenNetworkFilter } from '../../../../store/actions'; import { useMultichainSelector } from '../../../../hooks/useMultichainSelector'; import { getMultichainShouldShowFiat } from '../../../../selectors/multichain'; +import { consolidateTokenBalances } from '../util/consolidateTokenBalances'; type TokenListProps = { onTokenClick: (chainId: string, address: string) => void; @@ -130,62 +129,16 @@ export default function TokenList({ } }, [Object.keys(allNetworks).length]); - const consolidatedBalances = () => { - const tokensWithBalance: TokenWithFiatAmount[] = []; - Object.entries(selectedAccountTokensChains).forEach( - ([stringChainKey, tokens]) => { - const chainId = stringChainKey as Hex; - tokens.forEach((token: Token) => { - const { isNative, address, decimals } = token; - const balance = - calculateTokenBalance({ - isNative, - chainId, - address, - decimals, - nativeBalances, - selectedAccountTokenBalancesAcrossChains, - }) || '0'; - - const tokenFiatAmount = calculateTokenFiatAmount({ - token, - chainId, - balance, - marketData, - currencyRates, - }); - - // Respect the "hide zero balance" setting (when true): - // - Native tokens should always display with zero balance when on the current network filter. - // - Native tokens should not display with zero balance when on all networks filter - // - ERC20 tokens with zero balances should respect the setting on both the current and all networks. - - // Respect the "hide zero balance" setting (when false): - // - Native tokens should always display with zero balance when on the current network filter. - // - Native tokens should always display with zero balance when on all networks filter - // - ERC20 tokens always display with zero balance on both the current and all networks filter. - if ( - !hideZeroBalanceTokens || - balance !== '0' || - (token.isNative && isOnCurrentNetwork) - ) { - tokensWithBalance.push({ - ...token, - balance, - tokenFiatAmount, - chainId, - string: String(balance), - }); - } - }); - }, - ); - - return tokensWithBalance; - }; - const sortedFilteredTokens = useMemo(() => { - const consolidatedTokensWithBalances = consolidatedBalances(); + const consolidatedTokensWithBalances = consolidateTokenBalances( + selectedAccountTokensChains, + nativeBalances, + selectedAccountTokenBalancesAcrossChains, + marketData, + currencyRates, + hideZeroBalanceTokens, + isOnCurrentNetwork, + ); const filteredAssets = filterAssets(consolidatedTokensWithBalances, [ { key: 'chainId', diff --git a/ui/components/app/assets/util/calculateTokenFiatAmount.ts b/ui/components/app/assets/util/calculateTokenFiatAmount.ts index 279fae37f582..5155c7b1ca94 100644 --- a/ui/components/app/assets/util/calculateTokenFiatAmount.ts +++ b/ui/components/app/assets/util/calculateTokenFiatAmount.ts @@ -1,7 +1,7 @@ import { Hex } from '@metamask/utils'; import { ChainAddressMarketData, Token } from '../token-list/token-list'; -type SymbolCurrencyRateMapping = Record>; +export type SymbolCurrencyRateMapping = Record>; type CalculateTokenFiatAmountParams = { token: Token; diff --git a/ui/components/app/assets/util/consolidateTokenBalances.ts b/ui/components/app/assets/util/consolidateTokenBalances.ts new file mode 100644 index 000000000000..6b46235c8cad --- /dev/null +++ b/ui/components/app/assets/util/consolidateTokenBalances.ts @@ -0,0 +1,76 @@ +import { Hex } from '@metamask/utils'; +import { + ChainAddressMarketData, + Token, + TokenWithFiatAmount, +} from '../token-list/token-list'; +import { calculateTokenBalance } from './calculateTokenBalance'; +import { + SymbolCurrencyRateMapping, + calculateTokenFiatAmount, +} from './calculateTokenFiatAmount'; + +export const consolidateTokenBalances = ( + selectedAccountTokensChains: Record, + nativeBalances: Record, + selectedAccountTokenBalancesAcrossChains: Record< + `0x${string}`, + Record<`0x${string}`, `0x${string}`> + >, + marketData: ChainAddressMarketData, + currencyRates: SymbolCurrencyRateMapping, + hideZeroBalanceTokens: boolean, + isOnCurrentNetwork: boolean, +) => { + const tokensWithBalance: TokenWithFiatAmount[] = []; + Object.entries(selectedAccountTokensChains).forEach( + ([stringChainKey, tokens]) => { + const chainId = stringChainKey as Hex; + tokens.forEach((token: Token) => { + const { isNative, address, decimals } = token; + const balance = + calculateTokenBalance({ + isNative, + chainId, + address, + decimals, + nativeBalances, + selectedAccountTokenBalancesAcrossChains, + }) || '0'; + + const tokenFiatAmount = calculateTokenFiatAmount({ + token, + chainId, + balance, + marketData, + currencyRates, + }); + + // Respect the "hide zero balance" setting (when true): + // - Native tokens should always display with zero balance when on the current network filter. + // - Native tokens should not display with zero balance when on all networks filter + // - ERC20 tokens with zero balances should respect the setting on both the current and all networks. + + // Respect the "hide zero balance" setting (when false): + // - Native tokens should always display with zero balance when on the current network filter. + // - Native tokens should always display with zero balance when on all networks filter + // - ERC20 tokens always display with zero balance on both the current and all networks filter. + if ( + !hideZeroBalanceTokens || + balance !== '0' || + (token.isNative && isOnCurrentNetwork) + ) { + tokensWithBalance.push({ + ...token, + balance, + tokenFiatAmount, + chainId, + string: String(balance), + }); + } + }); + }, + ); + + return tokensWithBalance; +}; diff --git a/ui/pages/asset/util.ts b/ui/pages/asset/util.ts index 2f4a41df6cc9..0d413fc6be8d 100644 --- a/ui/pages/asset/util.ts +++ b/ui/pages/asset/util.ts @@ -92,3 +92,67 @@ export const findAssetByAddress = ( token.address && token.address.toLowerCase() === address.toLowerCase(), ); }; + +type ParsedAssetId = { + namespace: string; // Namespace (e.g., eip155, solana, bip122) + chainId: string; // Full chain ID (namespace + blockchain ID) + assetNamespace: string; // Asset namespace (e.g., slip44, erc20, token, ordinal) + assetReference: string; // Asset reference (on-chain address, token identifier, etc.) +}; + +const parseAssetId = (assetId: string): ParsedAssetId => { + // Split the assetId into chain_id and asset details + const [chainId, assetDetails] = assetId.split('/'); + + if (!chainId || !assetDetails) { + throw new Error( + 'Invalid assetId format. Must include both chainId and asset details.', + ); + } + + // Split asset details into namespace and reference + const [assetNamespace, assetReference] = assetDetails.split(':'); + + if (!assetNamespace || !assetReference) { + throw new Error( + 'Invalid asset details format. Must include both assetNamespace and assetReference.', + ); + } + + // Validate the chainId format (namespace:blockchainId) + const [namespace, blockchainId] = chainId.split(':'); + if (!namespace || !blockchainId) { + throw new Error( + 'Invalid chainId format. Must include both namespace and blockchain ID.', + ); + } + + // Validate assetNamespace (must match [-a-z0-9]{3,8}) + // https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-19.md#syntax + const assetNamespaceRegex = /^[-a-z0-9]{3,8}$/u; + if (!assetNamespaceRegex.test(assetNamespace)) { + throw new Error( + `Invalid assetNamespace format: "${assetNamespace}". Must be 3-8 characters, containing only lowercase letters, numbers, or dashes.`, + ); + } + + // Validate assetReference (must match [-.%a-zA-Z0-9]{1,128}) + // https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-19.md#syntax + const assetReferenceRegex = /^[-.%a-zA-Z0-9]{1,128}$/u; + if (!assetReferenceRegex.test(assetReference)) { + throw new Error( + `Invalid assetReference format: "${assetReference}". Must be 1-128 characters, containing only alphanumerics, dashes, dots, or percent signs.`, + ); + } + + // Ensure assetReference is URL-decoded if necessary + // https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-19.md#syntax + const decodedAssetReference = decodeURIComponent(assetReference); + + return { + namespace, + chainId, + assetNamespace, + assetReference: decodedAssetReference, + }; +}; From 2eecfcf2d1b703b309348e379eb50c5dd98a1353 Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Thu, 23 Jan 2025 13:49:59 -0800 Subject: [PATCH 2/2] fix: Continue to break out utility files --- .../app/assets/asset-list/asset-list.tsx | 94 ++++--------------- .../app/assets/auto-detect-token/index.scss | 10 -- .../assets/util/importAllDetectedTokens.ts | 90 ++++++++++++++++++ 3 files changed, 110 insertions(+), 84 deletions(-) delete mode 100644 ui/components/app/assets/auto-detect-token/index.scss create mode 100644 ui/components/app/assets/util/importAllDetectedTokens.ts diff --git a/ui/components/app/assets/asset-list/asset-list.tsx b/ui/components/app/assets/asset-list/asset-list.tsx index 5ac7a84e0b29..3fd5bbbd4369 100644 --- a/ui/components/app/assets/asset-list/asset-list.tsx +++ b/ui/components/app/assets/asset-list/asset-list.tsx @@ -1,6 +1,6 @@ import React, { useContext, useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { Token } from '@metamask/assets-controllers'; + import { NetworkConfiguration } from '@metamask/network-controller'; import TokenList from '../token-list'; import { PRIMARY } from '../../../../helpers/constants/common'; @@ -26,7 +26,6 @@ import { MetaMetricsContext } from '../../../../contexts/metametrics'; import { MetaMetricsEventCategory, MetaMetricsEventName, - MetaMetricsTokenEventSource, } from '../../../../../shared/constants/metametrics'; import DetectedToken from '../../detected-token/detected-token'; import { ReceiveModal } from '../../../multichain'; @@ -45,10 +44,8 @@ import { getSelectedNetworkClientId, } from '../../../../../shared/modules/selectors/networks'; import { addImportedTokens } from '../../../../store/actions'; -import { - AssetType, - TokenStandard, -} from '../../../../../shared/constants/transaction'; +import { Token } from '../token-list/token-list'; +import { importAllDetectedTokens } from '../util/importAllDetectedTokens'; import AssetListControlBar from './asset-list-control-bar'; import NativeToken from './native-token'; @@ -90,10 +87,6 @@ const AssetList = ({ onClickAsset, showTokensLinks }: AssetListProps) => { const detectedTokens = useSelector(getDetectedTokensInCurrentNetwork) || []; - const isTokenNetworkFilterEqualCurrentNetwork = useSelector( - getIsTokenNetworkFilterEqualCurrentNetwork, - ); - const allNetworks: Record<`0x${string}`, NetworkConfiguration> = useSelector( getNetworkConfigurationsByChainId, ); @@ -101,6 +94,9 @@ const AssetList = ({ onClickAsset, showTokensLinks }: AssetListProps) => { const selectedAddress = useSelector(getSelectedAddress); const useTokenDetection = useSelector(getUseTokenDetection); const currentChainId = useSelector(getCurrentChainId); + const isOnCurrentNetwork = useSelector( + getIsTokenNetworkFilterEqualCurrentNetwork, + ); const [showFundingMethodModal, setShowFundingMethodModal] = useState(false); const [showReceiveModal, setShowReceiveModal] = useState(false); @@ -125,7 +121,7 @@ const AssetList = ({ onClickAsset, showTokensLinks }: AssetListProps) => { const shouldShowTokensLinks = showTokensLinks ?? isEvm; const detectedTokensMultichain: { - [key: `0x${string}`]: Token[]; + [key: string]: Token[]; } = useSelector(getAllDetectedTokensForSelectedAddress); const multichainDetectedTokensLength = Object.values( @@ -134,70 +130,20 @@ const AssetList = ({ onClickAsset, showTokensLinks }: AssetListProps) => { // Add detected tokens to sate useEffect(() => { - const importAllDetectedTokens = async () => { - // If autodetect tokens toggle is OFF, return - if (!useTokenDetection) { - return; - } - // TODO add event for MetaMetricsEventName.TokenAdded - - if ( - process.env.PORTFOLIO_VIEW && - !isTokenNetworkFilterEqualCurrentNetwork - ) { - const importPromises = Object.entries(detectedTokensMultichain).map( - async ([networkId, tokens]) => { - const chainConfig = allNetworks[networkId as `0x${string}`]; - const { defaultRpcEndpointIndex } = chainConfig; - const { networkClientId: networkInstanceId } = - chainConfig.rpcEndpoints[defaultRpcEndpointIndex]; - - await dispatch( - addImportedTokens(tokens as Token[], networkInstanceId), - ); - tokens.forEach((importedToken) => { - trackEvent({ - event: MetaMetricsEventName.TokenAdded, - category: MetaMetricsEventCategory.Wallet, - sensitiveProperties: { - token_symbol: importedToken.symbol, - token_contract_address: importedToken.address, - token_decimal_precision: importedToken.decimals, - source: MetaMetricsTokenEventSource.Detected, - token_standard: TokenStandard.ERC20, - asset_type: AssetType.token, - token_added_type: 'detected', - chain_id: chainConfig.chainId, - }, - }); - }); - }, - ); - - await Promise.all(importPromises); - } else if (detectedTokens.length > 0) { - await dispatch(addImportedTokens(detectedTokens, networkClientId)); - detectedTokens.forEach((importedToken: Token) => { - trackEvent({ - event: MetaMetricsEventName.TokenAdded, - category: MetaMetricsEventCategory.Wallet, - sensitiveProperties: { - token_symbol: importedToken.symbol, - token_contract_address: importedToken.address, - token_decimal_precision: importedToken.decimals, - source: MetaMetricsTokenEventSource.Detected, - token_standard: TokenStandard.ERC20, - asset_type: AssetType.token, - token_added_type: 'detected', - chain_id: currentChainId, - }, - }); - }); - } - }; - importAllDetectedTokens(); + importAllDetectedTokens( + useTokenDetection, + isOnCurrentNetwork, + detectedTokensMultichain, + allNetworks, + networkClientId, + addImportedTokens, + currentChainId, + trackEvent, + detectedTokens, + dispatch, + ); }, [ - isTokenNetworkFilterEqualCurrentNetwork, + isOnCurrentNetwork, selectedAddress, networkClientId, detectedTokens.length, diff --git a/ui/components/app/assets/auto-detect-token/index.scss b/ui/components/app/assets/auto-detect-token/index.scss deleted file mode 100644 index 5714d957f0f9..000000000000 --- a/ui/components/app/assets/auto-detect-token/index.scss +++ /dev/null @@ -1,10 +0,0 @@ -.auto-detect-in-modal { - &__benefit { - flex: 1; - } - - &__dialog { - background-position: -80px 16px; - background-repeat: no-repeat; - } -} diff --git a/ui/components/app/assets/util/importAllDetectedTokens.ts b/ui/components/app/assets/util/importAllDetectedTokens.ts new file mode 100644 index 000000000000..2ee69587b34c --- /dev/null +++ b/ui/components/app/assets/util/importAllDetectedTokens.ts @@ -0,0 +1,90 @@ +import { Hex } from '@metamask/utils'; +import { + MetaMetricsEventCategory, + MetaMetricsEventName, + MetaMetricsTokenEventSource, +} from '../../../../../shared/constants/metametrics'; +import { + AssetType, + TokenStandard, +} from '../../../../../shared/constants/transaction'; +import { Token } from '../token-list/token-list'; +import { + NetworkClientId, + NetworkConfiguration, +} from '@metamask/network-controller'; + +export const importAllDetectedTokens = async ( + useTokenDetection: boolean, + isOnCurrentNetwork: boolean, + detectedTokensMultichain: { + [key: string]: Token[]; + }, + allNetworks: Record, + networkClientId: NetworkClientId, + addImportedTokens: ( + tokensToImport: Token[], + networkClientId?: NetworkClientId, + ) => void, + currentChainId: string, + trackEvent: any, + detectedTokens: any, + dispatch: any, +) => { + // If autodetect tokens toggle is OFF, return + if (!useTokenDetection) { + return; + } + // TODO add event for MetaMetricsEventName.TokenAdded + + if (process.env.PORTFOLIO_VIEW && !isOnCurrentNetwork) { + const importPromises = Object.entries(detectedTokensMultichain).map( + async ([networkId, tokens]) => { + const chainConfig = allNetworks[networkId]; + const { defaultRpcEndpointIndex } = chainConfig; + const { networkClientId: networkInstanceId } = + chainConfig.rpcEndpoints[defaultRpcEndpointIndex]; + + await dispatch(addImportedTokens(tokens as Token[], networkInstanceId)); + tokens.forEach((importedToken) => { + // when multichain is fully integrated, we should change these event signatures for analytics + trackEvent({ + event: MetaMetricsEventName.TokenAdded, + category: MetaMetricsEventCategory.Wallet, + sensitiveProperties: { + token_symbol: importedToken.symbol, + token_contract_address: importedToken.address, + token_decimal_precision: importedToken.decimals, + source: MetaMetricsTokenEventSource.Detected, + token_standard: TokenStandard.ERC20, + asset_type: AssetType.token, + token_added_type: 'detected', + chain_id: chainConfig.chainId, + }, + }); + }); + }, + ); + + await Promise.all(importPromises); + } else if (detectedTokens.length > 0) { + await dispatch(addImportedTokens(detectedTokens, networkClientId)); + detectedTokens.forEach((importedToken: Token) => { + // when multichain is fully integrated, we should change these event signatures for analytics + trackEvent({ + event: MetaMetricsEventName.TokenAdded, + category: MetaMetricsEventCategory.Wallet, + sensitiveProperties: { + token_symbol: importedToken.symbol, + token_contract_address: importedToken.address, + token_decimal_precision: importedToken.decimals, + source: MetaMetricsTokenEventSource.Detected, + token_standard: TokenStandard.ERC20, + asset_type: AssetType.token, + token_added_type: 'detected', + chain_id: currentChainId, + }, + }); + }); + } +};