feat: implement eth_feeHistory (#2083)

Co-authored-by: Dan Cline <6798349+Rjected@users.noreply.github.com>
Co-authored-by: Matthias Seitz <matthias.seitz@outlook.de>
This commit is contained in:
Satyam Kulkarni
2023-04-20 14:20:03 +05:30
committed by GitHub
parent 64e3a57dd9
commit 7e965a3c79
7 changed files with 326 additions and 104 deletions

View File

@@ -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<u64>) -> Option<u128> {
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].

View File

@@ -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<std::cmp::Ordering> {
// compare only the reward
// see:
// <https://github.com/ethereum/go-ethereum/blob/ee8e83fa5f6cb261dad2ed0a7bbcde4930c41e6c/eth/gasprice/feehistory.go#L85>
self.reward.partial_cmp(&other.reward)
}
}
impl Ord for TxGasAndReward {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
// compare only the reward
// see:
// <https://github.com/ethereum/go-ethereum/blob/ee8e83fa5f6cb261dad2ed0a7bbcde4930c41e6c/eth/gasprice/feehistory.go#L85>
self.reward.cmp(&other.reward)
}
}
/// Response type for `eth_feeHistory`
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]

View File

@@ -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;

View File

@@ -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<Client, Pool, Network> EthApi<Client, Pool, Network>
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<Vec<f64>>,
) -> EthResult<FeeHistory> {
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<f64> =
fee_history_cache_items.values().map(|item| item.gas_used_ratio).collect();
let mut rewards: Vec<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),
})
}
}

View File

@@ -15,6 +15,7 @@ use std::{num::NonZeroUsize, sync::Arc};
mod block;
mod call;
mod fees;
mod server;
mod sign;
mod state;

View File

@@ -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<Vec<f64>>,
) -> Result<FeeHistory> {
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<Header> =
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 = <EthApi<_, _, _> as EthApiServer>::fee_history(
&eth_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 = <EthApi<_, _, _> as EthApiServer>::fee_history(
&eth_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));
}
}

View File

@@ -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<EthApiError> for RpcError {
@@ -83,6 +86,7 @@ impl From<EthApiError> 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()),
}
}
}