feat: [info] add multi-chain balances on TDP (#7493)

* feat: wip, [info] add TDP crosschain balances

* very wip new balances

* progress on balances

* wip new balance

* add todo for native tokens

* fix bridge info caching

* fix bridge info caching & clean up

* cleanup query logic

* remove pollinginterval enum change

* fix logo flickering

* minor comment cleanup

* more minor comment cleanup

* use gqlToCurrency instead

* css changes for balance box

* css changes for mobile balance summary footer

* fix apollo client caching tokens merge

* clarify comment

* make chainId required

* comment cleanup

* fix: balance fetch caching

* fix prefetchbalancewrapper css jank

* remove padding

* delete extraneous borderRadius

* update comment

* should not show balancecard at all if no balances

* rename to multichain

* changes to mobile bar css

* use surface1 theme background

* oops add back bottom-bar

* fix cypress tests ??

* revert change

* broken apollo merge??

* remove extraneous tokens call

* remove apollo merge for portfolio>tokens

* oops fix some pr review

* load portfolio balances as it updates

* pr review

* update comment linear ticket

* remove extraneous chainId prop

* increase timeout time

* should not do symbols check

* pr review

* pr review

* refactor multichainbalances into map

* remove address native

* nit pr review

* use portfoliobalance fragment

* fix typechecking gql

* TYPES

---------

Co-authored-by: cartcrom <cartergcromer@gmail.com>
This commit is contained in:
Kristie Huang 2023-11-27 13:40:19 -05:00 committed by GitHub
parent 4a5a41c59e
commit fc7ecc7e3b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 395 additions and 146 deletions

@ -110,7 +110,7 @@ const ActiveDot = styled.span<{ closed: boolean; outOfRange: boolean }>`
margin-top: 1px; margin-top: 1px;
` `
function calculcateLiquidityValue(price0: number | undefined, price1: number | undefined, position: Position) { function calculateLiquidityValue(price0: number | undefined, price1: number | undefined, position: Position) {
if (!price0 || !price1) return undefined if (!price0 || !price1) return undefined
const value0 = parseFloat(position.amount0.toExact()) * price0 const value0 = parseFloat(position.amount0.toExact()) * price0
@ -124,7 +124,7 @@ function PositionListItem({ positionInfo }: { positionInfo: PositionInfo }) {
const { chainId, position, pool, details, inRange, closed } = positionInfo const { chainId, position, pool, details, inRange, closed } = positionInfo
const { priceA, priceB, fees: feeValue } = useFeeValues(positionInfo) const { priceA, priceB, fees: feeValue } = useFeeValues(positionInfo)
const liquidityValue = calculcateLiquidityValue(priceA, priceB, position) const liquidityValue = calculateLiquidityValue(priceA, priceB, position)
const navigate = useNavigate() const navigate = useNavigate()
const toggleWalletDrawer = useToggleAccountDrawer() const toggleWalletDrawer = useToggleAccountDrawer()

@ -4,7 +4,8 @@ import { useCachedPortfolioBalancesQuery } from 'components/PrefetchBalancesWrap
import Row from 'components/Row' import Row from 'components/Row'
import { DeltaArrow } from 'components/Tokens/TokenDetails/Delta' import { DeltaArrow } from 'components/Tokens/TokenDetails/Delta'
import { useInfoExplorePageEnabled } from 'featureFlags/flags/infoExplore' import { useInfoExplorePageEnabled } from 'featureFlags/flags/infoExplore'
import { TokenBalance } from 'graphql/data/__generated__/types-and-hooks' import { PortfolioTokenBalancePartsFragment } from 'graphql/data/__generated__/types-and-hooks'
import { PortfolioToken } from 'graphql/data/portfolios'
import { getTokenDetailsURL, gqlToCurrency, logSentryErrorForUnsupportedChain } from 'graphql/data/util' import { getTokenDetailsURL, gqlToCurrency, logSentryErrorForUnsupportedChain } from 'graphql/data/util'
import { useAtomValue } from 'jotai/utils' import { useAtomValue } from 'jotai/utils'
import { EmptyWalletModule } from 'nft/components/profile/view/EmptyWalletContent' import { EmptyWalletModule } from 'nft/components/profile/view/EmptyWalletContent'
@ -28,7 +29,7 @@ export default function Tokens({ account }: { account: string }) {
const { data } = useCachedPortfolioBalancesQuery({ account }) const { data } = useCachedPortfolioBalancesQuery({ account })
const tokenBalances = data?.portfolios?.[0].tokenBalances as TokenBalance[] | undefined const tokenBalances = data?.portfolios?.[0].tokenBalances
const { visibleTokens, hiddenTokens } = useMemo( const { visibleTokens, hiddenTokens } = useMemo(
() => splitHiddenTokens(tokenBalances ?? [], { hideSmallBalances }), () => splitHiddenTokens(tokenBalances ?? [], { hideSmallBalances }),
@ -69,9 +70,12 @@ const TokenNameText = styled(ThemedText.SubHeader)`
${EllipsisStyle} ${EllipsisStyle}
` `
type PortfolioToken = NonNullable<TokenBalance['token']> function TokenRow({
token,
function TokenRow({ token, quantity, denominatedValue, tokenProjectMarket }: TokenBalance & { token: PortfolioToken }) { quantity,
denominatedValue,
tokenProjectMarket,
}: PortfolioTokenBalancePartsFragment & { token: PortfolioToken }) {
const { formatDelta } = useFormatter() const { formatDelta } = useFormatter()
const percentChange = tokenProjectMarket?.pricePercentChange?.value ?? 0 const percentChange = tokenProjectMarket?.pricePercentChange?.value ?? 0

@ -3,6 +3,7 @@ import { usePortfolioBalancesLazyQuery, usePortfolioBalancesQuery } from 'graphq
import { GQL_MAINNET_CHAINS } from 'graphql/data/util' import { GQL_MAINNET_CHAINS } from 'graphql/data/util'
import usePrevious from 'hooks/usePrevious' import usePrevious from 'hooks/usePrevious'
import { atom, useAtom } from 'jotai' import { atom, useAtom } from 'jotai'
import ms from 'ms'
import { PropsWithChildren, useCallback, useEffect } from 'react' import { PropsWithChildren, useCallback, useEffect } from 'react'
import { usePendingActivity } from '../AccountDrawer/MiniPortfolio/Activity/hooks' import { usePendingActivity } from '../AccountDrawer/MiniPortfolio/Activity/hooks'
@ -31,8 +32,9 @@ const hasUnfetchedBalancesAtom = atom<boolean>(true)
export default function PrefetchBalancesWrapper({ export default function PrefetchBalancesWrapper({
children, children,
shouldFetchOnAccountUpdate, shouldFetchOnAccountUpdate,
shouldFetchOnHover = true,
className, className,
}: PropsWithChildren<{ shouldFetchOnAccountUpdate: boolean; className?: string }>) { }: PropsWithChildren<{ shouldFetchOnAccountUpdate: boolean; shouldFetchOnHover?: boolean; className?: string }>) {
const { account } = useWeb3React() const { account } = useWeb3React()
const [prefetchPortfolioBalances] = usePortfolioBalancesLazyQuery() const [prefetchPortfolioBalances] = usePortfolioBalancesLazyQuery()
@ -40,8 +42,13 @@ export default function PrefetchBalancesWrapper({
const [hasUnfetchedBalances, setHasUnfetchedBalances] = useAtom(hasUnfetchedBalancesAtom) const [hasUnfetchedBalances, setHasUnfetchedBalances] = useAtom(hasUnfetchedBalancesAtom)
const fetchBalances = useCallback(() => { const fetchBalances = useCallback(() => {
if (account) { if (account) {
prefetchPortfolioBalances({ variables: { ownerAddress: account, chains: GQL_MAINNET_CHAINS } }) // Backend takes <2sec to get the updated portfolio value after a transaction
setHasUnfetchedBalances(false) // This timeout is an interim solution while we're working on a websocket that'll ping the client when connected account gets changes
// TODO(WEB-3131): remove this timeout after websocket is implemented
setTimeout(() => {
prefetchPortfolioBalances({ variables: { ownerAddress: account, chains: GQL_MAINNET_CHAINS } })
setHasUnfetchedBalances(false)
}, ms('3.5s'))
} }
}, [account, prefetchPortfolioBalances, setHasUnfetchedBalances]) }, [account, prefetchPortfolioBalances, setHasUnfetchedBalances])
@ -62,12 +69,18 @@ export default function PrefetchBalancesWrapper({
} }
}, [account, prevAccount, shouldFetchOnAccountUpdate, fetchBalances, hasUpdatedTx, setHasUnfetchedBalances]) }, [account, prevAccount, shouldFetchOnAccountUpdate, fetchBalances, hasUpdatedTx, setHasUnfetchedBalances])
// Temporary workaround to fix balances on TDP - this fetches balances if shouldFetchOnAccountUpdate becomes true while hasUnfetchedBalances is true
// TODO(WEB-3071) remove this logic once balance provider refactor is done
useEffect(() => {
if (hasUnfetchedBalances && shouldFetchOnAccountUpdate) fetchBalances()
}, [fetchBalances, hasUnfetchedBalances, shouldFetchOnAccountUpdate])
const onHover = useCallback(() => { const onHover = useCallback(() => {
if (hasUnfetchedBalances) fetchBalances() if (hasUnfetchedBalances) fetchBalances()
}, [fetchBalances, hasUnfetchedBalances]) }, [fetchBalances, hasUnfetchedBalances])
return ( return (
<div onMouseEnter={onHover} className={className}> <div onMouseEnter={shouldFetchOnHover ? onHover : undefined} className={className}>
{children} {children}
</div> </div>
) )

@ -5,7 +5,6 @@ import { ChainId, Currency, CurrencyAmount, Token } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core' import { useWeb3React } from '@web3-react/core'
import { Trace } from 'analytics' import { Trace } from 'analytics'
import { useCachedPortfolioBalancesQuery } from 'components/PrefetchBalancesWrapper/PrefetchBalancesWrapper' import { useCachedPortfolioBalancesQuery } from 'components/PrefetchBalancesWrapper/PrefetchBalancesWrapper'
import { TokenBalance } from 'graphql/data/__generated__/types-and-hooks'
import { supportedChainIdFromGQLChain } from 'graphql/data/util' import { supportedChainIdFromGQLChain } from 'graphql/data/util'
import useDebounce from 'hooks/useDebounce' import useDebounce from 'hooks/useDebounce'
import { useOnClickOutside } from 'hooks/useOnClickOutside' import { useOnClickOutside } from 'hooks/useOnClickOutside'
@ -101,7 +100,7 @@ export function CurrencySearch({
}, [chainId, data?.portfolios]) }, [chainId, data?.portfolios])
const sortedTokens: Token[] = useMemo(() => { const sortedTokens: Token[] = useMemo(() => {
const portfolioTokenBalances = data?.portfolios?.[0].tokenBalances as TokenBalance[] | undefined const portfolioTokenBalances = data?.portfolios?.[0].tokenBalances
const portfolioTokens = splitHiddenTokens(portfolioTokenBalances ?? []) const portfolioTokens = splitHiddenTokens(portfolioTokenBalances ?? [])
.visibleTokens.map((tokenBalance) => { .visibleTokens.map((tokenBalance) => {
if (!tokenBalance?.token?.chain || !tokenBalance.token?.address || !tokenBalance.token?.decimals) { if (!tokenBalance?.token?.chain || !tokenBalance.token?.address || !tokenBalance.token?.decimals) {

@ -1,22 +1,30 @@
import { Trans } from '@lingui/macro' import { Trans } from '@lingui/macro'
import { ChainId, Currency } from '@uniswap/sdk-core' import { ChainId, Currency, CurrencyAmount } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core' import { useWeb3React } from '@web3-react/core'
import { PortfolioLogo } from 'components/AccountDrawer/MiniPortfolio/PortfolioLogo' import { PortfolioLogo } from 'components/AccountDrawer/MiniPortfolio/PortfolioLogo'
import { getChainInfo } from 'constants/chainInfo' import { getChainInfo } from 'constants/chainInfo'
import { asSupportedChain } from 'constants/chains' import { asSupportedChain } from 'constants/chains'
import { useInfoExplorePageEnabled } from 'featureFlags/flags/infoExplore'
import { useInfoTDPEnabled } from 'featureFlags/flags/infoTDP'
import { Chain, PortfolioTokenBalancePartsFragment } from 'graphql/data/__generated__/types-and-hooks'
import { getTokenDetailsURL, gqlToCurrency, supportedChainIdFromGQLChain } from 'graphql/data/util'
import { useStablecoinValue } from 'hooks/useStablecoinPrice' import { useStablecoinValue } from 'hooks/useStablecoinPrice'
import useCurrencyBalance from 'lib/hooks/useCurrencyBalance' import useCurrencyBalance from 'lib/hooks/useCurrencyBalance'
import { useMemo } from 'react' import { useMemo } from 'react'
import styled, { useTheme } from 'styled-components' import { useNavigate } from 'react-router-dom'
import styled from 'styled-components'
import { ThemedText } from 'theme/components' import { ThemedText } from 'theme/components'
import { NumberType, useFormatter } from 'utils/formatNumbers' import { NumberType, useFormatter } from 'utils/formatNumbers'
const BalancesCard = styled.div` import { MultiChainMap } from '.'
border-radius: 16px;
const BalancesCard = styled.div<{ isInfoTDPEnabled?: boolean }>`
color: ${({ theme }) => theme.neutral1}; color: ${({ theme }) => theme.neutral1};
display: none; display: flex;
flex-direction: column;
gap: 24px;
height: fit-content; height: fit-content;
padding: 16px; ${({ isInfoTDPEnabled }) => !isInfoTDPEnabled && 'padding: 16px;'}
width: 100%; width: 100%;
// 768 hardcoded to match NFT-redesign navbar breakpoints // 768 hardcoded to match NFT-redesign navbar breakpoints
@ -48,11 +56,13 @@ const BalanceContainer = styled.div`
flex: 1; flex: 1;
` `
const BalanceAmountsContainer = styled.div` const BalanceAmountsContainer = styled.div<{ isInfoTDPEnabled?: boolean }>`
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
width: 100%;
${({ isInfoTDPEnabled }) => isInfoTDPEnabled && 'margin-left: 12px;'}
` `
const StyledNetworkLabel = styled.div` const StyledNetworkLabel = styled.div`
@ -61,49 +71,187 @@ const StyledNetworkLabel = styled.div`
line-height: 16px; line-height: 16px;
` `
export default function BalanceSummary({ token }: { token: Currency }) { interface BalanceProps {
const { account, chainId } = useWeb3React() currency?: Currency
const theme = useTheme() chainId?: ChainId
const { label, color } = getChainInfo(asSupportedChain(chainId) ?? ChainId.MAINNET) balance?: CurrencyAmount<Currency> // TODO(WEB-3026): only used for pre-Info-project calculations, should remove after project goes live
const balance = useCurrencyBalance(account, token) gqlBalance?: PortfolioTokenBalancePartsFragment
const { formatCurrencyAmount } = useFormatter() onClick?: () => void
}
const Balance = ({ currency, chainId = ChainId.MAINNET, balance, gqlBalance, onClick }: BalanceProps) => {
const { formatCurrencyAmount, formatNumber } = useFormatter()
const { label: chainName, color } = getChainInfo(asSupportedChain(chainId) ?? ChainId.MAINNET)
const currencies = useMemo(() => [currency], [currency])
const isInfoTDPEnabled = useInfoExplorePageEnabled()
const formattedBalance = formatCurrencyAmount({ const formattedBalance = formatCurrencyAmount({
amount: balance, amount: balance,
type: NumberType.TokenNonTx, type: NumberType.TokenNonTx,
}) })
const formattedUsdValue = formatCurrencyAmount({ const formattedUsdValue = formatCurrencyAmount({
amount: useStablecoinValue(balance), amount: useStablecoinValue(balance),
type: NumberType.FiatTokenStats, type: NumberType.PortfolioBalance,
})
const formattedGqlBalance = formatNumber({
input: gqlBalance?.quantity,
type: NumberType.TokenNonTx,
})
const formattedUsdGqlValue = formatNumber({
input: gqlBalance?.denominatedValue?.value,
type: NumberType.PortfolioBalance,
}) })
const currencies = useMemo(() => [token], [token]) if (isInfoTDPEnabled) {
return (
<BalanceRow onClick={onClick}>
<PortfolioLogo currencies={currencies} chainId={chainId} size="2rem" />
<BalanceAmountsContainer isInfoTDPEnabled>
<BalanceItem>
<ThemedText.BodyPrimary>{formattedUsdGqlValue}</ThemedText.BodyPrimary>
</BalanceItem>
<BalanceItem>
<ThemedText.BodySecondary>{formattedGqlBalance}</ThemedText.BodySecondary>
</BalanceItem>
</BalanceAmountsContainer>
</BalanceRow>
)
} else {
return (
<BalanceRow>
<PortfolioLogo currencies={currencies} chainId={chainId} size="2rem" />
<BalanceContainer>
<BalanceAmountsContainer>
<BalanceItem>
<ThemedText.SubHeader>
{formattedBalance} {currency?.symbol}
</ThemedText.SubHeader>
</BalanceItem>
<BalanceItem>
<ThemedText.BodyPrimary>{formattedUsdValue}</ThemedText.BodyPrimary>
</BalanceItem>
</BalanceAmountsContainer>
<StyledNetworkLabel color={color}>{chainName}</StyledNetworkLabel>
</BalanceContainer>
</BalanceRow>
)
}
}
if (!account || !balance) { const ConnectedChainBalanceSummary = ({
connectedChainBalance,
}: {
connectedChainBalance?: CurrencyAmount<Currency>
}) => {
const { chainId: connectedChainId } = useWeb3React()
if (!connectedChainId || !connectedChainBalance || !connectedChainBalance.greaterThan(0)) return null
const token = connectedChainBalance.currency
const { label: chainName } = getChainInfo(asSupportedChain(connectedChainId) ?? ChainId.MAINNET)
return (
<BalanceSection>
<ThemedText.SubHeaderSmall color="neutral1">
<Trans>Your balance on {chainName}</Trans>
</ThemedText.SubHeaderSmall>
<Balance currency={token} chainId={connectedChainId} balance={connectedChainBalance} />
</BalanceSection>
)
}
const PageChainBalanceSummary = ({ pageChainBalance }: { pageChainBalance?: PortfolioTokenBalancePartsFragment }) => {
if (!pageChainBalance || !pageChainBalance.token) return null
const currency = gqlToCurrency(pageChainBalance.token)
return (
<BalanceSection>
<ThemedText.HeadlineSmall color="neutral1">
<Trans>Your balance</Trans>
</ThemedText.HeadlineSmall>
<Balance currency={currency} chainId={currency?.chainId} gqlBalance={pageChainBalance} />
</BalanceSection>
)
}
const OtherChainsBalanceSummary = ({
otherChainBalances,
hasPageChainBalance,
}: {
otherChainBalances: readonly PortfolioTokenBalancePartsFragment[]
hasPageChainBalance: boolean
}) => {
const navigate = useNavigate()
const isInfoExplorePageEnabled = useInfoExplorePageEnabled()
if (!otherChainBalances.length) return null
return (
<BalanceSection>
{hasPageChainBalance ? (
<ThemedText.SubHeaderSmall>
<Trans>On other networks</Trans>
</ThemedText.SubHeaderSmall>
) : (
<ThemedText.HeadlineSmall>
<Trans>Balance on other networks</Trans>
</ThemedText.HeadlineSmall>
)}
{otherChainBalances.map((balance) => {
const currency = balance.token && gqlToCurrency(balance.token)
const chainId = (balance.token && supportedChainIdFromGQLChain(balance.token.chain)) ?? ChainId.MAINNET
return (
<Balance
key={balance.id}
currency={currency}
chainId={chainId}
gqlBalance={balance}
onClick={() =>
navigate(
getTokenDetailsURL({
address: balance.token?.address,
chain: balance.token?.chain ?? Chain.Ethereum,
isInfoExplorePageEnabled,
})
)
}
/>
)
})}
</BalanceSection>
)
}
export default function BalanceSummary({
currency,
chain,
multiChainMap,
}: {
currency: Currency
chain: Chain
multiChainMap: MultiChainMap
}) {
const { account } = useWeb3React()
const isInfoTDPEnabled = useInfoTDPEnabled()
const connectedChainBalance = useCurrencyBalance(account, currency)
const pageChainBalance = multiChainMap[chain].balance
const otherChainBalances: PortfolioTokenBalancePartsFragment[] = []
for (const [key, value] of Object.entries(multiChainMap)) {
if (key !== chain && value.balance !== undefined) {
otherChainBalances.push(value.balance)
}
}
const hasBalances = pageChainBalance || Boolean(otherChainBalances.length)
if (!account || !hasBalances) {
return null return null
} }
return ( return (
<BalancesCard> <BalancesCard isInfoTDPEnabled={isInfoTDPEnabled}>
<BalanceSection> {!isInfoTDPEnabled && <ConnectedChainBalanceSummary connectedChainBalance={connectedChainBalance} />}
<ThemedText.SubHeaderSmall color={theme.neutral1}> {isInfoTDPEnabled && (
<Trans>Your balance on {label}</Trans> <>
</ThemedText.SubHeaderSmall> <PageChainBalanceSummary pageChainBalance={pageChainBalance} />
<BalanceRow> <OtherChainsBalanceSummary otherChainBalances={otherChainBalances} hasPageChainBalance={!!pageChainBalance} />
<PortfolioLogo currencies={currencies} chainId={token.chainId} size="2rem" /> </>
<BalanceContainer> )}
<BalanceAmountsContainer>
<BalanceItem>
<ThemedText.SubHeader>
{formattedBalance} {token.symbol}
</ThemedText.SubHeader>
</BalanceItem>
<BalanceItem>
<ThemedText.BodyPrimary>{formattedUsdValue}</ThemedText.BodyPrimary>
</BalanceItem>
</BalanceAmountsContainer>
<StyledNetworkLabel color={color}>{label}</StyledNetworkLabel>
</BalanceContainer>
</BalanceRow>
</BalanceSection>
</BalancesCard> </BalancesCard>
) )
} }

@ -2,21 +2,20 @@ import { Trans } from '@lingui/macro'
import { Currency } from '@uniswap/sdk-core' import { Currency } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core' import { useWeb3React } from '@web3-react/core'
import { NATIVE_CHAIN_ID } from 'constants/tokens' import { NATIVE_CHAIN_ID } from 'constants/tokens'
import { useInfoTDPEnabled } from 'featureFlags/flags/infoTDP'
import { PortfolioTokenBalancePartsFragment } from 'graphql/data/__generated__/types-and-hooks'
import { CHAIN_ID_TO_BACKEND_NAME } from 'graphql/data/util' import { CHAIN_ID_TO_BACKEND_NAME } from 'graphql/data/util'
import { useStablecoinValue } from 'hooks/useStablecoinPrice' import { useStablecoinValue } from 'hooks/useStablecoinPrice'
import useCurrencyBalance from 'lib/hooks/useCurrencyBalance' import useCurrencyBalance from 'lib/hooks/useCurrencyBalance'
import styled from 'styled-components' import styled, { css } from 'styled-components'
import { StyledInternalLink } from 'theme/components' import { StyledInternalLink, ThemedText } from 'theme/components'
import { NumberType, useFormatter } from 'utils/formatNumbers' import { NumberType, useFormatter } from 'utils/formatNumbers'
const Wrapper = styled.div` const Wrapper = styled.div<{ isInfoTDPEnabled?: boolean }>`
align-content: center; align-content: center;
align-items: center; align-items: center;
border: 1px solid ${({ theme }) => theme.surface3};
border-bottom: none;
background-color: ${({ theme }) => theme.surface1}; background-color: ${({ theme }) => theme.surface1};
border-radius: 20px 20px 0px 0px; border: 1px solid ${({ theme }) => theme.surface3};
bottom: 52px;
color: ${({ theme }) => theme.neutral2}; color: ${({ theme }) => theme.neutral2};
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -26,9 +25,24 @@ const Wrapper = styled.div`
justify-content: space-between; justify-content: space-between;
left: 0; left: 0;
line-height: 20px; line-height: 20px;
padding: 12px 16px;
position: fixed; position: fixed;
width: 100%;
${({ isInfoTDPEnabled }) =>
isInfoTDPEnabled
? css`
border-radius: 20px;
bottom: 56px;
margin: 8px;
padding: 12px 32px;
width: calc(100vw - 16px);
`
: css`
border-bottom: none;
border-radius: 20px 20px 0px 0px;
bottom: 52px;
padding: 12px 16px;
width: 100%;
`}
@media screen and (min-width: ${({ theme }) => theme.breakpoint.md}px) { @media screen and (min-width: ${({ theme }) => theme.breakpoint.md}px) {
bottom: 0px; bottom: 0px;
@ -37,27 +51,29 @@ const Wrapper = styled.div`
display: none; display: none;
} }
` `
const BalanceValue = styled.div` const BalanceValue = styled.div<{ isInfoTDPEnabled?: boolean }>`
color: ${({ theme }) => theme.neutral1}; color: ${({ theme }) => theme.neutral1};
font-size: 20px; font-size: 20px;
line-height: 28px; line-height: ${({ isInfoTDPEnabled }) => (isInfoTDPEnabled ? '20px' : '28px')};
display: flex; display: flex;
gap: 8px; gap: 8px;
` `
const Balance = styled.div` const Balance = styled.div<{ isInfoTDPEnabled?: boolean }>`
align-items: center; align-items: ${({ isInfoTDPEnabled }) => (isInfoTDPEnabled ? 'flex-end' : 'center')};
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
` `
const BalanceInfo = styled.div` const BalanceInfo = styled.div<{ isInfoTDPEnabled?: boolean }>`
display: flex; display: flex;
flex: 10 1 auto; flex: 10 1 auto;
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
${({ isInfoTDPEnabled }) => isInfoTDPEnabled && 'gap: 6px;'}
` `
const FiatValue = styled.span` const FiatValue = styled(ThemedText.Caption)<{ isInfoTDPEnabled?: boolean }>`
${({ isInfoTDPEnabled, theme }) => !isInfoTDPEnabled && `color: ${theme.neutral2};`}
font-size: 12px; font-size: 12px;
line-height: 16px; line-height: 16px;
@ -65,15 +81,15 @@ const FiatValue = styled.span`
line-height: 24px; line-height: 24px;
} }
` `
const SwapButton = styled(StyledInternalLink)` const SwapButton = styled(StyledInternalLink)<{ isInfoTDPEnabled?: boolean }>`
background-color: ${({ theme }) => theme.accent1}; background-color: ${({ theme }) => theme.accent1};
border: none; border: none;
border-radius: 12px; border-radius: ${({ isInfoTDPEnabled }) => (isInfoTDPEnabled ? '22px' : '12px')};
color: ${({ theme }) => theme.deprecated_accentTextLightPrimary}; color: ${({ theme }) => theme.deprecated_accentTextLightPrimary};
display: flex; display: flex;
flex: 1 1 auto; flex: 1 1 auto;
padding: 12px 16px; padding: 12px 16px;
font-size: 1em; font-size: ${({ isInfoTDPEnabled }) => (isInfoTDPEnabled ? '16px' : '1em')};
font-weight: 535; font-weight: 535;
height: 44px; height: 44px;
justify-content: center; justify-content: center;
@ -81,10 +97,18 @@ const SwapButton = styled(StyledInternalLink)`
max-width: 100vw; max-width: 100vw;
` `
export default function MobileBalanceSummaryFooter({ token }: { token: Currency }) { export default function MobileBalanceSummaryFooter({
currency,
pageChainBalance,
}: {
currency: Currency
pageChainBalance?: PortfolioTokenBalancePartsFragment
}) {
const isInfoTDPEnabled = useInfoTDPEnabled()
const { account } = useWeb3React() const { account } = useWeb3React()
const balance = useCurrencyBalance(account, token) const balance = useCurrencyBalance(account, currency)
const { formatCurrencyAmount } = useFormatter() const { formatCurrencyAmount, formatNumber } = useFormatter()
const formattedBalance = formatCurrencyAmount({ const formattedBalance = formatCurrencyAmount({
amount: balance, amount: balance,
type: NumberType.TokenNonTx, type: NumberType.TokenNonTx,
@ -93,22 +117,35 @@ export default function MobileBalanceSummaryFooter({ token }: { token: Currency
amount: useStablecoinValue(balance), amount: useStablecoinValue(balance),
type: NumberType.FiatTokenStats, type: NumberType.FiatTokenStats,
}) })
const chain = CHAIN_ID_TO_BACKEND_NAME[token.chainId].toLowerCase() const formattedGqlBalance = formatNumber({
input: pageChainBalance?.quantity,
type: NumberType.TokenNonTx,
})
const formattedUsdGqlValue = formatNumber({
input: pageChainBalance?.denominatedValue?.value,
type: NumberType.PortfolioBalance,
})
const chain = CHAIN_ID_TO_BACKEND_NAME[currency.chainId].toLowerCase()
return ( return (
<Wrapper> <Wrapper isInfoTDPEnabled={isInfoTDPEnabled}>
{Boolean(account && balance) && ( {Boolean(account && (isInfoTDPEnabled ? pageChainBalance : balance)) && (
<BalanceInfo> <BalanceInfo isInfoTDPEnabled={isInfoTDPEnabled}>
<Trans>Your {token.symbol} balance</Trans> {isInfoTDPEnabled ? <Trans>Your balance</Trans> : <Trans>Your {currency.symbol} balance</Trans>}
<Balance> <Balance isInfoTDPEnabled={isInfoTDPEnabled}>
<BalanceValue> <BalanceValue isInfoTDPEnabled={isInfoTDPEnabled}>
{formattedBalance} {token.symbol} {isInfoTDPEnabled ? formattedGqlBalance : formattedBalance} {currency.symbol}
</BalanceValue> </BalanceValue>
<FiatValue>{formattedUsdValue}</FiatValue> <FiatValue isInfoTDPEnabled={isInfoTDPEnabled}>
{isInfoTDPEnabled ? `(${formattedUsdGqlValue})` : formattedUsdValue}
</FiatValue>
</Balance> </Balance>
</BalanceInfo> </BalanceInfo>
)} )}
<SwapButton to={`/swap?chainName=${chain}&outputCurrency=${token.isNative ? NATIVE_CHAIN_ID : token.address}`}> <SwapButton
isInfoTDPEnabled={isInfoTDPEnabled}
to={`/swap?chainName=${chain}&outputCurrency=${currency.isNative ? NATIVE_CHAIN_ID : currency.address}`}
>
<Trans>Swap</Trans> <Trans>Swap</Trans>
</SwapButton> </SwapButton>
</Wrapper> </Wrapper>

@ -3,12 +3,11 @@ import { InterfacePageName } from '@uniswap/analytics-events'
import { useWeb3React } from '@web3-react/core' import { useWeb3React } from '@web3-react/core'
import { Trace } from 'analytics' import { Trace } from 'analytics'
import { PortfolioLogo } from 'components/AccountDrawer/MiniPortfolio/PortfolioLogo' import { PortfolioLogo } from 'components/AccountDrawer/MiniPortfolio/PortfolioLogo'
import { useCachedPortfolioBalancesQuery } from 'components/PrefetchBalancesWrapper/PrefetchBalancesWrapper'
import { AboutSection } from 'components/Tokens/TokenDetails/About' import { AboutSection } from 'components/Tokens/TokenDetails/About'
import AddressSection from 'components/Tokens/TokenDetails/AddressSection' import AddressSection from 'components/Tokens/TokenDetails/AddressSection'
import BalanceSummary from 'components/Tokens/TokenDetails/BalanceSummary'
import { BreadcrumbNav, BreadcrumbNavLink } from 'components/Tokens/TokenDetails/BreadcrumbNavLink' import { BreadcrumbNav, BreadcrumbNavLink } from 'components/Tokens/TokenDetails/BreadcrumbNavLink'
import ChartSection from 'components/Tokens/TokenDetails/ChartSection' import ChartSection from 'components/Tokens/TokenDetails/ChartSection'
import MobileBalanceSummaryFooter from 'components/Tokens/TokenDetails/MobileBalanceSummaryFooter'
import ShareButton from 'components/Tokens/TokenDetails/ShareButton' import ShareButton from 'components/Tokens/TokenDetails/ShareButton'
import TokenDetailsSkeleton, { import TokenDetailsSkeleton, {
Hr, Hr,
@ -25,8 +24,13 @@ import { NATIVE_CHAIN_ID, nativeOnChain } from 'constants/tokens'
import { checkWarning } from 'constants/tokenSafety' import { checkWarning } from 'constants/tokenSafety'
import { useInfoExplorePageEnabled } from 'featureFlags/flags/infoExplore' import { useInfoExplorePageEnabled } from 'featureFlags/flags/infoExplore'
import { useInfoTDPEnabled } from 'featureFlags/flags/infoTDP' import { useInfoTDPEnabled } from 'featureFlags/flags/infoTDP'
import { TokenPriceQuery } from 'graphql/data/__generated__/types-and-hooks' import {
import { Chain, TokenQuery, TokenQueryData } from 'graphql/data/Token' Chain,
PortfolioTokenBalancePartsFragment,
TokenPriceQuery,
TokenQuery,
} from 'graphql/data/__generated__/types-and-hooks'
import { TokenQueryData } from 'graphql/data/Token'
import { getTokenDetailsURL, gqlToCurrency, InterfaceGqlChain, supportedChainIdFromGQLChain } from 'graphql/data/util' import { getTokenDetailsURL, gqlToCurrency, InterfaceGqlChain, supportedChainIdFromGQLChain } from 'graphql/data/util'
import { useOnGlobalChainSwitch } from 'hooks/useGlobalChainSwitch' import { useOnGlobalChainSwitch } from 'hooks/useGlobalChainSwitch'
import { UNKNOWN_TOKEN_SYMBOL, useTokenFromActiveNetwork } from 'lib/hooks/useCurrency' import { UNKNOWN_TOKEN_SYMBOL, useTokenFromActiveNetwork } from 'lib/hooks/useCurrency'
@ -41,7 +45,9 @@ import { CopyContractAddress } from 'theme/components'
import { isAddress, shortenAddress } from 'utils' import { isAddress, shortenAddress } from 'utils'
import { addressesAreEquivalent } from 'utils/addressesAreEquivalent' import { addressesAreEquivalent } from 'utils/addressesAreEquivalent'
import BalanceSummary from './BalanceSummary'
import InvalidTokenDetails from './InvalidTokenDetails' import InvalidTokenDetails from './InvalidTokenDetails'
import MobileBalanceSummaryFooter from './MobileBalanceSummaryFooter'
import { TokenDescription } from './TokenDescription' import { TokenDescription } from './TokenDescription'
const TokenSymbol = styled.span` const TokenSymbol = styled.span`
@ -94,7 +100,7 @@ function useRelevantToken(
[onChainToken, queryToken] [onChainToken, queryToken]
) )
} }
export type MultiChainMap = { [chain: string]: { address?: string; balance?: PortfolioTokenBalancePartsFragment } }
type TokenDetailsProps = { type TokenDetailsProps = {
urlAddress?: string urlAddress?: string
inputTokenAddress?: string inputTokenAddress?: string
@ -117,17 +123,25 @@ export default function TokenDetails({
[urlAddress] [urlAddress]
) )
const { chainId: connectedChainId } = useWeb3React() const { account, chainId: connectedChainId } = useWeb3React()
const pageChainId = supportedChainIdFromGQLChain(chain) const pageChainId = supportedChainIdFromGQLChain(chain)
const tokenQueryData = tokenQuery.token const tokenQueryData = tokenQuery.token
const crossChainMap = useMemo( const { data: balanceQuery } = useCachedPortfolioBalancesQuery({ account })
() => const multiChainMap = useMemo(() => {
tokenQueryData?.project?.tokens.reduce((map, current) => { const tokenBalances = balanceQuery?.portfolios?.[0].tokenBalances
if (current) map[current.chain] = current.address const tokensAcrossChains = tokenQueryData?.project?.tokens
return map if (!tokensAcrossChains) return {}
}, {} as { [key: string]: string | undefined }) ?? {}, return tokensAcrossChains.reduce((map, current) => {
[tokenQueryData] if (current) {
) if (!map[current.chain]) {
map[current.chain] = {}
}
map[current.chain].address = current.address
map[current.chain].balance = tokenBalances?.find((tokenBalance) => tokenBalance.token?.id === current.id)
}
return map
}, {} as MultiChainMap)
}, [balanceQuery?.portfolios, tokenQueryData?.project?.tokens])
const { token: detailedToken, didFetchFromChain } = useRelevantToken(address, pageChainId, tokenQueryData) const { token: detailedToken, didFetchFromChain } = useRelevantToken(address, pageChainId, tokenQueryData)
@ -143,7 +157,7 @@ export default function TokenDetails({
const navigateToTokenForChain = useCallback( const navigateToTokenForChain = useCallback(
(update: Chain) => { (update: Chain) => {
if (!address) return if (!address) return
const bridgedAddress = crossChainMap[update] const bridgedAddress = multiChainMap[update].address
if (bridgedAddress) { if (bridgedAddress) {
startTokenTransition(() => startTokenTransition(() =>
navigate( navigate(
@ -158,7 +172,7 @@ export default function TokenDetails({
startTokenTransition(() => navigate(getTokenDetailsURL({ address, chain: update, isInfoExplorePageEnabled }))) startTokenTransition(() => navigate(getTokenDetailsURL({ address, chain: update, isInfoExplorePageEnabled })))
} }
}, },
[address, crossChainMap, didFetchFromChain, detailedToken?.isNative, navigate, isInfoExplorePageEnabled] [address, multiChainMap, didFetchFromChain, detailedToken?.isNative, navigate, isInfoExplorePageEnabled]
) )
useOnGlobalChainSwitch(navigateToTokenForChain) useOnGlobalChainSwitch(navigateToTokenForChain)
@ -283,7 +297,7 @@ export default function TokenDetails({
/> />
</div> </div>
{tokenWarning && <TokenSafetyMessage tokenAddress={address} warning={tokenWarning} />} {tokenWarning && <TokenSafetyMessage tokenAddress={address} warning={tokenWarning} />}
{!isInfoTDPEnabled && detailedToken && <BalanceSummary token={detailedToken} />} {detailedToken && <BalanceSummary currency={detailedToken} chain={chain} multiChainMap={multiChainMap} />}
{isInfoTDPEnabled && ( {isInfoTDPEnabled && (
<TokenDescription <TokenDescription
tokenAddress={address} tokenAddress={address}
@ -293,7 +307,9 @@ export default function TokenDetails({
/> />
)} )}
</RightPanel> </RightPanel>
{!isInfoTDPEnabled && detailedToken && <MobileBalanceSummaryFooter token={detailedToken} />} {detailedToken && (
<MobileBalanceSummaryFooter currency={detailedToken} pageChainBalance={multiChainMap[chain].balance} />
)}
<TokenSafetyModal <TokenSafetyModal
isOpen={openTokenSafetyModal || !!continueSwap} isOpen={openTokenSafetyModal || !!continueSwap}

@ -97,7 +97,4 @@ gql`
} }
} }
` `
export type { Chain, TokenQuery } from './__generated__/types-and-hooks'
export type TokenQueryData = TokenQuery['token'] export type TokenQueryData = TokenQuery['token']

@ -49,6 +49,22 @@ export const apolloClient = new ApolloClient({
}, },
}, },
}, },
TokenProject: {
fields: {
tokens: {
// cache data may be lost when replacing the tokens array
merge(existing, incoming) {
if (!existing) {
return incoming
} else if (Array.isArray(existing)) {
return [...existing, ...incoming]
} else {
return [existing, ...incoming]
}
},
},
},
},
}, },
}), }),
defaultOptions: { defaultOptions: {

@ -1,6 +1,39 @@
import gql from 'graphql-tag' import gql from 'graphql-tag'
import { PortfolioTokenBalancePartsFragment } from './__generated__/types-and-hooks'
gql` gql`
fragment PortfolioTokenBalanceParts on TokenBalance {
id
quantity
denominatedValue {
id
currency
value
}
token {
id
chain
address
name
symbol
standard
decimals
}
tokenProjectMarket {
id
pricePercentChange(duration: DAY) {
id
value
}
tokenProject {
id
logoUrl
isSpam
}
}
}
query PortfolioBalances($ownerAddress: String!, $chains: [Chain!]!) { query PortfolioBalances($ownerAddress: String!, $chains: [Chain!]!) {
portfolios(ownerAddresses: [$ownerAddress], chains: $chains) { portfolios(ownerAddresses: [$ownerAddress], chains: $chains) {
id id
@ -19,35 +52,10 @@ gql`
} }
} }
tokenBalances { tokenBalances {
id ...PortfolioTokenBalanceParts
quantity
denominatedValue {
id
currency
value
}
tokenProjectMarket {
id
pricePercentChange(duration: DAY) {
id
value
}
tokenProject {
id
logoUrl
isSpam
}
}
token {
id
chain
address
name
symbol
standard
decimals
}
} }
} }
} }
` `
export type PortfolioToken = NonNullable<PortfolioTokenBalancePartsFragment['token']>

@ -1,5 +1,5 @@
import { useWeb3React } from '@web3-react/core' import { useWeb3React } from '@web3-react/core'
import { Chain } from 'graphql/data/Token' import { Chain } from 'graphql/data/__generated__/types-and-hooks'
import { chainIdToBackendName } from 'graphql/data/util' import { chainIdToBackendName } from 'graphql/data/util'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'

@ -108,6 +108,10 @@ export function useTokenBalance(account?: string, token?: Token): CurrencyAmount
return tokenBalances[token.address] return tokenBalances[token.address]
} }
/**
* Returns balances for tokens on currently-connected chainId via RPC.
* See useCachedPortfolioBalancesQuery for multichain portfolio balances via GQL.
*/
export function useCurrencyBalances( export function useCurrencyBalances(
account?: string, account?: string,
currencies?: (Currency | undefined)[] currencies?: (Currency | undefined)[]

@ -1,3 +1,4 @@
import PrefetchBalancesWrapper from 'components/PrefetchBalancesWrapper/PrefetchBalancesWrapper'
import TokenDetails from 'components/Tokens/TokenDetails' import TokenDetails from 'components/Tokens/TokenDetails'
import { TokenDetailsPageSkeleton } from 'components/Tokens/TokenDetails/Skeleton' import { TokenDetailsPageSkeleton } from 'components/Tokens/TokenDetails/Skeleton'
import { NATIVE_CHAIN_ID } from 'constants/tokens' import { NATIVE_CHAIN_ID } from 'constants/tokens'
@ -7,10 +8,15 @@ import useParsedQueryString from 'hooks/useParsedQueryString'
import { atomWithStorage, useAtomValue } from 'jotai/utils' import { atomWithStorage, useAtomValue } from 'jotai/utils'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import styled from 'styled-components'
import { getNativeTokenDBAddress } from 'utils/nativeTokens' import { getNativeTokenDBAddress } from 'utils/nativeTokens'
export const pageTimePeriodAtom = atomWithStorage<TimePeriod>('tokenDetailsTimePeriod', TimePeriod.DAY) export const pageTimePeriodAtom = atomWithStorage<TimePeriod>('tokenDetailsTimePeriod', TimePeriod.DAY)
const StyledPrefetchBalancesWrapper = styled(PrefetchBalancesWrapper)`
display: contents;
`
export default function TokenDetailsPage() { export default function TokenDetailsPage() {
const { tokenAddress, chainName } = useParams<{ const { tokenAddress, chainName } = useParams<{
tokenAddress: string tokenAddress: string
@ -58,12 +64,14 @@ export default function TokenDetailsPage() {
if (!tokenQuery) return <TokenDetailsPageSkeleton /> if (!tokenQuery) return <TokenDetailsPageSkeleton />
return ( return (
<TokenDetails <StyledPrefetchBalancesWrapper shouldFetchOnAccountUpdate={true} shouldFetchOnHover={false}>
urlAddress={tokenAddress} <TokenDetails
chain={chain} urlAddress={tokenAddress}
tokenQuery={tokenQuery} chain={chain}
tokenPriceQuery={currentPriceQuery} tokenQuery={tokenQuery}
inputTokenAddress={parsedInputTokenAddress} tokenPriceQuery={currentPriceQuery}
/> inputTokenAddress={parsedInputTokenAddress}
/>
</StyledPrefetchBalancesWrapper>
) )
} }

@ -1,7 +1,6 @@
import { ChainId, Currency } from '@uniswap/sdk-core' import { ChainId, Currency } from '@uniswap/sdk-core'
import { NATIVE_CHAIN_ID } from 'constants/tokens' import { NATIVE_CHAIN_ID } from 'constants/tokens'
import { TokenStandard } from 'graphql/data/__generated__/types-and-hooks' import { Chain, TokenStandard } from 'graphql/data/__generated__/types-and-hooks'
import { Chain } from 'graphql/data/Token'
import { supportedChainIdFromGQLChain } from 'graphql/data/util' import { supportedChainIdFromGQLChain } from 'graphql/data/util'
export type CurrencyKey = string export type CurrencyKey = string

@ -1,15 +1,15 @@
import { TokenBalance, TokenStandard } from 'graphql/data/__generated__/types-and-hooks' import { PortfolioTokenBalancePartsFragment, TokenStandard } from 'graphql/data/__generated__/types-and-hooks'
const HIDE_SMALL_USD_BALANCES_THRESHOLD = 1 const HIDE_SMALL_USD_BALANCES_THRESHOLD = 1
export function splitHiddenTokens( export function splitHiddenTokens(
tokenBalances: TokenBalance[], tokenBalances: readonly PortfolioTokenBalancePartsFragment[],
options: { options: {
hideSmallBalances?: boolean hideSmallBalances?: boolean
} = { hideSmallBalances: true } } = { hideSmallBalances: true }
) { ) {
const visibleTokens: TokenBalance[] = [] const visibleTokens: PortfolioTokenBalancePartsFragment[] = []
const hiddenTokens: TokenBalance[] = [] const hiddenTokens: PortfolioTokenBalancePartsFragment[] = []
for (const tokenBalance of tokenBalances) { for (const tokenBalance of tokenBalances) {
// if undefined we keep visible (see https://linear.app/uniswap/issue/WEB-1940/[mp]-update-how-we-handle-what-goes-in-hidden-token-section-of-mini) // if undefined we keep visible (see https://linear.app/uniswap/issue/WEB-1940/[mp]-update-how-we-handle-what-goes-in-hidden-token-section-of-mini)
@ -29,7 +29,7 @@ export function splitHiddenTokens(
return { visibleTokens, hiddenTokens } return { visibleTokens, hiddenTokens }
} }
function meetsThreshold(tokenBalance: TokenBalance) { function meetsThreshold(tokenBalance: PortfolioTokenBalancePartsFragment) {
const value = tokenBalance.denominatedValue?.value ?? 0 const value = tokenBalance.denominatedValue?.value ?? 0
return value > HIDE_SMALL_USD_BALANCES_THRESHOLD return value > HIDE_SMALL_USD_BALANCES_THRESHOLD
} }