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}