feat: token data cache (#4534)

* initial cache construction
* switched to relay cache, updated hooks
* fixed comments
This commit is contained in:
cartcrom 2022-09-13 13:09:12 -04:00 committed by GitHub
parent fb389137e7
commit 84070835df
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 520 additions and 424 deletions

@ -1,4 +1,3 @@
import { Token } from '@uniswap/sdk-core'
import { AxisBottom, TickFormatter } from '@visx/axis'
import { localPoint } from '@visx/event'
import { EventType } from '@visx/event/lib/types'
@ -6,11 +5,12 @@ import { GlyphCircle } from '@visx/glyph'
import { Line } from '@visx/shape'
import { filterTimeAtom } from 'components/Tokens/state'
import { bisect, curveCardinal, NumberValue, scaleLinear, timeDay, timeHour, timeMinute, timeMonth } from 'd3'
import { useTokenPriceQuery } from 'graphql/data/TokenPriceQuery'
import { TimePeriod } from 'graphql/data/TopTokenQuery'
import { TokenPrices$key } from 'graphql/data/__generated__/TokenPrices.graphql'
import { useTokenPricesCached } from 'graphql/data/Token'
import { PricePoint, TimePeriod } from 'graphql/data/Token'
import { useActiveLocale } from 'hooks/useActiveLocale'
import { useAtom } from 'jotai'
import { useCallback, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { ArrowDownRight, ArrowUpRight } from 'react-feather'
import styled, { useTheme } from 'styled-components/macro'
import { OPACITY_HOVER } from 'theme'
@ -29,8 +29,6 @@ import { DISPLAYS, ORDERED_TIMES } from '../TokenTable/TimeSelector'
// TODO: This should be combined with the logic in TimeSelector.
export type PricePoint = { value: number; timestamp: number }
export const DATA_EMPTY = { value: 0, timestamp: 0 }
function getPriceBounds(pricePoints: PricePoint[]): [number, number] {
@ -72,7 +70,6 @@ export function formatDelta(delta: number) {
export const ChartHeader = styled.div`
position: absolute;
`
export const TokenPrice = styled.span`
font-size: 36px;
line-height: 44px;
@ -124,37 +121,41 @@ const crosshairDateOverhang = 80
interface PriceChartProps {
width: number
height: number
token: Token
tokenAddress: string
priceData?: TokenPrices$key | null
}
export function PriceChart({ width, height, token }: PriceChartProps) {
export function PriceChart({ width, height, tokenAddress, priceData }: PriceChartProps) {
const [timePeriod, setTimePeriod] = useAtom(filterTimeAtom)
const locale = useActiveLocale()
const theme = useTheme()
// TODO: Add network selector input, consider using backend type instead of current front end selector type
const pricePoints: PricePoint[] = useTokenPriceQuery(token.address, timePeriod, 'ETHEREUM').filter(
(p): p is PricePoint => Boolean(p && p.value)
)
const { priceMap } = useTokenPricesCached(priceData, tokenAddress, 'ETHEREUM', timePeriod)
const prices = priceMap.get(timePeriod)
const hasData = pricePoints.length !== 0
/* TODO: Implement API calls & cache to use here */
const startingPrice = hasData ? pricePoints[0] : DATA_EMPTY
const endingPrice = hasData ? pricePoints[pricePoints.length - 1] : DATA_EMPTY
const startingPrice = prices?.[0] ?? DATA_EMPTY
const endingPrice = prices?.[prices.length - 1] ?? DATA_EMPTY
const [displayPrice, setDisplayPrice] = useState(startingPrice)
const [crosshair, setCrosshair] = useState<number | null>(null)
const graphWidth = width + crosshairDateOverhang
// TODO: remove this logic after suspense is properly added
const graphHeight = height - timeOptionsHeight > 0 ? height - timeOptionsHeight : 0
const graphInnerHeight = graphHeight - margin.top - margin.bottom > 0 ? graphHeight - margin.top - margin.bottom : 0
// Defining scales
// x scale
const timeScale = scaleLinear().domain([startingPrice.timestamp, endingPrice.timestamp]).range([0, width]).nice()
const timeScale = useMemo(
() => scaleLinear().domain([startingPrice.timestamp, endingPrice.timestamp]).range([0, width]).nice(),
[startingPrice, endingPrice, width]
)
// y scale
const rdScale = scaleLinear().domain(getPriceBounds(pricePoints)).range([graphInnerHeight, 0])
const rdScale = useMemo(
() =>
scaleLinear()
.domain(getPriceBounds(prices ?? []))
.range([graphInnerHeight, 0]),
[prices, graphInnerHeight]
)
function tickFormat(
startTimestamp: number,
@ -206,16 +207,18 @@ export function PriceChart({ width, height, token }: PriceChartProps) {
const handleHover = useCallback(
(event: Element | EventType) => {
if (!prices) return
const { x } = localPoint(event) || { x: 0 }
const x0 = timeScale.invert(x) // get timestamp from the scalexw
const index = bisect(
pricePoints.map((x) => x.timestamp),
prices.map((x) => x.timestamp),
x0,
1
)
const d0 = pricePoints[index - 1]
const d1 = pricePoints[index]
const d0 = prices[index - 1]
const d1 = prices[index]
let pricePoint = d0
const hasPreviousData = d1 && d1.timestamp
@ -226,7 +229,7 @@ export function PriceChart({ width, height, token }: PriceChartProps) {
setCrosshair(timeScale(pricePoint.timestamp))
setDisplayPrice(pricePoint)
},
[timeScale, pricePoints]
[timeScale, prices]
)
const resetDisplay = useCallback(() => {
@ -234,8 +237,8 @@ export function PriceChart({ width, height, token }: PriceChartProps) {
setDisplayPrice(endingPrice)
}, [setCrosshair, setDisplayPrice, endingPrice])
// TODO: connect to loading state
if (!hasData) {
// TODO: Display no data available error
if (!prices) {
return null
}
@ -264,7 +267,7 @@ export function PriceChart({ width, height, token }: PriceChartProps) {
</DeltaContainer>
</ChartHeader>
<LineChart
data={pricePoints}
data={prices}
getX={(p: PricePoint) => timeScale(p.timestamp)}
getY={(p: PricePoint) => rdScale(p.value)}
marginTop={margin.top}
@ -335,7 +338,13 @@ export function PriceChart({ width, height, token }: PriceChartProps) {
<TimeOptionsWrapper>
<TimeOptionsContainer>
{ORDERED_TIMES.map((time) => (
<TimeButton key={DISPLAYS[time]} active={timePeriod === time} onClick={() => setTimePeriod(time)}>
<TimeButton
key={DISPLAYS[time]}
active={timePeriod === time}
onClick={() => {
setTimePeriod(time)
}}
>
{DISPLAYS[time]}
</TimeButton>
))}

@ -7,9 +7,8 @@ import { VerifiedIcon } from 'components/TokenSafety/TokenSafetyIcon'
import { getChainInfo } from 'constants/chainInfo'
import { nativeOnChain, WRAPPED_NATIVE_CURRENCY } from 'constants/tokens'
import { checkWarning } from 'constants/tokenSafety'
import { chainIdToChainName, useTokenDetailQuery } from 'graphql/data/TokenDetailQuery'
import { TokenQuery$data } from 'graphql/data/__generated__/TokenQuery.graphql'
import { useCurrency, useToken } from 'hooks/Tokens'
import { useAtomValue } from 'jotai/utils'
import { darken } from 'polished'
import { Suspense } from 'react'
import { useState } from 'react'
@ -18,7 +17,7 @@ import styled from 'styled-components/macro'
import { CopyContractAddress } from 'theme'
import { formatDollarAmount } from 'utils/formatDollarAmt'
import { filterNetworkAtom, useIsFavorited, useToggleFavorite } from '../state'
import { useIsFavorited, useToggleFavorite } from '../state'
import { ClickFavorited, FavoriteIcon } from '../TokenTable/TokenRow'
import LoadingTokenDetail from './LoadingTokenDetail'
import Resource from './Resource'
@ -169,7 +168,7 @@ export function AboutSection({ address, tokenDetailData }: { address: string; to
)
}
export default function LoadedTokenDetail({ address }: { address: string }) {
export default function LoadedTokenDetail({ address, query }: { address: string; query: TokenQuery$data }) {
const { chainId: connectedChainId } = useWeb3React()
const token = useToken(address)
let currency = useCurrency(address)
@ -179,13 +178,14 @@ export default function LoadedTokenDetail({ address }: { address: string }) {
const chainInfo = getChainInfo(token?.chainId)
const networkLabel = chainInfo?.label
const networkBadgebackgroundColor = chainInfo?.backgroundColor
const filterNetwork = useAtomValue(filterNetworkAtom)
const tokenDetailData = useTokenDetailQuery(address, chainIdToChainName(filterNetwork))
const relevantTokenDetailData = (({ description, homepageUrl, twitterName }) => ({
description,
homepageUrl,
twitterName,
}))(tokenDetailData)
const tokenData = query.tokenProjects?.[0]
const tokenDetails = tokenData?.markets?.[0]
const relevantTokenDetailData = {
description: tokenData?.description,
homepageUrl: tokenData?.homepageUrl,
twitterName: tokenData?.twitterName,
}
if (!token || !token.name || !token.symbol || !connectedChainId) {
return <LoadingTokenDetail />
@ -198,10 +198,8 @@ export default function LoadedTokenDetail({ address }: { address: string }) {
currency = nativeOnChain(connectedChainId)
}
const tokenName = isWrappedNativeToken && currency ? currency.name : tokenDetailData.name
const defaultTokenSymbol = tokenDetailData.tokens?.[0]?.symbol ?? token.symbol
const tokenSymbol = isWrappedNativeToken && currency ? currency.symbol : defaultTokenSymbol
const tokenName = tokenData?.name ?? token.name
const tokenSymbol = tokenData?.tokens?.[0]?.symbol ?? token.symbol
return (
<Suspense fallback={<LoadingTokenDetail />}>
<TopArea>
@ -231,7 +229,11 @@ export default function LoadedTokenDetail({ address }: { address: string }) {
</TokenActions>
</TokenInfoContainer>
<ChartContainer>
<ParentSize>{({ width, height }) => <PriceChart token={token} width={width} height={height} />}</ParentSize>
<ParentSize>
{({ width, height }) => (
<PriceChart tokenAddress={address} width={width} height={height} priceData={tokenData?.prices?.[0]} />
)}
</ParentSize>
</ChartContainer>
</ChartHeader>
<StatsSection>
@ -239,13 +241,13 @@ export default function LoadedTokenDetail({ address }: { address: string }) {
<Stat>
<Trans>Market cap</Trans>
<StatPrice>
{tokenDetailData.marketCap?.value ? formatDollarAmount(tokenDetailData.marketCap?.value) : '-'}
{tokenDetails?.marketCap?.value ? formatDollarAmount(tokenDetails.marketCap?.value) : '-'}
</StatPrice>
</Stat>
<Stat>
24H volume
<StatPrice>
{tokenDetailData.volume24h?.value ? formatDollarAmount(tokenDetailData.volume24h?.value) : '-'}
{tokenDetails?.volume1D?.value ? formatDollarAmount(tokenDetails.volume1D.value) : '-'}
</StatPrice>
</Stat>
</StatPair>
@ -253,13 +255,13 @@ export default function LoadedTokenDetail({ address }: { address: string }) {
<Stat>
52W low
<StatPrice>
{tokenDetailData.priceLow52W?.value ? formatDollarAmount(tokenDetailData.priceLow52W?.value) : '-'}
{tokenDetails?.priceLow52W?.value ? formatDollarAmount(tokenDetails.priceLow52W?.value) : '-'}
</StatPrice>
</Stat>
<Stat>
52W high
<StatPrice>
{tokenDetailData.priceHigh52W?.value ? formatDollarAmount(tokenDetailData.priceHigh52W?.value) : '-'}
{tokenDetails?.priceHigh52W?.value ? formatDollarAmount(tokenDetails.priceHigh52W?.value) : '-'}
</StatPrice>
</Stat>
</StatPair>

@ -1,4 +1,4 @@
import { TimePeriod } from 'graphql/data/TopTokenQuery'
import { TimePeriod } from 'graphql/data/Token'
import { useOnClickOutside } from 'hooks/useOnClickOutside'
import { useAtom } from 'jotai'
import { useRef } from 'react'

@ -5,7 +5,8 @@ import { EventName } from 'components/AmplitudeAnalytics/constants'
import SparklineChart from 'components/Charts/SparklineChart'
import CurrencyLogo from 'components/CurrencyLogo'
import { getChainInfo } from 'constants/chainInfo'
import { TimePeriod, TokenData } from 'graphql/data/TopTokenQuery'
import { getDurationDetails, SingleTokenData } from 'graphql/data/Token'
import { TimePeriod } from 'graphql/data/Token'
import { useCurrency } from 'hooks/Tokens'
import { useAtomValue } from 'jotai/utils'
import { ReactNode } from 'react'
@ -454,29 +455,29 @@ export function LoadingRow() {
/* Loaded State: row component with token information */
export default function LoadedRow({
tokenAddress,
tokenListIndex,
tokenListLength,
tokenData,
timePeriod,
}: {
tokenAddress: string
tokenListIndex: number
tokenListLength: number
tokenData: TokenData
tokenData: SingleTokenData
timePeriod: TimePeriod
}) {
const tokenAddress = tokenData?.tokens?.[0].address
const currency = useCurrency(tokenAddress)
const tokenName = tokenData.name
const tokenSymbol = tokenData.symbol
const tokenName = tokenData?.name
const tokenSymbol = tokenData?.tokens?.[0].symbol
const isFavorited = useIsFavorited(tokenAddress)
const toggleFavorite = useToggleFavorite(tokenAddress)
const filterString = useAtomValue(filterStringAtom)
const filterNetwork = useAtomValue(filterNetworkAtom)
const L2Icon = getChainInfo(filterNetwork).circleLogoUrl
const delta = tokenData.percentChange?.[timePeriod]?.value
const arrow = delta ? getDeltaArrow(delta) : null
const formattedDelta = delta ? formatDelta(delta) : null
const tokenDetails = tokenData?.markets?.[0]
const { volume, pricePercentChange } = getDurationDetails(tokenData, timePeriod)
const arrow = pricePercentChange ? getDeltaArrow(pricePercentChange) : null
const formattedDelta = pricePercentChange ? formatDelta(pricePercentChange) : null
const exploreTokenSelectedEventProperties = {
chain_id: filterNetwork,
@ -522,7 +523,7 @@ export default function LoadedRow({
price={
<ClickableContent>
<PriceInfoCell>
{tokenData.price?.value ? formatDollarAmount(tokenData.price?.value) : '-'}
{tokenDetails?.price?.value ? formatDollarAmount(tokenDetails?.price?.value) : '-'}
<PercentChangeInfoCell>
{formattedDelta}
{arrow}
@ -538,16 +539,10 @@ export default function LoadedRow({
}
marketCap={
<ClickableContent>
{tokenData.marketCap?.value ? formatDollarAmount(tokenData.marketCap?.value) : '-'}
</ClickableContent>
}
volume={
<ClickableContent>
{tokenData.volume?.[timePeriod]?.value
? formatDollarAmount(tokenData.volume?.[timePeriod]?.value ?? undefined)
: '-'}
{tokenDetails?.marketCap?.value ? formatDollarAmount(tokenDetails?.marketCap?.value) : '-'}
</ClickableContent>
}
volume={<ClickableContent>{volume ? formatDollarAmount(volume ?? undefined) : '-'}</ClickableContent>}
sparkLine={
<SparkLine>
<ParentSize>{({ width, height }) => <SparklineChart width={width} height={height} />}</ParentSize>

@ -7,7 +7,9 @@ import {
sortCategoryAtom,
sortDirectionAtom,
} from 'components/Tokens/state'
import { TimePeriod, TokenData } from 'graphql/data/TopTokenQuery'
import { TokenTopQuery$data } from 'graphql/data/__generated__/TokenTopQuery.graphql'
import { getDurationDetails, SingleTokenData, useTopTokenQuery } from 'graphql/data/Token'
import { TimePeriod } from 'graphql/data/Token'
import { useAtomValue } from 'jotai/utils'
import { ReactNode, Suspense, useCallback, useMemo } from 'react'
import { AlertTriangle } from 'react-feather'
@ -47,33 +49,37 @@ const TokenRowsContainer = styled.div`
width: 100%;
`
function useFilteredTokens(tokens: TokenData[] | undefined) {
function useFilteredTokens(data: TokenTopQuery$data): SingleTokenData[] | undefined {
const filterString = useAtomValue(filterStringAtom)
const favoriteTokenAddresses = useAtomValue(favoritesAtom)
const favorites = useAtomValue(favoritesAtom)
const showFavorites = useAtomValue(showFavoritesAtom)
const shownTokens =
showFavorites && tokens ? tokens.filter((token) => favoriteTokenAddresses.includes(token.address)) : tokens
return useMemo(
() =>
(shownTokens ?? []).filter((token) => {
if (!token.address) {
return false
}
if (!filterString) {
return true
}
const lowercaseFilterString = filterString.toLowerCase()
const addressIncludesFilterString = token?.address?.toLowerCase().includes(lowercaseFilterString)
const nameIncludesFilterString = token?.name?.toLowerCase().includes(lowercaseFilterString)
const symbolIncludesFilterString = token?.symbol?.toLowerCase().includes(lowercaseFilterString)
return nameIncludesFilterString || symbolIncludesFilterString || addressIncludesFilterString
}),
[shownTokens, filterString]
data.topTokenProjects
?.filter(
(token) => !showFavorites || (token?.tokens?.[0].address && favorites.includes(token?.tokens?.[0].address))
)
.filter((token) => {
const tokenInfo = token?.tokens?.[0]
const address = tokenInfo?.address
if (!address) {
return false
} else if (!filterString) {
return true
} else {
const lowercaseFilterString = filterString.toLowerCase()
const addressIncludesFilterString = address?.toLowerCase().includes(lowercaseFilterString)
const nameIncludesFilterString = token?.name?.toLowerCase().includes(lowercaseFilterString)
const symbolIncludesFilterString = tokenInfo?.symbol?.toLowerCase().includes(lowercaseFilterString)
return nameIncludesFilterString || symbolIncludesFilterString || addressIncludesFilterString
}
}),
[data.topTokenProjects, favorites, filterString, showFavorites]
)
}
function useSortedTokens(tokenData: TokenData[] | null) {
function useSortedTokens(tokenData: SingleTokenData[] | undefined) {
const sortCategory = useAtomValue(sortCategoryAtom)
const sortDirection = useAtomValue(sortDirectionAtom)
const timePeriod = useAtomValue<TimePeriod>(filterTimeAtom)
@ -103,22 +109,25 @@ function useSortedTokens(tokenData: TokenData[] | null) {
}
let a: number | null | undefined
let b: number | null | undefined
const { volume: aVolume, pricePercentChange: aChange } = getDurationDetails(token1, timePeriod)
const { volume: bVolume, pricePercentChange: bChange } = getDurationDetails(token2, timePeriod)
switch (sortCategory) {
case Category.marketCap:
a = token1.marketCap?.value
b = token2.marketCap?.value
a = token1.markets?.[0]?.marketCap?.value
b = token2.markets?.[0]?.marketCap?.value
break
case Category.price:
a = token1.price?.value
b = token2.price?.value
a = token1.markets?.[0]?.price?.value
b = token2.markets?.[0]?.price?.value
break
case Category.volume:
a = token1.volume?.[timePeriod]?.value
b = token2.volume?.[timePeriod]?.value
a = aVolume
b = bVolume
break
case Category.percentChange:
a = token1.percentChange?.[timePeriod]?.value
b = token2.percentChange?.[timePeriod]?.value
a = aChange
b = bChange
break
}
return sortFn(a, b)
@ -149,14 +158,15 @@ export function LoadingTokenTable() {
)
}
export default function TokenTable({ data }: { data: TokenData[] | undefined }) {
export default function TokenTable() {
const showFavorites = useAtomValue<boolean>(showFavoritesAtom)
const timePeriod = useAtomValue<TimePeriod>(filterTimeAtom)
const filteredTokens = useFilteredTokens(data)
const topTokens = useTopTokenQuery(1, timePeriod)
const filteredTokens = useFilteredTokens(topTokens)
const sortedFilteredTokens = useSortedTokens(filteredTokens)
/* loading and error state */
if (data === null) {
if (topTokens === null) {
return (
<NoTokensState
message={
@ -184,8 +194,7 @@ export default function TokenTable({ data }: { data: TokenData[] | undefined })
<TokenRowsContainer>
{sortedFilteredTokens?.map((token, index) => (
<LoadedRow
key={token.address}
tokenAddress={token.address}
key={token?.name}
tokenListIndex={index}
tokenListLength={sortedFilteredTokens.length}
tokenData={token}

@ -1,5 +1,5 @@
import { SupportedChainId } from 'constants/chains'
import { TimePeriod } from 'graphql/data/TopTokenQuery'
import { TimePeriod } from 'graphql/data/Token'
import { atom, useAtom } from 'jotai'
import { atomWithReset, atomWithStorage, useAtomValue } from 'jotai/utils'
import { useCallback, useMemo } from 'react'
@ -15,10 +15,11 @@ export const sortCategoryAtom = atom<Category>(Category.marketCap)
export const sortDirectionAtom = atom<SortDirection>(SortDirection.decreasing)
/* for favoriting tokens */
export function useToggleFavorite(tokenAddress: string) {
export function useToggleFavorite(tokenAddress: string | undefined | null) {
const [favoriteTokens, updateFavoriteTokens] = useAtom(favoritesAtom)
return useCallback(() => {
if (!tokenAddress) return
let updatedFavoriteTokens
if (favoriteTokens.includes(tokenAddress.toLocaleLowerCase())) {
updatedFavoriteTokens = favoriteTokens.filter((address: string) => {
@ -48,8 +49,11 @@ export function useSetSortCategory(category: Category) {
}, [category, sortCategory, setSortCategory, sortDirection, setDirectionCategory])
}
export function useIsFavorited(tokenAddress: string) {
export function useIsFavorited(tokenAddress: string | null | undefined) {
const favoritedTokens = useAtomValue<string[]>(favoritesAtom)
return useMemo(() => favoritedTokens.includes(tokenAddress.toLocaleLowerCase()), [favoritedTokens, tokenAddress])
return useMemo(
() => (tokenAddress ? favoritedTokens.includes(tokenAddress.toLocaleLowerCase()) : false),
[favoritedTokens, tokenAddress]
)
}

@ -1,9 +1,41 @@
import { Environment, Network, RecordSource, Store } from 'relay-runtime'
import ms from 'ms.macro'
import { Variables } from 'react-relay'
import { Environment, Network, RecordSource, RequestParameters, Store } from 'relay-runtime'
import RelayQueryResponseCache from 'relay-runtime/lib/network/RelayQueryResponseCache'
import fetchGraphQL from './fetchGraphQL'
// max number of request in cache, least-recently updated entries purged first
const size = 250
// number in milliseconds, how long records stay valid in cache
const ttl = ms`5m`
export const cache = new RelayQueryResponseCache({ size, ttl })
const fetchQuery = async function wrappedFetchQuery(params: RequestParameters, variables: Variables) {
const queryID = params.name
const cachedData = cache.get(queryID, variables)
if (cachedData !== null) return cachedData
return fetchGraphQL(params, variables).then((data) => {
if (params.operationKind !== 'mutation') {
cache.set(queryID, variables, data)
}
return data
})
}
// This property tells Relay to not immediately clear its cache when the user
// navigates around the app. Relay will hold onto the specified number of
// query results, allowing the user to return to recently visited pages
// and reusing cached data if its available/fresh.
const gcReleaseBufferSize = 10
const store = new Store(new RecordSource(), { gcReleaseBufferSize })
const network = Network.create(fetchQuery)
// Export a singleton instance of Relay Environment configured with our network function:
export default new Environment({
network: Network.create(fetchGraphQL),
store: new Store(new RecordSource()),
network,
store,
})

347
src/graphql/data/Token.ts Normal file

@ -0,0 +1,347 @@
import graphql from 'babel-plugin-relay/macro'
import { useEffect, useState } from 'react'
import { fetchQuery, useFragment, useLazyLoadQuery, useRelayEnvironment } from 'react-relay'
import { TokenPriceQuery } from './__generated__/TokenPriceQuery.graphql'
import { TokenPrices$data, TokenPrices$key } from './__generated__/TokenPrices.graphql'
import { Chain, HistoryDuration, TokenQuery, TokenQuery$data } from './__generated__/TokenQuery.graphql'
import { TokenTopQuery, TokenTopQuery$data } from './__generated__/TokenTopQuery.graphql'
export enum TimePeriod {
HOUR,
DAY,
WEEK,
MONTH,
YEAR,
ALL,
}
function toHistoryDuration(timePeriod: TimePeriod): HistoryDuration {
switch (timePeriod) {
case TimePeriod.HOUR:
return 'HOUR'
case TimePeriod.DAY:
return 'DAY'
case TimePeriod.WEEK:
return 'WEEK'
case TimePeriod.MONTH:
return 'MONTH'
case TimePeriod.YEAR:
return 'YEAR'
case TimePeriod.ALL:
return 'MAX'
}
}
export type PricePoint = { value: number; timestamp: number }
const topTokensQuery = graphql`
query TokenTopQuery($page: Int!, $duration: HistoryDuration!) {
topTokenProjects(orderBy: MARKET_CAP, pageSize: 20, currency: USD, page: $page) {
description
homepageUrl
twitterName
name
tokens {
chain
address
symbol
}
prices: markets(currencies: [USD]) {
...TokenPrices
}
markets(currencies: [USD]) {
price {
value
currency
}
marketCap {
value
currency
}
fullyDilutedMarketCap {
value
currency
}
volume1D: volume(duration: DAY) {
value
currency
}
volume1W: volume(duration: WEEK) {
value
currency
}
volume1M: volume(duration: MONTH) {
value
currency
}
volume1Y: volume(duration: YEAR) {
value
currency
}
pricePercentChange24h {
currency
value
}
pricePercentChange1W: pricePercentChange(duration: WEEK) {
currency
value
}
pricePercentChange1M: pricePercentChange(duration: MONTH) {
currency
value
}
pricePercentChange1Y: pricePercentChange(duration: YEAR) {
currency
value
}
priceHigh52W: priceHighLow(duration: YEAR, highLow: HIGH) {
value
currency
}
priceLow52W: priceHighLow(duration: YEAR, highLow: LOW) {
value
currency
}
}
}
}
`
const tokenPricesFragment = graphql`
fragment TokenPrices on TokenProjectMarket {
priceHistory(duration: $duration) {
timestamp
value
}
}
`
type CachedTopToken = NonNullable<NonNullable<TokenTopQuery$data>['topTokenProjects']>[number]
let cachedTopTokens: Record<string, CachedTopToken> = {}
export function useTopTokenQuery(page: number, timePeriod: TimePeriod) {
const topTokens = useLazyLoadQuery<TokenTopQuery>(topTokensQuery, { page, duration: toHistoryDuration(timePeriod) })
cachedTopTokens =
topTokens.topTokenProjects?.reduce((acc, current) => {
const address = current?.tokens?.[0].address
if (address) acc[address] = current
return acc
}, {} as Record<string, CachedTopToken>) ?? {}
console.log(cachedTopTokens)
return topTokens
}
const tokenQuery = graphql`
query TokenQuery($contract: ContractInput!, $duration: HistoryDuration!, $skip: Boolean = false) {
tokenProjects(contracts: [$contract]) @skip(if: $skip) {
description
homepageUrl
twitterName
name
tokens {
chain
address
symbol
}
prices: markets(currencies: [USD]) {
...TokenPrices
}
markets(currencies: [USD]) {
price {
value
currency
}
marketCap {
value
currency
}
fullyDilutedMarketCap {
value
currency
}
volume1D: volume(duration: DAY) {
value
currency
}
volume1W: volume(duration: WEEK) {
value
currency
}
volume1M: volume(duration: MONTH) {
value
currency
}
volume1Y: volume(duration: YEAR) {
value
currency
}
pricePercentChange24h {
currency
value
}
pricePercentChange1W: pricePercentChange(duration: WEEK) {
currency
value
}
pricePercentChange1M: pricePercentChange(duration: MONTH) {
currency
value
}
pricePercentChange1Y: pricePercentChange(duration: YEAR) {
currency
value
}
priceHigh52W: priceHighLow(duration: YEAR, highLow: HIGH) {
value
currency
}
priceLow52W: priceHighLow(duration: YEAR, highLow: LOW) {
value
currency
}
}
}
}
`
export function useTokenQuery(address: string, chain: Chain, timePeriod: TimePeriod) {
const cachedTopToken = cachedTopTokens[address]
const data = useLazyLoadQuery<TokenQuery>(tokenQuery, {
contract: { address, chain },
duration: toHistoryDuration(timePeriod),
skip: !!cachedTopToken,
})
return !cachedTopToken ? data : { tokenProjects: [{ ...cachedTopToken }] }
}
const tokenPriceQuery = graphql`
query TokenPriceQuery(
$contract: ContractInput!
$skip1H: Boolean!
$skip1D: Boolean!
$skip1W: Boolean!
$skip1M: Boolean!
$skip1Y: Boolean!
$skipMax: Boolean!
) {
tokenProjects(contracts: [$contract]) {
markets(currencies: [USD]) {
priceHistory1H: priceHistory(duration: HOUR) @skip(if: $skip1H) {
timestamp
value
}
priceHistory1D: priceHistory(duration: DAY) @skip(if: $skip1D) {
timestamp
value
}
priceHistory1W: priceHistory(duration: WEEK) @skip(if: $skip1W) {
timestamp
value
}
priceHistory1M: priceHistory(duration: MONTH) @skip(if: $skip1M) {
timestamp
value
}
priceHistory1Y: priceHistory(duration: YEAR) @skip(if: $skip1Y) {
timestamp
value
}
priceHistoryMAX: priceHistory(duration: MAX) @skip(if: $skipMax) {
timestamp
value
}
}
}
}
`
function filterPrices(prices: TokenPrices$data['priceHistory'] | undefined) {
return prices?.filter((p): p is PricePoint => Boolean(p && p.value))
}
export function useTokenPricesCached(
key: TokenPrices$key | null | undefined,
address: string,
chain: Chain,
timePeriod: TimePeriod
) {
// Attempt to use token prices already provided by TokenDetails / TopToken queries
const environment = useRelayEnvironment()
const fetchedTokenPrices = useFragment(tokenPricesFragment, key ?? null)?.priceHistory
const [priceMap, setPriceMap] = useState(
new Map<TimePeriod, PricePoint[] | undefined>([[timePeriod, filterPrices(fetchedTokenPrices)]])
)
function updatePrices(key: TimePeriod, data?: PricePoint[]) {
setPriceMap(new Map(priceMap.set(key, data)))
}
// Fetch the other timePeriods after first render
useEffect(() => {
// Fetch all time periods except the one already populated
fetchQuery<TokenPriceQuery>(environment, tokenPriceQuery, {
contract: { address, chain },
skip1H: timePeriod === TimePeriod.HOUR && !!fetchedTokenPrices,
skip1D: timePeriod === TimePeriod.DAY && !!fetchedTokenPrices,
skip1W: timePeriod === TimePeriod.WEEK && !!fetchedTokenPrices,
skip1M: timePeriod === TimePeriod.MONTH && !!fetchedTokenPrices,
skip1Y: timePeriod === TimePeriod.YEAR && !!fetchedTokenPrices,
skipMax: timePeriod === TimePeriod.ALL && !!fetchedTokenPrices,
}).subscribe({
next: (data) => {
const markets = data.tokenProjects?.[0]?.markets?.[0]
if (markets) {
markets.priceHistory1H && updatePrices(TimePeriod.HOUR, filterPrices(markets.priceHistory1H))
markets.priceHistory1D && updatePrices(TimePeriod.DAY, filterPrices(markets.priceHistory1D))
markets.priceHistory1W && updatePrices(TimePeriod.WEEK, filterPrices(markets.priceHistory1W))
markets.priceHistory1M && updatePrices(TimePeriod.MONTH, filterPrices(markets.priceHistory1M))
markets.priceHistory1Y && updatePrices(TimePeriod.YEAR, filterPrices(markets.priceHistory1Y))
markets.priceHistoryMAX && updatePrices(TimePeriod.ALL, filterPrices(markets.priceHistoryMAX))
}
},
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return { priceMap }
}
export type SingleTokenData = NonNullable<TokenQuery$data['tokenProjects']>[number]
export function getDurationDetails(data: SingleTokenData, timePeriod: TimePeriod) {
let volume = null
let pricePercentChange = null
const markets = data?.markets?.[0]
if (markets) {
switch (timePeriod) {
case TimePeriod.HOUR:
pricePercentChange = null
break
case TimePeriod.DAY:
volume = markets.volume1D?.value
pricePercentChange = markets.pricePercentChange24h?.value
break
case TimePeriod.WEEK:
volume = markets.volume1W?.value
pricePercentChange = markets.pricePercentChange1W?.value
break
case TimePeriod.MONTH:
volume = markets.volume1M?.value
pricePercentChange = markets.pricePercentChange1M?.value
break
case TimePeriod.YEAR:
volume = markets.volume1Y?.value
pricePercentChange = markets.pricePercentChange1Y?.value
break
case TimePeriod.ALL:
volume = null
pricePercentChange = null
break
}
}
return { volume, pricePercentChange }
}

@ -1,89 +0,0 @@
import graphql from 'babel-plugin-relay/macro'
import { SupportedChainId } from 'constants/chains'
import { useLazyLoadQuery } from 'react-relay'
import type { Chain, TokenDetailQuery as TokenDetailQueryType } from './__generated__/TokenDetailQuery.graphql'
export function chainIdToChainName(networkId: SupportedChainId): Chain {
switch (networkId) {
case SupportedChainId.MAINNET:
return 'ETHEREUM'
case SupportedChainId.ARBITRUM_ONE:
return 'ARBITRUM'
case SupportedChainId.OPTIMISM:
return 'OPTIMISM'
case SupportedChainId.POLYGON:
return 'POLYGON'
default:
return 'ETHEREUM'
}
}
export function useTokenDetailQuery(address: string, chain: Chain) {
const tokenDetail = useLazyLoadQuery<TokenDetailQueryType>(
graphql`
query TokenDetailQuery($contract: ContractInput!) {
tokenProjects(contracts: [$contract]) {
description
homepageUrl
twitterName
name
markets(currencies: [USD]) {
price {
value
currency
}
marketCap {
value
currency
}
fullyDilutedMarketCap {
value
currency
}
volume24h: volume(duration: DAY) {
value
currency
}
priceHigh52W: priceHighLow(duration: YEAR, highLow: HIGH) {
value
currency
}
priceLow52W: priceHighLow(duration: YEAR, highLow: LOW) {
value
currency
}
}
tokens {
chain
address
symbol
decimals
}
}
}
`,
{
contract: {
address,
chain,
},
}
)
const { description, homepageUrl, twitterName, name, markets, tokens } = tokenDetail?.tokenProjects?.[0] ?? {}
const { price, marketCap, fullyDilutedMarketCap, volume24h, priceHigh52W, priceLow52W } = markets?.[0] ?? {}
return {
description,
homepageUrl,
twitterName,
name,
markets,
tokens,
price,
marketCap,
fullyDilutedMarketCap,
volume24h,
priceHigh52W,
priceLow52W,
}
}

@ -1,69 +0,0 @@
import graphql from 'babel-plugin-relay/macro'
import { useLazyLoadQuery } from 'react-relay'
import type { Chain, TokenPriceQuery as TokenPriceQueryType } from './__generated__/TokenPriceQuery.graphql'
import { TimePeriod } from './TopTokenQuery'
export function useTokenPriceQuery(address: string, timePeriod: TimePeriod, chain: Chain) {
const tokenPrices = useLazyLoadQuery<TokenPriceQueryType>(
graphql`
query TokenPriceQuery($contract: ContractInput!) {
tokenProjects(contracts: [$contract]) {
name
markets(currencies: [USD]) {
priceHistory1H: priceHistory(duration: HOUR) {
timestamp
value
}
priceHistory1D: priceHistory(duration: DAY) {
timestamp
value
}
priceHistory1W: priceHistory(duration: WEEK) {
timestamp
value
}
priceHistory1M: priceHistory(duration: MONTH) {
timestamp
value
}
priceHistory1Y: priceHistory(duration: YEAR) {
timestamp
value
}
}
tokens {
chain
address
symbol
decimals
}
}
}
`,
{
contract: {
address,
chain,
},
}
)
const { priceHistory1H, priceHistory1D, priceHistory1W, priceHistory1M, priceHistory1Y } =
tokenPrices.tokenProjects?.[0]?.markets?.[0] ?? {}
switch (timePeriod) {
case TimePeriod.HOUR:
return priceHistory1H ?? []
case TimePeriod.DAY:
return priceHistory1D ?? []
case TimePeriod.WEEK:
return priceHistory1W ?? []
case TimePeriod.MONTH:
return priceHistory1M ?? []
case TimePeriod.YEAR:
return priceHistory1Y ?? []
case TimePeriod.ALL:
//TODO: Add functionality for ALL, without requesting it at same time as rest of data for performance reasons
return priceHistory1Y ?? []
}
}

@ -1,147 +0,0 @@
import graphql from 'babel-plugin-relay/macro'
import { useLazyLoadQuery } from 'react-relay'
import type { Chain, Currency, TopTokenQuery as TopTokenQueryType } from './__generated__/TopTokenQuery.graphql'
export enum TimePeriod {
HOUR,
DAY,
WEEK,
MONTH,
YEAR,
ALL,
}
interface IAmount {
currency: Currency | null
value: number | null
}
export type TokenData = {
name: string | null
address: string
chain: Chain | null
symbol: string | null
price: IAmount | null | undefined
marketCap: IAmount | null | undefined
volume: Record<TimePeriod, IAmount | null | undefined>
percentChange: Record<TimePeriod, IAmount | null | undefined>
}
export interface UseTopTokensResult {
data: TokenData[] | undefined
error: string | null
loading: boolean
}
export function useTopTokenQuery(page: number) {
const topTokenData = useLazyLoadQuery<TopTokenQueryType>(
graphql`
query TopTokenQuery($page: Int!) {
topTokenProjects(orderBy: MARKET_CAP, pageSize: 100, currency: USD, page: $page) {
name
tokens {
chain
address
symbol
}
markets(currencies: [USD]) {
price {
value
currency
}
marketCap {
value
currency
}
fullyDilutedMarketCap {
value
currency
}
volume1H: volume(duration: HOUR) {
value
currency
}
volume1D: volume(duration: DAY) {
value
currency
}
volume1W: volume(duration: WEEK) {
value
currency
}
volume1M: volume(duration: MONTH) {
value
currency
}
volume1Y: volume(duration: YEAR) {
value
currency
}
volumeAll: volume(duration: MAX) {
value
currency
}
pricePercentChange1H: pricePercentChange(duration: HOUR) {
currency
value
}
pricePercentChange24h {
currency
value
}
pricePercentChange1W: pricePercentChange(duration: WEEK) {
currency
value
}
pricePercentChange1M: pricePercentChange(duration: MONTH) {
currency
value
}
pricePercentChange1Y: pricePercentChange(duration: YEAR) {
currency
value
}
pricePercentChangeAll: pricePercentChange(duration: MAX) {
currency
value
}
}
}
}
`,
{
page,
}
)
const topTokens: TokenData[] | undefined = topTokenData.topTokenProjects?.map((token) =>
token?.tokens?.[0].address
? {
name: token?.name,
address: token?.tokens?.[0].address,
chain: token?.tokens?.[0].chain,
symbol: token?.tokens?.[0].symbol,
price: token?.markets?.[0]?.price,
marketCap: token?.markets?.[0]?.marketCap,
volume: {
[TimePeriod.HOUR]: token?.markets?.[0]?.volume1H,
[TimePeriod.DAY]: token?.markets?.[0]?.volume1D,
[TimePeriod.WEEK]: token?.markets?.[0]?.volume1W,
[TimePeriod.MONTH]: token?.markets?.[0]?.volume1M,
[TimePeriod.YEAR]: token?.markets?.[0]?.volume1Y,
[TimePeriod.ALL]: token?.markets?.[0]?.volumeAll,
},
percentChange: {
[TimePeriod.HOUR]: token?.markets?.[0]?.pricePercentChange1H,
[TimePeriod.DAY]: token?.markets?.[0]?.pricePercentChange24h,
[TimePeriod.WEEK]: token?.markets?.[0]?.pricePercentChange1W,
[TimePeriod.MONTH]: token?.markets?.[0]?.pricePercentChange1M,
[TimePeriod.YEAR]: token?.markets?.[0]?.pricePercentChange1Y,
[TimePeriod.ALL]: token?.markets?.[0]?.pricePercentChangeAll,
},
}
: ({} as TokenData)
)
return topTokens
}

@ -43,10 +43,8 @@ export function useNetworkTokenBalances({ address }: useNetworkTokenBalancesArgs
const waitRandom = (min: number, max: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, min + Math.round(Math.random() * Math.max(0, max - min))))
try {
console.log('useNetworkTokenBalances.fetchNetworkTokenBalances', query)
setLoading(true)
setError(null)
console.log('useNetworkTokenBalances.fetchNetworkTokenBalances', address)
await waitRandom(250, 2000)
if (Math.random() < 0.05) {
throw new Error('fake error')

@ -5,6 +5,7 @@ import {
MOBILE_MEDIA_BREAKPOINT,
SMALL_MEDIA_BREAKPOINT,
} from 'components/Tokens/constants'
import { filterTimeAtom } from 'components/Tokens/state'
import BalanceSummary from 'components/Tokens/TokenDetails/BalanceSummary'
import FooterBalanceSummary from 'components/Tokens/TokenDetails/FooterBalanceSummary'
import LoadingTokenDetail from 'components/Tokens/TokenDetails/LoadingTokenDetail'
@ -16,10 +17,12 @@ 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 { checkWarning } from 'constants/tokenSafety'
import { useTokenQuery } from 'graphql/data/Token'
import { useIsUserAddedToken, useToken } from 'hooks/Tokens'
import { useNetworkTokenBalances } from 'hooks/useNetworkTokenBalances'
import { useAtomValue } from 'jotai/utils'
import { useCallback, useMemo, useState } from 'react'
import { Navigate, useLocation, useNavigate, useParams } from 'react-router-dom'
import { useNavigate, useParams } from 'react-router-dom'
import styled from 'styled-components/macro'
const Footer = styled.div`
@ -77,7 +80,6 @@ function NetworkBalances(tokenAddress: string | undefined) {
}
export default function TokenDetails() {
const location = useLocation()
const { tokenAddress } = useParams<{ tokenAddress?: string }>()
const token = useToken(tokenAddress)
const tokenWarning = tokenAddress ? checkWarning(tokenAddress) : null
@ -115,6 +117,9 @@ export default function TokenDetails() {
return chainIds
}, [connectedChainId])
const timePeriod = useAtomValue(filterTimeAtom)
const query = useTokenQuery(tokenAddress ?? '', 'ETHEREUM', timePeriod)
const balancesByNetwork = networkData
? chainsToList.map((chainId) => {
const amount = networkData[chainId]
@ -137,15 +142,17 @@ export default function TokenDetails() {
})
: null
if (token === undefined) {
return <Navigate to={{ ...location, pathname: '/tokens' }} replace />
}
// TODO: Fix this logic to not automatically redirect on refresh, yet still catch invalid addresses
//const location = useLocation()
//if (token === undefined) {
// return <Navigate to={{ ...location, pathname: '/tokens' }} replace />
//}
return (
<TokenDetailsLayout>
{token && (
<>
<TokenDetail address={token.address} />
<TokenDetail address={token.address} query={query} />
<RightPanel>
<Widget defaultToken={token ?? undefined} onReviewSwapClick={onReviewSwap} />
{tokenWarning && <TokenSafetyMessage tokenAddress={token.address} warning={tokenWarning} />}

@ -9,7 +9,6 @@ import SearchBar from 'components/Tokens/TokenTable/SearchBar'
import TimeSelector from 'components/Tokens/TokenTable/TimeSelector'
import TokenTable, { LoadingTokenTable } from 'components/Tokens/TokenTable/TokenTable'
import { TokensNetworkFilterVariant, useTokensNetworkFilterFlag } from 'featureFlags/flags/tokensNetworkFilter'
import { useTopTokenQuery } from 'graphql/data/TopTokenQuery'
import { useResetAtom } from 'jotai/utils'
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'
@ -69,7 +68,6 @@ const FiltersWrapper = styled.div`
`
const Tokens = () => {
const topTokens = useTopTokenQuery(1)
const tokensNetworkFilterFlag = useTokensNetworkFilterFlag()
const resetFilterString = useResetAtom(filterStringAtom)
const location = useLocation()
@ -98,7 +96,7 @@ const Tokens = () => {
</FiltersWrapper>
<TokenTableContainer>
<TokenTable data={topTokens} />
<TokenTable />
</TokenTableContainer>
</ExploreContainer>
</Trace>