Skip to main content

ant_node/replication/
recent_provers.rs

1//! Holder-eligibility cache: which peers recently proved storage of
2//! which key, against which commitment.
3//!
4//! Phase 2d of the v12 storage-bound audit design (`notes/security-
5//! findings-2026-05-22/proposal-gossip-audit-v12.md`).
6//!
7//! When the auditor successfully verifies a commitment-bound audit for
8//! peer P on key K (against P's currently-credited commitment hash H),
9//! it inserts `(P, H, now)` into `recent_provers[K]`. Reward / quorum
10//! eligibility for P-as-holder-of-K then checks that this cache entry
11//! still matches P's *currently credited* commitment hash; if P rotates
12//! the hash via fresh gossip, the cache entry becomes stale and credit
13//! is denied until the next successful audit against the new hash.
14//!
15//! Invariants enforced here:
16//!
17//! - **Per-key cap**: at most [`MAX_PROVERS_PER_KEY`] entries per key,
18//!   LRU-evicted by `proved_at`. Bounds the per-key working set so a
19//!   well-replicated key cannot fill memory.
20//! - **RT-only**: only peers in the caller's routing table populate
21//!   entries — the caller is responsible for filtering before
22//!   [`RecentProvers::record_proof`]; this module just stores what it's
23//!   told.
24//! - **Hash-bound credit**: [`RecentProvers::is_credited_holder`]
25//!   requires the cache entry's `commitment_hash` to match the peer's
26//!   *current* `commitment_hash`. A peer who proves K under C1 then
27//!   rotates to C2 loses credit until re-proving K under C2.
28//!
29//! - **TTL**: entries older than [`PROVER_ENTRY_TTL`] are ignored by
30//!   [`RecentProvers::is_credited_holder`] on read, and
31//!   [`RecentProvers::sweep_expired`] reclaims their memory when a
32//!   caller invokes it (e.g. periodically from the engine).
33//! - **`PeerRemoved` cleanup**: the caller should call
34//!   [`RecentProvers::forget_peer`] when a peer leaves the routing
35//!   table to drop their entries immediately (faster than waiting for
36//!   TTL).
37
38use std::collections::HashMap;
39use std::time::{Duration, Instant};
40
41use saorsa_core::identity::PeerId;
42
43use crate::ant_protocol::XorName;
44
45/// Maximum number of cached provers per key.
46///
47/// Sized at 2× `CLOSE_GROUP_SIZE = 8`, giving 8 slack slots for churn
48/// without unbounded growth. LRU-evicted within the cap.
49pub const MAX_PROVERS_PER_KEY: usize = 16;
50
51/// Maximum age of a cached prover entry before it is considered stale.
52///
53/// A proof older than this is treated as "no credit" by
54/// [`RecentProvers::is_credited_holder`] even if the commitment hash
55/// still matches.
56///
57/// v10/v12 §6 spec: `RECENT_PROOF_TTL = 2 × max audit interval` (≈40 min
58/// at the default 20 min max). Setting too low → peers fall out of
59/// credit between audits. Setting too high → lazy node has more leeway
60/// before re-audit is required. 40 min comfortably covers one audit
61/// cycle on the average peer while still requiring re-proof inside the
62/// rotation window.
63pub const PROVER_ENTRY_TTL: Duration = Duration::from_secs(40 * 60);
64
65/// One cached prover entry: who proved the key, when, and against which
66/// commitment.
67#[derive(Debug, Clone, Copy)]
68pub struct ProverEntry {
69    /// The peer that produced the audit proof.
70    pub peer_id: PeerId,
71    /// When the proof was recorded. Used for LRU eviction.
72    pub proved_at: Instant,
73    /// The peer's commitment hash at proof time. Holder-eligibility
74    /// requires this to match the peer's *currently credited* hash.
75    pub commitment_hash: [u8; 32],
76}
77
78/// Per-key cache of recent provers, capped at [`MAX_PROVERS_PER_KEY`].
79#[derive(Debug, Default, Clone)]
80pub struct RecentProvers {
81    /// `entries[K]` is the per-key bounded list. Entries are kept sorted
82    /// by `proved_at` ascending so eviction is `O(1)` (drop head).
83    entries: HashMap<XorName, Vec<ProverEntry>>,
84}
85
86impl RecentProvers {
87    /// Empty cache.
88    #[must_use]
89    pub fn new() -> Self {
90        Self::default()
91    }
92
93    /// Record that `peer_id` proved storage of `key` under commitment
94    /// `commitment_hash` at `proved_at`.
95    ///
96    /// If the same `(peer_id, commitment_hash)` is already cached for
97    /// this key, the entry is updated in place (refreshes `proved_at`).
98    /// Otherwise a new entry is appended, evicting the oldest entry if
99    /// the per-key cap would be exceeded.
100    pub fn record_proof(
101        &mut self,
102        key: XorName,
103        peer_id: PeerId,
104        commitment_hash: [u8; 32],
105        proved_at: Instant,
106    ) {
107        let bucket = self.entries.entry(key).or_default();
108
109        // Refresh-in-place if the (peer, hash) already exists.
110        for e in bucket.iter_mut() {
111            if e.peer_id == peer_id && e.commitment_hash == commitment_hash {
112                e.proved_at = proved_at;
113                bucket.sort_by_key(|e| e.proved_at);
114                return;
115            }
116        }
117
118        // Evict the oldest entry if we're at the cap.
119        if bucket.len() >= MAX_PROVERS_PER_KEY {
120            // bucket is sorted ascending; oldest is index 0.
121            bucket.remove(0);
122        }
123
124        bucket.push(ProverEntry {
125            peer_id,
126            proved_at,
127            commitment_hash,
128        });
129        bucket.sort_by_key(|e| e.proved_at);
130    }
131
132    /// Is `peer_id` currently credited as a holder of `key`?
133    ///
134    /// Returns `true` iff there is a non-stale cached entry with `peer_id`
135    /// and `commitment_hash == current_commitment_hash`.
136    ///
137    /// "Non-stale" means `now - proved_at < PROVER_ENTRY_TTL`. The hash
138    /// binding is the v12 §6 lever: a peer that rotates their commitment
139    /// must re-prove every key they want credit for. The TTL is a
140    /// secondary safety net that revokes credit even if the hash
141    /// happens to match (e.g. a peer who proved long ago but has been
142    /// silent or offline since).
143    #[must_use]
144    pub fn is_credited_holder(
145        &self,
146        key: &XorName,
147        peer_id: &PeerId,
148        current_commitment_hash: &[u8; 32],
149    ) -> bool {
150        let now = Instant::now();
151        self.entries.get(key).is_some_and(|bucket| {
152            bucket.iter().any(|e| {
153                &e.peer_id == peer_id
154                    && &e.commitment_hash == current_commitment_hash
155                    && now.saturating_duration_since(e.proved_at) < PROVER_ENTRY_TTL
156            })
157        })
158    }
159
160    /// Sweep entries older than [`PROVER_ENTRY_TTL`] across all keys.
161    ///
162    /// Returns the number of entries dropped. Intended for periodic
163    /// invocation by a background task; `is_credited_holder` already
164    /// honours the TTL on read, so the sweep only reclaims memory.
165    pub fn sweep_expired(&mut self, now: Instant) -> usize {
166        let mut dropped = 0;
167        for bucket in self.entries.values_mut() {
168            let before = bucket.len();
169            bucket.retain(|e| now.saturating_duration_since(e.proved_at) < PROVER_ENTRY_TTL);
170            dropped += before - bucket.len();
171        }
172        self.entries.retain(|_, b| !b.is_empty());
173        dropped
174    }
175
176    /// Drop every cached entry for `peer_id` across all keys.
177    ///
178    /// Called when a peer leaves the routing table (RT-only invariant)
179    /// or on explicit eviction.
180    pub fn forget_peer(&mut self, peer_id: &PeerId) {
181        for bucket in self.entries.values_mut() {
182            bucket.retain(|e| &e.peer_id != peer_id);
183        }
184        self.entries.retain(|_, b| !b.is_empty());
185    }
186
187    /// Drop every entry whose `commitment_hash` matches `stale_hash`
188    /// (used when the auditor invalidates a peer's `last_commitment` —
189    /// e.g. on `UnknownCommitmentHash` rejection — to remove the cached
190    /// proofs against that no-longer-valid commitment).
191    pub fn forget_commitment(&mut self, stale_hash: &[u8; 32]) {
192        for bucket in self.entries.values_mut() {
193            bucket.retain(|e| &e.commitment_hash != stale_hash);
194        }
195        self.entries.retain(|_, b| !b.is_empty());
196    }
197
198    /// Number of cached entries for `key`. Test/observability helper.
199    #[must_use]
200    pub fn provers_for(&self, key: &XorName) -> usize {
201        self.entries.get(key).map_or(0, Vec::len)
202    }
203
204    /// Total number of cached entries across all keys.
205    #[must_use]
206    pub fn total_entries(&self) -> usize {
207        self.entries.values().map(Vec::len).sum()
208    }
209}
210
211// ---------------------------------------------------------------------------
212// Tests
213// ---------------------------------------------------------------------------
214
215#[cfg(test)]
216#[allow(clippy::unwrap_used, clippy::expect_used)]
217mod tests {
218    use super::*;
219    use std::time::Duration;
220
221    fn peer(byte: u8) -> PeerId {
222        let mut bytes = [0u8; 32];
223        bytes[0] = byte;
224        PeerId::from_bytes(bytes)
225    }
226
227    fn key(byte: u8) -> XorName {
228        let mut k = [0u8; 32];
229        k[0] = byte;
230        k
231    }
232
233    fn hash(byte: u8) -> [u8; 32] {
234        [byte; 32]
235    }
236
237    #[test]
238    fn empty_cache_credits_no_one() {
239        let cache = RecentProvers::new();
240        assert!(!cache.is_credited_holder(&key(1), &peer(1), &hash(1)));
241        assert_eq!(cache.total_entries(), 0);
242    }
243
244    #[test]
245    fn recorded_proof_credits_under_same_hash() {
246        let mut cache = RecentProvers::new();
247        cache.record_proof(key(1), peer(7), hash(0xAB), Instant::now());
248        assert!(cache.is_credited_holder(&key(1), &peer(7), &hash(0xAB)));
249    }
250
251    #[test]
252    fn rotated_hash_loses_credit() {
253        // Core v12 §6 attack-bound property: a peer who proves K under
254        // C1 must re-prove under C2 to keep credit. The cache entry's
255        // hash binding enforces this.
256        let mut cache = RecentProvers::new();
257        cache.record_proof(key(1), peer(7), hash(0xAB), Instant::now());
258        // Same peer, same key, but the auditor's "current" hash for
259        // this peer is now different (peer gossiped a new commitment).
260        assert!(!cache.is_credited_holder(&key(1), &peer(7), &hash(0xCD)));
261    }
262
263    #[test]
264    fn other_peer_under_same_hash_not_credited() {
265        let mut cache = RecentProvers::new();
266        cache.record_proof(key(1), peer(7), hash(0xAB), Instant::now());
267        assert!(!cache.is_credited_holder(&key(1), &peer(8), &hash(0xAB)));
268    }
269
270    #[test]
271    fn per_key_cap_evicts_oldest() {
272        let mut cache = RecentProvers::new();
273        let now = Instant::now();
274        // MAX_PROVERS_PER_KEY is a small usize (16). Narrow to u8 once
275        // so the test loop can hand the peer-id byte directly to
276        // `peer(...)` without per-iteration casts.
277        let max_u8 = u8::try_from(MAX_PROVERS_PER_KEY).unwrap_or(u8::MAX);
278        // Fill the bucket with MAX_PROVERS_PER_KEY + 1 distinct peers.
279        for i in 0..=max_u8 {
280            let t = now + Duration::from_millis(u64::from(i));
281            cache.record_proof(key(1), peer(i), hash(0xAB), t);
282        }
283        assert_eq!(cache.provers_for(&key(1)), MAX_PROVERS_PER_KEY);
284        // The oldest (peer 0) should be evicted; peer MAX should be present.
285        assert!(!cache.is_credited_holder(&key(1), &peer(0), &hash(0xAB)));
286        assert!(cache.is_credited_holder(&key(1), &peer(max_u8), &hash(0xAB)));
287    }
288
289    #[test]
290    fn refresh_in_place_does_not_grow_bucket() {
291        let mut cache = RecentProvers::new();
292        let now = Instant::now();
293        // Same (peer, hash) repeated three times. Bucket should stay at 1.
294        cache.record_proof(key(1), peer(1), hash(0xAB), now);
295        cache.record_proof(key(1), peer(1), hash(0xAB), now + Duration::from_secs(1));
296        cache.record_proof(key(1), peer(1), hash(0xAB), now + Duration::from_secs(2));
297        assert_eq!(cache.provers_for(&key(1)), 1);
298    }
299
300    #[test]
301    fn forget_peer_drops_all_entries() {
302        let mut cache = RecentProvers::new();
303        let now = Instant::now();
304        cache.record_proof(key(1), peer(1), hash(0xAB), now);
305        cache.record_proof(key(2), peer(1), hash(0xAB), now);
306        cache.record_proof(key(1), peer(2), hash(0xAB), now);
307        assert_eq!(cache.total_entries(), 3);
308
309        cache.forget_peer(&peer(1));
310        assert_eq!(cache.total_entries(), 1);
311        assert!(!cache.is_credited_holder(&key(1), &peer(1), &hash(0xAB)));
312        assert!(cache.is_credited_holder(&key(1), &peer(2), &hash(0xAB)));
313    }
314
315    #[test]
316    fn forget_commitment_drops_only_matching_entries() {
317        let mut cache = RecentProvers::new();
318        let now = Instant::now();
319        cache.record_proof(key(1), peer(1), hash(0xAB), now);
320        cache.record_proof(key(1), peer(1), hash(0xCD), now);
321        cache.record_proof(key(2), peer(2), hash(0xAB), now);
322        assert_eq!(cache.total_entries(), 3);
323
324        cache.forget_commitment(&hash(0xAB));
325        assert_eq!(cache.total_entries(), 1);
326        // Only the (peer(1), hash 0xCD) entry remains.
327        assert!(cache.is_credited_holder(&key(1), &peer(1), &hash(0xCD)));
328        assert!(!cache.is_credited_holder(&key(1), &peer(1), &hash(0xAB)));
329        assert!(!cache.is_credited_holder(&key(2), &peer(2), &hash(0xAB)));
330    }
331
332    #[test]
333    fn lazy_rotation_via_unknown_commitment_hash_drops_credit() {
334        // Scenario from v12 §5 (revised UnknownCommitmentHash handler):
335        //   1. Peer P proves K under C1 → cached.
336        //   2. Auditor pinned to C1 sends a new challenge.
337        //   3. P replies UnknownCommitmentHash (they rotated and
338        //      dropped the bytes).
339        //   4. Auditor invalidates last_commitment[P] AND calls
340        //      forget_commitment(C1) so credit doesn't linger.
341        //
342        // Property checked: after forget_commitment(C1), P is no longer
343        // credited as holder of K under C1.
344        let mut cache = RecentProvers::new();
345        cache.record_proof(key(1), peer(7), hash(0xAB), Instant::now());
346        assert!(cache.is_credited_holder(&key(1), &peer(7), &hash(0xAB)));
347
348        // Auditor detects rotation/dodge, invalidates the C1 hash.
349        cache.forget_commitment(&hash(0xAB));
350
351        assert!(!cache.is_credited_holder(&key(1), &peer(7), &hash(0xAB)));
352        // And under any new hash too — the peer has to re-prove.
353        assert!(!cache.is_credited_holder(&key(1), &peer(7), &hash(0xCD)));
354    }
355}