phantom_protocol/transport/
reputation.rs1use dashmap::DashMap;
2use std::net::IpAddr;
3use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6const WINDOW_SECS: u64 = 60;
7const BASE_DIFFICULTY: u8 = 8;
8const MAX_DIFFICULTY: u8 = 20;
9const DEFAULT_MAX_ENTRIES: usize = 100_000;
14
15struct ReputationEntry {
16 violations: AtomicU32,
17 last_seen_secs: AtomicU64,
18}
19
20impl ReputationEntry {
21 fn new(now: u64) -> Self {
22 Self {
23 violations: AtomicU32::new(1),
24 last_seen_secs: AtomicU64::new(now),
25 }
26 }
27}
28
29pub struct ReputationTracker {
35 entries: DashMap<IpAddr, ReputationEntry>,
36 max_entries: usize,
37}
38
39impl ReputationTracker {
40 pub fn new() -> Self {
41 Self::with_capacity(DEFAULT_MAX_ENTRIES)
42 }
43
44 pub fn with_capacity(max_entries: usize) -> Self {
46 Self {
47 entries: DashMap::new(),
48 max_entries: max_entries.max(1),
49 }
50 }
51
52 fn now_secs() -> u64 {
53 SystemTime::now()
54 .duration_since(UNIX_EPOCH)
55 .unwrap_or_default()
56 .as_secs()
57 }
58
59 pub fn record_violation(&self, ip: IpAddr) {
61 let now = Self::now_secs();
62
63 if let Some(entry) = self.entries.get(&ip) {
64 let last_seen = entry.last_seen_secs.load(Ordering::Relaxed);
65 if now > last_seen + WINDOW_SECS {
66 entry.violations.store(1, Ordering::Relaxed);
68 } else {
69 entry.violations.fetch_add(1, Ordering::Relaxed);
70 }
71 entry.last_seen_secs.store(now, Ordering::Relaxed);
72 } else {
73 if self.entries.len() >= self.max_entries {
77 self.gc();
78 if self.entries.len() >= self.max_entries {
79 return;
80 }
81 }
82 self.entries.insert(ip, ReputationEntry::new(now));
83 }
84 }
85
86 pub fn reset_violations(&self, ip: IpAddr) {
88 self.entries.remove(&ip);
89 }
90
91 pub fn calculate_difficulty(&self, ip: IpAddr, has_ticket: bool) -> u8 {
93 if has_ticket {
94 return 0; }
96
97 let now = Self::now_secs();
98
99 if let Some(entry) = self.entries.get(&ip) {
100 let last_seen = entry.last_seen_secs.load(Ordering::Relaxed);
101 if now > last_seen + WINDOW_SECS {
102 return 0;
104 }
105
106 let violations = entry.violations.load(Ordering::Relaxed);
107 if violations == 0 {
108 return 0;
109 }
110
111 let exp = (violations - 1).min(8);
115 let diff = (BASE_DIFFICULTY as u32 + (1u32 << exp)).min(MAX_DIFFICULTY as u32);
116 diff as u8
117 } else {
118 0
121 }
122 }
123
124 pub fn gc(&self) {
126 let now = Self::now_secs();
127 let before = self.entries.len();
128 self.entries.retain(|_, v| {
129 let last_seen = v.last_seen_secs.load(Ordering::Relaxed);
130 now <= last_seen + WINDOW_SECS
131 });
132 let after = self.entries.len();
133 if before > after {
134 log::info!(
135 "Reputation GC: removed {} expired entries, {} remaining",
136 before - after,
137 after
138 );
139 }
140 }
141}
142
143impl Default for ReputationTracker {
144 fn default() -> Self {
145 Self::new()
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 fn ip(n: u8) -> IpAddr {
154 IpAddr::from([10, 0, 0, n])
155 }
156
157 #[test]
161 fn clean_ip_pays_no_extra_difficulty() {
162 let rep = ReputationTracker::new();
163 assert_eq!(rep.calculate_difficulty(ip(1), false), 0, "new IP");
164 assert_eq!(rep.calculate_difficulty(ip(1), true), 0, "ticket holder");
165 }
166
167 #[test]
170 fn repeated_violations_escalate_and_reset_clears() {
171 let rep = ReputationTracker::new();
172 let a = ip(2);
173 assert_eq!(rep.calculate_difficulty(a, false), 0);
174 rep.record_violation(a);
175 let d1 = rep.calculate_difficulty(a, false);
176 assert!(
177 d1 >= BASE_DIFFICULTY,
178 "first violation escalates to >= base, got {d1}"
179 );
180 rep.record_violation(a);
181 rep.record_violation(a);
182 let d3 = rep.calculate_difficulty(a, false);
183 assert!(
184 d3 >= d1 && d3 <= MAX_DIFFICULTY,
185 "escalates further, capped at max; got {d3}"
186 );
187 rep.reset_violations(a);
188 assert_eq!(
189 rep.calculate_difficulty(a, false),
190 0,
191 "reset clears escalation"
192 );
193 }
194
195 #[test]
197 fn map_is_bounded() {
198 let rep = ReputationTracker::with_capacity(8);
199 for n in 0..100u8 {
200 rep.record_violation(IpAddr::from([10, 1, 0, n]));
201 }
202 assert!(
203 rep.entries.len() <= 8,
204 "map must stay bounded, got {}",
205 rep.entries.len()
206 );
207 }
208}