Skip to main content

reth_rpc_eth_types/
fee_history.rs

1//! Consist of types adjacent to the fee history cache and its configs
2
3use std::{
4    collections::{BTreeMap, VecDeque},
5    fmt::Debug,
6    sync::{atomic::Ordering::SeqCst, Arc},
7};
8
9use alloy_consensus::{BlockHeader, Header, Transaction, TxReceipt};
10use alloy_eips::eip7840::BlobParams;
11use alloy_rpc_types_eth::TxGasAndReward;
12use futures::{
13    future::{Fuse, FusedFuture},
14    FutureExt, Stream, StreamExt,
15};
16use metrics::atomics::AtomicU64;
17use reth_chain_state::CanonStateNotification;
18use reth_chainspec::{ChainSpecProvider, EthChainSpec};
19use reth_primitives_traits::{Block, BlockBody, NodePrimitives, SealedBlock};
20use reth_rpc_server_types::constants::gas_oracle::MAX_HEADER_HISTORY;
21use reth_storage_api::BlockReaderIdExt;
22use serde::{Deserialize, Serialize};
23use tracing::trace;
24
25use crate::utils::checked_blob_gas_used_ratio;
26
27use super::{EthApiError, EthStateCache};
28
29/// Contains cached fee history entries for blocks.
30///
31/// Purpose for this is to provide cached data for `eth_feeHistory`.
32#[derive(Debug, Clone)]
33pub struct FeeHistoryCache<H> {
34    inner: Arc<FeeHistoryCacheInner<H>>,
35}
36
37impl<H> FeeHistoryCache<H>
38where
39    H: BlockHeader + Clone,
40{
41    /// Creates new `FeeHistoryCache` instance, initialize it with the more recent data, set bounds
42    pub fn new(config: FeeHistoryCacheConfig) -> Self {
43        let inner = FeeHistoryCacheInner {
44            lower_bound: Default::default(),
45            upper_bound: Default::default(),
46            config,
47            entries: Default::default(),
48        };
49        Self { inner: Arc::new(inner) }
50    }
51
52    /// How the cache is configured.
53    #[inline]
54    pub fn config(&self) -> &FeeHistoryCacheConfig {
55        &self.inner.config
56    }
57
58    /// Returns the configured resolution for percentile approximation.
59    #[inline]
60    pub fn resolution(&self) -> u64 {
61        self.config().resolution
62    }
63
64    /// Returns all blocks that are missing in the cache in the [`lower_bound`, `upper_bound`]
65    /// range.
66    ///
67    /// This function is used to populate the cache with missing blocks, which can happen if the
68    /// node switched to stage sync node.
69    async fn missing_consecutive_blocks(&self) -> VecDeque<u64> {
70        let entries = self.inner.entries.read().await;
71        (self.lower_bound()..self.upper_bound())
72            .rev()
73            .filter(|&block_number| !entries.contains_key(&block_number))
74            .collect()
75    }
76
77    /// Insert block data into the cache.
78    async fn insert_blocks<'a, I, B, R, C>(&self, blocks: I, chain_spec: &C)
79    where
80        B: Block<Header = H> + 'a,
81        R: TxReceipt + 'a,
82        I: IntoIterator<Item = (&'a SealedBlock<B>, &'a [R])>,
83        C: EthChainSpec,
84    {
85        let mut entries = self.inner.entries.write().await;
86
87        let percentiles = self.predefined_percentiles();
88        // Insert all new blocks and calculate approximated rewards
89        for (block, receipts) in blocks {
90            let mut fee_history_entry = FeeHistoryEntry::<H>::new(
91                block,
92                chain_spec.blob_params_at_timestamp(block.header().timestamp()),
93            );
94            fee_history_entry.rewards = calculate_reward_percentiles_for_block(
95                &percentiles,
96                fee_history_entry.header.base_fee_per_gas().unwrap_or_default(),
97                block.body().transactions(),
98                receipts,
99            )
100            .unwrap_or_default();
101            entries.insert(block.number(), Arc::new(fee_history_entry));
102        }
103
104        // enforce bounds by popping the oldest entries
105        while entries.len() > self.inner.config.max_blocks as usize {
106            entries.pop_first();
107        }
108
109        if entries.is_empty() {
110            self.inner.upper_bound.store(0, SeqCst);
111            self.inner.lower_bound.store(0, SeqCst);
112            return
113        }
114
115        let upper_bound = *entries.last_entry().expect("Contains at least one entry").key();
116
117        // also enforce proper lower bound in case we have gaps
118        let target_lower = upper_bound.saturating_sub(self.inner.config.max_blocks);
119        while entries.len() > 1 && *entries.first_key_value().unwrap().0 < target_lower {
120            entries.pop_first();
121        }
122
123        let lower_bound = *entries.first_entry().expect("Contains at least one entry").key();
124        self.inner.upper_bound.store(upper_bound, SeqCst);
125        self.inner.lower_bound.store(lower_bound, SeqCst);
126    }
127
128    /// Get `UpperBound` value for `FeeHistoryCache`
129    pub fn upper_bound(&self) -> u64 {
130        self.inner.upper_bound.load(SeqCst)
131    }
132
133    /// Get `LowerBound` value for `FeeHistoryCache`
134    pub fn lower_bound(&self) -> u64 {
135        self.inner.lower_bound.load(SeqCst)
136    }
137
138    /// Collect fee history for the given range (inclusive `start_block..=end_block`).
139    ///
140    /// This function retrieves fee history entries from the cache for the specified range.
141    /// If the requested range (`start_block` to `end_block`) is within the cache bounds,
142    /// it returns the corresponding entries.
143    /// Otherwise it returns None.
144    pub async fn get_history(
145        &self,
146        start_block: u64,
147        end_block: u64,
148    ) -> Option<Vec<Arc<FeeHistoryEntry<H>>>> {
149        if end_block < start_block {
150            // invalid range, return None
151            return None
152        }
153        let lower_bound = self.lower_bound();
154        let upper_bound = self.upper_bound();
155        if start_block >= lower_bound && end_block <= upper_bound {
156            let entries = self.inner.entries.read().await;
157            let result = entries
158                .range(start_block..=end_block)
159                .map(|(_, fee_entry)| fee_entry.clone())
160                .collect::<Vec<_>>();
161
162            if result.is_empty() {
163                return None
164            }
165
166            Some(result)
167        } else {
168            None
169        }
170    }
171
172    /// Generates predefined set of percentiles
173    ///
174    /// This returns 100 * resolution points
175    pub fn predefined_percentiles(&self) -> Vec<f64> {
176        let res = self.resolution() as f64;
177        (0..=100 * self.resolution()).map(|p| p as f64 / res).collect()
178    }
179}
180
181/// Settings for the [`FeeHistoryCache`].
182#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
183#[serde(rename_all = "camelCase")]
184pub struct FeeHistoryCacheConfig {
185    /// Max number of blocks in cache.
186    ///
187    /// Default is [`MAX_HEADER_HISTORY`] plus some change to also serve slightly older blocks from
188    /// cache, since `fee_history` supports the entire range
189    pub max_blocks: u64,
190    /// Percentile approximation resolution
191    ///
192    /// Default is 4 which means 0.25
193    pub resolution: u64,
194}
195
196impl Default for FeeHistoryCacheConfig {
197    fn default() -> Self {
198        Self { max_blocks: MAX_HEADER_HISTORY + 100, resolution: 4 }
199    }
200}
201
202/// Container type for shared state in [`FeeHistoryCache`]
203#[derive(Debug)]
204struct FeeHistoryCacheInner<H> {
205    /// Stores the lower bound of the cache
206    lower_bound: AtomicU64,
207    /// Stores the upper bound of the cache
208    upper_bound: AtomicU64,
209    /// Config for `FeeHistoryCache`, consists of resolution for percentile approximation
210    /// and max number of blocks
211    config: FeeHistoryCacheConfig,
212    /// Stores the entries of the cache
213    entries: tokio::sync::RwLock<BTreeMap<u64, Arc<FeeHistoryEntry<H>>>>,
214}
215
216/// Awaits for new chain events and directly inserts them into the cache so they're available
217/// immediately before they need to be fetched from disk.
218pub async fn fee_history_cache_new_blocks_task<St, Provider, N>(
219    fee_history_cache: FeeHistoryCache<N::BlockHeader>,
220    mut events: St,
221    provider: Provider,
222    cache: EthStateCache<N>,
223) where
224    St: Stream<Item = CanonStateNotification<N>> + Unpin + 'static,
225    Provider:
226        BlockReaderIdExt<Block = N::Block, Receipt = N::Receipt> + ChainSpecProvider + 'static,
227    N: NodePrimitives,
228    N::BlockHeader: BlockHeader + Clone,
229{
230    // We're listening for new blocks emitted when the node is in live sync.
231    // If the node transitions to stage sync, we need to fetch the missing blocks
232    let mut missing_blocks = VecDeque::new();
233    let mut fetch_missing_block = Fuse::terminated();
234
235    loop {
236        if fetch_missing_block.is_terminated() &&
237            let Some(block_number) = missing_blocks.pop_front()
238        {
239            trace!(target: "rpc::fee", ?block_number, "Fetching missing block for fee history cache");
240            if let Ok(Some(hash)) = provider.block_hash(block_number) {
241                // fetch missing block
242                fetch_missing_block = cache.get_block_and_receipts(hash).boxed().fuse();
243            }
244        }
245
246        let chain_spec = provider.chain_spec();
247
248        tokio::select! {
249            res = &mut fetch_missing_block =>  {
250                if let Ok(res) = res {
251                    let res = res.as_ref()
252                        .map(|(b, r)| (b.sealed_block(), r.as_slice()));
253                    fee_history_cache.insert_blocks(res, &chain_spec).await;
254                }
255            }
256            event = events.next() =>  {
257                let Some(event) = event else {
258                     // the stream ended, we are done
259                    break
260                };
261
262                let committed = event.committed();
263                let blocks_and_receipts = committed
264                    .blocks_and_receipts()
265                    .map(|(block, receipts)| {
266                        (block.sealed_block(), receipts.as_slice())
267                    });
268                fee_history_cache.insert_blocks(blocks_and_receipts, &chain_spec).await;
269
270                // keep track of missing blocks
271                missing_blocks = fee_history_cache.missing_consecutive_blocks().await;
272            }
273        }
274    }
275}
276
277/// Calculates reward percentiles for transactions in a block.
278///
279/// The results are returned as a vector of `u128` values.
280pub fn calculate_reward_percentiles_for_block<T, R>(
281    percentiles: &[f64],
282    base_fee_per_gas: u64,
283    transactions: &[T],
284    receipts: &[R],
285) -> Result<Vec<u128>, EthApiError>
286where
287    T: Transaction,
288    R: TxReceipt,
289{
290    let mut transactions = transactions
291        .iter()
292        .zip(receipts)
293        .scan(0, |previous_gas, (tx, receipt)| {
294            // Convert the cumulative gas used in the receipts
295            // to the gas usage by the transaction
296            //
297            // While we will sum up the gas again later, it is worth
298            // noting that the order of the transactions will be different,
299            // so the sum will also be different for each receipt.
300            let gas_used = receipt.cumulative_gas_used() - *previous_gas;
301            *previous_gas = receipt.cumulative_gas_used();
302
303            Some(TxGasAndReward {
304                gas_used,
305                reward: tx.effective_tip_per_gas(base_fee_per_gas).unwrap_or_default(),
306            })
307        })
308        .collect::<Vec<_>>();
309
310    // EIP-8037 can make header gas used exceed the sum of per-transaction receipt gas.
311    let total_receipt_gas_used =
312        receipts.last().map(|receipt| receipt.cumulative_gas_used()).unwrap_or_default();
313
314    // Sort the transactions by their rewards in ascending order
315    transactions.sort_unstable_by_key(|tx| tx.reward);
316
317    // Find the transaction that corresponds to the given percentile
318    //
319    // We use a `tx_index` here that is shared across all percentiles, since we know
320    // the percentiles are monotonically increasing.
321    let mut tx_index = 0;
322    let mut cumulative_gas_used = transactions.first().map(|tx| tx.gas_used).unwrap_or_default();
323    let mut rewards_in_block = Vec::with_capacity(percentiles.len());
324    for percentile in percentiles {
325        // Empty blocks should return in a zero row
326        if transactions.is_empty() {
327            rewards_in_block.push(0);
328            continue
329        }
330
331        let threshold = (total_receipt_gas_used as f64 * percentile / 100.) as u64;
332        while cumulative_gas_used < threshold && tx_index < transactions.len() - 1 {
333            tx_index += 1;
334            cumulative_gas_used += transactions[tx_index].gas_used;
335        }
336        rewards_in_block.push(transactions[tx_index].reward);
337    }
338
339    Ok(rewards_in_block)
340}
341
342/// A cached entry for a block's fee history.
343#[derive(Debug, Clone)]
344pub struct FeeHistoryEntry<H = Header> {
345    /// The full block header.
346    pub header: H,
347    /// Gas used ratio this block.
348    pub gas_used_ratio: f64,
349    /// The base per blob gas for EIP-4844.
350    /// For pre EIP-4844 equals to zero.
351    pub base_fee_per_blob_gas: Option<u128>,
352    /// Blob gas used ratio for this block.
353    ///
354    /// Calculated as the ratio of blob gas used and the available blob data gas per block.
355    /// Will be zero if no blob gas was used or pre EIP-4844.
356    pub blob_gas_used_ratio: f64,
357    /// Approximated rewards for the configured percentiles.
358    pub rewards: Vec<u128>,
359    /// Blob parameters for this block.
360    pub blob_params: Option<BlobParams>,
361}
362
363impl<H> FeeHistoryEntry<H>
364where
365    H: BlockHeader + Clone,
366{
367    /// Creates a new entry from a sealed block.
368    ///
369    /// Note: This does not calculate the rewards for the block.
370    pub fn new<B>(block: &SealedBlock<B>, blob_params: Option<BlobParams>) -> Self
371    where
372        B: Block<Header = H>,
373    {
374        let header = block.header();
375        Self {
376            header: block.header().clone(),
377            gas_used_ratio: header.gas_used() as f64 / header.gas_limit() as f64,
378            base_fee_per_blob_gas: header
379                .excess_blob_gas()
380                .and_then(|excess_blob_gas| Some(blob_params?.calc_blob_fee(excess_blob_gas))),
381            blob_gas_used_ratio: checked_blob_gas_used_ratio(
382                block.body().blob_gas_used(),
383                blob_params
384                    .as_ref()
385                    .map(|params| params.max_blob_gas_per_block())
386                    .unwrap_or(alloy_eips::eip4844::MAX_DATA_GAS_PER_BLOCK_DENCUN),
387            ),
388            rewards: Vec::new(),
389            blob_params,
390        }
391    }
392
393    /// Returns the blob fee for the next block according to the EIP-4844 spec.
394    ///
395    /// Returns `None` if `excess_blob_gas` is None.
396    ///
397    /// See also [`Self::next_block_excess_blob_gas`]
398    pub fn next_block_blob_fee(&self) -> Option<u128> {
399        self.header.maybe_next_block_blob_fee(self.blob_params)
400    }
401
402    /// Calculate excess blob gas for the next block according to the EIP-4844 spec.
403    ///
404    /// Returns a `None` if no excess blob gas is set, no EIP-4844 support
405    pub fn next_block_excess_blob_gas(&self) -> Option<u64> {
406        self.header.maybe_next_block_excess_blob_gas(self.blob_params)
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use alloy_consensus::{TxEip1559, TxType};
414    use alloy_primitives::Signature;
415    use reth_ethereum_primitives::{Receipt, Transaction as EthTransaction, TransactionSigned};
416
417    #[test]
418    fn reward_percentiles_use_receipt_gas_weight_for_eip8037() {
419        const BASE_FEE: u64 = 1;
420        const LOW_GAS: u64 = 100_000;
421        const HIGH_GAS: u64 = 21_000;
422
423        let low_tip = 1;
424        let high_tip = 100_000_000_000;
425        let transactions =
426            [eip1559_transaction(low_tip, BASE_FEE), eip1559_transaction(high_tip, BASE_FEE)];
427        let receipts = [receipt(LOW_GAS), receipt(LOW_GAS + HIGH_GAS)];
428
429        let rewards =
430            calculate_reward_percentiles_for_block(&[50.0], BASE_FEE, &transactions, &receipts)
431                .unwrap();
432
433        assert_eq!(rewards, vec![low_tip]);
434    }
435
436    fn eip1559_transaction(tip: u128, base_fee: u64) -> TransactionSigned {
437        TransactionSigned::new_unhashed(
438            EthTransaction::Eip1559(TxEip1559 {
439                max_priority_fee_per_gas: tip,
440                max_fee_per_gas: tip + base_fee as u128,
441                ..Default::default()
442            }),
443            Signature::test_signature(),
444        )
445    }
446
447    fn receipt(cumulative_gas_used: u64) -> Receipt {
448        Receipt { tx_type: TxType::Eip1559, success: true, cumulative_gas_used, logs: Vec::new() }
449    }
450}