1use std::{collections::HashMap, path::Path};
18
19use alloy_primitives::{Address, U256};
20use serde::{Deserialize, Serialize};
21use tracing::{debug, warn};
22
23use crate::freshness::FreshnessParams;
24
25use super::versioned;
26use crate::errors::PersistenceError;
27
28const SLOT_OBSERVATIONS_MAGIC: &[u8; 8] = b"EFC-SOBS";
29const SLOT_OBSERVATIONS_VERSION: u32 = 1;
30
31#[derive(Serialize, Deserialize, Clone, Debug)]
33pub struct SlotObservation {
34 pub last_value: U256,
36 pub observation_count: u32,
38 pub change_count: u32,
40 pub last_checked: u64,
42 pub last_changed: u64,
44}
45
46#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
48struct SlotKey {
49 address: Address,
50 slot: U256,
51}
52
53pub struct SlotObservationTracker {
60 observations: HashMap<SlotKey, SlotObservation>,
61 skipped_this_cycle: Vec<(Address, U256)>,
63 dirty: bool,
64}
65
66impl SlotObservationTracker {
67 pub fn new() -> Self {
69 Self {
70 observations: HashMap::new(),
71 skipped_this_cycle: Vec::new(),
72 dirty: false,
73 }
74 }
75
76 pub fn load(path: &Path) -> Self {
82 match std::fs::read(path) {
83 Ok(data) => {
84 if let Some(observations) = versioned::decode::<HashMap<SlotKey, SlotObservation>>(
85 &data,
86 SLOT_OBSERVATIONS_MAGIC,
87 SLOT_OBSERVATIONS_VERSION,
88 "slot observations",
89 ) {
90 debug!(
91 entries = observations.len(),
92 "Loaded slot observation tracker"
93 );
94 Self {
95 observations,
96 skipped_this_cycle: Vec::new(),
97 dirty: false,
98 }
99 } else {
100 warn!("Slot observations cache miss, starting fresh");
101 Self::new()
102 }
103 }
104 Err(_) => {
105 debug!("No slot observations file found, starting fresh");
106 Self::new()
107 }
108 }
109 }
110
111 pub fn save(&mut self, path: &Path) -> Result<(), PersistenceError> {
114 if !self.dirty {
115 return Ok(());
116 }
117 if let Some(parent) = path.parent() {
118 std::fs::create_dir_all(parent)
119 .map_err(|err| PersistenceError::create_dir(parent, err))?;
120 }
121 let data = versioned::encode(
122 SLOT_OBSERVATIONS_MAGIC,
123 SLOT_OBSERVATIONS_VERSION,
124 &self.observations,
125 "slot observations",
126 )?;
127 std::fs::write(path, data).map_err(|err| PersistenceError::write(path, err))?;
128 self.dirty = false;
129 debug!(entries = self.observations.len(), "Saved slot observations");
130 Ok(())
131 }
132
133 pub fn should_refetch(
180 &self,
181 addr: Address,
182 slot: U256,
183 now: u64,
184 params: &FreshnessParams,
185 ) -> bool {
186 let key = SlotKey {
187 address: addr,
188 slot,
189 };
190 let Some(obs) = self.observations.get(&key) else {
191 return true; };
193
194 if obs.observation_count < params.min_observations {
196 return true;
197 }
198
199 if now.saturating_sub(obs.last_checked) > params.max_reuse {
201 return true;
202 }
203
204 if obs.change_count == 0 {
206 return false;
207 }
208
209 let change_rate = obs.change_count as f64 / obs.observation_count as f64;
210
211 if change_rate > params.always_refetch_rate {
213 return true;
214 }
215
216 let units_elapsed = now.saturating_sub(obs.last_checked) as f64;
218 let cycle_interval = params.cycle_interval.max(1) as f64;
219 let cycles_elapsed = (units_elapsed / cycle_interval).max(1.0);
220 let expected_changes = change_rate * cycles_elapsed;
221 expected_changes > params.staleness_threshold
222 }
223
224 pub fn observe(&mut self, addr: Address, slot: U256, value: U256, now: u64) -> bool {
229 let key = SlotKey {
230 address: addr,
231 slot,
232 };
233 self.dirty = true;
234
235 match self.observations.get_mut(&key) {
236 Some(obs) => {
237 let changed = obs.last_value != value;
238 obs.observation_count += 1;
239 if changed {
240 obs.change_count += 1;
241 obs.last_changed = now;
242 obs.last_value = value;
243 }
244 obs.last_checked = now;
245 changed
246 }
247 None => {
248 self.observations.insert(
249 key,
250 SlotObservation {
251 last_value: value,
252 observation_count: 1,
253 change_count: 0, last_checked: now,
255 last_changed: 0,
256 },
257 );
258 false
259 }
260 }
261 }
262
263 pub fn record_skip(&mut self, addr: Address, slot: U256) {
266 self.skipped_this_cycle.push((addr, slot));
267 }
268
269 pub fn take_skipped(&mut self) -> Vec<(Address, U256)> {
272 std::mem::take(&mut self.skipped_this_cycle)
273 }
274
275 pub fn reset_contract(&mut self, addr: Address) {
278 self.observations.retain(|k, _| k.address != addr);
279 self.dirty = true;
280 }
281
282 pub fn begin_cycle(&mut self) {
284 self.skipped_this_cycle.clear();
285 }
286
287 pub fn len(&self) -> usize {
289 self.observations.len()
290 }
291
292 pub fn is_empty(&self) -> bool {
294 self.observations.is_empty()
295 }
296
297 pub fn last_value(&self, addr: Address, slot: U256) -> Option<U256> {
299 let key = SlotKey {
300 address: addr,
301 slot,
302 };
303 self.observations.get(&key).map(|o| o.last_value)
304 }
305}
306
307impl Default for SlotObservationTracker {
308 fn default() -> Self {
309 Self::new()
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 fn addr(n: u8) -> Address {
318 Address::new([n; 20])
319 }
320
321 fn params() -> FreshnessParams {
324 FreshnessParams::default()
325 }
326
327 #[test]
328 fn test_unknown_slot_always_refetches() {
329 let tracker = SlotObservationTracker::new();
330 assert!(tracker.should_refetch(addr(1), U256::from(0), 100, ¶ms()));
331 }
332
333 #[test]
334 fn test_insufficient_observations_refetches() {
335 let mut tracker = SlotObservationTracker::new();
336 let p = params();
337 let a = addr(1);
338 let slot = U256::from(4);
339 for now in 0..(p.min_observations - 1) {
341 tracker.observe(a, slot, U256::from(42), now as u64);
342 }
343 assert!(tracker.should_refetch(a, slot, p.min_observations as u64, &p));
344 }
345
346 #[test]
347 fn test_never_changed_slot_skips_refetch() {
348 let mut tracker = SlotObservationTracker::new();
349 let p = params();
350 let a = addr(1);
351 let slot = U256::from(4);
352 let value = U256::from(42);
353 for now in 0..p.min_observations {
355 tracker.observe(a, slot, value, now as u64);
356 }
357 assert!(!tracker.should_refetch(a, slot, p.min_observations as u64 - 1, &p));
359 }
360
361 #[test]
362 fn test_never_changed_slot_refetches_past_max_reuse() {
363 let mut tracker = SlotObservationTracker::new();
364 let p = params();
365 let a = addr(1);
366 let slot = U256::from(4);
367 for now in 0..p.min_observations {
368 tracker.observe(a, slot, U256::from(42), now as u64);
369 }
370 let now = p.min_observations as u64 + p.max_reuse + 1;
372 assert!(tracker.should_refetch(a, slot, now, &p));
373 }
374
375 #[test]
376 fn test_always_changing_slot_refetches() {
377 let mut tracker = SlotObservationTracker::new();
378 let p = params();
379 let a = addr(1);
380 let slot = U256::from(4);
381 for now in 0..(p.min_observations + 1) {
383 tracker.observe(a, slot, U256::from(now), now as u64);
384 }
385 assert!(tracker.should_refetch(a, slot, p.min_observations as u64 + 1, &p));
386 }
387
388 #[test]
389 fn test_observe_returns_changed() {
390 let mut tracker = SlotObservationTracker::new();
391 let a = addr(1);
392 let slot = U256::from(0);
393 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)); }
398
399 #[test]
400 fn test_observe_records_change_clock() {
401 let mut tracker = SlotObservationTracker::new();
402 let a = addr(1);
403 let slot = U256::from(0);
404 tracker.observe(a, slot, U256::from(1), 10); tracker.observe(a, slot, U256::from(2), 25); let key = SlotKey { address: a, slot };
407 let obs = &tracker.observations[&key];
408 assert_eq!(obs.last_checked, 25);
409 assert_eq!(obs.last_changed, 25);
410 assert_eq!(obs.change_count, 1);
411 assert_eq!(obs.observation_count, 2);
412 }
413
414 #[test]
415 fn test_reset_contract_clears_observations() {
416 let mut tracker = SlotObservationTracker::new();
417 let p = params();
418 let a = addr(1);
419 for i in 0..p.min_observations {
420 tracker.observe(a, U256::from(i), U256::from(42), i as u64);
421 }
422 assert!(!tracker.is_empty());
423 tracker.reset_contract(a);
424 assert_eq!(tracker.len(), 0);
425 assert!(tracker.should_refetch(a, U256::from(0), 100, &p));
427 }
428
429 #[test]
430 fn test_skipped_slots_tracking() {
431 let mut tracker = SlotObservationTracker::new();
432 tracker.begin_cycle();
433 tracker.record_skip(addr(1), U256::from(0));
434 tracker.record_skip(addr(1), U256::from(4));
435 tracker.record_skip(addr(2), U256::from(8));
436
437 let skipped = tracker.take_skipped();
438 assert_eq!(skipped.len(), 3);
439 assert!(tracker.take_skipped().is_empty());
441 }
442
443 #[test]
444 fn test_begin_cycle_clears_skipped() {
445 let mut tracker = SlotObservationTracker::new();
446 tracker.record_skip(addr(1), U256::from(0));
447 tracker.begin_cycle();
448 assert!(tracker.take_skipped().is_empty());
449 }
450
451 fn temp_path(tag: &str) -> std::path::PathBuf {
456 let dir = std::env::temp_dir().join(format!(
457 "evm_fork_cache_slot_obs_{tag}_{}",
458 std::process::id()
459 ));
460 let _ = std::fs::remove_dir_all(&dir);
461 std::fs::create_dir_all(&dir).expect("create temp dir");
462 dir.join("observations.bin")
463 }
464
465 #[test]
466 fn test_save_load_round_trip() {
467 let path = temp_path("round_trip");
468
469 let mut tracker = SlotObservationTracker::new();
470 let a = addr(1);
471 tracker.observe(a, U256::from(0), U256::from(100), 0);
472 tracker.observe(a, U256::from(4), U256::from(200), 0);
473 tracker.save(&path).unwrap();
474 let data = std::fs::read(&path).expect("read saved observations");
475 assert!(
476 data.starts_with(b"EFC-SOBS"),
477 "slot observation files must carry a magic/version header"
478 );
479
480 let loaded = SlotObservationTracker::load(&path);
481 assert_eq!(loaded.len(), 2);
482 assert_eq!(loaded.last_value(a, U256::from(0)), Some(U256::from(100)));
483 assert_eq!(loaded.last_value(a, U256::from(4)), Some(U256::from(200)));
484 }
485
486 #[test]
487 fn legacy_raw_bincode_loads_as_default() {
488 let path = temp_path("legacy");
489
490 let a = addr(1);
491 let slot = U256::from(4);
492 let mut observations = HashMap::new();
493 observations.insert(
494 SlotKey { address: a, slot },
495 SlotObservation {
496 last_value: U256::from(42),
497 observation_count: 3,
498 change_count: 0,
499 last_checked: 2,
500 last_changed: 0,
501 },
502 );
503 let legacy = bincode::serialize(&observations).expect("serialize legacy observations");
504 std::fs::write(&path, legacy).expect("write legacy observations");
505
506 let loaded = SlotObservationTracker::load(&path);
507 assert!(
508 loaded.is_empty(),
509 "legacy raw bincode must be treated as a cache miss"
510 );
511 }
512
513 #[test]
514 fn test_last_value() {
515 let mut tracker = SlotObservationTracker::new();
516 let a = addr(1);
517 assert_eq!(tracker.last_value(a, U256::from(0)), None);
518 tracker.observe(a, U256::from(0), U256::from(42), 0);
519 assert_eq!(tracker.last_value(a, U256::from(0)), Some(U256::from(42)));
520 tracker.observe(a, U256::from(0), U256::from(99), 1);
521 assert_eq!(tracker.last_value(a, U256::from(0)), Some(U256::from(99)));
522 }
523
524 fn seed_obs(
530 tracker: &mut SlotObservationTracker,
531 a: Address,
532 slot: U256,
533 observation_count: u32,
534 change_count: u32,
535 last_checked: u64,
536 ) {
537 tracker.observations.insert(
538 SlotKey { address: a, slot },
539 SlotObservation {
540 last_value: U256::from(1),
541 observation_count,
542 change_count,
543 last_checked,
544 last_changed: last_checked,
545 },
546 );
547 }
548
549 #[test]
550 fn test_probabilistic_refetches_at_now_equals_last_checked() {
551 let mut tracker = SlotObservationTracker::new();
554 let p = params();
555 let a = addr(1);
556 let slot = U256::from(7);
557 seed_obs(&mut tracker, a, slot, 20, 3, 100);
558 assert!((3.0_f64 / 20.0) < p.always_refetch_rate);
560 assert!(tracker.should_refetch(a, slot, 100, &p));
561 }
562
563 #[test]
564 fn test_probabilistic_reuses_then_refetches_after_elapsed() {
565 let mut tracker = SlotObservationTracker::new();
569 let p = params();
570 let a = addr(1);
571 let slot = U256::from(7);
572 seed_obs(&mut tracker, a, slot, 100, 1, 100);
573
574 assert!(!tracker.should_refetch(a, slot, 100, &p));
576 assert!(!tracker.should_refetch(a, slot, 104, &p));
578 assert!(tracker.should_refetch(a, slot, 110, &p));
580 }
581
582 #[test]
583 fn test_probabilistic_cycle_interval_scaling() {
584 let mut tracker = SlotObservationTracker::new();
588 let p = FreshnessParams {
589 cycle_interval: 10,
590 ..FreshnessParams::default()
591 };
592 let a = addr(1);
593 let slot = U256::from(7);
594 seed_obs(&mut tracker, a, slot, 100, 1, 100);
595
596 assert!(tracker.should_refetch(a, slot, 160, &p));
598 assert!(!tracker.should_refetch(a, slot, 140, &p));
602 let unit = FreshnessParams::default();
603 assert!(
604 tracker.should_refetch(a, slot, 140, &unit),
605 "with cycle_interval = 1 the same elapsed gap refetches"
606 );
607 }
608}