diff --git a/Cargo.lock b/Cargo.lock index bef27c8ff46..2906e38dd97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11089,11 +11089,15 @@ dependencies = [ name = "starknet_l1_gas_price" version = "0.0.0" dependencies = [ + "async-trait", + "papyrus_base_layer", "papyrus_config", "serde", "starknet_api", + "starknet_sequencer_infra", "thiserror 1.0.69", "tokio", + "tracing", "validator", ] diff --git a/crates/starknet_l1_gas_price/Cargo.toml b/crates/starknet_l1_gas_price/Cargo.toml index e9e19589db8..426c768f7b4 100644 --- a/crates/starknet_l1_gas_price/Cargo.toml +++ b/crates/starknet_l1_gas_price/Cargo.toml @@ -6,11 +6,15 @@ repository.workspace = true license.workspace = true [dependencies] +async-trait.workspace = true +papyrus_base_layer.workspace = true papyrus_config.workspace = true serde.workspace = true starknet_api.workspace = true +starknet_sequencer_infra.workspace = true thiserror.workspace = true tokio.workspace = true +tracing.workspace = true validator.workspace = true [lints] diff --git a/crates/starknet_l1_gas_price/src/l1_gas_price_scraper.rs b/crates/starknet_l1_gas_price/src/l1_gas_price_scraper.rs new file mode 100644 index 00000000000..4b9d8588412 --- /dev/null +++ b/crates/starknet_l1_gas_price/src/l1_gas_price_scraper.rs @@ -0,0 +1,180 @@ +use std::any::type_name; +use std::cmp::max; +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use papyrus_base_layer::BaseLayerContract; +use papyrus_config::converters::deserialize_float_seconds_to_duration; +use papyrus_config::dumping::{ser_param, SerializeConfig}; +use papyrus_config::validators::validate_ascii; +use papyrus_config::{ParamPath, ParamPrivacyInput, SerializedParam}; +use serde::{Deserialize, Serialize}; +use starknet_api::block::{BlockNumber, BlockTimestamp}; +use starknet_api::core::ChainId; +use starknet_sequencer_infra::component_client::ClientError; +use starknet_sequencer_infra::component_definitions::ComponentStarter; +use starknet_sequencer_infra::errors::ComponentError; +use thiserror::Error; +use tracing::{error, info}; +use validator::Validate; + +use crate::l1_gas_price_provider::{ + L1GasPriceProviderClient, + L1GasPriceProviderError, + MEAN_NUMBER_OF_BLOCKS, +}; + +#[cfg(test)] +#[path = "l1_gas_price_scraper_tests.rs"] +pub mod l1_gas_price_scraper_tests; + +type L1GasPriceScraperResult = Result>; +pub type SharedL1GasPriceProvider = Arc; + +#[derive(Clone, Debug, Serialize, Deserialize, Validate, PartialEq)] +pub struct L1GasPriceScraperConfig { + pub l1_block_to_start_scraping_from: u64, + #[validate(custom = "validate_ascii")] + pub chain_id: ChainId, + pub finality: u64, + #[serde(deserialize_with = "deserialize_float_seconds_to_duration")] + pub polling_interval: Duration, +} + +impl Default for L1GasPriceScraperConfig { + fn default() -> Self { + Self { + l1_block_to_start_scraping_from: 0, + chain_id: ChainId::Mainnet, + finality: 0, + polling_interval: Duration::from_secs(1), + } + } +} + +impl SerializeConfig for L1GasPriceScraperConfig { + fn dump(&self) -> BTreeMap { + BTreeMap::from([ + ser_param( + "l1_block_to_start_scraping_from", + &0, + "Last L1 block number processed by the scraper", + ParamPrivacyInput::Public, + ), + ser_param( + "finality", + &3, + "Number of blocks to wait for finality", + ParamPrivacyInput::Public, + ), + ser_param( + "polling_interval", + &self.polling_interval.as_secs(), + "Interval in Seconds between each scraping attempt of L1.", + ParamPrivacyInput::Public, + ), + ser_param( + "chain_id", + &self.chain_id, + "The chain to follow. For more details see https://docs.starknet.io/documentation/architecture_and_concepts/Blocks/transactions/#chain-id.", + ParamPrivacyInput::Public, + ), + ]) + } +} + +pub struct L1GasPriceScraper { + pub config: L1GasPriceScraperConfig, + pub base_layer: B, + pub next_block_number_to_fetch: u64, + pub l1_gas_price_provider: SharedL1GasPriceProvider, +} + +impl L1GasPriceScraper { + pub fn new( + config: L1GasPriceScraperConfig, + l1_gas_price_provider: SharedL1GasPriceProvider, + base_layer: B, + ) -> Self { + Self { + l1_gas_price_provider, + base_layer, + next_block_number_to_fetch: config.l1_block_to_start_scraping_from, + config, + } + } + + async fn run(&mut self) -> L1GasPriceScraperResult<(), B> { + loop { + self.update_prices().await?; + tokio::time::sleep(self.config.polling_interval).await; + } + } + + async fn update_prices(&mut self) -> L1GasPriceScraperResult<(), B> { + let latest_l1_block_number = self + .base_layer + .latest_l1_block_number(self.config.finality) + .await + .map_err(L1GasPriceScraperError::BaseLayerError)?; + + if let Some(latest_l1_block_number) = latest_l1_block_number { + if self.next_block_number_to_fetch > latest_l1_block_number { + // We are already up to date. + return Ok(()); + } + // Choose the oldest block we need to fetch. + // It is either next_block_number_to_fetch, or the current head of the chain, + // minus 2 * MEAN_NUMBER_OF_BLOCKS. Note that this minus can be less than zero + // for short chains, hence the saturating_sub. + let oldest_l1_block_number = max( + self.next_block_number_to_fetch, + latest_l1_block_number.saturating_sub(2 * MEAN_NUMBER_OF_BLOCKS), + ); + for block_number in oldest_l1_block_number..=latest_l1_block_number { + if let Some(sample) = self + .base_layer + .get_price_sample(block_number) + .await + .map_err(L1GasPriceScraperError::BaseLayerError)? + { + self.l1_gas_price_provider + .add_price_info( + BlockNumber(block_number), + BlockTimestamp(sample.timestamp), + sample.base_fee_per_gas, + sample.blob_fee, + ) + .map_err(L1GasPriceScraperError::GasPriceProviderError)?; + + self.next_block_number_to_fetch = latest_l1_block_number + 1; + } + } + } else { + error!("Failed to get latest L1 block number, finality too high."); + } + + Ok(()) + } +} + +#[async_trait] +impl ComponentStarter for L1GasPriceScraper { + async fn start(&mut self) -> Result<(), ComponentError> { + info!("Starting component {}.", type_name::()); + self.run().await.map_err(|_| ComponentError::InternalComponentError) + } +} + +#[derive(Error, Debug)] +pub enum L1GasPriceScraperError { + #[error("Base layer error: {0}")] + BaseLayerError(T::Error), + #[error("Could not update gas price provider: {0}")] + GasPriceProviderError(L1GasPriceProviderError), + // Leaky abstraction, these errors should not propagate here. + #[error(transparent)] + NetworkError(ClientError), +} diff --git a/crates/starknet_l1_gas_price/src/l1_gas_price_scraper_tests.rs b/crates/starknet_l1_gas_price/src/l1_gas_price_scraper_tests.rs new file mode 100644 index 00000000000..578da49eb7c --- /dev/null +++ b/crates/starknet_l1_gas_price/src/l1_gas_price_scraper_tests.rs @@ -0,0 +1,208 @@ +use std::ops::RangeInclusive; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use async_trait::async_trait; +use papyrus_base_layer::{ + BaseLayerContract, + L1BlockNumber, + L1BlockReference, + L1Event, + PriceSample, +}; +use starknet_api::block::{BlockHashAndNumber, BlockNumber, BlockTimestamp}; + +use crate::l1_gas_price_provider::{L1GasPriceProviderClient, L1GasPriceProviderError}; +use crate::l1_gas_price_scraper::{L1GasPriceScraper, L1GasPriceScraperConfig}; + +const BLOCK_TIME: u64 = 2; +const GAS_PRICE: u128 = 42; +const DATA_PRICE: u128 = 137; + +#[derive(thiserror::Error, Debug)] +pub enum FakeBaseLayerError {} + +#[derive(Debug, Clone)] +struct FakeInternalBaseLayerData { + time_between_blocks: u64, + gas_price: u128, + data_price: u128, + last_block_num: L1BlockNumber, +} + +impl Default for FakeInternalBaseLayerData { + fn default() -> Self { + Self { + time_between_blocks: BLOCK_TIME, + gas_price: GAS_PRICE, + data_price: DATA_PRICE, + last_block_num: 0, + } + } +} + +#[derive(Debug, Clone)] +struct FakeBaseLayerContract { + data: Arc>, +} + +impl Default for FakeBaseLayerContract { + fn default() -> Self { + Self { data: Arc::new(Mutex::new(FakeInternalBaseLayerData::default())) } + } +} + +#[async_trait] +impl BaseLayerContract for FakeBaseLayerContract { + type Error = FakeBaseLayerError; + async fn get_price_sample( + &self, + block_num: L1BlockNumber, + ) -> Result, Self::Error> { + let data = self.data.lock().unwrap(); + Ok(Some(PriceSample { + timestamp: block_num * data.time_between_blocks, + base_fee_per_gas: data.gas_price, + blob_fee: data.data_price, + })) + } + + async fn latest_l1_block_number(&self, _: u64) -> Result, Self::Error> { + Ok(Some(self.data.lock().unwrap().last_block_num)) + } + + async fn get_proved_block_at( + &self, + _: L1BlockNumber, + ) -> Result { + todo!(); + } + + async fn latest_proved_block(&self, _: u64) -> Result, Self::Error> { + todo!(); + } + + async fn latest_l1_block(&self, _: u64) -> Result, Self::Error> { + todo!(); + } + + async fn l1_block_at(&self, _: L1BlockNumber) -> Result, Self::Error> { + todo!(); + } + + /// Get specific events from the Starknet base contract between two L1 block numbers. + async fn events( + &self, + _: RangeInclusive, + _: &[&str], + ) -> Result, Self::Error> { + todo!(); + } +} + +#[allow(clippy::type_complexity)] +#[derive(Debug, Default)] +struct FakeL1GasPriceProvider { + data: Arc>>, +} + +impl L1GasPriceProviderClient for FakeL1GasPriceProvider { + fn add_price_info( + &self, + height: BlockNumber, + timestamp: BlockTimestamp, + gas_price: u128, + data_gas_price: u128, + ) -> Result<(), L1GasPriceProviderError> { + self.data.lock().unwrap().push((height, timestamp, gas_price, data_gas_price)); + Ok(()) + } + + fn get_price_info( + &self, + timestamp: BlockTimestamp, + ) -> Result<(u128, u128), L1GasPriceProviderError> { + let vector = self.data.lock().unwrap(); + let index = vector.iter().position(|(_, ts, _, _)| ts.0 >= timestamp.0).unwrap(); + Ok((vector[index].2, vector[index].3)) + } +} + +#[tokio::test] +#[allow(clippy::as_conversions)] +async fn run_l1_gas_price_scraper() { + let fake_contract = FakeBaseLayerContract::default(); + let fake_provider = Arc::new(FakeL1GasPriceProvider::default()); + + let mut scraper = L1GasPriceScraper::new( + L1GasPriceScraperConfig { + polling_interval: Duration::from_millis(1), + ..Default::default() + }, + fake_provider.clone(), + fake_contract.clone(), + ); + + // Run the scraper as a separate task in the background. + let _future_handle = tokio::spawn(async move { + scraper.run().await.unwrap(); + }); + + // Let the scraper have some time to work. + tokio::time::sleep(Duration::from_millis(50)).await; + + // There is only block zero on the contract. + assert_eq!(fake_provider.data.lock().unwrap().len(), 1); + { + let data = fake_provider.data.lock().unwrap(); + assert_eq!(data[0].0.0, 0); + assert_eq!(data[0].1.0, 0); + assert_eq!(data[0].2, GAS_PRICE); + assert_eq!(data[0].3, DATA_PRICE); + } // Inner scope ends here to release the lock. + + // Add a few blocks to the contract. + let number = 10; + { + fake_contract.data.lock().unwrap().last_block_num = number; + } // Inner scope ends here to release the lock. + + // Let the scraper have some time to work. + tokio::time::sleep(Duration::from_millis(50)).await; + + // The provider should have received the blocks. + { + let data = fake_provider.data.lock().unwrap(); + let block_numbers = data.iter().map(|(x, _, _, _)| x.0).collect::>(); + let timestamps = data.iter().map(|(_, x, _, _)| x.0).collect::>(); + let gas_prices = data.iter().map(|(_, _, x, _)| *x).collect::>(); + let data_prices = data.iter().map(|(_, _, _, x)| *x).collect::>(); + + assert_eq!(block_numbers, (0..=number).collect::>()); + assert_eq!(timestamps, (0..=number).map(|x| x * BLOCK_TIME).collect::>()); + assert_eq!(gas_prices, vec![GAS_PRICE; number as usize + 1]); + assert_eq!(data_prices, vec![DATA_PRICE; number as usize + 1]); + } // Inner scope ends here to release the lock. + + // Change the pricing and add one more block. + { + let mut contract_data = fake_contract.data.lock().unwrap(); + contract_data.gas_price = 100; + contract_data.data_price = 200; + contract_data.last_block_num = number + 1; + } // Inner scope ends here to release the lock. + + // Let the scraper have some time to work. + tokio::time::sleep(Duration::from_millis(50)).await; + + // The provider should have received the new block. + { + let data = fake_provider.data.lock().unwrap(); + let block_numbers = data.iter().map(|(x, _, _, _)| x.0).collect::>(); + let last_timestamp = data[(number + 1) as usize].1.0; + assert_eq!(block_numbers, (0..=number + 1).collect::>()); + assert_eq!(last_timestamp, (number + 1) * BLOCK_TIME); + assert_eq!(data[(number + 1) as usize].2, 100); + assert_eq!(data[(number + 1) as usize].3, 200); + } // Inner scope ends here to release the lock. (in case the test is extended later) +} diff --git a/crates/starknet_l1_gas_price/src/lib.rs b/crates/starknet_l1_gas_price/src/lib.rs index 3e6fbb983bb..7a3ec096b35 100644 --- a/crates/starknet_l1_gas_price/src/lib.rs +++ b/crates/starknet_l1_gas_price/src/lib.rs @@ -1 +1,2 @@ pub mod l1_gas_price_provider; +pub mod l1_gas_price_scraper;