Skip to main content

ant_node/replication/
paid_list.rs

1//! Persistent `PaidForList` backed by LMDB.
2//!
3//! Tracks keys this node believes are paid-authorized. Survives restarts
4//! (Invariant 15). Bounded by `PaidCloseGroup` membership with
5//! hysteresis-based pruning.
6//!
7//! ## Storage layout
8//!
9//! ```text
10//! {root}/paid_list.mdb/   -- LMDB environment directory
11//! ```
12//!
13//! One unnamed database stores set membership: key = 32-byte `XorName`,
14//! value = empty byte slice.
15//!
16//! ## Out-of-range timestamps
17//!
18//! Per-key `PaidOutOfRangeFirstSeen` and `RecordOutOfRangeFirstSeen`
19//! timestamps live in memory only. On restart the hysteresis clock
20//! restarts from zero, which is safe: the prune timer simply starts
21//! fresh.
22
23use 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
36/// Default LMDB map size for the paid list: 256 MiB.
37///
38/// The paid list stores only 32-byte keys with empty values, so this is
39/// generous even for very large close-group memberships.
40const DEFAULT_MAP_SIZE: usize = 256 * 1_024 * 1_024;
41
42/// Persistent paid-for-list backed by LMDB.
43///
44/// Tracks which keys this node believes are paid-authorized.
45/// Survives node restarts via LMDB persistence.
46pub struct PaidList {
47    /// LMDB environment.
48    env: Env,
49    /// The unnamed default database (key = `XorName` bytes, value = empty).
50    db: Database<Bytes, Bytes>,
51    /// In-memory: when each paid key first went out of `PaidCloseGroup` range.
52    /// Cleared on restart (safe: hysteresis clock restarts from zero).
53    paid_out_of_range: RwLock<HashMap<XorName, Instant>>,
54    /// In-memory: when each stored record first went out of
55    /// storage-responsibility range.
56    record_out_of_range: RwLock<HashMap<XorName, Instant>>,
57}
58
59impl PaidList {
60    /// Open or create a `PaidList` backed by LMDB at `{root_dir}/paid_list.mdb/`.
61    ///
62    /// # Errors
63    ///
64    /// Returns an error if the LMDB environment cannot be opened or the
65    /// database cannot be created.
66    #[allow(unsafe_code)]
67    pub async fn new(root_dir: &Path) -> Result<Self> {
68        let env_dir = root_dir.join("paid_list.mdb");
69
70        std::fs::create_dir_all(&env_dir)
71            .map_err(|e| Error::Storage(format!("Failed to create paid-list directory: {e}")))?;
72
73        let env_dir_clone = env_dir.clone();
74        let (env, db) = spawn_blocking(move || -> Result<(Env, Database<Bytes, Bytes>)> {
75            // SAFETY: `EnvOpenOptions::open()` is unsafe because LMDB uses
76            // memory-mapped I/O and relies on OS file-locking to prevent
77            // corruption from concurrent access by multiple processes. We
78            // satisfy this by giving each node instance a unique `root_dir`
79            // (typically named by its full 64-hex peer ID), ensuring no two
80            // processes open the same LMDB environment.
81            let env = unsafe {
82                EnvOpenOptions::new()
83                    .map_size(DEFAULT_MAP_SIZE)
84                    .max_dbs(1)
85                    .open(&env_dir_clone)
86                    .map_err(|e| {
87                        Error::Storage(format!("Failed to open paid-list LMDB env: {e}"))
88                    })?
89            };
90
91            let mut wtxn = env
92                .write_txn()
93                .map_err(|e| Error::Storage(format!("Failed to create write txn: {e}")))?;
94            let db: Database<Bytes, Bytes> = env
95                .create_database(&mut wtxn, None)
96                .map_err(|e| Error::Storage(format!("Failed to create paid-list database: {e}")))?;
97            wtxn.commit()
98                .map_err(|e| Error::Storage(format!("Failed to commit db creation: {e}")))?;
99
100            Ok((env, db))
101        })
102        .await
103        .map_err(|e| Error::Storage(format!("Paid-list init task failed: {e}")))??;
104
105        let paid_list = Self {
106            env,
107            db,
108            paid_out_of_range: RwLock::new(HashMap::new()),
109            record_out_of_range: RwLock::new(HashMap::new()),
110        };
111
112        let count = paid_list.count()?;
113        debug!("Initialized paid-list at {env_dir:?} ({count} existing keys)");
114
115        Ok(paid_list)
116    }
117
118    /// Insert a key into the paid-for set.
119    ///
120    /// Returns `true` if the key was newly added, `false` if it already existed.
121    ///
122    /// # Errors
123    ///
124    /// Returns an error if the LMDB write transaction fails.
125    pub async fn insert(&self, key: &XorName) -> Result<bool> {
126        // Fast-path: avoid write transaction if key already present.
127        if self.contains(key)? {
128            trace!("Paid-list key {} already present", hex::encode(key));
129            return Ok(false);
130        }
131
132        let key_owned = *key;
133        let env = self.env.clone();
134        let db = self.db;
135
136        let was_new = spawn_blocking(move || -> Result<bool> {
137            let mut wtxn = env
138                .write_txn()
139                .map_err(|e| Error::Storage(format!("Failed to create write txn: {e}")))?;
140
141            // Authoritative existence check inside the serialized write txn.
142            if db
143                .get(&wtxn, &key_owned)
144                .map_err(|e| Error::Storage(format!("Failed to check paid-list existence: {e}")))?
145                .is_some()
146            {
147                return Ok(false);
148            }
149
150            db.put(&mut wtxn, &key_owned, &[])
151                .map_err(|e| Error::Storage(format!("Failed to insert into paid-list: {e}")))?;
152            wtxn.commit()
153                .map_err(|e| Error::Storage(format!("Failed to commit paid-list insert: {e}")))?;
154
155            Ok(true)
156        })
157        .await
158        .map_err(|e| Error::Storage(format!("Paid-list insert task failed: {e}")))??;
159
160        if was_new {
161            debug!("Added key {} to paid-list", hex::encode(key));
162        }
163
164        Ok(was_new)
165    }
166
167    /// Remove a key from the paid-for set.
168    ///
169    /// Also clears any in-memory out-of-range timestamps for this key.
170    ///
171    /// Returns `true` if the key existed and was removed, `false` otherwise.
172    ///
173    /// # Errors
174    ///
175    /// Returns an error if the LMDB write transaction fails.
176    pub async fn remove(&self, key: &XorName) -> Result<bool> {
177        let key_owned = *key;
178        let env = self.env.clone();
179        let db = self.db;
180
181        let existed = spawn_blocking(move || -> Result<bool> {
182            let mut wtxn = env
183                .write_txn()
184                .map_err(|e| Error::Storage(format!("Failed to create write txn: {e}")))?;
185            let deleted = db
186                .delete(&mut wtxn, &key_owned)
187                .map_err(|e| Error::Storage(format!("Failed to delete from paid-list: {e}")))?;
188            wtxn.commit()
189                .map_err(|e| Error::Storage(format!("Failed to commit paid-list delete: {e}")))?;
190            Ok(deleted)
191        })
192        .await
193        .map_err(|e| Error::Storage(format!("Paid-list remove task failed: {e}")))??;
194
195        if existed {
196            self.paid_out_of_range.write().remove(key);
197            self.record_out_of_range.write().remove(key);
198            debug!("Removed key {} from paid-list", hex::encode(key));
199        }
200
201        Ok(existed)
202    }
203
204    /// Check whether a key is in the paid-for set.
205    ///
206    /// This is a synchronous read-only operation (no write transaction needed).
207    ///
208    /// # Errors
209    ///
210    /// Returns an error if the LMDB read transaction fails.
211    pub fn contains(&self, key: &XorName) -> Result<bool> {
212        let rtxn = self
213            .env
214            .read_txn()
215            .map_err(|e| Error::Storage(format!("Failed to create read txn: {e}")))?;
216        let found = self
217            .db
218            .get(&rtxn, key.as_ref())
219            .map_err(|e| Error::Storage(format!("Failed to check paid-list membership: {e}")))?
220            .is_some();
221        Ok(found)
222    }
223
224    /// Return the number of keys in the paid-for set.
225    ///
226    /// This is an O(1) read of the B-tree page header, not a full scan.
227    ///
228    /// # Errors
229    ///
230    /// Returns an error if the LMDB read transaction fails.
231    pub fn count(&self) -> Result<u64> {
232        let rtxn = self
233            .env
234            .read_txn()
235            .map_err(|e| Error::Storage(format!("Failed to create read txn: {e}")))?;
236        let entries = self
237            .db
238            .stat(&rtxn)
239            .map_err(|e| Error::Storage(format!("Failed to read paid-list stats: {e}")))?
240            .entries;
241        Ok(entries as u64)
242    }
243
244    /// Return all keys in the paid-for set.
245    ///
246    /// Used during hint construction to advertise which keys this node holds.
247    ///
248    /// # Errors
249    ///
250    /// Returns an error if the LMDB read transaction or iteration fails.
251    pub fn all_keys(&self) -> Result<Vec<XorName>> {
252        let rtxn = self
253            .env
254            .read_txn()
255            .map_err(|e| Error::Storage(format!("Failed to create read txn: {e}")))?;
256        let mut keys = Vec::new();
257        let iter = self
258            .db
259            .iter(&rtxn)
260            .map_err(|e| Error::Storage(format!("Failed to iterate paid-list: {e}")))?;
261        for result in iter {
262            let (key_bytes, _) = result
263                .map_err(|e| Error::Storage(format!("Failed to read paid-list entry: {e}")))?;
264            if key_bytes.len() == XORNAME_LEN {
265                let mut key = [0u8; XORNAME_LEN];
266                key.copy_from_slice(key_bytes);
267                keys.push(key);
268            } else {
269                warn!(
270                    "PaidList: skipping entry with unexpected key length {} (expected {XORNAME_LEN})",
271                    key_bytes.len()
272                );
273            }
274        }
275        Ok(keys)
276    }
277
278    /// Record the `PaidOutOfRangeFirstSeen` timestamp for a key.
279    ///
280    /// Only sets the timestamp if one is not already recorded (first
281    /// observation wins).
282    pub fn set_paid_out_of_range(&self, key: &XorName) {
283        self.paid_out_of_range
284            .write()
285            .entry(*key)
286            .or_insert_with(Instant::now);
287    }
288
289    /// Clear the `PaidOutOfRangeFirstSeen` timestamp for a key.
290    ///
291    /// Called when the key moves back into `PaidCloseGroup` range.
292    pub fn clear_paid_out_of_range(&self, key: &XorName) {
293        self.paid_out_of_range.write().remove(key);
294    }
295
296    /// Get the `PaidOutOfRangeFirstSeen` timestamp for a key.
297    ///
298    /// Returns `None` if the key is currently in range (no timestamp set).
299    pub fn paid_out_of_range_since(&self, key: &XorName) -> Option<Instant> {
300        self.paid_out_of_range.read().get(key).copied()
301    }
302
303    /// Record the `RecordOutOfRangeFirstSeen` timestamp for a key.
304    ///
305    /// Only sets the timestamp if one is not already recorded (first
306    /// observation wins).
307    pub fn set_record_out_of_range(&self, key: &XorName) {
308        self.record_out_of_range
309            .write()
310            .entry(*key)
311            .or_insert_with(Instant::now);
312    }
313
314    /// Clear the `RecordOutOfRangeFirstSeen` timestamp for a key.
315    ///
316    /// Called when the record moves back into storage-responsibility range.
317    pub fn clear_record_out_of_range(&self, key: &XorName) {
318        self.record_out_of_range.write().remove(key);
319    }
320
321    /// Get the `RecordOutOfRangeFirstSeen` timestamp for a key.
322    ///
323    /// Returns `None` if the record is currently in range (no timestamp set).
324    pub fn record_out_of_range_since(&self, key: &XorName) -> Option<Instant> {
325        self.record_out_of_range.read().get(key).copied()
326    }
327
328    /// Remove multiple keys in a single write transaction.
329    ///
330    /// Also clears any in-memory out-of-range timestamps for removed keys.
331    ///
332    /// Returns the number of keys that were actually present and removed.
333    ///
334    /// # Errors
335    ///
336    /// Returns an error if the LMDB write transaction fails.
337    pub async fn remove_batch(&self, keys: &[XorName]) -> Result<usize> {
338        if keys.is_empty() {
339            return Ok(0);
340        }
341
342        let keys_owned: Vec<XorName> = keys.to_vec();
343        let env = self.env.clone();
344        let db = self.db;
345
346        let removed_keys = spawn_blocking(move || -> Result<Vec<XorName>> {
347            let mut wtxn = env
348                .write_txn()
349                .map_err(|e| Error::Storage(format!("Failed to create write txn: {e}")))?;
350
351            let mut removed = Vec::new();
352            for key in &keys_owned {
353                let deleted = db
354                    .delete(&mut wtxn, key.as_ref())
355                    .map_err(|e| Error::Storage(format!("Failed to delete from paid-list: {e}")))?;
356                if deleted {
357                    removed.push(*key);
358                }
359            }
360
361            wtxn.commit()
362                .map_err(|e| Error::Storage(format!("Failed to commit batch remove: {e}")))?;
363
364            Ok(removed)
365        })
366        .await
367        .map_err(|e| Error::Storage(format!("Paid-list batch remove task failed: {e}")))??;
368
369        // Clear in-memory timestamps for all removed keys.
370        // Acquire and release each lock separately to minimize hold time.
371        if !removed_keys.is_empty() {
372            {
373                let mut paid_oor = self.paid_out_of_range.write();
374                for key in &removed_keys {
375                    paid_oor.remove(key);
376                }
377            }
378            {
379                let mut record_oor = self.record_out_of_range.write();
380                for key in &removed_keys {
381                    record_oor.remove(key);
382                }
383            }
384        }
385
386        let count = removed_keys.len();
387        debug!("Batch-removed {count} keys from paid-list");
388        Ok(count)
389    }
390}
391
392#[cfg(test)]
393#[allow(clippy::unwrap_used, clippy::expect_used)]
394mod tests {
395    use super::*;
396    use crate::replication::config::{BOOTSTRAP_CLAIM_GRACE_PERIOD, PRUNE_HYSTERESIS_DURATION};
397    use crate::replication::types::{FailureEvidence, NeighborSyncState};
398    use saorsa_core::identity::PeerId;
399    use tempfile::TempDir;
400
401    async fn create_test_paid_list() -> (PaidList, TempDir) {
402        let temp_dir = TempDir::new().expect("create temp dir");
403        let paid_list = PaidList::new(temp_dir.path())
404            .await
405            .expect("create paid list");
406        (paid_list, temp_dir)
407    }
408
409    #[tokio::test]
410    async fn test_insert_and_contains() {
411        let (pl, _temp) = create_test_paid_list().await;
412
413        let key: XorName = [0xAA; 32];
414        assert!(!pl.contains(&key).expect("contains before insert"));
415
416        let was_new = pl.insert(&key).await.expect("insert");
417        assert!(was_new);
418
419        assert!(pl.contains(&key).expect("contains after insert"));
420    }
421
422    #[tokio::test]
423    async fn test_insert_duplicate_returns_false() {
424        let (pl, _temp) = create_test_paid_list().await;
425
426        let key: XorName = [0xBB; 32];
427
428        let first = pl.insert(&key).await.expect("first insert");
429        assert!(first);
430
431        let second = pl.insert(&key).await.expect("second insert");
432        assert!(!second);
433    }
434
435    #[tokio::test]
436    async fn test_remove_existing() {
437        let (pl, _temp) = create_test_paid_list().await;
438
439        let key: XorName = [0xCC; 32];
440        pl.insert(&key).await.expect("insert");
441        assert!(pl.contains(&key).expect("contains"));
442
443        let removed = pl.remove(&key).await.expect("remove");
444        assert!(removed);
445        assert!(!pl.contains(&key).expect("contains after remove"));
446    }
447
448    #[tokio::test]
449    async fn test_remove_nonexistent() {
450        let (pl, _temp) = create_test_paid_list().await;
451
452        let key: XorName = [0xDD; 32];
453        let removed = pl.remove(&key).await.expect("remove nonexistent");
454        assert!(!removed);
455    }
456
457    #[tokio::test]
458    async fn test_persistence_across_reopen() {
459        let temp_dir = TempDir::new().expect("create temp dir");
460        let key: XorName = [0xEE; 32];
461
462        // Insert a key, then drop the PaidList.
463        {
464            let pl = PaidList::new(temp_dir.path())
465                .await
466                .expect("create paid list");
467            pl.insert(&key).await.expect("insert");
468            assert_eq!(pl.count().expect("count"), 1);
469        }
470
471        // Re-open and verify the key persisted.
472        {
473            let pl = PaidList::new(temp_dir.path())
474                .await
475                .expect("reopen paid list");
476            assert_eq!(pl.count().expect("count"), 1);
477            assert!(pl.contains(&key).expect("contains after reopen"));
478        }
479    }
480
481    #[tokio::test]
482    async fn test_all_keys() {
483        let (pl, _temp) = create_test_paid_list().await;
484
485        let key_a: XorName = [0x01; 32];
486        let key_b: XorName = [0x02; 32];
487        let key_c: XorName = [0x03; 32];
488
489        pl.insert(&key_a).await.expect("insert 1");
490        pl.insert(&key_b).await.expect("insert 2");
491        pl.insert(&key_c).await.expect("insert 3");
492
493        let mut keys = pl.all_keys().expect("all_keys");
494        keys.sort_unstable();
495
496        let mut expected = vec![key_a, key_b, key_c];
497        expected.sort_unstable();
498
499        assert_eq!(keys, expected);
500    }
501
502    #[tokio::test]
503    async fn test_count() {
504        let (pl, _temp) = create_test_paid_list().await;
505
506        assert_eq!(pl.count().expect("count empty"), 0);
507
508        let key1: XorName = [0x10; 32];
509        let key2: XorName = [0x20; 32];
510
511        pl.insert(&key1).await.expect("insert 1");
512        assert_eq!(pl.count().expect("count after 1"), 1);
513
514        pl.insert(&key2).await.expect("insert 2");
515        assert_eq!(pl.count().expect("count after 2"), 2);
516
517        pl.remove(&key1).await.expect("remove 1");
518        assert_eq!(pl.count().expect("count after remove"), 1);
519    }
520
521    #[tokio::test]
522    async fn test_paid_out_of_range_timestamps() {
523        let (pl, _temp) = create_test_paid_list().await;
524
525        let key: XorName = [0xF0; 32];
526
527        // Initially no timestamp.
528        assert!(pl.paid_out_of_range_since(&key).is_none());
529
530        // Set timestamp.
531        let before = Instant::now();
532        pl.set_paid_out_of_range(&key);
533        let after = Instant::now();
534
535        let ts = pl
536            .paid_out_of_range_since(&key)
537            .expect("timestamp should exist");
538        assert!(ts >= before);
539        assert!(ts <= after);
540
541        // Setting again should not update (first observation wins).
542        std::thread::sleep(std::time::Duration::from_millis(10));
543        pl.set_paid_out_of_range(&key);
544        let ts2 = pl
545            .paid_out_of_range_since(&key)
546            .expect("timestamp should still exist");
547        assert_eq!(ts, ts2);
548
549        // Clear.
550        pl.clear_paid_out_of_range(&key);
551        assert!(pl.paid_out_of_range_since(&key).is_none());
552    }
553
554    #[tokio::test]
555    async fn test_record_out_of_range_timestamps() {
556        let (pl, _temp) = create_test_paid_list().await;
557
558        let key: XorName = [0xF1; 32];
559
560        assert!(pl.record_out_of_range_since(&key).is_none());
561
562        let before = Instant::now();
563        pl.set_record_out_of_range(&key);
564        let after = Instant::now();
565
566        let ts = pl
567            .record_out_of_range_since(&key)
568            .expect("timestamp should exist");
569        assert!(ts >= before);
570        assert!(ts <= after);
571
572        // Setting again should not update.
573        std::thread::sleep(std::time::Duration::from_millis(10));
574        pl.set_record_out_of_range(&key);
575        let ts2 = pl
576            .record_out_of_range_since(&key)
577            .expect("timestamp should still exist");
578        assert_eq!(ts, ts2);
579
580        // Clear.
581        pl.clear_record_out_of_range(&key);
582        assert!(pl.record_out_of_range_since(&key).is_none());
583    }
584
585    #[tokio::test]
586    async fn test_remove_clears_timestamps() {
587        let (pl, _temp) = create_test_paid_list().await;
588
589        let key: XorName = [0xA0; 32];
590        pl.insert(&key).await.expect("insert");
591
592        pl.set_paid_out_of_range(&key);
593        pl.set_record_out_of_range(&key);
594        assert!(pl.paid_out_of_range_since(&key).is_some());
595        assert!(pl.record_out_of_range_since(&key).is_some());
596
597        pl.remove(&key).await.expect("remove");
598        assert!(pl.paid_out_of_range_since(&key).is_none());
599        assert!(pl.record_out_of_range_since(&key).is_none());
600    }
601
602    #[tokio::test]
603    async fn test_remove_batch() {
604        let (pl, _temp) = create_test_paid_list().await;
605
606        let key1: XorName = [0x01; 32];
607        let key2: XorName = [0x02; 32];
608        let key3: XorName = [0x03; 32];
609        let key4: XorName = [0x04; 32]; // not inserted
610
611        pl.insert(&key1).await.expect("insert 1");
612        pl.insert(&key2).await.expect("insert 2");
613        pl.insert(&key3).await.expect("insert 3");
614
615        // Set timestamps to verify they get cleared.
616        pl.set_paid_out_of_range(&key1);
617        pl.set_record_out_of_range(&key2);
618
619        let removed = pl
620            .remove_batch(&[key1, key2, key4])
621            .await
622            .expect("remove_batch");
623        assert_eq!(removed, 2); // key1 and key2 existed; key4 did not
624
625        assert!(!pl.contains(&key1).expect("key1 gone"));
626        assert!(!pl.contains(&key2).expect("key2 gone"));
627        assert!(pl.contains(&key3).expect("key3 still present"));
628        assert_eq!(pl.count().expect("count"), 1);
629
630        // Timestamps should be cleared for removed keys.
631        assert!(pl.paid_out_of_range_since(&key1).is_none());
632        assert!(pl.record_out_of_range_since(&key2).is_none());
633    }
634
635    #[tokio::test]
636    async fn test_remove_batch_empty() {
637        let (pl, _temp) = create_test_paid_list().await;
638
639        let removed = pl.remove_batch(&[]).await.expect("remove_batch empty");
640        assert_eq!(removed, 0);
641    }
642
643    // -- Scenario tests -------------------------------------------------------
644
645    /// #50: Key goes out of range. `set_record_out_of_range` called.
646    /// Immediately the elapsed time is less than `PRUNE_HYSTERESIS_DURATION`,
647    /// so a prune pass should NOT delete it. We verify the timestamp is
648    /// present but recent.
649    #[tokio::test]
650    async fn scenario_50_hysteresis_prevents_premature_deletion() {
651        let (pl, _temp) = create_test_paid_list().await;
652        let key: XorName = [0x50; 32];
653
654        // Key goes out of range — record the timestamp.
655        pl.set_record_out_of_range(&key);
656
657        // Timestamp must be present.
658        let since = pl
659            .record_out_of_range_since(&key)
660            .expect("timestamp should exist after set");
661
662        // Elapsed time is effectively zero — well below hysteresis threshold.
663        let elapsed = since.elapsed();
664        assert!(
665            elapsed < PRUNE_HYSTERESIS_DURATION,
666            "elapsed ({elapsed:?}) should be far below PRUNE_HYSTERESIS_DURATION ({PRUNE_HYSTERESIS_DURATION:?})",
667        );
668    }
669
670    /// #51: Key goes out of range, then comes back. Timestamp is cleared.
671    /// If the key leaves again, the clock restarts from now.
672    #[tokio::test]
673    async fn scenario_51_timestamp_reset_on_heal() {
674        let (pl, _temp) = create_test_paid_list().await;
675        let key: XorName = [0x51; 32];
676
677        // Key goes out of range.
678        pl.set_record_out_of_range(&key);
679        assert!(
680            pl.record_out_of_range_since(&key).is_some(),
681            "timestamp should exist after going out of range"
682        );
683
684        // Partition heals — key comes back in range.
685        pl.clear_record_out_of_range(&key);
686        assert!(
687            pl.record_out_of_range_since(&key).is_none(),
688            "timestamp should be cleared after heal"
689        );
690
691        // Key goes out of range again — clock must restart.
692        let before_second = Instant::now();
693        pl.set_record_out_of_range(&key);
694        let second_ts = pl
695            .record_out_of_range_since(&key)
696            .expect("timestamp should exist after second out-of-range");
697        assert!(
698            second_ts >= before_second,
699            "new timestamp should be >= the instant before second set call"
700        );
701    }
702
703    /// #52: Paid and record out-of-range timestamps are independent.
704    /// Clearing one must not affect the other.
705    #[tokio::test]
706    async fn scenario_52_paid_and_record_timestamps_independent() {
707        let (pl, _temp) = create_test_paid_list().await;
708        let key: XorName = [0x52; 32];
709
710        // Set both timestamps.
711        pl.set_paid_out_of_range(&key);
712        pl.set_record_out_of_range(&key);
713        assert!(pl.paid_out_of_range_since(&key).is_some());
714        assert!(pl.record_out_of_range_since(&key).is_some());
715
716        // Clear record — paid must survive.
717        pl.clear_record_out_of_range(&key);
718        assert!(
719            pl.paid_out_of_range_since(&key).is_some(),
720            "paid timestamp should survive clearing record timestamp"
721        );
722        assert!(pl.record_out_of_range_since(&key).is_none());
723
724        // Re-set record, then clear paid — record must survive.
725        pl.set_record_out_of_range(&key);
726        pl.clear_paid_out_of_range(&key);
727        assert!(
728            pl.record_out_of_range_since(&key).is_some(),
729            "record timestamp should survive clearing paid timestamp"
730        );
731        assert!(pl.paid_out_of_range_since(&key).is_none());
732    }
733
734    /// #23: Inserting then removing a key from the paid list clears both
735    /// the persistence entry and any in-memory out-of-range timestamps.
736    #[tokio::test]
737    async fn scenario_23_paid_list_entry_removed() {
738        let (pl, _temp) = create_test_paid_list().await;
739        let key: XorName = [0x23; 32];
740
741        // Insert key and attach out-of-range timestamps.
742        pl.insert(&key).await.expect("insert");
743        pl.set_paid_out_of_range(&key);
744        pl.set_record_out_of_range(&key);
745
746        // Remove — should clear everything.
747        let removed = pl.remove(&key).await.expect("remove");
748        assert!(removed, "key should have existed");
749        assert!(
750            !pl.contains(&key).expect("contains check"),
751            "key should be gone from paid list"
752        );
753        assert!(
754            pl.paid_out_of_range_since(&key).is_none(),
755            "paid timestamp should be cleaned up on remove"
756        );
757        assert!(
758            pl.record_out_of_range_since(&key).is_none(),
759            "record timestamp should be cleaned up on remove"
760        );
761    }
762
763    /// #13: Responsible range shrink — out-of-range records have their
764    /// timestamp recorded, are NOT pruned before `PRUNE_HYSTERESIS_DURATION`,
765    /// and new in-range keys are still accepted while out-of-range keys
766    /// await expiry.
767    #[tokio::test]
768    async fn scenario_13_responsible_range_shrink() {
769        let (pl, _temp) = create_test_paid_list().await;
770
771        let out_of_range_key: XorName = [0x13; 32];
772        let in_range_key: XorName = [0x14; 32];
773
774        // Insert both keys initially (simulating they were once in range).
775        pl.insert(&out_of_range_key)
776            .await
777            .expect("insert out-of-range");
778        pl.insert(&in_range_key).await.expect("insert in-range");
779
780        // Range shrinks: out_of_range_key is no longer in responsibility range.
781        // Record RecordOutOfRangeFirstSeen.
782        pl.set_record_out_of_range(&out_of_range_key);
783        let first_seen = pl
784            .record_out_of_range_since(&out_of_range_key)
785            .expect("timestamp should be recorded for out-of-range key");
786
787        // Key must NOT be pruned yet — elapsed time is far below hysteresis.
788        let elapsed = first_seen.elapsed();
789        assert!(
790            elapsed < PRUNE_HYSTERESIS_DURATION,
791            "elapsed {elapsed:?} should be below PRUNE_HYSTERESIS_DURATION \
792             ({PRUNE_HYSTERESIS_DURATION:?}) — key must not be pruned yet"
793        );
794
795        // The key should still exist in the paid list (not deleted).
796        assert!(
797            pl.contains(&out_of_range_key).expect("contains"),
798            "out-of-range key should still be retained within hysteresis window"
799        );
800
801        // In-range key is unaffected — no out-of-range timestamp set.
802        assert!(
803            pl.record_out_of_range_since(&in_range_key).is_none(),
804            "in-range key should have no out-of-range timestamp"
805        );
806
807        // New in-range keys are still accepted during this period.
808        let new_key: XorName = [0x15; 32];
809        let was_new = pl.insert(&new_key).await.expect("insert new key");
810        assert!(
811            was_new,
812            "new in-range keys should still be accepted while out-of-range keys await expiry"
813        );
814        assert!(
815            pl.contains(&new_key).expect("contains new"),
816            "newly inserted in-range key should be present"
817        );
818    }
819
820    /// #46: Bootstrap claim first-seen is recorded and follows
821    /// first-observation-wins semantics.
822    #[test]
823    fn scenario_46_bootstrap_claim_first_seen_recorded() {
824        let peer = PeerId::from_bytes([0x46; 32]);
825        let mut state = NeighborSyncState::new_cycle(vec![peer]);
826
827        // Insert a first-seen timestamp.
828        let first_ts = Instant::now()
829            .checked_sub(std::time::Duration::from_secs(3))
830            .unwrap_or_else(Instant::now);
831        state.bootstrap_claims.insert(peer, first_ts);
832
833        // Verify recorded.
834        assert_eq!(
835            state.bootstrap_claims.get(&peer),
836            Some(&first_ts),
837            "first-seen timestamp should be recorded"
838        );
839
840        // Insert again — must NOT overwrite (first-observation-wins).
841        let later_ts = Instant::now();
842        state.bootstrap_claims.entry(peer).or_insert(later_ts);
843        assert_eq!(
844            state.bootstrap_claims.get(&peer),
845            Some(&first_ts),
846            "second insert must not overwrite the original timestamp"
847        );
848    }
849
850    /// #48: Peer P first claimed bootstrapping >24 h ago.  On next interaction
851    /// the claim age exceeds `BOOTSTRAP_CLAIM_GRACE_PERIOD` and the node emits
852    /// `BootstrapClaimAbuse` evidence.
853    #[test]
854    fn scenario_48_bootstrap_claim_abuse_after_grace_period() {
855        let peer = PeerId::from_bytes([0x48; 32]);
856        let mut state = NeighborSyncState::new_cycle(vec![peer]);
857
858        // Record a first-seen timestamp >24 h ago.
859        // `Instant::checked_sub` can fail on Windows where the epoch is
860        // process-start, so fall back to a recent instant when the platform
861        // cannot represent the backdated time (the claim-age assertion is
862        // skipped in that case since the subtraction itself proves nothing
863        // about production behaviour).
864        let grace_plus_margin = BOOTSTRAP_CLAIM_GRACE_PERIOD + std::time::Duration::from_secs(3600);
865        let first_seen = Instant::now()
866            .checked_sub(grace_plus_margin)
867            .unwrap_or_else(Instant::now);
868        state.bootstrap_claims.insert(peer, first_seen);
869
870        // On platforms that support the backdated instant, verify claim age.
871        let claim_age = Instant::now().duration_since(first_seen);
872        if claim_age > std::time::Duration::from_secs(1) {
873            assert!(
874                claim_age > BOOTSTRAP_CLAIM_GRACE_PERIOD,
875                "claim age {claim_age:?} should exceed grace period {BOOTSTRAP_CLAIM_GRACE_PERIOD:?}",
876            );
877        }
878
879        // Caller constructs BootstrapClaimAbuse evidence.
880        let evidence = FailureEvidence::BootstrapClaimAbuse { peer, first_seen };
881
882        let FailureEvidence::BootstrapClaimAbuse {
883            peer: p,
884            first_seen: fs,
885        } = evidence
886        else {
887            unreachable!("evidence was just constructed as BootstrapClaimAbuse");
888        };
889        assert_eq!(p, peer);
890        assert_eq!(fs, first_seen);
891    }
892
893    /// #49: Bootstrap claim is cleared when a peer responds normally.
894    #[test]
895    fn scenario_49_bootstrap_claim_cleared() {
896        let peer = PeerId::from_bytes([0x49; 32]);
897        let mut state = NeighborSyncState::new_cycle(vec![peer]);
898
899        // Record a bootstrap claim.
900        state.bootstrap_claims.insert(peer, Instant::now());
901        assert!(
902            state.bootstrap_claims.contains_key(&peer),
903            "claim should exist after insert"
904        );
905
906        // Peer responded normally — clear the claim.
907        state.bootstrap_claims.remove(&peer);
908        assert!(
909            !state.bootstrap_claims.contains_key(&peer),
910            "claim should be gone after normal response"
911        );
912    }
913}