Skip to main content

foundry_common/provider/
fee.rs

1//! EIP-1559 fee estimation with configurable presets.
2//!
3//! Estimates `maxFeePerGas` and `maxPriorityFeePerGas` from `eth_feeHistory`,
4//! where the chosen [`Eip1559FeeEstimatePreset`] selects the reward percentile
5//! used for the priority fee and the multiplier applied to the base fee.
6
7use alloy_consensus::BlockHeader;
8use alloy_eips::BlockNumberOrTag;
9use alloy_network::{BlockResponse, Network};
10use alloy_provider::{Provider, utils::Eip1559Estimation};
11use eyre::{Result, WrapErr};
12use foundry_config::Eip1559FeeEstimatePreset;
13
14/// The number of past blocks sampled from `eth_feeHistory` for fee estimation.
15const FEE_HISTORY_BLOCKS: u64 = 10;
16
17/// The minimum priority fee to provide, in wei.
18const MIN_PRIORITY_FEE: u128 = 1;
19
20/// Gas used ratio below which a block is treated as near-empty and its reward is
21/// dropped from sampling, so one-off tips in otherwise idle blocks do not
22/// dominate the cross-block median on low-traffic chains. Local heuristic: there
23/// is no protocol-defined utilization threshold for priority-fee estimation.
24const MIN_GAS_USED_RATIO: f64 = 0.1;
25
26/// The resolved EIP-1559 fees together with the base fee they were derived from.
27///
28/// The base fee is surfaced so callers can present a fee breakdown (max fee /
29/// priority fee / base fee) instead of a single, easily-misread "gas price".
30#[derive(Clone, Copy, Debug)]
31pub struct ResolvedEip1559Fees {
32    /// `maxFeePerGas`.
33    pub max_fee_per_gas: u128,
34    /// `maxPriorityFeePerGas`.
35    pub max_priority_fee_per_gas: u128,
36    /// The base fee of the latest block the estimate was derived from.
37    pub base_fee_per_gas: u128,
38}
39
40impl ResolvedEip1559Fees {
41    /// Returns the max fee and priority fee as an [`Eip1559Estimation`], dropping
42    /// the base fee.
43    pub const fn estimation(&self) -> Eip1559Estimation {
44        Eip1559Estimation {
45            max_fee_per_gas: self.max_fee_per_gas,
46            max_priority_fee_per_gas: self.max_priority_fee_per_gas,
47        }
48    }
49}
50
51/// Estimates EIP-1559 fees for `provider` using the given `preset`.
52///
53/// `preset` controls the reward percentile sampled for the priority fee and the
54/// base-fee multiplier used to build `maxFeePerGas`.
55pub async fn estimate_eip1559_fees<P, N>(
56    provider: &P,
57    preset: Eip1559FeeEstimatePreset,
58) -> Result<ResolvedEip1559Fees>
59where
60    P: Provider<N>,
61    N: Network,
62{
63    let fee_history = provider
64        .get_fee_history(
65            FEE_HISTORY_BLOCKS,
66            BlockNumberOrTag::Latest,
67            &[preset.reward_percentile()],
68        )
69        .await
70        .wrap_err("Failed to fetch fee history for EIP-1559 estimation")?;
71
72    // Use the base fee of the latest mined block. If the fee history omits or
73    // zeroes it, read it from the latest block header; if that is also absent the
74    // chain does not support EIP-1559, so error instead of guessing.
75    let base_fee_per_gas = match fee_history.latest_block_base_fee() {
76        Some(base_fee) if base_fee != 0 => base_fee,
77        _ => provider
78            .get_block_by_number(BlockNumberOrTag::Latest)
79            .await
80            .wrap_err("Failed to fetch latest block for EIP-1559 base fee")?
81            .ok_or_else(|| eyre::eyre!("Latest block not found"))?
82            .header()
83            .as_ref()
84            .base_fee_per_gas()
85            .ok_or_else(|| {
86                eyre::eyre!(
87                    "Chain does not appear to support EIP-1559; try adding --legacy to your command."
88                )
89            })?
90            .into(),
91    };
92
93    let max_priority_fee_per_gas = estimate_priority_fee(
94        fee_history.reward.as_deref().unwrap_or_default(),
95        &fee_history.gas_used_ratio,
96    );
97
98    let (num, den) = preset.base_fee_multiplier();
99    let max_fee_per_gas = base_fee_per_gas
100        .checked_mul(num)
101        .map_or(u128::MAX, |scaled| scaled / den)
102        .saturating_add(max_priority_fee_per_gas);
103
104    Ok(ResolvedEip1559Fees { max_fee_per_gas, max_priority_fee_per_gas, base_fee_per_gas })
105}
106
107/// Applies, in order, the optional `browser_suggested_tip`, `with_gas_price` and
108/// `priority_gas_price` overrides to estimated EIP-1559 fees.
109///
110/// `with_gas_price` overrides only `maxFeePerGas` and `priority_gas_price` only
111/// `maxPriorityFeePerGas`. Errors if the result has `maxPriorityFeePerGas` above
112/// `maxFeePerGas`.
113pub fn resolve_broadcast_eip1559_fees(
114    mut fees: ResolvedEip1559Fees,
115    with_gas_price: Option<u128>,
116    priority_gas_price: Option<u128>,
117    browser_suggested_tip: Option<u128>,
118) -> Result<ResolvedEip1559Fees> {
119    // Raise both caps by the same delta so `maxFeePerGas` keeps its buffer above
120    // the higher tip.
121    if let Some(suggested_tip) = browser_suggested_tip
122        && suggested_tip > fees.max_priority_fee_per_gas
123    {
124        let delta = suggested_tip - fees.max_priority_fee_per_gas;
125        fees.max_fee_per_gas = fees.max_fee_per_gas.saturating_add(delta);
126        fees.max_priority_fee_per_gas = suggested_tip;
127    }
128
129    if let Some(max_fee_per_gas) = with_gas_price {
130        fees.max_fee_per_gas = max_fee_per_gas;
131    }
132
133    if let Some(max_priority_fee_per_gas) = priority_gas_price {
134        fees.max_priority_fee_per_gas = max_priority_fee_per_gas;
135    }
136
137    if fees.max_priority_fee_per_gas > fees.max_fee_per_gas {
138        eyre::bail!(
139            "maxPriorityFeePerGas ({}) cannot be higher than maxFeePerGas ({})",
140            fees.max_priority_fee_per_gas,
141            fees.max_fee_per_gas,
142        );
143    }
144
145    Ok(fees)
146}
147
148/// Estimates the priority fee as the median of the non-zero per-block rewards,
149/// never returning less than [`MIN_PRIORITY_FEE`].
150///
151/// Rewards from blocks below [`MIN_GAS_USED_RATIO`] are dropped first so one-off
152/// tips in idle blocks do not dominate the median. If that empties the sample
153/// but at least half the blocks still carry a tip, the unfiltered median is used
154/// instead, so chronically light chains do not collapse to [`MIN_PRIORITY_FEE`].
155/// The filter is skipped when `gas_used_ratio` does not line up with `rewards`
156/// (e.g. `gasUsedRatio: null`, which alloy deserializes as an empty vec).
157fn estimate_priority_fee(rewards: &[Vec<u128>], gas_used_ratio: &[f64]) -> u128 {
158    let apply_filter = gas_used_ratio.len() == rewards.len();
159    let non_zero_rewards = |filtered: bool| {
160        rewards
161            .iter()
162            .enumerate()
163            .filter(|(i, _)| {
164                !filtered
165                    || (gas_used_ratio[*i].is_finite() && gas_used_ratio[*i] >= MIN_GAS_USED_RATIO)
166            })
167            .filter_map(|(_, reward)| reward.first().copied())
168            .filter(|reward| *reward > 0)
169            .collect::<Vec<_>>()
170    };
171
172    let mut rewards = non_zero_rewards(apply_filter);
173    // If every sampled block is near-empty but tipping is the norm across the
174    // sample (at least half the blocks carry a tip), fall back to the unfiltered
175    // non-zero median; a one-off tip on an idle chain (#13885) stays excluded.
176    if apply_filter && rewards.is_empty() {
177        let unfiltered = non_zero_rewards(false);
178        if unfiltered.len() * 2 >= gas_used_ratio.len() {
179            rewards = unfiltered;
180        }
181    }
182    if rewards.is_empty() {
183        return MIN_PRIORITY_FEE;
184    }
185
186    rewards.sort_unstable();
187
188    let n = rewards.len();
189    // `midpoint` avoids overflow when averaging the two middle values.
190    let median =
191        if n % 2 == 0 { rewards[n / 2 - 1].midpoint(rewards[n / 2]) } else { rewards[n / 2] };
192
193    std::cmp::max(median, MIN_PRIORITY_FEE)
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    /// A gas used ratio above [`MIN_GAS_USED_RATIO`].
201    const BUSY: f64 = 0.5;
202
203    #[test]
204    fn priority_fee_median_of_busy_blocks() {
205        // Empty rewards -> minimum priority fee.
206        assert_eq!(estimate_priority_fee(&[], &[]), MIN_PRIORITY_FEE);
207        assert_eq!(estimate_priority_fee(&[vec![0], vec![0]], &[BUSY, BUSY]), MIN_PRIORITY_FEE);
208
209        // Median of non-zero rewards from busy blocks.
210        assert_eq!(estimate_priority_fee(&[vec![1], vec![3], vec![5]], &[BUSY, BUSY, BUSY]), 3);
211        assert_eq!(estimate_priority_fee(&[vec![2], vec![4]], &[BUSY, BUSY]), 3);
212    }
213
214    #[test]
215    fn priority_fee_ignores_near_empty_blocks() {
216        // Rewards from blocks below MIN_GAS_USED_RATIO are dropped.
217        let rewards = vec![vec![0u128], vec![41_000_000_000_000u128], vec![0u128]];
218        let ratios = vec![0.0, 0.001, 0.0];
219        assert_eq!(estimate_priority_fee(&rewards, &ratios), MIN_PRIORITY_FEE);
220
221        // Only busy blocks are sampled -> median of [2, 3] = 2.
222        let rewards = vec![vec![500u128], vec![2u128], vec![800u128], vec![3u128], vec![999u128]];
223        let ratios = vec![0.01, BUSY, 0.03, 0.7, 0.02];
224        assert_eq!(estimate_priority_fee(&rewards, &ratios), 2);
225
226        // The threshold is inclusive.
227        assert_eq!(estimate_priority_fee(&[vec![10], vec![20]], &[0.1, 0.09]), 10);
228
229        // NaN/infinite ratios fail the filter; the fallback restores the median.
230        assert_eq!(estimate_priority_fee(&[vec![5], vec![6]], &[f64::NAN, f64::INFINITY]), 5);
231    }
232
233    #[test]
234    fn priority_fee_falls_back_when_tipping_is_the_norm() {
235        // Light chain, every block tipped: fallback keeps the median.
236        let rewards = vec![vec![1_000u128]; 10];
237        let ratios = vec![0.05; 10];
238        assert_eq!(estimate_priority_fee(&rewards, &ratios), 1_000);
239
240        // Exactly half the blocks tip -> `>=` is inclusive, fallback fires.
241        let rewards = vec![vec![0u128], vec![10u128], vec![0u128], vec![20u128]];
242        let ratios = vec![0.01; 4];
243        assert_eq!(estimate_priority_fee(&rewards, &ratios), 15);
244
245        // One-off tip on an idle chain (#13885): under half tip, no fallback.
246        let mut rewards = vec![vec![0u128]; 10];
247        rewards[3] = vec![41_000_000_000_000u128];
248        let ratios = vec![0.01; 10];
249        assert_eq!(estimate_priority_fee(&rewards, &ratios), MIN_PRIORITY_FEE);
250    }
251
252    #[test]
253    fn priority_fee_skips_filter_on_length_mismatch() {
254        // A mismatched `gas_used_ratio` (e.g. `gasUsedRatio: null`) disables the
255        // filter, so the unfiltered non-zero median is used.
256        assert_eq!(estimate_priority_fee(&[vec![7], vec![9], vec![11]], &[BUSY]), 9);
257        assert_eq!(estimate_priority_fee(&[vec![7]], &[BUSY, BUSY, BUSY]), 7);
258        assert_eq!(estimate_priority_fee(&[vec![2], vec![4]], &[]), 3);
259    }
260
261    /// Drives the public async estimator so `gas_used_ratio` wiring is covered.
262    #[tokio::test]
263    async fn estimate_filters_near_empty_block_outliers() {
264        use alloy_provider::{ProviderBuilder, mock::Asserter};
265        use alloy_rpc_types::FeeHistory;
266
267        let base = 20_000_000_000u128; // 20 gwei
268        let big = 41_000_000_000_000u128; // outlier tip in near-empty blocks
269        let fee_history = FeeHistory {
270            base_fee_per_gas: vec![base; 11],
271            gas_used_ratio: vec![0.001; 10],
272            base_fee_per_blob_gas: vec![1; 11],
273            blob_gas_used_ratio: vec![0.0; 10],
274            oldest_block: 1,
275            reward: Some(vec![
276                vec![0u128],
277                vec![big],
278                vec![0u128],
279                vec![big],
280                vec![0u128],
281                vec![big],
282                vec![0u128],
283                vec![0u128],
284                vec![0u128],
285                vec![0u128],
286            ]),
287        };
288
289        let asserter = Asserter::new();
290        asserter.push_success(&fee_history);
291        let provider = ProviderBuilder::new_with_network::<alloy_network::Ethereum>()
292            .connect_mocked_client(asserter);
293
294        let fees =
295            estimate_eip1559_fees(&provider, Eip1559FeeEstimatePreset::Market).await.unwrap();
296
297        // Near-empty outliers are dropped -> min priority, max_fee = 2 * base + min.
298        assert_eq!(fees.base_fee_per_gas, base);
299        assert_eq!(fees.max_priority_fee_per_gas, MIN_PRIORITY_FEE);
300        assert_eq!(fees.max_fee_per_gas, 40_000_000_001);
301    }
302
303    fn fees(max: u128, priority: u128) -> ResolvedEip1559Fees {
304        ResolvedEip1559Fees {
305            max_fee_per_gas: max,
306            max_priority_fee_per_gas: priority,
307            base_fee_per_gas: 100,
308        }
309    }
310
311    #[test]
312    fn resolve_overrides_each_field_independently() {
313        // No overrides: passthrough.
314        let r = resolve_broadcast_eip1559_fees(fees(300, 50), None, None, None).unwrap();
315        assert_eq!((r.max_fee_per_gas, r.max_priority_fee_per_gas), (300, 50));
316
317        // `--with-gas-price` overrides only max, `--priority-gas-price` only priority.
318        let r = resolve_broadcast_eip1559_fees(fees(300, 50), Some(500), None, None).unwrap();
319        assert_eq!((r.max_fee_per_gas, r.max_priority_fee_per_gas), (500, 50));
320        let r = resolve_broadcast_eip1559_fees(fees(300, 50), None, Some(80), None).unwrap();
321        assert_eq!((r.max_fee_per_gas, r.max_priority_fee_per_gas), (300, 80));
322
323        // Invalid when the resulting priority exceeds max.
324        let err = resolve_broadcast_eip1559_fees(fees(300, 50), None, Some(400), None).unwrap_err();
325        assert!(err.to_string().contains("cannot be higher than maxFeePerGas"));
326    }
327
328    #[test]
329    fn resolve_browser_tip_raises_both_caps_by_delta() {
330        // Higher tip raises priority to the tip and max by the same delta.
331        let r = resolve_broadcast_eip1559_fees(fees(300, 50), None, None, Some(120)).unwrap();
332        assert_eq!((r.max_fee_per_gas, r.max_priority_fee_per_gas), (370, 120)); // 300 + (120 - 50)
333
334        // Lower tip is ignored; max saturates instead of overflowing.
335        let r = resolve_broadcast_eip1559_fees(fees(300, 50), None, None, Some(10)).unwrap();
336        assert_eq!((r.max_fee_per_gas, r.max_priority_fee_per_gas), (300, 50));
337        let r = resolve_broadcast_eip1559_fees(fees(u128::MAX, 50), None, None, Some(120)).unwrap();
338        assert_eq!((r.max_fee_per_gas, r.max_priority_fee_per_gas), (u128::MAX, 120));
339    }
340
341    #[test]
342    fn market_preset_max_fee_formula() {
343        // Market preset: max_fee = base_fee * 2 + priority_fee.
344        let preset = Eip1559FeeEstimatePreset::Market;
345        let (num, den) = preset.base_fee_multiplier();
346        let base_fee = 2_000_000_000u128; // 2 gwei
347        let priority = estimate_priority_fee(&[vec![100], vec![300]], &[BUSY, BUSY]);
348        let max_fee = base_fee.saturating_mul(num) / den + priority;
349        assert_eq!(max_fee, base_fee * 2 + priority);
350    }
351}