reth_rpc_eth_types/
gas_oracle.rs

1//! An implementation of the eth gas price oracle, used for providing gas price estimates based on
2//! previous blocks.
3
4use super::{EthApiError, EthResult, EthStateCache, RpcInvalidTransactionError};
5use alloy_consensus::{constants::GWEI_TO_WEI, BlockHeader, Transaction, TxReceipt};
6use alloy_eips::BlockNumberOrTag;
7use alloy_primitives::{B256, U256};
8use alloy_rpc_types_eth::BlockId;
9use derive_more::{Deref, DerefMut, From, Into};
10use itertools::Itertools;
11use reth_rpc_server_types::{
12    constants,
13    constants::gas_oracle::{
14        DEFAULT_GAS_PRICE_BLOCKS, DEFAULT_GAS_PRICE_PERCENTILE, DEFAULT_IGNORE_GAS_PRICE,
15        DEFAULT_MAX_GAS_PRICE, MAX_HEADER_HISTORY, MAX_REWARD_PERCENTILE_COUNT, SAMPLE_NUMBER,
16    },
17};
18use reth_storage_api::{BlockReaderIdExt, NodePrimitivesProvider};
19use schnellru::{ByLength, LruMap};
20use serde::{Deserialize, Serialize};
21use std::fmt::{self, Debug, Formatter};
22use tokio::sync::Mutex;
23use tracing::warn;
24
25/// The default gas limit for `eth_call` and adjacent calls. See
26/// [`RPC_DEFAULT_GAS_CAP`](constants::gas_oracle::RPC_DEFAULT_GAS_CAP).
27pub const RPC_DEFAULT_GAS_CAP: GasCap = GasCap(constants::gas_oracle::RPC_DEFAULT_GAS_CAP);
28
29/// Settings for the [`GasPriceOracle`]
30#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
31#[serde(rename_all = "camelCase")]
32pub struct GasPriceOracleConfig {
33    /// The number of populated blocks to produce the gas price estimate
34    pub blocks: u32,
35
36    /// The percentile of gas prices to use for the estimate
37    pub percentile: u32,
38
39    /// The maximum number of headers to keep in the cache
40    pub max_header_history: u64,
41
42    /// The maximum number of blocks for estimating gas price
43    pub max_block_history: u64,
44
45    /// The maximum number for reward percentiles.
46    ///
47    /// This effectively limits how many transactions and receipts are fetched to compute the
48    /// reward percentile.
49    pub max_reward_percentile_count: u64,
50
51    /// The default gas price to use if there are no blocks to use
52    pub default_suggested_fee: Option<U256>,
53
54    /// The maximum gas price to use for the estimate
55    pub max_price: Option<U256>,
56
57    /// The minimum gas price, under which the sample will be ignored
58    pub ignore_price: Option<U256>,
59}
60
61impl Default for GasPriceOracleConfig {
62    fn default() -> Self {
63        Self {
64            blocks: DEFAULT_GAS_PRICE_BLOCKS,
65            percentile: DEFAULT_GAS_PRICE_PERCENTILE,
66            max_header_history: MAX_HEADER_HISTORY,
67            max_block_history: MAX_HEADER_HISTORY,
68            max_reward_percentile_count: MAX_REWARD_PERCENTILE_COUNT,
69            default_suggested_fee: None,
70            max_price: Some(DEFAULT_MAX_GAS_PRICE),
71            ignore_price: Some(DEFAULT_IGNORE_GAS_PRICE),
72        }
73    }
74}
75
76/// Calculates a gas price depending on recent blocks.
77#[derive(Debug)]
78pub struct GasPriceOracle<Provider>
79where
80    Provider: NodePrimitivesProvider,
81{
82    /// The type used to subscribe to block events and get block info
83    provider: Provider,
84    /// The cache for blocks
85    cache: EthStateCache<Provider::Primitives>,
86    /// The config for the oracle
87    oracle_config: GasPriceOracleConfig,
88    /// The price under which the sample will be ignored.
89    ignore_price: Option<u128>,
90    /// Stores the latest calculated price and its block hash and Cache stores the lowest effective
91    /// tip values of recent blocks
92    inner: Mutex<GasPriceOracleInner>,
93}
94
95impl<Provider> GasPriceOracle<Provider>
96where
97    Provider: BlockReaderIdExt + NodePrimitivesProvider,
98{
99    /// Creates and returns the [`GasPriceOracle`].
100    pub fn new(
101        provider: Provider,
102        mut oracle_config: GasPriceOracleConfig,
103        cache: EthStateCache<Provider::Primitives>,
104    ) -> Self {
105        // sanitize the percentile to be less than 100
106        if oracle_config.percentile > 100 {
107            warn!(prev_percentile = ?oracle_config.percentile, "Invalid configured gas price percentile, assuming 100.");
108            oracle_config.percentile = 100;
109        }
110        let ignore_price = oracle_config.ignore_price.map(|price| price.saturating_to());
111
112        // this is the number of blocks that we will cache the values for
113        let cached_values = (oracle_config.blocks * 5).max(oracle_config.max_block_history as u32);
114        let inner = Mutex::new(GasPriceOracleInner {
115            last_price: GasPriceOracleResult {
116                block_hash: B256::ZERO,
117                price: oracle_config
118                    .default_suggested_fee
119                    .unwrap_or_else(|| GasPriceOracleResult::default().price),
120            },
121            lowest_effective_tip_cache: EffectiveTipLruCache(LruMap::new(ByLength::new(
122                cached_values,
123            ))),
124        });
125
126        Self { provider, oracle_config, cache, ignore_price, inner }
127    }
128
129    /// Returns the configuration of the gas price oracle.
130    pub const fn config(&self) -> &GasPriceOracleConfig {
131        &self.oracle_config
132    }
133
134    /// Suggests a gas price estimate based on recent blocks, using the configured percentile.
135    pub async fn suggest_tip_cap(&self) -> EthResult<U256> {
136        let header = self
137            .provider
138            .sealed_header_by_number_or_tag(BlockNumberOrTag::Latest)?
139            .ok_or(EthApiError::HeaderNotFound(BlockId::latest()))?;
140
141        let mut inner = self.inner.lock().await;
142
143        // if we have stored a last price, then we check whether or not it was for the same head
144        if inner.last_price.block_hash == header.hash() {
145            return Ok(inner.last_price.price)
146        }
147
148        // if all responses are empty, then we can return a maximum of 2*check_block blocks' worth
149        // of prices
150        //
151        // we only return more than check_block blocks' worth of prices if one or more return empty
152        // transactions
153        let mut current_hash = header.hash();
154        let mut results = Vec::new();
155        let mut populated_blocks = 0;
156
157        // we only check a maximum of 2 * max_block_history, or the number of blocks in the chain
158        let max_blocks = header.number().min(self.oracle_config.max_block_history * 2);
159
160        for _ in 0..max_blocks {
161            // Check if current hash is in cache
162            let (parent_hash, block_values) =
163                if let Some(vals) = inner.lowest_effective_tip_cache.get(&current_hash) {
164                    vals.to_owned()
165                } else {
166                    // Otherwise we fetch it using get_block_values
167                    let (parent_hash, block_values) = self
168                        .get_block_values(current_hash, SAMPLE_NUMBER)
169                        .await?
170                        .ok_or(EthApiError::HeaderNotFound(current_hash.into()))?;
171                    inner
172                        .lowest_effective_tip_cache
173                        .insert(current_hash, (parent_hash, block_values.clone()));
174                    (parent_hash, block_values)
175                };
176
177            if block_values.is_empty() {
178                results.push(U256::from(inner.last_price.price));
179            } else {
180                results.extend(block_values);
181                populated_blocks += 1;
182            }
183
184            // break when we have enough populated blocks
185            if populated_blocks >= self.oracle_config.blocks {
186                break
187            }
188
189            current_hash = parent_hash;
190        }
191
192        // sort results then take the configured percentile result
193        let mut price = if results.is_empty() {
194            inner.last_price.price
195        } else {
196            results.sort_unstable();
197            *results.get((results.len() - 1) * self.oracle_config.percentile as usize / 100).expect(
198                "gas price index is a percent of nonzero array length, so a value always exists",
199            )
200        };
201
202        // constrain to the max price
203        if let Some(max_price) = self.oracle_config.max_price &&
204            price > max_price
205        {
206            price = max_price;
207        }
208
209        inner.last_price = GasPriceOracleResult { block_hash: header.hash(), price };
210
211        Ok(price)
212    }
213
214    /// Get the `limit` lowest effective tip values for the given block. If the oracle has a
215    /// configured `ignore_price` threshold, then tip values under that threshold will be ignored
216    /// before returning a result.
217    ///
218    /// If the block cannot be found, then this will return `None`.
219    ///
220    /// This method also returns the parent hash for the given block.
221    async fn get_block_values(
222        &self,
223        block_hash: B256,
224        limit: usize,
225    ) -> EthResult<Option<(B256, Vec<U256>)>> {
226        // check the cache (this will hit the disk if the block is not cached)
227        let Some(block) = self.cache.get_recovered_block(block_hash).await? else {
228            return Ok(None)
229        };
230
231        let base_fee_per_gas = block.base_fee_per_gas();
232        let parent_hash = block.parent_hash();
233
234        // sort the functions by ascending effective tip first
235        let sorted_transactions = block.transactions_recovered().sorted_by_cached_key(|tx| {
236            if let Some(base_fee) = base_fee_per_gas {
237                (*tx).effective_tip_per_gas(base_fee)
238            } else {
239                Some((*tx).priority_fee_or_price())
240            }
241        });
242
243        let mut prices = Vec::with_capacity(limit);
244
245        for tx in sorted_transactions {
246            let effective_tip = if let Some(base_fee) = base_fee_per_gas {
247                tx.effective_tip_per_gas(base_fee)
248            } else {
249                Some(tx.priority_fee_or_price())
250            };
251
252            // ignore transactions with a tip under the configured threshold
253            if let Some(ignore_under) = self.ignore_price &&
254                effective_tip < Some(ignore_under)
255            {
256                continue
257            }
258
259            // check if the sender was the coinbase, if so, ignore
260            if tx.signer() == block.beneficiary() {
261                continue
262            }
263
264            // a `None` effective_gas_tip represents a transaction where the max_fee_per_gas is
265            // less than the base fee which would be invalid
266            prices.push(U256::from(effective_tip.ok_or(RpcInvalidTransactionError::FeeCapTooLow)?));
267
268            // we have enough entries
269            if prices.len() >= limit {
270                break
271            }
272        }
273
274        Ok(Some((parent_hash, prices)))
275    }
276
277    /// Suggests a max priority fee value using a simplified and more predictable algorithm
278    /// appropriate for chains like Optimism with a single known block builder.
279    ///
280    /// It returns either:
281    /// - The minimum suggested priority fee when blocks have capacity
282    /// - 10% above the median effective priority fee from the last block when at capacity
283    ///
284    /// A block is considered at capacity if its total gas used plus the maximum single transaction
285    /// gas would exceed the block's gas limit.
286    pub async fn op_suggest_tip_cap(&self, min_suggested_priority_fee: U256) -> EthResult<U256> {
287        let header = self
288            .provider
289            .sealed_header_by_number_or_tag(BlockNumberOrTag::Latest)?
290            .ok_or(EthApiError::HeaderNotFound(BlockId::latest()))?;
291
292        let mut inner = self.inner.lock().await;
293
294        // if we have stored a last price, then we check whether or not it was for the same head
295        if inner.last_price.block_hash == header.hash() {
296            return Ok(inner.last_price.price);
297        }
298
299        let mut suggestion = min_suggested_priority_fee;
300
301        // find the maximum gas used by any of the transactions in the block to use as the
302        // capacity margin for the block, if no receipts are found return the
303        // suggested_min_priority_fee
304        let receipts = self
305            .cache
306            .get_receipts(header.hash())
307            .await?
308            .ok_or(EthApiError::ReceiptsNotFound(BlockId::latest()))?;
309
310        let mut max_tx_gas_used = 0u64;
311        let mut last_cumulative_gas = 0;
312        for receipt in receipts.as_ref() {
313            let cumulative_gas = receipt.cumulative_gas_used();
314            // get the gas used by each transaction in the block, by subtracting the
315            // cumulative gas used of the previous transaction from the cumulative gas used of
316            // the current transaction. This is because there is no gas_used()
317            // method on the Receipt trait.
318            let gas_used = cumulative_gas - last_cumulative_gas;
319            max_tx_gas_used = max_tx_gas_used.max(gas_used);
320            last_cumulative_gas = cumulative_gas;
321        }
322
323        // if the block is at capacity, the suggestion must be increased
324        if header.gas_used() + max_tx_gas_used > header.gas_limit() {
325            let Some(median_tip) = self.get_block_median_tip(header.hash()).await? else {
326                return Ok(suggestion);
327            };
328
329            let new_suggestion = median_tip + median_tip / U256::from(10);
330
331            if new_suggestion > suggestion {
332                suggestion = new_suggestion;
333            }
334        }
335
336        // constrain to the max price
337        if let Some(max_price) = self.oracle_config.max_price &&
338            suggestion > max_price
339        {
340            suggestion = max_price;
341        }
342
343        inner.last_price = GasPriceOracleResult { block_hash: header.hash(), price: suggestion };
344
345        Ok(suggestion)
346    }
347
348    /// Get the median tip value for the given block. This is useful for determining
349    /// tips when a block is at capacity.
350    ///
351    /// If the block cannot be found or has no transactions, this will return `None`.
352    pub async fn get_block_median_tip(&self, block_hash: B256) -> EthResult<Option<U256>> {
353        // check the cache (this will hit the disk if the block is not cached)
354        let Some(block) = self.cache.get_recovered_block(block_hash).await? else {
355            return Ok(None)
356        };
357
358        let base_fee_per_gas = block.base_fee_per_gas();
359
360        // Filter, sort and collect the prices
361        let prices = block
362            .transactions_recovered()
363            .filter_map(|tx| {
364                if let Some(base_fee) = base_fee_per_gas {
365                    (*tx).effective_tip_per_gas(base_fee)
366                } else {
367                    Some((*tx).priority_fee_or_price())
368                }
369            })
370            .sorted()
371            .collect::<Vec<_>>();
372
373        let median = if prices.is_empty() {
374            // if there are no prices, return `None`
375            None
376        } else if prices.len() % 2 == 1 {
377            Some(U256::from(prices[prices.len() / 2]))
378        } else {
379            Some(U256::from((prices[prices.len() / 2 - 1] + prices[prices.len() / 2]) / 2))
380        };
381
382        Ok(median)
383    }
384}
385/// Container type for mutable inner state of the [`GasPriceOracle`]
386#[derive(Debug)]
387struct GasPriceOracleInner {
388    last_price: GasPriceOracleResult,
389    lowest_effective_tip_cache: EffectiveTipLruCache,
390}
391
392/// Wrapper struct for `LruMap`
393#[derive(Deref, DerefMut)]
394pub struct EffectiveTipLruCache(LruMap<B256, (B256, Vec<U256>), ByLength>);
395
396impl Debug for EffectiveTipLruCache {
397    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
398        f.debug_struct("EffectiveTipLruCache")
399            .field("cache_length", &self.len())
400            .field("cache_memory_usage", &self.memory_usage())
401            .finish()
402    }
403}
404
405/// Stores the last result that the oracle returned
406#[derive(Debug, Clone)]
407pub struct GasPriceOracleResult {
408    /// The block hash that the oracle used to calculate the price
409    pub block_hash: B256,
410    /// The price that the oracle calculated
411    pub price: U256,
412}
413
414impl Default for GasPriceOracleResult {
415    fn default() -> Self {
416        Self { block_hash: B256::ZERO, price: U256::from(GWEI_TO_WEI) }
417    }
418}
419
420/// The wrapper type for gas limit
421#[derive(Debug, Clone, Copy, From, Into)]
422pub struct GasCap(pub u64);
423
424impl Default for GasCap {
425    fn default() -> Self {
426        RPC_DEFAULT_GAS_CAP
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    #[test]
435    fn max_price_sanity() {
436        assert_eq!(DEFAULT_MAX_GAS_PRICE, U256::from(500_000_000_000u64));
437        assert_eq!(DEFAULT_MAX_GAS_PRICE, U256::from(500 * GWEI_TO_WEI))
438    }
439
440    #[test]
441    fn ignore_price_sanity() {
442        assert_eq!(DEFAULT_IGNORE_GAS_PRICE, U256::from(2u64));
443    }
444}