use std::{collections::HashMap, path::Path};
use alloy_primitives::{Address, U256};
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
use crate::freshness::FreshnessParams;
use super::versioned;
use crate::errors::PersistenceError;
const SLOT_OBSERVATIONS_MAGIC: &[u8; 8] = b"EFC-SOBS";
const SLOT_OBSERVATIONS_VERSION: u32 = 1;
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SlotObservation {
pub last_value: U256,
pub observation_count: u32,
pub change_count: u32,
pub last_checked: u64,
pub last_changed: u64,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
struct SlotKey {
address: Address,
slot: U256,
}
pub struct SlotObservationTracker {
observations: HashMap<SlotKey, SlotObservation>,
skipped_this_cycle: Vec<(Address, U256)>,
dirty: bool,
}
impl SlotObservationTracker {
pub fn new() -> Self {
Self {
observations: HashMap::new(),
skipped_this_cycle: Vec::new(),
dirty: false,
}
}
pub fn load(path: &Path) -> Self {
match std::fs::read(path) {
Ok(data) => {
if let Some(observations) = versioned::decode::<HashMap<SlotKey, SlotObservation>>(
&data,
SLOT_OBSERVATIONS_MAGIC,
SLOT_OBSERVATIONS_VERSION,
"slot observations",
) {
debug!(
entries = observations.len(),
"Loaded slot observation tracker"
);
Self {
observations,
skipped_this_cycle: Vec::new(),
dirty: false,
}
} else {
warn!("Slot observations cache miss, starting fresh");
Self::new()
}
}
Err(_) => {
debug!("No slot observations file found, starting fresh");
Self::new()
}
}
}
pub fn save(&mut self, path: &Path) -> Result<(), PersistenceError> {
if !self.dirty {
return Ok(());
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|err| PersistenceError::create_dir(parent, err))?;
}
let data = versioned::encode(
SLOT_OBSERVATIONS_MAGIC,
SLOT_OBSERVATIONS_VERSION,
&self.observations,
"slot observations",
)?;
std::fs::write(path, data).map_err(|err| PersistenceError::write(path, err))?;
self.dirty = false;
debug!(entries = self.observations.len(), "Saved slot observations");
Ok(())
}
pub fn should_refetch(
&self,
addr: Address,
slot: U256,
now: u64,
params: &FreshnessParams,
) -> bool {
let key = SlotKey {
address: addr,
slot,
};
let Some(obs) = self.observations.get(&key) else {
return true; };
if obs.observation_count < params.min_observations {
return true;
}
if now.saturating_sub(obs.last_checked) > params.max_reuse {
return true;
}
if obs.change_count == 0 {
return false;
}
let change_rate = obs.change_count as f64 / obs.observation_count as f64;
if change_rate > params.always_refetch_rate {
return true;
}
let units_elapsed = now.saturating_sub(obs.last_checked) as f64;
let cycle_interval = params.cycle_interval.max(1) as f64;
let cycles_elapsed = (units_elapsed / cycle_interval).max(1.0);
let expected_changes = change_rate * cycles_elapsed;
expected_changes > params.staleness_threshold
}
pub fn observe(&mut self, addr: Address, slot: U256, value: U256, now: u64) -> bool {
let key = SlotKey {
address: addr,
slot,
};
self.dirty = true;
match self.observations.get_mut(&key) {
Some(obs) => {
let changed = obs.last_value != value;
obs.observation_count += 1;
if changed {
obs.change_count += 1;
obs.last_changed = now;
obs.last_value = value;
}
obs.last_checked = now;
changed
}
None => {
self.observations.insert(
key,
SlotObservation {
last_value: value,
observation_count: 1,
change_count: 0, last_checked: now,
last_changed: 0,
},
);
false
}
}
}
pub fn record_skip(&mut self, addr: Address, slot: U256) {
self.skipped_this_cycle.push((addr, slot));
}
pub fn take_skipped(&mut self) -> Vec<(Address, U256)> {
std::mem::take(&mut self.skipped_this_cycle)
}
pub fn reset_contract(&mut self, addr: Address) {
self.observations.retain(|k, _| k.address != addr);
self.dirty = true;
}
pub fn begin_cycle(&mut self) {
self.skipped_this_cycle.clear();
}
pub fn len(&self) -> usize {
self.observations.len()
}
pub fn is_empty(&self) -> bool {
self.observations.is_empty()
}
pub fn last_value(&self, addr: Address, slot: U256) -> Option<U256> {
let key = SlotKey {
address: addr,
slot,
};
self.observations.get(&key).map(|o| o.last_value)
}
}
impl Default for SlotObservationTracker {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn addr(n: u8) -> Address {
Address::new([n; 20])
}
fn params() -> FreshnessParams {
FreshnessParams::default()
}
#[test]
fn test_unknown_slot_always_refetches() {
let tracker = SlotObservationTracker::new();
assert!(tracker.should_refetch(addr(1), U256::from(0), 100, ¶ms()));
}
#[test]
fn test_insufficient_observations_refetches() {
let mut tracker = SlotObservationTracker::new();
let p = params();
let a = addr(1);
let slot = U256::from(4);
for now in 0..(p.min_observations - 1) {
tracker.observe(a, slot, U256::from(42), now as u64);
}
assert!(tracker.should_refetch(a, slot, p.min_observations as u64, &p));
}
#[test]
fn test_never_changed_slot_skips_refetch() {
let mut tracker = SlotObservationTracker::new();
let p = params();
let a = addr(1);
let slot = U256::from(4);
let value = U256::from(42);
for now in 0..p.min_observations {
tracker.observe(a, slot, value, now as u64);
}
assert!(!tracker.should_refetch(a, slot, p.min_observations as u64 - 1, &p));
}
#[test]
fn test_never_changed_slot_refetches_past_max_reuse() {
let mut tracker = SlotObservationTracker::new();
let p = params();
let a = addr(1);
let slot = U256::from(4);
for now in 0..p.min_observations {
tracker.observe(a, slot, U256::from(42), now as u64);
}
let now = p.min_observations as u64 + p.max_reuse + 1;
assert!(tracker.should_refetch(a, slot, now, &p));
}
#[test]
fn test_always_changing_slot_refetches() {
let mut tracker = SlotObservationTracker::new();
let p = params();
let a = addr(1);
let slot = U256::from(4);
for now in 0..(p.min_observations + 1) {
tracker.observe(a, slot, U256::from(now), now as u64);
}
assert!(tracker.should_refetch(a, slot, p.min_observations as u64 + 1, &p));
}
#[test]
fn test_observe_returns_changed() {
let mut tracker = SlotObservationTracker::new();
let a = addr(1);
let slot = U256::from(0);
assert!(!tracker.observe(a, slot, U256::from(1), 0)); assert!(!tracker.observe(a, slot, U256::from(1), 1)); assert!(tracker.observe(a, slot, U256::from(2), 2)); assert!(!tracker.observe(a, slot, U256::from(2), 3)); }
#[test]
fn test_observe_records_change_clock() {
let mut tracker = SlotObservationTracker::new();
let a = addr(1);
let slot = U256::from(0);
tracker.observe(a, slot, U256::from(1), 10); tracker.observe(a, slot, U256::from(2), 25); let key = SlotKey { address: a, slot };
let obs = &tracker.observations[&key];
assert_eq!(obs.last_checked, 25);
assert_eq!(obs.last_changed, 25);
assert_eq!(obs.change_count, 1);
assert_eq!(obs.observation_count, 2);
}
#[test]
fn test_reset_contract_clears_observations() {
let mut tracker = SlotObservationTracker::new();
let p = params();
let a = addr(1);
for i in 0..p.min_observations {
tracker.observe(a, U256::from(i), U256::from(42), i as u64);
}
assert!(!tracker.is_empty());
tracker.reset_contract(a);
assert_eq!(tracker.len(), 0);
assert!(tracker.should_refetch(a, U256::from(0), 100, &p));
}
#[test]
fn test_skipped_slots_tracking() {
let mut tracker = SlotObservationTracker::new();
tracker.begin_cycle();
tracker.record_skip(addr(1), U256::from(0));
tracker.record_skip(addr(1), U256::from(4));
tracker.record_skip(addr(2), U256::from(8));
let skipped = tracker.take_skipped();
assert_eq!(skipped.len(), 3);
assert!(tracker.take_skipped().is_empty());
}
#[test]
fn test_begin_cycle_clears_skipped() {
let mut tracker = SlotObservationTracker::new();
tracker.record_skip(addr(1), U256::from(0));
tracker.begin_cycle();
assert!(tracker.take_skipped().is_empty());
}
fn temp_path(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"evm_fork_cache_slot_obs_{tag}_{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp dir");
dir.join("observations.bin")
}
#[test]
fn test_save_load_round_trip() {
let path = temp_path("round_trip");
let mut tracker = SlotObservationTracker::new();
let a = addr(1);
tracker.observe(a, U256::from(0), U256::from(100), 0);
tracker.observe(a, U256::from(4), U256::from(200), 0);
tracker.save(&path).unwrap();
let data = std::fs::read(&path).expect("read saved observations");
assert!(
data.starts_with(b"EFC-SOBS"),
"slot observation files must carry a magic/version header"
);
let loaded = SlotObservationTracker::load(&path);
assert_eq!(loaded.len(), 2);
assert_eq!(loaded.last_value(a, U256::from(0)), Some(U256::from(100)));
assert_eq!(loaded.last_value(a, U256::from(4)), Some(U256::from(200)));
}
#[test]
fn legacy_raw_bincode_loads_as_default() {
let path = temp_path("legacy");
let a = addr(1);
let slot = U256::from(4);
let mut observations = HashMap::new();
observations.insert(
SlotKey { address: a, slot },
SlotObservation {
last_value: U256::from(42),
observation_count: 3,
change_count: 0,
last_checked: 2,
last_changed: 0,
},
);
let legacy = bincode::serialize(&observations).expect("serialize legacy observations");
std::fs::write(&path, legacy).expect("write legacy observations");
let loaded = SlotObservationTracker::load(&path);
assert!(
loaded.is_empty(),
"legacy raw bincode must be treated as a cache miss"
);
}
#[test]
fn test_last_value() {
let mut tracker = SlotObservationTracker::new();
let a = addr(1);
assert_eq!(tracker.last_value(a, U256::from(0)), None);
tracker.observe(a, U256::from(0), U256::from(42), 0);
assert_eq!(tracker.last_value(a, U256::from(0)), Some(U256::from(42)));
tracker.observe(a, U256::from(0), U256::from(99), 1);
assert_eq!(tracker.last_value(a, U256::from(0)), Some(U256::from(99)));
}
fn seed_obs(
tracker: &mut SlotObservationTracker,
a: Address,
slot: U256,
observation_count: u32,
change_count: u32,
last_checked: u64,
) {
tracker.observations.insert(
SlotKey { address: a, slot },
SlotObservation {
last_value: U256::from(1),
observation_count,
change_count,
last_checked,
last_changed: last_checked,
},
);
}
#[test]
fn test_probabilistic_refetches_at_now_equals_last_checked() {
let mut tracker = SlotObservationTracker::new();
let p = params();
let a = addr(1);
let slot = U256::from(7);
seed_obs(&mut tracker, a, slot, 20, 3, 100);
assert!((3.0_f64 / 20.0) < p.always_refetch_rate);
assert!(tracker.should_refetch(a, slot, 100, &p));
}
#[test]
fn test_probabilistic_reuses_then_refetches_after_elapsed() {
let mut tracker = SlotObservationTracker::new();
let p = params();
let a = addr(1);
let slot = U256::from(7);
seed_obs(&mut tracker, a, slot, 100, 1, 100);
assert!(!tracker.should_refetch(a, slot, 100, &p));
assert!(!tracker.should_refetch(a, slot, 104, &p));
assert!(tracker.should_refetch(a, slot, 110, &p));
}
#[test]
fn test_probabilistic_cycle_interval_scaling() {
let mut tracker = SlotObservationTracker::new();
let p = FreshnessParams {
cycle_interval: 10,
..FreshnessParams::default()
};
let a = addr(1);
let slot = U256::from(7);
seed_obs(&mut tracker, a, slot, 100, 1, 100);
assert!(tracker.should_refetch(a, slot, 160, &p));
assert!(!tracker.should_refetch(a, slot, 140, &p));
let unit = FreshnessParams::default();
assert!(
tracker.should_refetch(a, slot, 140, &unit),
"with cycle_interval = 1 the same elapsed gap refetches"
);
}
}