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::{
398        BootstrapClaimObservation, FailureEvidence, NeighborSyncState,
399    };
400    use saorsa_core::identity::PeerId;
401    use tempfile::TempDir;
402
403    async fn create_test_paid_list() -> (PaidList, TempDir) {
404        let temp_dir = TempDir::new().expect("create temp dir");
405        let paid_list = PaidList::new(temp_dir.path())
406            .await
407            .expect("create paid list");
408        (paid_list, temp_dir)
409    }
410
411    #[tokio::test]
412    async fn test_insert_and_contains() {
413        let (pl, _temp) = create_test_paid_list().await;
414
415        let key: XorName = [0xAA; 32];
416        assert!(!pl.contains(&key).expect("contains before insert"));
417
418        let was_new = pl.insert(&key).await.expect("insert");
419        assert!(was_new);
420
421        assert!(pl.contains(&key).expect("contains after insert"));
422    }
423
424    #[tokio::test]
425    async fn test_insert_duplicate_returns_false() {
426        let (pl, _temp) = create_test_paid_list().await;
427
428        let key: XorName = [0xBB; 32];
429
430        let first = pl.insert(&key).await.expect("first insert");
431        assert!(first);
432
433        let second = pl.insert(&key).await.expect("second insert");
434        assert!(!second);
435    }
436
437    #[tokio::test]
438    async fn test_remove_existing() {
439        let (pl, _temp) = create_test_paid_list().await;
440
441        let key: XorName = [0xCC; 32];
442        pl.insert(&key).await.expect("insert");
443        assert!(pl.contains(&key).expect("contains"));
444
445        let removed = pl.remove(&key).await.expect("remove");
446        assert!(removed);
447        assert!(!pl.contains(&key).expect("contains after remove"));
448    }
449
450    #[tokio::test]
451    async fn test_remove_nonexistent() {
452        let (pl, _temp) = create_test_paid_list().await;
453
454        let key: XorName = [0xDD; 32];
455        let removed = pl.remove(&key).await.expect("remove nonexistent");
456        assert!(!removed);
457    }
458
459    #[tokio::test]
460    async fn test_persistence_across_reopen() {
461        let temp_dir = TempDir::new().expect("create temp dir");
462        let key: XorName = [0xEE; 32];
463
464        // Insert a key, then drop the PaidList.
465        {
466            let pl = PaidList::new(temp_dir.path())
467                .await
468                .expect("create paid list");
469            pl.insert(&key).await.expect("insert");
470            assert_eq!(pl.count().expect("count"), 1);
471        }
472
473        // Re-open and verify the key persisted.
474        {
475            let pl = PaidList::new(temp_dir.path())
476                .await
477                .expect("reopen paid list");
478            assert_eq!(pl.count().expect("count"), 1);
479            assert!(pl.contains(&key).expect("contains after reopen"));
480        }
481    }
482
483    #[tokio::test]
484    async fn test_all_keys() {
485        let (pl, _temp) = create_test_paid_list().await;
486
487        let key_a: XorName = [0x01; 32];
488        let key_b: XorName = [0x02; 32];
489        let key_c: XorName = [0x03; 32];
490
491        pl.insert(&key_a).await.expect("insert 1");
492        pl.insert(&key_b).await.expect("insert 2");
493        pl.insert(&key_c).await.expect("insert 3");
494
495        let mut keys = pl.all_keys().expect("all_keys");
496        keys.sort_unstable();
497
498        let mut expected = vec![key_a, key_b, key_c];
499        expected.sort_unstable();
500
501        assert_eq!(keys, expected);
502    }
503
504    #[tokio::test]
505    async fn test_count() {
506        let (pl, _temp) = create_test_paid_list().await;
507
508        assert_eq!(pl.count().expect("count empty"), 0);
509
510        let key1: XorName = [0x10; 32];
511        let key2: XorName = [0x20; 32];
512
513        pl.insert(&key1).await.expect("insert 1");
514        assert_eq!(pl.count().expect("count after 1"), 1);
515
516        pl.insert(&key2).await.expect("insert 2");
517        assert_eq!(pl.count().expect("count after 2"), 2);
518
519        pl.remove(&key1).await.expect("remove 1");
520        assert_eq!(pl.count().expect("count after remove"), 1);
521    }
522
523    #[tokio::test]
524    async fn test_paid_out_of_range_timestamps() {
525        let (pl, _temp) = create_test_paid_list().await;
526
527        let key: XorName = [0xF0; 32];
528
529        // Initially no timestamp.
530        assert!(pl.paid_out_of_range_since(&key).is_none());
531
532        // Set timestamp.
533        let before = Instant::now();
534        pl.set_paid_out_of_range(&key);
535        let after = Instant::now();
536
537        let ts = pl
538            .paid_out_of_range_since(&key)
539            .expect("timestamp should exist");
540        assert!(ts >= before);
541        assert!(ts <= after);
542
543        // Setting again should not update (first observation wins).
544        std::thread::sleep(std::time::Duration::from_millis(10));
545        pl.set_paid_out_of_range(&key);
546        let ts2 = pl
547            .paid_out_of_range_since(&key)
548            .expect("timestamp should still exist");
549        assert_eq!(ts, ts2);
550
551        // Clear.
552        pl.clear_paid_out_of_range(&key);
553        assert!(pl.paid_out_of_range_since(&key).is_none());
554    }
555
556    #[tokio::test]
557    async fn test_record_out_of_range_timestamps() {
558        let (pl, _temp) = create_test_paid_list().await;
559
560        let key: XorName = [0xF1; 32];
561
562        assert!(pl.record_out_of_range_since(&key).is_none());
563
564        let before = Instant::now();
565        pl.set_record_out_of_range(&key);
566        let after = Instant::now();
567
568        let ts = pl
569            .record_out_of_range_since(&key)
570            .expect("timestamp should exist");
571        assert!(ts >= before);
572        assert!(ts <= after);
573
574        // Setting again should not update.
575        std::thread::sleep(std::time::Duration::from_millis(10));
576        pl.set_record_out_of_range(&key);
577        let ts2 = pl
578            .record_out_of_range_since(&key)
579            .expect("timestamp should still exist");
580        assert_eq!(ts, ts2);
581
582        // Clear.
583        pl.clear_record_out_of_range(&key);
584        assert!(pl.record_out_of_range_since(&key).is_none());
585    }
586
587    #[tokio::test]
588    async fn test_remove_clears_timestamps() {
589        let (pl, _temp) = create_test_paid_list().await;
590
591        let key: XorName = [0xA0; 32];
592        pl.insert(&key).await.expect("insert");
593
594        pl.set_paid_out_of_range(&key);
595        pl.set_record_out_of_range(&key);
596        assert!(pl.paid_out_of_range_since(&key).is_some());
597        assert!(pl.record_out_of_range_since(&key).is_some());
598
599        pl.remove(&key).await.expect("remove");
600        assert!(pl.paid_out_of_range_since(&key).is_none());
601        assert!(pl.record_out_of_range_since(&key).is_none());
602    }
603
604    #[tokio::test]
605    async fn test_remove_batch() {
606        let (pl, _temp) = create_test_paid_list().await;
607
608        let key1: XorName = [0x01; 32];
609        let key2: XorName = [0x02; 32];
610        let key3: XorName = [0x03; 32];
611        let key4: XorName = [0x04; 32]; // not inserted
612
613        pl.insert(&key1).await.expect("insert 1");
614        pl.insert(&key2).await.expect("insert 2");
615        pl.insert(&key3).await.expect("insert 3");
616
617        // Set timestamps to verify they get cleared.
618        pl.set_paid_out_of_range(&key1);
619        pl.set_record_out_of_range(&key2);
620
621        let removed = pl
622            .remove_batch(&[key1, key2, key4])
623            .await
624            .expect("remove_batch");
625        assert_eq!(removed, 2); // key1 and key2 existed; key4 did not
626
627        assert!(!pl.contains(&key1).expect("key1 gone"));
628        assert!(!pl.contains(&key2).expect("key2 gone"));
629        assert!(pl.contains(&key3).expect("key3 still present"));
630        assert_eq!(pl.count().expect("count"), 1);
631
632        // Timestamps should be cleared for removed keys.
633        assert!(pl.paid_out_of_range_since(&key1).is_none());
634        assert!(pl.record_out_of_range_since(&key2).is_none());
635    }
636
637    #[tokio::test]
638    async fn test_remove_batch_empty() {
639        let (pl, _temp) = create_test_paid_list().await;
640
641        let removed = pl.remove_batch(&[]).await.expect("remove_batch empty");
642        assert_eq!(removed, 0);
643    }
644
645    // -- Scenario tests -------------------------------------------------------
646
647    /// #50: Key goes out of range. `set_record_out_of_range` called.
648    /// Immediately the elapsed time is less than `PRUNE_HYSTERESIS_DURATION`,
649    /// so a prune pass should NOT delete it. We verify the timestamp is
650    /// present but recent.
651    #[tokio::test]
652    async fn scenario_50_hysteresis_prevents_premature_deletion() {
653        let (pl, _temp) = create_test_paid_list().await;
654        let key: XorName = [0x50; 32];
655
656        // Key goes out of range — record the timestamp.
657        pl.set_record_out_of_range(&key);
658
659        // Timestamp must be present.
660        let since = pl
661            .record_out_of_range_since(&key)
662            .expect("timestamp should exist after set");
663
664        // Elapsed time is effectively zero — well below hysteresis threshold.
665        let elapsed = since.elapsed();
666        assert!(
667            elapsed < PRUNE_HYSTERESIS_DURATION,
668            "elapsed ({elapsed:?}) should be far below PRUNE_HYSTERESIS_DURATION ({PRUNE_HYSTERESIS_DURATION:?})",
669        );
670    }
671
672    /// #51: Key goes out of range, then comes back. Timestamp is cleared.
673    /// If the key leaves again, the clock restarts from now.
674    #[tokio::test]
675    async fn scenario_51_timestamp_reset_on_heal() {
676        let (pl, _temp) = create_test_paid_list().await;
677        let key: XorName = [0x51; 32];
678
679        // Key goes out of range.
680        pl.set_record_out_of_range(&key);
681        assert!(
682            pl.record_out_of_range_since(&key).is_some(),
683            "timestamp should exist after going out of range"
684        );
685
686        // Partition heals — key comes back in range.
687        pl.clear_record_out_of_range(&key);
688        assert!(
689            pl.record_out_of_range_since(&key).is_none(),
690            "timestamp should be cleared after heal"
691        );
692
693        // Key goes out of range again — clock must restart.
694        let before_second = Instant::now();
695        pl.set_record_out_of_range(&key);
696        let second_ts = pl
697            .record_out_of_range_since(&key)
698            .expect("timestamp should exist after second out-of-range");
699        assert!(
700            second_ts >= before_second,
701            "new timestamp should be >= the instant before second set call"
702        );
703    }
704
705    /// #52: Paid and record out-of-range timestamps are independent.
706    /// Clearing one must not affect the other.
707    #[tokio::test]
708    async fn scenario_52_paid_and_record_timestamps_independent() {
709        let (pl, _temp) = create_test_paid_list().await;
710        let key: XorName = [0x52; 32];
711
712        // Set both timestamps.
713        pl.set_paid_out_of_range(&key);
714        pl.set_record_out_of_range(&key);
715        assert!(pl.paid_out_of_range_since(&key).is_some());
716        assert!(pl.record_out_of_range_since(&key).is_some());
717
718        // Clear record — paid must survive.
719        pl.clear_record_out_of_range(&key);
720        assert!(
721            pl.paid_out_of_range_since(&key).is_some(),
722            "paid timestamp should survive clearing record timestamp"
723        );
724        assert!(pl.record_out_of_range_since(&key).is_none());
725
726        // Re-set record, then clear paid — record must survive.
727        pl.set_record_out_of_range(&key);
728        pl.clear_paid_out_of_range(&key);
729        assert!(
730            pl.record_out_of_range_since(&key).is_some(),
731            "record timestamp should survive clearing paid timestamp"
732        );
733        assert!(pl.paid_out_of_range_since(&key).is_none());
734    }
735
736    /// #23: Inserting then removing a key from the paid list clears both
737    /// the persistence entry and any in-memory out-of-range timestamps.
738    #[tokio::test]
739    async fn scenario_23_paid_list_entry_removed() {
740        let (pl, _temp) = create_test_paid_list().await;
741        let key: XorName = [0x23; 32];
742
743        // Insert key and attach out-of-range timestamps.
744        pl.insert(&key).await.expect("insert");
745        pl.set_paid_out_of_range(&key);
746        pl.set_record_out_of_range(&key);
747
748        // Remove — should clear everything.
749        let removed = pl.remove(&key).await.expect("remove");
750        assert!(removed, "key should have existed");
751        assert!(
752            !pl.contains(&key).expect("contains check"),
753            "key should be gone from paid list"
754        );
755        assert!(
756            pl.paid_out_of_range_since(&key).is_none(),
757            "paid timestamp should be cleaned up on remove"
758        );
759        assert!(
760            pl.record_out_of_range_since(&key).is_none(),
761            "record timestamp should be cleaned up on remove"
762        );
763    }
764
765    /// #13: Responsible range shrink — out-of-range records have their
766    /// timestamp recorded, are NOT pruned before `PRUNE_HYSTERESIS_DURATION`,
767    /// and new in-range keys are still accepted while out-of-range keys
768    /// await expiry.
769    #[tokio::test]
770    async fn scenario_13_responsible_range_shrink() {
771        let (pl, _temp) = create_test_paid_list().await;
772
773        let out_of_range_key: XorName = [0x13; 32];
774        let in_range_key: XorName = [0x14; 32];
775
776        // Insert both keys initially (simulating they were once in range).
777        pl.insert(&out_of_range_key)
778            .await
779            .expect("insert out-of-range");
780        pl.insert(&in_range_key).await.expect("insert in-range");
781
782        // Range shrinks: out_of_range_key is no longer in responsibility range.
783        // Record RecordOutOfRangeFirstSeen.
784        pl.set_record_out_of_range(&out_of_range_key);
785        let first_seen = pl
786            .record_out_of_range_since(&out_of_range_key)
787            .expect("timestamp should be recorded for out-of-range key");
788
789        // Key must NOT be pruned yet — elapsed time is far below hysteresis.
790        let elapsed = first_seen.elapsed();
791        assert!(
792            elapsed < PRUNE_HYSTERESIS_DURATION,
793            "elapsed {elapsed:?} should be below PRUNE_HYSTERESIS_DURATION \
794             ({PRUNE_HYSTERESIS_DURATION:?}) — key must not be pruned yet"
795        );
796
797        // The key should still exist in the paid list (not deleted).
798        assert!(
799            pl.contains(&out_of_range_key).expect("contains"),
800            "out-of-range key should still be retained within hysteresis window"
801        );
802
803        // In-range key is unaffected — no out-of-range timestamp set.
804        assert!(
805            pl.record_out_of_range_since(&in_range_key).is_none(),
806            "in-range key should have no out-of-range timestamp"
807        );
808
809        // New in-range keys are still accepted during this period.
810        let new_key: XorName = [0x15; 32];
811        let was_new = pl.insert(&new_key).await.expect("insert new key");
812        assert!(
813            was_new,
814            "new in-range keys should still be accepted while out-of-range keys await expiry"
815        );
816        assert!(
817            pl.contains(&new_key).expect("contains new"),
818            "newly inserted in-range key should be present"
819        );
820    }
821
822    /// #46: Bootstrap claim first-seen is recorded and follows
823    /// first-observation-wins semantics.
824    #[test]
825    fn scenario_46_bootstrap_claim_first_seen_recorded() {
826        let peer = PeerId::from_bytes([0x46; 32]);
827        let mut state = NeighborSyncState::new_cycle(vec![peer]);
828
829        let first_ts = Instant::now()
830            .checked_sub(std::time::Duration::from_secs(3))
831            .unwrap_or_else(Instant::now);
832        let observed = state.observe_bootstrap_claim(peer, first_ts, BOOTSTRAP_CLAIM_GRACE_PERIOD);
833        assert_eq!(
834            observed,
835            BootstrapClaimObservation::WithinGrace {
836                first_seen: first_ts
837            }
838        );
839
840        // Verify recorded.
841        assert_eq!(
842            state.bootstrap_claims.get(&peer),
843            Some(&first_ts),
844            "first-seen timestamp should be recorded"
845        );
846        assert_eq!(
847            state.bootstrap_claim_history.get(&peer),
848            Some(&first_ts),
849            "first-ever timestamp should be retained"
850        );
851
852        // Observe again while still active — must NOT overwrite
853        // (first-observation-wins).
854        let later_ts = Instant::now();
855        let observed = state.observe_bootstrap_claim(peer, later_ts, BOOTSTRAP_CLAIM_GRACE_PERIOD);
856        assert_eq!(
857            observed,
858            BootstrapClaimObservation::WithinGrace {
859                first_seen: first_ts
860            }
861        );
862        assert_eq!(
863            state.bootstrap_claims.get(&peer),
864            Some(&first_ts),
865            "second insert must not overwrite the original timestamp"
866        );
867    }
868
869    /// #48: Peer P first claimed bootstrapping >24 h ago.  On next interaction
870    /// the claim age exceeds `BOOTSTRAP_CLAIM_GRACE_PERIOD` and the node emits
871    /// `BootstrapClaimAbuse` evidence.
872    #[test]
873    fn scenario_48_bootstrap_claim_abuse_after_grace_period() {
874        let peer = PeerId::from_bytes([0x48; 32]);
875        let mut state = NeighborSyncState::new_cycle(vec![peer]);
876
877        // Record a first-seen timestamp >24 h ago.
878        // `Instant::checked_sub` can fail on Windows where the epoch is
879        // process-start, so fall back to a recent instant when the platform
880        // cannot represent the backdated time (the claim-age assertion is
881        // skipped in that case since the subtraction itself proves nothing
882        // about production behaviour).
883        let grace_plus_margin = BOOTSTRAP_CLAIM_GRACE_PERIOD + std::time::Duration::from_secs(3600);
884        let first_seen = Instant::now()
885            .checked_sub(grace_plus_margin)
886            .unwrap_or_else(Instant::now);
887        state.bootstrap_claims.insert(peer, first_seen);
888        state.bootstrap_claim_history.insert(peer, first_seen);
889
890        // On platforms that support the backdated instant, verify claim age.
891        let claim_age = Instant::now().duration_since(first_seen);
892        if claim_age > std::time::Duration::from_secs(1) {
893            assert!(
894                claim_age > BOOTSTRAP_CLAIM_GRACE_PERIOD,
895                "claim age {claim_age:?} should exceed grace period {BOOTSTRAP_CLAIM_GRACE_PERIOD:?}",
896            );
897        }
898
899        // Caller constructs BootstrapClaimAbuse evidence.
900        let evidence = FailureEvidence::BootstrapClaimAbuse { peer, first_seen };
901
902        let FailureEvidence::BootstrapClaimAbuse {
903            peer: p,
904            first_seen: fs,
905        } = evidence
906        else {
907            unreachable!("evidence was just constructed as BootstrapClaimAbuse");
908        };
909        assert_eq!(p, peer);
910        assert_eq!(fs, first_seen);
911    }
912
913    /// #49: Bootstrap claim is cleared when a peer responds normally.
914    #[test]
915    fn scenario_49_bootstrap_claim_cleared() {
916        let peer = PeerId::from_bytes([0x49; 32]);
917        let mut state = NeighborSyncState::new_cycle(vec![peer]);
918
919        // Record a bootstrap claim.
920        let first_seen = Instant::now();
921        let _ = state.observe_bootstrap_claim(peer, first_seen, BOOTSTRAP_CLAIM_GRACE_PERIOD);
922        assert!(
923            state.bootstrap_claims.contains_key(&peer),
924            "claim should exist after insert"
925        );
926
927        // Peer responded normally — clear only the active claim.
928        state.clear_active_bootstrap_claim(&peer);
929        assert!(
930            !state.bootstrap_claims.contains_key(&peer),
931            "claim should be gone after normal response"
932        );
933        assert!(
934            state.bootstrap_claim_history.contains_key(&peer),
935            "claim history should remain so the peer cannot claim bootstrapping again"
936        );
937
938        let repeated =
939            state.observe_bootstrap_claim(peer, Instant::now(), BOOTSTRAP_CLAIM_GRACE_PERIOD);
940        assert_eq!(
941            repeated,
942            BootstrapClaimObservation::Repeated { first_seen },
943            "a second bootstrap claim should be classified as repeated abuse"
944        );
945    }
946}