Skip to main content

ethrex_rpc/eth/
gas_tip_estimator.rs

1use ethrex_common::{H256, U256, types::MIN_GAS_TIP};
2use ethrex_storage::Store;
3use tracing::error;
4
5use crate::utils::RpcErr;
6
7// TODO: Maybe these constants should be some kind of config.
8/// How many transactions to take as a price sample from a block.
9const TXS_SAMPLE_SIZE: usize = 3;
10/// How many blocks we'll go back to calculate the estimate.
11const BLOCK_RANGE_LOWER_BOUND_DEC: u64 = 20;
12
13#[derive(Debug, Clone)]
14/// Struct in charge of performing gas tip estimations & saving latest results for following estimations
15pub struct GasTipEstimator {
16    // The latest block hash for which the gas tip estimation was performed
17    pub last_hash: H256,
18    // The latest estimated gas tip
19    pub last_tip: u64,
20}
21
22impl GasTipEstimator {
23    // Creates a new GasTipEstimator with default tip
24    pub fn new() -> GasTipEstimator {
25        Self {
26            last_hash: H256::default(),
27            last_tip: MIN_GAS_TIP,
28        }
29    }
30
31    // The following comment is taken from the implementation of gas_price and is still valid, the logic was just moved here.
32
33    // Disclaimer:
34    // This estimation is somewhat based on how currently go-ethereum does it.
35    // Reference: https://github.com/ethereum/go-ethereum/blob/368e16f39d6c7e5cce72a92ec289adbfbaed4854/eth/gasprice/gasprice.go#L153
36    // Although it will (probably) not yield the same result.
37    // The idea here is to:
38    // - Take the last 20 blocks (100% arbitrary, this could be more or less blocks)
39    // - For each block, take the 3 txs with the lowest gas price (100% arbitrary)
40    // - Join every fetched tx into a single vec and sort it.
41    // - Return the one in the middle (what is also known as the 'median sample')
42    // The intuition here is that we're sampling already accepted transactions,
43    // fetched from recent blocks, so they should be real, representative values.
44    // This specific implementation probably is not the best way to do this
45    // but it works for now for a simple estimation, in the future
46    // we can look into more sophisticated estimation methods, if needed.
47    /// Estimate Gas Price based on already accepted transactions,
48    /// as per the spec, this will be returned in wei.
49    pub async fn estimate_gas_tip(&mut self, storage: &Store) -> Result<u64, RpcErr> {
50        let latest_block_number = storage.get_latest_block_number().await?;
51        let latest_block_hash = storage
52            .get_canonical_block_hash(latest_block_number)
53            .await?
54            .ok_or(RpcErr::Internal(format!(
55                "Latest Block {latest_block_number} not cannonical"
56            )))?;
57        // Check if we already estimated the gas tip for this block
58        if self.last_hash == latest_block_hash {
59            return Ok(self.last_tip);
60        }
61        let block_range_lower_bound =
62            latest_block_number.saturating_sub(BLOCK_RANGE_LOWER_BOUND_DEC);
63        // These are the blocks we'll use to estimate the price.
64        let block_range = block_range_lower_bound..=latest_block_number;
65        if block_range.is_empty() {
66            error!(
67                "Calculated block range from block {} \
68                up to block {} for gas price estimation is empty",
69                block_range_lower_bound, latest_block_number
70            );
71            return Err(RpcErr::Internal("Error calculating gas price".to_string()));
72        }
73        let mut results: Vec<U256> = vec![];
74        // TODO: Estimating gas price involves querying multiple blocks
75        // and doing some calculations with each of them, let's consider
76        // caching this result, also we can have a specific DB method
77        // that returns a block range to not query them one-by-one.
78        for block_num in block_range {
79            let Some(block_body) = storage.get_block_body(block_num).await? else {
80                error!(
81                    "Block body for block number {block_num} is missing but is below the latest known block!"
82                );
83                return Err(RpcErr::Internal(
84                    "Error calculating gas price: missing data".to_string(),
85                ));
86            };
87
88            let base_fee = storage
89                .get_block_header(block_num)
90                .ok()
91                .flatten()
92                .and_then(|header| header.base_fee_per_gas);
93
94            // Previously we took the gas_price, now we take the effective_gas_tip and add the base_fee in the RPC
95            // call if needed.
96            let mut gas_tip_samples = block_body
97                .transactions
98                .into_iter()
99                .filter_map(|tx| tx.effective_gas_tip(base_fee))
100                .collect::<Vec<U256>>();
101
102            gas_tip_samples.sort();
103            results.extend(gas_tip_samples.into_iter().take(TXS_SAMPLE_SIZE));
104        }
105        results.sort();
106
107        // If we cannot get a median sample due to blocks being empty, use the last calculated tip
108        let last_tip_u256 = U256::from(self.last_tip);
109        let estimated_tip = *results.get(results.len() / 2).unwrap_or(&last_tip_u256);
110        // Gas tips in practice always fit in u64; clamp to u64::MAX if somehow larger
111        let estimated_tip = u64::try_from(estimated_tip).unwrap_or(u64::MAX);
112
113        // Update last estimation results
114        self.last_hash = latest_block_hash;
115        self.last_tip = estimated_tip;
116
117        Ok(estimated_tip)
118    }
119}
120
121impl Default for GasTipEstimator {
122    fn default() -> Self {
123        Self::new()
124    }
125}
126
127// Tests for the estimate_gas_tip function.
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::test_utils::{
132        BASE_PRICE_IN_WEI, add_eip1559_tx_blocks, add_empty_blocks, add_legacy_tx_blocks,
133        add_mixed_tx_blocks, setup_store,
134    };
135
136    #[tokio::test]
137    async fn test_for_legacy_txs() {
138        let storage = setup_store().await;
139        add_legacy_tx_blocks(&storage, 20, 10).await;
140        let gas_tip = GasTipEstimator::new()
141            .estimate_gas_tip(&storage)
142            .await
143            .unwrap();
144        assert_eq!(gas_tip, BASE_PRICE_IN_WEI);
145    }
146
147    #[tokio::test]
148    async fn test_for_eip1559_txs() {
149        let storage = setup_store().await;
150        add_eip1559_tx_blocks(&storage, 20, 10).await;
151        let gas_tip = GasTipEstimator::new()
152            .estimate_gas_tip(&storage)
153            .await
154            .unwrap();
155        assert_eq!(gas_tip, BASE_PRICE_IN_WEI);
156    }
157
158    #[tokio::test]
159    async fn test_for_mixed_txs() {
160        let storage = setup_store().await;
161        add_mixed_tx_blocks(&storage, 20, 10).await;
162        let gas_tip = GasTipEstimator::new()
163            .estimate_gas_tip(&storage)
164            .await
165            .unwrap();
166        assert_eq!(gas_tip, BASE_PRICE_IN_WEI);
167    }
168
169    #[tokio::test]
170    async fn test_for_no_blocks() {
171        let storage = setup_store().await;
172        let gas_tip = GasTipEstimator::new()
173            .estimate_gas_tip(&storage)
174            .await
175            .unwrap();
176        assert_eq!(gas_tip, MIN_GAS_TIP);
177    }
178
179    #[tokio::test]
180    async fn test_for_empty_blocks() {
181        let storage = setup_store().await;
182        add_empty_blocks(&storage, 20).await;
183        let gas_tip = GasTipEstimator::new()
184            .estimate_gas_tip(&storage)
185            .await
186            .unwrap();
187        assert_eq!(gas_tip, MIN_GAS_TIP);
188    }
189}