Skip to content

Commit

Permalink
feat(usd): further refactor SpotPrice updater (#5297)
Browse files Browse the repository at this point in the history
* feat: further refactor SpotPrice updater

Use UsdPriceUpdater instead of triggering new requests from here

* fix: lint

* fix: prices must be in Fraction before division
  • Loading branch information
alfetopito authored Jan 16, 2025
1 parent 2a7a51d commit 85e4679
Show file tree
Hide file tree
Showing 4 changed files with 113 additions and 140 deletions.
113 changes: 39 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,60 @@ 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 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
}
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,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<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
}
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])
}
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],
)
}

0 comments on commit 85e4679

Please sign in to comment.