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