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;
26
27const SLOT_OBSERVATIONS_MAGIC: &[u8; 8] = b"EFC-SOBS";
28const SLOT_OBSERVATIONS_VERSION: u32 = 1;
29
30#[derive(Serialize, Deserialize, Clone, Debug)]
32pub struct SlotObservation {
33 pub last_value: U256,
35 pub observation_count: u32,
37 pub change_count: u32,
39 pub last_checked: u64,
41 pub last_changed: u64,
43}
44
45#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
47struct SlotKey {
48 address: Address,
49 slot: U256,
50}
51
52pub struct SlotObservationTracker {
59 observations: HashMap<SlotKey, SlotObservation>,
60 skipped_this_cycle: Vec<(Address, U256)>,
62 dirty: bool,
63}
64
65impl SlotObservationTracker {
66 pub fn new() -> Self {
67 Self {
68 observations: HashMap::new(),
69 skipped_this_cycle: Vec::new(),
70 dirty: false,
71 }
72 }
73
74 pub fn load(path: &Path) -> Self {
80 match std::fs::read(path) {
81 Ok(data) => {
82 if let Some(observations) = versioned::decode::<HashMap<SlotKey, SlotObservation>>(
83 &data,
84 SLOT_OBSERVATIONS_MAGIC,
85 SLOT_OBSERVATIONS_VERSION,
86 "slot observations",
87 ) {
88 debug!(
89 entries = observations.len(),
90 "Loaded slot observation tracker"
91 );
92 Self {
93 observations,
94 skipped_this_cycle: Vec::new(),
95 dirty: false,
96 }
97 } else {
98 warn!("Slot observations cache miss, starting fresh");
99 Self::new()
100 }
101 }
102 Err(_) => {
103 debug!("No slot observations file found, starting fresh");
104 Self::new()
105 }
106 }
107 }
108
109 pub fn save(&mut self, path: &Path) -> anyhow::Result<()> {
112 if !self.dirty {
113 return Ok(());
114 }
115 if let Some(parent) = path.parent() {
116 std::fs::create_dir_all(parent)?;
117 }
118 let data = versioned::encode(
119 SLOT_OBSERVATIONS_MAGIC,
120 SLOT_OBSERVATIONS_VERSION,
121 &self.observations,
122 "slot observations",
123 )?;
124 std::fs::write(path, data)?;
125 self.dirty = false;
126 debug!(entries = self.observations.len(), "Saved slot observations");
127 Ok(())
128 }
129
130 pub fn should_refetch(
177 &self,
178 addr: Address,
179 slot: U256,
180 now: u64,
181 params: &FreshnessParams,
182 ) -> bool {
183 let key = SlotKey {
184 address: addr,
185 slot,
186 };
187 let Some(obs) = self.observations.get(&key) else {
188 return true; };
190
191 if obs.observation_count < params.min_observations {
193 return true;
194 }
195
196 if now.saturating_sub(obs.last_checked) > params.max_reuse {
198 return true;
199 }
200
201 if obs.change_count == 0 {
203 return false;
204 }
205
206 let change_rate = obs.change_count as f64 / obs.observation_count as f64;
207
208 if change_rate > params.always_refetch_rate {
210 return true;
211 }
212
213 let units_elapsed = now.saturating_sub(obs.last_checked) as f64;
215 let cycle_interval = params.cycle_interval.max(1) as f64;
216 let cycles_elapsed = (units_elapsed / cycle_interval).max(1.0);
217 let expected_changes = change_rate * cycles_elapsed;
218 expected_changes > params.staleness_threshold
219 }
220
221 pub fn observe(&mut self, addr: Address, slot: U256, value: U256, now: u64) -> bool {
226 let key = SlotKey {
227 address: addr,
228 slot,
229 };
230 self.dirty = true;
231
232 match self.observations.get_mut(&key) {
233 Some(obs) => {
234 let changed = obs.last_value != value;
235 obs.observation_count += 1;
236 if changed {
237 obs.change_count += 1;
238 obs.last_changed = now;
239 obs.last_value = value;
240 }
241 obs.last_checked = now;
242 changed
243 }
244 None => {
245 self.observations.insert(
246 key,
247 SlotObservation {
248 last_value: value,
249 observation_count: 1,
250 change_count: 0, last_checked: now,
252 last_changed: 0,
253 },
254 );
255 false
256 }
257 }
258 }
259
260 pub fn record_skip(&mut self, addr: Address, slot: U256) {
263 self.skipped_this_cycle.push((addr, slot));
264 }
265
266 pub fn take_skipped(&mut self) -> Vec<(Address, U256)> {
269 std::mem::take(&mut self.skipped_this_cycle)
270 }
271
272 pub fn reset_contract(&mut self, addr: Address) {
275 self.observations.retain(|k, _| k.address != addr);
276 self.dirty = true;
277 }
278
279 pub fn begin_cycle(&mut self) {
281 self.skipped_this_cycle.clear();
282 }
283
284 pub fn len(&self) -> usize {
286 self.observations.len()
287 }
288
289 pub fn is_empty(&self) -> bool {
291 self.observations.is_empty()
292 }
293
294 pub fn last_value(&self, addr: Address, slot: U256) -> Option<U256> {
296 let key = SlotKey {
297 address: addr,
298 slot,
299 };
300 self.observations.get(&key).map(|o| o.last_value)
301 }
302}
303
304impl Default for SlotObservationTracker {
305 fn default() -> Self {
306 Self::new()
307 }
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 fn addr(n: u8) -> Address {
315 Address::new([n; 20])
316 }
317
318 fn params() -> FreshnessParams {
321 FreshnessParams::default()
322 }
323
324 #[test]
325 fn test_unknown_slot_always_refetches() {
326 let tracker = SlotObservationTracker::new();
327 assert!(tracker.should_refetch(addr(1), U256::from(0), 100, ¶ms()));
328 }
329
330 #[test]
331 fn test_insufficient_observations_refetches() {
332 let mut tracker = SlotObservationTracker::new();
333 let p = params();
334 let a = addr(1);
335 let slot = U256::from(4);
336 for now in 0..(p.min_observations - 1) {
338 tracker.observe(a, slot, U256::from(42), now as u64);
339 }
340 assert!(tracker.should_refetch(a, slot, p.min_observations as u64, &p));
341 }
342
343 #[test]
344 fn test_never_changed_slot_skips_refetch() {
345 let mut tracker = SlotObservationTracker::new();
346 let p = params();
347 let a = addr(1);
348 let slot = U256::from(4);
349 let value = U256::from(42);
350 for now in 0..p.min_observations {
352 tracker.observe(a, slot, value, now as u64);
353 }
354 assert!(!tracker.should_refetch(a, slot, p.min_observations as u64 - 1, &p));
356 }
357
358 #[test]
359 fn test_never_changed_slot_refetches_past_max_reuse() {
360 let mut tracker = SlotObservationTracker::new();
361 let p = params();
362 let a = addr(1);
363 let slot = U256::from(4);
364 for now in 0..p.min_observations {
365 tracker.observe(a, slot, U256::from(42), now as u64);
366 }
367 let now = p.min_observations as u64 + p.max_reuse + 1;
369 assert!(tracker.should_refetch(a, slot, now, &p));
370 }
371
372 #[test]
373 fn test_always_changing_slot_refetches() {
374 let mut tracker = SlotObservationTracker::new();
375 let p = params();
376 let a = addr(1);
377 let slot = U256::from(4);
378 for now in 0..(p.min_observations + 1) {
380 tracker.observe(a, slot, U256::from(now), now as u64);
381 }
382 assert!(tracker.should_refetch(a, slot, p.min_observations as u64 + 1, &p));
383 }
384
385 #[test]
386 fn test_observe_returns_changed() {
387 let mut tracker = SlotObservationTracker::new();
388 let a = addr(1);
389 let slot = U256::from(0);
390 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)); }
395
396 #[test]
397 fn test_observe_records_change_clock() {
398 let mut tracker = SlotObservationTracker::new();
399 let a = addr(1);
400 let slot = U256::from(0);
401 tracker.observe(a, slot, U256::from(1), 10); tracker.observe(a, slot, U256::from(2), 25); let key = SlotKey { address: a, slot };
404 let obs = &tracker.observations[&key];
405 assert_eq!(obs.last_checked, 25);
406 assert_eq!(obs.last_changed, 25);
407 assert_eq!(obs.change_count, 1);
408 assert_eq!(obs.observation_count, 2);
409 }
410
411 #[test]
412 fn test_reset_contract_clears_observations() {
413 let mut tracker = SlotObservationTracker::new();
414 let p = params();
415 let a = addr(1);
416 for i in 0..p.min_observations {
417 tracker.observe(a, U256::from(i), U256::from(42), i as u64);
418 }
419 assert!(!tracker.is_empty());
420 tracker.reset_contract(a);
421 assert_eq!(tracker.len(), 0);
422 assert!(tracker.should_refetch(a, U256::from(0), 100, &p));
424 }
425
426 #[test]
427 fn test_skipped_slots_tracking() {
428 let mut tracker = SlotObservationTracker::new();
429 tracker.begin_cycle();
430 tracker.record_skip(addr(1), U256::from(0));
431 tracker.record_skip(addr(1), U256::from(4));
432 tracker.record_skip(addr(2), U256::from(8));
433
434 let skipped = tracker.take_skipped();
435 assert_eq!(skipped.len(), 3);
436 assert!(tracker.take_skipped().is_empty());
438 }
439
440 #[test]
441 fn test_begin_cycle_clears_skipped() {
442 let mut tracker = SlotObservationTracker::new();
443 tracker.record_skip(addr(1), U256::from(0));
444 tracker.begin_cycle();
445 assert!(tracker.take_skipped().is_empty());
446 }
447
448 #[test]
449 fn test_save_load_round_trip() {
450 let dir = std::env::temp_dir().join("evm_fork_cache_test_slot_obs");
451 let path = dir.join("test_observations.bin");
452 let _ = std::fs::remove_file(&path);
453
454 let mut tracker = SlotObservationTracker::new();
455 let a = addr(1);
456 tracker.observe(a, U256::from(0), U256::from(100), 0);
457 tracker.observe(a, U256::from(4), U256::from(200), 0);
458 tracker.save(&path).unwrap();
459 let data = std::fs::read(&path).expect("read saved observations");
460 assert!(
461 data.starts_with(b"EFC-SOBS"),
462 "slot observation files must carry a magic/version header"
463 );
464
465 let loaded = SlotObservationTracker::load(&path);
466 assert_eq!(loaded.len(), 2);
467 assert_eq!(loaded.last_value(a, U256::from(0)), Some(U256::from(100)));
468 assert_eq!(loaded.last_value(a, U256::from(4)), Some(U256::from(200)));
469
470 let _ = std::fs::remove_file(&path);
471 let _ = std::fs::remove_dir(&dir);
472 }
473
474 #[test]
475 fn legacy_raw_bincode_loads_as_default() {
476 let dir = std::env::temp_dir().join("evm_fork_cache_test_slot_obs_legacy");
477 let path = dir.join("legacy_observations.bin");
478 let _ = std::fs::remove_dir_all(&dir);
479 std::fs::create_dir_all(&dir).expect("create temp dir");
480
481 let a = addr(1);
482 let slot = U256::from(4);
483 let mut observations = HashMap::new();
484 observations.insert(
485 SlotKey { address: a, slot },
486 SlotObservation {
487 last_value: U256::from(42),
488 observation_count: 3,
489 change_count: 0,
490 last_checked: 2,
491 last_changed: 0,
492 },
493 );
494 let legacy = bincode::serialize(&observations).expect("serialize legacy observations");
495 std::fs::write(&path, legacy).expect("write legacy observations");
496
497 let loaded = SlotObservationTracker::load(&path);
498 assert!(
499 loaded.is_empty(),
500 "legacy raw bincode must be treated as a cache miss"
501 );
502
503 let _ = std::fs::remove_dir_all(&dir);
504 }
505
506 #[test]
507 fn test_last_value() {
508 let mut tracker = SlotObservationTracker::new();
509 let a = addr(1);
510 assert_eq!(tracker.last_value(a, U256::from(0)), None);
511 tracker.observe(a, U256::from(0), U256::from(42), 0);
512 assert_eq!(tracker.last_value(a, U256::from(0)), Some(U256::from(42)));
513 tracker.observe(a, U256::from(0), U256::from(99), 1);
514 assert_eq!(tracker.last_value(a, U256::from(0)), Some(U256::from(99)));
515 }
516
517 fn seed_obs(
523 tracker: &mut SlotObservationTracker,
524 a: Address,
525 slot: U256,
526 observation_count: u32,
527 change_count: u32,
528 last_checked: u64,
529 ) {
530 tracker.observations.insert(
531 SlotKey { address: a, slot },
532 SlotObservation {
533 last_value: U256::from(1),
534 observation_count,
535 change_count,
536 last_checked,
537 last_changed: last_checked,
538 },
539 );
540 }
541
542 #[test]
543 fn test_probabilistic_refetches_at_now_equals_last_checked() {
544 let mut tracker = SlotObservationTracker::new();
547 let p = params();
548 let a = addr(1);
549 let slot = U256::from(7);
550 seed_obs(&mut tracker, a, slot, 20, 3, 100);
551 assert!((3.0_f64 / 20.0) < p.always_refetch_rate);
553 assert!(tracker.should_refetch(a, slot, 100, &p));
554 }
555
556 #[test]
557 fn test_probabilistic_reuses_then_refetches_after_elapsed() {
558 let mut tracker = SlotObservationTracker::new();
562 let p = params();
563 let a = addr(1);
564 let slot = U256::from(7);
565 seed_obs(&mut tracker, a, slot, 100, 1, 100);
566
567 assert!(!tracker.should_refetch(a, slot, 100, &p));
569 assert!(!tracker.should_refetch(a, slot, 104, &p));
571 assert!(tracker.should_refetch(a, slot, 110, &p));
573 }
574
575 #[test]
576 fn test_probabilistic_cycle_interval_scaling() {
577 let mut tracker = SlotObservationTracker::new();
581 let p = FreshnessParams {
582 cycle_interval: 10,
583 ..FreshnessParams::default()
584 };
585 let a = addr(1);
586 let slot = U256::from(7);
587 seed_obs(&mut tracker, a, slot, 100, 1, 100);
588
589 assert!(tracker.should_refetch(a, slot, 160, &p));
591 assert!(!tracker.should_refetch(a, slot, 140, &p));
595 let unit = FreshnessParams::default();
596 assert!(
597 tracker.should_refetch(a, slot, 140, &unit),
598 "with cycle_interval = 1 the same elapsed gap refetches"
599 );
600 }
601}