From e23d58dd0e22da59968b1e8dcd0150da9c3e048c Mon Sep 17 00:00:00 2001 From: Hayden Shively <17186559+haydenshively@users.noreply.github.com> Date: Tue, 21 May 2024 23:20:30 -0500 Subject: [PATCH] Create usePriceRelay tanstack hook (#865) --- .../components/boost/ImportBoostWidget.tsx | 2 +- earn/src/data/PriceRelayResponse.ts | 19 -- earn/src/pages/MarketsPage.tsx | 71 +------- earn/src/pages/PortfolioPage.tsx | 87 +++------- earn/src/pages/boost/ManageBoostPage.tsx | 2 +- shared/src/data/constants/Values.ts | 8 +- shared/src/data/hooks/UsePriceRelay.ts | 162 ++++++++++++++++++ 7 files changed, 207 insertions(+), 144 deletions(-) create mode 100644 shared/src/data/hooks/UsePriceRelay.ts diff --git a/earn/src/components/boost/ImportBoostWidget.tsx b/earn/src/components/boost/ImportBoostWidget.tsx index cbc5b361..6a57c0e7 100644 --- a/earn/src/components/boost/ImportBoostWidget.tsx +++ b/earn/src/components/boost/ImportBoostWidget.tsx @@ -20,6 +20,7 @@ import { GN, GNFormat } from 'shared/lib/data/GoodNumber'; import useChain from 'shared/lib/data/hooks/UseChain'; import { useChainDependentState } from 'shared/lib/data/hooks/UseChainDependentState'; import { useLendingPair, useLendingPairs } from 'shared/lib/data/hooks/UseLendingPairs'; +import { PriceRelayLatestResponse } from 'shared/lib/data/hooks/UsePriceRelay'; import { Token } from 'shared/lib/data/Token'; import { getTokenBySymbol } from 'shared/lib/data/TokenData'; import { @@ -46,7 +47,6 @@ import { import SlippageWidget from './SlippageWidget'; import { fetchListOfBorrowerNfts } from '../../data/BorrowerNft'; import { API_PRICE_RELAY_LATEST_URL } from '../../data/constants/Values'; -import { PriceRelayLatestResponse } from '../../data/PriceRelayResponse'; import { BoostCardInfo } from '../../data/Uniboost'; import { BOOST_MAX, BOOST_MIN } from '../../pages/boost/ImportBoostPage'; import { useEthersProvider } from '../../util/Provider'; diff --git a/earn/src/data/PriceRelayResponse.ts b/earn/src/data/PriceRelayResponse.ts index cfb98add..e69de29b 100644 --- a/earn/src/data/PriceRelayResponse.ts +++ b/earn/src/data/PriceRelayResponse.ts @@ -1,19 +0,0 @@ -export type PriceRelayLatestResponse = { - [key: string]: { - price: number; - }; -}; - -export type PriceRelayHistoricalResponse = { - [key: string]: { - prices: { - price: number; - timestamp: number; - }[]; - }; -}; - -export type PriceRelayConsolidatedResponse = { - latest: PriceRelayLatestResponse; - historical: PriceRelayHistoricalResponse; -}; diff --git a/earn/src/pages/MarketsPage.tsx b/earn/src/pages/MarketsPage.tsx index 61bac041..1671a4f6 100644 --- a/earn/src/pages/MarketsPage.tsx +++ b/earn/src/pages/MarketsPage.tsx @@ -1,7 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { type WriteContractReturnType } from '@wagmi/core'; -import axios, { AxiosResponse } from 'axios'; import { useSearchParams } from 'react-router-dom'; import AppPage from 'shared/lib/components/common/AppPage'; import { Display, Text } from 'shared/lib/components/common/Typography'; @@ -11,6 +10,7 @@ import { GetNumericFeeTier } from 'shared/lib/data/FeeTier'; import useChain from 'shared/lib/data/hooks/UseChain'; import { useChainDependentState } from 'shared/lib/data/hooks/UseChainDependentState'; import { useLendingPairs } from 'shared/lib/data/hooks/UseLendingPairs'; +import { useLatestPriceRelay } from 'shared/lib/data/hooks/UsePriceRelay'; import { getLendingPairBalances, LendingPairBalancesMap } from 'shared/lib/data/LendingPair'; import { Token } from 'shared/lib/data/Token'; import { formatUSDAuto } from 'shared/lib/util/Numbers'; @@ -26,9 +26,7 @@ import InfoTab from '../components/markets/monitor/InfoTab'; import SupplyTable, { SupplyTableRow } from '../components/markets/supply/SupplyTable'; import { BorrowerNftBorrower, fetchListOfFuse2BorrowNfts } from '../data/BorrowerNft'; import { ZERO_ADDRESS } from '../data/constants/Addresses'; -import { API_PRICE_RELAY_LATEST_URL } from '../data/constants/Values'; import { fetchBorrowerDatas, UniswapPoolInfo } from '../data/MarginAccount'; -import { PriceRelayLatestResponse } from '../data/PriceRelayResponse'; import { getProminentColor } from '../util/Colors'; import { useEthersProvider } from '../util/Provider'; @@ -78,13 +76,10 @@ enum TabOption { Liquidate = 'liquidate', } -type TokenSymbol = string; -type Quote = number; - export default function MarketsPage() { const activeChain = useChain(); // MARK: component state - const [tokenQuotes, setTokenQuotes] = useChainDependentState>(new Map(), activeChain.id); + // const [tokenQuotes, setTokenQuotes] = useChainDependentState>(new Map(), activeChain.id); const [balancesMap, setBalancesMap] = useChainDependentState(new Map(), activeChain.id); const [borrowers, setBorrowers] = useChainDependentState(null, activeChain.id); const [tokenColors, setTokenColors] = useChainDependentState>(new Map(), activeChain.id); @@ -107,6 +102,7 @@ export default function MarketsPage() { // MARK: custom hooks const { lendingPairs, refetchOracleData, refetchLenderData } = useLendingPairs(activeChain.id); + const { data: tokenQuotes } = useLatestPriceRelay(lendingPairs); useWatchBlockNumber({ onBlockNumber(/* blockNumber */) { @@ -182,55 +178,6 @@ export default function MarketsPage() { })(); }, [uniqueTokens, setTokenColors]); - // MARK: Fetching token prices - useEffect(() => { - let mounted = true; - (async () => { - // Determine set of unique token symbols (tickers) - const symbolSet = new Set(); - lendingPairs.forEach((pair) => { - symbolSet.add(pair.token0.symbol); - symbolSet.add(pair.token1.symbol); - }); - const uniqueSymbols = Array.from(symbolSet.values()); - - // Return early if there's nothing new to fetch - if ( - uniqueSymbols.length === 0 || - uniqueSymbols.every((symbol) => { - return tokenQuotes.has(symbol.toLowerCase()) || (symbol === 'USDC.e' && tokenQuotes.has('USDC')); - }) - ) { - return; - } - - // Query API for price data, returning early if request fails - let quoteDataResponse: AxiosResponse; - try { - quoteDataResponse = await axios.get( - `${API_PRICE_RELAY_LATEST_URL}?symbols=${uniqueSymbols.join(',').toUpperCase()}` - ); - } catch { - return; - } - const prResponse: PriceRelayLatestResponse = quoteDataResponse.data; - if (!prResponse) return; - - // Convert response to the desired Map format - const symbolToPriceMap = new Map(); - Object.entries(prResponse).forEach(([k, v]) => { - symbolToPriceMap.set(k.toLowerCase(), v.price); - symbolToPriceMap.set(k, v.price); - }); - - if (mounted) setTokenQuotes(symbolToPriceMap); - })(); - - return () => { - mounted = false; - }; - }, [lendingPairs, tokenQuotes, setTokenQuotes]); - // MARK: Fetching token balances useEffect(() => { (async () => { @@ -280,8 +227,8 @@ export default function MarketsPage() { const isToken0Weth = pair.token0.name === 'Wrapped Ether'; const isToken1Weth = pair.token1.name === 'Wrapped Ether'; - const token0Price = tokenQuotes.get(pair.token0.symbol) || 0; - const token1Price = tokenQuotes.get(pair.token1.symbol) || 0; + const token0Price = tokenQuotes?.get(pair.token0.symbol) || 0; + const token1Price = tokenQuotes?.get(pair.token1.symbol) || 0; const token0Balance = (balancesMap.get(pair.token0.address)?.value || 0) + ((isToken0Weth && ethBalance?.value) || 0); const token1Balance = @@ -351,7 +298,7 @@ export default function MarketsPage() { lendingPairs={lendingPairs} uniqueTokens={uniqueTokens} tokenBalances={balancesMap} - tokenQuotes={tokenQuotes} + tokenQuotes={tokenQuotes!} tokenColors={tokenColors} setPendingTxn={setPendingTxn} /> @@ -374,7 +321,7 @@ export default function MarketsPage() { ); @@ -388,8 +335,8 @@ export default function MarketsPage() { const totalBorrowed = useMemo(() => { return lendingPairs.reduce((acc, pair) => { - const token0Price = tokenQuotes.get(pair.token0.symbol) || 0; - const token1Price = tokenQuotes.get(pair.token1.symbol) || 0; + const token0Price = tokenQuotes?.get(pair.token0.symbol) || 0; + const token1Price = tokenQuotes?.get(pair.token1.symbol) || 0; const token0BorrowedUsd = pair.kitty0Info.totalBorrows.toNumber() * token0Price; const token1BorrowedUsd = pair.kitty1Info.totalBorrows.toNumber() * token1Price; return acc + token0BorrowedUsd + token1BorrowedUsd; diff --git a/earn/src/pages/PortfolioPage.tsx b/earn/src/pages/PortfolioPage.tsx index cd50bb35..ecd93f25 100644 --- a/earn/src/pages/PortfolioPage.tsx +++ b/earn/src/pages/PortfolioPage.tsx @@ -1,7 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { type WriteContractReturnType } from '@wagmi/core'; -import axios, { AxiosResponse } from 'axios'; import { useNavigate } from 'react-router-dom'; import AppPage from 'shared/lib/components/common/AppPage'; import { Text } from 'shared/lib/components/common/Typography'; @@ -9,6 +8,7 @@ import { GREY_700 } from 'shared/lib/data/constants/Colors'; import useChain from 'shared/lib/data/hooks/UseChain'; import { useChainDependentState } from 'shared/lib/data/hooks/UseChainDependentState'; import { useLendingPairs } from 'shared/lib/data/hooks/UseLendingPairs'; +import { useConsolidatedPriceRelay } from 'shared/lib/data/hooks/UsePriceRelay'; import { getLendingPairBalances, LendingPairBalances } from 'shared/lib/data/LendingPair'; import { Token } from 'shared/lib/data/Token'; import { getTokenBySymbol } from 'shared/lib/data/TokenData'; @@ -33,8 +33,6 @@ import PortfolioBalance from '../components/portfolio/PortfolioBalance'; import PortfolioGrid from '../components/portfolio/PortfolioGrid'; import PortfolioPageWidgetWrapper from '../components/portfolio/PortfolioPageWidgetWrapper'; import { RESPONSIVE_BREAKPOINT_SM, RESPONSIVE_BREAKPOINT_XS } from '../data/constants/Breakpoints'; -import { API_PRICE_RELAY_CONSOLIDATED_URL } from '../data/constants/Values'; -import { PriceRelayConsolidatedResponse } from '../data/PriceRelayResponse'; import { getProminentColor } from '../util/Colors'; import { useEthersProvider } from '../util/Provider'; @@ -107,14 +105,10 @@ export default function PortfolioPage() { const [pendingTxn, setPendingTxn] = useState(null); const [tokenColors, setTokenColors] = useState>(new Map()); - const [tokenQuotes, setTokenQuotes] = useChainDependentState([], activeChain.id); const [lendingPairBalances, setLendingPairBalances] = useChainDependentState( [], activeChain.id ); - const [tokenPriceData, setTokenPriceData] = useState([]); - const [isLoadingPrices, setIsLoadingPrices] = useState(true); - const [errorLoadingPrices, setErrorLoadingPrices] = useState(false); const [activeAsset, setActiveAsset] = useState(null); const [isSendCryptoModalOpen, setIsSendCryptoModalOpen] = useState(false); const [isEarnInterestModalOpen, setIsEarnInterestModalOpen] = useState(false); @@ -124,15 +118,19 @@ export default function PortfolioPage() { const [pendingTxnModalStatus, setPendingTxnModalStatus] = useState(null); const { lendingPairs } = useLendingPairs(activeChain.id); + const { + data: consolidatedPriceData, + isPending: isPendingPrices, + isFetching: isFetchingPrices, + isError: errorLoadingPrices, + } = useConsolidatedPriceRelay(lendingPairs, 2 * 60 * 1_000); + const isLoadingPrices = isPendingPrices || isFetchingPrices || consolidatedPriceData?.latestPrices.size === 0; + const client = useClient({ chainId: activeChain.id }); const provider = useEthersProvider(client); const { address, isConnecting, isConnected } = useAccount(); const navigate = useNavigate(); - useEffect(() => { - setIsLoadingPrices(true); - }, [activeChain.id, setIsLoadingPrices]); - const uniqueTokens = useMemo(() => { const tokens = new Set(); lendingPairs.forEach((pair) => { @@ -142,55 +140,24 @@ export default function PortfolioPage() { return Array.from(tokens); }, [lendingPairs]); - /** - * Get the latest and historical prices for all tokens - */ - useEffect(() => { - (async () => { - // Only fetch prices for tokens if they are all on the same (active) chain - if (uniqueTokens.length > 0 && uniqueTokens.some((token) => token.chainId !== activeChain.id)) { - return; - } - const symbols = uniqueTokens - .map((token) => token?.symbol) - .filter((symbol) => symbol !== undefined) - .join(','); - if (symbols.length === 0) { - return; - } - let priceRelayResponses: AxiosResponse | null = null; - try { - priceRelayResponses = await axios.get(`${API_PRICE_RELAY_CONSOLIDATED_URL}?symbols=${symbols}`); - } catch (error) { - setErrorLoadingPrices(true); - setIsLoadingPrices(false); - return; - } - if (priceRelayResponses == null) { - return; - } - const latestPriceResponse = priceRelayResponses.data.latest; - const historicalPriceResponse = priceRelayResponses.data.historical; - if (!latestPriceResponse || !historicalPriceResponse) { - return; - } - const tokenQuoteData: TokenQuote[] = Object.entries(latestPriceResponse).map(([symbol, data]) => { - return { - token: getTokenBySymbol(activeChain.id, symbol), - price: data.price, - }; - }); - const tokenPriceData: TokenPriceData[] = Object.entries(historicalPriceResponse).map(([symbol, data]) => { - return { - token: getTokenBySymbol(activeChain.id, symbol), - priceEntries: data.prices, - }; - }); - setTokenQuotes(tokenQuoteData); - setTokenPriceData(tokenPriceData); - setIsLoadingPrices(false); - })(); - }, [activeChain, uniqueTokens, setTokenQuotes, setTokenPriceData, setIsLoadingPrices, setErrorLoadingPrices]); + const { tokenQuotes, tokenPriceData } = useMemo(() => { + if (!consolidatedPriceData) + return { + tokenQuotes: [], + tokenPriceData: [], + }; + + return { + tokenQuotes: Array.from(consolidatedPriceData.latestPrices.entries()).map(([k, v]) => ({ + token: getTokenBySymbol(activeChain.id, k), + price: v, + })), + tokenPriceData: Array.from(consolidatedPriceData.historicalPrices.entries()).map(([k, v]) => ({ + token: getTokenBySymbol(activeChain.id, k), + priceEntries: v, + })), + }; + }, [activeChain.id, consolidatedPriceData]); useEffect(() => { (async () => { diff --git a/earn/src/pages/boost/ManageBoostPage.tsx b/earn/src/pages/boost/ManageBoostPage.tsx index a8162c00..98356748 100644 --- a/earn/src/pages/boost/ManageBoostPage.tsx +++ b/earn/src/pages/boost/ManageBoostPage.tsx @@ -13,6 +13,7 @@ import { sqrtRatioToTick } from 'shared/lib/data/BalanceSheet'; import { ALOE_II_BORROWER_NFT_ADDRESS } from 'shared/lib/data/constants/ChainSpecific'; import useChain from 'shared/lib/data/hooks/UseChain'; import { useChainDependentState } from 'shared/lib/data/hooks/UseChainDependentState'; +import { PriceRelayLatestResponse } from 'shared/lib/data/hooks/UsePriceRelay'; import styled from 'styled-components'; import { Address } from 'viem'; import { Config, useAccount, useClient, usePublicClient, useReadContract } from 'wagmi'; @@ -22,7 +23,6 @@ import BurnBoostModal from '../../components/boost/BurnBoostModal'; import CollectFeesWidget from '../../components/boost/CollectFeesWidget'; import PendingTxnModal, { PendingTxnModalStatus } from '../../components/common/PendingTxnModal'; import { API_PRICE_RELAY_LATEST_URL } from '../../data/constants/Values'; -import { PriceRelayLatestResponse } from '../../data/PriceRelayResponse'; import { BoostCardInfo, BoostCardType, fetchBoostBorrower } from '../../data/Uniboost'; import { getProminentColor, rgb } from '../../util/Colors'; import { useEthersProvider } from '../../util/Provider'; diff --git a/shared/src/data/constants/Values.ts b/shared/src/data/constants/Values.ts index 249e5e23..904e316e 100644 --- a/shared/src/data/constants/Values.ts +++ b/shared/src/data/constants/Values.ts @@ -5,12 +5,18 @@ import { toBig } from '../../util/Numbers'; export const DEFAULT_CHAIN = optimism; export const DEFAULT_ETHERSCAN_URL = 'https://etherscan.io'; export const Q32 = 0x100000000; + export const API_GEO_FENCING_URL = 'https://geo-fencing.aloe.capital/v1/verify'; export const API_SCREENING_URL = 'https://geo-fencing.aloe.capital/v1/screen'; +export const API_PRICE_RELAY_LATEST_URL = 'https://api-price.aloe.capital/price-relay/v1/latest'; +export const API_PRICE_RELAY_HISTORICAL_URL = 'https://api-price.aloe.capital/price-relay/v1/historical'; +export const API_PRICE_RELAY_CONSOLIDATED_URL = 'https://api-price.aloe.capital/price-relay/v1/consolidated'; +export const API_LEADERBOARD_URL = 'https://leaderboard.aloe.capital/v1/leaderboard'; + export const NOTIFICATION_BOT_URL = 'https://t.me/aloe_notifier_bot'; export const TERMS_OF_SERVICE_URL = 'https://aloe.capital/legal/terms-of-service'; export const PRIVACY_POLICY_URL = 'https://aloe.capital/legal/privacy-policy'; -export const API_LEADERBOARD_URL = 'https://leaderboard.aloe.capital/v1/leaderboard'; + export const LAUNCH_DATE = new Date('2024-01-02T06:00:00.000Z'); // 12 AM CST on Jan 2, 2024 export const DEAD_ADDRESS = '0xdead00000000000000000000000000000000dead'; export const ROUTER_TRANSMITTANCE = 9999; diff --git a/shared/src/data/hooks/UsePriceRelay.ts b/shared/src/data/hooks/UsePriceRelay.ts new file mode 100644 index 00000000..3d18a5ab --- /dev/null +++ b/shared/src/data/hooks/UsePriceRelay.ts @@ -0,0 +1,162 @@ +import { useMemo } from 'react'; +import { LendingPair } from '../LendingPair'; +import axios from 'axios'; +import { useQuery } from '@tanstack/react-query'; +import { + API_PRICE_RELAY_LATEST_URL, + API_PRICE_RELAY_HISTORICAL_URL, + API_PRICE_RELAY_CONSOLIDATED_URL, +} from '../constants/Values'; + +type TokenSymbol = string; +type Quote = number; + +export type PriceRelayLatestResponse = { + [key: TokenSymbol]: { + price: Quote; + }; +}; + +export type PriceRelayHistoricalResponse = { + [key: TokenSymbol]: { + prices: { + price: Quote; + timestamp: number; + }[]; + }; +}; + +export type PriceRelayConsolidatedResponse = { + latest: PriceRelayLatestResponse; + historical: PriceRelayHistoricalResponse; +}; + +/// Compute unique tokens, sorted alphabetically +function commaSeparatedSymbolsFor(lendingPairs: LendingPair[]) { + const symbolSet = new Set(); + lendingPairs.forEach((pair) => { + symbolSet.add(pair.token0.symbol); + symbolSet.add(pair.token1.symbol); + }); + if (symbolSet.has('USDC.e')) { + symbolSet.delete('USDC.e'); + symbolSet.add('USDC'); + } + + const symbols = Array.from(symbolSet); + symbols.sort((a, b) => a.localeCompare(b)); + return symbols.join(',').toUpperCase(); +} + +function toLatestPrices(response: PriceRelayLatestResponse) { + const latestPrices = new Map(); + Object.entries(response).forEach(([k, v]) => { + latestPrices.set(k, v.price); + latestPrices.set(k.toLowerCase(), v.price); + if (k.toLowerCase() === 'usdc') { + latestPrices.set('usdc.e', v.price); + latestPrices.set('USDC.e', v.price); + } + }); + return latestPrices; +} + +function toHistoricalPrices(response: PriceRelayHistoricalResponse) { + const historicalPrices = new Map(); + Object.entries(response).forEach(([k, v]) => { + historicalPrices.set(k, v.prices); + historicalPrices.set(k.toLowerCase(), v.prices); + if (k.toLowerCase() === 'usdc') { + historicalPrices.set('usdc.e', v.prices); + historicalPrices.set('USDC.e', v.prices); + } + }); + return historicalPrices; +} + +export function useLatestPriceRelay(lendingPairs: LendingPair[], staleTime = 60 * 1_000) { + const commaSeparatedSymbols = useMemo(() => commaSeparatedSymbolsFor(lendingPairs), [lendingPairs]); + + const queryFn = async () => { + const response = (await axios.get(`${API_PRICE_RELAY_LATEST_URL}?symbols=${commaSeparatedSymbols}`)).data; + if (!response) { + throw new Error('Price relay failed to respond.'); + } + return toLatestPrices(response); + }; + + const queryKey = ['usePriceRelay', 'latest', commaSeparatedSymbols]; + + return useQuery({ + queryKey, + queryFn, + staleTime, + refetchOnMount: true, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + refetchInterval: staleTime, + refetchIntervalInBackground: false, + placeholderData: new Map(), + enabled: lendingPairs.length > 0, + }); +} + +export function useHistoricalPriceRelay(lendingPairs: LendingPair[], staleTime = 20 * 60 * 1_000) { + const commaSeparatedSymbols = useMemo(() => commaSeparatedSymbolsFor(lendingPairs), [lendingPairs]); + + const queryFn = async () => { + const response = (await axios.get(`${API_PRICE_RELAY_HISTORICAL_URL}?symbols=${commaSeparatedSymbols}`)).data; + if (!response) { + throw new Error('Price relay failed to respond.'); + } + return toHistoricalPrices(response); + }; + + const queryKey = ['usePriceRelay', 'historical', commaSeparatedSymbols]; + + return useQuery({ + queryKey, + queryFn, + staleTime, + refetchOnMount: true, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + refetchInterval: staleTime, + refetchIntervalInBackground: false, + placeholderData: new Map(), + enabled: lendingPairs.length > 0, + }); +} + +export function useConsolidatedPriceRelay(lendingPairs: LendingPair[], staleTime = 60 * 1_000) { + const commaSeparatedSymbols = useMemo(() => commaSeparatedSymbolsFor(lendingPairs), [lendingPairs]); + + const queryFn = async () => { + const response = (await axios.get(`${API_PRICE_RELAY_CONSOLIDATED_URL}?symbols=${commaSeparatedSymbols}`)).data; + if (!response) { + throw new Error('Price relay failed to respond.'); + } + return { + latestPrices: toLatestPrices(response.latest), + historicalPrices: toHistoricalPrices(response.historical), + }; + }; + + const queryKey = ['usePriceRelay', 'consolidated', commaSeparatedSymbols]; + + return useQuery({ + queryKey, + queryFn, + staleTime, + refetchOnMount: true, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + refetchInterval: staleTime, + refetchIntervalInBackground: false, + placeholderData: { + latestPrices: new Map(), + historicalPrices: new Map(), + }, + enabled: lendingPairs.length > 0, + }); +}