Skip to main content

ipfrs_network/
relay.rs

1//! Circuit Relay v2 reservation management
2//!
3//! This module provides [`RelayManager`] which tracks active Circuit Relay v2
4//! reservations, enforces a maximum-reservation cap, and evicts stale/expired
5//! entries so the node always has a fresh set of relay contacts for NAT
6//! traversal.
7
8use parking_lot::RwLock;
9use std::collections::HashMap;
10
11// ─── Data types ──────────────────────────────────────────────────────────────
12
13/// State captured when a circuit relay v2 reservation is accepted.
14#[derive(Debug, Clone)]
15pub struct RelayReservation {
16    /// Peer ID of the relay (string form of [`libp2p::PeerId`]).
17    pub relay_peer_id: String,
18    /// Multiaddr string of the relay.
19    pub relay_addr: String,
20    /// Unix timestamp (ms) when the reservation was created.
21    pub reserved_at_ms: u64,
22    /// Unix timestamp (ms) when the reservation expires (TTL from relay).
23    pub expires_at_ms: u64,
24    /// Optional relay voucher bytes (`None` when the relay did not provide one).
25    pub voucher: Option<Vec<u8>>,
26}
27
28impl RelayReservation {
29    /// Returns `true` when the reservation has passed its expiry time.
30    #[inline]
31    pub fn is_expired(&self, now_ms: u64) -> bool {
32        now_ms >= self.expires_at_ms
33    }
34
35    /// Remaining lifetime in milliseconds, or `0` if already expired.
36    #[inline]
37    pub fn ttl_remaining_ms(&self, now_ms: u64) -> u64 {
38        self.expires_at_ms.saturating_sub(now_ms)
39    }
40}
41
42// ─── Error type ──────────────────────────────────────────────────────────────
43
44/// Errors returned by [`RelayManager`] operations.
45#[derive(Debug)]
46pub enum RelayError {
47    /// The manager has reached its reservation cap.
48    AtCapacity {
49        /// Maximum allowed reservations.
50        max: usize,
51    },
52    /// A reservation for this relay peer already exists.
53    AlreadyExists {
54        /// The peer that was already registered.
55        relay_peer_id: String,
56    },
57}
58
59impl std::fmt::Display for RelayError {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        match self {
62            Self::AtCapacity { max } => {
63                write!(f, "relay manager at capacity (max {max} reservations)")
64            }
65            Self::AlreadyExists { relay_peer_id } => {
66                write!(
67                    f,
68                    "relay reservation already exists for peer {relay_peer_id}"
69                )
70            }
71        }
72    }
73}
74
75impl std::error::Error for RelayError {}
76
77// ─── RelayManager ────────────────────────────────────────────────────────────
78
79/// Manages circuit relay v2 reservations.
80///
81/// All public methods are safe to call from multiple threads concurrently; the
82/// internal [`RwLock`] is a `parking_lot` lock and never blocks async tasks
83/// for long.
84pub struct RelayManager {
85    /// Current reservations, keyed by relay peer ID.
86    reservations: RwLock<HashMap<String, RelayReservation>>,
87    /// Maximum number of simultaneously held reservations.
88    max_reservations: usize,
89}
90
91impl RelayManager {
92    /// Create a new manager with the given capacity limit.
93    pub fn new(max_reservations: usize) -> Self {
94        Self {
95            reservations: RwLock::new(HashMap::new()),
96            max_reservations,
97        }
98    }
99
100    /// Insert a new reservation.
101    ///
102    /// Fails with:
103    /// - [`RelayError::AlreadyExists`] if `reservation.relay_peer_id` is
104    ///   already tracked.
105    /// - [`RelayError::AtCapacity`] if the cap would be exceeded.
106    pub fn add_reservation(&self, reservation: RelayReservation) -> Result<(), RelayError> {
107        let mut map = self.reservations.write();
108        if map.contains_key(&reservation.relay_peer_id) {
109            return Err(RelayError::AlreadyExists {
110                relay_peer_id: reservation.relay_peer_id.clone(),
111            });
112        }
113        if map.len() >= self.max_reservations {
114            return Err(RelayError::AtCapacity {
115                max: self.max_reservations,
116            });
117        }
118        map.insert(reservation.relay_peer_id.clone(), reservation);
119        Ok(())
120    }
121
122    /// Remove the reservation for `relay_peer_id`.
123    ///
124    /// Returns `true` if a reservation existed and was removed.
125    pub fn remove_reservation(&self, relay_peer_id: &str) -> bool {
126        self.reservations.write().remove(relay_peer_id).is_some()
127    }
128
129    /// Look up a reservation by relay peer ID (cloned snapshot).
130    pub fn get_reservation(&self, relay_peer_id: &str) -> Option<RelayReservation> {
131        self.reservations.read().get(relay_peer_id).cloned()
132    }
133
134    /// Return all non-expired reservations as of `now_ms`.
135    pub fn active_reservations(&self, now_ms: u64) -> Vec<RelayReservation> {
136        self.reservations
137            .read()
138            .values()
139            .filter(|r| !r.is_expired(now_ms))
140            .cloned()
141            .collect()
142    }
143
144    /// Return the peer IDs of all expired reservations.
145    pub fn expired_reservations(&self, now_ms: u64) -> Vec<String> {
146        self.reservations
147            .read()
148            .values()
149            .filter(|r| r.is_expired(now_ms))
150            .map(|r| r.relay_peer_id.clone())
151            .collect()
152    }
153
154    /// Remove every expired reservation and return the count of removed entries.
155    pub fn prune_expired(&self, now_ms: u64) -> usize {
156        let mut map = self.reservations.write();
157        let before = map.len();
158        map.retain(|_, r| !r.is_expired(now_ms));
159        before - map.len()
160    }
161
162    /// Total number of tracked reservations (active + expired).
163    pub fn reservation_count(&self) -> usize {
164        self.reservations.read().len()
165    }
166
167    /// Returns `true` when the manager cannot accept any more reservations.
168    pub fn is_at_capacity(&self) -> bool {
169        self.reservations.read().len() >= self.max_reservations
170    }
171}
172
173// ─── Tests ───────────────────────────────────────────────────────────────────
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    fn make_reservation(peer_id: &str, reserved_at: u64, expires_at: u64) -> RelayReservation {
180        RelayReservation {
181            relay_peer_id: peer_id.to_string(),
182            relay_addr: format!("/ip4/127.0.0.1/tcp/4001/p2p/{peer_id}"),
183            reserved_at_ms: reserved_at,
184            expires_at_ms: expires_at,
185            voucher: None,
186        }
187    }
188
189    #[test]
190    fn test_add_and_get_reservation() {
191        let mgr = RelayManager::new(4);
192        let res = make_reservation("peer1", 1000, 5000);
193        mgr.add_reservation(res).expect("should succeed");
194        let got = mgr.get_reservation("peer1").expect("should be present");
195        assert_eq!(got.relay_peer_id, "peer1");
196        assert_eq!(got.reserved_at_ms, 1000);
197        assert_eq!(got.expires_at_ms, 5000);
198    }
199
200    #[test]
201    fn test_remove_reservation() {
202        let mgr = RelayManager::new(4);
203        mgr.add_reservation(make_reservation("peer1", 0, 9999))
204            .expect("add ok");
205        assert!(mgr.remove_reservation("peer1"));
206        assert!(!mgr.remove_reservation("peer1")); // second remove returns false
207        assert!(mgr.get_reservation("peer1").is_none());
208    }
209
210    #[test]
211    fn test_prune_expired() {
212        let mgr = RelayManager::new(10);
213        mgr.add_reservation(make_reservation("alive", 0, 10_000))
214            .expect("ok");
215        mgr.add_reservation(make_reservation("dead1", 0, 100))
216            .expect("ok");
217        mgr.add_reservation(make_reservation("dead2", 0, 200))
218            .expect("ok");
219
220        let removed = mgr.prune_expired(500);
221        assert_eq!(removed, 2);
222        assert_eq!(mgr.reservation_count(), 1);
223        assert!(mgr.get_reservation("alive").is_some());
224    }
225
226    #[test]
227    fn test_active_reservations_filters_expired() {
228        let mgr = RelayManager::new(10);
229        mgr.add_reservation(make_reservation("alive", 0, 10_000))
230            .expect("ok");
231        mgr.add_reservation(make_reservation("expired", 0, 50))
232            .expect("ok");
233
234        let active = mgr.active_reservations(100);
235        assert_eq!(active.len(), 1);
236        assert_eq!(active[0].relay_peer_id, "alive");
237    }
238
239    #[test]
240    fn test_capacity_limit() {
241        let mgr = RelayManager::new(2);
242        mgr.add_reservation(make_reservation("p1", 0, 9999))
243            .expect("ok");
244        mgr.add_reservation(make_reservation("p2", 0, 9999))
245            .expect("ok");
246        let err = mgr
247            .add_reservation(make_reservation("p3", 0, 9999))
248            .expect_err("should fail at capacity");
249        assert!(
250            matches!(err, RelayError::AtCapacity { max: 2 }),
251            "expected AtCapacity, got {err}"
252        );
253    }
254
255    #[test]
256    fn test_duplicate_reservation_error() {
257        let mgr = RelayManager::new(4);
258        mgr.add_reservation(make_reservation("peer1", 0, 9999))
259            .expect("first add ok");
260        let err = mgr
261            .add_reservation(make_reservation("peer1", 100, 9999))
262            .expect_err("duplicate should fail");
263        assert!(
264            matches!(err, RelayError::AlreadyExists { .. }),
265            "expected AlreadyExists, got {err}"
266        );
267    }
268
269    #[test]
270    fn test_reservation_ttl_remaining() {
271        let res = make_reservation("p", 0, 5000);
272        assert_eq!(res.ttl_remaining_ms(3000), 2000);
273        assert_eq!(res.ttl_remaining_ms(5000), 0); // exactly at expiry
274        assert_eq!(res.ttl_remaining_ms(6000), 0); // saturating_sub
275    }
276
277    #[test]
278    fn test_reservation_is_expired() {
279        let res = make_reservation("p", 0, 5000);
280        assert!(!res.is_expired(4999));
281        assert!(res.is_expired(5000));
282        assert!(res.is_expired(9999));
283    }
284}