diff --git a/crates/primitives/src/transaction/mod.rs b/crates/primitives/src/transaction/mod.rs index 867515f0a5..fdc5bc1275 100644 --- a/crates/primitives/src/transaction/mod.rs +++ b/crates/primitives/src/transaction/mod.rs @@ -278,6 +278,43 @@ impl Transaction { } } + // TODO: dedup with effective_tip_per_gas + /// Determine the effective gas limit for the given transaction and base fee. + /// If the base fee is `None`, the `max_priority_fee_per_gas`, or gas price for non-EIP1559 + /// transactions is returned. + /// + /// If the `max_fee_per_gas` is less than the base fee, `None` returned. + pub fn effective_gas_price(&self, base_fee: Option) -> Option { + if let Some(base_fee) = base_fee { + let max_fee_per_gas = self.max_fee_per_gas(); + if max_fee_per_gas < base_fee as u128 { + None + } else { + let effective_max_fee = max_fee_per_gas - base_fee as u128; + Some(std::cmp::min(effective_max_fee, self.priority_fee_or_price())) + } + } else { + Some(self.priority_fee_or_price()) + } + } + + /// Return the max priority fee per gas if the transaction is an EIP-1559 transaction, and + /// otherwise return the gas price. + /// + /// # Warning + /// + /// This is different than the `max_priority_fee_per_gas` method, which returns `None` for + /// non-EIP-1559 transactions. + pub(crate) fn priority_fee_or_price(&self) -> u128 { + match self { + Transaction::Legacy(TxLegacy { gas_price, .. }) | + Transaction::Eip2930(TxEip2930 { gas_price, .. }) => *gas_price, + Transaction::Eip1559(TxEip1559 { max_priority_fee_per_gas, .. }) => { + *max_priority_fee_per_gas + } + } + } + /// Returns the effective miner gas tip cap (`gasTipCap`) for the given base fee. /// /// Returns `None` if the basefee is higher than the [Transaction::max_fee_per_gas]. diff --git a/crates/rpc/rpc-types/src/eth/fee.rs b/crates/rpc/rpc-types/src/eth/fee.rs index d5dbf88615..4ace0e0c5c 100644 --- a/crates/rpc/rpc-types/src/eth/fee.rs +++ b/crates/rpc/rpc-types/src/eth/fee.rs @@ -4,6 +4,33 @@ use serde::{Deserialize, Serialize}; use std::{num::NonZeroUsize, sync::Arc}; use tokio::sync::Mutex; +/// Internal struct to calculate reward percentiles +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TxGasAndReward { + /// gas used by a block + pub gas_used: u128, + /// minimum between max_priority_fee_per_gas or max_fee_per_gas - base_fee_for_block + pub reward: u128, +} + +impl PartialOrd for TxGasAndReward { + fn partial_cmp(&self, other: &Self) -> Option { + // compare only the reward + // see: + // + self.reward.partial_cmp(&other.reward) + } +} + +impl Ord for TxGasAndReward { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + // compare only the reward + // see: + // + self.reward.cmp(&other.reward) + } +} + /// Response type for `eth_feeHistory` #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/rpc/rpc-types/src/eth/mod.rs b/crates/rpc/rpc-types/src/eth/mod.rs index 3efa9750a7..1bff0f4491 100644 --- a/crates/rpc/rpc-types/src/eth/mod.rs +++ b/crates/rpc/rpc-types/src/eth/mod.rs @@ -19,7 +19,7 @@ mod work; pub use account::*; pub use block::*; pub use call::CallRequest; -pub use fee::{FeeHistory, FeeHistoryCache, FeeHistoryCacheItem}; +pub use fee::{FeeHistory, FeeHistoryCache, FeeHistoryCacheItem, TxGasAndReward}; pub use filter::*; pub use index::Index; pub use log::Log; diff --git a/crates/rpc/rpc/src/eth/api/fees.rs b/crates/rpc/rpc/src/eth/api/fees.rs new file mode 100644 index 0000000000..1ad8495aa4 --- /dev/null +++ b/crates/rpc/rpc/src/eth/api/fees.rs @@ -0,0 +1,188 @@ +//! Contains RPC handler implementations for fee history. + +use crate::{ + eth::error::{EthApiError, EthResult, InvalidTransactionError}, + EthApi, +}; +use reth_network_api::NetworkInfo; +use reth_primitives::{BlockId, U256}; +use reth_provider::{BlockProvider, EvmEnvProvider, StateProviderFactory}; +use reth_rpc_types::{FeeHistory, FeeHistoryCacheItem, TxGasAndReward}; +use reth_transaction_pool::TransactionPool; +use std::collections::BTreeMap; + +impl EthApi +where + Pool: TransactionPool + Clone + 'static, + Client: BlockProvider + StateProviderFactory + EvmEnvProvider + 'static, + Network: NetworkInfo + Send + Sync + 'static, +{ + /// Reports the fee history, for the given amount of blocks, up until the newest block + /// provided. + pub(crate) async fn fee_history( + &self, + block_count: u64, + newest_block: BlockId, + reward_percentiles: Option>, + ) -> EthResult { + if block_count == 0 { + return Ok(FeeHistory::default()) + } + + let Some(previous_to_end_block) = self.inner.client.block_number_for_id(newest_block)? else { return Err(EthApiError::UnknownBlockNumber)}; + let end_block = previous_to_end_block + 1; + + if end_block < block_count { + return Err(EthApiError::InvalidBlockRange) + } + + let mut start_block = end_block - block_count; + + if block_count == 1 { + start_block = previous_to_end_block; + } + + // if not provided the percentiles are [] + let reward_percentiles = reward_percentiles.unwrap_or_default(); + + // checks for rewardPercentile's sorted-ness + // check if any of rewardPercentile is greater than 100 + // pre 1559 blocks, return 0 for baseFeePerGas + for window in reward_percentiles.windows(2) { + if window[0] >= window[1] { + return Err(EthApiError::InvalidRewardPercentile(window[1])) + } + + if window[0] < 0.0 || window[0] > 100.0 { + return Err(EthApiError::InvalidRewardPercentile(window[0])) + } + } + + let mut fee_history_cache = self.fee_history_cache.0.lock().await; + + // Sorted map that's populated in two rounds: + // 1. Cache entries until first non-cached block + // 2. Database query from the first non-cached block + let mut fee_history_cache_items = BTreeMap::new(); + + let mut first_non_cached_block = None; + let mut last_non_cached_block = None; + for block in start_block..=end_block { + // Check if block exists in cache, and move it to the head of the list if so + if let Some(fee_history_cache_item) = fee_history_cache.get(&block) { + fee_history_cache_items.insert(block, fee_history_cache_item.clone()); + } else { + // If block doesn't exist in cache, set it as a first non-cached block to query it + // from the database + first_non_cached_block.get_or_insert(block); + // And last non-cached block, so we could query the database until we reach it + last_non_cached_block = Some(block); + } + } + + // If we had any cache misses, query the database starting with the first non-cached block + // and ending with the last + if let (Some(start_block), Some(end_block)) = + (first_non_cached_block, last_non_cached_block) + { + let header_range = start_block..=end_block; + + let headers = self.inner.client.headers_range(header_range.clone())?; + let transactions_by_block = + self.inner.client.transactions_by_block_range(header_range)?; + + let header_tx = headers.iter().zip(&transactions_by_block); + + // We should receive exactly the amount of blocks missing from the cache + if headers.len() != (end_block - start_block + 1) as usize { + return Err(EthApiError::InvalidBlockRange) + } + + // We should receive exactly the amount of blocks missing from the cache + if transactions_by_block.len() != (end_block - start_block + 1) as usize { + return Err(EthApiError::InvalidBlockRange) + } + + for (header, transactions) in header_tx { + let base_fee_per_gas: U256 = header.base_fee_per_gas. + unwrap_or_default(). // Zero for pre-EIP-1559 blocks + try_into().unwrap(); // u64 -> U256 won't fail + let gas_used_ratio = header.gas_used as f64 / header.gas_limit as f64; + + let mut sorter = Vec::with_capacity(transactions.len()); + for transaction in transactions.iter() { + let reward = transaction + .effective_gas_price(header.base_fee_per_gas) + .ok_or(InvalidTransactionError::FeeCapTooLow)?; + + sorter.push(TxGasAndReward { gas_used: header.gas_used as u128, reward }) + } + + sorter.sort(); + + let mut rewards = Vec::with_capacity(reward_percentiles.len()); + let mut sum_gas_used = sorter[0].gas_used; + let mut tx_index = 0; + + for percentile in reward_percentiles.iter() { + let threshold_gas_used = (header.gas_used as f64) * percentile / 100_f64; + while sum_gas_used < threshold_gas_used as u128 && tx_index < transactions.len() + { + tx_index += 1; + sum_gas_used += sorter[tx_index].gas_used; + } + + rewards.push(U256::from(sorter[tx_index].reward)); + } + + let fee_history_cache_item = FeeHistoryCacheItem { + hash: None, + base_fee_per_gas, + gas_used_ratio, + reward: Some(rewards), + }; + + // Insert missing cache entries in the map for further response composition from + // it + fee_history_cache_items.insert(header.number, fee_history_cache_item.clone()); + // And populate the cache with new entries + fee_history_cache.push(header.number, fee_history_cache_item); + } + } + + // get the first block in the range from the db + let oldest_block_hash = + self.inner.client.block_hash(start_block)?.ok_or(EthApiError::UnknownBlockNumber)?; + + // Set the hash in cache items if the block is present in the cache + if let Some(cache_item) = fee_history_cache_items.get_mut(&start_block) { + cache_item.hash = Some(oldest_block_hash); + } + + if let Some(cache_item) = fee_history_cache.get_mut(&start_block) { + cache_item.hash = Some(oldest_block_hash); + } + + // `fee_history_cache_items` now contains full requested block range (populated from both + // cache and database), so we can iterate over it in order and populate the response fields + let base_fee_per_gas = + fee_history_cache_items.values().map(|item| item.base_fee_per_gas).collect(); + + let mut gas_used_ratio: Vec = + fee_history_cache_items.values().map(|item| item.gas_used_ratio).collect(); + + let mut rewards: Vec> = + fee_history_cache_items.values().filter_map(|item| item.reward.clone()).collect(); + + // gasUsedRatio doesn't have data for next block in this case the last block + gas_used_ratio.pop(); + rewards.pop(); + + Ok(FeeHistory { + base_fee_per_gas, + gas_used_ratio, + oldest_block: U256::from(start_block), + reward: Some(rewards), + }) + } +} diff --git a/crates/rpc/rpc/src/eth/api/mod.rs b/crates/rpc/rpc/src/eth/api/mod.rs index 1941e9995b..4c3930fee3 100644 --- a/crates/rpc/rpc/src/eth/api/mod.rs +++ b/crates/rpc/rpc/src/eth/api/mod.rs @@ -15,6 +15,7 @@ use std::{num::NonZeroUsize, sync::Arc}; mod block; mod call; +mod fees; mod server; mod sign; mod state; diff --git a/crates/rpc/rpc/src/eth/api/server.rs b/crates/rpc/rpc/src/eth/api/server.rs index c2a6349a7f..613828c2a7 100644 --- a/crates/rpc/rpc/src/eth/api/server.rs +++ b/crates/rpc/rpc/src/eth/api/server.rs @@ -5,27 +5,24 @@ use super::EthApiSpec; use crate::{ eth::{ api::{EthApi, EthTransactions}, - error::{ensure_success, EthApiError}, + error::ensure_success, }, result::{internal_rpc_err, ToRpcResult}, }; use jsonrpsee::core::RpcResult as Result; +use reth_network_api::NetworkInfo; use reth_primitives::{ serde_helper::JsonStorageKey, AccessListWithGasUsed, Address, BlockId, BlockNumberOrTag, Bytes, - Header, H256, H64, U256, U64, + H256, H64, U256, U64, }; use reth_provider::{BlockProvider, EvmEnvProvider, HeaderProvider, StateProviderFactory}; use reth_rpc_api::EthApiServer; use reth_rpc_types::{ - state::StateOverride, CallRequest, EIP1186AccountProofResponse, FeeHistory, - FeeHistoryCacheItem, Index, RichBlock, SyncStatus, TransactionReceipt, TransactionRequest, - Work, + state::StateOverride, CallRequest, EIP1186AccountProofResponse, FeeHistory, Index, RichBlock, + SyncStatus, TransactionReceipt, TransactionRequest, Work, }; use reth_transaction_pool::TransactionPool; - -use reth_network_api::NetworkInfo; use serde_json::Value; -use std::collections::BTreeMap; use tracing::trace; #[async_trait::async_trait] @@ -270,94 +267,8 @@ where reward_percentiles: Option>, ) -> Result { trace!(target: "rpc::eth", ?block_count, ?newest_block, ?reward_percentiles, "Serving eth_feeHistory"); - let block_count = block_count.as_u64(); - - if block_count == 0 { - return Ok(FeeHistory::default()) - } - - let Some(end_block) = self.inner.client.block_number_for_id(newest_block).to_rpc_result()? else { return Err(EthApiError::UnknownBlockNumber.into())}; - - if end_block < block_count { - return Err(EthApiError::InvalidBlockRange.into()) - } - - let start_block = end_block - block_count; - - let mut fee_history_cache = self.fee_history_cache.0.lock().await; - - // Sorted map that's populated in two rounds: - // 1. Cache entries until first non-cached block - // 2. Database query from the first non-cached block - let mut fee_history_cache_items = BTreeMap::new(); - - let mut first_non_cached_block = None; - let mut last_non_cached_block = None; - for block in start_block..=end_block { - // Check if block exists in cache, and move it to the head of the list if so - if let Some(fee_history_cache_item) = fee_history_cache.get(&block) { - fee_history_cache_items.insert(block, fee_history_cache_item.clone()); - } else { - // If block doesn't exist in cache, set it as a first non-cached block to query it - // from the database - first_non_cached_block.get_or_insert(block); - // And last non-cached block, so we could query the database until we reach it - last_non_cached_block = Some(block); - } - } - - // If we had any cache misses, query the database starting with the first non-cached block - // and ending with the last - if let (Some(start_block), Some(end_block)) = - (first_non_cached_block, last_non_cached_block) - { - let headers: Vec
= - self.inner.client.headers_range(start_block..=end_block).to_rpc_result()?; - - // We should receive exactly the amount of blocks missing from the cache - if headers.len() != (end_block - start_block + 1) as usize { - return Err(EthApiError::InvalidBlockRange.into()) - } - - for header in headers { - let base_fee_per_gas = header.base_fee_per_gas. - unwrap_or_default(). // Zero for pre-EIP-1559 blocks - try_into().unwrap(); // u64 -> U256 won't fail - let gas_used_ratio = header.gas_used as f64 / header.gas_limit as f64; - - let fee_history_cache_item = FeeHistoryCacheItem { - hash: None, - base_fee_per_gas, - gas_used_ratio, - reward: None, // TODO: calculate rewards per transaction - }; - - // Insert missing cache entries in the map for further response composition from it - fee_history_cache_items.insert(header.number, fee_history_cache_item.clone()); - // And populate the cache with new entries - fee_history_cache.push(header.number, fee_history_cache_item); - } - } - - let oldest_block_hash = self.inner.client.block_hash(start_block).to_rpc_result()?.unwrap(); - - fee_history_cache_items.get_mut(&start_block).unwrap().hash = Some(oldest_block_hash); - fee_history_cache.get_mut(&start_block).unwrap().hash = Some(oldest_block_hash); - - // `fee_history_cache_items` now contains full requested block range (populated from both - // cache and database), so we can iterate over it in order and populate the response fields - Ok(FeeHistory { - base_fee_per_gas: fee_history_cache_items - .values() - .map(|item| item.base_fee_per_gas) - .collect(), - gas_used_ratio: fee_history_cache_items - .values() - .map(|item| item.gas_used_ratio) - .collect(), - oldest_block: U256::from_be_bytes(oldest_block_hash.0), - reward: None, - }) + return Ok(EthApi::fee_history(self, block_count.as_u64(), newest_block, reward_percentiles) + .await?) } /// Handler for: `eth_maxPriorityFeePerGas` @@ -449,7 +360,7 @@ mod tests { }; use rand::random; use reth_network_api::test_utils::NoopNetwork; - use reth_primitives::{Block, BlockNumberOrTag, Header, H256, U256}; + use reth_primitives::{Block, BlockNumberOrTag, Header, TransactionSigned, H256, U256}; use reth_provider::test_utils::{MockEthProvider, NoopProvider}; use reth_rpc_api::EthApiServer; use reth_transaction_pool::test_utils::testing_pool; @@ -464,7 +375,13 @@ mod tests { EthStateCache::spawn(NoopProvider::default(), Default::default()), ); - let response = eth_api.fee_history(1.into(), BlockNumberOrTag::Latest.into(), None).await; + let response = as EthApiServer>::fee_history( + ð_api, + 1.into(), + BlockNumberOrTag::Latest.into(), + None, + ) + .await; assert!(matches!(response, RpcResult::Err(RpcError::Call(CallError::Custom(_))))); let Err(RpcError::Call(CallError::Custom(error_object))) = response else { unreachable!() }; assert_eq!(error_object.code(), INVALID_PARAMS_CODE); @@ -492,7 +409,39 @@ mod tests { ..Default::default() }; - mock_provider.add_block(hash, Block { header: header.clone(), ..Default::default() }); + let mut transactions = vec![]; + for _ in 0..100 { + let random_fee: u128 = random(); + + if let Some(base_fee_per_gas) = header.base_fee_per_gas { + let transaction = TransactionSigned { + transaction: reth_primitives::Transaction::Eip1559( + reth_primitives::TxEip1559 { + max_priority_fee_per_gas: random_fee, + max_fee_per_gas: random_fee + base_fee_per_gas as u128, + ..Default::default() + }, + ), + ..Default::default() + }; + + transactions.push(transaction); + } else { + let transaction = TransactionSigned { + transaction: reth_primitives::Transaction::Legacy( + reth_primitives::TxLegacy { ..Default::default() }, + ), + ..Default::default() + }; + + transactions.push(transaction); + } + } + + mock_provider.add_block( + hash, + Block { header: header.clone(), body: transactions, ..Default::default() }, + ); mock_provider.add_header(hash, header); oldest_block.get_or_insert(hash); @@ -501,6 +450,8 @@ mod tests { .push(base_fee_per_gas.map(|fee| U256::try_from(fee).unwrap()).unwrap_or_default()); } + gas_used_ratios.pop(); + let eth_api = EthApi::new( mock_provider, testing_pool(), @@ -508,17 +459,31 @@ mod tests { EthStateCache::spawn(NoopProvider::default(), Default::default()), ); - let response = - eth_api.fee_history((newest_block + 1).into(), newest_block.into(), None).await; + let response = as EthApiServer>::fee_history( + ð_api, + (newest_block + 1).into(), + newest_block.into(), + Some(vec![10.0]), + ) + .await; assert!(matches!(response, RpcResult::Err(RpcError::Call(CallError::Custom(_))))); let Err(RpcError::Call(CallError::Custom(error_object))) = response else { unreachable!() }; assert_eq!(error_object.code(), INVALID_PARAMS_CODE); + // newest_block is finalized let fee_history = - eth_api.fee_history(block_count.into(), newest_block.into(), None).await.unwrap(); + eth_api.fee_history(block_count, (newest_block - 1).into(), None).await.unwrap(); assert_eq!(fee_history.base_fee_per_gas, base_fees_per_gas); assert_eq!(fee_history.gas_used_ratio, gas_used_ratios); - assert_eq!(fee_history.oldest_block, U256::from_be_bytes(oldest_block.unwrap().0)); + assert_eq!(fee_history.oldest_block, U256::from(newest_block - block_count)); + + // newest_block is pending + let fee_history = + eth_api.fee_history(block_count, (newest_block - 1).into(), None).await.unwrap(); + + assert_eq!(fee_history.base_fee_per_gas, base_fees_per_gas); + assert_eq!(fee_history.gas_used_ratio, gas_used_ratios); + assert_eq!(fee_history.oldest_block, U256::from(newest_block - block_count)); } } diff --git a/crates/rpc/rpc/src/eth/error.rs b/crates/rpc/rpc/src/eth/error.rs index e8c0c0c7ab..6f2b10aade 100644 --- a/crates/rpc/rpc/src/eth/error.rs +++ b/crates/rpc/rpc/src/eth/error.rs @@ -60,6 +60,9 @@ pub enum EthApiError { /// When tracer config does not match the tracer #[error("invalid tracer config")] InvalidTracerConfig, + /// Percentile array is invalid + #[error("invalid reward percentile")] + InvalidRewardPercentile(f64), } impl From for RpcError { @@ -83,6 +86,7 @@ impl From for RpcError { rpc_error_with_code(EthRpcErrorCode::ResourceNotFound.code(), error.to_string()) } EthApiError::Unsupported(msg) => internal_rpc_err(msg), + EthApiError::InvalidRewardPercentile(msg) => internal_rpc_err(msg.to_string()), } } }