diff --git a/apis/birdeye/helpers.ts b/apis/birdeye/helpers.ts index b3c61fd7..fdc8123d 100644 --- a/apis/birdeye/helpers.ts +++ b/apis/birdeye/helpers.ts @@ -1,5 +1,5 @@ import Decimal from 'decimal.js' -import { BirdeyePriceResponse } from 'hooks/useBirdeyeMarketPrices' +import { BirdeyePriceResponse } from 'types' import { DAILY_SECONDS } from 'utils/constants' /* eslint-disable @typescript-eslint/no-explicit-any */ diff --git a/components/explore/PerpMarketsTable.tsx b/components/explore/PerpMarketsTable.tsx index 76140c40..dfd0349c 100644 --- a/components/explore/PerpMarketsTable.tsx +++ b/components/explore/PerpMarketsTable.tsx @@ -33,10 +33,7 @@ const PerpMarketsTable = () => { const showTableView = width ? width > breakpoints.md : false const rate = usePerpFundingRate() const router = useRouter() - const { perpMarketsWithData, isLoading, isFetching } = - useListedMarketsWithMarketData() - - const loadingMarketData = isLoading || isFetching + const { perpMarketsWithData, isLoading } = useListedMarketsWithMarketData() return ( @@ -126,8 +123,8 @@ const PerpMarketsTable = () => { - {!loadingMarketData ? ( - priceHistory && priceHistory.length ? ( + {!isLoading ? ( + priceHistory && priceHistory?.length ? (
{ return ( ) diff --git a/components/explore/RecentGainersLosers.tsx b/components/explore/RecentGainersLosers.tsx index 06e1c1c5..141151ab 100644 --- a/components/explore/RecentGainersLosers.tsx +++ b/components/explore/RecentGainersLosers.tsx @@ -98,10 +98,7 @@ const RecentGainersLosers = () => { for (const token of banksWithMarketData) { const volume = token.market?.marketData?.quote_volume_24h || 0 if (token.market?.quoteTokenIndex === 0 && volume > 0) { - const pastPrice = token.market?.marketData?.price_24h - const change = pastPrice - ? ((token.bank.uiPrice - pastPrice) / pastPrice) * 100 - : 0 + const change = token.market?.rollingChange || 0 tradeableAssets.push({ bank: token.bank, change, type: 'spot' }) } } diff --git a/components/explore/Spot.tsx b/components/explore/Spot.tsx index 17493f06..14a12c65 100644 --- a/components/explore/Spot.tsx +++ b/components/explore/Spot.tsx @@ -17,6 +17,7 @@ import Input from '@components/forms/Input' import EmptyState from '@components/nftMarket/EmptyState' import { Bank } from '@blockworks-foundation/mango-v4' import useBanks from 'hooks/useBanks' +import SheenLoader from '@components/shared/SheenLoader' export type BankWithMarketData = { bank: Bank @@ -91,7 +92,8 @@ const Spot = () => { const { t } = useTranslation(['common', 'explore', 'trade']) const { group } = useMangoGroup() const { banks } = useBanks() - const { serumMarketsWithData } = useListedMarketsWithMarketData() + const { serumMarketsWithData, isLoading: loadingMarketsData } = + useListedMarketsWithMarketData() const [sortByKey, setSortByKey] = useState('quote_volume_24h') const [search, setSearch] = useState('') const [showTableView, setShowTableView] = useState(true) @@ -169,7 +171,15 @@ const Spot = () => {
- {sortedTokensToShow.length ? ( + {loadingMarketsData ? ( +
+ {[...Array(4)].map((x, i) => ( + +
+ + ))} +
+ ) : sortedTokensToShow.length ? ( showTableView ? (
diff --git a/components/explore/SpotCards.tsx b/components/explore/SpotCards.tsx index b7f1222c..d1358d9d 100644 --- a/components/explore/SpotCards.tsx +++ b/components/explore/SpotCards.tsx @@ -14,7 +14,6 @@ import Tooltip from '@components/shared/Tooltip' import SimpleAreaChart from '@components/shared/SimpleAreaChart' import { COLORS } from 'styles/colors' import useThemeWrapper from 'hooks/useThemeWrapper' -import dayjs from 'dayjs' import TokenReduceOnlyDesc from '@components/shared/TokenReduceOnlyDesc' import CollateralWeightDisplay from '@components/shared/CollateralWeightDisplay' @@ -38,18 +37,15 @@ const SpotCards = ({ tokens }: { tokens: BankWithMarketData[] }) => { ).mul(bank.uiPrice) const depositRate = bank.getDepositRateUi() const borrowRate = bank.getBorrowRateUi() - const pastPrice = token.market?.marketData?.price_24h - const volume = token.market?.marketData?.quote_volume_24h || 0 - const change = - volume > 0 && pastPrice - ? ((bank.uiPrice - pastPrice) / pastPrice) * 100 - : 0 + const chartData = token?.market?.priceHistory?.length + ? token.market.priceHistory + ?.sort((a, b) => a.time - b.time) + .concat([{ price: bank.uiPrice, time: Date.now() }]) + : [] - const chartData = - token.market?.marketData?.price_history - ?.sort((a, b) => a.time.localeCompare(b.time)) - .concat([{ price: bank.uiPrice, time: dayjs().toISOString() }]) || - [] + const volume = token.market?.marketData?.quote_volume_24h || 0 + + const change = token.market?.rollingChange || 0 return (
{ -
- - - - {token.market ? ( - - ) : null} -
+ {bank.uiPrice ? ( +
+ + + + {token.market ? ( + + ) : null} +
+ ) : null}
{chartData.length ? ( @@ -98,11 +96,9 @@ const SpotCards = ({ tokens }: { tokens: BankWithMarketData[] }) => {

{!token.market ? ( '–' - ) : token.market?.marketData?.quote_volume_24h ? ( + ) : volume ? ( - {numberCompacter.format( - token.market.marketData.quote_volume_24h, - )}{' '} + {numberCompacter.format(volume)}{' '} USDC ) : ( diff --git a/components/explore/SpotTable.tsx b/components/explore/SpotTable.tsx index c640370a..6371d288 100644 --- a/components/explore/SpotTable.tsx +++ b/components/explore/SpotTable.tsx @@ -30,7 +30,6 @@ import BankAmountWithValue from '@components/shared/BankAmountWithValue' import { BankWithMarketData } from './Spot' import { SerumMarketWithMarketData } from 'hooks/useListedMarketsWithMarketData' import Tooltip from '@components/shared/Tooltip' -import dayjs from 'dayjs' import TableTokenName from '@components/shared/TableTokenName' import { LinkButton } from '@components/shared/Button' import { formatTokenSymbol } from 'utils/tokens' @@ -48,7 +47,7 @@ type TableData = { price: number priceHistory: { price: number - time: string + time: number }[] volume: number isUp: boolean @@ -69,17 +68,15 @@ const SpotTable = ({ tokens }: { tokens: BankWithMarketData[] }) => { const baseBank = token.bank const price = baseBank.uiPrice - const pastPrice = token.market?.marketData?.price_24h - - const priceHistory = - token.market?.marketData?.price_history - ?.sort((a, b) => a.time.localeCompare(b.time)) - .concat([{ price: price, time: dayjs().toISOString() }]) || [] + const priceHistory = token?.market?.priceHistory?.length + ? token.market.priceHistory + ?.sort((a, b) => a.time - b.time) + .concat([{ price: price, time: Date.now() }]) + : [] const volume = token.market?.marketData?.quote_volume_24h || 0 - const change = - volume > 0 && pastPrice ? ((price - pastPrice) / pastPrice) * 100 : 0 + const change = token.market?.rollingChange || 0 const tokenName = baseBank.name @@ -272,7 +269,7 @@ const SpotTable = ({ tokens }: { tokens: BankWithMarketData[] }) => {

- {market ? ( + {market && price ? ( ) : ( diff --git a/components/shared/MarketChange.tsx b/components/shared/MarketChange.tsx index 1d41e426..e8c8afea 100644 --- a/components/shared/MarketChange.tsx +++ b/components/shared/MarketChange.tsx @@ -13,7 +13,7 @@ const MarketChange = ({ market: PerpMarket | Serum3Market | undefined size?: 'small' }) => { - const { perpMarketsWithData, serumMarketsWithData, isLoading, isFetching } = + const { perpMarketsWithData, serumMarketsWithData, isLoading } = useListedMarketsWithMarketData() const change = useMemo(() => { @@ -23,18 +23,16 @@ const MarketChange = ({ const perpMarket = perpMarketsWithData.find( (m) => m.name.toLowerCase() === market.name.toLowerCase(), ) - return perpMarket ? perpMarket.rollingChange : 0 + return perpMarket?.rollingChange ? perpMarket.rollingChange : 0 } else { const spotMarket = serumMarketsWithData.find( (m) => m.name.toLowerCase() === market.name.toLowerCase(), ) - return spotMarket ? spotMarket.rollingChange : 0 + return spotMarket?.rollingChange ? spotMarket.rollingChange : 0 } }, [perpMarketsWithData, serumMarketsWithData]) - const loading = isLoading || isFetching - - return loading ? ( + return isLoading ? (
diff --git a/components/token/CoingeckoStats.tsx b/components/token/CoingeckoStats.tsx index 33daef41..3c15eb95 100644 --- a/components/token/CoingeckoStats.tsx +++ b/components/token/CoingeckoStats.tsx @@ -6,13 +6,13 @@ import { useQuery } from '@tanstack/react-query' import { makeApiRequest } from 'apis/birdeye/helpers' import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' -import { BirdeyePriceResponse } from 'hooks/useBirdeyeMarketPrices' import parse from 'html-react-parser' import { useTranslation } from 'next-i18next' import { useMemo, useState } from 'react' import { DAILY_SECONDS } from 'utils/constants' import DetailedAreaOrBarChart from '@components/shared/DetailedAreaOrBarChart' import { countLeadingZeros, formatCurrencyValue } from 'utils/numbers' +import { BirdeyePriceResponse } from 'types' dayjs.extend(relativeTime) const DEFAULT_COINGECKO_VALUES = { diff --git a/components/trade/MarketSelectDropdown.tsx b/components/trade/MarketSelectDropdown.tsx index d24e5f77..a31a537f 100644 --- a/components/trade/MarketSelectDropdown.tsx +++ b/components/trade/MarketSelectDropdown.tsx @@ -55,7 +55,7 @@ const MarketSelectDropdown = () => { const [isOpen, setIsOpen] = useState(false) const { group } = useMangoGroup() const [spotBaseFilter, setSpotBaseFilter] = useState('All') - const { perpMarketsWithData, serumMarketsWithData, isLoading, isFetching } = + const { perpMarketsWithData, serumMarketsWithData, isLoading } = useListedMarketsWithMarketData() const { isDesktop } = useViewport() const focusRef = useRef(null) @@ -130,8 +130,6 @@ const MarketSelectDropdown = () => { } }, [focusRef, isDesktop, isOpen, spotOrPerp]) - const loadingMarketData = isLoading || isFetching - return ( {({ open, close }) => ( @@ -269,7 +267,7 @@ const MarketSelectDropdown = () => {
- {loadingMarketData ? ( + {isLoading ? (
@@ -446,7 +444,7 @@ const MarketSelectDropdown = () => {
- {loadingMarketData ? ( + {isLoading ? (
diff --git a/hooks/useBirdeye24hrPrices.ts b/hooks/useBirdeye24hrPrices.ts new file mode 100644 index 00000000..cf0102fa --- /dev/null +++ b/hooks/useBirdeye24hrPrices.ts @@ -0,0 +1,88 @@ +import { Group, Serum3Market } from '@blockworks-foundation/mango-v4' +import mangoStore from '@store/mangoStore' +import { useQuery } from '@tanstack/react-query' +import { makeApiRequest } from 'apis/birdeye/helpers' +import useMangoGroup from './useMangoGroup' +import { DAILY_SECONDS } from 'utils/constants' + +const fetchBirdeye24hrPrices = async ( + group: Group | undefined, + spotMarkets: Serum3Market[], +) => { + if (!group) return [] + + try { + const queryEnd = Math.floor(Date.now() / 1000) + const queryStart = queryEnd - DAILY_SECONDS + + // collect unique quote tokens + const uniqueQuoteTokens = Array.from( + new Set( + spotMarkets.map((market) => { + const quoteBank = group.getFirstBankByTokenIndex( + market.quoteTokenIndex, + ) + return quoteBank?.mint + }), + ), + ).filter(Boolean) // remove any undefined values + + // fetch responses for unique quote tokens + const quoteResponses = await Promise.all( + uniqueQuoteTokens.map(async (quoteToken) => { + const quoteQuery = `defi/history_price?address=${quoteToken}&address_type=token&type=1H&time_from=${queryStart}&time_to=${queryEnd}` + const quoteResponse = await makeApiRequest(quoteQuery) + return { + quoteToken, + items: quoteResponse?.data?.items?.length + ? quoteResponse.data.items + : [], + } + }), + ) + + // create a map for quick access to quote items based on quoteToken + const quoteItemsMap = new Map( + quoteResponses.map((response) => [response.quoteToken, response.items]), + ) + + // fetch base responses and match them with quote items + const promises = spotMarkets.map(async (market) => { + const baseBank = group.getFirstBankByTokenIndex(market.baseTokenIndex) + const quoteBank = group.getFirstBankByTokenIndex(market.quoteTokenIndex) + + const baseQuery = `defi/history_price?address=${baseBank?.mint}&address_type=token&type=1H&time_from=${queryStart}&time_to=${queryEnd}` + + const baseResponse = await makeApiRequest(baseQuery) + + return { + base: baseResponse?.data?.items?.length ? baseResponse.data.items : [], + quote: quoteItemsMap.get(quoteBank?.mint) || [], + marketIndex: market.marketIndex, + } + }) + + const responses = await Promise.all(promises) + + return responses + } catch (e) { + console.error('error fetching 24-hour price data from birdeye', e) + return [] + } +} + +export const useBirdeye24hrPrices = () => { + const spotMarkets = mangoStore((s) => s.serumMarkets) + const { group } = useMangoGroup() + return useQuery( + ['birdeye-daily-prices'], + () => fetchBirdeye24hrPrices(group, spotMarkets), + { + cacheTime: 1000 * 60 * 15, + staleTime: 1000 * 60 * 10, + retry: 3, + enabled: !!(group && spotMarkets?.length), + refetchOnWindowFocus: false, + }, + ) +} diff --git a/hooks/useBirdeyeMarketPrices.ts b/hooks/useBirdeyeMarketPrices.ts deleted file mode 100644 index 8164a702..00000000 --- a/hooks/useBirdeyeMarketPrices.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Serum3Market } from '@blockworks-foundation/mango-v4' -import mangoStore from '@store/mangoStore' -import { useQuery } from '@tanstack/react-query' -import { makeApiRequest } from 'apis/birdeye/helpers' -import { DAILY_SECONDS } from 'utils/constants' - -export interface BirdeyePriceResponse { - address: string - unixTime: number - value: number -} - -const fetchBirdeyePrices = async ( - spotMarkets: Serum3Market[], -): Promise<{ data: BirdeyePriceResponse[]; mint: string }[]> => { - const mints = spotMarkets.map((market) => - market.serumMarketExternal.toString(), - ) - - const promises = [] - const queryEnd = Math.floor(Date.now() / 1000) - const queryStart = queryEnd - DAILY_SECONDS - for (const mint of mints) { - const query = `defi/history_price?address=${mint}&address_type=pair&type=30m&time_from=${queryStart}&time_to=${queryEnd}` - promises.push(makeApiRequest(query)) - } - - const responses = await Promise.all(promises) - if (responses?.length) { - return responses.map((res) => ({ - data: res.data.items, - mint: res.data.items[0]?.address, - })) - } - - return [] -} - -export const useBirdeyeMarketPrices = () => { - const spotMarkets = mangoStore((s) => s.serumMarkets) - const res = useQuery( - ['birdeye-market-prices'], - () => fetchBirdeyePrices(spotMarkets), - { - cacheTime: 1000 * 60 * 15, - staleTime: 1000 * 60 * 10, - retry: 3, - enabled: !!spotMarkets?.length, - refetchOnWindowFocus: false, - }, - ) - - return { - isFetching: res?.isFetching, - isLoading: res?.isLoading, - data: res?.data || [], - } -} diff --git a/hooks/useListedMarketsWithMarketData.ts b/hooks/useListedMarketsWithMarketData.ts index 2a4b0259..7fffa5c4 100644 --- a/hooks/useListedMarketsWithMarketData.ts +++ b/hooks/useListedMarketsWithMarketData.ts @@ -3,6 +3,7 @@ import useMarketsData from './useMarketsData' import { useMemo } from 'react' import mangoStore from '@store/mangoStore' import { PerpMarket, Serum3Market } from '@blockworks-foundation/mango-v4' +import { useBirdeye24hrPrices } from './useBirdeye24hrPrices' type ApiData = { marketData: MarketsDataItem | undefined @@ -10,6 +11,7 @@ type ApiData = { type MarketRollingChange = { rollingChange: number | undefined + priceHistory: Array<{ price: number; time: number }> } export type SerumMarketWithMarketData = Serum3Market & @@ -21,7 +23,12 @@ export type PerpMarketWithMarketData = PerpMarket & MarketRollingChange export default function useListedMarketsWithMarketData() { - const { data: marketsData, isLoading, isFetching } = useMarketsData() + const { data: marketsData, isInitialLoading: loadingMarketsData } = + useMarketsData() + const { + data: birdeyeSpotDailyPrices, + isInitialLoading: loadingBirdeyeSpotDailyPrices, + } = useBirdeye24hrPrices() const serumMarkets = mangoStore((s) => s.serumMarkets) const perpMarkets = mangoStore((s) => s.perpMarkets) @@ -62,27 +69,43 @@ export default function useListedMarketsWithMarketData() { if (!serumMarkets || !serumMarkets.length) return [] const allSpotMarkets: SerumMarketWithMarketData[] = serumMarkets as SerumMarketWithMarketData[] - if (spotData) { + if (spotData && birdeyeSpotDailyPrices?.length) { for (const market of allSpotMarkets) { const spotEntries = Object.entries(spotData).find( (e) => e[0].toLowerCase() === market.name.toLowerCase(), ) + const birdeyePrices = birdeyeSpotDailyPrices.find( + (prices) => prices.marketIndex === market.marketIndex, + ) + const priceHistory = [] + let pastPrice = 0 + if (birdeyePrices?.base?.length && birdeyePrices?.quote?.length) { + pastPrice = + birdeyePrices.base[0]?.value / birdeyePrices.quote[0]?.value + for (let i = 0; i < birdeyePrices.base.length; i++) { + const base = birdeyePrices.base[i] + const quote = birdeyePrices.quote[i] + if (base.unixTime === quote?.unixTime && quote?.value) { + const price = base.value / quote.value + const time = base.unixTime + priceHistory.push({ price, time }) + } + } + } // calculate price change - const pastPrice = spotEntries ? spotEntries[1][0]?.price_24h : 0 - const dailyVolume = spotEntries - ? spotEntries[1][0]?.quote_volume_24h - : 0 const currentPrice = currentPrices[market.name] - const change = - dailyVolume > 0 ? ((currentPrice - pastPrice) / pastPrice) * 100 : 0 + const change = currentPrice + ? ((currentPrice - pastPrice) / pastPrice) * 100 + : 0 market.rollingChange = change + market.priceHistory = priceHistory market.marketData = spotEntries ? spotEntries[1][0] : undefined } } return [...allSpotMarkets].sort((a, b) => a.name.localeCompare(b.name)) - }, [spotData, serumMarkets]) + }, [currentPrices, birdeyeSpotDailyPrices, spotData, serumMarkets]) const perpMarketsWithData = useMemo(() => { if (!perpMarkets || !perpMarkets.length) return [] @@ -111,5 +134,7 @@ export default function useListedMarketsWithMarketData() { ) }, [perpData, perpMarkets]) - return { perpMarketsWithData, serumMarketsWithData, isLoading, isFetching } + const isLoading = loadingMarketsData || loadingBirdeyeSpotDailyPrices + + return { perpMarketsWithData, serumMarketsWithData, isLoading } } diff --git a/types/index.ts b/types/index.ts index c538d3cd..f0e4dfe7 100644 --- a/types/index.ts +++ b/types/index.ts @@ -498,6 +498,12 @@ export function isMangoError(error: unknown): error is MangoError { ) } +export interface BirdeyePriceResponse { + address: string + unixTime: number + value: number +} + export type MarketData = { [key: string]: MarketsDataItem[] } export type MarketsDataItem = {