ethers_middleware/gas_oracle/
median.rs

1use super::{GasOracle, GasOracleError, Result};
2use async_trait::async_trait;
3use ethers_core::types::U256;
4use futures_util::future::join_all;
5use std::{fmt::Debug, future::Future};
6use tracing::warn;
7
8#[derive(Default, Debug)]
9pub struct Median {
10    oracles: Vec<(f32, Box<dyn GasOracle>)>,
11}
12
13/// Computes the median gas price from a selection of oracles.
14///
15/// Don't forget to set a timeout on the source oracles. By default
16/// the reqwest based oracles will never time out.
17impl Median {
18    pub fn new() -> Self {
19        Self::default()
20    }
21
22    pub fn add<T: 'static + GasOracle>(&mut self, oracle: T) {
23        self.add_weighted(1.0, oracle)
24    }
25
26    pub fn add_weighted<T: 'static + GasOracle>(&mut self, weight: f32, oracle: T) {
27        assert!(weight > 0.0);
28        self.oracles.push((weight, Box::new(oracle)));
29    }
30
31    pub async fn query_all<'a, Fn, Fut, O>(&'a self, mut f: Fn) -> Result<Vec<(f32, O)>>
32    where
33        Fn: FnMut(&'a dyn GasOracle) -> Fut,
34        Fut: Future<Output = Result<O>>,
35    {
36        // Process the oracles in parallel
37        let futures = self.oracles.iter().map(|(_, oracle)| f(oracle.as_ref()));
38        let results = join_all(futures).await;
39
40        // Filter out any errors
41        let values =
42            self.oracles.iter().zip(results).filter_map(
43                |((weight, oracle), result)| match result {
44                    Ok(value) => Some((*weight, value)),
45                    Err(err) => {
46                        warn!("Failed to fetch gas price from {:?}: {}", oracle, err);
47                        None
48                    }
49                },
50            );
51        let values = values.collect::<Vec<_>>();
52        if values.is_empty() {
53            return Err(GasOracleError::NoValues)
54        }
55        Ok(values)
56    }
57}
58
59#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
60#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
61impl GasOracle for Median {
62    async fn fetch(&self) -> Result<U256> {
63        let mut values = self.query_all(|oracle| oracle.fetch()).await?;
64        // `query_all` guarantees `values` is not empty
65        Ok(*weighted_fractile_by_key(0.5, &mut values, |fee| fee).unwrap())
66    }
67
68    async fn estimate_eip1559_fees(&self) -> Result<(U256, U256)> {
69        let mut values = self.query_all(|oracle| oracle.estimate_eip1559_fees()).await?;
70        // `query_all` guarantees `values` is not empty
71        Ok((
72            weighted_fractile_by_key(0.5, &mut values, |(max_fee, _)| max_fee).unwrap().0,
73            weighted_fractile_by_key(0.5, &mut values, |(_, priority_fee)| priority_fee).unwrap().1,
74        ))
75    }
76}
77
78/// Weighted fractile by key.
79///
80/// Sort the values in place by key and return the weighted fractile value such that `fractile`
81/// fraction of the values by weight are less than or equal to the value.
82///
83/// Returns `None` if the values are empty.
84///
85/// Note: it doesn't handle NaNs or other special float values.
86///
87/// See: <https://en.wikipedia.org/wiki/Percentile#The_weighted_percentile_method>
88///
89/// # Panics
90///
91/// Panics if `fractile` is not in the range `0.0..=1.0`.
92fn weighted_fractile_by_key<'a, T, F, K>(
93    fractile: f32,
94    values: &'a mut [(f32, T)],
95    mut key: F,
96) -> Option<&'a T>
97where
98    F: for<'b> FnMut(&'b T) -> &'b K,
99    K: Ord,
100{
101    assert!((0.0..=1.0).contains(&fractile));
102    if values.is_empty() {
103        return None
104    }
105    let weight_rank = fractile * values.iter().map(|(weight, _)| *weight).sum::<f32>();
106    values.sort_unstable_by(|a, b| key(&a.1).cmp(key(&b.1)));
107    let mut cumulative_weight = 0.0_f32;
108    for (weight, value) in values.iter() {
109        cumulative_weight += *weight;
110        if cumulative_weight >= weight_rank {
111            return Some(value)
112        }
113    }
114    // By the last element, cumulative_weight == weight_rank and we should have
115    // returned already. Assume there is a slight rounding error causing
116    // cumulative_weight to be slightly less than expected. In this case the last
117    // element is appropriate. (This is not exactly right, since the last
118    // elements may have zero weight.)
119    // `values` is not empty.
120    Some(&values.last().unwrap().1)
121}