feat(token-details): native/wrapped-native balance UI (#4814)

* feat(token-details): balance hook

* mobile balance UI

* feat(token-details): sidebar balance summary

* pr feedback
This commit is contained in:
Jordan Frankfurt 2022-10-05 22:46:24 -05:00 committed by GitHub
parent 53b57879a3
commit 22b26de78d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 426 additions and 509 deletions

@ -1,83 +1,110 @@
import { Trans } from '@lingui/macro' import { Trans } from '@lingui/macro'
import { useToken } from 'hooks/Tokens' import { Currency, CurrencyAmount, Token } from '@uniswap/sdk-core'
import { useNetworkTokenBalances } from 'hooks/useNetworkTokenBalances' import { formatToDecimal } from 'analytics/utils'
import { AlertTriangle } from 'react-feather' import CurrencyLogo from 'components/CurrencyLogo'
import { useStablecoinValue } from 'hooks/useStablecoinPrice'
import styled from 'styled-components/macro' import styled from 'styled-components/macro'
import { formatDollarAmount } from 'utils/formatDollarAmt'
const BalancesCard = styled.div` const BalancesCard = styled.div`
width: 100%;
height: fit-content;
color: ${({ theme }) => theme.textPrimary};
font-size: 12px;
line-height: 16px;
padding: 20px;
box-shadow: ${({ theme }) => theme.shallowShadow}; box-shadow: ${({ theme }) => theme.shallowShadow};
background-color: ${({ theme }) => theme.backgroundSurface}; background-color: ${({ theme }) => theme.backgroundSurface};
border: ${({ theme }) => `1px solid ${theme.backgroundOutline}`}; border: ${({ theme }) => `1px solid ${theme.backgroundOutline}`};
border-radius: 16px; border-radius: 16px;
` color: ${({ theme }) => theme.textPrimary};
const ErrorState = styled.div` display: none;
display: flex; font-size: 12px;
align-items: center; height: fit-content;
gap: 12px; line-height: 16px;
color: ${({ theme }) => theme.textSecondary}; padding: 20px;
font-weight: 500; width: 100%;
font-size: 14px;
line-height: 20px;
`
const ErrorText = styled.span`
display: flex;
flex-wrap: wrap;
`
// 768 hardcoded to match NFT-redesign navbar breakpoints
// src/nft/css/sprinkles.css.ts
// change to match theme breakpoints when this navbar is updated
@media screen and (min-width: 768px) {
display: flex;
}
`
const TotalBalanceSection = styled.div` const TotalBalanceSection = styled.div`
height: fit-content; height: fit-content;
width: 100%;
` `
const TotalBalance = styled.div` const TotalBalance = styled.div`
align-items: center;
display: flex; display: flex;
justify-content: space-between; flex-direction: row;
font-size: 20px; font-size: 20px;
justify-content: space-between;
line-height: 28px; line-height: 28px;
margin-top: 12px; margin-top: 12px;
align-items: center;
` `
const TotalBalanceItem = styled.div` const TotalBalanceItem = styled.div`
display: flex; display: flex;
` `
export default function BalanceSummary({ export interface BalanceSummaryProps {
address, tokenAmount: CurrencyAmount<Token> | undefined
balance, nativeCurrencyAmount: CurrencyAmount<Currency> | undefined
balanceUsd, isNative: boolean
}: { }
address: string
balance?: number export default function BalanceSummary({ tokenAmount, nativeCurrencyAmount, isNative }: BalanceSummaryProps) {
balanceUsd?: number const balanceUsdValue = useStablecoinValue(tokenAmount)?.toFixed(2)
}) { const nativeBalanceUsdValue = useStablecoinValue(nativeCurrencyAmount)?.toFixed(2)
const token = useToken(address)
const { loading, error } = useNetworkTokenBalances({ address }) const tokenIsWrappedNative =
tokenAmount &&
nativeCurrencyAmount &&
tokenAmount.currency.address.toLowerCase() === nativeCurrencyAmount.currency.wrapped.address.toLowerCase()
if (
(!tokenAmount && !tokenIsWrappedNative && !isNative) ||
(!isNative && !tokenIsWrappedNative && tokenAmount?.equalTo(0)) ||
(isNative && tokenAmount?.equalTo(0) && nativeCurrencyAmount?.equalTo(0))
) {
return null
}
const showNative = tokenIsWrappedNative || isNative
const currencies = []
if (tokenAmount) {
currencies.push({
currency: tokenAmount.currency,
formattedBalance: formatToDecimal(tokenAmount, Math.min(tokenAmount.currency.decimals, 2)),
formattedUSDValue: balanceUsdValue ? parseFloat(balanceUsdValue) : undefined,
})
}
if (showNative && nativeCurrencyAmount) {
const nativeData = {
currency: nativeCurrencyAmount.currency,
formattedBalance: formatToDecimal(nativeCurrencyAmount, Math.min(nativeCurrencyAmount.currency.decimals, 2)),
formattedUSDValue: nativeBalanceUsdValue ? parseFloat(nativeBalanceUsdValue) : undefined,
}
if (isNative) {
currencies.unshift(nativeData)
} else {
currencies.push(nativeData)
}
}
if (loading || (!error && !balance && !balanceUsd)) return null
return ( return (
<BalancesCard> <BalancesCard>
{error ? ( <TotalBalanceSection>
<ErrorState> <Trans>Your balance</Trans>
<AlertTriangle size={24} /> {currencies.map(({ currency, formattedBalance, formattedUSDValue }) => (
<ErrorText> <TotalBalance key={currency.wrapped.address}>
<Trans>There was an error loading your {token?.symbol} balance</Trans> <TotalBalanceItem>
</ErrorText> <CurrencyLogo currency={currency} />
</ErrorState> &nbsp;{formattedBalance} {currency?.symbol}
) : ( </TotalBalanceItem>
<> <TotalBalanceItem>
<TotalBalanceSection> {formatDollarAmount(formattedUSDValue === 0 ? undefined : formattedUSDValue)}
Your balance </TotalBalanceItem>
<TotalBalance> </TotalBalance>
<TotalBalanceItem>{`${balance} ${token?.symbol}`}</TotalBalanceItem> ))}
<TotalBalanceItem>{`$${balanceUsd}`}</TotalBalanceItem> </TotalBalanceSection>
</TotalBalance>
</TotalBalanceSection>
</>
)}
</BalancesCard> </BalancesCard>
) )
} }

@ -1,199 +0,0 @@
import { Trans } from '@lingui/macro'
import { useToken } from 'hooks/Tokens'
import { useNetworkTokenBalances } from 'hooks/useNetworkTokenBalances'
import { useState } from 'react'
import { AlertTriangle } from 'react-feather'
import { Link } from 'react-router-dom'
import styled from 'styled-components/macro'
import { SMALLEST_MOBILE_MEDIA_BREAKPOINT } from '../constants'
import { LoadingBubble } from '../loading'
const PLACEHOLDER_NAV_FOOTER_HEIGHT = '56px'
const BalanceFooter = styled.div`
height: fit-content;
border: 1px solid ${({ theme }) => theme.backgroundOutline};
background-color: ${({ theme }) => theme.backgroundSurface};
border-radius: 20px 20px 0px 0px;
padding: 12px 16px;
font-weight: 500;
font-size: 14px;
line-height: 20px;
width: 100%;
color: ${({ theme }) => theme.textSecondary};
position: fixed;
left: 0;
bottom: ${PLACEHOLDER_NAV_FOOTER_HEIGHT};
display: flex;
flex-direction: column;
align-content: center;
`
const BalanceValue = styled.div`
font-size: 20px;
line-height: 28px;
display: flex;
gap: 8px;
`
const BalanceTotal = styled.div`
display: flex;
flex-wrap: wrap;
gap: 8px;
color: ${({ theme }) => theme.textPrimary};
`
const BalanceInfo = styled.div`
display: flex;
justify-content: flex-start;
flex-direction: column;
`
const FakeFooterNavBar = styled.div`
position: fixed;
bottom: 0px;
left: 0px;
background-color: ${({ theme }) => theme.backgroundBackdrop};
height: ${PLACEHOLDER_NAV_FOOTER_HEIGHT};
width: 100%;
align-items: flex-end;
padding: 20px 8px;
font-size: 10px;
`
const FiatValue = styled.span`
display: flex;
align-self: flex-end;
font-size: 12px;
line-height: 24px;
@media only screen and (max-width: ${SMALLEST_MOBILE_MEDIA_BREAKPOINT}) {
line-height: 16px;
}
`
const NetworkBalancesSection = styled.div`
height: fit-content;
border-top: 1px solid ${({ theme }) => theme.backgroundOutline};
display: flex;
flex-direction: column;
padding: 16px 0px 8px 0px;
margin-top: 16px;
color: ${({ theme }) => theme.textPrimary};
`
const NetworkBalancesLabel = styled.span`
color: ${({ theme }) => theme.textSecondary};
`
const SwapButton = styled.button`
background-color: ${({ theme }) => theme.accentAction};
border-radius: 12px;
display: flex;
align-items: center;
border: none;
color: ${({ theme }) => theme.accentTextLightPrimary};
padding: 12px 16px;
width: 120px;
height: 44px;
font-size: 16px;
font-weight: 600;
justify-content: center;
`
const TotalBalancesSection = styled.div`
display: flex;
color: ${({ theme }) => theme.textSecondary};
justify-content: space-between;
align-items: center;
`
const ViewAll = styled.span`
display: flex;
color: ${({ theme }) => theme.accentAction};
font-size: 14px;
line-height: 20px;
cursor: pointer;
`
const ErrorState = styled.div`
display: flex;
align-items: center;
gap: 8px;
padding-right: 8px;
`
const LoadingState = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
`
const TopBalanceLoadBubble = styled(LoadingBubble)`
height: 12px;
width: 172px;
`
const BottomBalanceLoadBubble = styled(LoadingBubble)`
height: 16px;
width: 188px;
`
const ErrorText = styled.span`
display: flex;
flex-wrap: wrap;
`
export default function FooterBalanceSummary({
address,
networkBalances,
balance,
balanceUsd,
}: {
address: string
networkBalances: (JSX.Element | null)[] | null
balance?: number
balanceUsd?: number
}) {
const tokenSymbol = useToken(address)?.symbol
const [showMultipleBalances, setShowMultipleBalances] = useState(false)
const multipleBalances = false // for testing purposes
const networkNameIfOneBalance = 'Ethereum' // for testing purposes
const { loading, error } = useNetworkTokenBalances({ address })
return (
<BalanceFooter>
<TotalBalancesSection>
{loading ? (
<LoadingState>
<TopBalanceLoadBubble></TopBalanceLoadBubble>
<BottomBalanceLoadBubble></BottomBalanceLoadBubble>
</LoadingState>
) : error ? (
<ErrorState>
<AlertTriangle size={17} />
<ErrorText>
<Trans>There was an error fetching your balance</Trans>
</ErrorText>
</ErrorState>
) : (
!!balance &&
!!balanceUsd && (
<BalanceInfo>
{multipleBalances ? 'Balance on all networks' : `Your balance on ${networkNameIfOneBalance}`}
<BalanceTotal>
<BalanceValue>
{balance} {tokenSymbol}
</BalanceValue>
<FiatValue>{`$${balanceUsd}`}</FiatValue>
</BalanceTotal>
{multipleBalances && (
<ViewAll onClick={() => setShowMultipleBalances(!showMultipleBalances)}>
<Trans>{showMultipleBalances ? 'Hide' : 'View'} all balances</Trans>
</ViewAll>
)}
</BalanceInfo>
)
)}
<Link to={`/swap?outputCurrency=${address}`}>
<SwapButton>
<Trans>Swap</Trans>
</SwapButton>
</Link>
</TotalBalancesSection>
{showMultipleBalances && (
<NetworkBalancesSection>
<NetworkBalancesLabel>
<Trans>Your balances by network</Trans>
</NetworkBalancesLabel>
{networkBalances}
</NetworkBalancesSection>
)}
<FakeFooterNavBar>**leaving space for updated nav footer**</FakeFooterNavBar>
</BalanceFooter>
)
}

@ -1,5 +1,5 @@
import { WidgetSkeleton } from 'components/Widget' import { WidgetSkeleton } from 'components/Widget'
import { Footer, LeftPanel, RightPanel, TokenDetailsLayout } from 'pages/TokenDetails' import { LeftPanel, RightPanel, TokenDetailsLayout } from 'pages/TokenDetails'
import styled, { useTheme } from 'styled-components/macro' import styled, { useTheme } from 'styled-components/macro'
import { LoadingBubble } from '../loading' import { LoadingBubble } from '../loading'
@ -158,7 +158,6 @@ export function LoadingTokenDetails() {
<RightPanel> <RightPanel>
<WidgetSkeleton /> <WidgetSkeleton />
</RightPanel> </RightPanel>
<Footer />
</TokenDetailsLayout> </TokenDetailsLayout>
) )
} }

@ -0,0 +1,143 @@
import { Trans } from '@lingui/macro'
import { formatToDecimal } from 'analytics/utils'
import { useStablecoinValue } from 'hooks/useStablecoinPrice'
import { Link } from 'react-router-dom'
import styled from 'styled-components/macro'
import { formatDollarAmount } from 'utils/formatDollarAmt'
import { SMALLEST_MOBILE_MEDIA_BREAKPOINT } from '../constants'
import { BalanceSummaryProps } from './BalanceSummary'
const Wrapper = styled.div`
height: fit-content;
border: 1px solid ${({ theme }) => theme.backgroundOutline};
background-color: ${({ theme }) => theme.backgroundSurface};
border-radius: 20px 20px 0px 0px;
display: flex;
padding: 12px 16px;
font-weight: 500;
font-size: 14px;
line-height: 20px;
width: 100%;
color: ${({ theme }) => theme.textSecondary};
position: fixed;
left: 0;
bottom: 56px;
display: flex;
flex-direction: column;
align-content: center;
// 768 hardcoded to match NFT-redesign navbar breakpoints
// src/nft/css/sprinkles.css.ts
// change to match theme breakpoints when this navbar is updated
@media screen and (min-width: 768px) {
display: none !important;
}
`
const BalanceValue = styled.div`
font-size: 20px;
line-height: 28px;
display: flex;
gap: 8px;
`
const BalanceTotal = styled.div`
display: flex;
flex-wrap: wrap;
gap: 8px;
color: ${({ theme }) => theme.textPrimary};
`
const BalanceInfo = styled.div`
display: flex;
justify-content: flex-start;
flex-direction: column;
`
const FiatValue = styled.span`
display: flex;
align-self: flex-end;
font-size: 12px;
line-height: 24px;
@media only screen and (max-width: ${SMALLEST_MOBILE_MEDIA_BREAKPOINT}) {
line-height: 16px;
}
`
const SwapButton = styled.button`
background-color: ${({ theme }) => theme.accentAction};
border-radius: 12px;
display: flex;
align-items: center;
border: none;
color: ${({ theme }) => theme.accentTextLightPrimary};
padding: 12px 16px;
width: 120px;
height: 44px;
font-size: 16px;
font-weight: 600;
justify-content: center;
`
const TotalBalancesSection = styled.div`
display: flex;
color: ${({ theme }) => theme.textSecondary};
justify-content: space-between;
align-items: center;
`
export default function MobileBalanceSummaryFooter({
tokenAmount,
nativeCurrencyAmount,
isNative,
}: BalanceSummaryProps) {
const balanceUsdValue = useStablecoinValue(tokenAmount)?.toFixed(2)
const nativeBalanceUsdValue = useStablecoinValue(nativeCurrencyAmount)?.toFixed(2)
const formattedBalance = tokenAmount
? formatToDecimal(tokenAmount, Math.min(tokenAmount.currency.decimals, 2))
: undefined
const balanceUsd = balanceUsdValue ? parseFloat(balanceUsdValue) : undefined
const formattedNativeBalance = nativeCurrencyAmount
? formatToDecimal(nativeCurrencyAmount, Math.min(nativeCurrencyAmount.currency.decimals, 2))
: undefined
const nativeBalanceUsd = nativeBalanceUsdValue ? parseFloat(nativeBalanceUsdValue) : undefined
if ((!tokenAmount && !nativeCurrencyAmount) || (nativeCurrencyAmount?.equalTo(0) && tokenAmount?.equalTo(0))) {
return null
}
const outputTokenAddress = tokenAmount?.currency.address ?? nativeCurrencyAmount?.wrapped.currency.address
return (
<Wrapper>
<TotalBalancesSection>
{Boolean(formattedBalance !== undefined && !isNative) && (
<BalanceInfo>
<Trans>Your {tokenAmount?.currency?.symbol} balance</Trans>
<BalanceTotal>
<BalanceValue>
{formattedBalance} {tokenAmount?.currency?.symbol}
</BalanceValue>
<FiatValue>{formatDollarAmount(balanceUsd)}</FiatValue>
</BalanceTotal>
</BalanceInfo>
)}
{isNative && (
<BalanceInfo>
<Trans>Your {nativeCurrencyAmount?.currency?.symbol} balance</Trans>
<BalanceTotal>
<BalanceValue>
{formattedNativeBalance} {nativeCurrencyAmount?.currency?.symbol}
</BalanceValue>
<FiatValue>{formatDollarAmount(nativeBalanceUsd)}</FiatValue>
</BalanceTotal>
</BalanceInfo>
)}
<Link to={`/swap?outputCurrency=${outputTokenAddress}`}>
<SwapButton>
<Trans>Swap</Trans>
</SwapButton>
</Link>
</TotalBalancesSection>
</Wrapper>
)
}

@ -1,64 +0,0 @@
import styled, { useTheme } from 'styled-components/macro'
const Balance = styled.div`
width: 100%;
display: flex;
flex-direction: column;
font-size: 16px;
line-height: 20px;
`
const BalanceItem = styled.div`
display: flex;
`
const BalanceRow = styled.div`
display: flex;
justify-content: space-between;
`
const Logo = styled.img`
height: 32px;
width: 32px;
margin-right: 8px;
`
const Network = styled.span<{ color: string }>`
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: ${({ color }) => color};
`
const NetworkBalanceContainer = styled.div`
display: flex;
padding-top: 16px;
align-items: center;
`
export default function NetworkBalance({
logoUrl,
balance,
tokenSymbol,
fiatValue,
label,
networkColor,
}: {
logoUrl: string
balance: string
tokenSymbol: string
fiatValue: string | number
label: string
networkColor: string | undefined
}) {
const theme = useTheme()
return (
<NetworkBalanceContainer>
<Logo src={logoUrl} />
<Balance>
<BalanceRow>
<BalanceItem>
{balance}&nbsp;{tokenSymbol}
</BalanceItem>
<BalanceItem>${fiatValue}</BalanceItem>
</BalanceRow>
<Network color={networkColor ?? theme.textPrimary}>{label}</Network>
</Balance>
</NetworkBalanceContainer>
)
}

@ -47,6 +47,9 @@ class TokenSafetyLookupTable {
if (!this.dict) { if (!this.dict) {
this.dict = this.createMap() this.dict = this.createMap()
} }
if (address === 'native') {
return TOKEN_LIST_TYPES.UNI_DEFAULT
}
return this.dict[address] ?? TOKEN_LIST_TYPES.UNKNOWN return this.dict[address] ?? TOKEN_LIST_TYPES.UNKNOWN
} }
} }

@ -6,4 +6,5 @@ export enum FeatureFlag {
tokens = 'tokens', tokens = 'tokens',
tokenSafety = 'tokenSafety', tokenSafety = 'tokenSafety',
traceJsonRpc = 'traceJsonRpc', traceJsonRpc = 'traceJsonRpc',
multiNetworkBalances = 'multiNetworkBalances',
} }

@ -0,0 +1,123 @@
import { Currency, CurrencyAmount, NativeCurrency, Token } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core'
import { Weth } from 'abis/types'
import WETH_ABI from 'abis/weth.json'
import { ALL_SUPPORTED_CHAIN_IDS, isSupportedChain, SupportedChainId, TESTNET_CHAIN_IDS } from 'constants/chains'
import { RPC_PROVIDERS } from 'constants/providers'
import { nativeOnChain, WRAPPED_NATIVE_CURRENCY } from 'constants/tokens'
import { BaseVariant, FeatureFlag, useBaseFlag } from 'featureFlags'
import { useCallback, useEffect, useState } from 'react'
import { getContract } from 'utils'
interface useMultiNetworkAddressBalancesArgs {
ownerAddress: string | undefined
tokenAddress: 'NATIVE' | string | undefined
}
type AddressNetworkBalanceData = Partial<
Record<
SupportedChainId,
Record<string | 'NATIVE', CurrencyAmount<Token> | CurrencyAmount<NativeCurrency> | undefined>
>
>
interface handleBalanceArg {
amount: CurrencyAmount<Currency>
chainId: SupportedChainId
tokenAddress: string | 'NATIVE'
}
const testnetSet = new Set(TESTNET_CHAIN_IDS) as Set<SupportedChainId>
export function useMultiNetworkAddressBalances({ ownerAddress, tokenAddress }: useMultiNetworkAddressBalancesArgs) {
const [data, setData] = useState<AddressNetworkBalanceData>({})
const [error] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const { chainId: connectedChainId } = useWeb3React()
const feature_flag_multi_network_balances = useBaseFlag(FeatureFlag.multiNetworkBalances)
const handleBalance = useCallback(({ amount, chainId, tokenAddress }: handleBalanceArg) => {
if (!amount.greaterThan(0) || !tokenAddress) {
return
}
setData((data) => ({
...data,
[chainId]: {
...(data[chainId] ?? {}),
[tokenAddress]: amount,
},
}))
}, [])
useEffect(() => {
if (!ownerAddress || !tokenAddress) {
return
}
const isConnecteToTestnet = connectedChainId ? TESTNET_CHAIN_IDS.includes(connectedChainId) : false
setLoading(true)
const isNative = tokenAddress === 'NATIVE'
const promises: Promise<any>[] = []
const isWrappedNative = ALL_SUPPORTED_CHAIN_IDS.some(
(chainId) => WRAPPED_NATIVE_CURRENCY[chainId]?.address.toLowerCase() === tokenAddress.toLowerCase()
)
const chainsToCheck: SupportedChainId[] =
feature_flag_multi_network_balances === BaseVariant.Enabled
? ALL_SUPPORTED_CHAIN_IDS
: isSupportedChain(connectedChainId)
? [SupportedChainId.MAINNET, connectedChainId]
: [SupportedChainId.MAINNET]
chainsToCheck.forEach((chainId) => {
const isTestnet = testnetSet.has(chainId)
if ((isConnecteToTestnet && isTestnet) || !isTestnet) {
const provider = RPC_PROVIDERS[chainId]
if (isWrappedNative || isNative) {
const wrappedNative = WRAPPED_NATIVE_CURRENCY[chainId]
if (wrappedNative) {
promises.push(
new Promise(async (resolve) => {
try {
const wrappedNativeContract = getContract(wrappedNative.address, WETH_ABI, provider) as Weth
const balance = await wrappedNativeContract.balanceOf(ownerAddress, { blockTag: 'latest' })
const amount = CurrencyAmount.fromRawAmount(wrappedNative, balance.toString())
resolve(handleBalance({ amount, chainId, tokenAddress: wrappedNative.address.toLowerCase() }))
} catch (e) {}
})
)
}
promises.push(
new Promise(async (resolve) => {
try {
const balance = await provider.getBalance(ownerAddress, 'latest')
const nativeCurrency = nativeOnChain(chainId)
const amount = CurrencyAmount.fromRawAmount(nativeCurrency, balance.toString())
resolve(handleBalance({ amount, chainId, tokenAddress: 'NATIVE' }))
} catch (e) {}
})
)
// todo (jordan): support multi-network ERC20 balances
// } else {
// promises.push(
// new Promise(async (resolve) => {
// try {
// const ERC20Contract = getContract(tokenAddress, ERC20_ABI, provider) as Erc20
// const balance = await ERC20Contract.balanceOf(ownerAddress, { blockTag: 'latest' })
// const amount = //
// resolve(handleBalance({ amount, chainId, tokenAddress }))
// } catch (e) {}
// })
// )
}
}
})
Promise.all(promises)
.catch(() => ({}))
.finally(() => setLoading(false))
}, [connectedChainId, feature_flag_multi_network_balances, handleBalance, ownerAddress, tokenAddress])
return { data, error, loading }
}

@ -1,77 +0,0 @@
import { CurrencyAmount, Token } from '@uniswap/sdk-core'
import { SupportedChainId } from 'constants/chains'
import { gql } from 'graphql-request'
import { useEffect, useState } from 'react'
type NetworkTokenBalancesMap = Partial<Record<SupportedChainId, CurrencyAmount<Token>>>
interface useNetworkTokenBalancesResult {
data: NetworkTokenBalancesMap | null
error: null | string
loading: boolean
}
interface useNetworkTokenBalancesArgs {
address: string | undefined
}
export function useNetworkTokenBalances({ address }: useNetworkTokenBalancesArgs): useNetworkTokenBalancesResult {
const [data, setData] = useState<NetworkTokenBalancesMap | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const query = gql``
useEffect(() => {
if (address) {
const FAKE_TOKEN_NETWORK_BALANCES = {
[SupportedChainId.ARBITRUM_ONE]: CurrencyAmount.fromRawAmount(
new Token(SupportedChainId.ARBITRUM_ONE, address, 18),
10e18
),
[SupportedChainId.MAINNET]: CurrencyAmount.fromRawAmount(
new Token(SupportedChainId.MAINNET, address, 18),
1e18
),
[SupportedChainId.RINKEBY]: CurrencyAmount.fromRawAmount(
new Token(SupportedChainId.RINKEBY, address, 9),
10e18
),
}
const fetchNetworkTokenBalances = async (address: string): Promise<NetworkTokenBalancesMap | void> => {
const waitRandom = (min: number, max: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, min + Math.round(Math.random() * Math.max(0, max - min))))
try {
setLoading(true)
setError(null)
await waitRandom(250, 2000)
if (Math.random() < 0.05) {
throw new Error('fake error')
}
return FAKE_TOKEN_NETWORK_BALANCES
} catch (e) {
setError('something went wrong')
} finally {
setLoading(false)
}
}
setLoading(true)
setError(null)
fetchNetworkTokenBalances(address)
.then((data) => {
if (data) setData(data)
})
.catch((e) => setError(e))
.finally(() => setLoading(false))
} else {
setData(null)
}
}, [address, query])
return {
data,
error,
loading,
}
}

@ -1,6 +1,5 @@
import { NativeCurrency, Token } from '@uniswap/sdk-core' import { Token } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core' import { useWeb3React } from '@web3-react/core'
import { formatToDecimal } from 'analytics/utils'
import { import {
LARGE_MEDIA_BREAKPOINT, LARGE_MEDIA_BREAKPOINT,
MAX_WIDTH_MEDIA_BREAKPOINT, MAX_WIDTH_MEDIA_BREAKPOINT,
@ -13,14 +12,11 @@ import AddressSection from 'components/Tokens/TokenDetails/AddressSection'
import BalanceSummary from 'components/Tokens/TokenDetails/BalanceSummary' import BalanceSummary from 'components/Tokens/TokenDetails/BalanceSummary'
import { BreadcrumbNavLink } from 'components/Tokens/TokenDetails/BreadcrumbNavLink' import { BreadcrumbNavLink } from 'components/Tokens/TokenDetails/BreadcrumbNavLink'
import ChartSection from 'components/Tokens/TokenDetails/ChartSection' import ChartSection from 'components/Tokens/TokenDetails/ChartSection'
import FooterBalanceSummary from 'components/Tokens/TokenDetails/FooterBalanceSummary' import MobileBalanceSummaryFooter from 'components/Tokens/TokenDetails/MobileBalanceSummaryFooter'
import NetworkBalance from 'components/Tokens/TokenDetails/NetworkBalance'
import StatsSection from 'components/Tokens/TokenDetails/StatsSection' import StatsSection from 'components/Tokens/TokenDetails/StatsSection'
import TokenSafetyMessage from 'components/TokenSafety/TokenSafetyMessage' import TokenSafetyMessage from 'components/TokenSafety/TokenSafetyMessage'
import TokenSafetyModal from 'components/TokenSafety/TokenSafetyModal' import TokenSafetyModal from 'components/TokenSafety/TokenSafetyModal'
import Widget, { WIDGET_WIDTH } from 'components/Widget' import Widget, { WIDGET_WIDTH } from 'components/Widget'
import { getChainInfo } from 'constants/chainInfo'
import { L1_CHAIN_IDS, L2_CHAIN_IDS, SupportedChainId, TESTNET_CHAIN_IDS } from 'constants/chains'
import { isCelo, nativeOnChain } from 'constants/tokens' import { isCelo, nativeOnChain } from 'constants/tokens'
import { checkWarning } from 'constants/tokenSafety' import { checkWarning } from 'constants/tokenSafety'
import { Chain } from 'graphql/data/__generated__/TokenQuery.graphql' import { Chain } from 'graphql/data/__generated__/TokenQuery.graphql'
@ -28,21 +24,13 @@ import { useTokenQuery } from 'graphql/data/Token'
import { CHAIN_NAME_TO_CHAIN_ID, validateUrlChainParam } from 'graphql/data/util' import { CHAIN_NAME_TO_CHAIN_ID, validateUrlChainParam } from 'graphql/data/util'
import { useIsUserAddedTokenOnChain } from 'hooks/Tokens' import { useIsUserAddedTokenOnChain } from 'hooks/Tokens'
import { useOnGlobalChainSwitch } from 'hooks/useGlobalChainSwitch' import { useOnGlobalChainSwitch } from 'hooks/useGlobalChainSwitch'
import { useNetworkTokenBalances } from 'hooks/useNetworkTokenBalances'
import { useStablecoinValue } from 'hooks/useStablecoinPrice'
import { useAtomValue } from 'jotai/utils' import { useAtomValue } from 'jotai/utils'
import { useTokenBalance } from 'lib/hooks/useCurrencyBalance' import useCurrencyBalance, { useTokenBalance } from 'lib/hooks/useCurrencyBalance'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { ArrowLeft } from 'react-feather' import { ArrowLeft } from 'react-feather'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import styled from 'styled-components/macro' import styled from 'styled-components/macro'
export const Footer = styled.div`
display: none;
@media only screen and (max-width: ${LARGE_MEDIA_BREAKPOINT}) {
display: flex;
}
`
export const TokenDetailsLayout = styled.div` export const TokenDetailsLayout = styled.div`
display: flex; display: flex;
gap: 60px; gap: 60px;
@ -91,48 +79,60 @@ export const RightPanel = styled.div`
display: none; display: none;
} }
` `
function NetworkBalances(tokenAddress: string | undefined) {
return useNetworkTokenBalances({ address: tokenAddress })
}
export default function TokenDetails() { export default function TokenDetails() {
const { tokenAddress: tokenAddressParam, chainName } = useParams<{ tokenAddress?: string; chainName?: string }>() const { tokenAddress: tokenAddressParam, chainName } = useParams<{ tokenAddress?: string; chainName?: string }>()
const chainId = CHAIN_NAME_TO_CHAIN_ID[validateUrlChainParam(chainName)] const { account } = useWeb3React()
let tokenAddress = tokenAddressParam
let nativeCurrency: NativeCurrency | Token | undefined
if (tokenAddressParam === 'NATIVE') {
nativeCurrency = nativeOnChain(chainId)
tokenAddress = nativeCurrency.wrapped.address
}
const tokenWarning = tokenAddress ? checkWarning(tokenAddress) : null
const isBlockedToken = tokenWarning?.canProceed === false
const timePeriod = useAtomValue(filterTimeAtom)
const currentChainName = validateUrlChainParam(chainName) const currentChainName = validateUrlChainParam(chainName)
const [token, prices] = useTokenQuery(tokenAddress ?? '', currentChainName, timePeriod) const pageChainId = CHAIN_NAME_TO_CHAIN_ID[validateUrlChainParam(chainName)]
const nativeCurrency = nativeOnChain(pageChainId)
const timePeriod = useAtomValue(filterTimeAtom)
const isNative = tokenAddressParam === 'NATIVE'
const tokenQueryAddress = isNative ? nativeCurrency.wrapped.address : tokenAddressParam
const [tokenQueryData, prices] = useTokenQuery(tokenQueryAddress ?? '', currentChainName, timePeriod)
const pageToken = useMemo(
() =>
tokenQueryData && !isNative
? new Token(
CHAIN_NAME_TO_CHAIN_ID[currentChainName],
tokenAddressParam ?? '',
18,
tokenQueryData?.symbol ?? '',
tokenQueryData?.name ?? ''
)
: undefined,
[currentChainName, isNative, tokenAddressParam, tokenQueryData]
)
const nativeCurrencyBalance = useCurrencyBalance(account, nativeCurrency)
const tokenBalance = useTokenBalance(account, isNative ? nativeCurrency.wrapped : pageToken)
const tokenWarning = tokenAddressParam ? checkWarning(tokenAddressParam) : null
const isBlockedToken = tokenWarning?.canProceed === false
const navigate = useNavigate() const navigate = useNavigate()
const switchChains = useCallback( const switchChains = useCallback(
(newChain: Chain) => { (newChain: Chain) => {
const chainSegment = newChain.toLowerCase() const chainSegment = newChain.toLowerCase()
if (tokenAddressParam === 'NATIVE') { if (isNative) {
navigate(`/tokens/${chainSegment}/NATIVE`) navigate(`/tokens/${chainSegment}/NATIVE`)
} else { } else {
token?.project?.tokens?.forEach((token) => { tokenQueryData?.project?.tokens?.forEach((token) => {
if (token.chain === newChain && token.address) { if (token.chain === newChain && token.address) {
navigate(`/tokens/${chainSegment}/${token.address}`) navigate(`/tokens/${chainSegment}/${token.address}`)
} }
}) })
} }
}, },
[navigate, token?.project?.tokens, tokenAddressParam] [isNative, navigate, tokenQueryData?.project?.tokens]
) )
useOnGlobalChainSwitch(switchChains) useOnGlobalChainSwitch(switchChains)
const [continueSwap, setContinueSwap] = useState<{ resolve: (value: boolean | PromiseLike<boolean>) => void }>() const [continueSwap, setContinueSwap] = useState<{ resolve: (value: boolean | PromiseLike<boolean>) => void }>()
const shouldShowSpeedbump = !useIsUserAddedTokenOnChain(tokenAddress, chainId) && tokenWarning !== null const shouldShowSpeedbump = !useIsUserAddedTokenOnChain(tokenAddressParam, pageChainId) && tokenWarning !== null
// Show token safety modal if Swap-reviewing a warning token, at all times if the current token is blocked // Show token safety modal if Swap-reviewing a warning token, at all times if the current token is blocked
const onReviewSwap = useCallback( const onReviewSwap = useCallback(
() => new Promise<boolean>((resolve) => (shouldShowSpeedbump ? setContinueSwap({ resolve }) : resolve(true))), () => new Promise<boolean>((resolve) => (shouldShowSpeedbump ? setContinueSwap({ resolve }) : resolve(true))),
@ -147,102 +147,63 @@ export default function TokenDetails() {
[continueSwap, setContinueSwap] [continueSwap, setContinueSwap]
) )
/* network balance handling */
const { data: networkData } = NetworkBalances(tokenAddress)
const { chainId: connectedChainId, account } = useWeb3React()
// TODO: consider updating useTokenBalance to work with just address/chain to avoid using Token data structure here
const balanceValue = useTokenBalance(
account,
useMemo(() => new Token(chainId, tokenAddress ?? '', 18), [chainId, tokenAddress])
)
const balance = balanceValue ? formatToDecimal(balanceValue, Math.min(balanceValue.currency.decimals, 6)) : undefined
const balanceUsdValue = useStablecoinValue(balanceValue)?.toFixed(2)
const balanceUsd = balanceUsdValue ? parseFloat(balanceUsdValue) : undefined
const chainsToList = useMemo(() => {
let chainIds = [...L1_CHAIN_IDS, ...L2_CHAIN_IDS]
const userConnectedToATestNetwork = connectedChainId && TESTNET_CHAIN_IDS.includes(connectedChainId)
if (!userConnectedToATestNetwork) {
chainIds = chainIds.filter((id) => !(TESTNET_CHAIN_IDS as unknown as SupportedChainId[]).includes(id))
}
return chainIds
}, [connectedChainId])
const balancesByNetwork = networkData
? chainsToList.map((chainId) => {
const amount = networkData[chainId]
const fiatValue = amount // for testing purposes
if (!fiatValue || !token?.symbol) return null
const chainInfo = getChainInfo(chainId)
const networkColor = chainInfo.color
if (!chainInfo) return null
return (
<NetworkBalance
key={chainId}
logoUrl={chainInfo.logoUrl}
balance={'1'}
tokenSymbol={token.symbol}
fiatValue={fiatValue.toSignificant(2)}
label={chainInfo.label}
networkColor={networkColor}
/>
)
})
: null
const widgetToken = useMemo(() => { const widgetToken = useMemo(() => {
const currentChainId = CHAIN_NAME_TO_CHAIN_ID[currentChainName] if (pageToken) {
// The widget is not yet configured to use Celo. return pageToken
if (isCelo(chainId) || isCelo(currentChainId)) return undefined }
if (nativeCurrency) {
return ( if (isCelo(pageChainId)) return undefined
nativeCurrency ?? return nativeCurrency
(token?.address && token.symbol && token.name }
? new Token(currentChainId, token.address, 18, token.symbol, token.name) return undefined
: undefined) }, [nativeCurrency, pageChainId, pageToken])
)
}, [chainId, currentChainName, nativeCurrency, token?.address, token?.name, token?.symbol])
return ( return (
<TokenDetailsLayout> <TokenDetailsLayout>
{token && ( {tokenQueryData && (
<> <>
<LeftPanel> <LeftPanel>
<BreadcrumbNavLink to={`/tokens/${chainName}`}> <BreadcrumbNavLink to={`/tokens/${chainName}`}>
<ArrowLeft size={14} /> Tokens <ArrowLeft size={14} /> Tokens
</BreadcrumbNavLink> </BreadcrumbNavLink>
<ChartSection token={token} prices={prices} nativeCurrency={nativeCurrency} /> <ChartSection
token={tokenQueryData}
nativeCurrency={isNative ? nativeCurrency : undefined}
prices={prices}
/>
<StatsSection <StatsSection
TVL={token.market?.totalValueLocked?.value} TVL={tokenQueryData.market?.totalValueLocked?.value}
volume24H={token.market?.volume24H?.value} volume24H={tokenQueryData.market?.volume24H?.value}
priceHigh52W={token.market?.priceHigh52W?.value} priceHigh52W={tokenQueryData.market?.priceHigh52W?.value}
priceLow52W={token.market?.priceLow52W?.value} priceLow52W={tokenQueryData.market?.priceLow52W?.value}
/> />
<AboutSection <AboutSection
address={token.address ?? ''} address={tokenQueryData.address ?? ''}
description={token.project?.description} description={tokenQueryData.project?.description}
homepageUrl={token.project?.homepageUrl} homepageUrl={tokenQueryData.project?.homepageUrl}
twitterName={token.project?.twitterName} twitterName={tokenQueryData.project?.twitterName}
/> />
<AddressSection address={token.address ?? ''} /> <AddressSection address={tokenQueryData.address ?? ''} />
</LeftPanel> </LeftPanel>
<RightPanel> <RightPanel>
<Widget defaultToken={widgetToken} onReviewSwapClick={onReviewSwap} /> <Widget defaultToken={widgetToken} onReviewSwapClick={onReviewSwap} />
{tokenWarning && <TokenSafetyMessage tokenAddress={token.address ?? ''} warning={tokenWarning} />} {tokenWarning && <TokenSafetyMessage tokenAddress={tokenQueryData.address ?? ''} warning={tokenWarning} />}
<BalanceSummary address={token.address ?? ''} balance={balance} balanceUsd={balanceUsd} /> <BalanceSummary
</RightPanel> tokenAmount={tokenBalance}
<Footer> nativeCurrencyAmount={nativeCurrencyBalance}
<FooterBalanceSummary isNative={isNative}
address={token.address ?? ''}
networkBalances={balancesByNetwork}
balance={balance}
balanceUsd={balanceUsd}
/> />
</Footer> </RightPanel>
<MobileBalanceSummaryFooter
tokenAmount={tokenBalance}
nativeCurrencyAmount={nativeCurrencyBalance}
isNative={isNative}
/>
<TokenSafetyModal <TokenSafetyModal
isOpen={isBlockedToken || !!continueSwap} isOpen={isBlockedToken || !!continueSwap}
tokenAddress={token.address} tokenAddress={tokenQueryData.address}
onContinue={() => onResolveSwap(true)} onContinue={() => onResolveSwap(true)}
onBlocked={() => navigate(-1)} onBlocked={() => navigate(-1)}
onCancel={() => onResolveSwap(false)} onCancel={() => onResolveSwap(false)}