Skip to content

Commit

Permalink
Implement CoinGecko for fetching native prices (#2824)
Browse files Browse the repository at this point in the history
# Description
Implement a native prices fetch from CoinGecko. As we see in the issue
#2814, we are getting many
orders rejected because we do not have native prices for many tokens.
And the cache of outdated tokens keeps growing over time. In order to
solve this, we add CoinGecko as a native price fetcher too.

This PR standalone (without infrastructure PR) doesn't have any impact,
as CoinGecko must be configured as a native price estimator source.

The PR can already prove if fetching prices from CoinGecko improves the
cache and the order errors rate, but the following changes are needed in
upcoming PRs:
- Instead of fetching prices from all the sources at one, have a primary
method for fetching prices (CoinGecko) and fallback methods (solvers +
1inch)
- Potentially improve performance by doing bulk requests
- Move the code from autopilot

Please, see my comments below for more clarification.

# Changes
- Add configuration for CoinGeck estimator source
- Implement the trait for fetching native prices for CoinGecko

## How to test
1. Unit test

## Related Issues

### Partially fixes: #2814

---------

Co-authored-by: ilya <[email protected]>
  • Loading branch information
m-lord-renkse and squadgazzz authored Jul 24, 2024
1 parent 681faa0 commit 11772f9
Show file tree
Hide file tree
Showing 5 changed files with 178 additions and 3 deletions.
19 changes: 19 additions & 0 deletions crates/shared/src/price_estimation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,15 @@ pub struct NativePriceEstimators(Vec<Vec<NativePriceEstimator>>);
pub enum NativePriceEstimator {
Driver(ExternalSolver),
OneInchSpotPriceApi,
CoinGecko,
}

impl Display for NativePriceEstimator {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
let formatter = match self {
NativePriceEstimator::Driver(s) => format!("{}|{}", &s.name, s.url),
NativePriceEstimator::OneInchSpotPriceApi => "OneInchSpotPriceApi".into(),
NativePriceEstimator::CoinGecko => "CoinGecko".into(),
};
write!(f, "{}", formatter)
}
Expand Down Expand Up @@ -100,6 +102,7 @@ impl FromStr for NativePriceEstimator {
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"OneInchSpotPriceApi" => Ok(NativePriceEstimator::OneInchSpotPriceApi),
"CoinGecko" => Ok(NativePriceEstimator::CoinGecko),
estimator => Ok(NativePriceEstimator::Driver(ExternalSolver::from_str(
estimator,
)?)),
Expand Down Expand Up @@ -179,6 +182,18 @@ pub struct Arguments {
#[clap(long, env, default_value = "https://api.1inch.dev/")]
pub one_inch_url: Url,

/// The API key for the CoinGecko API.
#[clap(long, env)]
pub coin_gecko_api_key: Option<String>,

/// The base URL for the CoinGecko API.
#[clap(
long,
env,
default_value = "https://api.coingecko.com/api/v3/simple/token_price"
)]
pub coin_gecko_url: Url,

/// How inaccurate a quote must be before it gets discarded provided as a
/// factor.
/// E.g. a value of `0.01` means at most 1 percent of the sell or buy tokens
Expand Down Expand Up @@ -233,6 +248,8 @@ impl Display for Arguments {
balancer_sor_url,
one_inch_api_key,
one_inch_url,
coin_gecko_api_key,
coin_gecko_url,
quote_inaccuracy_limit,
quote_verification,
quote_timeout,
Expand Down Expand Up @@ -276,6 +293,8 @@ impl Display for Arguments {
display_option(f, "balancer_sor_url", balancer_sor_url)?;
display_secret_option(f, "one_inch_spot_price_api_key: {:?}", one_inch_api_key)?;
writeln!(f, "one_inch_spot_price_api_url: {}", one_inch_url)?;
display_secret_option(f, "coin_gecko_api_key: {:?}", coin_gecko_api_key)?;
writeln!(f, "coin_gecko_api_url: {}", coin_gecko_url)?;
writeln!(f, "quote_inaccuracy_limit: {}", quote_inaccuracy_limit)?;
writeln!(f, "quote_verification: {:?}", quote_verification)?;
writeln!(f, "quote_timeout: {:?}", quote_timeout)?;
Expand Down
9 changes: 9 additions & 0 deletions crates/shared/src/price_estimation/factory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,15 @@ impl<'a> PriceEstimatorFactory<'a> {
self.components.tokens.clone(),
)),
)),
NativePriceEstimatorSource::CoinGecko => Ok((
"CoinGecko".into(),
Arc::new(native::CoinGecko::new(
self.components.http_factory.create(),
self.args.coin_gecko_url.clone(),
self.args.coin_gecko_api_key.clone(),
self.network.chain_id,
)?),
)),
}
}

Expand Down
4 changes: 3 additions & 1 deletion crates/shared/src/price_estimation/native.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ use {
std::sync::Arc,
};

mod coingecko;
mod oneinch;
pub use self::oneinch::OneInch;

pub use self::{coingecko::CoinGecko, oneinch::OneInch};

pub type NativePrice = f64;
pub type NativePriceEstimateResult = Result<NativePrice, PriceEstimationError>;
Expand Down
145 changes: 145 additions & 0 deletions crates/shared/src/price_estimation/native/coingecko.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
use {
super::{NativePriceEstimateResult, NativePriceEstimating},
crate::price_estimation::PriceEstimationError,
anyhow::{anyhow, Result},
futures::{future::BoxFuture, FutureExt},
primitive_types::H160,
reqwest::{Client, StatusCode},
serde::Deserialize,
std::collections::HashMap,
url::Url,
};

#[derive(Debug, Deserialize)]
struct Response(HashMap<H160, Price>);

#[derive(Debug, Deserialize)]
struct Price {
eth: f64,
}

type Token = H160;

pub struct CoinGecko {
client: Client,
base_url: Url,
api_key: Option<String>,
chain: String,
}

impl CoinGecko {
/// Authorization header for CoinGecko
const AUTHORIZATION: &'static str = "x-cg-pro-api-key";

pub fn new(
client: Client,
base_url: Url,
api_key: Option<String>,
chain_id: u64,
) -> Result<Self> {
let chain = match chain_id {
1 => "ethereum".to_string(),
100 => "xdai".to_string(),
42161 => "arbitrum-one".to_string(),
n => anyhow::bail!("unsupported network {n}"),
};
Ok(Self {
client,
base_url,
api_key,
chain,
})
}
}

impl NativePriceEstimating for CoinGecko {
fn estimate_native_price(&self, token: Token) -> BoxFuture<'_, NativePriceEstimateResult> {
async move {
let url = format!(
"{}/{}?contract_addresses={token:#x}&vs_currencies=eth",
self.base_url, self.chain
);
let mut builder = self.client.get(&url);
if let Some(ref api_key) = self.api_key {
builder = builder.header(Self::AUTHORIZATION, api_key)
}
observe::coingecko_request(&url);
let response = builder.send().await.map_err(|e| {
PriceEstimationError::EstimatorInternal(anyhow!(
"failed to sent CoinGecko price request: {e:?}"
))
})?;
if !response.status().is_success() {
let status = response.status();
return match status {
StatusCode::TOO_MANY_REQUESTS => Err(PriceEstimationError::RateLimited),
status => Err(PriceEstimationError::EstimatorInternal(anyhow!(
"failed to retrieve prices from CoinGecko: error with status code \
{status}."
))),
};
}
let response = response.text().await;
observe::coingecko_response(&url, response.as_deref());
let response = response.map_err(|e| {
PriceEstimationError::EstimatorInternal(anyhow!(
"failed to fetch native CoinGecko prices: {e:?}"
))
})?;
let prices = serde_json::from_str::<Response>(&response)
.map_err(|e| {
PriceEstimationError::EstimatorInternal(anyhow!(
"failed to parse native CoinGecko prices from {response:?}: {e:?}"
))
})?
.0;

let price = prices
.get(&token)
.ok_or(PriceEstimationError::NoLiquidity)?;
Ok(price.eth)
}
.boxed()
}
}

mod observe {
/// Observe a request to be sent to CoinGecko
pub fn coingecko_request(endpoint: &str) {
tracing::trace!(%endpoint, "sending request to CoinGecko");
}

/// Observe that a response was received from CoinGecko
pub fn coingecko_response(endpoint: &str, res: Result<&str, &reqwest::Error>) {
match res {
Ok(res) => {
tracing::trace!(%endpoint, ?res, "received response from CoinGecko")
}
Err(err) => {
tracing::warn!(%endpoint, ?err, "failed to receive response from CoinGecko")
}
}
}
}

#[cfg(test)]
mod tests {
use {super::*, std::str::FromStr};

// It is ok to call this API without an API for local testing purposes as it is
// difficulty to hit the rate limit manually
const BASE_URL: &str = "https://api.coingecko.com/api/v3/simple/token_price";

#[tokio::test]
#[ignore]
async fn works() {
let native_token = H160::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap();
let instance =
CoinGecko::new(Client::default(), Url::parse(BASE_URL).unwrap(), None, 1).unwrap();

let estimated_price = instance.estimate_native_price(native_token).await.unwrap();
// Since the WETH precise price against ETH is not always exact to 1.0 (it can
// vary slightly)
assert!((0.95..=1.05).contains(&estimated_price));
}
}
4 changes: 2 additions & 2 deletions crates/shared/src/price_estimation/native/oneinch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use {
number::{conversions::u256_to_big_rational, serialization::HexOrDecimalU256},
primitive_types::{H160, U256},
reqwest::{header::AUTHORIZATION, Client},
serde::{Deserialize, Serialize},
serde::Deserialize,
serde_with::serde_as,
std::{
collections::HashMap,
Expand All @@ -18,7 +18,7 @@ use {
};

#[serde_as]
#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Deserialize)]
struct Response(#[serde_as(as = "HashMap<_, HexOrDecimalU256>")] HashMap<H160, U256>);

type Token = H160;
Expand Down

0 comments on commit 11772f9

Please sign in to comment.