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 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}