Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(usd): further refactor SpotPrice updater #5297

Merged
merged 3 commits into from
Jan 16, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 37 additions & 74 deletions apps/cowswap-frontend/src/common/updaters/orders/SpotPricesUpdater.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -52,93 +50,58 @@ function useMarkets(chainId: SupportedChainId, account: string | undefined): Mar

return acc
},
{}
{},
)
}, [pending])
}

interface UseUpdatePendingProps {
isWindowVisibleRef: React.MutableRefObject<boolean>
isUpdating: React.MutableRefObject<boolean>
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<Token[]>((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 fraction = inputPrice.price.divide(outputPrice.price)

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
}
1 change: 0 additions & 1 deletion apps/cowswap-frontend/src/legacy/state/orders/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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%
Expand Down
Original file line number Diff line number Diff line change
@@ -1,90 +1,42 @@
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 { 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<Fraction | null> {
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
}
return inputUsdPrice.price.divide(outputUsdPrice.price)
},
[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])
}
57 changes: 56 additions & 1 deletion apps/cowswap-frontend/src/modules/usdAmount/hooks/useUsdPrice.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useAtomValue, useSetAtom } from 'jotai'
import { useEffect, useMemo } from 'react'

import { Token } from '@uniswap/sdk-core'

Expand All @@ -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<Token>): UsdPriceState | null {
const currencyAddress = currency?.address?.toLowerCase()

Expand Down Expand Up @@ -36,3 +40,54 @@ export function useUsdPrice(currency: Nullish<Token>): 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<string>()

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<string, UsdPriceState | null> {
useSubscribeUsdPrices(currencies)
const usdPrices = useAtomValue(usdTokenPricesAtom)

return useMemo(
() =>
currencies.reduce<Record<string, UsdPriceState | null>>((acc, currency) => {
const currencyAddress = currency.address.toLowerCase()

acc[currencyAddress] = usdPrices[currencyAddress] || null

return acc
}, {}),
[currencies, usdPrices],
)
}
Loading