Skip to main content

hopr_transport/path/
selector.rs

1use hopr_api::graph::{
2    NetworkGraphTraverse, NetworkGraphView, ValueFn, function::EdgeValueFn, traits::EdgeObservableRead,
3};
4use hopr_types::{crypto::types::OffchainPublicKey, internal::errors::PathError};
5
6use super::{
7    errors::{PathPlannerError, Result},
8    traits::{PathSelector, PathWithCost},
9};
10
11/// Compute candidate paths from `src` to `dest` through `graph`.
12///
13/// `length` is the number of edges to traverse (= intermediate hops + 1).
14/// `take` caps the number of candidate paths returned.
15/// The source node is stripped from every returned path; callers receive
16/// `([intermediates..., dest], cost)`.
17fn compute_paths<G, C>(
18    graph: &G,
19    src: &OffchainPublicKey,
20    dest: &OffchainPublicKey,
21    length: std::num::NonZeroUsize,
22    take: usize,
23    cost_fn: C,
24) -> Vec<PathWithCost>
25where
26    G: NetworkGraphTraverse<NodeId = OffchainPublicKey> + NetworkGraphView<NodeId = OffchainPublicKey>,
27    <G as NetworkGraphTraverse>::Observed: EdgeObservableRead + Send + 'static,
28    C: ValueFn<Weight = <G as NetworkGraphTraverse>::Observed, Value = f64>,
29{
30    let raw = graph.simple_paths(src, dest, length.get(), Some(take), cost_fn);
31
32    raw.into_iter()
33        .filter_map(|(path, _, cost)| {
34            tracing::trace!(?path, ?cost, "evaluating candidate path");
35            if cost > 0.0 {
36                // Drop the first element (src) — callers expect [intermediates..., dest].
37                Some(PathWithCost {
38                    path: path.into_iter().skip(1).collect::<Vec<_>>(),
39                    cost,
40                })
41            } else {
42                None
43            }
44        })
45        .collect::<Vec<_>>()
46}
47
48/// A lightweight graph-backed path selector.
49///
50/// Returns all candidate paths for a `(src, dest, hops)` query directly from
51/// the network graph — no caching is performed here.  The caller (typically
52/// [`crate::path::planner::PathPlanner`]) is responsible for caching, TTL management,
53/// background refresh, and final path selection.
54///
55/// Stores the planner's own identity (`me`) so that it can choose the
56/// appropriate cost function:
57/// - forward path (`src == me`): [`EdgeValueFn::forward`]
58/// - return path (`dest == me`): [`EdgeValueFn::returning`]
59#[derive(Clone)]
60pub struct HoprGraphPathSelector<G> {
61    me: OffchainPublicKey,
62    graph: G,
63    max_paths: usize,
64    edge_penalty: f64,
65    min_ack_rate: f64,
66}
67
68impl<G> HoprGraphPathSelector<G>
69where
70    G: NetworkGraphTraverse<NodeId = OffchainPublicKey>
71        + NetworkGraphView<NodeId = OffchainPublicKey>
72        + Clone
73        + Send
74        + Sync
75        + 'static,
76    <G as NetworkGraphTraverse>::Observed: EdgeObservableRead + Send + 'static,
77{
78    /// Create a new selector.
79    ///
80    /// * `me` – the planner's own offchain public key, used to determine path direction.
81    /// * `graph` – the network graph to query.
82    /// * `max_paths` – maximum number of candidate paths to return per query.
83    /// * `edge_penalty` – penalty multiplier for edges lacking probe-based quality observations.
84    /// * `min_ack_rate` – minimum acceptable message acknowledgment rate for path selection.
85    pub fn new(me: OffchainPublicKey, graph: G, max_paths: usize, edge_penalty: f64, min_ack_rate: f64) -> Self {
86        Self {
87            me,
88            graph,
89            max_paths,
90            edge_penalty,
91            min_ack_rate,
92        }
93    }
94
95    /// Extended forward path search: find shorter paths using
96    /// [`EdgeValueFn::forward_without_self_loopback`] and append `dest` to each one.
97    ///
98    /// This handles the case where the last edge (relay -> dest) has no graph edge
99    /// (e.g. no payment channel) but the path planner can still assume the last hop
100    /// is reachable. Paths already found by Phase 1 are excluded via `existing`.
101    ///
102    /// The cost from the shorter traversal is preserved as-is — the missing last
103    /// edge contributes a neutral `1.0` multiplier (no quality data available).
104    fn compute_extended_forward_paths(
105        &self,
106        src: &OffchainPublicKey,
107        dest: &OffchainPublicKey,
108        shorter_length: std::num::NonZeroUsize,
109        take: usize,
110        existing: &[PathWithCost],
111    ) -> Vec<PathWithCost> {
112        let raw = self.graph.simple_paths_from(
113            src,
114            shorter_length.get(),
115            Some(take),
116            EdgeValueFn::forward_without_self_loopback(self.edge_penalty, self.min_ack_rate),
117        );
118
119        raw.into_iter()
120            .filter_map(|(path, _, cost)| {
121                if cost <= 0.0 {
122                    return None;
123                }
124
125                // Strip source to get intermediate hops only.
126                // Guard: if dest already appears as an intermediate, appending it
127                // again creates a [dest, dest] loop that ValidatedPath::new would
128                // reject — skip early instead of generating noise.
129                let candidate: Vec<_> = path.into_iter().skip(1).collect();
130                if candidate.contains(dest) {
131                    return None;
132                }
133                let mut candidate = candidate;
134                candidate.push(*dest);
135
136                // Skip paths already found by Phase 1 (compare path component only).
137                if existing.iter().any(|pwc| pwc.path == candidate) {
138                    return None;
139                }
140
141                tracing::trace!(?candidate, ?cost, "extended forward path candidate");
142                Some(PathWithCost { path: candidate, cost })
143            })
144            .take(take)
145            .collect()
146    }
147}
148
149impl<G> PathSelector for HoprGraphPathSelector<G>
150where
151    G: NetworkGraphTraverse<NodeId = OffchainPublicKey>
152        + NetworkGraphView<NodeId = OffchainPublicKey>
153        + Clone
154        + Send
155        + Sync
156        + 'static,
157    <G as NetworkGraphTraverse>::Observed: EdgeObservableRead + Send + 'static,
158{
159    /// Return all candidate paths from `src` to `dest` via `hops` relays.
160    ///
161    /// Each returned tuple contains a path `Vec<OffchainPublicKey>` of length
162    /// `hops + 1` (`[intermediates..., dest]`; `src` excluded) paired with its
163    /// accumulated traversal cost.
164    ///
165    /// Returns `Err(PathNotFound)` when the graph yields no positive-cost paths.
166    ///
167    /// The function has a potential to run expensive operations, it should be benchmarked
168    /// in a production environment and possibly guarded (e.g. by offloading the long execution
169    /// in an async executor to avoid blocking the caller).
170    #[tracing::instrument(level = "trace", skip(self), fields(src = %src, dest = %dest, hops), ret, err)]
171    fn select_path(&self, src: OffchainPublicKey, dest: OffchainPublicKey, hops: usize) -> Result<Vec<PathWithCost>> {
172        let direction = if src == self.me { "forward" } else { "return" };
173        tracing::debug!(%src, %dest, hops, direction, "computing paths from graph");
174
175        let length = std::num::NonZeroUsize::new(hops + 1)
176            .expect("can never fail, it is physically at least 1 after the addition");
177
178        let paths = if src == self.me {
179            // Phase 1: search for full-length forward paths to dest.
180            let mut found = compute_paths(
181                &self.graph,
182                &src,
183                &dest,
184                length,
185                self.max_paths,
186                EdgeValueFn::forward(length, self.edge_penalty, self.min_ack_rate),
187            );
188            tracing::debug!(
189                direction,
190                phase = 1,
191                count = found.len(),
192                "[forward] phase 1 candidates"
193            );
194
195            // Phase 2: if not enough paths, do an extended search with EdgeValueFn::forward_without_self_loopback
196            // for (length - 1) edges and assume the last hop can be done by anybody.
197            if found.len() < self.max_paths
198                && let Some(shorter) = std::num::NonZeroUsize::new(length.get() - 1)
199            {
200                let remaining = self.max_paths - found.len();
201                let extended = self.compute_extended_forward_paths(&src, &dest, shorter, remaining, &found);
202                tracing::debug!(
203                    direction,
204                    phase = 2,
205                    count = extended.len(),
206                    "[forward] phase 2 extended candidates"
207                );
208                found.extend(extended);
209            }
210
211            found
212        } else {
213            let found = compute_paths(
214                &self.graph,
215                &src,
216                &dest,
217                length,
218                self.max_paths,
219                EdgeValueFn::returning(length, self.edge_penalty, self.min_ack_rate),
220            );
221            tracing::debug!(direction, count = found.len(), "[return] candidates");
222            found
223        };
224
225        for (i, pwc) in paths.iter().enumerate() {
226            tracing::debug!(
227                direction,
228                index = i,
229                path = ?pwc.path,
230                cost = pwc.cost,
231                "[{direction}] candidate path"
232            );
233        }
234
235        if paths.is_empty() {
236            Err(PathPlannerError::Path(PathError::PathNotFound(
237                hops,
238                src.to_string(),
239                dest.to_string(),
240            )))
241        } else {
242            Ok(paths)
243        }
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use std::time::Duration;
250
251    use anyhow::Context;
252    use hex_literal::hex;
253    use hopr_api::graph::{
254        NetworkGraphWrite,
255        traits::{EdgeObservableWrite, EdgeWeightType},
256    };
257    use hopr_network_graph::ChannelGraph;
258    use hopr_types::{
259        crypto::prelude::{Keypair, OffchainKeypair},
260        internal::routing::RoutingOptions,
261    };
262
263    use super::*;
264    use crate::path::{PathPlannerConfig, traits::PathSelector};
265
266    fn test_selector(
267        me: OffchainPublicKey,
268        graph: ChannelGraph,
269        max_paths: usize,
270    ) -> HoprGraphPathSelector<ChannelGraph> {
271        let cfg = PathPlannerConfig::default();
272        HoprGraphPathSelector::new(me, graph, max_paths, cfg.edge_penalty, cfg.min_ack_rate)
273    }
274
275    const SECRET_0: [u8; 32] = hex!("60741b83b99e36aa0c1331578156e16b8e21166d01834abb6c64b103f885734d");
276    const SECRET_1: [u8; 32] = hex!("71bf1f42ebbfcd89c3e197a3fd7cda79b92499e509b6fefa0fe44d02821d146a");
277    const SECRET_2: [u8; 32] = hex!("c24bd833704dd2abdae3933fcc9962c2ac404f84132224c474147382d4db2299");
278    const SECRET_3: [u8; 32] = hex!("e0bf93e9c916104da00b1850adc4608bd7e9087bbd3f805451f4556aa6b3fd6e");
279    const SECRET_4: [u8; 32] = hex!("cfc66f718ec66fb822391775d749d7a0d66b690927673634816b63339bc12a3c");
280
281    const MAX_PATHS: usize = 4;
282
283    fn pubkey(secret: &[u8; 32]) -> OffchainPublicKey {
284        *OffchainKeypair::from_secret(secret).expect("valid secret").public()
285    }
286
287    /// Mark an edge as fully ready for intermediate routing.
288    fn mark_edge_full(graph: &ChannelGraph, src: &OffchainPublicKey, dst: &OffchainPublicKey) {
289        graph.upsert_edge(src, dst, |obs| {
290            obs.record(EdgeWeightType::Connected(true));
291            obs.record(EdgeWeightType::Immediate(Ok(Duration::from_millis(50))));
292            obs.record(EdgeWeightType::Intermediate(Ok(Duration::from_millis(50))));
293            obs.record(EdgeWeightType::Capacity(Some(1000)));
294        });
295    }
296
297    /// Mark an edge as ready only as the last hop (forward or return).
298    ///
299    /// Forward last edge requires capacity; return last edge requires connectivity + score.
300    /// This helper sets all of them so it works for either direction.
301    fn mark_edge_last(graph: &ChannelGraph, src: &OffchainPublicKey, dst: &OffchainPublicKey) {
302        graph.upsert_edge(src, dst, |obs| {
303            obs.record(EdgeWeightType::Connected(true));
304            obs.record(EdgeWeightType::Immediate(Ok(Duration::from_millis(50))));
305            obs.record(EdgeWeightType::Intermediate(Ok(Duration::from_millis(50))));
306            obs.record(EdgeWeightType::Capacity(Some(1000)));
307        });
308    }
309
310    // Helper: build a bidirectional 2-hop graph: me ↔ hop ↔ dest.
311    fn two_hop_graph() -> (OffchainPublicKey, OffchainPublicKey, OffchainPublicKey, ChannelGraph) {
312        let me = pubkey(&SECRET_0);
313        let hop = pubkey(&SECRET_1);
314        let dest = pubkey(&SECRET_2);
315        let graph = ChannelGraph::new(me);
316        graph.add_node(hop);
317        graph.add_node(dest);
318        // Forward: me → hop → dest
319        graph.add_edge(&me, &hop).unwrap();
320        graph.add_edge(&hop, &dest).unwrap();
321        mark_edge_full(&graph, &me, &hop);
322        mark_edge_last(&graph, &hop, &dest);
323        // Reverse: dest → hop → me
324        graph.add_edge(&dest, &hop).unwrap();
325        graph.add_edge(&hop, &me).unwrap();
326        mark_edge_full(&graph, &dest, &hop);
327        mark_edge_last(&graph, &hop, &me);
328        (me, hop, dest, graph)
329    }
330
331    #[tokio::test]
332    async fn unreachable_dest_should_return_error() -> anyhow::Result<()> {
333        let me = pubkey(&SECRET_0);
334        let unreachable = pubkey(&SECRET_1);
335        let graph = ChannelGraph::new(me);
336        // No edges at all — neither direction has a path.
337        let selector = test_selector(me, graph, MAX_PATHS);
338
339        let fwd = selector.select_path(me, unreachable, 1);
340        assert!(fwd.is_err(), "forward: should error when destination is unreachable");
341        assert!(matches!(
342            fwd.unwrap_err(),
343            PathPlannerError::Path(PathError::PathNotFound(..))
344        ));
345
346        let rev = selector.select_path(unreachable, me, 1);
347        assert!(rev.is_err(), "reverse: should error when destination is unreachable");
348        assert!(matches!(
349            rev.unwrap_err(),
350            PathPlannerError::Path(PathError::PathNotFound(..))
351        ));
352
353        Ok(())
354    }
355
356    #[tokio::test]
357    async fn path_should_exclude_source() -> anyhow::Result<()> {
358        let (me, _hop, dest, graph) = two_hop_graph();
359        let selector = test_selector(me, graph, MAX_PATHS);
360
361        let fwd = selector.select_path(me, dest, 1).context("forward path")?;
362        assert!(!fwd.is_empty());
363        for pwc in &fwd {
364            assert!(!pwc.path.contains(&me), "forward path must not contain the source");
365            assert!(pwc.cost > 0.0, "cost must be positive");
366        }
367
368        let rev = selector.select_path(dest, me, 1).context("reverse path")?;
369        assert!(!rev.is_empty());
370        for pwc in &rev {
371            assert!(!pwc.path.contains(&dest), "reverse path must not contain the source");
372            assert!(pwc.cost > 0.0, "cost must be positive");
373        }
374
375        Ok(())
376    }
377
378    #[tokio::test]
379    async fn multi_hop_path_should_have_correct_length() -> anyhow::Result<()> {
380        // Bidirectional: me ↔ A ↔ B ↔ dest  (2 intermediate hops each way)
381        let me = pubkey(&SECRET_0);
382        let a = pubkey(&SECRET_1);
383        let b = pubkey(&SECRET_2);
384        let dest = pubkey(&SECRET_3);
385        let graph = ChannelGraph::new(me);
386        for n in [a, b, dest] {
387            graph.add_node(n);
388        }
389        // Forward: me → A → B → dest
390        graph.add_edge(&me, &a).unwrap();
391        graph.add_edge(&a, &b).unwrap();
392        graph.add_edge(&b, &dest).unwrap();
393        mark_edge_full(&graph, &me, &a);
394        mark_edge_full(&graph, &a, &b);
395        mark_edge_last(&graph, &b, &dest);
396        // Reverse: dest → B → A → me
397        graph.add_edge(&dest, &b).unwrap();
398        graph.add_edge(&b, &a).unwrap();
399        graph.add_edge(&a, &me).unwrap();
400        mark_edge_full(&graph, &dest, &b);
401        mark_edge_full(&graph, &b, &a);
402        mark_edge_last(&graph, &a, &me);
403
404        let selector = test_selector(me, graph, MAX_PATHS);
405
406        let fwd = selector.select_path(me, dest, 2).context("forward 2-hop path")?;
407        assert!(!fwd.is_empty());
408        for pwc in &fwd {
409            assert_eq!(pwc.path.len(), 3, "forward 2-hop path: [A, B, dest]");
410            assert_eq!(pwc.path.last(), Some(&dest));
411        }
412
413        let rev = selector.select_path(dest, me, 2).context("reverse 2-hop path")?;
414        assert!(!rev.is_empty());
415        for pwc in &rev {
416            assert_eq!(pwc.path.len(), 3, "reverse 2-hop path: [B, A, me]");
417            assert_eq!(pwc.path.last(), Some(&me));
418        }
419
420        Ok(())
421    }
422
423    #[tokio::test]
424    async fn one_hop_path_should_include_relay_and_destination() -> anyhow::Result<()> {
425        // Bidirectional: me ↔ relay ↔ dest
426        let (me, relay, dest, graph) = two_hop_graph();
427        let selector = test_selector(me, graph, MAX_PATHS);
428
429        let fwd = selector.select_path(me, dest, 1).context("forward 1-hop path")?;
430        assert!(!fwd.is_empty());
431        for pwc in &fwd {
432            assert_eq!(pwc.path.len(), 2, "forward: [relay, dest]");
433            assert_eq!(pwc.path.last(), Some(&dest));
434            assert!(!pwc.path.contains(&me));
435        }
436
437        let rev = selector.select_path(dest, me, 1).context("reverse 1-hop path")?;
438        assert!(!rev.is_empty());
439        for pwc in &rev {
440            assert_eq!(pwc.path.len(), 2, "reverse: [relay, me]");
441            assert_eq!(pwc.path.last(), Some(&me));
442            assert!(!pwc.path.contains(&dest));
443        }
444
445        let _ = relay;
446        Ok(())
447    }
448
449    #[tokio::test]
450    async fn diamond_topology_should_return_multiple_paths() -> anyhow::Result<()> {
451        // Bidirectional diamond: me ↔ a ↔ dest and me ↔ b ↔ dest
452        let me = pubkey(&SECRET_0);
453        let a = pubkey(&SECRET_1);
454        let b = pubkey(&SECRET_2);
455        let dest = pubkey(&SECRET_3);
456        let graph = ChannelGraph::new(me);
457        for n in [a, b, dest] {
458            graph.add_node(n);
459        }
460        // Forward: me → a → dest,  me → b → dest
461        graph.add_edge(&me, &a).unwrap();
462        graph.add_edge(&me, &b).unwrap();
463        graph.add_edge(&a, &dest).unwrap();
464        graph.add_edge(&b, &dest).unwrap();
465        mark_edge_full(&graph, &me, &a);
466        mark_edge_full(&graph, &me, &b);
467        mark_edge_last(&graph, &a, &dest);
468        mark_edge_last(&graph, &b, &dest);
469        // Reverse: dest → a → me,  dest → b → me
470        graph.add_edge(&dest, &a).unwrap();
471        graph.add_edge(&dest, &b).unwrap();
472        graph.add_edge(&a, &me).unwrap();
473        graph.add_edge(&b, &me).unwrap();
474        mark_edge_full(&graph, &dest, &a);
475        mark_edge_full(&graph, &dest, &b);
476        mark_edge_last(&graph, &a, &me);
477        mark_edge_last(&graph, &b, &me);
478
479        let selector = test_selector(me, graph, MAX_PATHS);
480
481        let fwd = selector.select_path(me, dest, 1).context("forward path")?;
482        assert_eq!(fwd.len(), 2, "forward: both paths via a and b should be returned");
483        for pwc in &fwd {
484            assert_eq!(pwc.path.last(), Some(&dest));
485        }
486
487        let rev = selector.select_path(dest, me, 1).context("reverse path")?;
488        assert_eq!(rev.len(), 2, "reverse: both paths via a and b should be returned");
489        for pwc in &rev {
490            assert_eq!(pwc.path.last(), Some(&me));
491        }
492
493        Ok(())
494    }
495
496    #[tokio::test]
497    async fn zero_cost_paths_should_return_error() -> anyhow::Result<()> {
498        // Graph has edges in both directions but no observations → all costs zero → pruned.
499        let me = pubkey(&SECRET_0);
500        let dest = pubkey(&SECRET_1);
501        let graph = ChannelGraph::new(me);
502        graph.add_node(dest);
503        graph.add_edge(&me, &dest).unwrap();
504        graph.add_edge(&dest, &me).unwrap();
505        // No observations → cost function will return non-positive cost → pruned.
506
507        let selector = test_selector(me, graph, MAX_PATHS);
508        assert!(
509            selector.select_path(me, dest, 1).is_err(),
510            "forward: edge with no observations should produce no valid path"
511        );
512        assert!(
513            selector.select_path(dest, me, 1).is_err(),
514            "reverse: edge with no observations should produce no valid path"
515        );
516        Ok(())
517    }
518
519    #[tokio::test]
520    async fn no_path_at_requested_hop_count_should_return_error() -> anyhow::Result<()> {
521        // Graph has direct edges both ways; requesting 2 hops should fail in both directions.
522        let me = pubkey(&SECRET_0);
523        let dest = pubkey(&SECRET_1);
524        let graph = ChannelGraph::new(me);
525        graph.add_node(dest);
526        graph.add_edge(&me, &dest).unwrap();
527        graph.add_edge(&dest, &me).unwrap();
528        mark_edge_full(&graph, &me, &dest);
529        mark_edge_full(&graph, &dest, &me);
530
531        let selector = test_selector(me, graph, MAX_PATHS);
532        assert!(
533            selector.select_path(me, dest, 2).is_err(),
534            "forward: no 2-hop path should exist for a direct edge"
535        );
536        assert!(
537            selector.select_path(dest, me, 2).is_err(),
538            "reverse: no 2-hop path should exist for a direct edge"
539        );
540        Ok(())
541    }
542
543    #[tokio::test]
544    async fn forward_path_should_work_without_last_edge() -> anyhow::Result<()> {
545        // In the real network, the last edge (relay → dest) may not have a
546        // payment channel. The graph has me → relay but NO relay → dest edge.
547        // The virtual last hop fallback should still find the forward path.
548        let me = pubkey(&SECRET_0);
549        let relay = pubkey(&SECRET_1);
550        let dest = pubkey(&SECRET_2);
551        let graph = ChannelGraph::new(me);
552        graph.add_node(relay);
553        graph.add_node(dest);
554        // Forward: me → relay (fully observed). NO relay → dest edge.
555        graph.add_edge(&me, &relay).unwrap();
556        mark_edge_full(&graph, &me, &relay);
557        // Reverse: dest → relay → me (both edges exist for the return path).
558        graph.add_edge(&dest, &relay).unwrap();
559        graph.add_edge(&relay, &me).unwrap();
560        mark_edge_full(&graph, &dest, &relay);
561        mark_edge_last(&graph, &relay, &me);
562
563        let selector = test_selector(me, graph, MAX_PATHS);
564
565        // Forward path should work via virtual last hop
566        let fwd = selector
567            .select_path(me, dest, 1)
568            .context("forward path with virtual last hop")?;
569        assert!(!fwd.is_empty(), "forward path should find at least one route");
570        for pwc in &fwd {
571            assert_eq!(pwc.path.len(), 2, "forward: [relay, dest]");
572            assert_eq!(pwc.path[0], relay);
573            assert_eq!(pwc.path[1], dest);
574        }
575
576        // Return path should work normally (both edges exist)
577        let rev = selector.select_path(dest, me, 1).context("return path")?;
578        assert!(!rev.is_empty(), "return path should find at least one route");
579        for pwc in &rev {
580            assert_eq!(pwc.path.len(), 2, "return: [relay, me]");
581            assert_eq!(pwc.path.last(), Some(&me));
582        }
583
584        Ok(())
585    }
586
587    #[tokio::test]
588    async fn five_node_chain_should_support_max_hops() -> anyhow::Result<()> {
589        // Bidirectional: me ↔ a ↔ b ↔ c ↔ dest  (3 intermediate hops each way)
590        let me = pubkey(&SECRET_0);
591        let a = pubkey(&SECRET_1);
592        let b = pubkey(&SECRET_2);
593        let c = pubkey(&SECRET_3);
594        let dest = pubkey(&SECRET_4);
595        let graph = ChannelGraph::new(me);
596        for n in [a, b, c, dest] {
597            graph.add_node(n);
598        }
599        // Forward: me → a → b → c → dest
600        graph.add_edge(&me, &a).unwrap();
601        graph.add_edge(&a, &b).unwrap();
602        graph.add_edge(&b, &c).unwrap();
603        graph.add_edge(&c, &dest).unwrap();
604        mark_edge_full(&graph, &me, &a);
605        mark_edge_full(&graph, &a, &b);
606        mark_edge_full(&graph, &b, &c);
607        mark_edge_last(&graph, &c, &dest);
608        // Reverse: dest → c → b → a → me
609        graph.add_edge(&dest, &c).unwrap();
610        graph.add_edge(&c, &b).unwrap();
611        graph.add_edge(&b, &a).unwrap();
612        graph.add_edge(&a, &me).unwrap();
613        mark_edge_full(&graph, &dest, &c);
614        mark_edge_full(&graph, &c, &b);
615        mark_edge_full(&graph, &b, &a);
616        mark_edge_last(&graph, &a, &me);
617
618        let selector = test_selector(me, graph, MAX_PATHS);
619
620        let fwd = selector
621            .select_path(me, dest, RoutingOptions::MAX_INTERMEDIATE_HOPS)
622            .context("forward 3-hop path")?;
623        assert!(!fwd.is_empty());
624        for pwc in &fwd {
625            assert_eq!(pwc.path.len(), 4, "forward: [a, b, c, dest]");
626            assert_eq!(pwc.path.last(), Some(&dest));
627            assert!(!pwc.path.contains(&me));
628        }
629
630        let rev = selector
631            .select_path(dest, me, RoutingOptions::MAX_INTERMEDIATE_HOPS)
632            .context("reverse 3-hop path")?;
633        assert!(!rev.is_empty());
634        for pwc in &rev {
635            assert_eq!(pwc.path.len(), 4, "reverse: [c, b, a, me]");
636            assert_eq!(pwc.path.last(), Some(&me));
637            assert!(!pwc.path.contains(&dest));
638        }
639
640        Ok(())
641    }
642
643    #[tokio::test]
644    async fn selector_should_reject_extended_path_containing_destination() -> anyhow::Result<()> {
645        // If me has a direct edge to dest (e.g. an edge node with a channel to its
646        // exit server), Phase 2 must not emit [dest, dest] — appending dest to a
647        // candidate that already ends in dest forms a loop that ValidatedPath::new
648        // would catch anyway, but we want the guard to skip it cleanly.
649        let me = pubkey(&SECRET_0);
650        let relay = pubkey(&SECRET_1);
651        let dest = pubkey(&SECRET_2);
652        let graph = ChannelGraph::new(me);
653        graph.add_node(relay);
654        graph.add_node(dest);
655        // me → dest: direct channel (this is what the own_chain_addr fix exposes)
656        graph.add_edge(&me, &dest).unwrap();
657        mark_edge_full(&graph, &me, &dest);
658        // me → relay: for 1-hop via relay
659        graph.add_edge(&me, &relay).unwrap();
660        mark_edge_full(&graph, &me, &relay);
661        // Return path
662        graph.add_edge(&dest, &relay).unwrap();
663        graph.add_edge(&relay, &me).unwrap();
664        mark_edge_full(&graph, &dest, &relay);
665        mark_edge_last(&graph, &relay, &me);
666
667        let selector = test_selector(me, graph, MAX_PATHS);
668
669        let fwd = selector
670            .select_path(me, dest, 1)
671            .context("forward path with dest as direct neighbor")?;
672        assert!(!fwd.is_empty(), "should find at least one path via relay");
673        for pwc in &fwd {
674            assert_eq!(pwc.path.len(), 2, "path must be [relay, dest]");
675            assert_eq!(pwc.path[0], relay, "first node must be relay, not dest");
676            assert_eq!(pwc.path[1], dest);
677        }
678        Ok(())
679    }
680
681    #[tokio::test]
682    async fn selector_should_reject_one_hop_path_where_relay_equals_destination() -> anyhow::Result<()> {
683        // Same as above but without relay→dest edge — only Phase 2 (virtual last hop)
684        // is in play. The guard must skip the me→dest edge as a relay candidate.
685        let me = pubkey(&SECRET_0);
686        let relay = pubkey(&SECRET_1);
687        let dest = pubkey(&SECRET_2);
688        let graph = ChannelGraph::new(me);
689        graph.add_node(relay);
690        graph.add_node(dest);
691        // me → dest: direct channel (no relay→dest edge)
692        graph.add_edge(&me, &dest).unwrap();
693        mark_edge_full(&graph, &me, &dest);
694        // me → relay (relay is a valid intermediate; no relay→dest edge needed for Phase 2)
695        graph.add_edge(&me, &relay).unwrap();
696        mark_edge_full(&graph, &me, &relay);
697        // Return path
698        graph.add_edge(&dest, &relay).unwrap();
699        graph.add_edge(&relay, &me).unwrap();
700        mark_edge_full(&graph, &dest, &relay);
701        mark_edge_last(&graph, &relay, &me);
702
703        let selector = test_selector(me, graph, MAX_PATHS);
704
705        let fwd = selector
706            .select_path(me, dest, 1)
707            .context("forward path — dest is direct neighbor, relay is intermediate")?;
708        assert!(!fwd.is_empty(), "should find path via relay (virtual last hop)");
709        for pwc in &fwd {
710            assert_eq!(pwc.path[0], relay, "intermediate must be relay, not dest");
711            assert_ne!(pwc.path[0], dest, "dest must not appear as intermediate");
712        }
713        Ok(())
714    }
715
716    #[tokio::test]
717    async fn selector_should_skip_zero_cost_paths() -> anyhow::Result<()> {
718        // Build graph with edges but NO observations → cost function returns 0.
719        let me = pubkey(&SECRET_0);
720        let hop = pubkey(&SECRET_1);
721        let dest = pubkey(&SECRET_2);
722        let graph = ChannelGraph::new(me);
723        graph.add_node(hop);
724        graph.add_node(dest);
725        graph.add_edge(&me, &hop).context("adding edge me -> hop")?;
726        graph.add_edge(&hop, &dest).context("adding edge hop -> dest")?;
727        // No mark_edge_full/mark_edge_last → observations are empty → cost = 0
728
729        let selector = test_selector(me, graph, MAX_PATHS);
730
731        let err = selector
732            .select_path(me, dest, 1)
733            .expect_err("zero-cost paths should be filtered out");
734        anyhow::ensure!(
735            matches!(err, PathPlannerError::Path(PathError::PathNotFound(..))),
736            "expected PathNotFound, got: {err}"
737        );
738        Ok(())
739    }
740}