Commit 751ba3c6 authored by Zach Pomerantz's avatar Zach Pomerantz Committed by GitHub

fix: token price (#5018)

* feat: add decimals, wrapper to token query

* fix: construct token in details page

* fix: clean widget default behavior

* fix: actual defaulting again

* fix: split token from price queries

* fix: reimplement TokenPrice query

* chore: rm old code

* fix: keep loading chart while loading

* fix: mv loader down

* fix: loading chart
parent 83bc6db7
import { SparkLineLoadingBubble } from 'components/Tokens/TokenTable/TokenRow'
import { curveCardinal, scaleLinear } from 'd3'
import { PricePoint } from 'graphql/data/Token'
import { PricePoint } from 'graphql/data/TokenPrice'
import { SparklineMap, TopToken } from 'graphql/data/TopTokens'
import { TimePeriod } from 'graphql/data/util'
import { memo } from 'react'
......
......@@ -3,12 +3,12 @@ import { Currency, NativeCurrency, Token } from '@uniswap/sdk-core'
import { ParentSize } from '@visx/responsive'
import CurrencyLogo from 'components/CurrencyLogo'
import { getChainInfo } from 'constants/chainInfo'
import { PriceDurations, PricePoint, SingleTokenData } from 'graphql/data/Token'
import { TokenQueryData } from 'graphql/data/Token'
import { PriceDurations } from 'graphql/data/TokenPrice'
import { TopToken } from 'graphql/data/TopTokens'
import { CHAIN_NAME_TO_CHAIN_ID, TimePeriod } from 'graphql/data/util'
import { CHAIN_NAME_TO_CHAIN_ID } from 'graphql/data/util'
import { useAtomValue } from 'jotai/utils'
import useCurrencyLogoURIs from 'lib/hooks/useCurrencyLogoURIs'
import { useMemo } from 'react'
import styled from 'styled-components/macro'
import { textFadeIn } from 'theme/animations'
......@@ -54,7 +54,7 @@ const TokenActions = styled.div`
`
export function useTokenLogoURI(
token: NonNullable<SingleTokenData> | NonNullable<TopToken>,
token: NonNullable<TokenQueryData> | NonNullable<TopToken>,
nativeCurrency?: Token | NativeCurrency
) {
const chainId = CHAIN_NAME_TO_CHAIN_ID[token.chain]
......@@ -71,10 +71,10 @@ export default function ChartSection({
nativeCurrency,
prices,
}: {
token: NonNullable<SingleTokenData>
token: NonNullable<TokenQueryData>
currency?: Currency | null
nativeCurrency?: Token | NativeCurrency
prices: PriceDurations
prices?: PriceDurations
}) {
const chainId = CHAIN_NAME_TO_CHAIN_ID[token.chain]
const L2Icon = getChainInfo(chainId)?.circleLogoUrl
......@@ -82,26 +82,6 @@ export default function ChartSection({
const logoSrc = useTokenLogoURI(token, nativeCurrency)
// Backend doesn't always return latest price point for every duration.
// Thus we need to manually determine latest price point available, and
// append it to the prices list for every duration.
useMemo(() => {
let latestPricePoint: PricePoint = { value: 0, timestamp: 0 }
let latestPricePointTimePeriod: TimePeriod
Object.keys(prices).forEach((key) => {
const latestPricePointForTimePeriod = prices[key as unknown as TimePeriod]?.slice(-1)[0]
if (latestPricePointForTimePeriod && latestPricePointForTimePeriod.timestamp > latestPricePoint.timestamp) {
latestPricePoint = latestPricePointForTimePeriod
latestPricePointTimePeriod = key as unknown as TimePeriod
}
})
Object.keys(prices).forEach((key) => {
if ((key as unknown as TimePeriod) !== latestPricePointTimePeriod) {
prices[key as unknown as TimePeriod]?.push(latestPricePoint)
}
})
}, [prices])
return (
<ChartHeader>
<TokenInfoContainer>
......@@ -124,7 +104,7 @@ export default function ChartSection({
</TokenInfoContainer>
<ChartContainer>
<ParentSize>
{({ width }) => prices && <PriceChart prices={prices[timePeriod]} width={width} height={436} />}
{({ width }) => <PriceChart prices={prices ? prices?.[timePeriod] : null} width={width} height={436} />}
</ParentSize>
</ChartContainer>
</ChartHeader>
......
......@@ -7,7 +7,7 @@ import { Line } from '@visx/shape'
import AnimatedInLineChart from 'components/Charts/AnimatedInLineChart'
import { filterTimeAtom } from 'components/Tokens/state'
import { bisect, curveCardinal, NumberValue, scaleLinear, timeDay, timeHour, timeMinute, timeMonth } from 'd3'
import { PricePoint } from 'graphql/data/Token'
import { PricePoint } from 'graphql/data/TokenPrice'
import { TimePeriod } from 'graphql/data/util'
import { useActiveLocale } from 'hooks/useActiveLocale'
import { useAtom } from 'jotai'
......@@ -59,7 +59,7 @@ export function getDeltaArrow(delta: number | null | undefined) {
export function formatDelta(delta: number | null | undefined) {
// Null-check not including zero
if (delta === null || delta === undefined) {
if (delta === null || delta === undefined || delta === Infinity || isNaN(delta)) {
return '-'
}
let formattedDelta = delta.toFixed(2) + '%'
......@@ -133,7 +133,7 @@ const timeOptionsHeight = 44
interface PriceChartProps {
width: number
height: number
prices: PricePoint[] | undefined
prices: PricePoint[] | undefined | null
}
export function PriceChart({ width, height, prices }: PriceChartProps) {
......@@ -281,7 +281,15 @@ export function PriceChart({ width, height, prices }: PriceChartProps) {
<MissingPriceChart
width={width}
height={graphHeight}
message={prices && prices.length === 0 ? <NoV3DataMessage /> : <MissingDataMessage />}
message={
prices === null ? (
<Trans>Loading chart data</Trans>
) : prices?.length === 0 ? (
<Trans>This token doesn&apos;t have chart data because it hasn&apos;t been traded on Uniswap v3</Trans>
) : (
<Trans>Missing chart data</Trans>
)
}
/>
) : (
<svg width={width} height={graphHeight} style={{ minWidth: '100%' }}>
......@@ -395,11 +403,6 @@ const StyledMissingChart = styled.svg`
const chartBottomPadding = 15
const NoV3DataMessage = () => (
<Trans>This token doesn&apos;t have chart data because it hasn&apos;t been traded on Uniswap v3</Trans>
)
const MissingDataMessage = () => <Trans>Missing chart data</Trans>
function MissingPriceChart({ width, height, message }: { width: number; height: number; message: ReactNode }) {
const theme = useTheme()
const midPoint = height / 2 + 45
......
import { Trans } from '@lingui/macro'
import { NATIVE_CHAIN_ID } from 'constants/tokens'
import { SingleTokenData } from 'graphql/data/Token'
import { TokenQueryData } from 'graphql/data/Token'
import { useOnClickOutside } from 'hooks/useOnClickOutside'
import { useRef } from 'react'
import { Twitter } from 'react-feather'
......@@ -64,7 +64,7 @@ const ShareAction = styled.div`
`
interface TokenInfo {
token: NonNullable<SingleTokenData>
token: NonNullable<TokenQueryData>
isNative: boolean
}
......
import graphql from 'babel-plugin-relay/macro'
import { DEFAULT_ERC20_DECIMALS } from 'constants/tokens'
import { useMemo, useState } from 'react'
import { fetchQuery, useLazyLoadQuery } from 'react-relay'
import { useMemo } from 'react'
import { useLazyLoadQuery } from 'react-relay'
import { WrappedTokenInfo } from 'state/lists/wrappedTokenInfo'
import { Chain, TokenPriceQuery } from './__generated__/TokenPriceQuery.graphql'
import { ContractInput, HistoryDuration, TokenQuery, TokenQuery$data } from './__generated__/TokenQuery.graphql'
import environment from './RelayEnvironment'
import { CHAIN_NAME_TO_CHAIN_ID, TimePeriod, toHistoryDuration } from './util'
import { Chain } from './__generated__/TokenPriceQuery.graphql'
import { TokenQuery, TokenQuery$data } from './__generated__/TokenQuery.graphql'
import { CHAIN_NAME_TO_CHAIN_ID } from './util'
/*
The difference between Token and TokenProject:
......@@ -18,7 +17,7 @@ The difference between Token and TokenProject:
TokenProjectMarket is aggregated market data (aggregated over multiple dexes and centralized exchanges) that we get from coingecko.
*/
const tokenQuery = graphql`
query TokenQuery($contract: ContractInput!, $duration: HistoryDuration!) {
query TokenQuery($contract: ContractInput!) {
tokens(contracts: [$contract]) {
id @required(action: LOG)
decimals
......@@ -31,10 +30,6 @@ const tokenQuery = graphql`
value
currency
}
priceHistory(duration: $duration) {
timestamp
value
}
price {
value
currency
......@@ -64,120 +59,17 @@ const tokenQuery = graphql`
}
`
const tokenPriceQuery = graphql`
query TokenPriceQuery(
$contract: ContractInput!
$skip1H: Boolean!
$skip1D: Boolean!
$skip1W: Boolean!
$skip1M: Boolean!
$skip1Y: Boolean!
) {
tokens(contracts: [$contract]) {
market(currency: 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
}
}
}
}
`
export type PricePoint = { value: number; timestamp: number }
export function filterPrices(prices: NonNullable<NonNullable<SingleTokenData>['market']>['priceHistory'] | undefined) {
return prices?.filter((p): p is PricePoint => Boolean(p && p.value))
}
export type PriceDurations = Record<TimePeriod, PricePoint[] | undefined>
function fetchAllPriceDurations(contract: ContractInput, originalDuration: HistoryDuration) {
return fetchQuery<TokenPriceQuery>(environment, tokenPriceQuery, {
contract,
skip1H: originalDuration === 'HOUR',
skip1D: originalDuration === 'DAY',
skip1W: originalDuration === 'WEEK',
skip1M: originalDuration === 'MONTH',
skip1Y: originalDuration === 'YEAR',
})
}
export type SingleTokenData = NonNullable<TokenQuery$data['tokens']>[number]
export function useTokenQuery(
address: string,
chain: Chain,
timePeriod: TimePeriod
): [SingleTokenData | undefined, PriceDurations] {
const [prices, setPrices] = useState<PriceDurations>({
[TimePeriod.HOUR]: undefined,
[TimePeriod.DAY]: undefined,
[TimePeriod.WEEK]: undefined,
[TimePeriod.MONTH]: undefined,
[TimePeriod.YEAR]: undefined,
})
const contract = useMemo(() => {
return { address: address.toLowerCase(), chain }
}, [address, chain])
// eslint-disable-next-line react-hooks/exhaustive-deps
const originalTimePeriod = useMemo(() => timePeriod, [contract])
const updatePrices = (response: TokenPriceQuery['response']) => {
const priceData = response.tokens?.[0]?.market
if (priceData) {
setPrices((current) => {
return {
[TimePeriod.HOUR]: filterPrices(priceData.priceHistory1H) ?? current[TimePeriod.HOUR],
[TimePeriod.DAY]: filterPrices(priceData.priceHistory1D) ?? current[TimePeriod.DAY],
[TimePeriod.WEEK]: filterPrices(priceData.priceHistory1W) ?? current[TimePeriod.WEEK],
[TimePeriod.MONTH]: filterPrices(priceData.priceHistory1M) ?? current[TimePeriod.MONTH],
[TimePeriod.YEAR]: filterPrices(priceData.priceHistory1Y) ?? current[TimePeriod.YEAR],
}
})
}
}
// Fetch prices & token info in tandem so we can render faster
useMemo(
() => fetchAllPriceDurations(contract, toHistoryDuration(originalTimePeriod)).subscribe({ next: updatePrices }),
[contract, originalTimePeriod]
)
const token = useLazyLoadQuery<TokenQuery>(tokenQuery, {
contract,
duration: toHistoryDuration(originalTimePeriod),
}).tokens?.[0]
useMemo(
() =>
setPrices((current) => {
current[originalTimePeriod] = filterPrices(token?.market?.priceHistory)
return current
}),
[originalTimePeriod, token?.market?.priceHistory]
)
export type TokenQueryData = NonNullable<TokenQuery$data['tokens']>[number]
return [token, prices]
export function useTokenQuery(address: string, chain: Chain): TokenQueryData | undefined {
const contract = useMemo(() => ({ address: address.toLowerCase(), chain }), [address, chain])
const token = useLazyLoadQuery<TokenQuery>(tokenQuery, { contract }).tokens?.[0]
return token
}
// TODO: Return a QueryToken from useTokenQuery instead of SingleTokenData to make it more usable in Currency-centric interfaces.
// TODO: Return a QueryToken from useTokenQuery instead of TokenQueryData to make it more usable in Currency-centric interfaces.
export class QueryToken extends WrappedTokenInfo {
constructor(data: NonNullable<SingleTokenData>) {
constructor(data: NonNullable<TokenQueryData>) {
super({
chainId: CHAIN_NAME_TO_CHAIN_ID[data.chain],
address: data.address,
......
import graphql from 'babel-plugin-relay/macro'
import { useEffect, useMemo, useState } from 'react'
import { fetchQuery } from 'react-relay'
import { Chain, TokenPriceQuery } from './__generated__/TokenPriceQuery.graphql'
import environment from './RelayEnvironment'
import { TimePeriod } from './util'
const tokenPriceQuery = graphql`
query TokenPriceQuery($contract: ContractInput!) {
tokens(contracts: [$contract]) {
market(currency: 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
}
}
}
}
`
export type PricePoint = { timestamp: number; value: number }
export type PriceDurations = Partial<Record<TimePeriod, PricePoint[]>>
export function isPricePoint(p: { timestamp: number; value: number | null } | null): p is PricePoint {
return Boolean(p && p.value)
}
export function useTokenPriceQuery(address: string, chain: Chain): PriceDurations | undefined {
const contract = useMemo(() => ({ address: address.toLowerCase(), chain }), [address, chain])
const [prices, setPrices] = useState<PriceDurations>()
useEffect(() => {
const subscription = fetchQuery<TokenPriceQuery>(environment, tokenPriceQuery, { contract }).subscribe({
next: (response: TokenPriceQuery['response']) => {
const priceData = response.tokens?.[0]?.market
const prices = {
[TimePeriod.HOUR]: priceData?.priceHistory1H?.filter(isPricePoint),
[TimePeriod.DAY]: priceData?.priceHistory1D?.filter(isPricePoint),
[TimePeriod.WEEK]: priceData?.priceHistory1W?.filter(isPricePoint),
[TimePeriod.MONTH]: priceData?.priceHistory1M?.filter(isPricePoint),
[TimePeriod.YEAR]: priceData?.priceHistory1Y?.filter(isPricePoint),
}
// Ensure the latest price available is available for every TimePeriod.
const latests = Object.values(prices)
.map((prices) => prices?.slice(-1)?.[0] ?? null)
.filter(isPricePoint)
if (latests.length) {
const latest = latests.reduce((latest, pricePoint) =>
latest.timestamp > pricePoint.timestamp ? latest : pricePoint
)
Object.values(prices)
.filter((prices) => prices && prices.slice(-1)[0] !== latest)
.forEach((prices) => prices?.push(latest))
}
setPrices(prices)
},
})
return () => {
setPrices(undefined)
subscription.unsubscribe()
}
}, [contract])
return prices
}
......@@ -12,7 +12,7 @@ import { fetchQuery, useLazyLoadQuery, useRelayEnvironment } from 'react-relay'
import type { Chain, TopTokens100Query } from './__generated__/TopTokens100Query.graphql'
import { TopTokensSparklineQuery } from './__generated__/TopTokensSparklineQuery.graphql'
import { filterPrices, PricePoint } from './Token'
import { isPricePoint, PricePoint } from './TokenPrice'
import { CHAIN_NAME_TO_CHAIN_ID, toHistoryDuration, unwrapToken } from './util'
const topTokens100Query = graphql`
......@@ -137,7 +137,8 @@ export function useTopTokens(chain: Chain): UseTopTokensReturnValue {
next(data) {
const map: SparklineMap = {}
data.topTokens?.forEach(
(current) => current?.address && (map[current.address] = filterPrices(current?.market?.priceHistory))
(current) =>
current?.address && (map[current.address] = current?.market?.priceHistory?.filter(isPricePoint))
)
setSparklines(map)
},
......
......@@ -2,7 +2,7 @@ import { SupportedChainId } from 'constants/chains'
import { ZERO_ADDRESS } from 'constants/misc'
import { NATIVE_CHAIN_ID, nativeOnChain, WRAPPED_NATIVE_CURRENCY } from 'constants/tokens'
import { Chain, HistoryDuration } from './__generated__/TokenQuery.graphql'
import { Chain, HistoryDuration } from './__generated__/TopTokens100Query.graphql'
export enum TimePeriod {
HOUR,
......
import { Currency, Token } from '@uniswap/sdk-core'
import { PageName } from 'analytics/constants'
import { Trace } from 'analytics/Trace'
import { filterTimeAtom } from 'components/Tokens/state'
import { AboutSection } from 'components/Tokens/TokenDetails/About'
import AddressSection from 'components/Tokens/TokenDetails/AddressSection'
import BalanceSummary from 'components/Tokens/TokenDetails/BalanceSummary'
......@@ -22,10 +21,10 @@ import { DEFAULT_ERC20_DECIMALS, NATIVE_CHAIN_ID, nativeOnChain } from 'constant
import { checkWarning } from 'constants/tokenSafety'
import { Chain } from 'graphql/data/__generated__/TokenQuery.graphql'
import { QueryToken, useTokenQuery } from 'graphql/data/Token'
import { useTokenPriceQuery } from 'graphql/data/TokenPrice'
import { CHAIN_NAME_TO_CHAIN_ID, validateUrlChainParam } from 'graphql/data/util'
import { useIsUserAddedTokenOnChain } from 'hooks/Tokens'
import { useOnGlobalChainSwitch } from 'hooks/useGlobalChainSwitch'
import { useAtomValue } from 'jotai/utils'
import { useCallback, useMemo, useState, useTransition } from 'react'
import { ArrowLeft } from 'react-feather'
import { useNavigate, useParams } from 'react-router-dom'
......@@ -35,13 +34,9 @@ export default function TokenDetails() {
const chain = validateUrlChainParam(chainName)
const pageChainId = CHAIN_NAME_TO_CHAIN_ID[chain]
const nativeCurrency = nativeOnChain(pageChainId)
const timePeriod = useAtomValue(filterTimeAtom)
const isNative = tokenAddress === NATIVE_CHAIN_ID
const [tokenQueryData, prices] = useTokenQuery(
isNative ? nativeCurrency.wrapped.address : tokenAddress ?? '',
chain,
timePeriod
)
const tokenQueryData = useTokenQuery(isNative ? nativeCurrency.wrapped.address : tokenAddress ?? '', chain)
const prices = useTokenPriceQuery(isNative ? nativeCurrency.wrapped.address : tokenAddress ?? '', chain)
const token = useMemo(() => {
if (!tokenAddress) return undefined
if (isNative) return nativeCurrency
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment