From c923dfe51d5e827e3efbd62d2079389aaa79e490 Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Fri, 25 Oct 2024 13:14:53 -0700 Subject: [PATCH] feat: poc of consolidated asset view across chains. balances are mocked --- .../app/assets/token-cell/token-cell.tsx | 20 +-- .../app/assets/token-list/token-list.tsx | 115 ++++++++---------- ui/selectors/selectors.js | 97 +++++++++++++++ 3 files changed, 157 insertions(+), 75 deletions(-) diff --git a/ui/components/app/assets/token-cell/token-cell.tsx b/ui/components/app/assets/token-cell/token-cell.tsx index 5f5b43d6c098..72b8dde77902 100644 --- a/ui/components/app/assets/token-cell/token-cell.tsx +++ b/ui/components/app/assets/token-cell/token-cell.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { useSelector } from 'react-redux'; -import { getTokenList } from '../../../../selectors'; -import { useTokenFiatAmount } from '../../../../hooks/useTokenFiatAmount'; +import { getCurrentCurrency, getTokenList } from '../../../../selectors'; import { TokenListItem } from '../../../multichain'; import { isEqualCaseInsensitive } from '../../../../../shared/modules/string-utils'; import { useIsOriginalTokenSymbol } from '../../../../hooks/useIsOriginalTokenSymbol'; @@ -11,6 +10,7 @@ type TokenCellProps = { address: string; symbol: string; string?: string; + tokenFiatAmount: number; image: string; onClick?: (arg: string) => void; }; @@ -20,8 +20,10 @@ export default function TokenCell({ image, symbol, string, + tokenFiatAmount, onClick, }: TokenCellProps) { + const currentCurrency = useSelector(getCurrentCurrency); const tokenList = useSelector(getTokenList); const tokenData = Object.values(tokenList).find( (token) => @@ -30,13 +32,11 @@ export default function TokenCell({ ); const title = tokenData?.name || symbol; const tokenImage = tokenData?.iconUrl || image; - const formattedFiat = useTokenFiatAmount(address, string, symbol, {}, false); const locale = useSelector(getIntlLocale); - const primary = new Intl.NumberFormat(locale, { - minimumSignificantDigits: 1, - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - }).format(string.toString()); + const formattedFiatBalance = new Intl.NumberFormat(locale, { + currency: currentCurrency.toUpperCase(), + style: 'currency', + }).format(tokenFiatAmount); const isOriginalTokenSymbol = useIsOriginalTokenSymbol(address, symbol); @@ -45,8 +45,8 @@ export default function TokenCell({ onClick={onClick ? () => onClick(address) : undefined} tokenSymbol={symbol} tokenImage={tokenImage} - primary={`${primary || 0}`} - secondary={isOriginalTokenSymbol ? formattedFiat : null} + primary={string} + secondary={formattedFiatBalance} title={title} isOriginalTokenSymbol={isOriginalTokenSymbol} address={address} diff --git a/ui/components/app/assets/token-list/token-list.tsx b/ui/components/app/assets/token-list/token-list.tsx index 0e0b03846654..eed6be982545 100644 --- a/ui/components/app/assets/token-list/token-list.tsx +++ b/ui/components/app/assets/token-list/token-list.tsx @@ -8,61 +8,25 @@ import { Display, JustifyContent, } from '../../../../helpers/constants/design-system'; -import { TokenWithBalance } from '../asset-list/asset-list'; import { sortAssets } from '../util/sort'; import { - getAllTokens, - getCurrentChainId, + getCurrencyRates, getPreferences, getSelectedAccount, + getSelectedAccountTokenBalancesAcrossChains, + getSelectedAccountTokensAcrossChains, getShouldHideZeroBalanceTokens, getTokenExchangeRates, + getTokensMarketDataAcrossChains, } from '../../../../selectors'; -import { useAccountTotalFiatBalance } from '../../../../hooks/useAccountTotalFiatBalance'; import { getConversionRate } from '../../../../ducks/metamask/metamask'; import { useNativeTokenBalance } from '../asset-list/native-token/use-native-token-balance'; -import { useTokenTracker } from '../../../../hooks/useTokenTracker'; type TokenListProps = { onTokenClick: (arg: string) => void; nativeToken: ReactNode; }; -function aggregateTokensByAccount(data: Record) { - // Initialize an empty object to hold tokens by account - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const tokensByAccount: Record = {}; - - // Loop through each chain (0x1, 0x89, etc.) - for (const chainId in data) { - // Ensure we're only iterating over data's own properties - if (data[chainId]) { - // Loop through each account in the chain - const chainData = data[chainId]; - for (const accountId in chainData) { - if (chainData[accountId]) { - // If the accountId does not exist in the tokensByAccount object, initialize it with an empty array - if (!tokensByAccount[accountId]) { - tokensByAccount[accountId] = []; - } - - // Loop through each token associated with the account - // eslint-disable-next-line @typescript-eslint/no-explicit-any - chainData[accountId].forEach((token: any) => { - // Add the chainId to each token object - const tokenWithChain = { ...token, chainId }; - - // Push the token to the respective account's token list - tokensByAccount[accountId].push(tokenWithChain); - }); - } - } - } - } - - return tokensByAccount; -} - export default function TokenList({ onTokenClick, nativeToken, @@ -80,44 +44,65 @@ export default function TokenList({ shallowEqual, ); - const allTokens = useSelector(getAllTokens); - const aggregatedCrossChainTokensByAccount = - aggregateTokensByAccount(allTokens); + const selectedAccountTokensChains: Record = useSelector( + getSelectedAccountTokensAcrossChains, + ); + + const selectedAccountTokenBalancesAcrossChains: Record = + useSelector(getSelectedAccountTokenBalancesAcrossChains); + + const marketData = useSelector(getTokensMarketDataAcrossChains); + const currencyRates = useSelector(getCurrencyRates); + + // Select token data, and token balances + // Consolidate both data structures into an array of tokens, along with their balances + // include an isNative boolean to indicate that these are _not_ native tokens + const consolidatedBalances = () => { + const tokensWithBalance: any[] = []; - console.log(aggregatedCrossChainTokensByAccount[selectedAccount.address]); + // Iterate over each chainId in accountTokensByChain + Object.keys(selectedAccountTokensChains).forEach((chainId: string) => { + // For each token in the chain, add the balance from tokenBalancesByChain + selectedAccountTokensChains[chainId].forEach( + (token: Record) => { + const { address } = token; + const balance = + selectedAccountTokenBalancesAcrossChains[chainId]?.[address] || + '0.00000'; // Default to "0.00000" if no balance found - const { tokensWithBalances: crossChainTokensWithBalances } = useTokenTracker({ - tokens: aggregatedCrossChainTokensByAccount[selectedAccount.address], - address: selectedAccount?.address, - includeFailedTokens: true, - hideZeroBalanceTokens: shouldHideZeroBalanceTokens, - }); - console.log('crossChainTokensWithBalances: ', crossChainTokensWithBalances); + const baseCurrency = marketData[chainId]?.[address]?.currency; + + const tokenMarketPrice = marketData[chainId]?.[address]?.price || '0'; + const tokenExchangeRate = + currencyRates[baseCurrency]?.conversionRate || '0'; + + // Add the token with its balance to the result array + tokensWithBalance.push({ + ...token, + balance, + tokenFiatAmount: tokenMarketPrice * tokenExchangeRate * balance, + isNative: false, + string: balance.toString(), + }); + }, + ); + }); - const { tokensWithBalances, loading } = useAccountTotalFiatBalance( - selectedAccount, - shouldHideZeroBalanceTokens, - ) as { - tokensWithBalances: TokenWithBalance[]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - mergedRates: any; - loading: boolean; + return tokensWithBalance; }; const sortedTokens = useMemo(() => { - // TODO filter assets by networkTokenFilter before sorting - return sortAssets( - [nativeTokenWithBalance, ...tokensWithBalances], - tokenSortConfig, - ); + const consolidatedTokensWithBalances = consolidatedBalances(); // TODO include nativeTokens in this list + // .filter() // TODO filter assets by networkTokenFilter before sorting + return sortAssets(consolidatedTokensWithBalances, tokenSortConfig); }, [ - tokensWithBalances, tokenSortConfig, tokenNetworkFilter, conversionRate, contractExchangeRates, ]); + const loading = false; // TODO should we include loading/polling logic? return loading ? ( balance + */ +export function getSelectedAccountNativeTokenCachedBalanceByChainId(state) { + const { accountsByChainId } = state.metamask; + const { address: selectedAddress } = getSelectedInternalAccount(state); + + const balancesByChainId = {}; + for (const [chainId, accounts] of Object.entries(accountsByChainId || {})) { + if (accounts[selectedAddress]) { + balancesByChainId[chainId] = accounts[selectedAddress].balance; + } + } + return balancesByChainId; +} + +/** + * Based on the current account address, query for all tokens across all chain networks on that account. + * This will eventually be exposed in a new piece of state called `tokenBalances`, which will including the new polling mechanism to stay up to date + * + * @param {object} state - Redux state + * @returns {object} An array of tokens with balances for the given account. Data relationship will be chainId => balance + */ +export function getSelectedAccountTokensAcrossChains(state) { + const { allTokens } = state.metamask; + const { address: selectedAddress } = getSelectedInternalAccount(state); + + // Initialize an empty object to hold tokens by chainId for the selected account + const tokensByChain = {}; + + // Loop through each chain (0x1, 0x89, etc.) + for (const chainId in allTokens) { + if (allTokens[chainId]) { + const chainData = allTokens[chainId]; + + // Check if the selected account exists within this chain + if (chainData[selectedAddress]) { + // Ensure the chainId key exists in tokensByChain + if (!tokensByChain[chainId]) { + tokensByChain[chainId] = []; + } + + // Add each token to the array under its chainId + chainData[selectedAddress].forEach((token) => { + const tokenWithChain = { ...token, chainId }; + tokensByChain[chainId].push(tokenWithChain); + }); + } + } + } + + return tokensByChain; +} + +/** + * Based on the current account address, query for all tokens across all chain networks on that account. + * This will eventually be exposed in a new piece of state called `tokenBalances`, which will including the new polling mechanism to stay up to date + * + * @param {object} state - Redux state + * @returns {object} An array of tokens with balances for the given account. Data relationship will be chainId => balance + */ +export function getSelectedAccountTokenBalancesAcrossChains(state) { + const accountTokens = getSelectedAccountTokensAcrossChains(state); + + // TODO: read this from tokenBalances state + function generateRandomBalance(min = 10, max = 20) { + const factor = 100000; // 10^5 to get 5 decimal places + const randomValue = Math.random() * (max - min) + min; + return Math.floor(randomValue * factor) / factor; + } + + const tokenBalancesByChain = {}; + + Object.keys(accountTokens).forEach((chainId) => { + tokenBalancesByChain[chainId] = {}; + + accountTokens[chainId].forEach((token) => { + const { address } = token; + + tokenBalancesByChain[chainId][address] = generateRandomBalance(); + }); + }); + + return tokenBalancesByChain; +} + /** * @typedef {import('./selectors.types').InternalAccountWithBalance} InternalAccountWithBalance */ @@ -591,6 +680,10 @@ export const getTokensMarketData = (state) => { return state.metamask.marketData?.[chainId]; }; +export const getTokensMarketDataAcrossChains = (state) => { + return state.metamask.marketData; +}; + export function getAddressBook(state) { const chainId = getCurrentChainId(state); if (!state.metamask.addressBook[chainId]) { @@ -1244,6 +1337,10 @@ export function getUSDConversionRate(state) { ?.usdConversionRate; } +export function getCurrencyRates(state) { + return state.metamask.currencyRates; +} + export function getWeb3ShimUsageStateForOrigin(state, origin) { return state.metamask.web3ShimUsageOrigins[origin]; }