-
Notifications
You must be signed in to change notification settings - Fork 2.1k
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
encody
wants to merge
8
commits into
main
Choose a base branch
from
jl-add-coinmarketcap-api
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
f33a835
feat: adds cmc api
encody bc73e39
chore: small cleanup
encody e49670c
Merge branch 'main' into jl-add-coinmarketcap-api
encody fb31d1f
chore: rename to proper CamelCase
encody 4f4dfb8
feat: adds cmc to node builder
encody 8bb5c7a
chore: trying enums instead of inline string matching
encody a6b02de
chore: remove dynamic layer name
encody 1042be0
fix: removes superfluous .into() call
encody File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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? | ||
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}"); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
|
55 changes: 0 additions & 55 deletions
55
core/node/node_framework/src/implementations/layers/base_token/coingecko_client.rs
This file was deleted.
Oops, something went wrong.
52 changes: 0 additions & 52 deletions
52
core/node/node_framework/src/implementations/layers/base_token/forced_price_client.rs
This file was deleted.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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)?
There was a problem hiding this comment.
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!