Skip to main content

tenzro_network/
peer_manager.rs

1//! Peer tracking and reputation management for Tenzro Network
2//!
3//! This module implements comprehensive peer management including:
4//!
5//! - **Reputation scoring**: Peers earn reputation from +100 to -100 based on behavior
6//! - **Rate limiting**: Per-peer sliding window rate limiting (default: 100 msgs/10s)
7//! - **Automatic banning**: Peers are auto-banned when reputation drops below -50
8//! - **Ban management**: Configurable ban duration (default: 1 hour) with automatic expiry
9//! - **Connection tracking**: Failed connection attempts trigger auto-banning after 5 failures
10//!
11//! Rate limiting is implemented using a sliding window approach with timestamps stored
12//! in a VecDeque. The `check_rate_limit()` method should be called for each incoming
13//! message to enforce limits.
14
15use dashmap::{DashMap, DashSet};
16use governor::{
17    clock::DefaultClock,
18    state::{InMemoryState, NotKeyed},
19    Quota, RateLimiter,
20};
21use libp2p::{Multiaddr, PeerId};
22use std::collections::{HashSet, VecDeque};
23use std::net::IpAddr;
24use std::num::NonZeroU32;
25use std::sync::Arc;
26use std::time::{Duration, Instant};
27use tenzro_types::network::{NetworkRole, PeerStatus};
28
29/// Type alias for a keyed rate limiter over IP addresses.
30/// Each IP gets its own token bucket; eviction is handled by governor internally.
31type IpRateLimiter = RateLimiter<IpAddr, dashmap::DashMap<IpAddr, governor::state::InMemoryState>, DefaultClock>;
32
33/// Type alias for a global (non-keyed) rate limiter.
34type GlobalRateLimiter = RateLimiter<NotKeyed, InMemoryState, DefaultClock>;
35
36/// Trait for checking validator set membership.
37///
38/// Implemented by the node to provide the network layer with access to the
39/// current validator set without creating a circular dependency on tenzro-consensus.
40pub trait ValidatorRegistry: Send + Sync {
41    /// Returns true if the given PeerId is a known, active validator.
42    fn is_validator(&self, peer_id: &PeerId) -> bool;
43
44    /// Returns the set of all active validator PeerIds.
45    fn validator_peer_ids(&self) -> HashSet<PeerId>;
46
47    /// Attempt to dynamically register this peer as a validator after the
48    /// identify handshake has confirmed it speaks a trusted Tenzro protocol.
49    ///
50    /// Default implementation: no-op. The node-level registry overrides this
51    /// to admit peers so that long-running validator topics (consensus,
52    /// attestations) don't cause mutual peer-score decay & gossipsub bans
53    /// when a new validator joins after the boot set has been wired.
54    fn try_add_validator(&self, _peer_id: &PeerId) {}
55
56    /// Remove a peer from the validator set.
57    ///
58    /// Called by the peer manager when a peer is banned for misbehavior —
59    /// a banned peer must not continue to be authorized for validator-only
60    /// gossipsub topics (consensus, attestations). This is the symmetric
61    /// counterpart to `try_add_validator` and ensures the validator set
62    /// stays consistent with the connected/admissible peer set.
63    ///
64    /// Default implementation: no-op. The node-level registry overrides this.
65    fn try_remove_validator(&self, _peer_id: &PeerId) {}
66}
67
68/// Topics that require validator authorization for message publishing.
69/// Non-validators can subscribe and receive these topics (light clients)
70/// but cannot publish to them.
71/// NOTE: "blocks" is intentionally excluded — block announcements must propagate
72/// to ALL nodes including RPC nodes and nodes not yet in the local validator registry.
73/// Only consensus voting and attestation messages are restricted to known validators.
74pub const VALIDATOR_ONLY_TOPICS: &[&str] = &[
75    "consensus",
76    "attestations",
77];
78
79/// Peer information tracked by the manager
80#[derive(Debug, Clone)]
81pub struct ManagedPeer {
82    /// Peer ID
83    pub peer_id: PeerId,
84    /// Peer role
85    pub role: Option<NetworkRole>,
86    /// Reputation score (-100 to 100)
87    pub reputation: i32,
88    /// Connection status
89    pub status: PeerStatus,
90    /// Last seen timestamp
91    pub last_seen: Instant,
92    /// Number of failed connections
93    pub failed_connections: u32,
94    /// Ban expiration time (if banned)
95    pub ban_until: Option<Instant>,
96    /// Protocol version
97    pub protocol_version: Option<String>,
98    /// Message timestamps for rate limiting (sliding window)
99    pub message_timestamps: VecDeque<Instant>,
100    /// Last observed remote multiaddr for the peer's primary connection.
101    /// Used to detect address migrations (e.g. QUIC path migration, mobile
102    /// network switches, NAT rebinding). When the remote endpoint of a newly
103    /// established connection differs from this value, a migration counter is
104    /// incremented at the service layer.
105    pub last_endpoint: Option<Multiaddr>,
106}
107
108impl ManagedPeer {
109    /// Creates a new peer entry
110    pub fn new(peer_id: PeerId) -> Self {
111        Self {
112            peer_id,
113            role: None,
114            reputation: 0,
115            status: PeerStatus::Disconnected,
116            last_seen: Instant::now(),
117            failed_connections: 0,
118            ban_until: None,
119            protocol_version: None,
120            message_timestamps: VecDeque::new(),
121            last_endpoint: None,
122        }
123    }
124
125    /// Checks if the peer is banned
126    pub fn is_banned(&self) -> bool {
127        if let Some(until) = self.ban_until {
128            Instant::now() < until
129        } else {
130            false
131        }
132    }
133
134    /// Increases reputation
135    pub fn increase_reputation(&mut self, amount: i32) {
136        self.reputation = (self.reputation + amount).min(100);
137    }
138
139    /// Decreases reputation
140    pub fn decrease_reputation(&mut self, amount: i32) {
141        self.reputation = (self.reputation - amount).max(-100);
142    }
143
144    /// Updates last seen time
145    pub fn update_last_seen(&mut self) {
146        self.last_seen = Instant::now();
147    }
148}
149
150/// Peer manager for tracking and managing peers
151pub struct PeerManager {
152    /// Map of peer ID to peer info
153    peers: DashMap<PeerId, ManagedPeer>,
154    /// Maximum allowed peers
155    max_peers: usize,
156    /// Ban duration for misbehaving peers
157    ban_duration: Duration,
158    /// Reputation threshold for banning
159    ban_threshold: i32,
160    /// Maximum messages per peer per rate limit window
161    rate_limit_max_messages: u32,
162    /// Rate limit sliding window duration
163    rate_limit_window: Duration,
164    /// Optional validator registry for peer authorization
165    validator_registry: Option<Arc<dyn ValidatorRegistry>>,
166    /// Boot-node peers that should never be banned.
167    /// These are critical infrastructure peers (e.g., boot nodes from --boot-nodes CLI).
168    /// They may trigger rate limits due to high block production rates, but banning them
169    /// would isolate the node from the network entirely.
170    /// Peers protected from auto-ban. Boot nodes are added at startup; any
171    /// peer that completes a libp2p Identify handshake announcing a
172    /// `tenzro/` protocol is promoted here, so cluster validators can never
173    /// ban each other via reputation decay or rate-limit penalties.
174    ///
175    /// Stored as a lock-free `DashSet` so Identify-time promotion can mutate
176    /// it from the `&self` swarm event path (see
177    /// `try_register_validator_on_identify`).
178    protected_peers: DashSet<PeerId>,
179    /// Per-IP dial rate limiter (Sybil / eclipse attack defence).
180    ///
181    /// Limits inbound dial attempts from a single IP to prevent an attacker
182    /// from consuming all 32 pending-inbound slots by repeatedly reconnecting.
183    /// Default quota: 10 dials/minute per IP with burst of 5.
184    ip_dial_limiter: Arc<IpRateLimiter>,
185    /// Global dial rate limiter — protects against aggregate dial floods.
186    /// Default: 200 dials/minute with burst of 20.
187    global_dial_limiter: Arc<GlobalRateLimiter>,
188}
189
190impl PeerManager {
191    /// Creates a new peer manager
192    pub fn new(max_peers: usize) -> Self {
193        // Per-IP dial limit: 10/min, burst 5. Blocks Sybil-by-reconnect where
194        // an attacker tries to fill our 32 pending-inbound slots by repeatedly
195        // dialling from one IP. Legitimate boot-node reconnects use ≤1 dial/min.
196        let ip_quota = Quota::per_minute(NonZeroU32::new(10).unwrap())
197            .allow_burst(NonZeroU32::new(5).unwrap());
198        let ip_dial_limiter = Arc::new(RateLimiter::dashmap_with_clock(ip_quota, DefaultClock::default()));
199
200        // Global dial limit: 200/min, burst 20. Aggregate protection against
201        // dial floods spread across many IPs (distributed Sybil).
202        let global_quota = Quota::per_minute(NonZeroU32::new(200).unwrap())
203            .allow_burst(NonZeroU32::new(20).unwrap());
204        let global_dial_limiter = Arc::new(RateLimiter::direct_with_clock(global_quota, DefaultClock::default()));
205
206        Self {
207            peers: DashMap::new(),
208            max_peers,
209            ban_duration: Duration::from_secs(300), // 5 minutes (was 1 hour — too aggressive)
210            ban_threshold: -50,
211            rate_limit_max_messages: 1000, // 1000 messages per window (high to accommodate block production)
212            rate_limit_window: Duration::from_secs(10), // 10 second window
213            validator_registry: None,
214            protected_peers: DashSet::new(),
215            ip_dial_limiter,
216            global_dial_limiter,
217        }
218    }
219
220    /// Checks if a dial from the given IP should be allowed.
221    ///
222    /// Consults two rate limiters:
223    /// 1. Per-IP bucket — blocks Sybil-by-reconnect from a single source
224    /// 2. Global bucket — blocks aggregate dial floods across many IPs
225    ///
226    /// Returns `true` if the dial is within both rate limits, `false` if
227    /// either limit has been exceeded. The caller (service.rs swarm dial
228    /// handler) should reject the connection and increment
229    /// `DIAL_REJECTED_RATE_LIMIT` on a `false` result.
230    pub fn check_dial_rate_limit(&self, remote_ip: IpAddr) -> bool {
231        if self.global_dial_limiter.check().is_err() {
232            tracing::warn!("Global dial rate limit exceeded; rejecting connection");
233            return false;
234        }
235        if self.ip_dial_limiter.check_key(&remote_ip).is_err() {
236            tracing::warn!(ip = %remote_ip, "Per-IP dial rate limit exceeded; rejecting connection");
237            return false;
238        }
239        true
240    }
241
242    /// Returns the gossipsub application-specific score for a peer.
243    ///
244    /// Maps our internal reputation (-100 .. +100) onto the f64 range used
245    /// by libp2p gossipsub's P5 factor. Gossipsub combines this with P1-P4
246    /// and P6-P7 via weighted sum. Typical mapping:
247    ///
248    /// - Reputation  +100 → P5 score +32.0  (capped at topic_score_cap)
249    /// - Reputation     0 → P5 score   0.0
250    /// - Reputation  -100 → P5 score -32.0
251    ///
252    /// Peers not tracked return 0.0 (neutral). This method is designed to
253    /// be hot-path safe — no allocations, single DashMap lookup.
254    pub fn app_specific_score(&self, peer_id: &PeerId) -> f64 {
255        self.peers
256            .get(peer_id)
257            .map(|p| (p.reputation as f64) * 0.32)
258            .unwrap_or(0.0)
259    }
260
261    /// Creates a new peer manager with custom rate limiting
262    pub fn with_rate_limit(max_peers: usize, max_messages: u32, window_secs: u64) -> Self {
263        Self {
264            rate_limit_max_messages: max_messages,
265            rate_limit_window: Duration::from_secs(window_secs),
266            ..Self::new(max_peers)
267        }
268    }
269
270    /// Sets the validator registry for peer authorization
271    pub fn set_validator_registry(&mut self, registry: Arc<dyn ValidatorRegistry>) {
272        self.validator_registry = Some(registry);
273    }
274
275    /// Returns a clone of the installed validator registry, if any.
276    ///
277    /// Used by the network service's admitted-mesh gate to intersect the
278    /// gossipsub mesh peer set with the validator-admitted set, so that the
279    /// first consensus publish only fires after enough mesh peers have been
280    /// admitted via identify (closing the race where messages arrive before
281    /// `try_register_validator_on_identify` has fired and get silently dropped
282    /// by `authorize_peer_for_topic`).
283    pub fn validator_registry(&self) -> Option<Arc<dyn ValidatorRegistry>> {
284        self.validator_registry.clone()
285    }
286
287    /// Checks if a peer is authorized to publish on a validator-only topic.
288    ///
289    /// Returns `true` if:
290    /// - The topic is not validator-only (e.g., transactions, status, inference), OR
291    /// - No validator registry is set (permissive mode), OR
292    /// - The peer is a known validator in the current validator set
293    ///
294    /// Returns `false` if the topic requires validator status and the peer is not a validator.
295    pub fn authorize_peer_for_topic(&self, peer_id: &PeerId, topic_name: &str) -> bool {
296        // Check if this topic requires validator authorization
297        let is_validator_topic = VALIDATOR_ONLY_TOPICS.iter().any(|t| topic_name.contains(t));
298
299        if !is_validator_topic {
300            // Non-validator topics (transactions, models, inference, status) are open to all
301            return true;
302        }
303
304        // Validator-only topic — check registry
305        match &self.validator_registry {
306            Some(registry) => {
307                let authorized = registry.is_validator(peer_id);
308                if !authorized {
309                    tracing::warn!(
310                        peer = %peer_id,
311                        topic = topic_name,
312                        "Rejected message from non-validator on validator-only topic"
313                    );
314                }
315                authorized
316            }
317            None => {
318                // No registry configured — permissive mode (pre-genesis or single-node)
319                true
320            }
321        }
322    }
323
324    /// Dynamically admit a peer as a validator after the identify handshake
325    /// confirms it speaks a trusted Tenzro protocol.
326    ///
327    /// This closes the "mutual ban" gap where a peer that joins AFTER the
328    /// static boot-node list was wired would never be admitted to validator
329    /// topics, causing its consensus/attestation messages to be rejected and
330    /// its peer score to decay below thresholds.
331    ///
332    /// Only protocol versions starting with `"tenzro/"` are admitted. All
333    /// other identify payloads are ignored (no-op).
334    pub fn try_register_validator_on_identify(&self, peer_id: &PeerId, protocol_version: &str) {
335        if !protocol_version.starts_with("tenzro/") {
336            return;
337        }
338
339        // Promote to protected so cluster validators can never mutually ban
340        // each other via reputation decay / rate-limit penalties. This is
341        // the load-bearing fix for the "all blocks empty; peers perpetually
342        // banned" class of outage on the live testnet.
343        self.add_protected_peer(*peer_id);
344
345        if let Some(registry) = &self.validator_registry
346            && !registry.is_validator(peer_id)
347        {
348            tracing::info!(
349                peer = %peer_id,
350                protocol = protocol_version,
351                "Dynamically admitting peer as validator via identify handshake"
352            );
353            registry.try_add_validator(peer_id);
354        }
355    }
356
357    /// Adds a new peer or updates existing peer
358    pub fn add_peer(&self, peer_id: PeerId) {
359        self.peers.entry(peer_id).or_insert_with(|| ManagedPeer::new(peer_id));
360    }
361
362    /// Removes a peer
363    pub fn remove_peer(&self, peer_id: &PeerId) {
364        self.peers.remove(peer_id);
365    }
366
367    /// Gets peer information
368    pub fn get_peer(&self, peer_id: &PeerId) -> Option<ManagedPeer> {
369        self.peers.get(peer_id).map(|p| p.clone())
370    }
371
372    /// Updates peer status
373    pub fn update_status(&self, peer_id: &PeerId, status: PeerStatus) {
374        if let Some(mut peer) = self.peers.get_mut(peer_id) {
375            peer.status = status;
376            peer.update_last_seen();
377        }
378    }
379
380    /// Records the observed remote multiaddr for a peer's newly established
381    /// connection. Returns `true` if the previous `last_endpoint` was set and
382    /// differed from `addr` — i.e. an address migration was observed
383    /// (QUIC path migration, mobile network switch, NAT rebinding). Returns
384    /// `false` on first observation or when the address is unchanged.
385    pub fn update_endpoint(&self, peer_id: &PeerId, addr: Multiaddr) -> bool {
386        let mut migrated = false;
387        if let Some(mut peer) = self.peers.get_mut(peer_id) {
388            match &peer.last_endpoint {
389                Some(prev) if prev != &addr => {
390                    migrated = true;
391                }
392                _ => {}
393            }
394            peer.last_endpoint = Some(addr);
395        }
396        migrated
397    }
398
399    /// Updates peer role
400    pub fn update_role(&self, peer_id: &PeerId, role: NetworkRole) {
401        if let Some(mut peer) = self.peers.get_mut(peer_id) {
402            peer.role = Some(role);
403        }
404    }
405
406    /// Updates peer protocol version
407    pub fn update_protocol_version(&self, peer_id: &PeerId, version: String) {
408        if let Some(mut peer) = self.peers.get_mut(peer_id) {
409            peer.protocol_version = Some(version);
410        }
411    }
412
413    /// Increases a peer's reputation
414    pub fn increase_reputation(&self, peer_id: &PeerId, amount: i32) {
415        if let Some(mut peer) = self.peers.get_mut(peer_id) {
416            peer.increase_reputation(amount);
417            tracing::debug!(
418                "Increased reputation for peer {} by {} (new: {})",
419                peer_id,
420                amount,
421                peer.reputation
422            );
423        }
424    }
425
426    /// Decreases a peer's reputation and potentially bans them.
427    ///
428    /// Protected peers (boot nodes, peers that completed a `tenzro/`
429    /// Identify handshake) are skipped entirely — neither their reputation
430    /// nor their ban state is touched. This is deliberate: on a small
431    /// validator set (e.g. 3 validators on the live testnet), HotStuff-2
432    /// consensus bursts plus any single malformed gossip frame would
433    /// otherwise drive mutual reputation below the ban threshold within
434    /// seconds and wedge the chain in empty-block mode.
435    pub fn decrease_reputation(&self, peer_id: &PeerId, amount: i32) {
436        if self.protected_peers.contains(peer_id) {
437            tracing::trace!(
438                "Skipping reputation decrease for protected peer {} (amount={})",
439                peer_id,
440                amount
441            );
442            return;
443        }
444        if let Some(mut peer) = self.peers.get_mut(peer_id) {
445            peer.decrease_reputation(amount);
446            tracing::debug!(
447                "Decreased reputation for peer {} by {} (new: {})",
448                peer_id,
449                amount,
450                peer.reputation
451            );
452
453            // Auto-ban if reputation drops too low
454            if peer.reputation <= self.ban_threshold {
455                self.ban_peer_internal(&mut peer);
456            }
457        }
458    }
459
460    /// Bans a peer for misbehavior
461    pub fn ban_peer(&self, peer_id: &PeerId) {
462        if let Some(mut peer) = self.peers.get_mut(peer_id) {
463            self.ban_peer_internal(&mut peer);
464        }
465    }
466
467    /// Marks a peer as a protected peer (e.g., boot node).
468    /// Protected peers will never be auto-banned, even if they trigger rate limits.
469    ///
470    /// Takes `&self` because `protected_peers` is a lock-free `DashSet`.
471    /// The previous `&mut self` signature prevented Identify-time promotion
472    /// from the swarm event loop (which holds only `&self` on PeerManager).
473    pub fn add_protected_peer(&self, peer_id: PeerId) {
474        if self.protected_peers.insert(peer_id) {
475            tracing::info!("Added protected peer {}", peer_id);
476        }
477    }
478
479    /// Checks if a peer is protected (boot node or critical infrastructure)
480    pub fn is_protected(&self, peer_id: &PeerId) -> bool {
481        self.protected_peers.contains(peer_id)
482    }
483
484    /// Internal ban implementation
485    fn ban_peer_internal(&self, peer: &mut ManagedPeer) {
486        if self.protected_peers.contains(&peer.peer_id) {
487            tracing::debug!(
488                "Skipping ban for protected peer {} (reputation: {})",
489                peer.peer_id,
490                peer.reputation
491            );
492            // Reset reputation to 0 instead of banning — prevents runaway negative scores
493            peer.reputation = 0;
494            return;
495        }
496        peer.status = PeerStatus::Banned;
497        peer.ban_until = Some(Instant::now() + self.ban_duration);
498        tracing::warn!("Banned peer {} for {} seconds", peer.peer_id, self.ban_duration.as_secs());
499
500        // A banned peer must also be removed from the validator set so it
501        // cannot continue to be authorized for validator-only gossipsub topics
502        // (consensus, attestations). This keeps validator authorization
503        // consistent with peer admission state.
504        if let Some(registry) = &self.validator_registry
505            && registry.is_validator(&peer.peer_id)
506        {
507            registry.try_remove_validator(&peer.peer_id);
508        }
509    }
510
511    /// Unbans a peer
512    pub fn unban_peer(&self, peer_id: &PeerId) {
513        if let Some(mut peer) = self.peers.get_mut(peer_id) {
514            peer.ban_until = None;
515            peer.status = PeerStatus::Disconnected;
516            peer.reputation = 0; // Reset reputation
517            tracing::info!("Unbanned peer {}", peer_id);
518        }
519    }
520
521    /// Checks if a peer is banned
522    pub fn is_banned(&self, peer_id: &PeerId) -> bool {
523        // Protected peers are never considered banned, even if a previous
524        // (pre-protection) ban is still lingering on their ManagedPeer record.
525        // Clear the ban_until stamp on first read to keep downstream paths
526        // (ban duration metrics, reconnect logic) consistent.
527        if self.protected_peers.contains(peer_id) {
528            if let Some(mut peer) = self.peers.get_mut(peer_id)
529                && peer.is_banned()
530            {
531                tracing::info!(
532                    peer = %peer_id,
533                    "Clearing lingering ban on now-protected peer"
534                );
535                peer.ban_until = None;
536                peer.reputation = peer.reputation.max(0);
537            }
538            return false;
539        }
540        self.peers
541            .get(peer_id)
542            .map(|p| p.is_banned())
543            .unwrap_or(false)
544    }
545
546    /// Gets all connected peers
547    pub fn connected_peers(&self) -> Vec<PeerId> {
548        self.peers
549            .iter()
550            .filter(|entry| entry.status == PeerStatus::Connected)
551            .map(|entry| entry.peer_id)
552            .collect()
553    }
554
555    /// Gets peers by role
556    pub fn peers_by_role(&self, role: NetworkRole) -> Vec<PeerId> {
557        self.peers
558            .iter()
559            .filter(|entry| entry.role == Some(role))
560            .map(|entry| entry.peer_id)
561            .collect()
562    }
563
564    /// Gets the number of connected peers
565    pub fn connected_count(&self) -> usize {
566        self.peers
567            .iter()
568            .filter(|entry| entry.status == PeerStatus::Connected)
569            .count()
570    }
571
572    /// Checks if we can accept more peers
573    pub fn can_accept_peer(&self) -> bool {
574        self.connected_count() < self.max_peers
575    }
576
577    /// Checks if a peer is rate limited and records the message.
578    ///
579    /// Returns `true` if the peer is within rate limits (message allowed),
580    /// `false` if the peer has exceeded the rate limit (message should be dropped).
581    /// Automatically decreases reputation for rate-limited peers.
582    pub fn check_rate_limit(&self, peer_id: &PeerId) -> bool {
583        // Protected peers (boot nodes + identified tenzro/ peers) bypass
584        // the rate limiter entirely. HotStuff-2 + gossip amplification can
585        // legitimately exceed 1000 msgs/10s on a small cluster during
586        // view changes; dropping those messages stalls consensus.
587        if self.protected_peers.contains(peer_id) {
588            return true;
589        }
590
591        if let Some(mut peer) = self.peers.get_mut(peer_id) {
592            let now = Instant::now();
593            let window_start = now - self.rate_limit_window;
594
595            // Evict timestamps outside the sliding window
596            while peer.message_timestamps.front().is_some_and(|&t| t < window_start) {
597                peer.message_timestamps.pop_front();
598            }
599
600            // Check if we're over the limit
601            if peer.message_timestamps.len() >= self.rate_limit_max_messages as usize {
602                // Rate limited — penalize reputation
603                peer.decrease_reputation(5);
604                tracing::warn!(
605                    "Rate limited peer {} ({} msgs in {}s window)",
606                    peer_id,
607                    peer.message_timestamps.len(),
608                    self.rate_limit_window.as_secs()
609                );
610
611                // Auto-ban if reputation drops too low
612                if peer.reputation <= self.ban_threshold {
613                    self.ban_peer_internal(&mut peer);
614                }
615
616                return false;
617            }
618
619            // Record this message
620            peer.message_timestamps.push_back(now);
621            true
622        } else {
623            // Unknown peer — allow but don't track
624            true
625        }
626    }
627
628    /// Records a failed connection attempt.
629    ///
630    /// In a K8s environment, outgoing connection errors are common during pod startup
631    /// (e.g., when validators 1/2 try to dial validator-0 before it's fully ready).
632    /// These are transient and should NOT trigger banning — only actual misbehavior
633    /// (equivocation, spam, invalid messages) warrants a ban. We still track the
634    /// failure count for diagnostics.
635    pub fn record_failed_connection(&self, peer_id: &PeerId) {
636        if let Some(mut peer) = self.peers.get_mut(peer_id) {
637            peer.failed_connections += 1;
638            tracing::debug!(
639                "Recorded failed connection for peer {} (total: {})",
640                peer_id,
641                peer.failed_connections
642            );
643            // NOTE: Intentionally NOT auto-banning on connection failures.
644            // libp2p will retry with exponential backoff; banning here causes
645            // permanent validator isolation after just a few startup glitches.
646        }
647    }
648
649    /// Cleans up stale peers (not seen for a long time)
650    pub fn cleanup_stale_peers(&self, max_age: Duration) {
651        let now = Instant::now();
652        let stale_peers: Vec<PeerId> = self
653            .peers
654            .iter()
655            .filter(|entry| {
656                entry.status == PeerStatus::Disconnected
657                    && now.duration_since(entry.last_seen) > max_age
658            })
659            .map(|entry| entry.peer_id)
660            .collect();
661
662        for peer_id in stale_peers {
663            self.remove_peer(&peer_id);
664            tracing::debug!("Removed stale peer {}", peer_id);
665        }
666    }
667
668    /// Cleans up expired bans
669    pub fn cleanup_expired_bans(&self) {
670        let now = Instant::now();
671        for mut entry in self.peers.iter_mut() {
672            if let Some(until) = entry.ban_until
673                && now >= until
674            {
675                entry.ban_until = None;
676                entry.status = PeerStatus::Disconnected;
677                entry.reputation = 0;
678                tracing::info!("Ban expired for peer {}", entry.peer_id);
679            }
680        }
681    }
682
683    /// Gets statistics about peers
684    pub fn stats(&self) -> PeerManagerStats {
685        let mut stats = PeerManagerStats::default();
686
687        for peer in self.peers.iter() {
688            match peer.status {
689                PeerStatus::Connected => stats.connected += 1,
690                PeerStatus::Connecting => stats.connecting += 1,
691                PeerStatus::Disconnected => stats.disconnected += 1,
692                PeerStatus::Failed => stats.failed += 1,
693                PeerStatus::Banned => stats.banned += 1,
694            }
695        }
696
697        stats.total = self.peers.len();
698        stats
699    }
700}
701
702/// Peer manager statistics
703#[derive(Debug, Default, Clone, Copy)]
704pub struct PeerManagerStats {
705    pub total: usize,
706    pub connected: usize,
707    pub connecting: usize,
708    pub disconnected: usize,
709    pub failed: usize,
710    pub banned: usize,
711}
712
713#[cfg(test)]
714mod tests {
715    use super::*;
716
717    /// Test implementation of ValidatorRegistry
718    struct TestValidatorRegistry {
719        validators: HashSet<PeerId>,
720    }
721
722    impl TestValidatorRegistry {
723        fn new(validators: Vec<PeerId>) -> Self {
724            Self {
725                validators: validators.into_iter().collect(),
726            }
727        }
728    }
729
730    impl ValidatorRegistry for TestValidatorRegistry {
731        fn is_validator(&self, peer_id: &PeerId) -> bool {
732            self.validators.contains(peer_id)
733        }
734
735        fn validator_peer_ids(&self) -> HashSet<PeerId> {
736            self.validators.clone()
737        }
738    }
739
740    #[test]
741    fn test_peer_reputation() {
742        let manager = PeerManager::new(10);
743        let peer_id = PeerId::random();
744
745        manager.add_peer(peer_id);
746
747        manager.increase_reputation(&peer_id, 10);
748        let peer = manager.get_peer(&peer_id).unwrap();
749        assert_eq!(peer.reputation, 10);
750
751        manager.decrease_reputation(&peer_id, 5);
752        let peer = manager.get_peer(&peer_id).unwrap();
753        assert_eq!(peer.reputation, 5);
754    }
755
756    #[test]
757    fn test_peer_banning() {
758        let manager = PeerManager::new(10);
759        let peer_id = PeerId::random();
760
761        manager.add_peer(peer_id);
762        assert!(!manager.is_banned(&peer_id));
763
764        manager.ban_peer(&peer_id);
765        assert!(manager.is_banned(&peer_id));
766
767        manager.unban_peer(&peer_id);
768        assert!(!manager.is_banned(&peer_id));
769    }
770
771    #[test]
772    fn test_auto_ban_on_low_reputation() {
773        let manager = PeerManager::new(10);
774        let peer_id = PeerId::random();
775
776        manager.add_peer(peer_id);
777        manager.decrease_reputation(&peer_id, 160); // 100 - 160 = -60, below -50 threshold
778
779        assert!(manager.is_banned(&peer_id));
780    }
781
782    #[test]
783    fn test_rate_limiting() {
784        // Allow only 5 messages in a 10 second window
785        let manager = PeerManager::with_rate_limit(10, 5, 10);
786        let peer_id = PeerId::random();
787
788        manager.add_peer(peer_id);
789
790        // First 5 messages should pass
791        for _ in 0..5 {
792            assert!(manager.check_rate_limit(&peer_id));
793        }
794
795        // 6th message should be rate limited
796        assert!(!manager.check_rate_limit(&peer_id));
797
798        // Reputation should have decreased
799        let peer = manager.get_peer(&peer_id).unwrap();
800        assert!(peer.reputation < 0);
801    }
802
803    #[test]
804    fn test_validator_authorization_no_registry() {
805        // Without a registry, all peers are allowed (permissive mode)
806        let manager = PeerManager::new(10);
807        let peer_id = PeerId::random();
808
809        assert!(manager.authorize_peer_for_topic(&peer_id, "tenzro/blocks"));
810        assert!(manager.authorize_peer_for_topic(&peer_id, "tenzro/consensus"));
811        assert!(manager.authorize_peer_for_topic(&peer_id, "tenzro/transactions"));
812    }
813
814    #[test]
815    fn test_validator_authorization_with_registry() {
816        let validator = PeerId::random();
817        let non_validator = PeerId::random();
818
819        let registry = Arc::new(TestValidatorRegistry::new(vec![validator]));
820
821        let mut manager = PeerManager::new(10);
822        manager.set_validator_registry(registry);
823
824        // Validator should be allowed on all topics
825        assert!(manager.authorize_peer_for_topic(&validator, "tenzro/blocks"));
826        assert!(manager.authorize_peer_for_topic(&validator, "tenzro/consensus"));
827        assert!(manager.authorize_peer_for_topic(&validator, "tenzro/attestations"));
828        assert!(manager.authorize_peer_for_topic(&validator, "tenzro/transactions"));
829
830        // Non-validator should be blocked on validator-only topics (consensus, attestations)
831        // NOTE: "tenzro/blocks" is intentionally open to all peers so that
832        // RPC nodes and model providers can receive block gossip for chain sync.
833        assert!(manager.authorize_peer_for_topic(&non_validator, "tenzro/blocks"));
834        assert!(!manager.authorize_peer_for_topic(&non_validator, "tenzro/consensus"));
835        assert!(!manager.authorize_peer_for_topic(&non_validator, "tenzro/attestations"));
836
837        // Non-validator should be allowed on open topics
838        assert!(manager.authorize_peer_for_topic(&non_validator, "tenzro/transactions"));
839        assert!(manager.authorize_peer_for_topic(&non_validator, "tenzro/status"));
840        assert!(manager.authorize_peer_for_topic(&non_validator, "tenzro/inference"));
841        assert!(manager.authorize_peer_for_topic(&non_validator, "tenzro/models"));
842    }
843
844    #[test]
845    fn test_protected_peer_not_banned() {
846        let manager = PeerManager::new(10);
847        let protected = PeerId::random();
848        let normal = PeerId::random();
849
850        // Register protected peer
851        manager.add_protected_peer(protected);
852        assert!(manager.is_protected(&protected));
853        assert!(!manager.is_protected(&normal));
854
855        // Add both peers
856        manager.add_peer(protected);
857        manager.add_peer(normal);
858
859        // Tank both peers' reputation below ban threshold (-50)
860        manager.decrease_reputation(&protected, 160);
861        manager.decrease_reputation(&normal, 160);
862
863        // Normal peer should be banned
864        assert!(manager.is_banned(&normal));
865
866        // Protected peer should NOT be banned, and reputation reset to 0
867        assert!(!manager.is_banned(&protected));
868        let peer = manager.get_peer(&protected).unwrap();
869        assert_eq!(peer.reputation, 0);
870    }
871}