1use futures::{StreamExt, stream::BoxStream};
2use hopr_api::{
3 ct::{CoverTrafficGeneration, ProbeRouting, ProbingTrafficGeneration},
4 graph::{
5 NetworkGraphTraverse, NetworkGraphView,
6 traits::{EdgeNetworkObservableRead, EdgeObservableRead},
7 },
8 types::{
9 crypto::types::OffchainPublicKey,
10 crypto_random::Randomizable,
11 internal::{
12 NodeId,
13 protocol::HoprPseudonym,
14 routing::{DestinationRouting, PathId, RoutingOptions},
15 },
16 },
17};
18use hopr_utils::statistics::WeightedCollection;
19
20use crate::{ProberConfig, priority::immediate_probe_priority};
21
22pub struct FullNetworkDiscovery<U> {
23 me: OffchainPublicKey,
24 cfg: ProberConfig,
25 graph: U,
26}
27
28impl<U> FullNetworkDiscovery<U> {
29 pub fn new(me: OffchainPublicKey, cfg: ProberConfig, graph: U) -> Self {
30 Self { me, cfg, graph }
31 }
32}
33
34fn strip_loopback_endpoints(mut path: Vec<OffchainPublicKey>, me: &OffchainPublicKey) -> Vec<OffchainPublicKey> {
41 if path.last() == Some(me) {
42 path.pop();
43 }
44 if path.first() == Some(me) {
45 path.remove(0);
46 }
47 path
48}
49
50fn loopback_routing(me: NodeId, path: Vec<OffchainPublicKey>) -> Option<DestinationRouting> {
52 let path: Vec<NodeId> = path.into_iter().map(NodeId::from).collect();
53 hopr_api::network::BoundedVec::try_from(path)
54 .ok()
55 .map(|path| DestinationRouting::Forward {
56 destination: Box::new(me),
57 pseudonym: Some(HoprPseudonym::random()),
58 forward_options: RoutingOptions::IntermediatePath(path),
59 return_options: None,
60 })
61}
62
63fn loopback_path_stream<U>(cfg: ProberConfig, graph: U) -> impl futures::Stream<Item = (Vec<OffchainPublicKey>, PathId)>
68where
69 U: NetworkGraphTraverse<NodeId = OffchainPublicKey> + Clone + Send + Sync + 'static,
70{
71 futures_time::stream::interval(futures_time::time::Duration::from(cfg.interval))
73 .flat_map(|_| futures::stream::iter([2usize, 3, 4]))
74 .filter_map(move |edge_count| futures::future::ready(std::num::NonZeroUsize::new(edge_count)))
75 .flat_map(move |edge_count| {
76 let paths = graph.simple_loopback_to_self(edge_count.get(), Some(100));
77
78 let count = paths.len();
79 tracing::debug!(edge_count = edge_count.get(), count, "loopback path candidates");
80 let weighted: Vec<((Vec<OffchainPublicKey>, PathId), f64)> = paths
81 .into_iter()
82 .map(|(path, path_id)| ((path, path_id), cfg.base_priority))
83 .collect();
84
85 let wc = WeightedCollection::new(weighted);
86 futures::stream::iter(wc.into_shuffled())
87 })
88}
89
90impl<U> CoverTrafficGeneration for FullNetworkDiscovery<U>
91where
92 U: NetworkGraphTraverse<NodeId = OffchainPublicKey> + Clone + Send + Sync + 'static,
93{
94 fn build(&self) -> BoxStream<'static, DestinationRouting> {
95 cfg_if::cfg_if! {
96 if #[cfg(feature = "noise")] {
97 let me = self.me;
98 let me_node: NodeId = me.into();
99
100 loopback_path_stream(self.cfg, self.graph.clone())
101 .filter_map(move |(path, _)| {
102 let intermediates = strip_loopback_endpoints(path, &me);
103 futures::future::ready(loopback_routing(me_node, intermediates))
104 })
105 .boxed()
106 } else {
107 Box::pin(futures::stream::empty())
108 }
109 }
110 }
111}
112
113impl<U> ProbingTrafficGeneration for FullNetworkDiscovery<U>
114where
115 U: NetworkGraphView<NodeId = OffchainPublicKey, Observed = hopr_network_graph::Observations>
116 + NetworkGraphTraverse<NodeId = OffchainPublicKey>
117 + Clone
118 + Send
119 + Sync
120 + 'static,
121{
122 fn build(&self) -> BoxStream<'static, ProbeRouting> {
123 let cfg = self.cfg;
124 let me = self.me;
125
126 let immediates = immediate_probe_stream(me, cfg, self.graph.clone());
127
128 let me_node: NodeId = me.into();
129 let intermediates = loopback_path_stream(cfg, self.graph.clone()).filter_map(move |(path, path_id)| {
130 let intermediates = strip_loopback_endpoints(path, &me);
131 let routing = loopback_routing(me_node, intermediates).map(|r| ProbeRouting::Looping((r, path_id)));
132 futures::future::ready(routing)
133 });
134
135 futures::stream::select(immediates, intermediates).boxed()
136 }
137}
138
139struct ShuffleCache {
141 probes: Vec<ProbeRouting>,
142 created_at: std::time::Instant,
143}
144
145fn immediate_probe_stream<U>(
157 me: OffchainPublicKey,
158 cfg: ProberConfig,
159 graph: U,
160) -> impl futures::Stream<Item = ProbeRouting>
161where
162 U: NetworkGraphView<NodeId = OffchainPublicKey, Observed = hopr_network_graph::Observations>
163 + Clone
164 + Send
165 + Sync
166 + 'static,
167{
168 let cache: Option<ShuffleCache> = None;
169
170 futures::stream::unfold(
171 (
172 cache,
173 futures_time::stream::interval(futures_time::time::Duration::from(cfg.interval)),
174 ),
175 move |(mut cache, mut ticker)| {
176 let graph = graph.clone();
177
178 async move {
179 use futures::StreamExt as _;
180 ticker.next().await?;
181
182 let needs_refresh = cache
184 .as_ref()
185 .is_none_or(|c| c.probes.is_empty() || c.created_at.elapsed() >= cfg.shuffle_ttl);
186
187 if needs_refresh {
188 let now = std::time::SystemTime::now()
189 .duration_since(std::time::UNIX_EPOCH)
190 .unwrap_or_default();
191
192 let weighted: Vec<_> = graph
193 .nodes()
194 .filter(|peer| futures::future::ready(peer != &me))
195 .filter_map(|peer| {
196 let obs = graph.edge(&me, &peer);
197 if cfg.probe_connected_only {
198 let connected = obs
199 .as_ref()
200 .and_then(|o| o.immediate_qos())
201 .map(|imm| imm.is_connected())
202 .unwrap_or(false);
203 if !connected {
204 return futures::future::ready(None);
205 }
206 }
207 let priority = match obs {
208 Some(obs) => immediate_probe_priority(obs.score(), obs.last_update(), now, &cfg),
209 None => immediate_probe_priority(0.0, std::time::Duration::ZERO, now, &cfg),
210 };
211 futures::future::ready(Some((peer, priority)))
212 })
213 .collect()
214 .await;
215
216 let peer_count = weighted.len();
217 let zero_hop = RoutingOptions::Hops(0.try_into().expect("0 is a valid u8"));
218 let wc = WeightedCollection::new(weighted);
219 let probes: Vec<_> = wc
220 .into_shuffled()
221 .into_iter()
222 .map(|peer| {
223 ProbeRouting::Neighbor(DestinationRouting::Forward {
224 destination: Box::new(peer.into()),
225 pseudonym: Some(HoprPseudonym::random()),
226 forward_options: zero_hop.clone(),
227 return_options: Some(zero_hop.clone()),
228 })
229 })
230 .collect();
231
232 tracing::debug!(peer_count, probes = probes.len(), "computed new neighbor probe shuffle");
233 cache = Some(ShuffleCache {
234 probes,
235 created_at: std::time::Instant::now(),
236 });
237 }
238
239 let batch = cache.as_ref().map(|c| c.probes.clone()).unwrap_or_default();
240 tracing::debug!(probes = batch.len(), "emitting neighbor probe batch");
241
242 Some((futures::stream::iter(batch), (cache, ticker)))
243 }
244 },
245 )
246 .flatten()
247}
248
249#[cfg(test)]
250mod tests {
251 use std::{collections::HashSet, sync::Arc};
252
253 use futures::{StreamExt, pin_mut};
254 use hopr_api::{
255 OffchainKeypair,
256 ct::{ProbeRouting, ProbingTrafficGeneration},
257 graph::{NetworkGraphUpdate, NetworkGraphWrite},
258 types::{crypto::keypairs::Keypair, internal::NodeId},
259 };
260 use hopr_network_graph::ChannelGraph;
261 use tokio::time::timeout;
262
263 use super::*;
264
265 const TINY_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(20);
266
267 fn fast_cfg() -> ProberConfig {
268 ProberConfig {
269 interval: std::time::Duration::from_millis(1),
270 shuffle_ttl: std::time::Duration::ZERO,
271 probe_connected_only: false,
272 ..Default::default()
273 }
274 }
275
276 fn random_key() -> OffchainPublicKey {
277 *OffchainKeypair::random().public()
278 }
279
280 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
281 struct Node {
282 pub id: OffchainPublicKey,
283 }
284
285 impl From<Node> for OffchainPublicKey {
286 fn from(node: Node) -> Self {
287 node.id
288 }
289 }
290
291 lazy_static::lazy_static! {
292 static ref RANDOM_PEERS: HashSet<Node> = (1..10).map(|_| {
293 Node {
294 id: OffchainPublicKey::from_privkey(&hopr_api::types::crypto_random::random_bytes::<32>()).unwrap(),
295 }
296 }).collect::<HashSet<_>>();
297 }
298
299 #[tokio::test]
300 async fn peers_should_not_be_passed_if_none_are_present() -> anyhow::Result<()> {
301 let me = random_key();
302 let prober = FullNetworkDiscovery::new(me, Default::default(), Arc::new(ChannelGraph::new(me)));
303 let stream = ProbingTrafficGeneration::build(&prober);
304 pin_mut!(stream);
305
306 assert!(timeout(TINY_TIMEOUT, stream.next()).await.is_err());
307 Ok(())
308 }
309
310 #[tokio::test]
311 async fn peers_should_have_randomized_order() -> anyhow::Result<()> {
312 let me = random_key();
313 let graph = Arc::new(ChannelGraph::new(me));
314 for node in RANDOM_PEERS.iter() {
315 graph.record_node(node.clone());
316 }
317
318 let peer_count = RANDOM_PEERS.len();
319 let prober = FullNetworkDiscovery::new(me, fast_cfg(), graph);
320 let stream = ProbingTrafficGeneration::build(&prober);
321 pin_mut!(stream);
322
323 let extract_peer = |routing: ProbeRouting| -> OffchainPublicKey {
324 match routing {
325 ProbeRouting::Neighbor(DestinationRouting::Forward { destination, .. }) => {
326 if let NodeId::Offchain(peer_key) = destination.as_ref() {
327 *peer_key
328 } else {
329 panic!("expected offchain destination");
330 }
331 }
332 _ => panic!("expected Neighbor Forward routing"),
333 }
334 };
335
336 let both_rounds: Vec<OffchainPublicKey> = timeout(
338 TINY_TIMEOUT * 40,
339 stream.take(peer_count * 2).map(extract_peer).collect::<Vec<_>>(),
340 )
341 .await?;
342
343 let round_1 = &both_rounds[..peer_count];
344 let round_2 = &both_rounds[peer_count..];
345
346 let set_1: HashSet<_> = round_1.iter().collect();
348 let set_2: HashSet<_> = round_2.iter().collect();
349 assert_eq!(set_1, set_2, "both rounds should cover the same peers");
350
351 assert_ne!(round_1, round_2, "two rounds should differ in order (probabilistic)");
353 Ok(())
354 }
355
356 #[tokio::test]
357 async fn peers_should_be_generated_in_multiple_rounds() -> anyhow::Result<()> {
358 let me = random_key();
359 let graph = Arc::new(ChannelGraph::new(me));
360 graph.record_node(RANDOM_PEERS.iter().next().unwrap().clone());
361
362 let prober = FullNetworkDiscovery::new(me, fast_cfg(), graph);
363 let stream = ProbingTrafficGeneration::build(&prober);
364 pin_mut!(stream);
365
366 assert!(timeout(TINY_TIMEOUT, stream.next()).await?.is_some());
367 assert!(timeout(TINY_TIMEOUT, stream.next()).await?.is_some());
368 Ok(())
369 }
370
371 #[cfg(not(feature = "noise"))]
372 #[tokio::test]
373 async fn cover_traffic_should_produce_empty_stream() -> anyhow::Result<()> {
374 let me = random_key();
375 let prober = FullNetworkDiscovery::new(me, fast_cfg(), Arc::new(ChannelGraph::new(me)));
376 let stream = CoverTrafficGeneration::build(&prober);
377 pin_mut!(stream);
378
379 assert!(timeout(TINY_TIMEOUT, stream.next()).await?.is_none());
380 Ok(())
381 }
382
383 #[tokio::test]
384 async fn only_neighbor_probes_emitted_when_no_looping_paths_exist() -> anyhow::Result<()> {
385 let me = random_key();
386 let graph = Arc::new(ChannelGraph::new(me));
387
388 let a = random_key();
389 let b = random_key();
390 graph.record_node(a);
391 graph.record_node(b);
392 graph.add_edge(&me, &a)?;
393 graph.add_edge(&a, &b)?;
394
395 let prober = FullNetworkDiscovery::new(me, fast_cfg(), graph);
396 let stream = ProbingTrafficGeneration::build(&prober);
397 pin_mut!(stream);
398
399 let items: Vec<ProbeRouting> = timeout(TINY_TIMEOUT * 50, stream.take(10).collect::<Vec<_>>()).await?;
400
401 assert!(!items.is_empty(), "should produce neighbor probes");
402 assert!(
403 items.iter().all(|r| matches!(r, ProbeRouting::Neighbor(_))),
404 "all probes should be Neighbor when no looping paths exist"
405 );
406 Ok(())
407 }
408
409 #[tokio::test]
410 async fn neighbor_probes_should_cover_all_known_nodes_across_rounds() -> anyhow::Result<()> {
411 let me = random_key();
412 let graph = Arc::new(ChannelGraph::new(me));
413
414 let peer_a = random_key();
415 let peer_b = random_key();
416 graph.record_node(peer_a);
417 graph.record_node(peer_b);
418
419 let prober = FullNetworkDiscovery::new(me, fast_cfg(), graph);
420 let stream = ProbingTrafficGeneration::build(&prober);
421 pin_mut!(stream);
422
423 let destinations: Vec<NodeId> = timeout(
424 TINY_TIMEOUT * 50,
425 neighbor_destinations(stream).take(4).collect::<Vec<_>>(),
426 )
427 .await?;
428
429 let unique: HashSet<NodeId> = destinations.iter().cloned().collect();
430 let expected: HashSet<NodeId> = [peer_a, peer_b].into_iter().map(NodeId::from).collect();
431
432 assert_eq!(unique, expected, "probes should cover all known graph peers");
433 assert_eq!(destinations.len(), 4, "should have probes across multiple rounds");
434 Ok(())
435 }
436
437 #[tokio::test]
438 async fn single_tick_should_emit_all_peers_in_burst() -> anyhow::Result<()> {
439 let me = random_key();
440 let graph = Arc::new(ChannelGraph::new(me));
441
442 let peer_count = RANDOM_PEERS.len();
443 for node in RANDOM_PEERS.iter() {
444 graph.record_node(node.clone());
445 }
446
447 let prober = FullNetworkDiscovery::new(me, fast_cfg(), graph);
448 let stream = ProbingTrafficGeneration::build(&prober);
449 pin_mut!(stream);
450
451 let burst: Vec<ProbeRouting> = timeout(TINY_TIMEOUT * 20, stream.take(peer_count).collect::<Vec<_>>()).await?;
453
454 assert_eq!(
455 burst.len(),
456 peer_count,
457 "a single tick should emit all {peer_count} peers"
458 );
459 assert!(
460 burst.iter().all(|r| matches!(r, ProbeRouting::Neighbor(_))),
461 "all burst items should be Neighbor probes"
462 );
463
464 Ok(())
465 }
466
467 fn neighbor_destinations(stream: impl futures::Stream<Item = ProbeRouting>) -> impl futures::Stream<Item = NodeId> {
469 stream.filter_map(|r| {
470 futures::future::ready(match r {
471 ProbeRouting::Neighbor(DestinationRouting::Forward { destination, .. }) => Some(*destination),
472 _ => None,
473 })
474 })
475 }
476
477 fn mark_edge_ready(graph: &ChannelGraph, src: &OffchainPublicKey, dst: &OffchainPublicKey) {
479 use hopr_api::graph::traits::{EdgeObservableWrite, EdgeWeightType};
480 graph.upsert_edge(src, dst, |obs| {
481 obs.record(EdgeWeightType::Connected(true));
482 obs.record(EdgeWeightType::Immediate(Ok(std::time::Duration::from_millis(50))));
483 obs.record(EdgeWeightType::Capacity(Some(1000)));
484 });
485 }
486
487 #[tokio::test]
488 async fn loopback_probes_should_be_emitted_for_two_edge_path() -> anyhow::Result<()> {
489 let me = random_key();
492 let a = random_key();
493 let b = random_key();
494 let graph = Arc::new(ChannelGraph::new(me));
495 graph.add_node(a);
496 graph.add_node(b);
497
498 graph.add_edge(&me, &a)?;
500 graph.add_edge(&a, &b)?;
501 mark_edge_ready(&graph, &me, &a);
502 mark_edge_ready(&graph, &a, &b);
503
504 graph.add_edge(&b, &me)?;
506 mark_edge_ready(&graph, &b, &me);
507
508 graph.add_edge(&me, &b)?;
510 mark_edge_ready(&graph, &me, &b);
511
512 let prober = FullNetworkDiscovery::new(me, fast_cfg(), graph);
513 let stream = ProbingTrafficGeneration::build(&prober);
514 pin_mut!(stream);
515
516 let items: Vec<ProbeRouting> = timeout(TINY_TIMEOUT * 100, stream.take(20).collect::<Vec<_>>()).await?;
518
519 let looping_count = items.iter().filter(|r| matches!(r, ProbeRouting::Looping(_))).count();
520 let neighbor_count = items.iter().filter(|r| matches!(r, ProbeRouting::Neighbor(_))).count();
521
522 assert!(neighbor_count > 0, "should have neighbor probes");
523 assert!(
524 looping_count > 0,
525 "should have loopback probes (was {looping_count} out of {} total)",
526 items.len()
527 );
528
529 for item in &items {
531 if let ProbeRouting::Looping((
532 DestinationRouting::Forward {
533 destination,
534 forward_options,
535 ..
536 },
537 _,
538 )) = item
539 {
540 assert_eq!(
541 destination.as_ref(),
542 &NodeId::Offchain(me),
543 "loopback destination should be me"
544 );
545 assert!(
546 matches!(forward_options, RoutingOptions::IntermediatePath(_)),
547 "loopback should use IntermediatePath routing"
548 );
549 }
550 }
551
552 Ok(())
553 }
554
555 #[tokio::test]
556 async fn probe_connected_only_should_skip_unconnected_peers() -> anyhow::Result<()> {
557 let me = random_key();
558 let graph = Arc::new(ChannelGraph::new(me));
559
560 let connected_peer = random_key();
561 let unconnected_peer = random_key();
562 graph.record_node(connected_peer);
563 graph.record_node(unconnected_peer);
564
565 mark_edge_ready(&graph, &me, &connected_peer);
567
568 let cfg = ProberConfig {
569 probe_connected_only: true,
570 ..fast_cfg()
571 };
572 let prober = FullNetworkDiscovery::new(me, cfg, graph);
573 let stream = ProbingTrafficGeneration::build(&prober);
574 pin_mut!(stream);
575
576 let destinations: Vec<NodeId> = timeout(
577 TINY_TIMEOUT * 50,
578 neighbor_destinations(stream).take(3).collect::<Vec<_>>(),
579 )
580 .await?;
581
582 let unique: HashSet<NodeId> = destinations.iter().cloned().collect();
583 assert_eq!(unique.len(), 1, "only one peer should be probed");
584 assert!(
585 unique.contains(&NodeId::from(connected_peer)),
586 "only the connected peer should be probed"
587 );
588 Ok(())
589 }
590
591 #[tokio::test]
592 async fn probe_connected_only_disabled_should_probe_all_peers() -> anyhow::Result<()> {
593 let me = random_key();
594 let graph = Arc::new(ChannelGraph::new(me));
595
596 let connected_peer = random_key();
597 let unconnected_peer = random_key();
598 graph.record_node(connected_peer);
599 graph.record_node(unconnected_peer);
600
601 mark_edge_ready(&graph, &me, &connected_peer);
602
603 let prober = FullNetworkDiscovery::new(me, fast_cfg(), graph);
605 let stream = ProbingTrafficGeneration::build(&prober);
606 pin_mut!(stream);
607
608 let destinations: Vec<NodeId> = timeout(
609 TINY_TIMEOUT * 50,
610 neighbor_destinations(stream).take(4).collect::<Vec<_>>(),
611 )
612 .await?;
613
614 let unique: HashSet<NodeId> = destinations.iter().cloned().collect();
615 let expected: HashSet<NodeId> = [connected_peer, unconnected_peer]
616 .into_iter()
617 .map(NodeId::from)
618 .collect();
619 assert_eq!(
620 unique, expected,
621 "both peers should be probed when probe_connected_only is false"
622 );
623 Ok(())
624 }
625
626 #[tokio::test]
627 async fn loopback_routing_should_reject_full_path_with_me() -> anyhow::Result<()> {
628 let me = random_key();
632 let a = random_key();
633 let b = random_key();
634 let me_node = NodeId::Offchain(me);
635
636 let c = random_key();
638 let oversized_path = vec![a, b, c, me];
639 assert!(
640 loopback_routing(me_node, oversized_path).is_none(),
641 "path [a, b, c, me] should exceed BoundedVec<3> and return None"
642 );
643
644 let stripped_path = vec![a, b];
646 assert!(
647 loopback_routing(me_node, stripped_path).is_some(),
648 "stripped path [a, b] should fit BoundedVec<3> and return Some"
649 );
650
651 Ok(())
652 }
653}