diff --git a/crates/interfaces/src/provider.rs b/crates/interfaces/src/provider.rs index 45c05a780c..d690f7b4d4 100644 --- a/crates/interfaces/src/provider.rs +++ b/crates/interfaces/src/provider.rs @@ -86,6 +86,9 @@ pub enum ProviderError { /// Thrown when the cache service task dropped #[error("cache service task stopped")] CacheServiceUnavailable, + /// Thrown when the gas oracle task dropped + #[error("gas oracle task stopped")] + GasPriceOracleServiceUnavailable, /// Thrown when we failed to lookup a block for the pending state #[error("Unknown block hash: {0:}")] UnknownBlockHash(H256), diff --git a/crates/rpc/rpc-builder/src/auth.rs b/crates/rpc/rpc-builder/src/auth.rs index 92e7617455..692bab44d4 100644 --- a/crates/rpc/rpc-builder/src/auth.rs +++ b/crates/rpc/rpc-builder/src/auth.rs @@ -14,8 +14,8 @@ use reth_provider::{ BlockProviderIdExt, EvmEnvProvider, HeaderProvider, ReceiptProviderIdExt, StateProviderFactory, }; use reth_rpc::{ - eth::cache::EthStateCache, AuthLayer, Claims, EngineEthApi, EthApi, EthFilter, - JwtAuthValidator, JwtSecret, + eth::{cache::EthStateCache, gas_oracle::GasPriceOracle}, + AuthLayer, Claims, EngineEthApi, EthApi, EthFilter, JwtAuthValidator, JwtSecret, }; use reth_rpc_api::{servers::*, EngineApiServer}; use reth_tasks::TaskSpawner; @@ -51,8 +51,9 @@ where EngineApi: EngineApiServer, { // spawn a new cache task - let eth_cache = EthStateCache::spawn_with(client.clone(), Default::default(), executor); - let eth_api = EthApi::new(client.clone(), pool.clone(), network, eth_cache.clone()); + let eth_cache = EthStateCache::spawn_with(client.clone(), Default::default(), executor.clone()); + let gas_oracle = GasPriceOracle::new(client.clone(), Default::default(), eth_cache.clone()); + let eth_api = EthApi::new(client.clone(), pool.clone(), network, eth_cache.clone(), gas_oracle); let eth_filter = EthFilter::new(client, pool, eth_cache.clone(), DEFAULT_MAX_LOGS_IN_RESPONSE); launch_with_eth_api(eth_api, eth_filter, engine_api, socket_addr, secret).await } diff --git a/crates/rpc/rpc-builder/src/eth.rs b/crates/rpc/rpc-builder/src/eth.rs index 00f21188b2..90f2cda17f 100644 --- a/crates/rpc/rpc-builder/src/eth.rs +++ b/crates/rpc/rpc-builder/src/eth.rs @@ -1,5 +1,8 @@ use reth_rpc::{ - eth::cache::{EthStateCache, EthStateCacheConfig}, + eth::{ + cache::{EthStateCache, EthStateCacheConfig}, + gas_oracle::GasPriceOracleConfig, + }, EthApi, EthFilter, EthPubSub, }; use serde::{Deserialize, Serialize}; @@ -25,6 +28,8 @@ pub struct EthHandlers { pub struct EthConfig { /// Settings for the caching layer pub cache: EthStateCacheConfig, + /// Settings for the gas price oracle + pub gas_oracle: GasPriceOracleConfig, /// The maximum number of tracing calls that can be executed in concurrently. pub max_tracing_requests: usize, /// Maximum number of logs that can be returned in a single response in `eth_getLogs` calls. @@ -35,6 +40,7 @@ impl Default for EthConfig { fn default() -> Self { Self { cache: EthStateCacheConfig::default(), + gas_oracle: GasPriceOracleConfig::default(), max_tracing_requests: 10, max_logs_per_response: DEFAULT_MAX_LOGS_IN_RESPONSE, } diff --git a/crates/rpc/rpc-builder/src/lib.rs b/crates/rpc/rpc-builder/src/lib.rs index 4d0cd0f66a..d0391459ca 100644 --- a/crates/rpc/rpc-builder/src/lib.rs +++ b/crates/rpc/rpc-builder/src/lib.rs @@ -110,8 +110,9 @@ use reth_provider::{ StateProviderFactory, }; use reth_rpc::{ - eth::cache::EthStateCache, AdminApi, DebugApi, EngineEthApi, EthApi, EthFilter, EthPubSub, - EthSubscriptionIdProvider, NetApi, TraceApi, TracingCallGuard, TxPoolApi, Web3Api, + eth::{cache::EthStateCache, gas_oracle::GasPriceOracle}, + AdminApi, DebugApi, EngineEthApi, EthApi, EthFilter, EthPubSub, EthSubscriptionIdProvider, + NetApi, TraceApi, TracingCallGuard, TxPoolApi, Web3Api, }; use reth_rpc_api::{servers::*, EngineApiServer}; use reth_tasks::TaskSpawner; @@ -826,6 +827,11 @@ where self.config.eth.cache.clone(), self.executor.clone(), ); + let gas_oracle = GasPriceOracle::new( + self.client.clone(), + self.config.eth.gas_oracle.clone(), + cache.clone(), + ); let new_canonical_blocks = self.events.canonical_state_stream(); let c = cache.clone(); self.executor.spawn_critical( @@ -840,6 +846,7 @@ where self.pool.clone(), self.network.clone(), cache.clone(), + gas_oracle, ); let filter = EthFilter::new( self.client.clone(), diff --git a/crates/rpc/rpc-builder/tests/it/http.rs b/crates/rpc/rpc-builder/tests/it/http.rs index 71ed65353e..61a190f7e6 100644 --- a/crates/rpc/rpc-builder/tests/it/http.rs +++ b/crates/rpc/rpc-builder/tests/it/http.rs @@ -97,8 +97,8 @@ where EthApiClient::send_transaction(client, transaction_request).await.unwrap_err(); EthApiClient::hashrate(client).await.unwrap(); EthApiClient::submit_hashrate(client, U256::default(), H256::default()).await.unwrap(); - EthApiClient::gas_price(client).await.unwrap(); - EthApiClient::max_priority_fee_per_gas(client).await.unwrap(); + EthApiClient::gas_price(client).await.unwrap_err(); + EthApiClient::max_priority_fee_per_gas(client).await.unwrap_err(); // Unimplemented assert!(is_unimplemented( diff --git a/crates/rpc/rpc/src/eth/api/fees.rs b/crates/rpc/rpc/src/eth/api/fees.rs index 7518dc4a86..e9b1e2ad36 100644 --- a/crates/rpc/rpc/src/eth/api/fees.rs +++ b/crates/rpc/rpc/src/eth/api/fees.rs @@ -30,8 +30,7 @@ where /// Returns a suggestion for the priority fee (the tip) pub(crate) async fn suggested_priority_fee(&self) -> EthResult { - // TODO: properly implement sampling https://github.com/ethereum/pm/issues/328#issuecomment-853234014 - Ok(U256::from(1e9 as u64)) + self.gas_oracle().suggest_tip_cap().await } /// Reports the fee history, for the given amount of blocks, up until the newest block diff --git a/crates/rpc/rpc/src/eth/api/mod.rs b/crates/rpc/rpc/src/eth/api/mod.rs index f0b9632846..6e6b7fa2a6 100644 --- a/crates/rpc/rpc/src/eth/api/mod.rs +++ b/crates/rpc/rpc/src/eth/api/mod.rs @@ -6,6 +6,7 @@ use crate::eth::{ cache::EthStateCache, error::{EthApiError, EthResult}, + gas_oracle::GasPriceOracle, signer::EthSigner, }; use async_trait::async_trait; @@ -71,8 +72,21 @@ pub struct EthApi { impl EthApi { /// Creates a new, shareable instance. - pub fn new(client: Client, pool: Pool, network: Network, eth_cache: EthStateCache) -> Self { - let inner = EthApiInner { client, pool, network, signers: Default::default(), eth_cache }; + pub fn new( + client: Client, + pool: Pool, + network: Network, + eth_cache: EthStateCache, + gas_oracle: GasPriceOracle, + ) -> Self { + let inner = EthApiInner { + client, + pool, + network, + signers: Default::default(), + eth_cache, + gas_oracle, + }; Self { inner: Arc::new(inner), fee_history_cache: FeeHistoryCache::new( @@ -86,6 +100,11 @@ impl EthApi { &self.inner.eth_cache } + /// Returns the gas oracle frontend + pub(crate) fn gas_oracle(&self) -> &GasPriceOracle { + &self.inner.gas_oracle + } + /// Returns the inner `Client` pub fn client(&self) -> &Client { &self.inner.client @@ -238,4 +257,6 @@ struct EthApiInner { signers: Vec>, /// The async cache frontend for eth related data eth_cache: EthStateCache, + /// The async gas oracle frontend for gas price suggestions + gas_oracle: GasPriceOracle, } diff --git a/crates/rpc/rpc/src/eth/api/server.rs b/crates/rpc/rpc/src/eth/api/server.rs index 9296e0129b..d4f46375ea 100644 --- a/crates/rpc/rpc/src/eth/api/server.rs +++ b/crates/rpc/rpc/src/eth/api/server.rs @@ -364,7 +364,10 @@ where #[cfg(test)] mod tests { - use crate::{eth::cache::EthStateCache, EthApi}; + use crate::{ + eth::{cache::EthStateCache, gas_oracle::GasPriceOracle}, + EthApi, + }; use jsonrpsee::types::error::INVALID_PARAMS_CODE; use rand::random; use reth_network_api::test_utils::NoopNetwork; @@ -376,11 +379,13 @@ mod tests { #[tokio::test] /// Handler for: `eth_test_fee_history` async fn test_fee_history() { + let cache = EthStateCache::spawn(NoopProvider::default(), Default::default()); let eth_api = EthApi::new( NoopProvider::default(), testing_pool(), NoopNetwork, - EthStateCache::spawn(NoopProvider::default(), Default::default()), + cache.clone(), + GasPriceOracle::new(NoopProvider::default(), Default::default(), cache), ); let response = as EthApiServer>::fee_history( @@ -460,11 +465,13 @@ mod tests { gas_used_ratios.pop(); + let cache = EthStateCache::spawn(mock_provider.clone(), Default::default()); let eth_api = EthApi::new( - mock_provider, + mock_provider.clone(), testing_pool(), NoopNetwork, - EthStateCache::spawn(NoopProvider::default(), Default::default()), + cache.clone(), + GasPriceOracle::new(mock_provider, Default::default(), cache.clone()), ); let response = as EthApiServer>::fee_history( diff --git a/crates/rpc/rpc/src/eth/api/state.rs b/crates/rpc/rpc/src/eth/api/state.rs index 0ed49530ef..24b04f73c0 100644 --- a/crates/rpc/rpc/src/eth/api/state.rs +++ b/crates/rpc/rpc/src/eth/api/state.rs @@ -145,7 +145,7 @@ where #[cfg(test)] mod tests { use super::*; - use crate::eth::cache::EthStateCache; + use crate::eth::{cache::EthStateCache, gas_oracle::GasPriceOracle}; use reth_primitives::{StorageKey, StorageValue}; use reth_provider::test_utils::{ExtendedAccount, MockEthProvider, NoopProvider}; use reth_transaction_pool::test_utils::testing_pool; @@ -156,11 +156,13 @@ mod tests { // === Noop === let pool = testing_pool(); + let cache = EthStateCache::spawn(NoopProvider::default(), Default::default()); let eth_api = EthApi::new( NoopProvider::default(), pool.clone(), (), - EthStateCache::spawn(NoopProvider::default(), Default::default()), + cache.clone(), + GasPriceOracle::new(NoopProvider::default(), Default::default(), cache), ); let address = Address::random(); let storage = eth_api.storage_at(address, U256::ZERO.into(), None).unwrap(); @@ -174,11 +176,13 @@ mod tests { let account = ExtendedAccount::new(0, U256::ZERO).extend_storage(storage); mock_provider.add_account(address, account); + let cache = EthStateCache::spawn(mock_provider.clone(), Default::default()); let eth_api = EthApi::new( mock_provider.clone(), pool, (), - EthStateCache::spawn(mock_provider, Default::default()), + cache.clone(), + GasPriceOracle::new(mock_provider, Default::default(), cache), ); let storage_key: U256 = storage_key.into(); diff --git a/crates/rpc/rpc/src/eth/api/transactions.rs b/crates/rpc/rpc/src/eth/api/transactions.rs index 267f9faa4d..ac3f7d8c38 100644 --- a/crates/rpc/rpc/src/eth/api/transactions.rs +++ b/crates/rpc/rpc/src/eth/api/transactions.rs @@ -756,7 +756,10 @@ impl From for Transaction { #[cfg(test)] mod tests { use super::*; - use crate::{eth::cache::EthStateCache, EthApi}; + use crate::{ + eth::{cache::EthStateCache, gas_oracle::GasPriceOracle}, + EthApi, + }; use reth_network_api::test_utils::NoopNetwork; use reth_primitives::{hex_literal::hex, Bytes}; use reth_provider::test_utils::NoopProvider; @@ -769,11 +772,13 @@ mod tests { let pool = testing_pool(); + let cache = EthStateCache::spawn(noop_provider, Default::default()); let eth_api = EthApi::new( noop_provider, pool.clone(), noop_network_provider, - EthStateCache::spawn(NoopProvider::default(), Default::default()), + cache.clone(), + GasPriceOracle::new(noop_provider, Default::default(), cache), ); // https://etherscan.io/tx/0xa694b71e6c128a2ed8e2e0f6770bddbe52e3bb8f10e8472f9a79ab81497a8b5d diff --git a/crates/rpc/rpc/src/eth/gas_oracle.rs b/crates/rpc/rpc/src/eth/gas_oracle.rs new file mode 100644 index 0000000000..76bdfb4057 --- /dev/null +++ b/crates/rpc/rpc/src/eth/gas_oracle.rs @@ -0,0 +1,261 @@ +//! An implementation of the eth gas price oracle, used for providing gas price estimates based on +//! previous blocks. +use crate::eth::{ + cache::EthStateCache, + error::{EthApiError, EthResult, InvalidTransactionError}, +}; +use reth_primitives::{constants::GWEI_TO_WEI, BlockId, BlockNumberOrTag, H256, U256}; +use reth_provider::BlockProviderIdExt; +use serde::{Deserialize, Serialize}; +use tokio::sync::Mutex; +use tracing::warn; + +/// The number of transactions sampled in a block +pub const SAMPLE_NUMBER: u32 = 3; + +/// The default maximum gas price to use for the estimate +pub const DEFAULT_MAX_PRICE: U256 = U256::from_limbs([500_000_000_000u64, 0, 0, 0]); + +/// The default minimum gas price, under which the sample will be ignored +pub const DEFAULT_IGNORE_PRICE: U256 = U256::from_limbs([2u64, 0, 0, 0]); + +/// Settings for the [GasPriceOracle] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GasPriceOracleConfig { + /// The number of populated blocks to produce the gas price estimate + pub blocks: u32, + + /// The percentile of gas prices to use for the estimate + pub percentile: u32, + + /// The maximum number of headers to keep in the cache + pub max_header_history: u64, + + /// The maximum number of blocks for estimating gas price + pub max_block_history: u64, + + /// The default gas price to use if there are no blocks to use + pub default: Option, + + /// The maximum gas price to use for the estimate + pub max_price: Option, + + /// The minimum gas price, under which the sample will be ignored + pub ignore_price: Option, +} + +impl Default for GasPriceOracleConfig { + fn default() -> Self { + GasPriceOracleConfig { + blocks: 20, + percentile: 60, + max_header_history: 1024, + max_block_history: 1024, + default: None, + max_price: Some(DEFAULT_MAX_PRICE), + ignore_price: Some(DEFAULT_IGNORE_PRICE), + } + } +} + +/// Calculates a gas price depending on recent blocks. +#[derive(Debug)] +pub struct GasPriceOracle { + /// The type used to subscribe to block events and get block info + client: Client, + /// The cache for blocks + cache: EthStateCache, + /// The config for the oracle + oracle_config: GasPriceOracleConfig, + /// The latest calculated price and its block hash + last_price: Mutex, +} + +impl GasPriceOracle +where + Client: BlockProviderIdExt + 'static, +{ + /// Creates and returns the [GasPriceOracle]. + pub fn new( + client: Client, + mut oracle_config: GasPriceOracleConfig, + cache: EthStateCache, + ) -> Self { + // sanitize the perentile to be less than 100 + if oracle_config.percentile > 100 { + warn!(prev_percentile=?oracle_config.percentile, "Invalid configured gas price percentile, using 100 instead"); + oracle_config.percentile = 100; + } + + Self { client, oracle_config, last_price: Default::default(), cache } + } + + /// Suggests a gas price estimate based on recent blocks, using the configured percentile. + pub async fn suggest_tip_cap(&self) -> EthResult { + let block = self + .client + .block_by_id(BlockId::Number(BlockNumberOrTag::Latest))? + .ok_or(EthApiError::UnknownBlockNumber)?; + + // seal for the block hash + let header = block.header.seal_slow(); + + let mut last_price = self.last_price.lock().await; + + // if we have stored a last price, then we check whether or not it was for the same head + if last_price.block_hash == header.hash { + return Ok(last_price.price) + } + + // if all responses are empty, then we can return a maximum of 2*check_block blocks' worth + // of prices + // + // we only return more than check_block blocks' worth of prices if one or more return empty + // transactions + let mut current_hash = header.hash; + let mut results = Vec::new(); + let mut populated_blocks = 0; + + // we only check a maximum of 2 * max_block_history, or the number of blocks in the chain + let max_blocks = if self.oracle_config.max_block_history * 2 > header.number { + header.number + } else { + self.oracle_config.max_block_history * 2 + }; + + for _ in 0..max_blocks { + let (parent_hash, block_values) = self + .get_block_values(current_hash, SAMPLE_NUMBER as usize) + .await? + .ok_or(EthApiError::UnknownBlockNumber)?; + + if block_values.is_empty() { + results.push(U256::from(last_price.price)); + } else { + results.extend(block_values); + populated_blocks += 1; + } + + // break when we have enough populated blocks + if populated_blocks >= self.oracle_config.blocks { + break + } + + current_hash = parent_hash; + } + + // sort results then take the configured percentile result + let mut price = last_price.price; + if !results.is_empty() { + results.sort_unstable(); + price = *results + .get((results.len() - 1) * self.oracle_config.percentile as usize / 100) + .expect("gas price index is a percent of nonzero array length, so a value always exists; qed"); + } + + // constrain to the max price + if let Some(max_price) = self.oracle_config.max_price { + if price > max_price { + price = max_price; + } + } + + *last_price = GasPriceOracleResult { block_hash: header.hash, price }; + + Ok(price) + } + + /// Get the `limit` lowest effective tip values for the given block. If the oracle has a + /// configured `ignore_price` threshold, then tip values under that threshold will be ignored + /// before returning a result. + /// + /// If the block cannot be found, then this will return `None`. + /// + /// This method also returns the parent hash for the given block. + async fn get_block_values( + &self, + block_hash: H256, + limit: usize, + ) -> EthResult)>> { + // check the cache (this will hit the disk if the block is not cached) + let block = match self.cache.get_block(block_hash).await? { + Some(block) => block, + None => return Ok(None), + }; + + // sort the transactions by effective tip + // but first filter those that should be ignored + let txs = block.body.iter(); + let mut txs = txs + .filter(|tx| { + if let Some(ignore_under) = self.oracle_config.ignore_price { + if tx.effective_gas_tip(block.base_fee_per_gas).map(U256::from) < + Some(ignore_under) + { + return false + } + } + + // recover sender, check if coinbase + let sender = tx.recover_signer(); + match sender { + // transactions will be filtered if this is false + Some(addr) => addr != block.beneficiary, + // TODO: figure out an error for this case or ignore + None => false, + } + }) + // map all values to effective_gas_tip because we will be returning those values + // anyways + .map(|tx| tx.effective_gas_tip(block.base_fee_per_gas)) + .collect::>(); + + // now do the sort + txs.sort_unstable(); + + // fill result with the top `limit` transactions + let mut final_result = Vec::with_capacity(limit); + for tx in txs.iter().take(limit) { + // a `None` effective_gas_tip represents a transaction where the max_fee_per_gas is + // less than the base fee + let effective_tip = tx.ok_or(InvalidTransactionError::FeeCapTooLow)?; + final_result.push(U256::from(effective_tip)); + } + + Ok(Some((block.parent_hash, final_result))) + } +} + +/// Stores the last result that the oracle returned +#[derive(Debug, Clone)] +pub struct GasPriceOracleResult { + /// The block hash that the oracle used to calculate the price + pub block_hash: H256, + /// The price that the oracle calculated + pub price: U256, +} + +impl Default for GasPriceOracleResult { + fn default() -> Self { + Self { block_hash: H256::zero(), price: U256::from(GWEI_TO_WEI) } + } +} + +#[cfg(test)] +mod tests { + use reth_primitives::constants::GWEI_TO_WEI; + + use super::*; + + #[test] + fn max_price_sanity() { + assert_eq!(DEFAULT_MAX_PRICE, U256::from(500_000_000_000u64)); + assert_eq!(DEFAULT_MAX_PRICE, U256::from(500 * GWEI_TO_WEI)) + } + + #[test] + fn ignore_price_sanity() { + assert_eq!(DEFAULT_IGNORE_PRICE, U256::from(2u64)); + } +} diff --git a/crates/rpc/rpc/src/eth/mod.rs b/crates/rpc/rpc/src/eth/mod.rs index bb07bc88a3..19032dd8e9 100644 --- a/crates/rpc/rpc/src/eth/mod.rs +++ b/crates/rpc/rpc/src/eth/mod.rs @@ -4,6 +4,7 @@ mod api; pub mod cache; pub mod error; mod filter; +pub mod gas_oracle; mod id_provider; mod logs_utils; mod pubsub;