From 2bd17bfdddc0248c7516d5a0da49eade1aa34a10 Mon Sep 17 00:00:00 2001 From: Leandro <alfetopito@users.noreply.github.com> Date: Mon, 13 Jan 2025 10:10:27 +0000 Subject: [PATCH] feat(usd): use bff prices (#5279) * feat: add getBffUsdPrice * feat: replace Coingecko price source with BFF Keep the existing fallbacks in case BFF is down * feat: remove getCoingeckoUsdPrice * chore: remove redundant variable * refactor: use try/catch instead of promise chainning --- .../modules/usdAmount/apis/getBffUsdPrice.ts | 62 ++++++++++++ .../usdAmount/apis/getCoingeckoUsdPrice.ts | 98 ------------------- .../services/fetchCurrencyUsdPrice.ts | 60 +++++------- 3 files changed, 85 insertions(+), 135 deletions(-) create mode 100644 apps/cowswap-frontend/src/modules/usdAmount/apis/getBffUsdPrice.ts delete mode 100644 apps/cowswap-frontend/src/modules/usdAmount/apis/getCoingeckoUsdPrice.ts diff --git a/apps/cowswap-frontend/src/modules/usdAmount/apis/getBffUsdPrice.ts b/apps/cowswap-frontend/src/modules/usdAmount/apis/getBffUsdPrice.ts new file mode 100644 index 0000000000..a84965f7e0 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/usdAmount/apis/getBffUsdPrice.ts @@ -0,0 +1,62 @@ +import { BFF_BASE_URL } from '@cowprotocol/common-const' +import { FractionUtils } from '@cowprotocol/common-utils' +import { Fraction, Token } from '@uniswap/sdk-core' + +import ms from 'ms.macro' + +import { fetchWithRateLimit } from 'common/utils/fetch' + +import { UnknownCurrencyError } from './errors' + +type BffUsdPriceResponse = { + price: number +} +type BffUsdErrorResponse = { message: string } +type BffResponse = BffUsdPriceResponse | BffUsdErrorResponse + +const fetchRateLimited = fetchWithRateLimit({ + // Allow 5 requests per second + rateLimit: { + tokensPerInterval: 5, + interval: 'second', + }, + // 2 retry attempts with 100ms delay + backoff: { + maxDelay: ms`0.1s`, + numOfAttempts: 2, + }, +}) + +export async function getBffUsdPrice(currency: Token): Promise<Fraction | null> { + const url = `${BFF_BASE_URL}/${currency.chainId}/tokens/${currency.address}/usdPrice` + + try { + const res = await fetchRateLimited(url) + + // Token not found + if (res.status === 404) { + throw new UnknownCurrencyError({ + cause: `BFF did not return a price for '${currency.address}' on chain '${currency.chainId}'`, + }) + // Unknown error case + } else if (res.status !== 200) { + throw new Error(`Unexpected response from BFF: ${res.status}`) + } + + const data: BffResponse = await res.json() + + // 200 response with error message + if (isErrorResponse(data)) { + throw new Error(`Unexpected response from BFF: ${JSON.stringify(data)}`) + } + + // Happy path + return FractionUtils.fromNumber(data.price) + } catch (error) { + return Promise.reject(error) + } +} + +function isErrorResponse(response: BffResponse): response is BffUsdErrorResponse { + return 'message' in response && !('price' in response) +} diff --git a/apps/cowswap-frontend/src/modules/usdAmount/apis/getCoingeckoUsdPrice.ts b/apps/cowswap-frontend/src/modules/usdAmount/apis/getCoingeckoUsdPrice.ts deleted file mode 100644 index 1ff4164ea3..0000000000 --- a/apps/cowswap-frontend/src/modules/usdAmount/apis/getCoingeckoUsdPrice.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { BFF_BASE_URL } from '@cowprotocol/common-const' -import { FractionUtils } from '@cowprotocol/common-utils' -import { SupportedChainId } from '@cowprotocol/cow-sdk' -import { Fraction, Token } from '@uniswap/sdk-core' - -import ms from 'ms.macro' - -import { fetchWithRateLimit } from 'common/utils/fetch' - -import { RateLimitError, UnknownCurrencyError, UnsupportedPlatformError } from './errors' - -type SuccessCoingeckoUsdQuoteResponse = { - [address: string]: { - usd: number - } -} -type ErrorCoingeckoResponse = { status: { error_code: number; error_message: string } } - -export const COINGECKO_PLATFORMS: Record<SupportedChainId, string | null> = { - [SupportedChainId.MAINNET]: 'ethereum', - [SupportedChainId.GNOSIS_CHAIN]: 'xdai', - [SupportedChainId.ARBITRUM_ONE]: 'arbitrum-one', - [SupportedChainId.BASE]: 'base', - [SupportedChainId.SEPOLIA]: null, -} - -const BASE_URL = `${BFF_BASE_URL}/proxies/coingecko` -const VS_CURRENCY = 'usd' -/** - * This is a text of 429 HTTP code - * https://saturncloud.io/blog/catching-javascript-fetch-failing-with-cloudflare-429-missing-cors-header/ - */ -const FAILED_FETCH_ERROR = 'Failed to fetch' - -const fetchRateLimited = fetchWithRateLimit({ - // Allow 2 requests per second - rateLimit: { - tokensPerInterval: 2, - interval: 'second', - }, - // 2 retry attempts with 100ms delay - backoff: { - maxDelay: ms`0.1s`, - numOfAttempts: 2, - }, -}) - -export const COINGECKO_RATE_LIMIT_TIMEOUT = ms`1m` - -export async function getCoingeckoUsdPrice(currency: Token): Promise<Fraction | null> { - const platform = COINGECKO_PLATFORMS[currency.chainId as SupportedChainId] - - if (!platform) throw new UnsupportedPlatformError({ cause: `Coingecko does not support chain '${currency.chainId}'` }) - - const params = { - contract_addresses: currency.address, - vs_currencies: VS_CURRENCY, - } - - const url = `${BASE_URL}/simple/token_price/${platform}?${new URLSearchParams(params)}` - - return fetchRateLimited(url) - .then((res) => res.json()) - .catch((error) => { - if (error.message.includes(FAILED_FETCH_ERROR)) { - throw new RateLimitError({ cause: error }) - } - - return Promise.reject(error) - }) - .then((res: SuccessCoingeckoUsdQuoteResponse | ErrorCoingeckoResponse) => { - if (isErrorResponse(res)) { - if (res.status.error_code === 429) { - throw new RateLimitError({ cause: res }) - } else { - throw new Error(res.status.error_message, { cause: res }) - } - } - - const value = res[currency.address.toLowerCase()]?.usd - - // If coingecko API returns an empty response - // It means Coingecko doesn't know about the currency - if (value === undefined) { - throw new UnknownCurrencyError({ - cause: `Coingecko did not return a price for '${currency.address}' on chain '${currency.chainId}'`, - }) - } - - return FractionUtils.fromNumber(value) - }) -} - -function isErrorResponse( - res: SuccessCoingeckoUsdQuoteResponse | ErrorCoingeckoResponse, -): res is ErrorCoingeckoResponse { - return 'status' in res -} diff --git a/apps/cowswap-frontend/src/modules/usdAmount/services/fetchCurrencyUsdPrice.ts b/apps/cowswap-frontend/src/modules/usdAmount/services/fetchCurrencyUsdPrice.ts index 2e69e80bde..41961433ff 100644 --- a/apps/cowswap-frontend/src/modules/usdAmount/services/fetchCurrencyUsdPrice.ts +++ b/apps/cowswap-frontend/src/modules/usdAmount/services/fetchCurrencyUsdPrice.ts @@ -3,28 +3,17 @@ import { PersistentStateByChain } from '@cowprotocol/types' import { Fraction, Token } from '@uniswap/sdk-core' import { RateLimitError, UnknownCurrencyError } from '../apis/errors' -import { COINGECKO_PLATFORMS, COINGECKO_RATE_LIMIT_TIMEOUT, getCoingeckoUsdPrice } from '../apis/getCoingeckoUsdPrice' +import { getBffUsdPrice } from '../apis/getBffUsdPrice' import { getCowProtocolUsdPrice } from '../apis/getCowProtocolUsdPrice' import { DEFILLAMA_PLATFORMS, DEFILLAMA_RATE_LIMIT_TIMEOUT, getDefillamaUsdPrice } from '../apis/getDefillamaUsdPrice' type UnknownCurrencies = { [address: string]: true } type UnknownCurrenciesMap = PersistentStateByChain<UnknownCurrencies> -let coingeckoRateLimitHitTimestamp: null | number = null let defillamaRateLimitHitTimestamp: null | number = null -const coingeckoUnknownCurrencies: UnknownCurrenciesMap = mapSupportedNetworks({}) const defillamaUnknownCurrencies: UnknownCurrenciesMap = mapSupportedNetworks({}) - -function getShouldSkipCoingecko(currency: Token): boolean { - return getShouldSkipPriceSource( - currency, - COINGECKO_PLATFORMS, - coingeckoUnknownCurrencies, - coingeckoRateLimitHitTimestamp, - COINGECKO_RATE_LIMIT_TIMEOUT, - ) -} +const bffUnknownCurrencies: UnknownCurrenciesMap = mapSupportedNetworks({}) function getShouldSkipDefillama(currency: Token): boolean { return getShouldSkipPriceSource( @@ -38,7 +27,7 @@ function getShouldSkipDefillama(currency: Token): boolean { function getShouldSkipPriceSource( currency: Token, - platforms: Record<SupportedChainId, string | null>, + platforms: Record<SupportedChainId, string | null> | null, unknownCurrenciesMap: UnknownCurrenciesMap, rateLimitTimestamp: null | number, timeout: number, @@ -46,29 +35,28 @@ function getShouldSkipPriceSource( const chainId = currency.chainId as SupportedChainId const unknownCurrenciesForChain = unknownCurrenciesMap[chainId] || {} - if (!platforms[chainId]) return true + if (platforms && !platforms[chainId]) return true if (unknownCurrenciesForChain[currency.address.toLowerCase()]) return true return !!rateLimitTimestamp && Date.now() - rateLimitTimestamp < timeout } +function getShouldSkipBff(currency: Token): boolean { + return getShouldSkipPriceSource(currency, null, bffUnknownCurrencies, null, 0) +} + /** - * Fetches USD price for a given currency from coingecko or CowProtocol - * CoW Protocol Orderbook API is used as a fallback - * When Coingecko rate limit is hit, CowProtocol will be used for 1 minute + * Fetches USD price for a given currency from BFF, Defillama, or CowProtocol + * Tries sources in that order */ export function fetchCurrencyUsdPrice( currency: Token, getUsdcPrice: () => Promise<Fraction | null>, ): Promise<Fraction | null> { - const shouldSkipCoingecko = getShouldSkipCoingecko(currency) + const shouldSkipBff = getShouldSkipBff(currency) const shouldSkipDefillama = getShouldSkipDefillama(currency) - if (coingeckoRateLimitHitTimestamp && !shouldSkipCoingecko) { - coingeckoRateLimitHitTimestamp = null - } - if (defillamaRateLimitHitTimestamp && !shouldSkipDefillama) { defillamaRateLimitHitTimestamp = null } @@ -80,24 +68,22 @@ export function fetchCurrencyUsdPrice( }) } - // No coingecko. Try Defillama, then cow - if (shouldSkipCoingecko) { + // Try BFF first, then fall back to Defillama, then CoW + if (!shouldSkipBff) { + return getBffUsdPrice(currency) + .catch(handleErrorFactory(currency, null, bffUnknownCurrencies, getDefillamaUsdPrice)) + .catch(handleErrorFactory(currency, defillamaRateLimitHitTimestamp, defillamaUnknownCurrencies, getCowPrice)) + } + + // If BFF is skipped, try Defillama + if (!shouldSkipDefillama) { return getDefillamaUsdPrice(currency).catch( handleErrorFactory(currency, defillamaRateLimitHitTimestamp, defillamaUnknownCurrencies, getCowPrice), ) } - // No Defillama. Try coingecko, then cow - if (shouldSkipDefillama) { - return getCoingeckoUsdPrice(currency).catch( - handleErrorFactory(currency, coingeckoRateLimitHitTimestamp, coingeckoUnknownCurrencies, getCowPrice), - ) - } - // Both coingecko and defillama available. Try coingecko, then defillama, then cow - return getCoingeckoUsdPrice(currency) - .catch( - handleErrorFactory(currency, coingeckoRateLimitHitTimestamp, coingeckoUnknownCurrencies, getDefillamaUsdPrice), - ) - .catch(handleErrorFactory(currency, defillamaRateLimitHitTimestamp, defillamaUnknownCurrencies, getCowPrice)) + + // If all other sources are skipped, use CoW as last resort + return getCowPrice(currency) } function handleErrorFactory(