referrerpolicy=no-referrer-when-downgrade

pallet_revive_eth_rpc/
fee_history_provider.rs

1// This file is part of Substrate.
2
3// Copyright (C) Parity Technologies (UK) Ltd.
4// SPDX-License-Identifier: Apache-2.0
5
6// Licensed under the Apache License, Version 2.0 (the "License");
7// you may not use this file except in compliance with the License.
8// You may obtain a copy of the License at
9//
10// 	http://www.apache.org/licenses/LICENSE-2.0
11//
12// Unless required by applicable law or agreed to in writing, software
13// distributed under the License is distributed on an "AS IS" BASIS,
14// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15// See the License for the specific language governing permissions and
16// limitations under the License.
17use crate::{ClientError, FeeHistoryResult, ReceiptInfo, client::SubstrateBlockNumber};
18use pallet_revive_types::runtime_api::BlockV1;
19use sp_core::U256;
20use std::{collections::BTreeMap, sync::Arc};
21use tokio::sync::RwLock;
22
23/// The size of the fee history cache.
24const CACHE_SIZE: u32 = 1024;
25
26/// The maximum number of reward percentiles accepted in a single `eth_feeHistory` request,
27/// matching go-ethereum's query limit.
28const MAX_REWARD_PERCENTILES: usize = 100;
29
30#[derive(Default, Clone)]
31struct FeeHistoryCacheItem {
32	base_fee: u128,
33	gas_used_ratio: f64,
34	rewards: Vec<u128>,
35}
36
37/// Maps a reward percentile requested via `eth_feeHistory` to its bucket index in
38/// [`FeeHistoryCacheItem::rewards`].
39///
40/// Rewards are cached at half-percentile resolution, i.e. one bucket per `0.5` percent over
41/// `0.0..=100.0` (201 buckets). The index is therefore the percentile, clamped to that range and
42/// scaled to the bucket resolution, rounded to the nearest bucket.
43fn reward_bucket_index(percentile: f64) -> usize {
44	// Two buckets per whole percentile, hence the factor of two.
45	(percentile.clamp(0.0, 100.0) * 2f64).round() as usize
46}
47
48/// Validates the reward percentiles requested via `eth_feeHistory`.
49///
50/// Matching go-ethereum, the list may hold at most [`MAX_REWARD_PERCENTILES`] entries, each
51/// percentile must lie within `0.0..=100.0`, and the list must be strictly increasing; otherwise
52/// the request is rejected rather than silently clamped or approximated at the wrong bucket.
53/// Returns the rejection reason as the error message.
54pub(crate) fn validate_reward_percentiles(percentiles: &[f64]) -> Result<(), String> {
55	if percentiles.len() > MAX_REWARD_PERCENTILES {
56		return Err(format!(
57			"invalid reward percentile: over the query limit {MAX_REWARD_PERCENTILES}"
58		));
59	}
60	for (i, &p) in percentiles.iter().enumerate() {
61		if !(0.0..=100.0).contains(&p) {
62			return Err(format!("invalid reward percentile: {p}"));
63		}
64		if i > 0 && p <= percentiles[i - 1] {
65			return Err(format!(
66				"invalid reward percentile: #{}:{} >= #{}:{}",
67				i - 1,
68				percentiles[i - 1],
69				i,
70				p
71			));
72		}
73	}
74	Ok(())
75}
76
77/// Manages the fee history cache.
78#[derive(Default, Clone)]
79pub struct FeeHistoryProvider {
80	fee_history_cache: Arc<RwLock<BTreeMap<SubstrateBlockNumber, FeeHistoryCacheItem>>>,
81}
82
83impl FeeHistoryProvider {
84	/// Update the fee history cache with the given block and receipts.
85	pub async fn update_fee_history(&self, block: &BlockV1, receipts: &[ReceiptInfo]) {
86		// Evenly spaced percentile list from 0.0 to 100.0 with a 0.5 resolution.
87		// This means we cache 200 percentile points.
88		// Later in request handling we will approximate by rounding percentiles that
89		// fall in between with `(round(n*2)/2)`.
90		let reward_percentiles: Vec<f64> = (0..=200).map(|i| i as f64 * 0.5).collect();
91		let block_number: SubstrateBlockNumber =
92			block.number.try_into().expect("Block number is always valid");
93
94		let base_fee = block.base_fee_per_gas.as_u128();
95		let gas_used = block.gas_used.as_u128();
96		let gas_used_ratio = (gas_used as f64) / (block.gas_limit.as_u128() as f64);
97		let mut result = FeeHistoryCacheItem { base_fee, gas_used_ratio, rewards: vec![] };
98
99		let mut receipts = receipts
100			.iter()
101			.map(|receipt| {
102				let gas_used = receipt.gas_used.as_u128();
103				let effective_reward =
104					receipt.effective_gas_price.as_u128().saturating_sub(base_fee);
105				(gas_used, effective_reward)
106			})
107			.collect::<Vec<_>>();
108		receipts.sort_by(|(_, a), (_, b)| a.cmp(b));
109
110		// Calculate percentile rewards.
111		result.rewards = reward_percentiles
112			.into_iter()
113			.filter_map(|p| {
114				let target_gas = (p * gas_used as f64 / 100f64) as u128;
115				let mut sum_gas = 0u128;
116				for (gas_used, reward) in &receipts {
117					sum_gas += gas_used;
118					if target_gas <= sum_gas {
119						return Some(*reward);
120					}
121				}
122				None
123			})
124			.collect();
125
126		let mut cache = self.fee_history_cache.write().await;
127		if cache.len() >= CACHE_SIZE as usize {
128			cache.pop_first();
129		}
130		cache.insert(block_number, result);
131	}
132
133	/// Get the fee history for the given block range.
134	pub async fn fee_history(
135		&self,
136		block_count: u32,
137		highest: SubstrateBlockNumber,
138		reward_percentiles: Option<Vec<f64>>,
139	) -> Result<FeeHistoryResult, ClientError> {
140		let block_count = block_count.min(CACHE_SIZE);
141
142		let cache = self.fee_history_cache.read().await;
143		let Some(lowest_in_cache) = cache.first_key_value().map(|(k, _)| *k) else {
144			return Ok(FeeHistoryResult {
145				oldest_block: U256::zero(),
146				base_fee_per_gas: vec![],
147				gas_used_ratio: vec![],
148				reward: vec![],
149			});
150		};
151
152		let lowest = highest
153			.saturating_sub(SubstrateBlockNumber::from(block_count.saturating_sub(1)))
154			.max(lowest_in_cache);
155
156		let mut response = FeeHistoryResult {
157			oldest_block: U256::from(lowest),
158			base_fee_per_gas: Vec::new(),
159			gas_used_ratio: Vec::new(),
160			reward: Default::default(),
161		};
162
163		let rewards = &mut response.reward;
164		// Iterate over the requested block range.
165		for n in lowest..=highest {
166			if let Some(block) = cache.get(&n) {
167				response.base_fee_per_gas.push(U256::from(block.base_fee));
168				response.gas_used_ratio.push(block.gas_used_ratio);
169				// If the request includes reward percentiles, get them from the cache.
170				if let Some(ref requested_percentiles) = reward_percentiles {
171					let mut block_rewards = Vec::new();
172					// Get cached reward for each provided percentile.
173					for p in requested_percentiles {
174						// Get and push the reward.
175						let reward = if let Some(r) = block.rewards.get(reward_bucket_index(*p)) {
176							U256::from(*r)
177						} else {
178							U256::zero()
179						};
180						block_rewards.push(reward);
181					}
182					// Push block rewards.
183					if !block_rewards.is_empty() {
184						rewards.push(block_rewards);
185					}
186				}
187			}
188		}
189
190		// Next block base fee, use constant value for now
191		let base_fee = cache
192			.last_key_value()
193			.map(|(_, block)| U256::from(block.base_fee))
194			.unwrap_or_default();
195		response.base_fee_per_gas.push(base_fee);
196		Ok(response)
197	}
198}
199
200#[tokio::test]
201async fn test_update_fee_history() {
202	let block = BlockV1 {
203		number: U256::from(200u64),
204		base_fee_per_gas: U256::from(1000u64),
205		gas_used: U256::from(600u64),
206		gas_limit: U256::from(1200u64),
207		..Default::default()
208	};
209
210	let receipts = vec![
211		ReceiptInfo {
212			gas_used: U256::from(200u64),
213			effective_gas_price: U256::from(1200u64),
214			..Default::default()
215		},
216		ReceiptInfo {
217			gas_used: U256::from(200u64),
218			effective_gas_price: U256::from(1100u64),
219			..Default::default()
220		},
221		ReceiptInfo {
222			gas_used: U256::from(200u64),
223			effective_gas_price: U256::from(1050u64),
224			..Default::default()
225		},
226	];
227
228	let provider = FeeHistoryProvider { fee_history_cache: Arc::new(RwLock::new(BTreeMap::new())) };
229	provider.update_fee_history(&block, &receipts).await;
230
231	let fee_history_result =
232		provider.fee_history(1, 200, Some(vec![0.0f64, 50.0, 100.0])).await.unwrap();
233
234	let expected_result = FeeHistoryResult {
235		oldest_block: U256::from(200),
236		base_fee_per_gas: vec![U256::from(1000), U256::from(1000)],
237		gas_used_ratio: vec![0.5f64],
238		reward: vec![vec![U256::from(50), U256::from(100), U256::from(200)]],
239	};
240	assert_eq!(fee_history_result, expected_result);
241}
242
243#[test]
244fn reward_bucket_index_matches_half_percentile_resolution() {
245	// Whole percentiles map to even buckets.
246	assert_eq!(reward_bucket_index(0.0), 0);
247	assert_eq!(reward_bucket_index(50.0), 100);
248	assert_eq!(reward_bucket_index(100.0), 200);
249
250	// Half percentiles are addressable and are not snapped to a neighbouring whole percentile.
251	assert_eq!(reward_bucket_index(0.5), 1);
252	assert_eq!(reward_bucket_index(50.5), 101);
253
254	// Out-of-range percentiles are clamped to the valid range.
255	assert_eq!(reward_bucket_index(-1.0), 0);
256	assert_eq!(reward_bucket_index(150.0), 200);
257}
258
259#[test]
260fn validate_reward_percentiles_matches_geth() {
261	// Valid: within range and strictly increasing (including half-percentiles).
262	assert!(validate_reward_percentiles(&[]).is_ok());
263	assert!(validate_reward_percentiles(&[0.0, 20.0, 50.5, 100.0]).is_ok());
264
265	// Out-of-range percentiles are rejected rather than clamped.
266	assert!(validate_reward_percentiles(&[-0.1]).is_err());
267	assert!(validate_reward_percentiles(&[100.1]).is_err());
268
269	// A non-increasing list is rejected, including equal adjacent values.
270	assert!(validate_reward_percentiles(&[50.0, 20.0]).is_err());
271	assert!(validate_reward_percentiles(&[20.0, 20.0]).is_err());
272
273	// The number of percentiles is capped: a list at the limit is accepted, one over is not.
274	let at_limit: Vec<f64> = (0..MAX_REWARD_PERCENTILES).map(|i| i as f64 * 0.5).collect();
275	assert!(validate_reward_percentiles(&at_limit).is_ok());
276	let over_limit: Vec<f64> = (0..=MAX_REWARD_PERCENTILES).map(|i| i as f64 * 0.5).collect();
277	assert!(validate_reward_percentiles(&over_limit).is_err());
278}