diff --git a/apps/cowswap-frontend/src/common/updaters/orders/SpotPricesUpdater.ts b/apps/cowswap-frontend/src/common/updaters/orders/SpotPricesUpdater.ts index 4c3e7967f1..687ec89cbe 100644 --- a/apps/cowswap-frontend/src/common/updaters/orders/SpotPricesUpdater.ts +++ b/apps/cowswap-frontend/src/common/updaters/orders/SpotPricesUpdater.ts @@ -1,18 +1,16 @@ import { useSetAtom } from 'jotai' -import { useCallback, useEffect, useRef } from 'react' +import { useEffect, useMemo } from 'react' -import { useIsWindowVisible } from '@cowprotocol/common-hooks' import { FractionUtils } from '@cowprotocol/common-utils' import { SupportedChainId } from '@cowprotocol/cow-sdk' import { UiOrderType } from '@cowprotocol/types' import { useWalletInfo } from '@cowprotocol/wallet' import { Token } from '@uniswap/sdk-core' -import { SPOT_PRICE_CHECK_POLL_INTERVAL } from 'legacy/state/orders/consts' import { useCombinedPendingOrders } from 'legacy/state/orders/hooks' -import { requestPrice } from 'modules/limitOrders/hooks/useGetInitialPrice' -import { UpdateSpotPriceAtom, updateSpotPricesAtom } from 'modules/orders/state/spotPricesAtom' +import { updateSpotPricesAtom } from 'modules/orders/state/spotPricesAtom' +import { useUsdPrices } from 'modules/usdAmount/hooks/useUsdPrice' import { getUiOrderType } from 'utils/orderUtils/getUiOrderType' @@ -52,93 +50,60 @@ function useMarkets(chainId: SupportedChainId, account: string | undefined): Mar return acc }, - {} + {}, ) }, [pending]) } -interface UseUpdatePendingProps { - isWindowVisibleRef: React.MutableRefObject - isUpdating: React.MutableRefObject - markets: MarketRecord - updateSpotPrices: (update: UpdateSpotPriceAtom) => void -} - -function useUpdatePending(props: UseUpdatePendingProps) { - const { isWindowVisibleRef, isUpdating, markets, updateSpotPrices } = props - - return useCallback(async () => { - if (isUpdating.current) { - return - } - - if (!isWindowVisibleRef.current) { - return - } - - // Lock updates - isUpdating.current = true - - const promises = Object.keys(markets).map((key) => { - const { chainId, inputCurrency, outputCurrency } = markets[key] - - return requestPrice(chainId, inputCurrency, outputCurrency) - .then((fraction) => { - if (!fraction) { - return - } - - const price = FractionUtils.toPrice(fraction, inputCurrency, outputCurrency) - - updateSpotPrices({ - chainId, - sellTokenAddress: inputCurrency.address, - buyTokenAddress: outputCurrency.address, - price, - }) - }) - .catch((e) => { - console.debug(`[SpotPricesUpdater] Failed to get price for ${key}`, e) - }) - }) - - // Wait everything to finish, regardless if failed or not - await Promise.allSettled(promises) - - // Release update lock - isUpdating.current = false - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [Object.keys(markets).sort().join(','), updateSpotPrices]) -} - /** - * TODO: move this updater to modules/orders * Spot Prices Updater * * Goes over all pending LIMIT orders and aggregates all markets - * Queries the spot price for given markets at every SPOT_PRICE_CHECK_POLL_INTERVAL + * Fetches the spot prices for all markets based on USD prices from usdPricesAtom */ export function SpotPricesUpdater(): null { const { chainId, account } = useWalletInfo() - const isWindowVisible = useIsWindowVisible() - const isWindowVisibleRef = useRef(isWindowVisible) - const updateSpotPrices = useSetAtom(updateSpotPricesAtom) const markets = useMarkets(chainId, account) - const isUpdating = useRef(false) // TODO: Implement using SWR or retry/cancellable promises - const updatePending = useUpdatePending({ isWindowVisibleRef, isUpdating, markets, updateSpotPrices }) - isWindowVisibleRef.current = isWindowVisible + const marketTokens = useMemo(() => { + return Object.values(markets).reduce((acc, { inputCurrency, outputCurrency }) => { + acc.push(inputCurrency) + acc.push(outputCurrency) - useEffect(() => { - updatePending() + return acc + }, []) + }, [markets]) - const interval = setInterval(updatePending, SPOT_PRICE_CHECK_POLL_INTERVAL) + const usdPrices = useUsdPrices(marketTokens) - return () => clearInterval(interval) - }, [chainId, isWindowVisible, updatePending]) + useEffect(() => { + Object.values(markets).forEach(({ inputCurrency, outputCurrency }) => { + const inputPrice = usdPrices[inputCurrency.address.toLowerCase()] + const outputPrice = usdPrices[outputCurrency.address.toLowerCase()] + + if (!inputPrice?.price || !outputPrice?.price || !inputPrice?.isLoading || !outputPrice?.isLoading) { + return + } + + const inputFraction = FractionUtils.fractionLikeToFraction(inputPrice.price) + const outputFraction = FractionUtils.fractionLikeToFraction(outputPrice.price) + const fraction = inputFraction.divide(outputFraction) + + if (!fraction) { + return + } + const price = FractionUtils.toPrice(fraction, inputCurrency, outputCurrency) + + updateSpotPrices({ + chainId, + sellTokenAddress: inputCurrency.address, + buyTokenAddress: outputCurrency.address, + price, + }) + }) + }, [usdPrices, markets, chainId, updateSpotPrices]) return null } diff --git a/apps/cowswap-frontend/src/legacy/state/orders/consts.ts b/apps/cowswap-frontend/src/legacy/state/orders/consts.ts index d667d60978..d2c2c2a8ca 100644 --- a/apps/cowswap-frontend/src/legacy/state/orders/consts.ts +++ b/apps/cowswap-frontend/src/legacy/state/orders/consts.ts @@ -12,7 +12,6 @@ export const MARKET_OPERATOR_API_POLL_INTERVAL = ms`2s` // We can have lots of limit orders and it creates a high load, so we poll them no so ofter as market orders export const LIMIT_OPERATOR_API_POLL_INTERVAL = ms`15s` export const PENDING_ORDERS_PRICE_CHECK_POLL_INTERVAL = ms`30s` -export const SPOT_PRICE_CHECK_POLL_INTERVAL = ms`15s` export const EXPIRED_ORDERS_CHECK_POLL_INTERVAL = ms`15s` export const OUT_OF_MARKET_PRICE_DELTA_PERCENTAGE = new Percent(1, 100) // 1/100 => 0.01 => 1% diff --git a/apps/cowswap-frontend/src/modules/limitOrders/hooks/useGetInitialPrice.ts b/apps/cowswap-frontend/src/modules/limitOrders/hooks/useGetInitialPrice.ts index c15e1ba49b..b196073a55 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/hooks/useGetInitialPrice.ts +++ b/apps/cowswap-frontend/src/modules/limitOrders/hooks/useGetInitialPrice.ts @@ -1,90 +1,44 @@ import { useEffect, useState } from 'react' -import { useIsWindowVisible } from '@cowprotocol/common-hooks' -import { getWrappedToken } from '@cowprotocol/common-utils' -import { useWalletInfo } from '@cowprotocol/wallet' -import { Currency, Fraction } from '@uniswap/sdk-core' +import { FractionUtils, getWrappedToken } from '@cowprotocol/common-utils' +import { Fraction } from '@uniswap/sdk-core' -import ms from 'ms.macro' import { useAsyncMemo } from 'use-async-memo' import { useLimitOrdersDerivedState } from 'modules/limitOrders/hooks/useLimitOrdersDerivedState' import { useSafeMemo } from 'common/hooks/useSafeMemo' -import { fetchCurrencyUsdPrice, usdcPriceLoader } from '../../usdAmount' - -const PRICE_UPDATE_INTERVAL = ms`10sec` - -export async function requestPrice( - chainId: number | undefined, - inputCurrency: Currency | null, - outputCurrency: Currency | null, -): Promise { - if (!chainId || !inputCurrency || !outputCurrency) { - return null - } - - const inputToken = getWrappedToken(inputCurrency) - const outputToken = getWrappedToken(outputCurrency) - - // Only needed for the fallback CoW price, which needs to know the USDC price - const getUsdPrice = usdcPriceLoader(chainId) - - return Promise.all([ - fetchCurrencyUsdPrice(inputToken, getUsdPrice), - fetchCurrencyUsdPrice(outputToken, getUsdPrice), - ]).then(([inputPrice, outputPrice]) => { - if (!inputPrice || !outputPrice) { - return null - } - - const result = inputPrice.divide(outputPrice) - - console.debug('Updated limit orders initial price: ', result.toSignificant(18)) - - return result - }) -} +import { useUsdPrice } from '../../usdAmount' // Fetches the INPUT and OUTPUT price and calculates initial Active rate // When return null it means we failed on price loading export function useGetInitialPrice(): { price: Fraction | null; isLoading: boolean } { - const { chainId } = useWalletInfo() const { inputCurrency, outputCurrency } = useLimitOrdersDerivedState() const [isLoading, setIsLoading] = useState(false) - const [updateTimestamp, setUpdateTimestamp] = useState(Date.now()) - const isWindowVisible = useIsWindowVisible() + + const inputToken = inputCurrency && getWrappedToken(inputCurrency) + const outputToken = outputCurrency && getWrappedToken(outputCurrency) + const inputUsdPrice = useUsdPrice(inputToken) + const outputUsdPrice = useUsdPrice(outputToken) + + useEffect(() => { + setIsLoading(!!inputUsdPrice?.isLoading || !!outputUsdPrice?.isLoading) + }, [inputUsdPrice?.isLoading, outputUsdPrice?.isLoading]) const price = useAsyncMemo( async () => { - setIsLoading(true) - console.debug('[useGetInitialPrice] Fetching price') - try { - return await requestPrice(chainId, inputCurrency, outputCurrency) - } finally { - setIsLoading(false) + if (!inputUsdPrice?.price || !outputUsdPrice?.price) { + return null } + const inputFraction = FractionUtils.fractionLikeToFraction(inputUsdPrice.price) + const outputFraction = FractionUtils.fractionLikeToFraction(outputUsdPrice.price) + return inputFraction.divide(outputFraction) }, - [chainId, inputCurrency, outputCurrency, updateTimestamp], + [inputUsdPrice?.price, outputUsdPrice?.price], null, ) - // Update initial price every 10 seconds - useEffect(() => { - if (!isWindowVisible) { - console.debug('[useGetInitialPrice] No need to fetch quotes') - return - } - - console.debug('[useGetInitialPrice] Periodically fetch price') - const interval = setInterval(() => { - setUpdateTimestamp(Date.now()) - }, PRICE_UPDATE_INTERVAL) - - return () => clearInterval(interval) - }, [isWindowVisible]) - return useSafeMemo(() => ({ price, isLoading }), [price, isLoading]) } diff --git a/apps/cowswap-frontend/src/modules/limitOrders/updaters/QuoteObserverUpdater/index.tsx b/apps/cowswap-frontend/src/modules/limitOrders/updaters/QuoteObserverUpdater/index.tsx index ff3d7416d8..611944dafc 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/updaters/QuoteObserverUpdater/index.tsx +++ b/apps/cowswap-frontend/src/modules/limitOrders/updaters/QuoteObserverUpdater/index.tsx @@ -1,17 +1,16 @@ import { useSetAtom } from 'jotai' -import { useEffect } from 'react' +import { useEffect, useMemo } from 'react' -import { FractionUtils } from '@cowprotocol/common-utils' -import { CurrencyAmount, Percent, Price } from '@uniswap/sdk-core' +import { FractionUtils, getWrappedToken } from '@cowprotocol/common-utils' +import { Fraction, Token } from '@uniswap/sdk-core' + +import { Nullish } from 'types' import { updateLimitRateAtom } from 'modules/limitOrders/state/limitRateAtom' import { useDerivedTradeState } from 'modules/trade/hooks/useDerivedTradeState' -import { useTradeQuote } from 'modules/tradeQuote' - -const LIMIT_ORDERS_PRICE_SLIPPAGE = new Percent(1, 10) // 0.1% +import { useUsdPrice } from 'modules/usdAmount/hooks/useUsdPrice' export function QuoteObserverUpdater() { - const { response } = useTradeQuote() const state = useDerivedTradeState() const updateLimitRateState = useSetAtom(updateLimitRateAtom) @@ -19,31 +18,39 @@ export function QuoteObserverUpdater() { const inputCurrency = state?.inputCurrency const outputCurrency = state?.outputCurrency - useEffect(() => { - if (!outputCurrency || !inputCurrency || !response) { - return - } - - const { buyAmount: buyAmountRaw, sellAmount: sellAmountRaw, feeAmount: feeAmountRaw } = response.quote + const inputToken = inputCurrency && getWrappedToken(inputCurrency) + const outputToken = outputCurrency && getWrappedToken(outputCurrency) - const feeAmount = CurrencyAmount.fromRawAmount(inputCurrency, feeAmountRaw) - const sellAmount = CurrencyAmount.fromRawAmount(inputCurrency, sellAmountRaw) - const buyAmount = CurrencyAmount.fromRawAmount(outputCurrency, buyAmountRaw) + const { price, isLoading } = useSpotPrice(inputToken, outputToken) - if (sellAmount.equalTo(0) || buyAmount.equalTo(0)) return + useEffect(() => { + updateLimitRateState({ marketRate: price, isLoadingMarketRate: isLoading }) + }, [price, isLoading, updateLimitRateState]) - const price = FractionUtils.fractionLikeToFraction(new Price({ baseAmount: sellAmount, quoteAmount: buyAmount })) - const marketRate = price.subtract(price.multiply(LIMIT_ORDERS_PRICE_SLIPPAGE.divide(100))) + return null +} - const biggestDecimal = Math.max(sellAmount.currency.decimals, buyAmount.currency.decimals) - /** - * In case when inputted sell amount is enormously big and the price is very small - * App crashes with "Invariant failed" - */ - const isPriceInvalid = +marketRate.toFixed(biggestDecimal) === 0 +function useSpotPrice( + inputCurrency: Nullish, + outputCurrency: Nullish, +): { + price: Fraction | null + isLoading: boolean +} { + const inputUsdPrice = useUsdPrice(inputCurrency) + const outputUsdPrice = useUsdPrice(outputCurrency) + + return useMemo(() => { + const isLoading = !!inputUsdPrice?.isLoading || !!outputUsdPrice?.isLoading + + if (!inputUsdPrice?.price || !outputUsdPrice?.price) { + return { price: null, isLoading } + } + const inputFraction = FractionUtils.fractionLikeToFraction(inputUsdPrice.price) + const outputFraction = FractionUtils.fractionLikeToFraction(outputUsdPrice.price) - updateLimitRateState({ marketRate: isPriceInvalid ? null : marketRate, feeAmount }) - }, [response, inputCurrency, outputCurrency, updateLimitRateState]) + const price = inputFraction.divide(outputFraction) - return null + return { price, isLoading } + }, [inputUsdPrice?.price, inputUsdPrice?.isLoading, outputUsdPrice?.price, outputUsdPrice?.isLoading]) } diff --git a/apps/cowswap-frontend/src/modules/usdAmount/hooks/useUsdPrice.ts b/apps/cowswap-frontend/src/modules/usdAmount/hooks/useUsdPrice.ts index 03d5c5e06c..65ffd66199 100644 --- a/apps/cowswap-frontend/src/modules/usdAmount/hooks/useUsdPrice.ts +++ b/apps/cowswap-frontend/src/modules/usdAmount/hooks/useUsdPrice.ts @@ -1,4 +1,5 @@ import { useAtomValue, useSetAtom } from 'jotai' +import { useEffect, useMemo } from 'react' import { Token } from '@uniswap/sdk-core' @@ -7,8 +8,11 @@ import { Nullish } from 'types' import { useSafeEffect } from 'common/hooks/useSafeMemo' import { addCurrencyToUsdPriceQueue, removeCurrencyToUsdPriceFromQueue } from '../state/usdRawPricesAtom' -import { usdTokenPricesAtom, UsdPriceState } from '../state/usdTokenPricesAtom' +import { UsdPriceState, usdTokenPricesAtom } from '../state/usdTokenPricesAtom' +/** + * Subscribe to USD price for a single currency and returns the USD price state + */ export function useUsdPrice(currency: Nullish): UsdPriceState | null { const currencyAddress = currency?.address?.toLowerCase() @@ -36,3 +40,54 @@ export function useUsdPrice(currency: Nullish): UsdPriceState | null { return price } + +/** + * Subscribe to USD prices for multiple currencies, returns void + */ +function useSubscribeUsdPrices(currencies: Token[]): void { + const addCurrencyToUsdPrice = useSetAtom(addCurrencyToUsdPriceQueue) + const removeCurrencyToUsdPrice = useSetAtom(removeCurrencyToUsdPriceFromQueue) + + useEffect(() => { + // Avoid subscribing to the same currency multiple times + const seenCurrencies = new Set() + + currencies.forEach((currency) => { + if (seenCurrencies.has(currency?.address?.toLowerCase())) { + return + } + + addCurrencyToUsdPrice(currency) + seenCurrencies.add(currency?.address?.toLowerCase()) + }) + + return () => { + currencies.forEach((currency) => { + if (seenCurrencies.has(currency?.address?.toLowerCase())) { + removeCurrencyToUsdPrice(currency) + seenCurrencies.delete(currency?.address?.toLowerCase()) + } + }) + } + }, [currencies, addCurrencyToUsdPrice, removeCurrencyToUsdPrice]) +} + +/** + * Subscribe to USD prices for multiple currencies and returns the USD prices state + */ +export function useUsdPrices(currencies: Token[]): Record { + useSubscribeUsdPrices(currencies) + const usdPrices = useAtomValue(usdTokenPricesAtom) + + return useMemo( + () => + currencies.reduce>((acc, currency) => { + const currencyAddress = currency.address.toLowerCase() + + acc[currencyAddress] = usdPrices[currencyAddress] || null + + return acc + }, {}), + [currencies, usdPrices], + ) +}