diff --git a/Cargo.lock b/Cargo.lock index bd9f2d5ef28..1c446558988 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10259,7 +10259,9 @@ dependencies = [ "rand 0.8.5", "reqwest 0.12.7", "serde", + "serde_json", "tokio", + "tracing", "url", "zksync_config", "zksync_types", diff --git a/core/bin/zksync_server/src/node_builder.rs b/core/bin/zksync_server/src/node_builder.rs index 4600b0f9e54..9fdbc129b19 100644 --- a/core/bin/zksync_server/src/node_builder.rs +++ b/core/bin/zksync_server/src/node_builder.rs @@ -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, @@ -557,24 +555,8 @@ impl MainNodeBuilder { fn add_external_api_client_layer(mut self) -> anyhow::Result { 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) } diff --git a/core/lib/external_price_api/Cargo.toml b/core/lib/external_price_api/Cargo.toml index 3eee675b4e6..1e849f60006 100644 --- a/core/lib/external_price_api/Cargo.toml +++ b/core/lib/external_price_api/Cargo.toml @@ -20,8 +20,12 @@ serde.workspace = true reqwest = { workspace = true, features = ["json"] } fraction.workspace = true rand.workspace = true +tracing.workspace = true zksync_config.workspace = true zksync_types.workspace = true tokio.workspace = true + +[dev-dependencies] httpmock.workspace = true +serde_json.workspace = true diff --git a/core/lib/external_price_api/src/cmc_api.rs b/core/lib/external_price_api/src/cmc_api.rs new file mode 100644 index 00000000000..05cb5e4d728 --- /dev/null +++ b/core/lib/external_price_api/src/cmc_api.rs @@ -0,0 +1,357 @@ +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::{address_to_string, utils::get_fraction, PriceAPIClient}; + +const AUTH_HEADER: &str = "x-cmc_pro_api_key"; +const DEFAULT_API_URL: &str = "https://pro-api.coinmarketcap.com"; +const ALLOW_TOKENS_ONLY_ON_PLATFORM_ID: i32 = 1; // 1 = Ethereum +const REQUEST_QUOTE_IN_CURRENCY_ID: &str = "1027"; // 1027 = ETH + +#[derive(Debug)] +pub struct CmcPriceApiClient { + base_url: Url, + client: reqwest::Client, + cache_token_id_by_address: RwLock>, +} + +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(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_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 { + if let Some(x) = self.cache_token_id_by_address.read().await.get(&address) { + return Ok(*x); + } + + 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::().await?; + for token_info in parsed.data { + if let Some(platform) = token_info.platform { + if platform.id == ALLOW_TOKENS_ONLY_ON_PLATFORM_ID + && Address::from_str(&platform.token_address).is_ok_and(|a| a == address) + { + if token_info.is_active != 1 { + tracing::warn!( + "CoinMarketCap API reports token {} ({}) on platform {} ({}) is not active", + address_to_string(&address), + token_info.name, + platform.id, + platform.name, + ); + } + + 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 { + 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 { + let response = self + .get("/v2/cryptocurrency/quotes/latest") + .query(&[("id", id)]) + .query(&[("convert_id", REQUEST_QUOTE_IN_CURRENCY_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_default(), + )); + } + + response + .json::() + .await? + .data + .get(&id) + .and_then(|data| data.quote.get(REQUEST_QUOTE_IN_CURRENCY_ID)) + .map(|mq| mq.price) + .ok_or_else(|| anyhow::anyhow!("Price not found for token: {id}")) + } +} + +#[derive(Debug, Deserialize)] +struct V2CryptocurrencyQuotesLatestResponse { + data: HashMap, +} + +#[derive(Debug, Deserialize)] +struct CryptocurrencyQuoteObject { + quote: HashMap, +} + +#[derive(Debug, Deserialize)] +struct MarketQuote { + price: f64, +} + +#[derive(Debug, Deserialize)] +struct V1CryptocurrencyMapResponse { + data: Vec, +} + +#[derive(Debug, Deserialize)] +struct CryptocurrencyObject { + id: i32, + name: String, + is_active: u8, + platform: Option, +} + +#[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 { + let base_token_in_eth = self.get_token_price_by_address(token_address).await?; + let (term_ether, term_base_token) = get_fraction(base_token_in_eth)?; + + return Ok(BaseTokenAPIRatio { + numerator: term_base_token, + denominator: term_ether, + ratio_timestamp: Utc::now(), + }); + } +} + +#[cfg(test)] +mod tests { + use httpmock::prelude::*; + use serde_json::json; + + use super::*; + use crate::tests::*; + + fn make_client(server: &MockServer, api_key: Option) -> Box { + Box::new(CmcPriceApiClient::new(ExternalPriceApiClientConfig { + source: "coinmarketcap".to_string(), + base_url: Some(server.base_url()), + api_key, + client_timeout_ms: 5000, + forced: None, + })) + } + + fn make_mock_server() -> MockServer { + let mock_server = MockServer::start(); + // cryptocurrency map + mock_server.mock(|when, then| { + when.method(GET) + .header_exists(AUTH_HEADER) + .path("/v1/cryptocurrency/map"); + then.status(200) + .header("content-type", "application/json") + .json_body(json!({ + "status": { + "timestamp": "2024-09-25T11:29:38.440Z", + "error_code": 0, + "error_message": null, + "elapsed": 351, + "credit_count": 1, + "notice": null + }, + "data": [ + { + "id": 7083, + "rank": 26, + "name": "Uniswap", + "symbol": "UNI", + "slug": "uniswap", + "is_active": 1, + "first_historical_data": "2020-09-17T01:10:00.000Z", + "last_historical_data": "2024-09-25T11:25:00.000Z", + "platform": { + "id": 1, + "name": "Ethereum", + "symbol": "ETH", + "slug": "ethereum", + "token_address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984" + } + } + ] + })); + }); + + // cryptocurrency quote + mock_server.mock(|when, then| { + // TODO: check for api authentication header + when.method(GET) + .header_exists(AUTH_HEADER) + .path("/v2/cryptocurrency/quotes/latest") + .query_param("id", "7083") // Uniswap + .query_param("convert_id", "1027"); // Ether + then.status(200) + .header("content-type", "application/json") + .json_body(json!({ + "status": { + "timestamp": "2024-10-02T14:15:07.189Z", + "error_code": 0, + "error_message": null, + "elapsed": 39, + "credit_count": 1, + "notice": null + }, + "data": { + "7083": { + "id": 7083, + "name": "Uniswap", + "symbol": "UNI", + "slug": "uniswap", + "date_added": "2020-09-17T00:00:00.000Z", + "tags": [], + "max_supply": null, + "circulating_supply": 600294743.71, + "total_supply": 1000000000, + "platform": { + "id": 1027, + "name": "Ethereum", + "symbol": "ETH", + "slug": "ethereum", + "token_address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984" + }, + "is_active": 1, + "infinite_supply": false, + "cmc_rank": 22, + "is_fiat": 0, + "last_updated": "2024-10-02T14:13:00.000Z", + "quote": { + "1027": { + "price": 0.0028306661720164175, + "last_updated": "2024-10-02T14:12:00.000Z" + } + } + } + } + })); + }); + + mock_server + } + + #[tokio::test] + async fn mock_happy() { + let server = make_mock_server(); + let client = make_client( + &server, + Some("00000000-0000-0000-0000-000000000000".to_string()), + ); + + let token_address: Address = TEST_TOKEN_ADDRESS.parse().unwrap(); + + let api_price = client.fetch_ratio(token_address).await.unwrap(); + + const REPORTED_PRICE: f64 = 1_f64 / 0.0028306661720164175_f64; + const EPSILON: f64 = 0.000001_f64 * REPORTED_PRICE; + + assert!((approximate_value(&api_price) - REPORTED_PRICE).abs() < EPSILON); + } + + #[tokio::test] + #[should_panic = "Request did not match any route or mock"] + async fn mock_fail_no_api_key() { + let server = make_mock_server(); + let client = make_client(&server, None); + + let token_address: Address = TEST_TOKEN_ADDRESS.parse().unwrap(); + + client.fetch_ratio(token_address).await.unwrap(); + } + + #[tokio::test] + #[should_panic = "Token ID not found for address"] + async fn mock_fail_not_found() { + let server = make_mock_server(); + let client = make_client( + &server, + Some("00000000-0000-0000-0000-000000000000".to_string()), + ); + + let token_address: Address = Address::random(); + + client.fetch_ratio(token_address).await.unwrap(); + } + + #[tokio::test] + #[ignore = "run manually (accesses network); specify CoinMarketCap API key in env var CMC_API_KEY"] + async fn real_cmc_tether() { + let client = CmcPriceApiClient::new(ExternalPriceApiClientConfig { + api_key: Some(std::env::var("CMC_API_KEY").unwrap()), + base_url: None, + client_timeout_ms: 5000, + source: "coinmarketcap".to_string(), + forced: None, + }); + + let tether: Address = "0xdac17f958d2ee523a2206206994597c13d831ec7" + .parse() + .unwrap(); + + let r = client.get_token_price_by_address(tether).await.unwrap(); + + println!("{r}"); + } +} diff --git a/core/lib/external_price_api/src/lib.rs b/core/lib/external_price_api/src/lib.rs index 7a068f9b1cb..01fc433802b 100644 --- a/core/lib/external_price_api/src/lib.rs +++ b/core/lib/external_price_api/src/lib.rs @@ -1,3 +1,4 @@ +pub mod cmc_api; pub mod coingecko_api; pub mod forced_price_client; #[cfg(test)] diff --git a/core/lib/external_price_api/src/tests.rs b/core/lib/external_price_api/src/tests.rs index bb2af866cf5..fd6a8b9928f 100644 --- a/core/lib/external_price_api/src/tests.rs +++ b/core/lib/external_price_api/src/tests.rs @@ -2,13 +2,13 @@ use std::str::FromStr; use chrono::Utc; use httpmock::MockServer; -use zksync_types::Address; +use zksync_types::{base_token_ratio::BaseTokenAPIRatio, Address}; use crate::PriceAPIClient; const TIME_TOLERANCE_MS: i64 = 100; /// Uniswap (UNI) -const TEST_TOKEN_ADDRESS: &str = "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984"; +pub const TEST_TOKEN_ADDRESS: &str = "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984"; /// 1UNI = 0.00269ETH const TEST_TOKEN_PRICE_ETH: f64 = 0.00269; /// 1ETH = 371.74UNI; When converting gas price from ETH to UNI @@ -16,6 +16,10 @@ const TEST_TOKEN_PRICE_ETH: f64 = 0.00269; const TEST_BASE_PRICE: f64 = 371.74; const PRICE_FLOAT_COMPARE_TOLERANCE: f64 = 0.1; +pub(crate) fn approximate_value(api_price: &BaseTokenAPIRatio) -> f64 { + api_price.numerator.get() as f64 / api_price.denominator.get() as f64 +} + pub(crate) struct SetupResult { pub(crate) client: Box, } diff --git a/core/node/node_framework/src/implementations/layers/base_token/coingecko_client.rs b/core/node/node_framework/src/implementations/layers/base_token/coingecko_client.rs deleted file mode 100644 index 14ab568c2f3..00000000000 --- a/core/node/node_framework/src/implementations/layers/base_token/coingecko_client.rs +++ /dev/null @@ -1,55 +0,0 @@ -use std::sync::Arc; - -use zksync_config::configs::ExternalPriceApiClientConfig; -use zksync_external_price_api::coingecko_api::CoinGeckoPriceAPIClient; - -use crate::{ - implementations::resources::price_api_client::PriceAPIClientResource, - wiring_layer::{WiringError, WiringLayer}, - IntoContext, -}; - -/// Wiring layer for `CoingeckoApiClient` -/// -/// Responsible for inserting a resource with a client to get base token prices from CoinGecko to be -/// used by the `BaseTokenRatioPersister`. -#[derive(Debug)] -pub struct CoingeckoClientLayer { - config: ExternalPriceApiClientConfig, -} - -impl CoingeckoClientLayer { - /// Identifier of used client type. - /// Can be used to choose the layer for the client based on configuration variables. - pub const CLIENT_NAME: &'static str = "coingecko"; -} - -#[derive(Debug, IntoContext)] -#[context(crate = crate)] -pub struct Output { - pub price_api_client: PriceAPIClientResource, -} - -impl CoingeckoClientLayer { - pub fn new(config: ExternalPriceApiClientConfig) -> Self { - Self { config } - } -} - -#[async_trait::async_trait] -impl WiringLayer for CoingeckoClientLayer { - type Input = (); - type Output = Output; - - fn layer_name(&self) -> &'static str { - "coingecko_api_client" - } - - async fn wire(self, _input: Self::Input) -> Result { - let cg_client = Arc::new(CoinGeckoPriceAPIClient::new(self.config)); - - Ok(Output { - price_api_client: cg_client.into(), - }) - } -} diff --git a/core/node/node_framework/src/implementations/layers/base_token/forced_price_client.rs b/core/node/node_framework/src/implementations/layers/base_token/forced_price_client.rs deleted file mode 100644 index 67785dc26ed..00000000000 --- a/core/node/node_framework/src/implementations/layers/base_token/forced_price_client.rs +++ /dev/null @@ -1,52 +0,0 @@ -use std::sync::Arc; - -use zksync_config::configs::ExternalPriceApiClientConfig; -use zksync_external_price_api::forced_price_client::ForcedPriceClient; - -use crate::{ - implementations::resources::price_api_client::PriceAPIClientResource, - wiring_layer::{WiringError, WiringLayer}, - IntoContext, -}; - -/// Wiring layer for `ForcedPriceClient` -/// -/// Inserts a resource with a forced configured price to be used by the `BaseTokenRatioPersister`. -#[derive(Debug)] -pub struct ForcedPriceClientLayer { - config: ExternalPriceApiClientConfig, -} - -impl ForcedPriceClientLayer { - pub fn new(config: ExternalPriceApiClientConfig) -> Self { - Self { config } - } - - /// Identifier of used client type. - /// Can be used to choose the layer for the client based on configuration variables. - pub const CLIENT_NAME: &'static str = "forced"; -} - -#[derive(Debug, IntoContext)] -#[context(crate = crate)] -pub struct Output { - pub price_api_client: PriceAPIClientResource, -} - -#[async_trait::async_trait] -impl WiringLayer for ForcedPriceClientLayer { - type Input = (); - type Output = Output; - - fn layer_name(&self) -> &'static str { - "forced_price_client" - } - - async fn wire(self, _input: Self::Input) -> Result { - let forced_client = Arc::new(ForcedPriceClient::new(self.config)); - - Ok(Output { - price_api_client: forced_client.into(), - }) - } -} diff --git a/core/node/node_framework/src/implementations/layers/base_token/mod.rs b/core/node/node_framework/src/implementations/layers/base_token/mod.rs index 5b58527a3d8..7a63b573d78 100644 --- a/core/node/node_framework/src/implementations/layers/base_token/mod.rs +++ b/core/node/node_framework/src/implementations/layers/base_token/mod.rs @@ -1,5 +1,92 @@ +use std::{str::FromStr, sync::Arc}; + +use zksync_config::configs::ExternalPriceApiClientConfig; +use zksync_external_price_api::{ + cmc_api::CmcPriceApiClient, coingecko_api::CoinGeckoPriceAPIClient, + forced_price_client::ForcedPriceClient, NoOpPriceAPIClient, +}; + +use crate::{ + implementations::resources::price_api_client::PriceAPIClientResource, IntoContext, WiringError, + WiringLayer, +}; + pub mod base_token_ratio_persister; pub mod base_token_ratio_provider; -pub mod coingecko_client; -pub mod forced_price_client; -pub mod no_op_external_price_api_client; + +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord, Default)] +enum ExternalPriceApiKind { + #[default] + NoOp, + Forced, + CoinGecko, + CoinMarketCap, +} + +#[derive(Debug, thiserror::Error)] +#[error("Unknown external price API client source: \"{0}\"")] +pub struct UnknownExternalPriceApiClientSourceError(String); + +impl FromStr for ExternalPriceApiKind { + type Err = UnknownExternalPriceApiClientSourceError; + + fn from_str(s: &str) -> Result { + Ok(match &s.to_lowercase()[..] { + "no-op" | "noop" => Self::NoOp, + "forced" => Self::Forced, + "coingecko" => Self::CoinGecko, + "coinmarketcap" => Self::CoinMarketCap, + _ => return Err(UnknownExternalPriceApiClientSourceError(s.to_owned())), + }) + } +} + +impl ExternalPriceApiKind { + fn instantiate(&self, config: ExternalPriceApiClientConfig) -> PriceAPIClientResource { + PriceAPIClientResource(match self { + Self::NoOp => Arc::new(NoOpPriceAPIClient {}), + Self::Forced => Arc::new(ForcedPriceClient::new(config)), + Self::CoinGecko => Arc::new(CoinGeckoPriceAPIClient::new(config)), + Self::CoinMarketCap => Arc::new(CmcPriceApiClient::new(config)), + }) + } +} + +#[derive(Debug)] +pub struct ExternalPriceApiLayer { + kind: ExternalPriceApiKind, + config: ExternalPriceApiClientConfig, +} + +impl TryFrom for ExternalPriceApiLayer { + type Error = UnknownExternalPriceApiClientSourceError; + + fn try_from(config: ExternalPriceApiClientConfig) -> Result { + Ok(Self { + kind: config.source.parse()?, + config, + }) + } +} + +#[derive(Debug, IntoContext)] +#[context(crate = crate)] +pub struct Output { + pub price_api_client: PriceAPIClientResource, +} + +#[async_trait::async_trait] +impl WiringLayer for ExternalPriceApiLayer { + type Input = (); + type Output = Output; + + fn layer_name(&self) -> &'static str { + "external_price_api" + } + + async fn wire(self, _input: Self::Input) -> Result { + Ok(Output { + price_api_client: self.kind.instantiate(self.config), + }) + } +} diff --git a/core/node/node_framework/src/implementations/layers/base_token/no_op_external_price_api_client.rs b/core/node/node_framework/src/implementations/layers/base_token/no_op_external_price_api_client.rs deleted file mode 100644 index 2bf5eda798f..00000000000 --- a/core/node/node_framework/src/implementations/layers/base_token/no_op_external_price_api_client.rs +++ /dev/null @@ -1,45 +0,0 @@ -use std::sync::Arc; - -use zksync_external_price_api::NoOpPriceAPIClient; - -use crate::{ - implementations::resources::price_api_client::PriceAPIClientResource, - wiring_layer::{WiringError, WiringLayer}, - IntoContext, -}; - -/// Wiring layer for `NoOpExternalPriceApiClient` -/// -/// Inserts a resource with a no-op client to get base token prices to be used by the `BaseTokenRatioPersister`. -#[derive(Debug)] -pub struct NoOpExternalPriceApiClientLayer; - -impl NoOpExternalPriceApiClientLayer { - /// Identifier of used client type. - /// Can be used to choose the layer for the client based on configuration variables. - pub const CLIENT_NAME: &'static str = "no-op"; -} - -#[derive(Debug, IntoContext)] -#[context(crate = crate)] -pub struct Output { - pub price_api_client: PriceAPIClientResource, -} - -#[async_trait::async_trait] -impl WiringLayer for NoOpExternalPriceApiClientLayer { - type Input = (); - type Output = Output; - - fn layer_name(&self) -> &'static str { - "no_op_external_price_api_client" - } - - async fn wire(self, _input: Self::Input) -> Result { - let no_op_client = Arc::new(NoOpPriceAPIClient {}); - - Ok(Output { - price_api_client: no_op_client.into(), - }) - } -}