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 #[test]
452 fn test_save_load_round_trip() {
453 let dir = std::env::temp_dir().join("evm_fork_cache_test_slot_obs");
454 let path = dir.join("test_observations.bin");
455 let _ = std::fs::remove_file(&path);
456
457 let mut tracker = SlotObservationTracker::new();
458 let a = addr(1);
459 tracker.observe(a, U256::from(0), U256::from(100), 0);
460 tracker.observe(a, U256::from(4), U256::from(200), 0);
461 tracker.save(&path).unwrap();
462 let data = std::fs::read(&path).expect("read saved observations");
463 assert!(
464 data.starts_with(b"EFC-SOBS"),
465 "slot observation files must carry a magic/version header"
466 );
467
468 let loaded = SlotObservationTracker::load(&path);
469 assert_eq!(loaded.len(), 2);
470 assert_eq!(loaded.last_value(a, U256::from(0)), Some(U256::from(100)));
471 assert_eq!(loaded.last_value(a, U256::from(4)), Some(U256::from(200)));
472
473 let _ = std::fs::remove_file(&path);
474 let _ = std::fs::remove_dir(&dir);
475 }
476
477 #[test]
478 fn legacy_raw_bincode_loads_as_default() {
479 let dir = std::env::temp_dir().join("evm_fork_cache_test_slot_obs_legacy");
480 let path = dir.join("legacy_observations.bin");
481 let _ = std::fs::remove_dir_all(&dir);
482 std::fs::create_dir_all(&dir).expect("create temp dir");
483
484 let a = addr(1);
485 let slot = U256::from(4);
486 let mut observations = HashMap::new();
487 observations.insert(
488 SlotKey { address: a, slot },
489 SlotObservation {
490 last_value: U256::from(42),
491 observation_count: 3,
492 change_count: 0,
493 last_checked: 2,
494 last_changed: 0,
495 },
496 );
497 let legacy = bincode::serialize(&observations).expect("serialize legacy observations");
498 std::fs::write(&path, legacy).expect("write legacy observations");
499
500 let loaded = SlotObservationTracker::load(&path);
501 assert!(
502 loaded.is_empty(),
503 "legacy raw bincode must be treated as a cache miss"
504 );
505
506 let _ = std::fs::remove_dir_all(&dir);
507 }
508
509 #[test]
510 fn test_last_value() {
511 let mut tracker = SlotObservationTracker::new();
512 let a = addr(1);
513 assert_eq!(tracker.last_value(a, U256::from(0)), None);
514 tracker.observe(a, U256::from(0), U256::from(42), 0);
515 assert_eq!(tracker.last_value(a, U256::from(0)), Some(U256::from(42)));
516 tracker.observe(a, U256::from(0), U256::from(99), 1);
517 assert_eq!(tracker.last_value(a, U256::from(0)), Some(U256::from(99)));
518 }
519
520 fn seed_obs(
526 tracker: &mut SlotObservationTracker,
527 a: Address,
528 slot: U256,
529 observation_count: u32,
530 change_count: u32,
531 last_checked: u64,
532 ) {
533 tracker.observations.insert(
534 SlotKey { address: a, slot },
535 SlotObservation {
536 last_value: U256::from(1),
537 observation_count,
538 change_count,
539 last_checked,
540 last_changed: last_checked,
541 },
542 );
543 }
544
545 #[test]
546 fn test_probabilistic_refetches_at_now_equals_last_checked() {
547 let mut tracker = SlotObservationTracker::new();
550 let p = params();
551 let a = addr(1);
552 let slot = U256::from(7);
553 seed_obs(&mut tracker, a, slot, 20, 3, 100);
554 assert!((3.0_f64 / 20.0) < p.always_refetch_rate);
556 assert!(tracker.should_refetch(a, slot, 100, &p));
557 }
558
559 #[test]
560 fn test_probabilistic_reuses_then_refetches_after_elapsed() {
561 let mut tracker = SlotObservationTracker::new();
565 let p = params();
566 let a = addr(1);
567 let slot = U256::from(7);
568 seed_obs(&mut tracker, a, slot, 100, 1, 100);
569
570 assert!(!tracker.should_refetch(a, slot, 100, &p));
572 assert!(!tracker.should_refetch(a, slot, 104, &p));
574 assert!(tracker.should_refetch(a, slot, 110, &p));
576 }
577
578 #[test]
579 fn test_probabilistic_cycle_interval_scaling() {
580 let mut tracker = SlotObservationTracker::new();
584 let p = FreshnessParams {
585 cycle_interval: 10,
586 ..FreshnessParams::default()
587 };
588 let a = addr(1);
589 let slot = U256::from(7);
590 seed_obs(&mut tracker, a, slot, 100, 1, 100);
591
592 assert!(tracker.should_refetch(a, slot, 160, &p));
594 assert!(!tracker.should_refetch(a, slot, 140, &p));
598 let unit = FreshnessParams::default();
599 assert!(
600 tracker.should_refetch(a, slot, 140, &unit),
601 "with cycle_interval = 1 the same elapsed gap refetches"
602 );
603 }
604}