From 88d110615506901e830f4f2db7308bc166c72f3c Mon Sep 17 00:00:00 2001 From: kev1n-peters <96065607+kev1n-peters@users.noreply.github.com> Date: Thu, 25 Apr 2024 14:53:59 -0500 Subject: [PATCH] Token balance display fix (#1982) * Token balance display fix Previously the token balances would only be shown after the TokensModal was opened to fetch and set the wallet's balances. The useGetTokenBalance hook is now used to fetch the balances on demand (if not already cached). * TokensModal uses hook, renamed to useGetTokenBalances * style thing * small refactor --- .../src/components/TokensModal.tsx | 166 +++--------------- .../src/hooks/useGetTokenBalances.ts | 127 ++++++++++++++ wormhole-connect/src/store/transferInput.ts | 34 ++-- .../src/utils/transferValidation.ts | 2 +- .../src/views/Bridge/Inputs/From.tsx | 16 +- .../src/views/Bridge/Inputs/To.tsx | 17 +- 6 files changed, 192 insertions(+), 170 deletions(-) create mode 100644 wormhole-connect/src/hooks/useGetTokenBalances.ts diff --git a/wormhole-connect/src/components/TokensModal.tsx b/wormhole-connect/src/components/TokensModal.tsx index ca9febfed..e7dd8af7f 100644 --- a/wormhole-connect/src/components/TokensModal.tsx +++ b/wormhole-connect/src/components/TokensModal.tsx @@ -4,28 +4,14 @@ import Button from '@mui/material/Button'; import IconButton from '@mui/material/IconButton'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import { useTheme } from '@mui/material/styles'; -import { ChainName, TokenId } from '@wormhole-foundation/wormhole-connect-sdk'; +import { ChainName } from '@wormhole-foundation/wormhole-connect-sdk'; import { AVAILABLE_MARKETS_URL } from 'config/constants'; import config from 'config'; import { TokenConfig } from 'config/types'; -import { BigNumber } from 'ethers'; import TokenIcon from 'icons/TokenIcons'; -import React, { - ChangeEvent, - useCallback, - useEffect, - useMemo, - useState, -} from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import React, { ChangeEvent, useEffect, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; import { RootState } from 'store'; -import { - Balances, - ChainBalances, - accessChainBalances, - formatBalance, - setBalances, -} from 'store/transferInput'; import { makeStyles } from 'tss-react/mui'; import { displayAddress, sortTokens } from 'utils'; import { isGatewayChain } from 'utils/cosmos'; @@ -39,6 +25,8 @@ import Tabs from './Tabs'; import { CCTPManual_CHAINS } from '../routes/cctpManual'; import { isTBTCCanonicalChain } from 'routes/tbtc'; import { CHAIN_ID_ETH } from '@certusone/wormhole-sdk/lib/esm/utils'; +import useGetTokenBalances from 'hooks/useGetTokenBalances'; +import { Balances } from 'store/transferInput'; const useStyles = makeStyles()((theme: any) => ({ tokensContainer: { @@ -149,7 +137,7 @@ const displayNativeChain = (token: TokenConfig): string => { type DisplayTokensProps = { tokens: TokenConfig[]; - balances: any; + balances: Balances; walletAddress: string | undefined; chain: any; selectToken: (tokenKey: string) => void; @@ -176,8 +164,7 @@ function DisplayTokens(props: DisplayTokensProps) { const showCircularProgress = (token: string): boolean => { if (!chain || !walletAddress) return false; - if (!balances) return true; - if (balances && balances[token] !== null) return true; + if (balances[token]?.balance !== null) return true; return false; }; @@ -211,8 +198,8 @@ function DisplayTokens(props: DisplayTokensProps) {
Balance
- {balances && balances[token.key] && walletAddress ? ( -
{balances[token.key]}
+ {balances[token.key]?.balance && walletAddress ? ( +
{balances[token.key].balance}
) : showCircularProgress(token.key) ? ( ) : ( @@ -293,16 +280,12 @@ function isGatewayNativeToken(token: TokenConfig) { function TokensModal(props: Props) { const theme = useTheme(); - const dispatch = useDispatch(); const { open, chain, walletAddress, type } = props; const mobile = useMediaQuery(theme.breakpoints.down('sm')); - const [loading, setLoading] = useState(false); - const [balancesLoaded, setBalancesLoaded] = useState(false); const [tokens, setTokens] = useState([]); const [search, setSearch] = useState(''); const { - balances, supportedSourceTokens, supportedDestTokens, allSupportedDestTokens: allSupportedDestTokensBase, @@ -320,9 +303,14 @@ function TokensModal(props: Props) { return supported.filter((t) => !isGatewayNativeToken(t)); }, [type, supportedSourceTokens, supportedDestTokens]); - const chainBalancesCache: ChainBalances | undefined = useMemo(() => { - return accessChainBalances(balances, walletAddress, chain); - }, [chain, balances, walletAddress]); + const queryTokens = + type === 'dest' ? allSupportedDestTokens : supportedTokens; + + const { isFetching, balances } = useGetTokenBalances( + walletAddress || '', + chain, + queryTokens, + ); // search tokens const handleSearch = ( @@ -376,110 +364,6 @@ function TokensModal(props: Props) { closeTokensModal(); }; - useEffect(() => { - setBalancesLoaded(false); - }, [chain, walletAddress]); - - const getBalances = useCallback(async () => { - if (!walletAddress || !chain) return; - const fiveMinutesAgo = Date.now() - 60 * 1000 * 5; - if ( - chainBalancesCache && - chainBalancesCache.balances && - chainBalancesCache.lastUpdated! > fiveMinutesAgo - ) { - setBalancesLoaded(true); - return; - } - - const queryTokens = - type === 'dest' ? allSupportedDestTokens : supportedTokens; - const nativeQueryToken = queryTokens.find( - (t) => !t.tokenId && t.nativeChain === chain, - ); - const queryTokensWithIds = queryTokens.filter((t) => !!t.tokenId); // pre-filter so indexes line up - const tokenIds = queryTokensWithIds.reduce( - (tIds, t) => (t.tokenId ? [...tIds, t.tokenId] : tIds), - [], - ); - let balances: Balances = {}; - if (nativeQueryToken) { - let nativeBalance: BigNumber | null = null; - try { - nativeBalance = await config.wh.getNativeBalance(walletAddress, chain); - balances = { - ...balances, - ...formatBalance(chain, nativeQueryToken, nativeBalance), - }; - } catch (e) { - console.warn('Failed to fetch native balance', e); - } - } - try { - const tokenBalances = await config.wh.getTokenBalances( - walletAddress, - tokenIds, - chain, - ); - balances = tokenIds.reduce( - (balances, tId, idx) => ({ - ...balances, - ...formatBalance(chain, queryTokensWithIds[idx], tokenBalances[idx]), - }), - balances, - ); - } catch (e) { - console.warn('Failed to fetch balances', e); - } - - dispatch( - setBalances({ - address: walletAddress, - chain, - balances, - }), - ); - }, [ - walletAddress, - chain, - dispatch, - type, - supportedTokens, - chainBalancesCache, - allSupportedDestTokens, - ]); - - // fetch token balances and set in store - useEffect(() => { - let active = true; - if (!walletAddress || !chain) { - setTokens(supportedTokens); - return; - } - - if (!balancesLoaded) { - setLoading(true); - getBalances().finally(() => { - if (active) { - setLoading(false); - setBalancesLoaded(true); - } - }); - } - return () => { - active = false; - }; - }, [ - walletAddress, - supportedTokens, - chain, - dispatch, - getBalances, - type, - open, - balancesLoaded, - ]); - useEffect(() => { // get tokens that exist on the chain and have a balance greater than 0 const filtered = supportedTokens.filter((t) => { @@ -506,13 +390,13 @@ function TokensModal(props: Props) { } if (type === 'dest') return true; - if (!chainBalancesCache) return true; - const b = chainBalancesCache.balances[t.key]; - const isNonzeroBalance = b !== null && b !== '0'; + if (!balances[t.key]) return true; + const { balance } = balances[t.key]; + const isNonzeroBalance = balance !== null && balance !== '0'; return isNonzeroBalance; }); setTokens(filtered); - }, [chainBalancesCache, chain, supportedTokens, type]); + }, [balances, chain, supportedTokens, type]); const tabs = [ { @@ -520,12 +404,12 @@ function TokensModal(props: Props) { panel: ( ), @@ -535,12 +419,12 @@ function TokensModal(props: Props) { panel: ( ), diff --git a/wormhole-connect/src/hooks/useGetTokenBalances.ts b/wormhole-connect/src/hooks/useGetTokenBalances.ts new file mode 100644 index 000000000..ceb6ef2b5 --- /dev/null +++ b/wormhole-connect/src/hooks/useGetTokenBalances.ts @@ -0,0 +1,127 @@ +import { useDispatch, useSelector } from 'react-redux'; +import { RootState } from 'store'; +import { ChainName, TokenId } from '@wormhole-foundation/wormhole-connect-sdk'; +import { useEffect, useState } from 'react'; +import { + accessBalance, + Balances, + formatBalance, + updateBalances, +} from 'store/transferInput'; +import config from 'config'; +import { TokenConfig } from 'config/types'; + +const useGetTokenBalances = ( + walletAddress: string, + chain: ChainName | undefined, + tokens: TokenConfig[], +): { isFetching: boolean; balances: Balances } => { + const [isFetching, setIsFetching] = useState(false); + const [balances, setBalances] = useState({}); + const cachedBalances = useSelector( + (state: RootState) => state.transferInput.balances, + ); + const dispatch = useDispatch(); + + useEffect(() => { + setIsFetching(true); + setBalances({}); + if ( + !walletAddress || + !chain || + !config.chains[chain] || + tokens.length === 0 + ) { + setIsFetching(false); + return; + } + const chainConfig = config.chains[chain]; + if (!chainConfig) { + setIsFetching(false); + return; + } + + let isActive = true; + + const getBalances = async () => { + const balances: Balances = {}; + type TokenConfigWithId = TokenConfig & { tokenId: TokenId }; + const needsUpdate: TokenConfigWithId[] = []; + const now = Date.now(); + const fiveMinutesAgo = now - 5 * 60 * 1000; + let updateCache = false; + for (const token of tokens) { + const cachedBalance = accessBalance( + cachedBalances, + walletAddress, + chain, + token.key, + ); + if (cachedBalance && cachedBalance.lastUpdated > fiveMinutesAgo) { + balances[token.key] = cachedBalance; + } else { + if (token.key === chainConfig.gasToken) { + try { + const balance = await config.wh.getNativeBalance( + walletAddress, + chain, + token.key, + ); + balances[token.key] = { + balance: formatBalance(chain, token, balance), + lastUpdated: now, + }; + updateCache = true; + } catch (e) { + console.error('Failed to get native balance', e); + } + } else if (token.tokenId) { + needsUpdate.push(token as TokenConfigWithId); + } + } + } + if (needsUpdate.length > 0) { + try { + const result = await config.wh.getTokenBalances( + walletAddress, + needsUpdate.map((t) => t.tokenId), + chain, + ); + result.forEach((balance, i) => { + const token = needsUpdate[i]; + balances[token.key] = { + balance: formatBalance(chain, token, balance), + lastUpdated: now, + }; + }); + updateCache = true; + } catch (e) { + console.error('Failed to get token balances', e); + } + } + if (isActive) { + setIsFetching(false); + setBalances(balances); + if (updateCache) { + dispatch( + updateBalances({ + address: walletAddress, + chain, + balances, + }), + ); + } + } + }; + + getBalances(); + + return () => { + isActive = false; + }; + }, [cachedBalances, walletAddress, chain, tokens]); + + return { isFetching, balances }; +}; + +export default useGetTokenBalances; diff --git a/wormhole-connect/src/store/transferInput.ts b/wormhole-connect/src/store/transferInput.ts index f4f14a412..6d829143e 100644 --- a/wormhole-connect/src/store/transferInput.ts +++ b/wormhole-connect/src/store/transferInput.ts @@ -23,9 +23,12 @@ import { isPorticoRoute } from 'routes/porticoBridge/utils'; import { isNttRoute } from 'routes'; import { getNttGroupKey, getNttTokenByGroupKey } from 'utils/ntt'; -export type Balances = { [key: string]: string | null }; +export type Balance = { + lastUpdated: number; + balance: string | null; +}; +export type Balances = { [key: string]: Balance }; export type ChainBalances = { - lastUpdated: number | undefined; balances: Balances; }; export type BalancesCache = { [key in ChainName]?: ChainBalances }; @@ -40,7 +43,7 @@ export const formatBalance = ( const decimals = getTokenDecimals(toChainId(chain), token.tokenId); const formattedBalance = balance !== null ? toDecimals(balance, decimals, 6) : null; - return { [token.key]: formattedBalance }; + return formattedBalance; }; // for use in USDC or other tokens that have versions on many chains @@ -75,9 +78,9 @@ export const accessBalance = ( walletAddress: WalletAddress | undefined, chain: ChainName | undefined, token: string, -): string | null => { +): Balance | undefined => { const chainBalances = accessChainBalances(balances, walletAddress, chain); - if (!chainBalances) return null; + if (!chainBalances) return undefined; return chainBalances.balances[token]; }; @@ -359,7 +362,7 @@ export const transferInputSlice = createSlice({ ) => { state.receiveAmount = errorDataWrapper(payload); }, - setBalances: ( + updateBalances: ( state: TransferInputState, { payload, @@ -371,17 +374,14 @@ export const transferInputSlice = createSlice({ ) => { const { chain, balances, address } = payload; if (!address) return; - const chainBalances = { - [chain]: { - lastUpdated: Date.now(), - balances, - }, + state.balances[address] ??= {}; + state.balances[address][chain] ??= { + balances: {}, + }; + state.balances[address][chain]!.balances = { + ...state.balances[address][chain]!.balances, + ...balances, }; - const currentWalletBallances = state.balances[address] || {}; - state.balances[address] = Object.assign( - currentWalletBallances, - chainBalances, - ); }, setReceiverNativeBalance: ( state: TransferInputState, @@ -546,7 +546,7 @@ export const { setTransferRoute, setSendingGasEst, setClaimGasEst, - setBalances, + updateBalances, clearTransfer, setIsTransactionInProgress, setReceiverNativeBalance, diff --git a/wormhole-connect/src/utils/transferValidation.ts b/wormhole-connect/src/utils/transferValidation.ts index 36cf0900e..5ea2c02ca 100644 --- a/wormhole-connect/src/utils/transferValidation.ts +++ b/wormhole-connect/src/utils/transferValidation.ts @@ -293,7 +293,7 @@ export const validateAll = async ( destToken: validateDestToken(destToken, toChain, supportedDestTokens), amount: validateAmount( amount, - sendingTokenBalance, + sendingTokenBalance?.balance || null, maxSendAmount, isCctpTx, ), diff --git a/wormhole-connect/src/views/Bridge/Inputs/From.tsx b/wormhole-connect/src/views/Bridge/Inputs/From.tsx index 9c45c3abd..dd502b800 100644 --- a/wormhole-connect/src/views/Bridge/Inputs/From.tsx +++ b/wormhole-connect/src/views/Bridge/Inputs/From.tsx @@ -8,7 +8,6 @@ import { selectFromChain, setAmount, setReceiveAmount, - accessBalance, setFetchingReceiveAmount, setReceiveAmountError, isDisabledChain, @@ -23,6 +22,7 @@ import AmountInput from './AmountInput'; import TokensModal from 'components/TokensModal'; import ChainsModal from 'components/ChainsModal'; import { isPorticoRoute } from 'routes/porticoBridge/utils'; +import useGetTokenBalances from 'hooks/useGetTokenBalances'; function FromInputs() { const dispatch = useDispatch(); @@ -44,15 +44,21 @@ function FromInputs() { route, fromChain, toChain, - balances, token, amount, isTransactionInProgress, destToken, } = useSelector((state: RootState) => state.transferInput); const tokenConfig = token && config.tokens[token]; - const balance = - accessBalance(balances, wallet.address, fromChain, token) || undefined; + const tokenConfigArr = useMemo( + () => (tokenConfig ? [tokenConfig] : []), + [tokenConfig], + ); + const { balances } = useGetTokenBalances( + wallet.address, + fromChain, + tokenConfigArr, + ); const isDisabled = useCallback( (chain: ChainName) => isDisabledChain(chain, wallet), @@ -184,7 +190,7 @@ function FromInputs() { onChainClick={() => setShowChainsModal(true)} tokenInput={tokenInput} amountInput={amountInput} - balance={balance} + balance={balances[token]?.balance || undefined} tokenPrice={getTokenPrice(prices, config.tokens[token])} /> {showTokensModal && ( diff --git a/wormhole-connect/src/views/Bridge/Inputs/To.tsx b/wormhole-connect/src/views/Bridge/Inputs/To.tsx index 2a8eaeff8..26178b124 100644 --- a/wormhole-connect/src/views/Bridge/Inputs/To.tsx +++ b/wormhole-connect/src/views/Bridge/Inputs/To.tsx @@ -4,7 +4,6 @@ import { ChainName } from '@wormhole-foundation/wormhole-connect-sdk'; import { RootState } from 'store'; import { - accessBalance, isDisabledChain, selectToChain, setDestToken, @@ -20,6 +19,7 @@ import TokenWarnings from './TokenWarnings'; import TokensModal from 'components/TokensModal'; import ChainsModal from 'components/ChainsModal'; import { isPorticoRoute } from 'routes/porticoBridge/utils'; +import useGetTokenBalances from 'hooks/useGetTokenBalances'; function ToInputs() { const dispatch = useDispatch(); @@ -31,7 +31,6 @@ function ToInputs() { validations, fromChain, toChain, - balances, destToken, receiveAmount, route, @@ -42,10 +41,16 @@ function ToInputs() { usdPrices: { data }, } = useSelector((state: RootState) => state.tokenPrices); const prices = data || {}; - const balance = - accessBalance(balances, receiving.address, toChain, destToken) || undefined; - const tokenConfig = config.tokens[destToken]; + const tokenConfigArr = useMemo( + () => (tokenConfig ? [tokenConfig] : []), + [tokenConfig], + ); + const { balances } = useGetTokenBalances( + receiving.address, + toChain, + tokenConfigArr, + ); const selectToken = (token: string) => { dispatch(setDestToken(token)); @@ -156,7 +161,7 @@ function ToInputs() { onChainClick={() => setShowChainsModal(true)} tokenInput={tokenInput} amountInput={amountInput} - balance={balance} + balance={balances[destToken]?.balance || undefined} warning={} tokenPrice={getTokenPrice(prices, config.tokens[destToken])} />