1use super::status::{FipsEndpointPeer, is_reusable_udp_socket_addr};
8use crate::PeerIdentity;
9use crate::config::{PeerAddress, PeerAddressProvenance, PeerConfig};
10use serde::{Deserialize, Serialize};
11use std::collections::{BTreeMap, HashSet};
12use std::net::SocketAddr;
13use thiserror::Error;
14
15pub const RECENT_PEERS_VERSION: u32 = 1;
17pub const RECENT_PEERS_MAX_PEERS: usize = 256;
19pub const RECENT_PEERS_MAX_ENDPOINTS_PER_PEER: usize = 4;
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(deny_unknown_fields)]
28pub struct RecentPeers {
29 pub version: u32,
30 pub local_npub: String,
31 pub scope: String,
32 pub peers: BTreeMap<String, RecentPeer>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct RecentPeer {
39 pub last_authenticated_at_ms: u64,
40 pub endpoints: Vec<RecentPeerEndpoint>,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(deny_unknown_fields)]
46pub struct RecentPeerEndpoint {
47 pub transport: RecentPeerTransport,
48 pub addr: String,
49 pub last_authenticated_at_ms: u64,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "lowercase")]
55pub enum RecentPeerTransport {
56 Udp,
57}
58
59#[derive(Debug, Error)]
61pub enum RecentPeersError {
62 #[error("invalid recent-peers JSON: {0}")]
63 Json(#[from] serde_json::Error),
64
65 #[error("unsupported recent-peers version {actual}; expected {RECENT_PEERS_VERSION}")]
66 UnsupportedVersion { actual: u32 },
67
68 #[error("invalid canonical local npub '{npub}': {reason}")]
69 InvalidLocalNpub { npub: String, reason: String },
70
71 #[error("recent-peers local identity mismatch: expected '{expected}', found '{actual}'")]
72 LocalIdentityMismatch { expected: String, actual: String },
73
74 #[error("recent-peers scope mismatch: expected '{expected}', found '{actual}'")]
75 ScopeMismatch { expected: String, actual: String },
76
77 #[error("invalid canonical peer npub '{npub}': {reason}")]
78 InvalidPeerNpub { npub: String, reason: String },
79
80 #[error("recent-peers document contains the local identity as a remote peer")]
81 LocalIdentityAsPeer,
82
83 #[error("recent-peers document has {actual} peers; maximum is {max}")]
84 TooManyPeers { actual: usize, max: usize },
85
86 #[error("recent peer '{npub}' has {actual} endpoints; maximum is {max}")]
87 TooManyEndpoints {
88 npub: String,
89 actual: usize,
90 max: usize,
91 },
92
93 #[error("recent peer '{npub}' has unusable UDP endpoint '{addr}'")]
94 UnusableUdpEndpoint { npub: String, addr: String },
95
96 #[error("recent peer '{npub}' repeats UDP endpoint '{addr}'")]
97 DuplicateUdpEndpoint { npub: String, addr: String },
98
99 #[error(
100 "recent peer '{npub}' endpoint timestamp {endpoint_at_ms} is newer than peer timestamp {peer_at_ms}"
101 )]
102 EndpointNewerThanPeer {
103 npub: String,
104 endpoint_at_ms: u64,
105 peer_at_ms: u64,
106 },
107}
108
109impl RecentPeers {
110 pub fn new(
112 local_npub: impl Into<String>,
113 scope: impl Into<String>,
114 ) -> Result<Self, RecentPeersError> {
115 let local_npub = local_npub.into();
116 validate_canonical_npub(&local_npub).map_err(|reason| {
117 RecentPeersError::InvalidLocalNpub {
118 npub: local_npub.clone(),
119 reason,
120 }
121 })?;
122 Ok(Self {
123 version: RECENT_PEERS_VERSION,
124 local_npub,
125 scope: scope.into(),
126 peers: BTreeMap::new(),
127 })
128 }
129
130 pub fn from_json(
137 json: &str,
138 expected_local_npub: &str,
139 expected_scope: &str,
140 ) -> Result<Self, RecentPeersError> {
141 let recent: Self = serde_json::from_str(json)?;
142 recent.validate()?;
143
144 validate_canonical_npub(expected_local_npub).map_err(|reason| {
145 RecentPeersError::InvalidLocalNpub {
146 npub: expected_local_npub.to_string(),
147 reason,
148 }
149 })?;
150 if recent.local_npub != expected_local_npub {
151 return Err(RecentPeersError::LocalIdentityMismatch {
152 expected: expected_local_npub.to_string(),
153 actual: recent.local_npub,
154 });
155 }
156 if recent.scope != expected_scope {
157 return Err(RecentPeersError::ScopeMismatch {
158 expected: expected_scope.to_string(),
159 actual: recent.scope,
160 });
161 }
162 Ok(recent)
163 }
164
165 pub fn to_json(&self) -> Result<String, RecentPeersError> {
167 self.validate()?;
168 Ok(serde_json::to_string(self)?)
169 }
170
171 pub fn to_json_pretty(&self) -> Result<String, RecentPeersError> {
173 self.validate()?;
174 Ok(serde_json::to_string_pretty(self)?)
175 }
176
177 pub fn local_npub(&self) -> &str {
179 &self.local_npub
180 }
181
182 pub fn scope(&self) -> &str {
184 &self.scope
185 }
186
187 pub fn validate(&self) -> Result<(), RecentPeersError> {
189 if self.version != RECENT_PEERS_VERSION {
190 return Err(RecentPeersError::UnsupportedVersion {
191 actual: self.version,
192 });
193 }
194 validate_canonical_npub(&self.local_npub).map_err(|reason| {
195 RecentPeersError::InvalidLocalNpub {
196 npub: self.local_npub.clone(),
197 reason,
198 }
199 })?;
200 if self.peers.len() > RECENT_PEERS_MAX_PEERS {
201 return Err(RecentPeersError::TooManyPeers {
202 actual: self.peers.len(),
203 max: RECENT_PEERS_MAX_PEERS,
204 });
205 }
206
207 for (npub, peer) in &self.peers {
208 validate_canonical_npub(npub).map_err(|reason| RecentPeersError::InvalidPeerNpub {
209 npub: npub.clone(),
210 reason,
211 })?;
212 if npub == &self.local_npub {
213 return Err(RecentPeersError::LocalIdentityAsPeer);
214 }
215 validate_peer(npub, peer)?;
216 }
217 Ok(())
218 }
219
220 pub fn observe_authenticated_peer(
229 &mut self,
230 peer: &FipsEndpointPeer,
231 authenticated_at_ms: u64,
232 ) -> Result<bool, RecentPeersError> {
233 if !peer.connected {
234 return Ok(false);
235 }
236 validate_canonical_npub(&peer.npub).map_err(|reason| {
237 RecentPeersError::InvalidPeerNpub {
238 npub: peer.npub.clone(),
239 reason,
240 }
241 })?;
242 if peer.npub == self.local_npub {
243 return Err(RecentPeersError::LocalIdentityAsPeer);
244 }
245
246 let previous = self.peers.get(&peer.npub).cloned();
247 let restart_addr = peer.authenticated_udp_restart_addr();
248 let entry = self.peers.entry(peer.npub.clone()).or_insert(RecentPeer {
249 last_authenticated_at_ms: authenticated_at_ms,
250 endpoints: Vec::new(),
251 });
252 entry.last_authenticated_at_ms = entry.last_authenticated_at_ms.max(authenticated_at_ms);
253
254 if let Some(addr) = restart_addr {
255 observe_udp_endpoint(entry, addr, authenticated_at_ms);
256 }
257 self.retain_newest_peers();
258 Ok(self.peers.get(&peer.npub) != previous.as_ref())
259 }
260
261 pub fn prune(&mut self, now_ms: u64, ttl_ms: u64) {
267 self.peers.retain(|_, peer| {
268 peer.endpoints.retain(|endpoint| {
269 now_ms.saturating_sub(endpoint.last_authenticated_at_ms) <= ttl_ms
270 });
271 now_ms.saturating_sub(peer.last_authenticated_at_ms) <= ttl_ms
272 });
273 }
274
275 pub fn merge_into_peer_configs(&self, peer_configs: &mut [PeerConfig]) -> usize {
285 let mut merged = 0;
286 for config in peer_configs {
287 let Some(canonical_npub) = peer_config_canonical_npub(&config.npub) else {
288 continue;
289 };
290 let Some(recent) = self.peers.get(&canonical_npub) else {
291 continue;
292 };
293 for endpoint in &recent.endpoints {
294 merged += merge_endpoint(config, endpoint);
295 }
296 }
297 merged
298 }
299
300 fn retain_newest_peers(&mut self) {
301 if self.peers.len() <= RECENT_PEERS_MAX_PEERS {
302 return;
303 }
304 let mut newest = self
305 .peers
306 .iter()
307 .map(|(npub, peer)| (peer.last_authenticated_at_ms, npub.clone()))
308 .collect::<Vec<_>>();
309 newest.sort_by(|left, right| right.cmp(left));
310 let keep = newest
311 .into_iter()
312 .take(RECENT_PEERS_MAX_PEERS)
313 .map(|(_, npub)| npub)
314 .collect::<HashSet<_>>();
315 self.peers.retain(|npub, _| keep.contains(npub));
316 }
317}
318
319fn validate_canonical_npub(npub: &str) -> Result<(), String> {
320 let identity = PeerIdentity::from_npub(npub).map_err(|error| error.to_string())?;
321 if identity.npub() != npub {
322 return Err("npub is not in canonical lowercase NIP-19 form".to_string());
323 }
324 Ok(())
325}
326
327fn validate_peer(npub: &str, peer: &RecentPeer) -> Result<(), RecentPeersError> {
328 if peer.endpoints.len() > RECENT_PEERS_MAX_ENDPOINTS_PER_PEER {
329 return Err(RecentPeersError::TooManyEndpoints {
330 npub: npub.to_string(),
331 actual: peer.endpoints.len(),
332 max: RECENT_PEERS_MAX_ENDPOINTS_PER_PEER,
333 });
334 }
335 let mut addresses = HashSet::new();
336 for endpoint in &peer.endpoints {
337 if endpoint.last_authenticated_at_ms > peer.last_authenticated_at_ms {
338 return Err(RecentPeersError::EndpointNewerThanPeer {
339 npub: npub.to_string(),
340 endpoint_at_ms: endpoint.last_authenticated_at_ms,
341 peer_at_ms: peer.last_authenticated_at_ms,
342 });
343 }
344 let addr = endpoint.addr.parse::<SocketAddr>().map_err(|_| {
345 RecentPeersError::UnusableUdpEndpoint {
346 npub: npub.to_string(),
347 addr: endpoint.addr.clone(),
348 }
349 })?;
350 if !is_reusable_udp_socket_addr(&addr) {
351 return Err(RecentPeersError::UnusableUdpEndpoint {
352 npub: npub.to_string(),
353 addr: endpoint.addr.clone(),
354 });
355 }
356 if !addresses.insert(addr) {
357 return Err(RecentPeersError::DuplicateUdpEndpoint {
358 npub: npub.to_string(),
359 addr: endpoint.addr.clone(),
360 });
361 }
362 }
363 Ok(())
364}
365
366fn peer_config_canonical_npub(value: &str) -> Option<String> {
367 if let Ok(identity) = PeerIdentity::from_npub(value) {
368 return Some(identity.npub());
369 }
370
371 value
372 .parse::<secp256k1::XOnlyPublicKey>()
373 .ok()
374 .map(PeerIdentity::from_pubkey)
375 .map(|identity| identity.npub())
376}
377
378fn observe_udp_endpoint(peer: &mut RecentPeer, addr: SocketAddr, authenticated_at_ms: u64) {
379 if let Some(endpoint) = peer.endpoints.iter_mut().find(|endpoint| {
380 endpoint
381 .addr
382 .parse::<SocketAddr>()
383 .is_ok_and(|stored| stored == addr)
384 }) {
385 endpoint.addr = addr.to_string();
386 endpoint.last_authenticated_at_ms =
387 endpoint.last_authenticated_at_ms.max(authenticated_at_ms);
388 } else {
389 peer.endpoints.push(RecentPeerEndpoint {
390 transport: RecentPeerTransport::Udp,
391 addr: addr.to_string(),
392 last_authenticated_at_ms: authenticated_at_ms,
393 });
394 }
395 peer.endpoints.sort_by(|left, right| {
396 right
397 .last_authenticated_at_ms
398 .cmp(&left.last_authenticated_at_ms)
399 .then_with(|| left.addr.cmp(&right.addr))
400 });
401 peer.endpoints.truncate(RECENT_PEERS_MAX_ENDPOINTS_PER_PEER);
402}
403
404fn merge_endpoint(config: &mut PeerConfig, endpoint: &RecentPeerEndpoint) -> usize {
405 let cached_addr = endpoint.addr.parse::<SocketAddr>().ok();
406 let existing = config.addresses.iter_mut().find(|candidate| {
407 candidate.transport == "udp"
408 && candidate
409 .addr
410 .parse::<SocketAddr>()
411 .ok()
412 .zip(cached_addr)
413 .is_some_and(|(candidate, cached)| candidate == cached)
414 });
415
416 if let Some(existing) = existing {
417 if existing.provenance == PeerAddressProvenance::Configured {
418 return 0;
419 }
420 let prior_provenance = existing.provenance;
421 let prior_seen_at_ms = existing.seen_at_ms;
422 existing.provenance = PeerAddressProvenance::Authenticated;
423 existing.seen_at_ms = Some(
424 existing
425 .seen_at_ms
426 .unwrap_or_default()
427 .max(endpoint.last_authenticated_at_ms),
428 );
429 return usize::from(
430 existing.provenance != prior_provenance || existing.seen_at_ms != prior_seen_at_ms,
431 );
432 }
433
434 config.addresses.push(
435 PeerAddress::new("udp", &endpoint.addr)
436 .authenticated()
437 .with_seen_at_ms(endpoint.last_authenticated_at_ms),
438 );
439 1
440}
441
442#[cfg(test)]
443mod tests;