use alloy_chains::NamedChain;
use alloy_primitives::{Address, BlockNumber};
use crate::cache::block_range::{BlockRangeCache, Mergeable};
use crate::gas::calculator::GasCostResult;
impl Mergeable for GasCostResult {
fn merge(&mut self, other: &Self) {
GasCostResult::merge(self, other);
}
}
#[derive(Debug, Clone, Default)]
pub struct GasCache {
inner: BlockRangeCache<(Address, Address), GasCostResult>,
}
impl GasCache {
pub fn get(
&self,
from: Address,
to: Address,
start_block: BlockNumber,
end_block: BlockNumber,
) -> Option<GasCostResult> {
self.inner.get(&(from, to), start_block, end_block)
}
pub fn insert(
&mut self,
from: Address,
to: Address,
start_block: BlockNumber,
end_block: BlockNumber,
result: GasCostResult,
) {
self.inner
.insert((from, to), start_block, end_block, result);
}
pub fn calculate_gaps(
&self,
chain: NamedChain,
from: Address,
to: Address,
start_block: BlockNumber,
end_block: BlockNumber,
) -> (Option<GasCostResult>, Vec<(BlockNumber, BlockNumber)>) {
self.inner
.calculate_gaps(&(from, to), start_block, end_block, || {
GasCostResult::new(chain, from, to)
})
}
pub fn clear_signer_data(&mut self, from: Address, to: Address) {
self.inner
.retain(|(cached_from, cached_to), _, _| *cached_from != from || *cached_to != to);
}
pub fn clear_old_blocks(&mut self, min_block: BlockNumber) {
self.inner.retain(|_, _, end_block| end_block >= min_block);
}
pub fn len(&self) -> usize {
self.inner.len()
}
pub fn is_empty(&self) -> bool {
self.inner.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{TransactionCount, WeiAmount};
use alloy_chains::NamedChain;
use alloy_primitives::Address;
fn create_test_result(
chain: NamedChain,
from: Address,
to: Address,
tx_count: usize,
gas_cost: u64,
) -> GasCostResult {
let mut result = GasCostResult::new(chain, from, to);
result.transaction_count = TransactionCount::new(tx_count);
result.total_gas_cost = WeiAmount::from(gas_cost);
result
}
#[test]
fn test_cache_insert_and_get() {
let mut cache = GasCache::default();
let from = Address::ZERO;
let to = Address::ZERO;
let result = create_test_result(NamedChain::Mainnet, from, to, 5, 100_000);
cache.insert(from, to, 100, 200, result.clone());
let cached = cache.get(from, to, 100, 200);
assert!(cached.is_some());
assert_eq!(cached.unwrap().transaction_count, TransactionCount::new(5));
assert!(cache.get(from, to, 120, 180).is_none());
assert!(cache.get(from, to, 50, 300).is_none());
}
#[test]
fn test_calculate_gaps() {
let mut cache = GasCache::default();
let from = Address::ZERO;
let to = Address::ZERO;
cache.insert(
from,
to,
100,
200,
create_test_result(NamedChain::Mainnet, from, to, 5, 100_000),
);
cache.insert(
from,
to,
300,
400,
create_test_result(NamedChain::Mainnet, from, to, 3, 60_000),
);
cache.insert(
from,
to,
600,
700,
create_test_result(NamedChain::Mainnet, from, to, 2, 40_000),
);
let (result, gaps) = cache.calculate_gaps(NamedChain::Mainnet, from, to, 50, 800);
assert!(result.is_some());
assert_eq!(gaps.len(), 4);
assert_eq!(gaps[0], (50, 99));
assert_eq!(gaps[1], (201, 299));
assert_eq!(gaps[2], (401, 599));
assert_eq!(gaps[3], (701, 800));
assert_eq!(result.unwrap().transaction_count, TransactionCount::new(10));
}
#[test]
fn test_partial_overlap_does_not_double_count() {
let mut cache = GasCache::default();
let from = Address::ZERO;
let to = Address::ZERO;
cache.insert(
from,
to,
100,
300,
create_test_result(NamedChain::Mainnet, from, to, 5, 100_000),
);
cache.insert(
from,
to,
250,
400,
create_test_result(NamedChain::Mainnet, from, to, 3, 60_000),
);
assert!(
cache.get(from, to, 100, 400).is_none(),
"no cached entry should claim coverage of [100, 400]"
);
let kept = cache
.get(from, to, 100, 300)
.expect("original range preserved");
assert_eq!(kept.transaction_count, TransactionCount::new(5));
assert_eq!(kept.total_gas_cost, WeiAmount::from(100_000u64));
}
#[test]
fn test_covering_insert_replaces_prior_segments() {
let mut cache = GasCache::default();
let from = Address::ZERO;
let to = Address::ZERO;
cache.insert(
from,
to,
100,
150,
create_test_result(NamedChain::Mainnet, from, to, 2, 20_000),
);
cache.insert(
from,
to,
200,
250,
create_test_result(NamedChain::Mainnet, from, to, 3, 30_000),
);
let aggregated = create_test_result(NamedChain::Mainnet, from, to, 7, 90_000);
cache.insert(from, to, 100, 250, aggregated);
assert_eq!(cache.len(), 1);
let stored = cache.get(from, to, 100, 250).unwrap();
assert_eq!(stored.transaction_count, TransactionCount::new(7));
assert_eq!(stored.total_gas_cost, WeiAmount::from(90_000u64));
}
#[test]
fn test_cache_merge_preserves_gas_breakdown() {
use crate::types::gas::{BlobCount, GasBreakdown};
use alloy_primitives::U256;
let mut cache = GasCache::default();
let from = Address::ZERO;
let to = Address::ZERO;
let mut first = create_test_result(NamedChain::Mainnet, from, to, 1, 1_000);
first.breakdown = GasBreakdown::builder()
.execution_gas_cost(U256::from(700u64))
.blob_gas_cost(U256::from(200u64))
.l1_data_fee(U256::from(100u64))
.blob_count(BlobCount::new(2))
.build();
cache.insert(from, to, 100, 200, first);
let mut second = create_test_result(NamedChain::Mainnet, from, to, 1, 500);
second.breakdown = GasBreakdown::builder()
.execution_gas_cost(U256::from(400u64))
.blob_gas_cost(U256::from(50u64))
.l1_data_fee(U256::from(50u64))
.blob_count(BlobCount::new(1))
.build();
cache.insert(from, to, 300, 400, second);
let (merged, gaps) = cache.calculate_gaps(NamedChain::Mainnet, from, to, 100, 400);
let merged = merged.expect("cached segments are merged for the query");
assert_eq!(gaps, vec![(201, 299)]);
assert_eq!(merged.breakdown.execution_gas_cost, U256::from(1_100u64));
assert_eq!(merged.breakdown.blob_gas_cost, U256::from(250u64));
assert_eq!(merged.breakdown.l1_data_fee, U256::from(150u64));
assert_eq!(merged.breakdown.blob_count, BlobCount::new(3));
}
mod proptests {
use super::*;
use proptest::prelude::*;
fn block_range_strategy() -> impl Strategy<Value = (BlockNumber, BlockNumber)> {
(0u64..100_000u64)
.prop_flat_map(|start| (Just(start), start..start.saturating_add(10_000)))
}
fn cached_ranges_strategy() -> impl Strategy<Value = Vec<(BlockNumber, BlockNumber)>> {
prop::collection::vec(block_range_strategy(), 0..10).prop_map(|mut ranges| {
ranges.sort_by_key(|(start, _)| *start);
let mut non_overlapping = Vec::new();
let mut last_end = 0u64;
for (start, end) in ranges {
let adjusted_start = start.max(last_end + 2);
if adjusted_start < end {
non_overlapping.push((adjusted_start, end));
last_end = end;
}
}
non_overlapping
})
}
proptest! {
#[test]
fn test_gaps_never_overlap_with_within_query_cached(
cached_ranges in cached_ranges_strategy(),
(query_start, query_end) in block_range_strategy()
) {
let mut cache = GasCache::default();
let from = Address::ZERO;
let to = Address::ZERO;
let chain = NamedChain::Mainnet;
for (start, end) in &cached_ranges {
cache.insert(from, to, *start, *end, create_test_result(chain, from, to, 1, 1000));
}
let (_, gaps) = cache.calculate_gaps(chain, from, to, query_start, query_end);
for (gap_start, gap_end) in &gaps {
for (cached_start, cached_end) in &cached_ranges {
if *cached_start < query_start || *cached_end > query_end {
continue;
}
let no_overlap = *gap_end < *cached_start || *gap_start > *cached_end;
prop_assert!(
no_overlap,
"Gap [{gap_start}, {gap_end}] overlaps with within-query cached range [{cached_start}, {cached_end}]"
);
}
}
}
#[test]
fn test_gaps_are_sorted(
cached_ranges in cached_ranges_strategy(),
(query_start, query_end) in block_range_strategy()
) {
let mut cache = GasCache::default();
let from = Address::ZERO;
let to = Address::ZERO;
let chain = NamedChain::Mainnet;
for (start, end) in &cached_ranges {
cache.insert(from, to, *start, *end, create_test_result(chain, from, to, 1, 1000));
}
let (_, gaps) = cache.calculate_gaps(chain, from, to, query_start, query_end);
for i in 1..gaps.len() {
prop_assert!(
gaps[i - 1].0 < gaps[i].0,
"Gaps not sorted: gap[{i_prev}] = {prev:?}, gap[{i}] = {curr:?}",
i_prev = i - 1,
prev = gaps[i - 1],
curr = gaps[i]
);
}
}
#[test]
fn test_gaps_cover_uncached_space(
cached_ranges in cached_ranges_strategy(),
(query_start, query_end) in block_range_strategy()
) {
let mut cache = GasCache::default();
let from = Address::ZERO;
let to = Address::ZERO;
let chain = NamedChain::Mainnet;
for (start, end) in &cached_ranges {
cache.insert(from, to, *start, *end, create_test_result(chain, from, to, 1, 1000));
}
let (_, gaps) = cache.calculate_gaps(chain, from, to, query_start, query_end);
let mut covered_blocks = std::collections::HashSet::new();
for (cached_start, cached_end) in &cached_ranges {
let start = (*cached_start).max(query_start);
let end = (*cached_end).min(query_end);
if start <= end {
for block in start..=end {
covered_blocks.insert(block);
}
}
}
for (gap_start, gap_end) in &gaps {
for block in *gap_start..=*gap_end {
covered_blocks.insert(block);
}
}
for block in query_start..=query_end {
prop_assert!(
covered_blocks.contains(&block),
"Block {block} in range [{query_start}, {query_end}] is not covered by cache or gaps"
);
}
}
#[test]
fn test_gaps_dont_overlap_each_other(
cached_ranges in cached_ranges_strategy(),
(query_start, query_end) in block_range_strategy()
) {
let mut cache = GasCache::default();
let from = Address::ZERO;
let to = Address::ZERO;
let chain = NamedChain::Mainnet;
for (start, end) in &cached_ranges {
cache.insert(from, to, *start, *end, create_test_result(chain, from, to, 1, 1000));
}
let (_, gaps) = cache.calculate_gaps(chain, from, to, query_start, query_end);
for i in 0..gaps.len() {
for j in (i + 1)..gaps.len() {
let (gap_i_start, gap_i_end) = gaps[i];
let (gap_j_start, gap_j_end) = gaps[j];
let no_overlap = gap_i_end < gap_j_start || gap_j_end < gap_i_start;
prop_assert!(
no_overlap,
"Gap {i} [{gap_i_start}, {gap_i_end}] overlaps with gap {j} [{gap_j_start}, {gap_j_end}]"
);
}
}
}
#[test]
fn test_empty_cache_returns_full_range(
(query_start, query_end) in block_range_strategy()
) {
let cache = GasCache::default();
let from = Address::ZERO;
let to = Address::ZERO;
let chain = NamedChain::Mainnet;
let (result, gaps) = cache.calculate_gaps(chain, from, to, query_start, query_end);
prop_assert!(result.is_none(), "Empty cache should return None result");
prop_assert_eq!(gaps.len(), 1, "Empty cache should return exactly one gap");
prop_assert_eq!(gaps[0], (query_start, query_end), "Gap should cover entire query range");
}
#[test]
fn test_exact_match_returns_no_gaps(
(start, end) in block_range_strategy()
) {
let mut cache = GasCache::default();
let from = Address::ZERO;
let to = Address::ZERO;
let chain = NamedChain::Mainnet;
cache.insert(from, to, start, end, create_test_result(chain, from, to, 1, 1000));
let (result, gaps) = cache.calculate_gaps(chain, from, to, start, end);
prop_assert!(result.is_some(), "Exact-match query should return a cached result");
prop_assert_eq!(gaps.len(), 0, "Exact-match query should return no gaps");
}
#[test]
fn test_wider_entry_does_not_satisfy_narrower_query(
(inner_start, inner_end) in block_range_strategy()
) {
prop_assume!(inner_start >= 10);
let mut cache = GasCache::default();
let from = Address::ZERO;
let to = Address::ZERO;
let chain = NamedChain::Mainnet;
let cache_start = inner_start - 10;
let cache_end = inner_end.saturating_add(10);
cache.insert(from, to, cache_start, cache_end, create_test_result(chain, from, to, 1, 1000));
let (result, gaps) = cache.calculate_gaps(chain, from, to, inner_start, inner_end);
prop_assert!(
result.is_none(),
"wider cached entry must not contribute to a narrower query"
);
prop_assert_eq!(gaps, vec![(inner_start, inner_end)], "whole window is reported as gap");
}
}
}
}