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(