1use crate::ant_protocol::XorName;
24use crate::error::{Error, Result};
25use crate::logging::{debug, trace, warn};
26use heed::types::Bytes;
27use heed::{Database, Env, EnvOpenOptions};
28use parking_lot::RwLock;
29use std::collections::HashMap;
30use std::path::Path;
31use std::time::Instant;
32use tokio::task::spawn_blocking;
33
34use crate::ant_protocol::XORNAME_LEN;
35
36const DEFAULT_MAP_SIZE: usize = 256 * 1_024 * 1_024;
41
42pub struct PaidList {
47 env: Env,
49 db: Database<Bytes, Bytes>,
51 paid_out_of_range: RwLock<HashMap<XorName, Instant>>,
54 record_out_of_range: RwLock<HashMap<XorName, Instant>>,
57 paid_prune_cursor: RwLock<usize>,
60}
61
62impl PaidList {
63 #[allow(unsafe_code)]
70 pub async fn new(root_dir: &Path) -> Result<Self> {
71 let env_dir = root_dir.join("paid_list.mdb");
72
73 std::fs::create_dir_all(&env_dir)
74 .map_err(|e| Error::Storage(format!("Failed to create paid-list directory: {e}")))?;
75
76 let env_dir_clone = env_dir.clone();
77 let (env, db) = spawn_blocking(move || -> Result<(Env, Database<Bytes, Bytes>)> {
78 let env = unsafe {
85 EnvOpenOptions::new()
86 .map_size(DEFAULT_MAP_SIZE)
87 .max_dbs(1)
88 .open(&env_dir_clone)
89 .map_err(|e| {
90 Error::Storage(format!("Failed to open paid-list LMDB env: {e}"))
91 })?
92 };
93
94 let mut wtxn = env
95 .write_txn()
96 .map_err(|e| Error::Storage(format!("Failed to create write txn: {e}")))?;
97 let db: Database<Bytes, Bytes> = env
98 .create_database(&mut wtxn, None)
99 .map_err(|e| Error::Storage(format!("Failed to create paid-list database: {e}")))?;
100 wtxn.commit()
101 .map_err(|e| Error::Storage(format!("Failed to commit db creation: {e}")))?;
102
103 Ok((env, db))
104 })
105 .await
106 .map_err(|e| Error::Storage(format!("Paid-list init task failed: {e}")))??;
107
108 let paid_list = Self {
109 env,
110 db,
111 paid_out_of_range: RwLock::new(HashMap::new()),
112 record_out_of_range: RwLock::new(HashMap::new()),
113 paid_prune_cursor: RwLock::new(0),
114 };
115
116 let count = paid_list.count()?;
117 debug!("Initialized paid-list at {env_dir:?} ({count} existing keys)");
118
119 Ok(paid_list)
120 }
121
122 pub async fn insert(&self, key: &XorName) -> Result<bool> {
130 if self.contains(key)? {
132 trace!("Paid-list key {} already present", hex::encode(key));
133 return Ok(false);
134 }
135
136 let key_owned = *key;
137 let env = self.env.clone();
138 let db = self.db;
139
140 let was_new = spawn_blocking(move || -> Result<bool> {
141 let mut wtxn = env
142 .write_txn()
143 .map_err(|e| Error::Storage(format!("Failed to create write txn: {e}")))?;
144
145 if db
147 .get(&wtxn, &key_owned)
148 .map_err(|e| Error::Storage(format!("Failed to check paid-list existence: {e}")))?
149 .is_some()
150 {
151 return Ok(false);
152 }
153
154 db.put(&mut wtxn, &key_owned, &[])
155 .map_err(|e| Error::Storage(format!("Failed to insert into paid-list: {e}")))?;
156 wtxn.commit()
157 .map_err(|e| Error::Storage(format!("Failed to commit paid-list insert: {e}")))?;
158
159 Ok(true)
160 })
161 .await
162 .map_err(|e| Error::Storage(format!("Paid-list insert task failed: {e}")))??;
163
164 if was_new {
165 debug!("Added key {} to paid-list", hex::encode(key));
166 }
167
168 Ok(was_new)
169 }
170
171 pub async fn remove(&self, key: &XorName) -> Result<bool> {
181 let key_owned = *key;
182 let env = self.env.clone();
183 let db = self.db;
184
185 let existed = spawn_blocking(move || -> Result<bool> {
186 let mut wtxn = env
187 .write_txn()
188 .map_err(|e| Error::Storage(format!("Failed to create write txn: {e}")))?;
189 let deleted = db
190 .delete(&mut wtxn, &key_owned)
191 .map_err(|e| Error::Storage(format!("Failed to delete from paid-list: {e}")))?;
192 wtxn.commit()
193 .map_err(|e| Error::Storage(format!("Failed to commit paid-list delete: {e}")))?;
194 Ok(deleted)
195 })
196 .await
197 .map_err(|e| Error::Storage(format!("Paid-list remove task failed: {e}")))??;
198
199 if existed {
200 self.paid_out_of_range.write().remove(key);
201 self.record_out_of_range.write().remove(key);
202 debug!("Removed key {} from paid-list", hex::encode(key));
203 }
204
205 Ok(existed)
206 }
207
208 pub fn contains(&self, key: &XorName) -> Result<bool> {
216 let rtxn = self
217 .env
218 .read_txn()
219 .map_err(|e| Error::Storage(format!("Failed to create read txn: {e}")))?;
220 let found = self
221 .db
222 .get(&rtxn, key.as_ref())
223 .map_err(|e| Error::Storage(format!("Failed to check paid-list membership: {e}")))?
224 .is_some();
225 Ok(found)
226 }
227
228 pub fn count(&self) -> Result<u64> {
236 let rtxn = self
237 .env
238 .read_txn()
239 .map_err(|e| Error::Storage(format!("Failed to create read txn: {e}")))?;
240 let entries = self
241 .db
242 .stat(&rtxn)
243 .map_err(|e| Error::Storage(format!("Failed to read paid-list stats: {e}")))?
244 .entries;
245 Ok(entries as u64)
246 }
247
248 pub fn all_keys(&self) -> Result<Vec<XorName>> {
256 let rtxn = self
257 .env
258 .read_txn()
259 .map_err(|e| Error::Storage(format!("Failed to create read txn: {e}")))?;
260 let mut keys = Vec::new();
261 let iter = self
262 .db
263 .iter(&rtxn)
264 .map_err(|e| Error::Storage(format!("Failed to iterate paid-list: {e}")))?;
265 for result in iter {
266 let (key_bytes, _) = result
267 .map_err(|e| Error::Storage(format!("Failed to read paid-list entry: {e}")))?;
268 if key_bytes.len() == XORNAME_LEN {
269 let mut key = [0u8; XORNAME_LEN];
270 key.copy_from_slice(key_bytes);
271 keys.push(key);
272 } else {
273 warn!(
274 "PaidList: skipping entry with unexpected key length {} (expected {XORNAME_LEN})",
275 key_bytes.len()
276 );
277 }
278 }
279 Ok(keys)
280 }
281
282 pub fn set_paid_out_of_range(&self, key: &XorName) {
287 self.paid_out_of_range
288 .write()
289 .entry(*key)
290 .or_insert_with(Instant::now);
291 }
292
293 pub fn clear_paid_out_of_range(&self, key: &XorName) {
297 self.paid_out_of_range.write().remove(key);
298 }
299
300 pub fn paid_out_of_range_since(&self, key: &XorName) -> Option<Instant> {
304 self.paid_out_of_range.read().get(key).copied()
305 }
306
307 pub fn set_record_out_of_range(&self, key: &XorName) {
312 self.record_out_of_range
313 .write()
314 .entry(*key)
315 .or_insert_with(Instant::now);
316 }
317
318 pub fn clear_record_out_of_range(&self, key: &XorName) {
322 self.record_out_of_range.write().remove(key);
323 }
324
325 pub fn record_out_of_range_since(&self, key: &XorName) -> Option<Instant> {
329 self.record_out_of_range.read().get(key).copied()
330 }
331
332 pub(crate) fn paid_prune_scan_start(&self, paid_key_count: usize) -> usize {
338 if paid_key_count == 0 {
339 return 0;
340 }
341
342 *self.paid_prune_cursor.read() % paid_key_count
343 }
344
345 pub(crate) fn advance_paid_prune_cursor(
347 &self,
348 paid_key_count: usize,
349 scan_start: usize,
350 last_selected_offset: Option<usize>,
351 ) {
352 let mut cursor = self.paid_prune_cursor.write();
353 if paid_key_count == 0 {
354 *cursor = 0;
355 return;
356 }
357
358 let advance_by = last_selected_offset.map_or(1, |offset| offset.saturating_add(1));
359 *cursor = (scan_start + advance_by) % paid_key_count;
360 }
361
362 pub async fn remove_batch(&self, keys: &[XorName]) -> Result<usize> {
372 if keys.is_empty() {
373 return Ok(0);
374 }
375
376 let keys_owned: Vec<XorName> = keys.to_vec();
377 let env = self.env.clone();
378 let db = self.db;
379
380 let removed_keys = spawn_blocking(move || -> Result<Vec<XorName>> {
381 let mut wtxn = env
382 .write_txn()
383 .map_err(|e| Error::Storage(format!("Failed to create write txn: {e}")))?;
384
385 let mut removed = Vec::new();
386 for key in &keys_owned {
387 let deleted = db
388 .delete(&mut wtxn, key.as_ref())
389 .map_err(|e| Error::Storage(format!("Failed to delete from paid-list: {e}")))?;
390 if deleted {
391 removed.push(*key);
392 }
393 }
394
395 wtxn.commit()
396 .map_err(|e| Error::Storage(format!("Failed to commit batch remove: {e}")))?;
397
398 Ok(removed)
399 })
400 .await
401 .map_err(|e| Error::Storage(format!("Paid-list batch remove task failed: {e}")))??;
402
403 if !removed_keys.is_empty() {
406 {
407 let mut paid_oor = self.paid_out_of_range.write();
408 for key in &removed_keys {
409 paid_oor.remove(key);
410 }
411 }
412 {
413 let mut record_oor = self.record_out_of_range.write();
414 for key in &removed_keys {
415 record_oor.remove(key);
416 }
417 }
418 }
419
420 let count = removed_keys.len();
421 debug!("Batch-removed {count} keys from paid-list");
422 Ok(count)
423 }
424}
425
426#[cfg(test)]
427#[allow(clippy::unwrap_used, clippy::expect_used)]
428mod tests {
429 use super::*;
430 use crate::replication::config::{BOOTSTRAP_CLAIM_GRACE_PERIOD, PRUNE_HYSTERESIS_DURATION};
431 use crate::replication::types::{
432 BootstrapClaimObservation, FailureEvidence, NeighborSyncState,
433 };
434 use saorsa_core::identity::PeerId;
435 use tempfile::TempDir;
436
437 async fn create_test_paid_list() -> (PaidList, TempDir) {
438 let temp_dir = TempDir::new().expect("create temp dir");
439 let paid_list = PaidList::new(temp_dir.path())
440 .await
441 .expect("create paid list");
442 (paid_list, temp_dir)
443 }
444
445 #[tokio::test]
446 async fn test_insert_and_contains() {
447 let (pl, _temp) = create_test_paid_list().await;
448
449 let key: XorName = [0xAA; 32];
450 assert!(!pl.contains(&key).expect("contains before insert"));
451
452 let was_new = pl.insert(&key).await.expect("insert");
453 assert!(was_new);
454
455 assert!(pl.contains(&key).expect("contains after insert"));
456 }
457
458 #[tokio::test]
459 async fn test_insert_duplicate_returns_false() {
460 let (pl, _temp) = create_test_paid_list().await;
461
462 let key: XorName = [0xBB; 32];
463
464 let first = pl.insert(&key).await.expect("first insert");
465 assert!(first);
466
467 let second = pl.insert(&key).await.expect("second insert");
468 assert!(!second);
469 }
470
471 #[tokio::test]
472 async fn test_remove_existing() {
473 let (pl, _temp) = create_test_paid_list().await;
474
475 let key: XorName = [0xCC; 32];
476 pl.insert(&key).await.expect("insert");
477 assert!(pl.contains(&key).expect("contains"));
478
479 let removed = pl.remove(&key).await.expect("remove");
480 assert!(removed);
481 assert!(!pl.contains(&key).expect("contains after remove"));
482 }
483
484 #[tokio::test]
485 async fn test_remove_nonexistent() {
486 let (pl, _temp) = create_test_paid_list().await;
487
488 let key: XorName = [0xDD; 32];
489 let removed = pl.remove(&key).await.expect("remove nonexistent");
490 assert!(!removed);
491 }
492
493 #[tokio::test]
494 async fn test_persistence_across_reopen() {
495 let temp_dir = TempDir::new().expect("create temp dir");
496 let key: XorName = [0xEE; 32];
497
498 {
500 let pl = PaidList::new(temp_dir.path())
501 .await
502 .expect("create paid list");
503 pl.insert(&key).await.expect("insert");
504 assert_eq!(pl.count().expect("count"), 1);
505 }
506
507 {
509 let pl = PaidList::new(temp_dir.path())
510 .await
511 .expect("reopen paid list");
512 assert_eq!(pl.count().expect("count"), 1);
513 assert!(pl.contains(&key).expect("contains after reopen"));
514 }
515 }
516
517 #[tokio::test]
518 async fn test_all_keys() {
519 let (pl, _temp) = create_test_paid_list().await;
520
521 let key_a: XorName = [0x01; 32];
522 let key_b: XorName = [0x02; 32];
523 let key_c: XorName = [0x03; 32];
524
525 pl.insert(&key_a).await.expect("insert 1");
526 pl.insert(&key_b).await.expect("insert 2");
527 pl.insert(&key_c).await.expect("insert 3");
528
529 let mut keys = pl.all_keys().expect("all_keys");
530 keys.sort_unstable();
531
532 let mut expected = vec![key_a, key_b, key_c];
533 expected.sort_unstable();
534
535 assert_eq!(keys, expected);
536 }
537
538 #[tokio::test]
539 async fn test_count() {
540 let (pl, _temp) = create_test_paid_list().await;
541
542 assert_eq!(pl.count().expect("count empty"), 0);
543
544 let key1: XorName = [0x10; 32];
545 let key2: XorName = [0x20; 32];
546
547 pl.insert(&key1).await.expect("insert 1");
548 assert_eq!(pl.count().expect("count after 1"), 1);
549
550 pl.insert(&key2).await.expect("insert 2");
551 assert_eq!(pl.count().expect("count after 2"), 2);
552
553 pl.remove(&key1).await.expect("remove 1");
554 assert_eq!(pl.count().expect("count after remove"), 1);
555 }
556
557 #[tokio::test]
558 async fn test_paid_out_of_range_timestamps() {
559 let (pl, _temp) = create_test_paid_list().await;
560
561 let key: XorName = [0xF0; 32];
562
563 assert!(pl.paid_out_of_range_since(&key).is_none());
565
566 let before = Instant::now();
568 pl.set_paid_out_of_range(&key);
569 let after = Instant::now();
570
571 let ts = pl
572 .paid_out_of_range_since(&key)
573 .expect("timestamp should exist");
574 assert!(ts >= before);
575 assert!(ts <= after);
576
577 std::thread::sleep(std::time::Duration::from_millis(10));
579 pl.set_paid_out_of_range(&key);
580 let ts2 = pl
581 .paid_out_of_range_since(&key)
582 .expect("timestamp should still exist");
583 assert_eq!(ts, ts2);
584
585 pl.clear_paid_out_of_range(&key);
587 assert!(pl.paid_out_of_range_since(&key).is_none());
588 }
589
590 #[tokio::test]
591 async fn test_record_out_of_range_timestamps() {
592 let (pl, _temp) = create_test_paid_list().await;
593
594 let key: XorName = [0xF1; 32];
595
596 assert!(pl.record_out_of_range_since(&key).is_none());
597
598 let before = Instant::now();
599 pl.set_record_out_of_range(&key);
600 let after = Instant::now();
601
602 let ts = pl
603 .record_out_of_range_since(&key)
604 .expect("timestamp should exist");
605 assert!(ts >= before);
606 assert!(ts <= after);
607
608 std::thread::sleep(std::time::Duration::from_millis(10));
610 pl.set_record_out_of_range(&key);
611 let ts2 = pl
612 .record_out_of_range_since(&key)
613 .expect("timestamp should still exist");
614 assert_eq!(ts, ts2);
615
616 pl.clear_record_out_of_range(&key);
618 assert!(pl.record_out_of_range_since(&key).is_none());
619 }
620
621 #[tokio::test]
622 async fn test_remove_clears_timestamps() {
623 let (pl, _temp) = create_test_paid_list().await;
624
625 let key: XorName = [0xA0; 32];
626 pl.insert(&key).await.expect("insert");
627
628 pl.set_paid_out_of_range(&key);
629 pl.set_record_out_of_range(&key);
630 assert!(pl.paid_out_of_range_since(&key).is_some());
631 assert!(pl.record_out_of_range_since(&key).is_some());
632
633 pl.remove(&key).await.expect("remove");
634 assert!(pl.paid_out_of_range_since(&key).is_none());
635 assert!(pl.record_out_of_range_since(&key).is_none());
636 }
637
638 #[tokio::test]
639 async fn test_remove_batch() {
640 let (pl, _temp) = create_test_paid_list().await;
641
642 let key1: XorName = [0x01; 32];
643 let key2: XorName = [0x02; 32];
644 let key3: XorName = [0x03; 32];
645 let key4: XorName = [0x04; 32]; pl.insert(&key1).await.expect("insert 1");
648 pl.insert(&key2).await.expect("insert 2");
649 pl.insert(&key3).await.expect("insert 3");
650
651 pl.set_paid_out_of_range(&key1);
653 pl.set_record_out_of_range(&key2);
654
655 let removed = pl
656 .remove_batch(&[key1, key2, key4])
657 .await
658 .expect("remove_batch");
659 assert_eq!(removed, 2); assert!(!pl.contains(&key1).expect("key1 gone"));
662 assert!(!pl.contains(&key2).expect("key2 gone"));
663 assert!(pl.contains(&key3).expect("key3 still present"));
664 assert_eq!(pl.count().expect("count"), 1);
665
666 assert!(pl.paid_out_of_range_since(&key1).is_none());
668 assert!(pl.record_out_of_range_since(&key2).is_none());
669 }
670
671 #[tokio::test]
672 async fn test_remove_batch_empty() {
673 let (pl, _temp) = create_test_paid_list().await;
674
675 let removed = pl.remove_batch(&[]).await.expect("remove_batch empty");
676 assert_eq!(removed, 0);
677 }
678
679 #[tokio::test]
680 async fn paid_prune_cursor_advances_past_selected_window() {
681 const PAID_KEY_COUNT: usize = 10;
682 const START_CURSOR: usize = 2;
683 const LAST_SELECTED_OFFSET: usize = 3;
684 const EXPECTED_CURSOR: usize = 6;
685
686 let (pl, _temp) = create_test_paid_list().await;
687 *pl.paid_prune_cursor.write() = START_CURSOR;
688
689 let scan_start = pl.paid_prune_scan_start(PAID_KEY_COUNT);
690 pl.advance_paid_prune_cursor(PAID_KEY_COUNT, scan_start, Some(LAST_SELECTED_OFFSET));
691
692 assert_eq!(*pl.paid_prune_cursor.read(), EXPECTED_CURSOR);
693 }
694
695 #[tokio::test]
696 async fn paid_prune_cursor_advances_even_without_selected_entry() {
697 const PAID_KEY_COUNT: usize = 10;
698 const START_CURSOR: usize = 9;
699 const EXPECTED_CURSOR: usize = 0;
700
701 let (pl, _temp) = create_test_paid_list().await;
702 *pl.paid_prune_cursor.write() = START_CURSOR;
703
704 let scan_start = pl.paid_prune_scan_start(PAID_KEY_COUNT);
705 pl.advance_paid_prune_cursor(PAID_KEY_COUNT, scan_start, None);
706
707 assert_eq!(*pl.paid_prune_cursor.read(), EXPECTED_CURSOR);
708 }
709
710 #[tokio::test]
711 async fn paid_prune_cursor_resets_for_empty_paid_list() {
712 const STALE_CURSOR: usize = 7;
713 const EMPTY_PAID_KEY_COUNT: usize = 0;
714 const EXPECTED_CURSOR: usize = 0;
715
716 let (pl, _temp) = create_test_paid_list().await;
717 *pl.paid_prune_cursor.write() = STALE_CURSOR;
718
719 let scan_start = pl.paid_prune_scan_start(EMPTY_PAID_KEY_COUNT);
720 pl.advance_paid_prune_cursor(EMPTY_PAID_KEY_COUNT, scan_start, Some(STALE_CURSOR));
721
722 assert_eq!(*pl.paid_prune_cursor.read(), EXPECTED_CURSOR);
723 }
724
725 #[tokio::test]
732 async fn scenario_50_hysteresis_prevents_premature_deletion() {
733 let (pl, _temp) = create_test_paid_list().await;
734 let key: XorName = [0x50; 32];
735
736 pl.set_record_out_of_range(&key);
738
739 let since = pl
741 .record_out_of_range_since(&key)
742 .expect("timestamp should exist after set");
743
744 let elapsed = since.elapsed();
746 assert!(
747 elapsed < PRUNE_HYSTERESIS_DURATION,
748 "elapsed ({elapsed:?}) should be far below PRUNE_HYSTERESIS_DURATION ({PRUNE_HYSTERESIS_DURATION:?})",
749 );
750 }
751
752 #[tokio::test]
755 async fn scenario_51_timestamp_reset_on_heal() {
756 let (pl, _temp) = create_test_paid_list().await;
757 let key: XorName = [0x51; 32];
758
759 pl.set_record_out_of_range(&key);
761 assert!(
762 pl.record_out_of_range_since(&key).is_some(),
763 "timestamp should exist after going out of range"
764 );
765
766 pl.clear_record_out_of_range(&key);
768 assert!(
769 pl.record_out_of_range_since(&key).is_none(),
770 "timestamp should be cleared after heal"
771 );
772
773 let before_second = Instant::now();
775 pl.set_record_out_of_range(&key);
776 let second_ts = pl
777 .record_out_of_range_since(&key)
778 .expect("timestamp should exist after second out-of-range");
779 assert!(
780 second_ts >= before_second,
781 "new timestamp should be >= the instant before second set call"
782 );
783 }
784
785 #[tokio::test]
788 async fn scenario_52_paid_and_record_timestamps_independent() {
789 let (pl, _temp) = create_test_paid_list().await;
790 let key: XorName = [0x52; 32];
791
792 pl.set_paid_out_of_range(&key);
794 pl.set_record_out_of_range(&key);
795 assert!(pl.paid_out_of_range_since(&key).is_some());
796 assert!(pl.record_out_of_range_since(&key).is_some());
797
798 pl.clear_record_out_of_range(&key);
800 assert!(
801 pl.paid_out_of_range_since(&key).is_some(),
802 "paid timestamp should survive clearing record timestamp"
803 );
804 assert!(pl.record_out_of_range_since(&key).is_none());
805
806 pl.set_record_out_of_range(&key);
808 pl.clear_paid_out_of_range(&key);
809 assert!(
810 pl.record_out_of_range_since(&key).is_some(),
811 "record timestamp should survive clearing paid timestamp"
812 );
813 assert!(pl.paid_out_of_range_since(&key).is_none());
814 }
815
816 #[tokio::test]
819 async fn scenario_23_paid_list_entry_removed() {
820 let (pl, _temp) = create_test_paid_list().await;
821 let key: XorName = [0x23; 32];
822
823 pl.insert(&key).await.expect("insert");
825 pl.set_paid_out_of_range(&key);
826 pl.set_record_out_of_range(&key);
827
828 let removed = pl.remove(&key).await.expect("remove");
830 assert!(removed, "key should have existed");
831 assert!(
832 !pl.contains(&key).expect("contains check"),
833 "key should be gone from paid list"
834 );
835 assert!(
836 pl.paid_out_of_range_since(&key).is_none(),
837 "paid timestamp should be cleaned up on remove"
838 );
839 assert!(
840 pl.record_out_of_range_since(&key).is_none(),
841 "record timestamp should be cleaned up on remove"
842 );
843 }
844
845 #[tokio::test]
850 async fn scenario_13_responsible_range_shrink() {
851 let (pl, _temp) = create_test_paid_list().await;
852
853 let out_of_range_key: XorName = [0x13; 32];
854 let in_range_key: XorName = [0x14; 32];
855
856 pl.insert(&out_of_range_key)
858 .await
859 .expect("insert out-of-range");
860 pl.insert(&in_range_key).await.expect("insert in-range");
861
862 pl.set_record_out_of_range(&out_of_range_key);
865 let first_seen = pl
866 .record_out_of_range_since(&out_of_range_key)
867 .expect("timestamp should be recorded for out-of-range key");
868
869 let elapsed = first_seen.elapsed();
871 assert!(
872 elapsed < PRUNE_HYSTERESIS_DURATION,
873 "elapsed {elapsed:?} should be below PRUNE_HYSTERESIS_DURATION \
874 ({PRUNE_HYSTERESIS_DURATION:?}) — key must not be pruned yet"
875 );
876
877 assert!(
879 pl.contains(&out_of_range_key).expect("contains"),
880 "out-of-range key should still be retained within hysteresis window"
881 );
882
883 assert!(
885 pl.record_out_of_range_since(&in_range_key).is_none(),
886 "in-range key should have no out-of-range timestamp"
887 );
888
889 let new_key: XorName = [0x15; 32];
891 let was_new = pl.insert(&new_key).await.expect("insert new key");
892 assert!(
893 was_new,
894 "new in-range keys should still be accepted while out-of-range keys await expiry"
895 );
896 assert!(
897 pl.contains(&new_key).expect("contains new"),
898 "newly inserted in-range key should be present"
899 );
900 }
901
902 #[test]
905 fn scenario_46_bootstrap_claim_first_seen_recorded() {
906 let peer = PeerId::from_bytes([0x46; 32]);
907 let mut state = NeighborSyncState::new_cycle(vec![peer]);
908
909 let first_ts = Instant::now()
910 .checked_sub(std::time::Duration::from_secs(3))
911 .unwrap_or_else(Instant::now);
912 let observed = state.observe_bootstrap_claim(peer, first_ts, BOOTSTRAP_CLAIM_GRACE_PERIOD);
913 assert_eq!(
914 observed,
915 BootstrapClaimObservation::WithinGrace {
916 first_seen: first_ts
917 }
918 );
919
920 assert_eq!(
922 state.bootstrap_claims.get(&peer),
923 Some(&first_ts),
924 "first-seen timestamp should be recorded"
925 );
926 assert_eq!(
927 state.bootstrap_claim_history.get(&peer),
928 Some(&first_ts),
929 "first-ever timestamp should be retained"
930 );
931
932 let later_ts = Instant::now();
935 let observed = state.observe_bootstrap_claim(peer, later_ts, BOOTSTRAP_CLAIM_GRACE_PERIOD);
936 assert_eq!(
937 observed,
938 BootstrapClaimObservation::WithinGrace {
939 first_seen: first_ts
940 }
941 );
942 assert_eq!(
943 state.bootstrap_claims.get(&peer),
944 Some(&first_ts),
945 "second insert must not overwrite the original timestamp"
946 );
947 }
948
949 #[test]
953 fn scenario_48_bootstrap_claim_abuse_after_grace_period() {
954 let peer = PeerId::from_bytes([0x48; 32]);
955 let mut state = NeighborSyncState::new_cycle(vec![peer]);
956
957 let grace_plus_margin = BOOTSTRAP_CLAIM_GRACE_PERIOD + std::time::Duration::from_secs(3600);
964 let first_seen = Instant::now()
965 .checked_sub(grace_plus_margin)
966 .unwrap_or_else(Instant::now);
967 state.bootstrap_claims.insert(peer, first_seen);
968 state.bootstrap_claim_history.insert(peer, first_seen);
969
970 let claim_age = Instant::now().duration_since(first_seen);
972 if claim_age > std::time::Duration::from_secs(1) {
973 assert!(
974 claim_age > BOOTSTRAP_CLAIM_GRACE_PERIOD,
975 "claim age {claim_age:?} should exceed grace period {BOOTSTRAP_CLAIM_GRACE_PERIOD:?}",
976 );
977 }
978
979 let evidence = FailureEvidence::BootstrapClaimAbuse { peer, first_seen };
981
982 let FailureEvidence::BootstrapClaimAbuse {
983 peer: p,
984 first_seen: fs,
985 } = evidence
986 else {
987 unreachable!("evidence was just constructed as BootstrapClaimAbuse");
988 };
989 assert_eq!(p, peer);
990 assert_eq!(fs, first_seen);
991 }
992
993 #[test]
995 fn scenario_49_bootstrap_claim_cleared() {
996 let peer = PeerId::from_bytes([0x49; 32]);
997 let mut state = NeighborSyncState::new_cycle(vec![peer]);
998
999 let first_seen = Instant::now();
1001 let _ = state.observe_bootstrap_claim(peer, first_seen, BOOTSTRAP_CLAIM_GRACE_PERIOD);
1002 assert!(
1003 state.bootstrap_claims.contains_key(&peer),
1004 "claim should exist after insert"
1005 );
1006
1007 state.clear_active_bootstrap_claim(&peer);
1009 assert!(
1010 !state.bootstrap_claims.contains_key(&peer),
1011 "claim should be gone after normal response"
1012 );
1013 assert!(
1014 state.bootstrap_claim_history.contains_key(&peer),
1015 "claim history should remain so the peer cannot claim bootstrapping again"
1016 );
1017
1018 let repeated =
1019 state.observe_bootstrap_claim(peer, Instant::now(), BOOTSTRAP_CLAIM_GRACE_PERIOD);
1020 assert_eq!(
1021 repeated,
1022 BootstrapClaimObservation::Repeated { first_seen },
1023 "a second bootstrap claim should be classified as repeated abuse"
1024 );
1025 }
1026}