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: Add CoinMarketCap external API #2971

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
24 changes: 3 additions & 21 deletions core/bin/zksync_server/src/node_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@ use zksync_node_framework::{
implementations::layers::{
base_token::{
base_token_ratio_persister::BaseTokenRatioPersisterLayer,
base_token_ratio_provider::BaseTokenRatioProviderLayer,
coingecko_client::CoingeckoClientLayer, forced_price_client::ForcedPriceClientLayer,
no_op_external_price_api_client::NoOpExternalPriceApiClientLayer,
base_token_ratio_provider::BaseTokenRatioProviderLayer, ExternalPriceApiLayer,
},
circuit_breaker_checker::CircuitBreakerCheckerLayer,
commitment_generator::CommitmentGeneratorLayer,
Expand Down Expand Up @@ -554,24 +552,8 @@ impl MainNodeBuilder {

fn add_external_api_client_layer(mut self) -> anyhow::Result<Self> {
let config = try_load_config!(self.configs.external_price_api_client_config);
match config.source.as_str() {
CoingeckoClientLayer::CLIENT_NAME => {
self.node.add_layer(CoingeckoClientLayer::new(config));
}
NoOpExternalPriceApiClientLayer::CLIENT_NAME => {
self.node.add_layer(NoOpExternalPriceApiClientLayer);
}
ForcedPriceClientLayer::CLIENT_NAME => {
self.node.add_layer(ForcedPriceClientLayer::new(config));
}
_ => {
anyhow::bail!(
"Unknown external price API client source: {}",
config.source
);
}
}

self.node
.add_layer(ExternalPriceApiLayer::try_from(config)?);
Ok(self)
}

Expand Down
199 changes: 199 additions & 0 deletions core/lib/external_price_api/src/cmc_api.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
use std::{collections::HashMap, str::FromStr};

use async_trait::async_trait;
use chrono::Utc;
use serde::Deserialize;
use tokio::sync::RwLock;
use url::Url;
use zksync_config::configs::ExternalPriceApiClientConfig;
use zksync_types::{base_token_ratio::BaseTokenAPIRatio, Address};

use crate::{utils::get_fraction, PriceAPIClient};

const CMC_AUTH_HEADER: &str = "x-cmc_pro_api_key";
const DEFAULT_CMC_API_URL: &str = "https://pro-api.coinmarketcap.com";
const ALLOW_TOKENS_ONLY_ON_CMC_PLATFORM_ID: i32 = 1; // 1 = Ethereum

#[derive(Debug)]
pub struct CmcPriceApiClient {
base_url: Url,
client: reqwest::Client,
cache_token_id_by_address: RwLock<HashMap<Address, i32>>,
}

impl CmcPriceApiClient {
pub fn new(config: ExternalPriceApiClientConfig) -> Self {
let client = if let Some(api_key) = &config.api_key {
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};

let default_headers = HeaderMap::from_iter([(
HeaderName::from_static(CMC_AUTH_HEADER),
HeaderValue::from_str(api_key).expect("Failed to create header value"),
)]);

reqwest::Client::builder().default_headers(default_headers)
} else {
reqwest::Client::builder()
}
.timeout(config.client_timeout())
.build()
.expect("Failed to build reqwest client");

let base_url = config.base_url.unwrap_or(DEFAULT_CMC_API_URL.to_string());
let base_url = Url::parse(&base_url).expect("Failed to parse CoinMarketCap API URL");

Self {
base_url,
client,
cache_token_id_by_address: RwLock::default(),
}
}

fn get(&self, path: &str) -> reqwest::RequestBuilder {
self.client
.get(self.base_url.join(path).expect("Failed to join URL path"))
}

async fn get_token_id(&self, address: Address) -> anyhow::Result<i32> {
if let Some(x) = self.cache_token_id_by_address.read().await.get(&address) {
return Ok(*x);
}
// drop read lock

let response = self.get("/v1/cryptocurrency/map").send().await?;
let status = response.status();
if !status.is_success() {
return Err(anyhow::anyhow!(
"Http error while fetching token id. Status: {status}, token: {address}, msg: {}",
response.text().await.unwrap_or_default(),
));
}

let parsed = response.json::<V1CryptocurrencyMapResponse>().await?;
for token_info in parsed.data {
if let Some(platform) = token_info.platform {
if platform.id == ALLOW_TOKENS_ONLY_ON_CMC_PLATFORM_ID
&& Address::from_str(&platform.token_address).is_ok_and(|a| a == address)
{
self.cache_token_id_by_address
.write()
.await
.insert(address, token_info.id);
return Ok(token_info.id);
}
}
}

Err(anyhow::anyhow!("Token ID not found for address {address}"))
}

async fn get_token_price_by_address(&self, address: Address) -> anyhow::Result<f64> {
let id = self.get_token_id(address).await?;
self.get_token_price_by_id(id).await
}

async fn get_token_price_by_id(&self, id: i32) -> anyhow::Result<f64> {
let response = self
.get("/v2/cryptocurrency/quotes/latest")
.query(&[("id", id)])
.send()
.await?;

let status = response.status();
if !status.is_success() {
return Err(anyhow::anyhow!(
"Http error while fetching token price. Status: {status}, token: {id}, msg: {}",
response.text().await.unwrap_or("".to_string())
));
}

response
.json::<V2CryptocurrencyQuotesLatestResponse>()
.await?
.data
.get(&id)
.and_then(|data| data.quote.get("USD"))
.map(|mq| mq.price)
.ok_or_else(|| anyhow::anyhow!("Price not found for token: {id}"))
}
}

#[derive(Debug, Deserialize)]
struct V2CryptocurrencyQuotesLatestResponse {
data: HashMap<i32, CryptocurrencyQuoteObject>,
}

#[derive(Debug, Deserialize)]
struct CryptocurrencyQuoteObject {
// #[serde(flatten)]
// cryptocurrency_object: CryptocurrencyObject,
quote: HashMap<String, MarketQuote>,
}

#[derive(Debug, Deserialize)]
struct MarketQuote {
price: f64,
}

#[derive(Debug, Deserialize)]
struct V1CryptocurrencyMapResponse {
data: Vec<CryptocurrencyObject>,
}

#[derive(Debug, Deserialize)]
struct CryptocurrencyObject {
id: i32,
// name: String,
// symbol: String,
// slug: String,
// is_active: u8, // TODO: This field is available, should we at least emit a warning if the listing is not marked as active?
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we care to do "liveness" checks (i.e. could emit a warning if the API says that the token is inactive)?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point - lets absolutely do it!

platform: Option<CryptocurrencyPlatform>,
}

#[derive(Debug, Deserialize)]
struct CryptocurrencyPlatform {
id: i32,
// name: String,
token_address: String,
}

#[async_trait]
impl PriceAPIClient for CmcPriceApiClient {
async fn fetch_ratio(&self, token_address: Address) -> anyhow::Result<BaseTokenAPIRatio> {
let base_token_in_eth = self.get_token_price_by_address(token_address).await?;
let (numerator, denominator) = get_fraction(base_token_in_eth);

return Ok(BaseTokenAPIRatio {
numerator,
denominator,
ratio_timestamp: Utc::now(),
});
}
}

#[cfg(test)]
mod tests {
use super::*;

#[tokio::test]
#[ignore = "run manually (accesses network); specify CoinMarketCap API key in env var CMC_API_KEY"]
async fn test() {
let client = CmcPriceApiClient::new(ExternalPriceApiClientConfig {
api_key: Some(std::env::var("CMC_API_KEY").unwrap()),
base_url: None,
client_timeout_ms: 5000,
source: String::new(),
forced: None,
});

let tether: Address = "0xdac17f958d2ee523a2206206994597c13d831ec7"
.parse()
.unwrap();

let r = client.get_token_price_by_address(tether).await.unwrap();

assert!((r - 1f64).abs() < 0.001, "USDT lost its peg");

println!("{r}");
}
}
1 change: 1 addition & 0 deletions core/lib/external_price_api/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod cmc_api;
pub mod coingecko_api;
pub mod forced_price_client;
mod utils;
Expand Down

This file was deleted.

This file was deleted.

Loading
Loading