Skip to main content

hopr_ct_full_network/
discovery.rs

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
34/// Strip leading and/or trailing `me` markers from a loopback path.
35///
36/// `simple_loopback_to_self` returns `[intermediates..., me]` — the leading source
37/// is already excluded by the graph crate. This helper also strips a leading `me`
38/// defensively in case the graph-crate contract changes, so the result is always
39/// a plain intermediate list suitable for `IntermediatePath`.
40fn 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
50/// Build a `DestinationRouting::Forward` with a random pseudonym and an explicit intermediate path.
51fn 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
63/// Stream that cycles through 1-, 2-, and 3-hop loopback paths with weighted shuffle.
64///
65/// Shared by both cover traffic and intermediate probing — the caller wraps each
66/// emitted item into the appropriate outer type.
67fn 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    // 2, 3, 4 edges → 1-, 2-, 3-hops in the HOPR protocol
72    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
139/// Cached weighted-shuffle batch with a creation timestamp for TTL checks.
140struct ShuffleCache {
141    probes: Vec<ProbeRouting>,
142    created_at: std::time::Instant,
143}
144
145/// Stream of neighbor probes emitted in bursts per tick.
146///
147/// Each tick emits the entire cached batch. The cache is recomputed when it is
148/// empty (all probes drained) or when `cfg.shuffle_ttl` has expired, whichever
149/// comes first. This avoids re-traversing the graph every tick while still
150/// emitting all peers in each burst.
151///
152/// When `cfg.probe_connected_only` is `true`, only peers with a
153/// `Connected(true)` edge from `me` are included. This assumes that a
154/// background discovery mechanism (e.g. libp2p identify/kademlia) has
155/// already established connections and recorded them in the graph.
156fn 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                // Reuse cached shuffle if still within TTL.
183                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        // Collect two full rounds from the stream.
337        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        // Both rounds should cover the same set of peers.
347        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        // The two rounds should (almost certainly) differ in order.
352        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        // Collect exactly peer_count items — should all arrive from a single tick burst.
452        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    /// Extract `NodeId` destinations from a `ProbeRouting::Neighbor` stream.
468    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    /// Helper: mark an edge as fully connected with capacity, so it passes all cost checks.
478    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        // Topology: me → a → b, b → me (connected neighbor)
490        // Loopback: me → a → b → me
491        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        // Forward edges
499        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        // Return edge: b is a connected neighbor of me
505        graph.add_edge(&b, &me)?;
506        mark_edge_ready(&graph, &b, &me);
507
508        // Also need me → b edge so simple_loopback_to_self finds b as a connected neighbor
509        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        // Collect enough items to get both neighbor and loopback probes
517        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        // Verify loopback routing has IntermediatePath
530        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        // Only mark one peer as connected.
566        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        // Default: probe_connected_only = false
604        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        // Verify that loopback_routing rejects paths that are too long for BoundedVec.
629        // simple_loopback_to_self now returns [a, b, me] (no leading me); after
630        // strip_loopback_endpoints this becomes [a, b], which is the correct input.
631        let me = random_key();
632        let a = random_key();
633        let b = random_key();
634        let me_node = NodeId::Offchain(me);
635
636        // Path with too many elements: [a, b, c, me] — 4 elements exceeds BoundedVec<3>.
637        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        // Stripped path (intermediates only): [a, b]
645        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}