diff --git a/Cargo.lock b/Cargo.lock index b013517e0cc2..46c8736d5b54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7886,6 +7886,22 @@ dependencies = [ "sha3 0.10.8", ] +[[package]] +name = "zksync_base_token_adjuster" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "bigdecimal", + "chrono", + "rand 0.8.5", + "tokio", + "tracing", + "zksync_config", + "zksync_dal", + "zksync_types", +] + [[package]] name = "zksync_basic_types" version = "0.1.0" @@ -8487,6 +8503,17 @@ dependencies = [ "zksync_web3_decl", ] +[[package]] +name = "zksync_external_price_api" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "serde", + "zksync_config", + "zksync_types", +] + [[package]] name = "zksync_health_check" version = "0.1.0" @@ -8736,10 +8763,12 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "bigdecimal", "test-casing", "tokio", "tracing", "vise", + "zksync_base_token_adjuster", "zksync_config", "zksync_dal", "zksync_eth_client", @@ -8764,6 +8793,7 @@ dependencies = [ "tokio", "tracing", "vlog", + "zksync_base_token_adjuster", "zksync_block_reverter", "zksync_circuit_breaker", "zksync_commitment_generator", @@ -9129,6 +9159,7 @@ dependencies = [ "tracing", "vise", "vm_utils", + "zksync_base_token_adjuster", "zksync_config", "zksync_contracts", "zksync_dal", @@ -9231,6 +9262,7 @@ name = "zksync_types" version = "0.1.0" dependencies = [ "anyhow", + "bigdecimal", "bincode", "blake2 0.10.6 (registry+https://github.com/rust-lang/crates.io-index)", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 5d9f6adf37ad..11317fc25e00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ members = [ "core/node/contract_verification_server", "core/node/api_server", "core/node/tee_verifier_input_producer", + "core/node/base_token_adjuster", # Libraries "core/lib/db_connection", "core/lib/zksync_core_leftovers", @@ -66,6 +67,7 @@ members = [ "core/lib/web3_decl", "core/lib/snapshots_applier", "core/lib/crypto_primitives", + "core/lib/external_price_api", # Test infrastructure "core/tests/test_account", "core/tests/loadnext", @@ -265,3 +267,4 @@ zksync_node_consensus = { path = "core/node/consensus" } zksync_contract_verification_server = { path = "core/node/contract_verification_server" } zksync_node_api_server = { path = "core/node/api_server" } zksync_tee_verifier_input_producer = { path = "core/node/tee_verifier_input_producer" } +zksync_base_token_adjuster = {path = "core/node/base_token_adjuster"} diff --git a/contracts b/contracts index db9387690502..8a70bbbc4812 160000 --- a/contracts +++ b/contracts @@ -1 +1 @@ -Subproject commit db9387690502937de081a959b164db5a5262ce0a +Subproject commit 8a70bbbc48125f5bde6189b4e3c6a3ee79631678 diff --git a/core/bin/zksync_server/src/main.rs b/core/bin/zksync_server/src/main.rs index dfb11b55da92..3914d34c362d 100644 --- a/core/bin/zksync_server/src/main.rs +++ b/core/bin/zksync_server/src/main.rs @@ -16,8 +16,9 @@ use zksync_config::{ L1Secrets, ObservabilityConfig, PrometheusConfig, ProofDataHandlerConfig, ProtectiveReadsWriterConfig, Secrets, }, - ApiConfig, ContractVerifierConfig, DBConfig, EthConfig, EthWatchConfig, GasAdjusterConfig, - GenesisConfig, ObjectStoreConfig, PostgresConfig, SnapshotsCreatorConfig, + ApiConfig, BaseTokenAdjusterConfig, ContractVerifierConfig, DBConfig, EthConfig, + EthWatchConfig, GasAdjusterConfig, GenesisConfig, ObjectStoreConfig, PostgresConfig, + SnapshotsCreatorConfig, }; use zksync_core_leftovers::{ genesis_init, is_genesis_needed, @@ -270,5 +271,6 @@ fn load_env_config() -> anyhow::Result { snapshot_creator: SnapshotsCreatorConfig::from_env().ok(), protective_reads_writer_config: ProtectiveReadsWriterConfig::from_env().ok(), core_object_store: ObjectStoreConfig::from_env().ok(), + base_token_adjuster_config: BaseTokenAdjusterConfig::from_env().ok(), }) } diff --git a/core/bin/zksync_server/src/node_builder.rs b/core/bin/zksync_server/src/node_builder.rs index 096d5e783551..5f6a1051b26f 100644 --- a/core/bin/zksync_server/src/node_builder.rs +++ b/core/bin/zksync_server/src/node_builder.rs @@ -15,6 +15,7 @@ use zksync_node_api_server::{ }; use zksync_node_framework::{ implementations::layers::{ + base_token_adjuster::BaseTokenAdjusterLayer, circuit_breaker_checker::CircuitBreakerCheckerLayer, commitment_generator::CommitmentGeneratorLayer, consensus::{ConsensusLayer, Mode as ConsensusMode}, @@ -145,11 +146,13 @@ impl MainNodeBuilder { .context("Gas adjuster")?; let state_keeper_config = try_load_config!(self.configs.state_keeper_config); let eth_sender_config = try_load_config!(self.configs.eth); + let base_token_adjuster_config = try_load_config!(self.configs.base_token_adjuster); let sequencer_l1_gas_layer = SequencerL1GasLayer::new( gas_adjuster_config, self.genesis_config.clone(), state_keeper_config, try_load_config!(eth_sender_config.sender).pubdata_sending_mode, + base_token_adjuster_config, ); self.node.add_layer(sequencer_l1_gas_layer); Ok(self) @@ -444,6 +447,13 @@ impl MainNodeBuilder { Ok(self) } + fn add_base_token_adjuster_layer(mut self) -> anyhow::Result { + let config = try_load_config!(self.configs.base_token_adjuster); + self.node.add_layer(BaseTokenAdjusterLayer::new(config)); + + Ok(self) + } + pub fn build(mut self, mut components: Vec) -> anyhow::Result { // Add "base" layers (resources and helper tasks). self = self @@ -531,6 +541,9 @@ impl MainNodeBuilder { Component::VmRunnerProtectiveReads => { self = self.add_vm_runner_protective_reads_layer()?; } + Component::BaseTokenAdjuster => { + self = self.add_base_token_adjuster_layer()?; + } } } Ok(self.node.build()?) diff --git a/core/lib/config/src/configs/base_token_adjuster.rs b/core/lib/config/src/configs/base_token_adjuster.rs new file mode 100644 index 000000000000..2a6b3851b373 --- /dev/null +++ b/core/lib/config/src/configs/base_token_adjuster.rs @@ -0,0 +1,31 @@ +use std::time::Duration; + +use serde::Deserialize; + +// By default external APIs shall be polled every 30 seconds for a new price. +pub const DEFAULT_INTERVAL_MS: u64 = 30_000; + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct BaseTokenAdjusterConfig { + /// How often to fetch external APIs for a new ETH<->Base-Token price. + pub price_polling_interval_ms: Option, + + /// Base token symbol. If none, an assumption of ETH is made. + pub base_token: Option, +} + +impl BaseTokenAdjusterConfig { + pub fn for_tests() -> Self { + Self { + price_polling_interval_ms: Some(DEFAULT_INTERVAL_MS), + base_token: Option::from("ETH".to_string()), + } + } + + pub fn price_polling_interval(&self) -> Duration { + match self.price_polling_interval_ms { + Some(interval) => Duration::from_millis(interval), + None => Duration::from_millis(DEFAULT_INTERVAL_MS), + } + } +} diff --git a/core/lib/config/src/configs/general.rs b/core/lib/config/src/configs/general.rs index 9f249d655f57..97bff71de89f 100644 --- a/core/lib/config/src/configs/general.rs +++ b/core/lib/config/src/configs/general.rs @@ -1,5 +1,6 @@ use crate::{ configs::{ + base_token_adjuster::BaseTokenAdjusterConfig, chain::{CircuitBreakerConfig, MempoolConfig, OperationsManagerConfig, StateKeeperConfig}, fri_prover_group::FriProverGroupConfig, house_keeper::HouseKeeperConfig, @@ -36,4 +37,5 @@ pub struct GeneralConfig { pub observability: Option, pub protective_reads_writer_config: Option, pub core_object_store: Option, + pub base_token_adjuster: Option, } diff --git a/core/lib/config/src/configs/mod.rs b/core/lib/config/src/configs/mod.rs index b2d9571ad292..ea69ab512cd7 100644 --- a/core/lib/config/src/configs/mod.rs +++ b/core/lib/config/src/configs/mod.rs @@ -1,6 +1,7 @@ // Public re-exports pub use self::{ api::ApiConfig, + base_token_adjuster::BaseTokenAdjusterConfig, contract_verifier::ContractVerifierConfig, contracts::{ContractsConfig, EcosystemContracts}, database::{DBConfig, PostgresConfig}, @@ -24,6 +25,7 @@ pub use self::{ }; pub mod api; +pub mod base_token_adjuster; pub mod chain; pub mod consensus; pub mod contract_verifier; diff --git a/core/lib/config/src/lib.rs b/core/lib/config/src/lib.rs index 66656e60b702..98dc194a56e8 100644 --- a/core/lib/config/src/lib.rs +++ b/core/lib/config/src/lib.rs @@ -1,8 +1,9 @@ #![allow(clippy::upper_case_acronyms, clippy::derive_partial_eq_without_eq)] pub use crate::configs::{ - ApiConfig, ContractVerifierConfig, ContractsConfig, DBConfig, EthConfig, EthWatchConfig, - GasAdjusterConfig, GenesisConfig, ObjectStoreConfig, PostgresConfig, SnapshotsCreatorConfig, + ApiConfig, BaseTokenAdjusterConfig, ContractVerifierConfig, ContractsConfig, DBConfig, + EthConfig, EthWatchConfig, GasAdjusterConfig, GenesisConfig, ObjectStoreConfig, PostgresConfig, + SnapshotsCreatorConfig, }; pub mod configs; diff --git a/core/lib/dal/.sqlx/query-4f4a8489cb3ee9337fce8c5c9756263bca6b63f6793b4e92e179a48491282d4c.json b/core/lib/dal/.sqlx/query-4f4a8489cb3ee9337fce8c5c9756263bca6b63f6793b4e92e179a48491282d4c.json new file mode 100644 index 000000000000..894812a14df8 --- /dev/null +++ b/core/lib/dal/.sqlx/query-4f4a8489cb3ee9337fce8c5c9756263bca6b63f6793b4e92e179a48491282d4c.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO\n base_token_prices (base_token_price, eth_price, ratio_timestamp, created_at, updated_at)\n VALUES\n ($1, $2, $3, NOW(), NOW())\n RETURNING\n id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Numeric", + "Numeric", + "Timestamp" + ] + }, + "nullable": [ + false + ] + }, + "hash": "4f4a8489cb3ee9337fce8c5c9756263bca6b63f6793b4e92e179a48491282d4c" +} diff --git a/core/lib/dal/.sqlx/query-fc15573c6564779792e7ab69f66ba6b5dc624747adb73c9f94afe84e61f7513d.json b/core/lib/dal/.sqlx/query-fc15573c6564779792e7ab69f66ba6b5dc624747adb73c9f94afe84e61f7513d.json new file mode 100644 index 000000000000..f235bbf16093 --- /dev/null +++ b/core/lib/dal/.sqlx/query-fc15573c6564779792e7ab69f66ba6b5dc624747adb73c9f94afe84e61f7513d.json @@ -0,0 +1,56 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n *\n FROM\n base_token_prices\n ORDER BY\n created_at DESC\n LIMIT\n 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamp" + }, + { + "ordinal": 2, + "name": "updated_at", + "type_info": "Timestamp" + }, + { + "ordinal": 3, + "name": "ratio_timestamp", + "type_info": "Timestamp" + }, + { + "ordinal": 4, + "name": "base_token_price", + "type_info": "Numeric" + }, + { + "ordinal": 5, + "name": "eth_price", + "type_info": "Numeric" + }, + { + "ordinal": 6, + "name": "used_in_l1", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "fc15573c6564779792e7ab69f66ba6b5dc624747adb73c9f94afe84e61f7513d" +} diff --git a/core/lib/dal/migrations/20240611121747_add_base_token_price_table.down.sql b/core/lib/dal/migrations/20240611121747_add_base_token_price_table.down.sql new file mode 100644 index 000000000000..db15b29ecaa3 --- /dev/null +++ b/core/lib/dal/migrations/20240611121747_add_base_token_price_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS base_token_price; diff --git a/core/lib/dal/migrations/20240611121747_add_base_token_price_table.up.sql b/core/lib/dal/migrations/20240611121747_add_base_token_price_table.up.sql new file mode 100644 index 000000000000..ba9a934794d6 --- /dev/null +++ b/core/lib/dal/migrations/20240611121747_add_base_token_price_table.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE base_token_prices ( + id SERIAL PRIMARY KEY, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + + ratio_timestamp TIMESTAMP NOT NULL, + base_token_price NUMERIC NOT NULL, + eth_price NUMERIC NOT NULL, + + used_in_l1 BOOLEAN NOT NULL DEFAULT FALSE +); diff --git a/core/lib/dal/src/base_token_dal.rs b/core/lib/dal/src/base_token_dal.rs new file mode 100644 index 000000000000..3b812a54e666 --- /dev/null +++ b/core/lib/dal/src/base_token_dal.rs @@ -0,0 +1,59 @@ +use bigdecimal::BigDecimal; +use zksync_db_connection::{connection::Connection, error::DalResult, instrument::InstrumentExt}; + +use crate::{models::storage_base_token_price::StorageBaseTokenPrice, Core}; + +#[derive(Debug)] +pub struct BaseTokenDal<'a, 'c> { + pub(crate) storage: &'a mut Connection<'c, Core>, +} + +impl BaseTokenDal<'_, '_> { + pub async fn insert_token_price( + &mut self, + base_token_price: &BigDecimal, + eth_price: &BigDecimal, + ratio_timestamp: &chrono::NaiveDateTime, + ) -> DalResult { + let row = sqlx::query!( + r#" + INSERT INTO + base_token_prices (base_token_price, eth_price, ratio_timestamp, created_at, updated_at) + VALUES + ($1, $2, $3, NOW(), NOW()) + RETURNING + id + "#, + base_token_price, + eth_price, + ratio_timestamp, + ) + .instrument("insert_base_token_price") + .fetch_one(self.storage) + .await?; + + Ok(row.id as usize) + } + + // TODO (PE-128): pub async fn mark_l1_update() + + pub async fn get_latest_price(&mut self) -> DalResult { + let row = sqlx::query_as!( + StorageBaseTokenPrice, + r#" + SELECT + * + FROM + base_token_prices + ORDER BY + created_at DESC + LIMIT + 1 + "#, + ) + .instrument("get_latest_base_token_price") + .fetch_one(self.storage) + .await?; + Ok(row) + } +} diff --git a/core/lib/dal/src/lib.rs b/core/lib/dal/src/lib.rs index 0a2ed3bdd641..5c59e43e368a 100644 --- a/core/lib/dal/src/lib.rs +++ b/core/lib/dal/src/lib.rs @@ -12,10 +12,11 @@ pub use zksync_db_connection::{ }; use crate::{ - blocks_dal::BlocksDal, blocks_web3_dal::BlocksWeb3Dal, consensus_dal::ConsensusDal, - contract_verification_dal::ContractVerificationDal, eth_sender_dal::EthSenderDal, - events_dal::EventsDal, events_web3_dal::EventsWeb3Dal, factory_deps_dal::FactoryDepsDal, - proof_generation_dal::ProofGenerationDal, protocol_versions_dal::ProtocolVersionsDal, + base_token_dal::BaseTokenDal, blocks_dal::BlocksDal, blocks_web3_dal::BlocksWeb3Dal, + consensus_dal::ConsensusDal, contract_verification_dal::ContractVerificationDal, + eth_sender_dal::EthSenderDal, events_dal::EventsDal, events_web3_dal::EventsWeb3Dal, + factory_deps_dal::FactoryDepsDal, proof_generation_dal::ProofGenerationDal, + protocol_versions_dal::ProtocolVersionsDal, protocol_versions_web3_dal::ProtocolVersionsWeb3Dal, pruning_dal::PruningDal, snapshot_recovery_dal::SnapshotRecoveryDal, snapshots_creator_dal::SnapshotsCreatorDal, snapshots_dal::SnapshotsDal, storage_logs_dal::StorageLogsDal, @@ -26,6 +27,7 @@ use crate::{ transactions_web3_dal::TransactionsWeb3Dal, vm_runner_dal::VmRunnerDal, }; +pub mod base_token_dal; pub mod blocks_dal; pub mod blocks_web3_dal; pub mod consensus; @@ -125,6 +127,8 @@ where fn pruning_dal(&mut self) -> PruningDal<'_, 'a>; fn vm_runner_dal(&mut self) -> VmRunnerDal<'_, 'a>; + + fn base_token_dal(&mut self) -> BaseTokenDal<'_, 'a>; } #[derive(Clone, Debug)] @@ -243,4 +247,8 @@ impl<'a> CoreDal<'a> for Connection<'a, Core> { fn vm_runner_dal(&mut self) -> VmRunnerDal<'_, 'a> { VmRunnerDal { storage: self } } + + fn base_token_dal(&mut self) -> BaseTokenDal<'_, 'a> { + BaseTokenDal { storage: self } + } } diff --git a/core/lib/dal/src/models/mod.rs b/core/lib/dal/src/models/mod.rs index bc0e2c657da5..97661aa66ba9 100644 --- a/core/lib/dal/src/models/mod.rs +++ b/core/lib/dal/src/models/mod.rs @@ -3,6 +3,7 @@ use anyhow::Context as _; use zksync_db_connection::error::SqlxContext; use zksync_types::{ProtocolVersionId, H160, H256}; +pub mod storage_base_token_price; pub mod storage_eth_tx; pub mod storage_event; pub mod storage_log; diff --git a/core/lib/dal/src/models/storage_base_token_price.rs b/core/lib/dal/src/models/storage_base_token_price.rs new file mode 100644 index 000000000000..f7d8634f90e4 --- /dev/null +++ b/core/lib/dal/src/models/storage_base_token_price.rs @@ -0,0 +1,27 @@ +use bigdecimal::BigDecimal; +use chrono::NaiveDateTime; +use zksync_types::base_token_price::BaseTokenPrice; + +/// Represents a row in the `storage_base_token_price` table. +#[derive(Debug, Clone)] +pub struct StorageBaseTokenPrice { + pub id: i64, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub ratio_timestamp: NaiveDateTime, + pub base_token_price: BigDecimal, + pub eth_price: BigDecimal, + pub used_in_l1: bool, +} + +impl From for BaseTokenPrice { + fn from(row: StorageBaseTokenPrice) -> BaseTokenPrice { + BaseTokenPrice { + id: row.id, + ratio_timestamp: row.ratio_timestamp.and_utc(), + base_token_price: row.base_token_price, + eth_price: row.eth_price, + used_in_l1: row.used_in_l1, + } + } +} diff --git a/core/lib/env_config/src/chain.rs b/core/lib/env_config/src/chain.rs index 5aaae9216736..89fa8d64d2cc 100644 --- a/core/lib/env_config/src/chain.rs +++ b/core/lib/env_config/src/chain.rs @@ -1,5 +1,9 @@ -use zksync_config::configs::chain::{ - CircuitBreakerConfig, MempoolConfig, NetworkConfig, OperationsManagerConfig, StateKeeperConfig, +use zksync_config::{ + configs::chain::{ + CircuitBreakerConfig, MempoolConfig, NetworkConfig, OperationsManagerConfig, + StateKeeperConfig, + }, + BaseTokenAdjusterConfig, }; use crate::{envy_load, FromEnv}; @@ -34,6 +38,12 @@ impl FromEnv for MempoolConfig { } } +impl FromEnv for BaseTokenAdjusterConfig { + fn from_env() -> anyhow::Result { + envy_load("base_token_adjuster", "BASE_TOKEN_ADJUSTER_") + } +} + #[cfg(test)] mod tests { use zksync_basic_types::{commitment::L1BatchCommitmentMode, L2ChainId}; diff --git a/core/lib/external_price_api/Cargo.toml b/core/lib/external_price_api/Cargo.toml new file mode 100644 index 000000000000..1abf08674c1c --- /dev/null +++ b/core/lib/external_price_api/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "zksync_external_price_api" +version.workspace = true +edition.workspace = true +authors.workspace = true +homepage.workspace = true +repository.workspace = true +license.workspace = true +keywords.workspace = true +categories.workspace = true + +[dependencies] +serde = { workspace = true, features = ["derive"] } +async-trait.workspace = true +anyhow.workspace = true + +zksync_config.workspace = true +zksync_types.workspace = true +reqwest.workspace = true +url.workspace = true +serde_json.workspace = true +num.workspace = true +bigdecimal.workspace = true +chrono.workspace = true +tokio.workspace = true +tracing.workspace = true + + +[dev-dependencies] +httpmock = "0.7.0" diff --git a/core/lib/external_price_api/README.md b/core/lib/external_price_api/README.md new file mode 100644 index 000000000000..d1604bbae7e7 --- /dev/null +++ b/core/lib/external_price_api/README.md @@ -0,0 +1,7 @@ +# Price API Client + +This crate provides a simple trait to be implemented by clients interacting with external price APIs to fetch +ETH<->BaseToken ratio. + +All clients should be implemented here and used by the node framework layer, which will be agnostic to the number of +clients available. 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 000000000000..0552840a4e98 --- /dev/null +++ b/core/lib/external_price_api/src/cmc_api.rs @@ -0,0 +1,467 @@ +use std::{collections::HashMap, str::FromStr}; + +use async_trait::async_trait; +use bigdecimal::BigDecimal; +use chrono::Utc; +use serde::Deserialize; +use url::Url; +use zksync_types::{base_token_price::BaseTokenAPIPrice, Address}; + +use crate::{address_to_string, PriceAPIClient}; + +const CMC_AUTH_HEADER: &str = "X-CMC_PRO_API_KEY"; +// it's safe to have id hardcoded as they are stable as claimed by CMC +const CMC_ETH_ID: i32 = 1027; + +#[derive(Debug)] +pub struct CMCPriceAPIClient { + base_url: Url, + api_key: String, + client: reqwest::Client, + token_id_by_address: HashMap, +} + +impl CMCPriceAPIClient { + fn new(base_url: Url, api_key: String, client: reqwest::Client) -> Self { + Self { + base_url, + api_key, + client, + token_id_by_address: HashMap::new(), + } + } + + async fn get_token_id(self: &mut Self, address: Address) -> anyhow::Result { + match self.token_id_by_address.get(&address) { + Some(x) => Ok(*x), + None => { + let url = self + .base_url + .join("/v1/cryptocurrency/map") + .expect("failed to join URL path"); + let address_str = address_to_string(&address); + let response = self + .client + .get(url) + .header(CMC_AUTH_HEADER, &self.api_key) + .send() + .await?; + if !response.status().is_success() { + return Err(anyhow::anyhow!( + "Http error while fetching token id. Status: {}, token: {}, msg: {}", + response.status(), + address_str, + response.text().await.unwrap_or("".to_string()) + )); + } + + let cmc_response = response.json::().await?; + for token_info in cmc_response.data { + if let Some(platform) = token_info.platform { + if platform.name.to_ascii_lowercase() == "ethereum" + && platform.token_address == address_str + { + self.token_id_by_address.insert(address, token_info.id); + return Ok(token_info.id); + } + } + } + + Err(anyhow::anyhow!( + "Token id not found for address {}", + address_str + )) + } + } + } + + async fn get_token_price_by_address(self: &mut 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: &Self, id: i32) -> anyhow::Result { + let price_url = self + .base_url + .join(format!("/v1/cryptocurrency/quotes/latest?id={}", id).as_str()) + .expect("failed to join URL path"); + + let response = self + .client + .get(price_url) + .header(CMC_AUTH_HEADER, &self.api_key) + .send() + .await?; + if !response.status().is_success() { + return Err(anyhow::anyhow!( + "Http error while fetching token price. Status: {}, token: {}, msg: {}", + response.status(), + id, + response.text().await.unwrap_or("".to_string()) + )); + } + + let json_response = response.json::().await?; + match json_response["data"][id.to_string()]["quote"]["USD"]["price"].as_f64() { + Some(x) => Ok(x), + None => Err(anyhow::anyhow!("Price not found for token: {}", &id)), + } + } +} + +#[derive(Debug, Deserialize)] +struct CMCResponse { + data: Vec, +} + +#[derive(Debug, Deserialize)] +struct CMCTokenInfo { + id: i32, + platform: Option, +} + +#[derive(Debug, Deserialize)] +struct CMCPlatform { + name: String, + token_address: String, +} + +#[async_trait] +impl PriceAPIClient for CMCPriceAPIClient { + async fn fetch_price(&mut self, token_address: Address) -> anyhow::Result { + let token_usd_price = self.get_token_price_by_address(token_address).await?; + let eth_usd_price = self.get_token_price_by_id(CMC_ETH_ID).await?; + return Ok(BaseTokenAPIPrice { + base_token_price: BigDecimal::from_str(&token_usd_price.to_string())?, + eth_price: BigDecimal::from_str(ð_usd_price.to_string())?, + ratio_timestamp: Utc::now(), + }); + } +} + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, str::FromStr}; + + use bigdecimal::BigDecimal; + use httpmock::{Mock, MockServer}; + use zksync_types::{base_token_price::BaseTokenAPIPrice, Address}; + + use crate::{ + address_to_string, + cmc_api::{CMCPriceAPIClient, CMC_AUTH_HEADER, CMC_ETH_ID}, + tests::tests::{ + add_mock, base_token_price_not_found_test, eth_price_not_found_test, happy_day_test, + no_base_token_price_404_test, no_eth_price_404_test, server_url, + }, + PriceAPIClient, + }; + + const TEST_API_KEY: &str = "test"; + + fn mock_crypto_map<'a>( + server: &'a MockServer, + address: &'a Address, + mock_id: &'a String, + ) -> Mock<'a> { + let address_str = address_to_string(address); + let body = format!( + r#"{{ + "data": [ + {{ + "id": 9999, + "platform": {{ + "name": "Ethereum2", + "token_address": "{}" + }} + }}, + {{ + "id": {}, + "platform": {{ + "name": "Ethereum", + "token_address": "{}" + }} + }} + ] + }}"#, + address_str, mock_id, address_str + ); + add_mock( + server, + httpmock::Method::GET, + "/v1/cryptocurrency/map".to_string(), + HashMap::new(), + 200, + body, + CMC_AUTH_HEADER.to_string(), + Some(TEST_API_KEY.to_string()), + ) + } + + fn add_mock_by_id<'a>( + server: &'a MockServer, + id: &'a String, + price: &'a String, + currency: &'a String, + ) -> Mock<'a> { + let body = format!( + r#"{{ + "data": {{ + "{}": {{ + "quote": {{ + "{}": {{ + "price": {} + }} + }} + }} + }} + }}"#, + id, currency, price + ); + let mut params = HashMap::new(); + params.insert("id".to_string(), id.clone()); + add_mock( + server, + httpmock::Method::GET, + "/v1/cryptocurrency/quotes/latest".to_string(), + params, + 200, + body, + CMC_AUTH_HEADER.to_string(), + Some(TEST_API_KEY.to_string()), + ) + } + + fn happy_day_setup( + server: &MockServer, + api_key: Option, + address: Address, + base_token_price: f64, + eth_price: f64, + ) -> Box { + let id = "50".to_string(); + let currency = "USD".to_string(); + mock_crypto_map(server, &address, &id); + add_mock_by_id(server, &id, &base_token_price.to_string(), ¤cy); + add_mock_by_id( + server, + &CMC_ETH_ID.to_string(), + ð_price.to_string(), + ¤cy, + ); + Box::new(CMCPriceAPIClient::new( + server_url(&server), + api_key.unwrap(), + reqwest::Client::new(), + )) + } + + #[tokio::test] + async fn test_happy_day() { + happy_day_test(Some(TEST_API_KEY.to_string()), happy_day_setup).await + } + + #[tokio::test] + async fn test_no_token_id() { + let server = MockServer::start(); + let address_str = "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984"; + let address = Address::from_str(address_str).unwrap(); + let id = "50".to_string(); + let base_token_price = 198.9; + let eth_price = 3000.0; + let currency = "USD".to_string(); + + // the response will be missing the token that we are seeking for + mock_crypto_map( + &server, + &Address::from_str("0x3Bad7800d9149B53Cba5da927E6449e4A3487a1F").unwrap(), + &"123".to_string(), + ); + add_mock_by_id(&server, &id, &base_token_price.to_string(), ¤cy); + add_mock_by_id( + &server, + &CMC_ETH_ID.to_string(), + ð_price.to_string(), + ¤cy, + ); + + let mut client = CMCPriceAPIClient::new( + server_url(&server), + TEST_API_KEY.to_string(), + reqwest::Client::new(), + ); + let api_price = client.fetch_price(address).await; + + assert!(api_price.is_err()); + let msg = api_price.err().unwrap().to_string(); + assert_eq!( + "Token id not found for address 0x1f9840a85d5af5bf1d1762f925bdaddc4201f984".to_string(), + msg + ) + } + + #[tokio::test] + async fn should_reuse_token_id_from_map() { + let server = MockServer::start(); + let address_str = "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984"; + let address = Address::from_str(address_str).unwrap(); + let base_token_price = 198.9; + let eth_price = 3000.0; + let id = "50".to_string(); + let currency = "USD".to_string(); + + let cm_mock = mock_crypto_map(&server, &address, &id); + add_mock_by_id(&server, &id, &base_token_price.to_string(), ¤cy); + add_mock_by_id( + &server, + &CMC_ETH_ID.to_string(), + ð_price.to_string(), + ¤cy, + ); + let mut client = CMCPriceAPIClient::new( + server_url(&server), + TEST_API_KEY.to_string(), + reqwest::Client::new(), + ); + + client.fetch_price(address).await.unwrap(); + let api_price = client.fetch_price(address).await.unwrap(); + + assert_eq!( + BaseTokenAPIPrice { + base_token_price: BigDecimal::from_str(&base_token_price.to_string()).unwrap(), + eth_price: BigDecimal::from_str(ð_price.to_string()).unwrap(), + ratio_timestamp: api_price.ratio_timestamp, + }, + api_price + ); + // crypto map should be fetched only once + assert_eq!(1, cm_mock.hits()); + } + + #[tokio::test] + async fn test_no_eth_price_404() { + no_eth_price_404_test( + Some(TEST_API_KEY.to_string()), + |server: &MockServer, + api_key: Option, + address: Address, + _base_token_price: f64, + _eth_price: f64| + -> Box { + let id = "50".to_string(); + mock_crypto_map(&server, &address, &id); + add_mock_by_id(&server, &id, &"123".to_string(), &"USD".to_string()); + + Box::new(CMCPriceAPIClient::new( + server_url(&server), + api_key.unwrap(), + reqwest::Client::new(), + )) + }, + ) + .await; + } + + #[tokio::test] + async fn test_eth_price_not_found() { + eth_price_not_found_test( + Some(TEST_API_KEY.to_string()), + |server: &MockServer, + api_key: Option, + address: Address, + _base_token_price: f64, + _eth_price: f64| + -> Box { + let id = "50".to_string(); + mock_crypto_map(&server, &address, &id); + add_mock_by_id(&server, &id, &"123".to_string(), &"USD".to_string()); + let mut params = HashMap::new(); + params.insert("id".to_string(), CMC_ETH_ID.to_string()); + add_mock( + server, + httpmock::Method::GET, + "/v1/cryptocurrency/quotes/latest".to_string(), + params, + 200, + "{}".to_string(), + CMC_AUTH_HEADER.to_string(), + Some(TEST_API_KEY.to_string()), + ); + Box::new(CMCPriceAPIClient::new( + server_url(&server), + api_key.unwrap(), + reqwest::Client::new(), + )) + }, + ) + .await; + } + + #[tokio::test] + async fn test_no_base_token_price_404() { + no_base_token_price_404_test( + Some(TEST_API_KEY.to_string()), + |server: &MockServer, + api_key: Option, + address: Address, + _base_token_price: f64, + _eth_price: f64| + -> Box { + mock_crypto_map(&server, &address, &"55".to_string()); + add_mock_by_id( + &server, + &CMC_ETH_ID.to_string(), + &"3900.12".to_string(), + &"USD".to_string(), + ); + Box::new(CMCPriceAPIClient::new( + server_url(&server), + api_key.unwrap(), + reqwest::Client::new(), + )) + }, + ) + .await; + } + + #[tokio::test] + async fn test_base_token_price_not_found() { + base_token_price_not_found_test( + Some(TEST_API_KEY.to_string()), + |server: &MockServer, + api_key: Option, + address: Address, + _base_token_price: f64, + _eth_price: f64| + -> Box { + let id = "55".to_string(); + mock_crypto_map(&server, &address, &id); + add_mock_by_id( + &server, + &CMC_ETH_ID.to_string(), + &"3900.12".to_string(), + &"USD".to_string(), + ); + let mut params = HashMap::new(); + params.insert("id".to_string(), id); + add_mock( + server, + httpmock::Method::GET, + "/v1/cryptocurrency/quotes/latest".to_string(), + params, + 200, + "{}".to_string(), + CMC_AUTH_HEADER.to_string(), + Some(TEST_API_KEY.to_string()), + ); + Box::new(CMCPriceAPIClient::new( + server_url(&server), + api_key.unwrap(), + reqwest::Client::new(), + )) + }, + ) + .await; + } +} diff --git a/core/lib/external_price_api/src/coingecko_api.rs b/core/lib/external_price_api/src/coingecko_api.rs new file mode 100644 index 000000000000..a35277e791ca --- /dev/null +++ b/core/lib/external_price_api/src/coingecko_api.rs @@ -0,0 +1,330 @@ +use std::{collections::HashMap, str::FromStr}; + +use async_trait::async_trait; +use bigdecimal::BigDecimal; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use url::Url; +use zksync_types::{base_token_price::BaseTokenAPIPrice, Address}; + +use crate::{address_to_string, PriceAPIClient}; + +#[derive(Debug)] +pub struct CoinGeckoPriceAPIClient { + base_url: Url, + api_key: Option, + client: reqwest::Client, +} + +const COIN_GECKO_AUTH_HEADER: &str = "x-cg-pro-api-key"; + +impl CoinGeckoPriceAPIClient { + pub fn new(base_url: Url, api_key: Option, client: reqwest::Client) -> Self { + Self { + base_url, + api_key, + client, + } + } + + async fn get_token_price_by_address(self: &Self, address: Address) -> anyhow::Result { + let vs_currency = "usd"; + let address_str = address_to_string(&address); + let price_url = self + .base_url + .join( + format!( + "/api/v3/simple/token_price/ethereum?contract_addresses={}&vs_currencies={}", + address_str, vs_currency + ) + .as_str(), + ) + .expect("failed to join URL path"); + + let mut builder = self.client.get(price_url); + + if let Some(x) = &self.api_key { + builder = builder.header(COIN_GECKO_AUTH_HEADER, x); + } + + let response = builder.send().await?; + if !response.status().is_success() { + return Err(anyhow::anyhow!( + "Http error while fetching token price. Status: {}, token: {}, msg: {}", + response.status(), + address_str, + response.text().await.unwrap_or("".to_string()) + )); + } + let cg_response = response.json::().await?; + match cg_response.get_price(&address_str, &String::from(vs_currency)) { + Some(&price) => Ok(price), + None => Err(anyhow::anyhow!( + "Price not found for token: {}", + address_str + )), + } + } + + async fn get_token_price_by_id(self: &Self, id: String) -> anyhow::Result { + let vs_currency = "usd"; + let price_url = self + .base_url + .join( + format!( + "/api/v3/simple/price?ids={}&vs_currencies={}", + id, vs_currency + ) + .as_str(), + ) + .expect("Failed to join URL path"); + + let mut builder = self.client.get(price_url); + + if let Some(x) = &self.api_key { + builder = builder.header(COIN_GECKO_AUTH_HEADER, x) + } + + let response = builder.send().await?; + if !response.status().is_success() { + return Err(anyhow::anyhow!( + "Http error while fetching token price. Status: {}, token: {}, msg: {}", + response.status(), + id, + response.text().await.unwrap_or("".to_string()) + )); + } + let cg_response = response.json::().await?; + match cg_response.get_price(&id, &String::from(vs_currency)) { + Some(&price) => Ok(price), + None => Err(anyhow::anyhow!("Price not found for token: {}", id)), + } + } +} + +#[async_trait] +impl PriceAPIClient for CoinGeckoPriceAPIClient { + async fn fetch_price(&mut self, token_address: Address) -> anyhow::Result { + let token_usd_price = self.get_token_price_by_address(token_address).await?; + let eth_usd_price = self.get_token_price_by_id("ethereum".to_string()).await?; + return Ok(BaseTokenAPIPrice { + base_token_price: BigDecimal::from_str(&token_usd_price.to_string())?, + eth_price: BigDecimal::from_str(ð_usd_price.to_string())?, + ratio_timestamp: Utc::now(), + }); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CoinGeckoPriceResponse { + #[serde(flatten)] + pub(crate) prices: HashMap>, +} + +impl CoinGeckoPriceResponse { + fn get_price(self: &Self, address: &String, currency: &String) -> Option<&f64> { + self.prices + .get(address) + .and_then(|price| price.get(currency)) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use httpmock::MockServer; + use zksync_types::Address; + + use crate::{ + address_to_string, + coingecko_api::{CoinGeckoPriceAPIClient, COIN_GECKO_AUTH_HEADER}, + tests::tests::{ + add_mock, base_token_price_not_found_test, eth_price_not_found_test, happy_day_test, + no_base_token_price_404_test, no_eth_price_404_test, server_url, + }, + PriceAPIClient, + }; + + fn add_mock_by_id(server: &MockServer, id: String, price: f64, api_key: Option) { + let mut params = HashMap::new(); + params.insert("ids".to_string(), id.clone()); + params.insert("vs_currencies".to_string(), "usd".to_string()); + add_mock( + server, + httpmock::Method::GET, + "/api/v3/simple/price".to_string(), + params, + 200, + format!("{{\"{}\":{{\"usd\":{}}}}}", &id, price), + COIN_GECKO_AUTH_HEADER.to_string(), + api_key, + ); + } + + fn add_mock_by_address( + server: &MockServer, + // use string explicitly to verify that conversion of the address to string works as expected + address: String, + price: f64, + api_key: Option, + ) { + let mut params = HashMap::new(); + params.insert("contract_addresses".to_string(), address.clone()); + params.insert("vs_currencies".to_string(), "usd".to_string()); + add_mock( + server, + httpmock::Method::GET, + "/api/v3/simple/token_price/ethereum".to_string(), + params, + 200, + format!("{{\"{}\":{{\"usd\":{}}}}}", address, price), + COIN_GECKO_AUTH_HEADER.to_string(), + api_key, + ); + } + + fn happy_day_setup( + server: &MockServer, + api_key: Option, + address: Address, + base_token_price: f64, + eth_price: f64, + ) -> Box { + add_mock_by_address( + &server, + address_to_string(&address), + base_token_price, + api_key.clone(), + ); + add_mock_by_id(&server, "ethereum".to_string(), eth_price, api_key.clone()); + Box::new(CoinGeckoPriceAPIClient::new( + server_url(&server), + api_key.clone(), + reqwest::Client::new(), + )) + } + + #[tokio::test] + async fn test_happy_day_with_api_key() { + happy_day_test(Some("test".to_string()), happy_day_setup).await + } + + #[tokio::test] + async fn test_happy_day_with_no_api_key() { + happy_day_test(None, happy_day_setup).await + } + + #[tokio::test] + async fn test_no_eth_price_404() { + no_eth_price_404_test( + None, + |server: &MockServer, + api_key: Option, + address: Address, + _base_token_price: f64, + _eth_price: f64| + -> Box { + add_mock_by_address(&server, address_to_string(&address), 198.9, None); + Box::new(CoinGeckoPriceAPIClient::new( + server_url(&server), + api_key, + reqwest::Client::new(), + )) + }, + ) + .await; + } + + #[tokio::test] + async fn test_eth_price_not_found() { + eth_price_not_found_test( + None, + |server: &MockServer, + api_key: Option, + address: Address, + _base_token_price: f64, + _eth_price: f64| + -> Box { + add_mock_by_address(&server, address_to_string(&address), 198.9, None); + let mut params = HashMap::new(); + params.insert("ids".to_string(), "ethereum".to_string()); + params.insert("vs_currencies".to_string(), "usd".to_string()); + add_mock( + &server, + httpmock::Method::GET, + "/api/v3/simple/price".to_string(), + params, + 200, + "{}".to_string(), + COIN_GECKO_AUTH_HEADER.to_string(), + api_key.clone(), + ); + Box::new(CoinGeckoPriceAPIClient::new( + server_url(&server), + api_key, + reqwest::Client::new(), + )) + }, + ) + .await; + } + + #[tokio::test] + async fn test_no_base_token_price_404() { + no_base_token_price_404_test( + None, + |server: &MockServer, + api_key: Option, + _address: Address, + _base_token_price: f64, + _eth_price: f64| + -> Box { + add_mock_by_id(&server, "ethereum".to_string(), 29.5, None); + Box::new(CoinGeckoPriceAPIClient::new( + server_url(&server), + api_key, + reqwest::Client::new(), + )) + }, + ) + .await; + } + + #[tokio::test] + async fn test_base_token_price_not_found() { + base_token_price_not_found_test( + None, + |server: &MockServer, + api_key: Option, + address: Address, + _base_token_price: f64, + _eth_price: f64| + -> Box { + add_mock_by_id(&server, "ethereum".to_string(), 29.5, None); + let mut params = HashMap::new(); + params.insert( + "contract_addresses".to_string(), + address_to_string(&address), + ); + params.insert("vs_currencies".to_string(), "usd".to_string()); + add_mock( + &server, + httpmock::Method::GET, + "/api/v3/simple/token_price/ethereum".to_string(), + params, + 200, + "{}".to_string(), + COIN_GECKO_AUTH_HEADER.to_string(), + api_key.clone(), + ); + Box::new(CoinGeckoPriceAPIClient::new( + server_url(&server), + api_key, + reqwest::Client::new(), + )) + }, + ) + .await; + } +} diff --git a/core/lib/external_price_api/src/lib.rs b/core/lib/external_price_api/src/lib.rs new file mode 100644 index 000000000000..36a3c84f9ced --- /dev/null +++ b/core/lib/external_price_api/src/lib.rs @@ -0,0 +1,20 @@ +use std::fmt::Debug; + +use async_trait::async_trait; +use zksync_types::{base_token_price::BaseTokenAPIPrice, Address}; + +mod cmc_api; +mod coingecko_api; +mod strategies; +mod tests; + +/// Trait that defines the interface for a client connecting with an external API to get prices. +#[async_trait] +pub trait PriceAPIClient: Sync + Send + Debug { + /// Returns the price for the input token address in $USD. + async fn fetch_price(&mut self, token_address: Address) -> anyhow::Result; +} + +fn address_to_string(address: &Address) -> String { + format!("{:#x}", address) +} diff --git a/core/lib/external_price_api/src/strategies.rs b/core/lib/external_price_api/src/strategies.rs new file mode 100644 index 000000000000..bfeb6b6596aa --- /dev/null +++ b/core/lib/external_price_api/src/strategies.rs @@ -0,0 +1,257 @@ +use std::{ + ops::{Add, Div}, + sync::Arc, +}; + +use async_trait::async_trait; +use bigdecimal::{BigDecimal, Zero}; +use chrono::Utc; +use tokio::sync::Mutex; +use zksync_types::{base_token_price::BaseTokenAPIPrice, Address}; + +use crate::PriceAPIClient; + +#[derive(Debug)] +struct AverageStrategy { + pricing_apis: Vec>>, +} + +impl AverageStrategy { + fn new(pricing_apis: Vec>>) -> Self { + return Self { pricing_apis }; + } +} + +#[async_trait] +impl PriceAPIClient for AverageStrategy { + async fn fetch_price(&mut self, token_address: Address) -> anyhow::Result { + let mut success_cnt = 0; + let mut aggregate_base_token_price = BigDecimal::zero(); + let mut aggregate_eth_price = BigDecimal::zero(); + for p in &mut self.pricing_apis { + match p.lock().await.fetch_price(token_address).await { + Ok(x) => { + aggregate_base_token_price = aggregate_base_token_price.add(x.base_token_price); + aggregate_eth_price = aggregate_eth_price.add(x.eth_price); + success_cnt += 1; + } + Err(e) => { + tracing::info!("Error fetching token price: {}", e) + } + } + } + + if success_cnt == 0 { + return Err(anyhow::anyhow!("No successful price fetches")); + } + + Ok(BaseTokenAPIPrice { + base_token_price: aggregate_base_token_price.div(success_cnt), + eth_price: aggregate_eth_price.div(success_cnt), + ratio_timestamp: Utc::now(), + }) + } +} + +#[derive(Debug)] +struct FailOverStrategy { + pricing_apis: Vec>>, +} + +impl FailOverStrategy { + fn new(pricing_apis: Vec>>) -> Self { + return Self { pricing_apis }; + } +} + +#[async_trait] +impl PriceAPIClient for FailOverStrategy { + async fn fetch_price(&mut self, token_address: Address) -> anyhow::Result { + for p in &mut self.pricing_apis { + match p.lock().await.fetch_price(token_address).await { + Ok(x) => return Ok(x), + Err(e) => { + tracing::info!("Error fetching token price: {}", e) + } + } + } + Err(anyhow::anyhow!("No successful price fetches")) + } +} + +#[cfg(test)] +mod tests { + use std::{str::FromStr, sync::Arc}; + + use async_trait::async_trait; + use bigdecimal::BigDecimal; + use chrono::{DateTime, Utc}; + use tokio::sync::Mutex; + use zksync_types::{base_token_price::BaseTokenAPIPrice, Address}; + + use crate::{ + strategies::{AverageStrategy, FailOverStrategy}, + PriceAPIClient, + }; + + #[tokio::test] + async fn test_avg_strategy_happy_day() { + let api_1 = MockPricingApi::new("1.5".to_string(), "2.5".to_string(), Utc::now()); + let api_2 = MockPricingApi::new("2.5".to_string(), "3.5".to_string(), Utc::now()); + let address = Address::random(); + + let mut avg_strategy = AverageStrategy::new(vec![api_1.clone(), api_2.clone()]); + let result_1 = avg_strategy.fetch_price(address).await.unwrap(); + assert_eq!(result_1.eth_price, BigDecimal::from_str("2").unwrap()); + assert_eq!( + result_1.base_token_price, + BigDecimal::from_str("3").unwrap() + ); + + assert_eq!(api_1.lock().await.hit_count, 1); + assert_eq!(api_2.lock().await.hit_count, 1); + } + + #[tokio::test] + async fn test_avg_strategy_should_skip_failed_apis() { + let api_1 = MockPricingApi::new("1.5".to_string(), "2.5".to_string(), Utc::now()); + let api_2 = MockPricingApi::new("2.5".to_string(), "3.5".to_string(), Utc::now()); + let api_3 = Arc::new(Mutex::new(AlwaysErrorPricingApi { hit_count: 0 })); + let address = Address::random(); + + let mut avg_strategy = + AverageStrategy::new(vec![api_1.clone(), api_2.clone(), api_3.clone()]); + let result = avg_strategy.fetch_price(address).await.unwrap(); + assert_eq!(result.eth_price, BigDecimal::from_str("2").unwrap()); + assert_eq!(result.base_token_price, BigDecimal::from_str("3").unwrap()); + + assert_eq!(api_1.lock().await.hit_count, 1); + assert_eq!(api_2.lock().await.hit_count, 1); + assert_eq!(api_2.lock().await.hit_count, 1); + } + + #[tokio::test] + async fn test_avg_strategy_if_all_apis_failed_should_fail_too() { + let api_1 = Arc::new(Mutex::new(AlwaysErrorPricingApi { hit_count: 0 })); + let api_2 = Arc::new(Mutex::new(AlwaysErrorPricingApi { hit_count: 0 })); + let address = Address::random(); + + let mut avg_strategy = AverageStrategy::new(vec![api_1.clone(), api_2.clone()]); + let result = avg_strategy.fetch_price(address).await; + assert_eq!( + "No successful price fetches", + result.err().unwrap().to_string() + ); + + assert_eq!(api_1.lock().await.hit_count, 1); + assert_eq!(api_2.lock().await.hit_count, 1); + } + + #[tokio::test] + async fn test_fail_over_strategy_happy_day() { + let api_1 = MockPricingApi::new("1.5".to_string(), "2.5".to_string(), Utc::now()); + let api_2 = MockPricingApi::new("2.5".to_string(), "3.5".to_string(), Utc::now()); + let address = Address::random(); + + let mut fo_strategy = FailOverStrategy::new(vec![api_1.clone(), api_2.clone()]); + let result_1 = fo_strategy.fetch_price(address).await.unwrap(); + assert_eq!(result_1.eth_price, BigDecimal::from_str("1.5").unwrap()); + assert_eq!( + result_1.base_token_price, + BigDecimal::from_str("2.5").unwrap() + ); + + assert_eq!(api_1.lock().await.hit_count, 1); + // second API should have never been hit + assert_eq!(api_2.lock().await.hit_count, 0); + } + + #[tokio::test] + async fn test_fail_over_strategy_should_fail_over() { + let api_1 = Arc::new(Mutex::new(AlwaysErrorPricingApi { hit_count: 0 })); + let api_2 = MockPricingApi::new("2.5".to_string(), "3.5".to_string(), Utc::now()); + let api_3 = MockPricingApi::new("3.5".to_string(), "4.5".to_string(), Utc::now()); + let address = Address::random(); + + let mut fo_strategy = + FailOverStrategy::new(vec![api_1.clone(), api_2.clone(), api_3.clone()]); + let result_1 = fo_strategy.fetch_price(address).await.unwrap(); + assert_eq!(result_1.eth_price, BigDecimal::from_str("2.5").unwrap()); + assert_eq!( + result_1.base_token_price, + BigDecimal::from_str("3.5").unwrap() + ); + + assert_eq!(api_1.lock().await.hit_count, 1); + assert_eq!(api_2.lock().await.hit_count, 1); + // third API should have never been hit + assert_eq!(api_3.lock().await.hit_count, 0); + } + + #[tokio::test] + async fn test_fail_over_strategy_fail_if_all_fail() { + let api_1 = Arc::new(Mutex::new(AlwaysErrorPricingApi { hit_count: 0 })); + let api_2 = Arc::new(Mutex::new(AlwaysErrorPricingApi { hit_count: 0 })); + let address = Address::random(); + + let mut fo_strategy = FailOverStrategy::new(vec![api_1.clone(), api_2.clone()]); + let result = fo_strategy.fetch_price(address).await; + assert_eq!( + "No successful price fetches", + result.err().unwrap().to_string() + ); + + assert_eq!(api_1.lock().await.hit_count, 1); + assert_eq!(api_2.lock().await.hit_count, 1); + } + + #[derive(Debug)] + struct MockPricingApi { + response: BaseTokenAPIPrice, + hit_count: i32, + } + + impl MockPricingApi { + fn new( + eth_price: String, + base_token_price: String, + ratio_timestamp: DateTime, + ) -> Arc> { + Arc::new(Mutex::new(MockPricingApi { + response: BaseTokenAPIPrice { + eth_price: BigDecimal::from_str(eth_price.as_str()).unwrap(), + base_token_price: BigDecimal::from_str(base_token_price.as_str()).unwrap(), + ratio_timestamp, + }, + hit_count: 0, + })) + } + } + + #[async_trait] + impl PriceAPIClient for MockPricingApi { + async fn fetch_price( + &mut self, + _token_address: Address, + ) -> anyhow::Result { + self.hit_count += 1; + Ok(self.response.clone()) + } + } + + #[derive(Debug)] + struct AlwaysErrorPricingApi { + hit_count: i32, + } + + #[async_trait] + impl PriceAPIClient for AlwaysErrorPricingApi { + async fn fetch_price( + &mut self, + _token_address: Address, + ) -> anyhow::Result { + self.hit_count += 1; + Err(anyhow::anyhow!("test")) + } + } +} diff --git a/core/lib/external_price_api/src/tests.rs b/core/lib/external_price_api/src/tests.rs new file mode 100644 index 000000000000..130e298bddd1 --- /dev/null +++ b/core/lib/external_price_api/src/tests.rs @@ -0,0 +1,134 @@ +#[cfg(test)] +pub(crate) mod tests { + use std::{collections::HashMap, str::FromStr}; + + use bigdecimal::BigDecimal; + use chrono::Utc; + use httpmock::{Mock, MockServer}; + use url::Url; + use zksync_types::{base_token_price::BaseTokenAPIPrice, Address}; + + use crate::PriceAPIClient; + + const TIME_TOLERANCE_MS: i64 = 100; + + pub(crate) type SetupFn = fn( + server: &MockServer, + api_key: Option, + address: Address, + base_token_price: f64, + eth_price: f64, + ) -> Box; + + pub(crate) fn server_url(server: &MockServer) -> Url { + Url::from_str(server.url("").as_str()).unwrap() + } + + pub(crate) fn add_mock( + server: &MockServer, + method: httpmock::Method, + path: String, + query_params: HashMap, + response_status: u16, + response_body: String, + auth_header: String, + api_key: Option, + ) -> Mock { + server.mock(|mut when, then| { + when = when.method(method).path(path); + if let Some(x) = api_key { + when = when.header(auth_header, x); + } + for (k, v) in &query_params { + when = when.query_param(k, v); + } + then.status(response_status).body(response_body); + }) + } + + pub(crate) async fn happy_day_test(api_key: Option, setup: SetupFn) { + let server = MockServer::start(); + let address_str = "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984"; + let address = Address::from_str(address_str).unwrap(); + let base_token_price = 198.9; + let eth_price = 3000.0; + + let mut client = setup(&server, api_key, address, base_token_price, eth_price); + let api_price = client.fetch_price(address).await.unwrap(); + + assert_eq!( + BaseTokenAPIPrice { + base_token_price: BigDecimal::from_str(&base_token_price.to_string()).unwrap(), + eth_price: BigDecimal::from_str(ð_price.to_string()).unwrap(), + ratio_timestamp: api_price.ratio_timestamp, + }, + api_price + ); + assert!((Utc::now() - api_price.ratio_timestamp).num_milliseconds() <= TIME_TOLERANCE_MS); + } + + pub(crate) async fn no_eth_price_404_test(api_key: Option, setup: SetupFn) { + let server = MockServer::start(); + let address_str = "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984"; + let address = Address::from_str(address_str).unwrap(); + let mut client = setup(&server, api_key, address, 1.0, 1.0); + let api_price = client.fetch_price(address).await; + + assert!(api_price.is_err()); + assert!(api_price + .err() + .unwrap() + .to_string() + .starts_with("Http error while fetching token price. Status: 404 Not Found")) + } + + pub(crate) async fn eth_price_not_found_test(api_key: Option, setup: SetupFn) { + let server = MockServer::start(); + let address_str = "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984"; + let address = Address::from_str("0x1f9840a85d5af5bf1d1762f925bdaddc4201f984").unwrap(); + + let mut client = setup(&server, api_key, address, 1.0, 1.0); + let api_price = client + .fetch_price(Address::from_str(address_str).unwrap()) + .await; + + assert!(api_price.is_err()); + assert!(api_price + .err() + .unwrap() + .to_string() + .starts_with("Price not found for token")) + } + + pub(crate) async fn no_base_token_price_404_test(api_key: Option, setup: SetupFn) { + let server = MockServer::start(); + let address_str = "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984"; + let address = Address::from_str(address_str).unwrap(); + + let mut client = setup(&server, api_key, address, 1.0, 1.0); + let api_price = client.fetch_price(address).await; + + assert!(api_price.is_err()); + assert!(api_price + .err() + .unwrap() + .to_string() + .starts_with("Http error while fetching token price. Status: 404 Not Found")) + } + + pub(crate) async fn base_token_price_not_found_test(api_key: Option, setup: SetupFn) { + let server = MockServer::start(); + let address_str = "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984"; + let address = Address::from_str(address_str).unwrap(); + + let mut client = setup(&server, api_key, address, 1.0, 1.0); + let api_price = client.fetch_price(address).await; + + assert!(api_price.is_err()); + assert!(api_price + .err() + .unwrap() + .to_string() + .starts_with("Price not found for token")) + } +} diff --git a/core/lib/protobuf_config/src/base_token_adjuster.rs b/core/lib/protobuf_config/src/base_token_adjuster.rs new file mode 100644 index 000000000000..c3cd81089498 --- /dev/null +++ b/core/lib/protobuf_config/src/base_token_adjuster.rs @@ -0,0 +1,22 @@ +use zksync_config::configs::{self}; +use zksync_protobuf::ProtoRepr; + +use crate::proto::base_token_adjuster as proto; + +impl ProtoRepr for proto::BaseTokenAdjuster { + type Type = configs::base_token_adjuster::BaseTokenAdjusterConfig; + + fn read(&self) -> anyhow::Result { + Ok(configs::base_token_adjuster::BaseTokenAdjusterConfig { + price_polling_interval_ms: self.price_polling_interval_ms, + base_token: self.base_token.clone(), + }) + } + + fn build(this: &Self::Type) -> Self { + Self { + price_polling_interval_ms: this.price_polling_interval_ms, + base_token: this.base_token.clone(), + } + } +} diff --git a/core/lib/protobuf_config/src/general.rs b/core/lib/protobuf_config/src/general.rs index 834977759ae2..7f6a0d3a4b21 100644 --- a/core/lib/protobuf_config/src/general.rs +++ b/core/lib/protobuf_config/src/general.rs @@ -41,6 +41,8 @@ impl ProtoRepr for proto::GeneralConfig { .context("protective_reads_writer")?, core_object_store: read_optional_repr(&self.core_object_store) .context("core_object_store")?, + base_token_adjuster: read_optional_repr(&self.base_token_adjuster) + .context("base_token_adjuster")?, }) } @@ -77,6 +79,7 @@ impl ProtoRepr for proto::GeneralConfig { .as_ref() .map(ProtoRepr::build), core_object_store: this.core_object_store.as_ref().map(ProtoRepr::build), + base_token_adjuster: this.base_token_adjuster.as_ref().map(ProtoRepr::build), } } } diff --git a/core/lib/protobuf_config/src/lib.rs b/core/lib/protobuf_config/src/lib.rs index 2fd9bbd9e059..023f1e343350 100644 --- a/core/lib/protobuf_config/src/lib.rs +++ b/core/lib/protobuf_config/src/lib.rs @@ -5,6 +5,7 @@ //! * protobuf json format mod api; +mod base_token_adjuster; mod chain; mod circuit_breaker; mod consensus; diff --git a/core/lib/protobuf_config/src/proto/config/base_token_adjuster.proto b/core/lib/protobuf_config/src/proto/config/base_token_adjuster.proto new file mode 100644 index 000000000000..f3f75c4b7b26 --- /dev/null +++ b/core/lib/protobuf_config/src/proto/config/base_token_adjuster.proto @@ -0,0 +1,8 @@ +syntax = "proto3"; + +package zksync.config.base_token_adjuster; + +message BaseTokenAdjuster { + optional uint64 price_polling_interval_ms = 1; + optional string base_token = 2; +} diff --git a/core/lib/protobuf_config/src/proto/config/general.proto b/core/lib/protobuf_config/src/proto/config/general.proto index fdfe257aecf1..8290463cf583 100644 --- a/core/lib/protobuf_config/src/proto/config/general.proto +++ b/core/lib/protobuf_config/src/proto/config/general.proto @@ -15,6 +15,7 @@ import "zksync/config/snapshots_creator.proto"; import "zksync/config/utils.proto"; import "zksync/config/vm_runner.proto"; import "zksync/config/object_store.proto"; +import "zksync/config/base_token_adjuster.proto"; message GeneralConfig { optional config.database.Postgres postgres = 1; @@ -39,4 +40,5 @@ message GeneralConfig { optional config.observability.Observability observability = 32; optional config.vm_runner.ProtectiveReadsWriter protective_reads_writer = 33; optional config.object_store.ObjectStore core_object_store = 34; + optional config.base_token_adjuster.BaseTokenAdjuster base_token_adjuster = 35; } diff --git a/core/lib/types/Cargo.toml b/core/lib/types/Cargo.toml index a562cccacbc1..10e59639629c 100644 --- a/core/lib/types/Cargo.toml +++ b/core/lib/types/Cargo.toml @@ -27,6 +27,7 @@ once_cell.workspace = true rlp.workspace = true serde.workspace = true serde_json.workspace = true +bigdecimal.workspace = true strum = { workspace = true, features = ["derive"] } thiserror.workspace = true num_enum.workspace = true diff --git a/core/lib/types/src/base_token_price.rs b/core/lib/types/src/base_token_price.rs new file mode 100644 index 000000000000..fc6249d4fff9 --- /dev/null +++ b/core/lib/types/src/base_token_price.rs @@ -0,0 +1,21 @@ +use bigdecimal::BigDecimal; +use chrono::{DateTime, Utc}; + +/// Represents the base token price at a given point in time. +#[derive(Debug, Clone, Default)] +pub struct BaseTokenPrice { + pub id: i64, + pub ratio_timestamp: DateTime, + pub base_token_price: BigDecimal, + pub eth_price: BigDecimal, + pub used_in_l1: bool, +} + +/// Struct to represent API response containing denominator, numerator and optional timestamp. +#[derive(Debug, Default, Eq, PartialEq, Clone)] +pub struct BaseTokenAPIPrice { + pub base_token_price: BigDecimal, + pub eth_price: BigDecimal, + // Either the timestamp of the quote or the timestamp of the request. + pub ratio_timestamp: DateTime, +} diff --git a/core/lib/types/src/lib.rs b/core/lib/types/src/lib.rs index 3c3a96c297d7..e54469bf0c49 100644 --- a/core/lib/types/src/lib.rs +++ b/core/lib/types/src/lib.rs @@ -57,6 +57,7 @@ pub mod vm_trace; pub mod zk_evm_types; pub mod api; +pub mod base_token_price; pub mod eth_sender; pub mod helpers; pub mod proto; diff --git a/core/lib/zksync_core_leftovers/src/lib.rs b/core/lib/zksync_core_leftovers/src/lib.rs index 8e85bad9cc33..b086a45a9e1f 100644 --- a/core/lib/zksync_core_leftovers/src/lib.rs +++ b/core/lib/zksync_core_leftovers/src/lib.rs @@ -88,6 +88,8 @@ pub enum Component { CommitmentGenerator, /// VM runner-based component that saves protective reads to Postgres. VmRunnerProtectiveReads, + /// A component to handle anything related to a chain's custom base token. + BaseTokenAdjuster, } #[derive(Debug)] @@ -127,6 +129,7 @@ impl FromStr for Components { "vm_runner_protective_reads" => { Ok(Components(vec![Component::VmRunnerProtectiveReads])) } + "base_token_adjuster" => Ok(Components(vec![Component::BaseTokenAdjuster])), other => Err(format!("{} is not a valid component name", other)), } } diff --git a/core/lib/zksync_core_leftovers/src/temp_config_store/mod.rs b/core/lib/zksync_core_leftovers/src/temp_config_store/mod.rs index cb3e0d08794d..3f851cb87ec6 100644 --- a/core/lib/zksync_core_leftovers/src/temp_config_store/mod.rs +++ b/core/lib/zksync_core_leftovers/src/temp_config_store/mod.rs @@ -12,8 +12,8 @@ use zksync_config::{ FriWitnessGeneratorConfig, FriWitnessVectorGeneratorConfig, GeneralConfig, ObservabilityConfig, PrometheusConfig, ProofDataHandlerConfig, ProtectiveReadsWriterConfig, }, - ApiConfig, ContractVerifierConfig, DBConfig, EthConfig, EthWatchConfig, GasAdjusterConfig, - ObjectStoreConfig, PostgresConfig, SnapshotsCreatorConfig, + ApiConfig, BaseTokenAdjusterConfig, ContractVerifierConfig, DBConfig, EthConfig, + EthWatchConfig, GasAdjusterConfig, ObjectStoreConfig, PostgresConfig, SnapshotsCreatorConfig, }; use zksync_protobuf::repr::ProtoRepr; @@ -56,6 +56,7 @@ pub struct TempConfigStore { pub snapshot_creator: Option, pub protective_reads_writer_config: Option, pub core_object_store: Option, + pub base_token_adjuster_config: Option, } impl TempConfigStore { @@ -83,6 +84,7 @@ impl TempConfigStore { observability: self.observability.clone(), protective_reads_writer_config: self.protective_reads_writer_config.clone(), core_object_store: self.core_object_store.clone(), + base_token_adjuster: self.base_token_adjuster_config.clone(), } } diff --git a/core/node/api_server/src/web3/backend_jsonrpsee/namespaces/zks.rs b/core/node/api_server/src/web3/backend_jsonrpsee/namespaces/zks.rs index 45cb312dde6e..46dae003be70 100644 --- a/core/node/api_server/src/web3/backend_jsonrpsee/namespaces/zks.rs +++ b/core/node/api_server/src/web3/backend_jsonrpsee/namespaces/zks.rs @@ -151,7 +151,9 @@ impl ZksNamespaceServer for ZksNamespace { } async fn get_fee_params(&self) -> RpcResult { - Ok(self.get_fee_params_impl()) + self.get_fee_params_impl() + .await + .map_err(|err| self.current_method().map_err(err)) } async fn get_batch_fee_input(&self) -> RpcResult { diff --git a/core/node/api_server/src/web3/namespaces/zks.rs b/core/node/api_server/src/web3/namespaces/zks.rs index 6b872bcf637e..5719055d2bea 100644 --- a/core/node/api_server/src/web3/namespaces/zks.rs +++ b/core/node/api_server/src/web3/namespaces/zks.rs @@ -450,12 +450,14 @@ impl ZksNamespace { } #[tracing::instrument(skip(self))] - pub fn get_fee_params_impl(&self) -> FeeParams { - self.state + pub async fn get_fee_params_impl(&self) -> Result { + Ok(self + .state .tx_sender .0 .batch_fee_input_provider .get_fee_model_params() + .await?) } pub async fn get_protocol_version_impl( diff --git a/core/node/base_token_adjuster/Cargo.toml b/core/node/base_token_adjuster/Cargo.toml new file mode 100644 index 000000000000..90616b7313a7 --- /dev/null +++ b/core/node/base_token_adjuster/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "zksync_base_token_adjuster" +version.workspace = true +edition.workspace = true +authors.workspace = true +homepage.workspace = true +repository.workspace = true +license.workspace = true +keywords.workspace = true +categories.workspace = true + + +[dependencies] +zksync_dal.workspace = true +zksync_config.workspace = true +zksync_types.workspace = true +bigdecimal.workspace = true + +tokio = { workspace = true, features = ["time"] } +anyhow.workspace = true +tracing.workspace = true +chrono.workspace = true +rand.workspace = true +async-trait.workspace = true diff --git a/core/node/base_token_adjuster/README.md b/core/node/base_token_adjuster/README.md new file mode 100644 index 000000000000..3444f06906c5 --- /dev/null +++ b/core/node/base_token_adjuster/README.md @@ -0,0 +1,13 @@ +# Base Token Adjuster + +This crate contains all the logic to handle ZK Chain with custom base tokens. It is used by other node layers to adjust +the fees to be denominated in the chain's base token. + +## Overview + +The Base Token Adjuster: + +- Connect with external APIs to get the current price of the base token. +- Persist the price of the base token in the database. +- Upon request, adjust the fees to be denominated in the base token. +- Upon certain configured threshold, update the L1 ETH<->BaseToken conversion ratio. diff --git a/core/node/base_token_adjuster/src/base_token_adjuster.rs b/core/node/base_token_adjuster/src/base_token_adjuster.rs new file mode 100644 index 000000000000..4ecfcaff0b1a --- /dev/null +++ b/core/node/base_token_adjuster/src/base_token_adjuster.rs @@ -0,0 +1,439 @@ +use std::{ + fmt::Debug, + ops::Div, + time::{Duration, Instant}, +}; + +use anyhow::Context as _; +use async_trait::async_trait; +use bigdecimal::{BigDecimal, ToPrimitive}; +use chrono::Utc; +use rand::Rng; +use tokio::{sync::watch, time::sleep}; +use zksync_config::configs::base_token_adjuster::BaseTokenAdjusterConfig; +use zksync_dal::{ConnectionPool, Core, CoreDal}; +use zksync_types::{ + base_token_price::BaseTokenAPIPrice, + fee_model::{FeeModelConfigV2, FeeParams, FeeParamsV2}, +}; + +#[async_trait] +pub trait BaseTokenAdjuster: Debug + Send + Sync { + /// Returns the last ratio cached by the adjuster and ensure it's still usable. + async fn maybe_convert_to_base_token(&self, params: FeeParams) -> anyhow::Result; + + /// Return configured symbol of the base token. + fn get_base_token(&self) -> &str; +} + +#[derive(Debug, Clone)] +/// BaseTokenAdjuster implementation for the main node (not the External Node). TODO (PE-137): impl APIBaseTokenAdjuster +pub struct MainNodeBaseTokenAdjuster { + pool: ConnectionPool, + config: BaseTokenAdjusterConfig, +} + +impl MainNodeBaseTokenAdjuster { + pub fn new(pool: ConnectionPool, config: BaseTokenAdjusterConfig) -> Self { + Self { pool, config } + } + + /// Main loop for the base token adjuster. + /// Orchestrates fetching new ratio, persisting it, and updating L1. + pub async fn run(&mut self, stop_receiver: watch::Receiver) -> anyhow::Result<()> { + let pool = self.pool.clone(); + loop { + if *stop_receiver.borrow() { + tracing::info!("Stop signal received, base_token_adjuster is shutting down"); + break; + } + + let start_time = Instant::now(); + + match self.fetch_new_ratio().await { + Ok(new_ratio) => match self.persist_ratio(&new_ratio, &pool).await { + Ok(id) => { + if let Err(err) = self.maybe_update_l1(&new_ratio, id).await { + tracing::error!("Error updating L1 ratio: {:?}", err); + } + } + Err(err) => tracing::error!("Error persisting ratio: {:?}", err), + }, + Err(err) => tracing::error!("Error fetching new ratio: {:?}", err), + } + + self.sleep_until_next_fetch(start_time).await; + } + Ok(()) + } + + // TODO (PE-135): Use real API client to fetch new ratio through self.PriceAPIClient & mock for tests. + // For now, these hard coded values are also hard coded in the integration tests. + async fn fetch_new_ratio(&self) -> anyhow::Result { + let new_base_token_price = BigDecimal::from(1); + let new_eth_price = BigDecimal::from(100000); + let ratio_timestamp = Utc::now(); + + Ok(BaseTokenAPIPrice { + base_token_price: new_base_token_price, + eth_price: new_eth_price, + ratio_timestamp, + }) + } + + async fn persist_ratio( + &self, + api_price: &BaseTokenAPIPrice, + pool: &ConnectionPool, + ) -> anyhow::Result { + let mut conn = pool + .connection_tagged("base_token_adjuster") + .await + .context("Failed to obtain connection to the database")?; + + let id = conn + .base_token_dal() + .insert_token_price( + &api_price.base_token_price, + &api_price.eth_price, + &api_price.ratio_timestamp.naive_utc(), + ) + .await?; + drop(conn); + + Ok(id) + } + + // TODO (PE-128): Complete L1 update flow. + async fn maybe_update_l1( + &self, + _new_ratio: &BaseTokenAPIPrice, + _id: usize, + ) -> anyhow::Result<()> { + Ok(()) + } + + async fn retry_get_latest_price(&self) -> anyhow::Result { + let mut retry_delay = 1; // seconds + let max_retries = 5; + let mut attempts = 1; + + loop { + let mut conn = self + .pool + .connection_tagged("base_token_adjuster") + .await + .expect("Failed to obtain connection to the database"); + + let result = conn.base_token_dal().get_latest_price().await; + + drop(conn); + + if let Ok(last_storage_price) = result { + return Ok(last_storage_price + .base_token_price + .div(&last_storage_price.eth_price)); + } else { + if attempts >= max_retries { + break; + } + let sleep_duration = Duration::from_secs(retry_delay) + .mul_f32(rand::thread_rng().gen_range(0.8..1.2)); + tracing::warn!( + "Attempt {}/{} failed to get latest base token price, retrying in {} seconds...", + attempts, max_retries, sleep_duration.as_secs() + ); + sleep(sleep_duration).await; + retry_delay *= 2; + attempts += 1; + } + } + anyhow::bail!( + "Failed to get latest base token price after {} attempts", + max_retries + ); + } + + // Sleep for the remaining duration of the polling period + async fn sleep_until_next_fetch(&self, start_time: Instant) { + let elapsed_time = start_time.elapsed(); + let sleep_duration = if elapsed_time >= self.config.price_polling_interval() { + Duration::from_secs(0) + } else { + self.config.price_polling_interval() - elapsed_time + }; + + tokio::time::sleep(sleep_duration).await; + } +} + +#[async_trait] +impl BaseTokenAdjuster for MainNodeBaseTokenAdjuster { + // TODO (PE-129): Implement latest ratio usability logic. + async fn maybe_convert_to_base_token(&self, params: FeeParams) -> anyhow::Result { + let base_token = self.get_base_token(); + + if base_token == "ETH" { + return Ok(params); + } + + // Retries are necessary for the initial setup, where prices may not yet be persisted. + let latest_ratio = self.retry_get_latest_price().await?; + + if let FeeParams::V2(params_v2) = params { + Ok(FeeParams::V2(convert_to_base_token( + params_v2, + latest_ratio, + ))) + } else { + panic!("Custom base token is not supported for V1 fee model") + } + } + + /// Return configured symbol of the base token. If not configured, return "ETH". + fn get_base_token(&self) -> &str { + match &self.config.base_token { + Some(base_token) => base_token.as_str(), + None => "ETH", + } + } +} + +/// Converts the fee parameters to the base token using the latest ratio fetched from the DB. +fn convert_to_base_token(params: FeeParamsV2, base_token_to_eth: BigDecimal) -> FeeParamsV2 { + let FeeParamsV2 { + config, + l1_gas_price, + l1_pubdata_price, + } = params; + + let convert_price = |price_in_wei: u64| -> u64 { + let converted_price_bd = BigDecimal::from(price_in_wei) * base_token_to_eth.clone(); + match converted_price_bd.to_u64() { + Some(converted_price) => converted_price, + None => { + if converted_price_bd > BigDecimal::from(u64::MAX) { + tracing::warn!( + "Conversion to base token price failed: converted price is too large: {}", + converted_price_bd + ); + } else { + tracing::error!( + "Conversion to base token price failed: converted price is not a valid u64: {}", + converted_price_bd + ); + } + u64::MAX + } + } + }; + + let l1_gas_price_converted = convert_price(l1_gas_price); + let l1_pubdata_price_converted = convert_price(l1_pubdata_price); + let minimal_l2_gas_price_converted = convert_price(config.minimal_l2_gas_price); + + FeeParamsV2 { + config: FeeModelConfigV2 { + minimal_l2_gas_price: minimal_l2_gas_price_converted, + ..config + }, + l1_gas_price: l1_gas_price_converted, + l1_pubdata_price: l1_pubdata_price_converted, + } +} + +#[derive(Debug)] +#[allow(dead_code)] +pub struct MockBaseTokenAdjuster { + last_ratio: BigDecimal, + base_token: String, +} + +impl MockBaseTokenAdjuster { + pub fn new(last_ratio: BigDecimal, base_token: String) -> Self { + Self { + last_ratio, + base_token, + } + } +} + +impl Default for MockBaseTokenAdjuster { + fn default() -> Self { + Self { + last_ratio: BigDecimal::from(1), + base_token: "ETH".to_string(), + } + } +} + +#[async_trait] +impl BaseTokenAdjuster for MockBaseTokenAdjuster { + async fn maybe_convert_to_base_token(&self, params: FeeParams) -> anyhow::Result { + // LOG THE PARAMS + tracing::info!("Params: {:?}", params); + match self.get_base_token() { + "ETH" => Ok(params), + _ => { + if let FeeParams::V2(params_v2) = params { + Ok(FeeParams::V2(convert_to_base_token( + params_v2, + self.last_ratio.clone(), + ))) + } else { + panic!("Custom base token is not supported for V1 fee model") + } + } + } + } + + fn get_base_token(&self) -> &str { + &self.base_token + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use bigdecimal::BigDecimal; + use zksync_types::fee_model::{FeeModelConfigV2, FeeParamsV2}; + + use super::*; + + #[test] + fn test_convert_to_base_token() { + let base_fee_model_config = FeeModelConfigV2 { + minimal_l2_gas_price: 0, + // All the below are unaffected by this flow. + compute_overhead_part: 1.0, + pubdata_overhead_part: 1.0, + batch_overhead_l1_gas: 1, + max_gas_per_batch: 1, + max_pubdata_per_batch: 1, + }; + + struct TestCase { + name: &'static str, + base_token_to_eth: BigDecimal, + input_minimal_l2_gas_price: u64, + input_l1_gas_price: u64, + input_l1_pubdata_price: u64, + expected_minimal_l2_gas_price: u64, + expected_l1_gas_price: u64, + expected_l1_pubdata_price: u64, + } + + let test_cases = vec![ + TestCase { + name: "1 ETH = 2 BaseToken", + base_token_to_eth: BigDecimal::from(2), + input_minimal_l2_gas_price: 1000, + input_l1_gas_price: 2000, + input_l1_pubdata_price: 3000, + expected_minimal_l2_gas_price: 2000, + expected_l1_gas_price: 4000, + expected_l1_pubdata_price: 6000, + }, + TestCase { + name: "1 ETH = 0.5 BaseToken", + base_token_to_eth: BigDecimal::from_str("0.5").unwrap(), + input_minimal_l2_gas_price: 1000, + input_l1_gas_price: 2000, + input_l1_pubdata_price: 3000, + expected_minimal_l2_gas_price: 500, + expected_l1_gas_price: 1000, + expected_l1_pubdata_price: 1500, + }, + TestCase { + name: "1 ETH = 1 BaseToken", + base_token_to_eth: BigDecimal::from(1), + input_minimal_l2_gas_price: 1000, + input_l1_gas_price: 2000, + input_l1_pubdata_price: 3000, + expected_minimal_l2_gas_price: 1000, + expected_l1_gas_price: 2000, + expected_l1_pubdata_price: 3000, + }, + TestCase { + name: "Small conversion - 1 ETH - 1_000_000 BaseToken", + base_token_to_eth: BigDecimal::from_str("0.000001").unwrap(), + input_minimal_l2_gas_price: 1_000_000, + input_l1_gas_price: 2_000_000, + input_l1_pubdata_price: 3_000_000, + expected_minimal_l2_gas_price: 1, + expected_l1_gas_price: 2, + expected_l1_pubdata_price: 3, + }, + TestCase { + name: "Large conversion - 1 ETH = 0.000001 BaseToken", + base_token_to_eth: BigDecimal::from_str("1000000").unwrap(), + input_minimal_l2_gas_price: 1, + input_l1_gas_price: 2, + input_l1_pubdata_price: 3, + expected_minimal_l2_gas_price: 1_000_000, + expected_l1_gas_price: 2_000_000, + expected_l1_pubdata_price: 3_000_000, + }, + TestCase { + name: "Fractional conversion ratio", + base_token_to_eth: BigDecimal::from_str("1.123456789").unwrap(), + input_minimal_l2_gas_price: 1000, + input_l1_gas_price: 2000, + input_l1_pubdata_price: 3000, + expected_minimal_l2_gas_price: 1123, + expected_l1_gas_price: 2246, + expected_l1_pubdata_price: 3370, + }, + TestCase { + name: "Zero conversion ratio", + base_token_to_eth: BigDecimal::from(0), + input_minimal_l2_gas_price: 1000, + input_l1_gas_price: 2000, + input_l1_pubdata_price: 3000, + expected_minimal_l2_gas_price: 0, + expected_l1_gas_price: 0, + expected_l1_pubdata_price: 0, + }, + TestCase { + name: "Conversion ratio too large so clamp down to u64::MAX", + base_token_to_eth: BigDecimal::from(u64::MAX), + input_minimal_l2_gas_price: 2, + input_l1_gas_price: 2, + input_l1_pubdata_price: 2, + expected_minimal_l2_gas_price: u64::MAX, + expected_l1_gas_price: u64::MAX, + expected_l1_pubdata_price: u64::MAX, + }, + ]; + + for case in test_cases { + let input_params = FeeParamsV2 { + config: FeeModelConfigV2 { + minimal_l2_gas_price: case.input_minimal_l2_gas_price, + ..base_fee_model_config + }, + l1_gas_price: case.input_l1_gas_price, + l1_pubdata_price: case.input_l1_pubdata_price, + }; + + let result = convert_to_base_token(input_params, case.base_token_to_eth.clone()); + + assert_eq!( + result.config.minimal_l2_gas_price, case.expected_minimal_l2_gas_price, + "Test case '{}' failed: minimal_l2_gas_price mismatch", + case.name + ); + assert_eq!( + result.l1_gas_price, case.expected_l1_gas_price, + "Test case '{}' failed: l1_gas_price mismatch", + case.name + ); + assert_eq!( + result.l1_pubdata_price, case.expected_l1_pubdata_price, + "Test case '{}' failed: l1_pubdata_price mismatch", + case.name + ); + } + } +} diff --git a/core/node/base_token_adjuster/src/lib.rs b/core/node/base_token_adjuster/src/lib.rs new file mode 100644 index 000000000000..45c0615dfc01 --- /dev/null +++ b/core/node/base_token_adjuster/src/lib.rs @@ -0,0 +1,7 @@ +extern crate core; + +pub use self::base_token_adjuster::{ + BaseTokenAdjuster, MainNodeBaseTokenAdjuster, MockBaseTokenAdjuster, +}; + +mod base_token_adjuster; diff --git a/core/node/fee_model/Cargo.toml b/core/node/fee_model/Cargo.toml index 7ac3c1d32e88..006a2c22da7e 100644 --- a/core/node/fee_model/Cargo.toml +++ b/core/node/fee_model/Cargo.toml @@ -17,6 +17,8 @@ zksync_config.workspace = true zksync_eth_client.workspace = true zksync_utils.workspace = true zksync_web3_decl.workspace = true +zksync_base_token_adjuster.workspace = true +bigdecimal.workspace = true tokio = { workspace = true, features = ["time"] } anyhow.workspace = true diff --git a/core/node/fee_model/src/l1_gas_price/gas_adjuster/mod.rs b/core/node/fee_model/src/l1_gas_price/gas_adjuster/mod.rs index 9e553ba47bf2..35f8f090a128 100644 --- a/core/node/fee_model/src/l1_gas_price/gas_adjuster/mod.rs +++ b/core/node/fee_model/src/l1_gas_price/gas_adjuster/mod.rs @@ -2,6 +2,7 @@ use std::{ collections::VecDeque, + fmt::Debug, ops::RangeInclusive, sync::{Arc, RwLock}, }; @@ -19,6 +20,11 @@ mod metrics; #[cfg(test)] mod tests; +pub trait L1GasAdjuster: Debug + Send + Sync { + fn estimate_effective_gas_price(&self) -> u64; + fn estimate_effective_pubdata_price(&self) -> u64; +} + /// This component keeps track of the median `base_fee` from the last `max_base_fee_samples` blocks /// and of the median `blob_base_fee` from the last `max_blob_base_fee_sample` blocks. /// It is used to adjust the base_fee of transactions sent to L1. @@ -35,6 +41,54 @@ pub struct GasAdjuster { commitment_mode: L1BatchCommitmentMode, } +impl L1GasAdjuster for GasAdjuster { + fn estimate_effective_gas_price(&self) -> u64 { + if let Some(price) = self.config.internal_enforced_l1_gas_price { + return price; + } + + let effective_gas_price = self.get_base_fee(0) + self.get_priority_fee(); + + let calculated_price = + (self.config.internal_l1_pricing_multiplier * effective_gas_price as f64) as u64; + + // Bound the price if it's too high. + self.bound_gas_price(calculated_price) + } + + fn estimate_effective_pubdata_price(&self) -> u64 { + if let Some(price) = self.config.internal_enforced_pubdata_price { + return price; + } + + match self.pubdata_sending_mode { + PubdataSendingMode::Blobs => { + const BLOB_GAS_PER_BYTE: u64 = 1; // `BYTES_PER_BLOB` = `GAS_PER_BLOB` = 2 ^ 17. + + let blob_base_fee_median = self.blob_base_fee_statistics.median(); + + // Check if blob base fee overflows `u64` before converting. Can happen only in very extreme cases. + if blob_base_fee_median > U256::from(u64::MAX) { + let max_allowed = self.config.max_blob_base_fee(); + tracing::error!("Blob base fee is too high: {blob_base_fee_median}, using max allowed: {max_allowed}"); + return max_allowed; + } + METRICS + .median_blob_base_fee + .set(blob_base_fee_median.as_u64()); + let calculated_price = blob_base_fee_median.as_u64() as f64 + * BLOB_GAS_PER_BYTE as f64 + * self.config.internal_pubdata_pricing_multiplier; + + self.bound_blob_base_fee(calculated_price) + } + PubdataSendingMode::Calldata => { + self.estimate_effective_gas_price() * self.pubdata_byte_gas() + } + } + } +} + impl GasAdjuster { pub async fn new( eth_client: Box>, @@ -155,54 +209,6 @@ impl GasAdjuster { Ok(()) } - /// Returns the sum of base and priority fee, in wei, not considering time in mempool. - /// Can be used to get an estimate of current gas price. - pub(crate) fn estimate_effective_gas_price(&self) -> u64 { - if let Some(price) = self.config.internal_enforced_l1_gas_price { - return price; - } - - let effective_gas_price = self.get_base_fee(0) + self.get_priority_fee(); - - let calculated_price = - (self.config.internal_l1_pricing_multiplier * effective_gas_price as f64) as u64; - - // Bound the price if it's too high. - self.bound_gas_price(calculated_price) - } - - pub(crate) fn estimate_effective_pubdata_price(&self) -> u64 { - if let Some(price) = self.config.internal_enforced_pubdata_price { - return price; - } - - match self.pubdata_sending_mode { - PubdataSendingMode::Blobs => { - const BLOB_GAS_PER_BYTE: u64 = 1; // `BYTES_PER_BLOB` = `GAS_PER_BLOB` = 2 ^ 17. - - let blob_base_fee_median = self.blob_base_fee_statistics.median(); - - // Check if blob base fee overflows `u64` before converting. Can happen only in very extreme cases. - if blob_base_fee_median > U256::from(u64::MAX) { - let max_allowed = self.config.max_blob_base_fee(); - tracing::error!("Blob base fee is too high: {blob_base_fee_median}, using max allowed: {max_allowed}"); - return max_allowed; - } - METRICS - .median_blob_base_fee - .set(blob_base_fee_median.as_u64()); - let calculated_price = blob_base_fee_median.as_u64() as f64 - * BLOB_GAS_PER_BYTE as f64 - * self.config.internal_pubdata_pricing_multiplier; - - self.bound_blob_base_fee(calculated_price) - } - PubdataSendingMode::Calldata => { - self.estimate_effective_gas_price() * self.pubdata_byte_gas() - } - } - } - fn pubdata_byte_gas(&self) -> u64 { match self.commitment_mode { L1BatchCommitmentMode::Validium => 0, @@ -415,3 +421,29 @@ impl GasStatistics { self.0.read().unwrap().last_processed_block } } + +#[derive(Debug)] +#[allow(dead_code)] +pub struct MockGasAdjuster { + pub effective_l1_gas_price: u64, + pub effective_l1_pubdata_price: u64, +} + +impl L1GasAdjuster for MockGasAdjuster { + fn estimate_effective_gas_price(&self) -> u64 { + self.effective_l1_gas_price + } + + fn estimate_effective_pubdata_price(&self) -> u64 { + self.effective_l1_pubdata_price + } +} + +impl MockGasAdjuster { + pub fn new(effective_l1_gas_price: u64, effective_l1_pubdata_price: u64) -> Self { + Self { + effective_l1_gas_price, + effective_l1_pubdata_price, + } + } +} diff --git a/core/node/fee_model/src/l1_gas_price/main_node_fetcher.rs b/core/node/fee_model/src/l1_gas_price/main_node_fetcher.rs index 259a5e3e3fed..0e93dfeae573 100644 --- a/core/node/fee_model/src/l1_gas_price/main_node_fetcher.rs +++ b/core/node/fee_model/src/l1_gas_price/main_node_fetcher.rs @@ -71,8 +71,16 @@ impl MainNodeFeeParamsFetcher { } } +#[async_trait::async_trait] impl BatchFeeModelInputProvider for MainNodeFeeParamsFetcher { - fn get_fee_model_params(&self) -> FeeParams { - *self.main_node_fee_params.read().unwrap() + async fn get_fee_model_params(&self) -> anyhow::Result { + Ok(*self.main_node_fee_params.read().unwrap()) + } + + async fn maybe_convert_params_to_base_token( + &self, + _params: FeeParams, + ) -> anyhow::Result { + Ok(_params) } } diff --git a/core/node/fee_model/src/l1_gas_price/mod.rs b/core/node/fee_model/src/l1_gas_price/mod.rs index 219dc2f9c38d..0f0a6918328b 100644 --- a/core/node/fee_model/src/l1_gas_price/mod.rs +++ b/core/node/fee_model/src/l1_gas_price/mod.rs @@ -3,7 +3,8 @@ use std::fmt; pub use self::{ - gas_adjuster::GasAdjuster, main_node_fetcher::MainNodeFeeParamsFetcher, + gas_adjuster::{GasAdjuster, L1GasAdjuster, MockGasAdjuster}, + main_node_fetcher::MainNodeFeeParamsFetcher, singleton::GasAdjusterSingleton, }; diff --git a/core/node/fee_model/src/lib.rs b/core/node/fee_model/src/lib.rs index 793b5d4f8441..f351f7caa30d 100644 --- a/core/node/fee_model/src/lib.rs +++ b/core/node/fee_model/src/lib.rs @@ -1,6 +1,8 @@ use std::{fmt, sync::Arc}; use anyhow::Context as _; +use async_trait::async_trait; +use zksync_base_token_adjuster::BaseTokenAdjuster; use zksync_dal::{ConnectionPool, Core, CoreDal}; use zksync_types::{ fee_model::{ @@ -11,12 +13,12 @@ use zksync_types::{ }; use zksync_utils::ceil_div_u256; -use crate::l1_gas_price::GasAdjuster; +use crate::l1_gas_price::L1GasAdjuster; pub mod l1_gas_price; /// Trait responsible for providing fee info for a batch -#[async_trait::async_trait] +#[async_trait] pub trait BatchFeeModelInputProvider: fmt::Debug + 'static + Send + Sync { /// Returns the batch fee with scaling applied. This may be used to account for the fact that the L1 gas and pubdata prices may fluctuate, esp. /// in API methods that should return values that are valid for some period of time after the estimation was done. @@ -25,7 +27,7 @@ pub trait BatchFeeModelInputProvider: fmt::Debug + 'static + Send + Sync { l1_gas_price_scale_factor: f64, l1_pubdata_price_scale_factor: f64, ) -> anyhow::Result { - let params = self.get_fee_model_params(); + let params = self.get_fee_model_params().await?; Ok(match params { FeeParams::V1(params) => BatchFeeInput::L1Pegged(compute_batch_fee_model_input_v1( @@ -42,8 +44,18 @@ pub trait BatchFeeModelInputProvider: fmt::Debug + 'static + Send + Sync { }) } - /// Returns the fee model parameters. - fn get_fee_model_params(&self) -> FeeParams; + /// Returns the fee model parameters using the denomination of the base token used (WEI for ETH). + async fn get_fee_model_params(&self) -> anyhow::Result { + let unconverted_params = self.get_fee_model_params().await?; + self.maybe_convert_params_to_base_token(unconverted_params) + .await + } + + /// Converts the fee model parameters to the base token denomination. + async fn maybe_convert_params_to_base_token( + &self, + params: FeeParams, + ) -> anyhow::Result; } impl dyn BatchFeeModelInputProvider { @@ -53,18 +65,20 @@ impl dyn BatchFeeModelInputProvider { } } -/// The struct that represents the batch fee input provider to be used in the main node of the server, i.e. -/// it explicitly gets the L1 gas price from the provider and uses it to calculate the batch fee input instead of getting -/// it from other node. +/// The struct that represents the batch fee input provider to be used in the main node of the server. +/// This struct gets the L1 gas price directly from the provider rather than from another node, as is the +/// case with the external node. #[derive(Debug)] pub struct MainNodeFeeInputProvider { - provider: Arc, + provider: Arc, + base_token_adjuster: Arc, config: FeeModelConfig, } +#[async_trait] impl BatchFeeModelInputProvider for MainNodeFeeInputProvider { - fn get_fee_model_params(&self) -> FeeParams { - match self.config { + async fn get_fee_model_params(&self) -> anyhow::Result { + let params = match self.config { FeeModelConfig::V1(config) => FeeParams::V1(FeeParamsV1 { config, l1_gas_price: self.provider.estimate_effective_gas_price(), @@ -74,13 +88,32 @@ impl BatchFeeModelInputProvider for MainNodeFeeInputProvider { l1_gas_price: self.provider.estimate_effective_gas_price(), l1_pubdata_price: self.provider.estimate_effective_pubdata_price(), }), - } + }; + + self.maybe_convert_params_to_base_token(params).await + } + + async fn maybe_convert_params_to_base_token( + &self, + params: FeeParams, + ) -> anyhow::Result { + self.base_token_adjuster + .maybe_convert_to_base_token(params) + .await } } impl MainNodeFeeInputProvider { - pub fn new(provider: Arc, config: FeeModelConfig) -> Self { - Self { provider, config } + pub fn new( + provider: Arc, + base_token_adjuster: Arc, + config: FeeModelConfig, + ) -> Self { + Self { + provider, + base_token_adjuster, + config, + } } } @@ -104,7 +137,7 @@ impl ApiFeeInputProvider { } } -#[async_trait::async_trait] +#[async_trait] impl BatchFeeModelInputProvider for ApiFeeInputProvider { async fn get_batch_fee_input_scaled( &self, @@ -130,8 +163,15 @@ impl BatchFeeModelInputProvider for ApiFeeInputProvider { } /// Returns the fee model parameters. - fn get_fee_model_params(&self) -> FeeParams { - self.inner.get_fee_model_params() + async fn get_fee_model_params(&self) -> anyhow::Result { + self.inner.get_fee_model_params().await + } + + async fn maybe_convert_params_to_base_token( + &self, + params: FeeParams, + ) -> anyhow::Result { + self.inner.maybe_convert_params_to_base_token(params).await } } @@ -227,15 +267,29 @@ impl Default for MockBatchFeeParamsProvider { } } +#[async_trait] impl BatchFeeModelInputProvider for MockBatchFeeParamsProvider { - fn get_fee_model_params(&self) -> FeeParams { - self.0 + async fn get_fee_model_params(&self) -> anyhow::Result { + Ok(self.0) + } + + async fn maybe_convert_params_to_base_token( + &self, + params: FeeParams, + ) -> anyhow::Result { + Ok(params) } } #[cfg(test)] mod tests { + // use bigdecimal::{BigDecimal, ToPrimitive}; + use bigdecimal::BigDecimal; + // use test_casing::test_casing; + use zksync_base_token_adjuster::MockBaseTokenAdjuster; + use super::*; + use crate::l1_gas_price::MockGasAdjuster; // To test that overflow never happens, we'll use giant L1 gas price, i.e. // almost realistic very large value of 100k gwei. Since it is so large, we'll also @@ -327,7 +381,7 @@ mod tests { } #[test] - fn test_compute_batch_fee_model_input_v2_only_compute_overhead() { + fn test_compute_baxtch_fee_model_input_v2_only_compute_overhead() { // Here we use sensible config, but when only compute is used to close the batch let config = FeeModelConfigV2 { minimal_l2_gas_price: 100_000_000_000, @@ -458,4 +512,94 @@ mod tests { "Max pubdata increase lowers pubdata price" ); } + + #[tokio::test] + async fn test_get_fee_model_params() { + struct TestCase { + name: &'static str, + base_token: String, + base_token_to_eth: BigDecimal, + effective_l1_gas_price: u64, + effective_l1_pubdata_price: u64, + minimal_l2_gas_price: u64, + expected_l1_gas_price: u64, + expected_l1_pubdata_price: u64, + expected_minimal_l2_gas_price: u64, + } + + let test_cases = vec![ + TestCase { + name: "Convert to a custom base token", + base_token: "ZK".to_string(), + base_token_to_eth: BigDecimal::from(200000), + effective_l1_gas_price: 10_000_000_000, // 10 gwei + effective_l1_pubdata_price: 20_000_000, // 0.02 gwei + minimal_l2_gas_price: 25_000_000, // 0.025 gwei + expected_l1_gas_price: 2_000_000_000_000_000, + expected_l1_pubdata_price: 4_000_000_000_000, + expected_minimal_l2_gas_price: 5_000_000_000_000, + }, + TestCase { + name: "ETH as base token (no conversion)", + base_token: "ETH".to_string(), + base_token_to_eth: BigDecimal::from(1), + effective_l1_gas_price: 15_000_000_000, // 15 gwei + effective_l1_pubdata_price: 30_000_000, // 0.03 gwei + minimal_l2_gas_price: 40_000_000, // 0.04 gwei + expected_l1_gas_price: 15_000_000_000, + expected_l1_pubdata_price: 30_000_000, + expected_minimal_l2_gas_price: 40_000_000, + }, + ]; + + for case in test_cases { + let gas_adjuster = Arc::new(MockGasAdjuster::new( + case.effective_l1_gas_price, + case.effective_l1_pubdata_price, + )); + + let base_token_adjuster = Arc::new(MockBaseTokenAdjuster::new( + case.base_token_to_eth.clone(), + case.base_token.clone(), + )); + + let config = FeeModelConfig::V2(FeeModelConfigV2 { + minimal_l2_gas_price: case.minimal_l2_gas_price, + // The below values don't matter for this test. + compute_overhead_part: 1.0, + pubdata_overhead_part: 1.0, + batch_overhead_l1_gas: 1, + max_gas_per_batch: 1, + max_pubdata_per_batch: 1, + }); + + let fee_provider = MainNodeFeeInputProvider::new( + gas_adjuster.clone(), + base_token_adjuster.clone(), + config, + ); + + let fee_params = fee_provider.get_fee_model_params().await.unwrap(); + + if let FeeParams::V2(params) = fee_params { + assert_eq!( + params.l1_gas_price, case.expected_l1_gas_price, + "Test case '{}' failed: l1_gas_price mismatch", + case.name + ); + assert_eq!( + params.l1_pubdata_price, case.expected_l1_pubdata_price, + "Test case '{}' failed: l1_pubdata_price mismatch", + case.name + ); + assert_eq!( + params.config.minimal_l2_gas_price, case.expected_minimal_l2_gas_price, + "Test case '{}' failed: minimal_l2_gas_price mismatch", + case.name + ); + } else { + panic!("Expected FeeParams::V2 for test case '{}'", case.name); + } + } + } } diff --git a/core/node/node_framework/Cargo.toml b/core/node/node_framework/Cargo.toml index d48522fb8116..e401de87e2d5 100644 --- a/core/node/node_framework/Cargo.toml +++ b/core/node/node_framework/Cargo.toml @@ -46,6 +46,7 @@ zksync_queued_job_processor.workspace = true zksync_reorg_detector.workspace = true zksync_vm_runner.workspace = true zksync_node_db_pruner.workspace = true +zksync_base_token_adjuster.workspace = true tracing.workspace = true thiserror.workspace = true diff --git a/core/node/node_framework/examples/main_node.rs b/core/node/node_framework/examples/main_node.rs index f0cb8417ff97..218b6b82f1f8 100644 --- a/core/node/node_framework/examples/main_node.rs +++ b/core/node/node_framework/examples/main_node.rs @@ -15,8 +15,8 @@ use zksync_config::{ DatabaseSecrets, FriProofCompressorConfig, FriProverConfig, FriWitnessGeneratorConfig, L1Secrets, ObservabilityConfig, ProofDataHandlerConfig, }, - ApiConfig, ContractVerifierConfig, ContractsConfig, DBConfig, EthConfig, EthWatchConfig, - GasAdjusterConfig, GenesisConfig, ObjectStoreConfig, PostgresConfig, + ApiConfig, BaseTokenAdjusterConfig, ContractVerifierConfig, ContractsConfig, DBConfig, + EthConfig, EthWatchConfig, GasAdjusterConfig, GenesisConfig, ObjectStoreConfig, PostgresConfig, }; use zksync_env_config::FromEnv; use zksync_metadata_calculator::MetadataCalculatorConfig; @@ -112,6 +112,7 @@ impl MainNodeBuilder { let state_keeper_config = StateKeeperConfig::from_env()?; let genesis_config = GenesisConfig::from_env()?; let eth_sender_config = EthConfig::from_env()?; + let base_token_adjuster_config = BaseTokenAdjusterConfig::from_env()?; let sequencer_l1_gas_layer = SequencerL1GasLayer::new( gas_adjuster_config, genesis_config, @@ -120,6 +121,7 @@ impl MainNodeBuilder { .sender .context("eth_sender")? .pubdata_sending_mode, + base_token_adjuster_config, ); self.node.add_layer(sequencer_l1_gas_layer); Ok(self) diff --git a/core/node/node_framework/src/implementations/layers/base_token_adjuster.rs b/core/node/node_framework/src/implementations/layers/base_token_adjuster.rs new file mode 100644 index 000000000000..bf1fc18a7acd --- /dev/null +++ b/core/node/node_framework/src/implementations/layers/base_token_adjuster.rs @@ -0,0 +1,61 @@ +use zksync_config::configs::base_token_adjuster::BaseTokenAdjusterConfig; +use zksync_dal::Core; +use zksync_db_connection::connection_pool::ConnectionPool; + +use crate::{ + implementations::resources::pools::{MasterPool, PoolResource}, + service::{ServiceContext, StopReceiver}, + task::{Task, TaskId}, + wiring_layer::{WiringError, WiringLayer}, +}; + +/// A layer that wires the Base Token Adjuster task. +#[derive(Debug)] +pub struct BaseTokenAdjusterLayer { + config: BaseTokenAdjusterConfig, +} + +impl BaseTokenAdjusterLayer { + pub fn new(config: BaseTokenAdjusterConfig) -> Self { + Self { config } + } +} + +#[async_trait::async_trait] +impl WiringLayer for BaseTokenAdjusterLayer { + fn layer_name(&self) -> &'static str { + "base_token_adjuster" + } + + async fn wire(self: Box, mut context: ServiceContext<'_>) -> Result<(), WiringError> { + let master_pool_resource = context.get_resource::>().await?; + let master_pool = master_pool_resource.get().await?; + + context.add_task(Box::new(BaseTokenAdjusterTask { + main_pool: master_pool, + config: self.config, + })); + + Ok(()) + } +} + +#[derive(Debug)] +struct BaseTokenAdjusterTask { + main_pool: ConnectionPool, + config: BaseTokenAdjusterConfig, +} + +#[async_trait::async_trait] +impl Task for BaseTokenAdjusterTask { + fn id(&self) -> TaskId { + "base_token_adjuster".into() + } + + async fn run(self: Box, stop_receiver: StopReceiver) -> anyhow::Result<()> { + let mut adjuster = + zksync_base_token_adjuster::MainNodeBaseTokenAdjuster::new(self.main_pool, self.config); + + adjuster.run(stop_receiver.0).await + } +} diff --git a/core/node/node_framework/src/implementations/layers/l1_gas.rs b/core/node/node_framework/src/implementations/layers/l1_gas.rs index d465510eff5d..2dd28475c1eb 100644 --- a/core/node/node_framework/src/implementations/layers/l1_gas.rs +++ b/core/node/node_framework/src/implementations/layers/l1_gas.rs @@ -1,17 +1,20 @@ use std::sync::Arc; use anyhow::Context; +use zksync_base_token_adjuster::MainNodeBaseTokenAdjuster; use zksync_config::{ configs::{chain::StateKeeperConfig, eth_sender::PubdataSendingMode}, - GasAdjusterConfig, GenesisConfig, + BaseTokenAdjusterConfig, GasAdjusterConfig, GenesisConfig, }; use zksync_node_fee_model::{l1_gas_price::GasAdjuster, MainNodeFeeInputProvider}; use zksync_types::fee_model::FeeModelConfig; use crate::{ implementations::resources::{ - eth_interface::EthInterfaceResource, fee_input::FeeInputResource, + eth_interface::EthInterfaceResource, + fee_input::FeeInputResource, l1_tx_params::L1TxParamsResource, + pools::{PoolResource, ReplicaPool}, }, service::{ServiceContext, StopReceiver}, task::{Task, TaskId}, @@ -24,6 +27,7 @@ pub struct SequencerL1GasLayer { genesis_config: GenesisConfig, pubdata_sending_mode: PubdataSendingMode, state_keeper_config: StateKeeperConfig, + base_token_adjuster_config: BaseTokenAdjusterConfig, } impl SequencerL1GasLayer { @@ -32,12 +36,14 @@ impl SequencerL1GasLayer { genesis_config: GenesisConfig, state_keeper_config: StateKeeperConfig, pubdata_sending_mode: PubdataSendingMode, + base_token_adjuster_config: BaseTokenAdjusterConfig, ) -> Self { Self { gas_adjuster_config, genesis_config, pubdata_sending_mode, state_keeper_config, + base_token_adjuster_config, } } } @@ -60,8 +66,15 @@ impl WiringLayer for SequencerL1GasLayer { .context("GasAdjuster::new()")?; let gas_adjuster = Arc::new(adjuster); + let pool_resource = context.get_resource::>().await?; + let replica_pool = pool_resource.get().await?; + + let base_token_adjuster = + MainNodeBaseTokenAdjuster::new(replica_pool.clone(), self.base_token_adjuster_config); + let batch_fee_input_provider = Arc::new(MainNodeFeeInputProvider::new( gas_adjuster.clone(), + Arc::new(base_token_adjuster), FeeModelConfig::from_state_keeper_config(&self.state_keeper_config), )); context.insert_resource(FeeInputResource(batch_fee_input_provider))?; diff --git a/core/node/node_framework/src/implementations/layers/mod.rs b/core/node/node_framework/src/implementations/layers/mod.rs index 8637f15459d5..e66bfbf01aa3 100644 --- a/core/node/node_framework/src/implementations/layers/mod.rs +++ b/core/node/node_framework/src/implementations/layers/mod.rs @@ -1,3 +1,4 @@ +pub mod base_token_adjuster; pub mod batch_status_updater; pub mod circuit_breaker_checker; pub mod commitment_generator; diff --git a/core/node/state_keeper/Cargo.toml b/core/node/state_keeper/Cargo.toml index c2ac940eef39..b663a2033dfe 100644 --- a/core/node/state_keeper/Cargo.toml +++ b/core/node/state_keeper/Cargo.toml @@ -28,6 +28,8 @@ zksync_test_account.workspace = true zksync_node_genesis.workspace = true zksync_node_test_utils.workspace = true vm_utils.workspace = true +zksync_base_token_adjuster.workspace = true + anyhow.workspace = true async-trait.workspace = true diff --git a/core/node/state_keeper/src/io/tests/tester.rs b/core/node/state_keeper/src/io/tests/tester.rs index 84dfd4354b3c..da4b5ccba748 100644 --- a/core/node/state_keeper/src/io/tests/tester.rs +++ b/core/node/state_keeper/src/io/tests/tester.rs @@ -3,6 +3,7 @@ use std::{slice, sync::Arc, time::Duration}; use multivm::vm_latest::constants::BATCH_COMPUTATIONAL_GAS_LIMIT; +use zksync_base_token_adjuster::MockBaseTokenAdjuster; use zksync_config::{ configs::{chain::StateKeeperConfig, eth_sender::PubdataSendingMode, wallets::Wallets}, GasAdjusterConfig, @@ -78,8 +79,10 @@ impl Tester { pub(super) async fn create_batch_fee_input_provider(&self) -> MainNodeFeeInputProvider { let gas_adjuster = Arc::new(self.create_gas_adjuster().await); + MainNodeFeeInputProvider::new( gas_adjuster, + Arc::new(MockBaseTokenAdjuster::default()), FeeModelConfig::V1(FeeModelConfigV1 { minimal_l2_gas_price: self.minimal_l2_gas_price(), }), @@ -98,6 +101,7 @@ impl Tester { let gas_adjuster = Arc::new(self.create_gas_adjuster().await); let batch_fee_input_provider = MainNodeFeeInputProvider::new( gas_adjuster, + Arc::new(MockBaseTokenAdjuster::default()), FeeModelConfig::V1(FeeModelConfigV1 { minimal_l2_gas_price: self.minimal_l2_gas_price(), }), diff --git a/prover/Cargo.lock b/prover/Cargo.lock index c0e965605fd4..3f953c88fc06 100644 --- a/prover/Cargo.lock +++ b/prover/Cargo.lock @@ -8485,6 +8485,7 @@ name = "zksync_types" version = "0.1.0" dependencies = [ "anyhow", + "bigdecimal", "blake2 0.10.6 (registry+https://github.com/rust-lang/crates.io-index)", "chrono", "derive_more", diff --git a/prover/config/src/lib.rs b/prover/config/src/lib.rs index f501dd2d6e06..3d27751b6d57 100644 --- a/prover/config/src/lib.rs +++ b/prover/config/src/lib.rs @@ -8,10 +8,10 @@ use zksync_config::{ }, fri_prover_group::FriProverGroupConfig, house_keeper::HouseKeeperConfig, - DatabaseSecrets, FriProofCompressorConfig, FriProverConfig, FriProverGatewayConfig, - FriWitnessGeneratorConfig, FriWitnessVectorGeneratorConfig, GeneralConfig, - ObjectStoreConfig, ObservabilityConfig, PrometheusConfig, ProofDataHandlerConfig, - ProtectiveReadsWriterConfig, + BaseTokenAdjusterConfig, DatabaseSecrets, FriProofCompressorConfig, FriProverConfig, + FriProverGatewayConfig, FriWitnessGeneratorConfig, FriWitnessVectorGeneratorConfig, + GeneralConfig, ObjectStoreConfig, ObservabilityConfig, PrometheusConfig, + ProofDataHandlerConfig, ProtectiveReadsWriterConfig, }, ApiConfig, ContractVerifierConfig, DBConfig, EthConfig, EthWatchConfig, GasAdjusterConfig, PostgresConfig, SnapshotsCreatorConfig, @@ -50,6 +50,7 @@ fn load_env_config() -> anyhow::Result { snapshot_creator: SnapshotsCreatorConfig::from_env().ok(), protective_reads_writer_config: ProtectiveReadsWriterConfig::from_env().ok(), core_object_store: ObjectStoreConfig::from_env().ok(), + base_token_adjuster_config: BaseTokenAdjusterConfig::from_env().ok(), }) } diff --git a/prover/prover_dal/sqlx-data.json b/prover/prover_dal/sqlx-data.json new file mode 100644 index 000000000000..95c8c858baaf --- /dev/null +++ b/prover/prover_dal/sqlx-data.json @@ -0,0 +1,3 @@ +{ + "db": "PostgreSQL" +} \ No newline at end of file