Skip to main content

hypercall_api/
gas_provider.rs

1use alloy::{
2    consensus::BlockHeader,
3    eips::BlockNumberOrTag,
4    network::BlockResponse,
5    primitives::utils::{format_units, parse_units},
6    providers::{DynProvider, Provider, ProviderBuilder},
7};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct GasEstimateTier {
11    pub max_priority_fee_wei: u128,
12    pub max_fee_wei: u128,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct GasEstimateResult {
17    pub slow: GasEstimateTier,
18    pub medium: GasEstimateTier,
19    pub fast: GasEstimateTier,
20    pub super_fast: GasEstimateTier,
21}
22
23impl GasEstimateResult {
24    fn normalize_zero_priority_fees(&mut self) {
25        normalize_zero_priority_fee(&mut self.slow);
26        normalize_zero_priority_fee(&mut self.medium);
27        normalize_zero_priority_fee(&mut self.fast);
28        normalize_zero_priority_fee(&mut self.super_fast);
29    }
30}
31
32fn normalize_zero_priority_fee(tier: &mut GasEstimateTier) {
33    if tier.max_priority_fee_wei == 0 {
34        let normalization_bump = tier.max_fee_wei / 100;
35        tier.max_priority_fee_wei = normalization_bump;
36        tier.max_fee_wei += normalization_bump;
37    }
38}
39
40fn trim_decimal_zeros(mut value: String) -> String {
41    if value.contains('.') {
42        while value.ends_with('0') {
43            value.pop();
44        }
45        if value.ends_with('.') {
46            value.pop();
47        }
48    }
49
50    if value.is_empty() {
51        "0".to_string()
52    } else {
53        value
54    }
55}
56
57pub(crate) fn format_wei_as_gwei_string(value: u128) -> Result<String, String> {
58    let formatted =
59        format_units(value, "gwei").map_err(|err| format!("Failed to format gas value: {err}"))?;
60    Ok(trim_decimal_zeros(formatted))
61}
62
63fn build_gas_estimate_result(
64    base_priority_fee: u128,
65    base_max_fee: u128,
66    current_base_fee: u128,
67) -> GasEstimateResult {
68    let base_fee_floor = current_base_fee + base_priority_fee;
69
70    let mut result = GasEstimateResult {
71        slow: GasEstimateTier {
72            max_priority_fee_wei: (base_priority_fee * 80) / 100,
73            max_fee_wei: ((base_max_fee * 90) / 100).max(base_fee_floor),
74        },
75        medium: GasEstimateTier {
76            max_priority_fee_wei: base_priority_fee,
77            max_fee_wei: base_max_fee.max(base_fee_floor),
78        },
79        fast: GasEstimateTier {
80            max_priority_fee_wei: (base_priority_fee * 130) / 100,
81            max_fee_wei: ((base_max_fee * 120) / 100).max(base_fee_floor),
82        },
83        super_fast: GasEstimateTier {
84            max_priority_fee_wei: (base_priority_fee * 180) / 100,
85            max_fee_wei: ((base_max_fee * 150) / 100).max(base_fee_floor),
86        },
87    };
88    result.normalize_zero_priority_fees();
89    result
90}
91
92fn parse_gwei_to_wei(value: &str) -> Result<u128, String> {
93    parse_units(value, "gwei")
94        .map_err(|err| format!("Failed to parse gwei literal {value}: {err}"))?
95        .try_into()
96        .map_err(|err| format!("Failed to convert gwei literal {value} to wei: {err}"))
97}
98
99#[derive(Clone)]
100pub struct GasProviderService {
101    provider: DynProvider,
102    chain_id: u64,
103}
104
105fn build_provider(rpc_url: &str) -> Result<DynProvider, String> {
106    Ok(ProviderBuilder::new()
107        .connect_http(
108            rpc_url
109                .parse()
110                .map_err(|err| format!("Invalid transaction_submitter.rpc_url: {err}"))?,
111        )
112        .erased())
113}
114
115impl GasProviderService {
116    pub async fn new(rpc_url: &str) -> Result<Self, String> {
117        let provider = build_provider(rpc_url)?;
118
119        let chain_id = provider.get_chain_id().await.map_err(|err| {
120            format!("Failed to resolve chain ID from transaction_submitter.rpc_url: {err}")
121        })?;
122
123        Ok(Self { provider, chain_id })
124    }
125
126    pub fn new_with_chain_id(rpc_url: &str, chain_id: u64) -> Result<Self, String> {
127        Ok(Self {
128            provider: build_provider(rpc_url)?,
129            chain_id,
130        })
131    }
132
133    /// Create a mock gas provider that doesn't connect to any RPC.
134    /// Used in test mode where no on-chain operations are performed.
135    pub fn new_mock() -> Self {
136        let provider = ProviderBuilder::new()
137            .connect_http("http://localhost:1".parse().unwrap())
138            .erased();
139        Self {
140            provider,
141            chain_id: 0,
142        }
143    }
144
145    pub fn chain_id(&self) -> u64 {
146        self.chain_id
147    }
148
149    pub async fn estimate_fees(&self) -> Result<GasEstimateResult, String> {
150        let (base_priority_fee, base_max_fee) = match self.estimate_with_fee_history().await {
151            Ok(fees) => fees,
152            Err(fee_history_err) => {
153                let suggested = self.provider.estimate_eip1559_fees().await.map_err(|err| {
154                    format!(
155                        "eth_feeHistory estimation failed: {fee_history_err}; \
156                         estimate_eip1559_fees fallback failed: {err}"
157                    )
158                })?;
159
160                let priority_fee = suggested.max_priority_fee_per_gas;
161                let max_fee = if self.chain_id == 1 {
162                    suggested.max_fee_per_gas.max(priority_fee * 2)
163                } else {
164                    suggested.max_fee_per_gas
165                };
166
167                (priority_fee, max_fee)
168            }
169        };
170
171        let current_base_fee = self.latest_block_base_fee().await?;
172        Ok(build_gas_estimate_result(
173            base_priority_fee,
174            base_max_fee,
175            current_base_fee,
176        ))
177    }
178
179    async fn estimate_with_fee_history(&self) -> Result<(u128, u128), String> {
180        let ethereum_like = self.chain_id == 1 || self.chain_id == 11155111;
181        let past_blocks = if ethereum_like { 20 } else { 60 };
182        let reward_percentile = if ethereum_like { 60.0 } else { 25.0 };
183
184        let fee_history = self
185            .provider
186            .get_fee_history(past_blocks, BlockNumberOrTag::Latest, &[reward_percentile])
187            .await
188            .map_err(|err| format!("eth_feeHistory RPC failed: {err}"))?;
189
190        let base_fee_per_gas = match fee_history.latest_block_base_fee() {
191            Some(base_fee) if base_fee != 0 => base_fee,
192            _ => self.latest_block_base_fee().await?,
193        };
194
195        let priority_fee = match &fee_history.reward {
196            Some(rewards) if !rewards.is_empty() => {
197                let mut all_rewards: Vec<u128> = rewards
198                    .iter()
199                    .filter_map(|block_rewards| block_rewards.first().copied())
200                    .collect();
201
202                if all_rewards.is_empty() {
203                    self.default_priority_fee(ethereum_like)?
204                } else {
205                    all_rewards.sort_unstable();
206                    all_rewards[all_rewards.len() / 2]
207                }
208            }
209            _ => self.default_priority_fee(ethereum_like)?,
210        };
211
212        let max_fee = if self.chain_id == 1 {
213            (base_fee_per_gas + priority_fee).max(priority_fee * 2)
214        } else {
215            base_fee_per_gas + (priority_fee * 2)
216        };
217
218        Ok((priority_fee, max_fee))
219    }
220
221    fn default_priority_fee(&self, ethereum_like: bool) -> Result<u128, String> {
222        if ethereum_like {
223            parse_gwei_to_wei("2")
224        } else {
225            parse_gwei_to_wei("0.01")
226        }
227    }
228
229    async fn latest_block_base_fee(&self) -> Result<u128, String> {
230        let block = self
231            .provider
232            .get_block_by_number(BlockNumberOrTag::Latest)
233            .await
234            .map_err(|err| format!("eth_getBlockByNumber RPC failed: {err}"))?
235            .ok_or_else(|| "Latest block not found".to_string())?;
236
237        block
238            .header()
239            .as_ref()
240            .base_fee_per_gas()
241            .map(Into::into)
242            .ok_or_else(|| "Latest block missing base fee, EIP-1559 is not supported".to_string())
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::{
249        build_gas_estimate_result, format_wei_as_gwei_string, normalize_zero_priority_fee,
250        GasEstimateResult, GasEstimateTier,
251    };
252
253    #[test]
254    fn zero_priority_normalization_adds_one_percent_to_priority_and_max_fee() {
255        let mut tier = GasEstimateTier {
256            max_priority_fee_wei: 0,
257            max_fee_wei: 120_000_000_000,
258        };
259
260        normalize_zero_priority_fee(&mut tier);
261
262        assert_eq!(tier.max_priority_fee_wei, 1_200_000_000);
263        assert_eq!(tier.max_fee_wei, 121_200_000_000);
264    }
265
266    #[test]
267    fn format_wei_as_gwei_string_is_plain_decimal() {
268        assert_eq!(format_wei_as_gwei_string(1_000_000_000).unwrap(), "1");
269        assert_eq!(format_wei_as_gwei_string(1_200_000_000).unwrap(), "1.2");
270        assert_eq!(format_wei_as_gwei_string(10_000_000).unwrap(), "0.01");
271    }
272
273    #[test]
274    fn normalize_zero_priority_fees_updates_each_tier() {
275        let mut estimates = GasEstimateResult {
276            slow: GasEstimateTier {
277                max_priority_fee_wei: 0,
278                max_fee_wei: 100,
279            },
280            medium: GasEstimateTier {
281                max_priority_fee_wei: 1,
282                max_fee_wei: 100,
283            },
284            fast: GasEstimateTier {
285                max_priority_fee_wei: 0,
286                max_fee_wei: 250,
287            },
288            super_fast: GasEstimateTier {
289                max_priority_fee_wei: 0,
290                max_fee_wei: 500,
291            },
292        };
293
294        estimates.normalize_zero_priority_fees();
295
296        assert_eq!(estimates.slow.max_priority_fee_wei, 1);
297        assert_eq!(estimates.slow.max_fee_wei, 101);
298        assert_eq!(estimates.medium.max_priority_fee_wei, 1);
299        assert_eq!(estimates.medium.max_fee_wei, 100);
300        assert_eq!(estimates.fast.max_priority_fee_wei, 2);
301        assert_eq!(estimates.fast.max_fee_wei, 252);
302        assert_eq!(estimates.super_fast.max_priority_fee_wei, 5);
303        assert_eq!(estimates.super_fast.max_fee_wei, 505);
304    }
305
306    #[test]
307    fn build_gas_estimate_result_applies_one_percent_bump_after_zero_priority_floor() {
308        let estimates = build_gas_estimate_result(0, 100_000_000_000, 160_000_000_000);
309
310        assert_eq!(estimates.slow.max_fee_wei, 161_600_000_000);
311        assert_eq!(estimates.slow.max_priority_fee_wei, 1_600_000_000);
312        assert_eq!(estimates.medium.max_fee_wei, 161_600_000_000);
313        assert_eq!(estimates.medium.max_priority_fee_wei, 1_600_000_000);
314        assert_eq!(estimates.fast.max_fee_wei, 161_600_000_000);
315        assert_eq!(estimates.fast.max_priority_fee_wei, 1_600_000_000);
316        assert_eq!(estimates.super_fast.max_fee_wei, 161_600_000_000);
317        assert_eq!(estimates.super_fast.max_priority_fee_wei, 1_600_000_000);
318    }
319}