1use std::{cmp::Ordering, sync::Arc};
2
3use hopr_api::{
4 OffchainPublicKey,
5 graph::{
6 NetworkGraphTraverse, NetworkGraphView,
7 function::{BasicValueFn, EdgeValueFn},
8 traits::{
9 EdgeImmediateProtocolObservable, EdgeLinkObservable, EdgeObservableRead, EdgeProtocolObservable, ValueFn,
10 },
11 },
12 types::internal::errors::PathError,
13};
14
15use super::{
16 errors::{PathPlannerError, Result},
17 traits::{PathSelector, PathWithMetrics},
18};
19
20#[derive(Clone, Debug)]
25struct PathCostWithMetrics {
26 cost: f64,
27 total_latency_ms: Option<u32>,
28 min_probe_success_rate: Option<f64>,
29 min_ack_rate: Option<f64>,
30 capacity_floor: Option<u128>,
31}
32
33impl PartialOrd for PathCostWithMetrics {
34 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
35 self.cost.partial_cmp(&other.cost)
36 }
37}
38
39impl PartialEq for PathCostWithMetrics {
40 fn eq(&self, other: &Self) -> bool {
41 self.cost == other.cost
42 }
43}
44
45impl From<(PathCostWithMetrics, Vec<OffchainPublicKey>)> for PathWithMetrics {
46 fn from((metrics, path): (PathCostWithMetrics, Vec<OffchainPublicKey>)) -> Self {
47 PathWithMetrics {
48 path,
49 cost: metrics.cost,
50 total_latency_ms: metrics.total_latency_ms,
51 min_probe_success_rate: metrics.min_probe_success_rate,
52 min_ack_rate: metrics.min_ack_rate,
53 capacity_floor: metrics.capacity_floor,
54 }
55 }
56}
57
58fn opt_min<T: PartialOrd>(a: Option<T>, b: Option<T>) -> Option<T> {
60 match (a, b) {
61 (Some(x), Some(y)) => Some(if x <= y { x } else { y }),
62 (x, y) => x.or(y),
63 }
64}
65
66struct MetricsValueFn<W: EdgeObservableRead> {
73 inner: EdgeValueFn<f64, W>,
74}
75
76impl<W> ValueFn for MetricsValueFn<W>
77where
78 W: EdgeObservableRead + Send + 'static,
79{
80 type Value = PathCostWithMetrics;
81 type Weight = W;
82
83 fn initial_value(&self) -> Self::Value {
84 PathCostWithMetrics {
85 cost: self.inner.initial_value(),
86 total_latency_ms: Some(0),
87 min_probe_success_rate: None,
88 min_ack_rate: None,
89 capacity_floor: None,
90 }
91 }
92
93 fn min_value(&self) -> Option<Self::Value> {
94 self.inner.min_value().map(|c| PathCostWithMetrics {
95 cost: c,
96 total_latency_ms: None,
97 min_probe_success_rate: None,
98 min_ack_rate: None,
99 capacity_floor: None,
100 })
101 }
102
103 fn into_value_fn(self) -> BasicValueFn<Self::Value, Self::Weight> {
104 let inner = self.inner.into_value_fn();
105 Arc::new(move |prev: PathCostWithMetrics, observed: &W, idx: usize| {
106 let cost = inner(prev.cost, observed, idx);
107
108 let edge_lat = observed
109 .immediate_qos()
110 .and_then(|m| m.average_latency())
111 .or_else(|| observed.intermediate_qos().and_then(|m| m.average_latency()));
112 let total_latency_ms = match (prev.total_latency_ms, edge_lat) {
113 (Some(acc), Some(lat)) => Some(((acc as u128 + lat.as_millis()).min(u32::MAX as u128)) as u32),
114 _ => None,
115 };
116
117 let edge_probe = observed
120 .immediate_qos()
121 .map(|m| m.average_probe_rate())
122 .into_iter()
123 .chain(observed.intermediate_qos().map(|m| m.average_probe_rate()))
124 .reduce(f64::min);
125 let min_probe_success_rate = opt_min(prev.min_probe_success_rate, edge_probe);
126
127 let edge_ack = observed.immediate_qos().and_then(|m| m.ack_rate());
128 let min_ack_rate = opt_min(prev.min_ack_rate, edge_ack);
129
130 let edge_cap = observed.intermediate_qos().and_then(|m| m.capacity());
131 let capacity_floor = opt_min(prev.capacity_floor, edge_cap);
132
133 PathCostWithMetrics {
134 cost,
135 total_latency_ms,
136 min_probe_success_rate,
137 min_ack_rate,
138 capacity_floor,
139 }
140 })
141 }
142}
143
144pub fn prune_for_consistency(candidates: Vec<PathWithMetrics>, floor: usize, hops: usize) -> Vec<PathWithMetrics> {
159 if floor == 0 || candidates.len() <= floor {
161 return candidates;
162 }
163
164 let fully_measured =
165 |p: &PathWithMetrics| p.total_latency_ms.is_some() && (hops == 0 || p.capacity_floor.is_some());
166
167 let (mut populated, unpopulated): (Vec<_>, Vec<_>) = candidates.into_iter().partition(|p| fully_measured(p));
168
169 populated.sort_by_key(|p| p.total_latency_ms.unwrap_or(u32::MAX));
171
172 let target_populated = populated.len().min(floor);
177 populated.truncate(target_populated);
178 let remaining = floor - target_populated;
179
180 let mut result = populated;
181 result.extend(unpopulated.into_iter().take(remaining));
182
183 result
184}
185
186fn compute_paths<G, W>(
193 graph: &G,
194 src: &OffchainPublicKey,
195 dest: &OffchainPublicKey,
196 length: std::num::NonZeroUsize,
197 take: usize,
198 value_fn: MetricsValueFn<W>,
199) -> Vec<PathWithMetrics>
200where
201 G: NetworkGraphTraverse<NodeId = OffchainPublicKey, Observed = W>,
202 W: EdgeObservableRead + Send + 'static,
203{
204 let raw = graph.simple_paths(src, dest, length.get(), Some(take), value_fn);
205
206 raw.into_iter()
207 .filter_map(|(path, _, metrics)| {
208 tracing::trace!(?path, cost = metrics.cost, "evaluating candidate path");
209 if metrics.cost > 0.0 {
210 let mut path = path;
211 path.push(*dest);
212 Some(PathWithMetrics::from((metrics, path)))
213 } else {
214 None
215 }
216 })
217 .collect()
218}
219
220#[derive(Clone)]
232pub struct HoprGraphPathSelector<G> {
233 me: OffchainPublicKey,
234 graph: G,
235 max_paths: usize,
236 edge_penalty: f64,
237 min_ack_rate: f64,
238 anonymity_floor: usize,
239}
240
241impl<G> HoprGraphPathSelector<G>
242where
243 G: NetworkGraphTraverse<NodeId = OffchainPublicKey>
244 + NetworkGraphView<NodeId = OffchainPublicKey>
245 + Clone
246 + Send
247 + Sync
248 + 'static,
249 <G as NetworkGraphTraverse>::Observed: EdgeObservableRead + Send + 'static,
250{
251 pub fn new(
260 me: OffchainPublicKey,
261 graph: G,
262 max_paths: usize,
263 edge_penalty: f64,
264 min_ack_rate: f64,
265 anonymity_floor: usize,
266 ) -> Self {
267 Self {
268 me,
269 graph,
270 max_paths,
271 edge_penalty,
272 min_ack_rate,
273 anonymity_floor,
274 }
275 }
276
277 fn compute_extended_forward_paths(
287 &self,
288 src: &OffchainPublicKey,
289 dest: &OffchainPublicKey,
290 shorter_length: std::num::NonZeroUsize,
291 take: usize,
292 existing: &[PathWithMetrics],
293 ) -> Vec<PathWithMetrics> {
294 let value_fn = MetricsValueFn {
295 inner: EdgeValueFn::forward_without_self_loopback(self.edge_penalty, self.min_ack_rate),
296 };
297 let raw = self
298 .graph
299 .simple_paths_from(src, shorter_length.get(), Some(take), value_fn);
300
301 raw.into_iter()
302 .filter_map(|(path, _, metrics)| {
303 if metrics.cost <= 0.0 {
304 return None;
305 }
306
307 if path.contains(dest) {
311 return None;
312 }
313 let mut candidate = path;
314 candidate.push(*dest);
315
316 if existing.iter().any(|pwm| pwm.path == candidate) {
317 return None;
318 }
319
320 tracing::trace!(?candidate, cost = metrics.cost, "extended forward path candidate");
321 Some(PathWithMetrics::from((metrics, candidate)))
322 })
323 .take(take)
324 .collect()
325 }
326}
327
328impl<G> PathSelector for HoprGraphPathSelector<G>
329where
330 G: NetworkGraphTraverse<NodeId = OffchainPublicKey>
331 + NetworkGraphView<NodeId = OffchainPublicKey>
332 + Clone
333 + Send
334 + Sync
335 + 'static,
336 <G as NetworkGraphTraverse>::Observed: EdgeObservableRead + Send + 'static,
337{
338 #[tracing::instrument(level = "trace", skip(self), fields(src = %src, dest = %dest, hops), ret, err)]
350 fn select_path(
351 &self,
352 src: OffchainPublicKey,
353 dest: OffchainPublicKey,
354 hops: usize,
355 ) -> Result<Vec<PathWithMetrics>> {
356 let direction = if src == self.me { "forward" } else { "return" };
357 tracing::debug!(%src, %dest, hops, direction, "computing paths from graph");
358
359 let length = std::num::NonZeroUsize::new(hops + 1)
360 .expect("can never fail, it is physically at least 1 after the addition");
361
362 let paths = if src == self.me {
363 let mut found = compute_paths(
365 &self.graph,
366 &src,
367 &dest,
368 length,
369 self.max_paths,
370 MetricsValueFn {
371 inner: EdgeValueFn::forward(length, self.edge_penalty, self.min_ack_rate),
372 },
373 );
374 tracing::debug!(
375 direction,
376 phase = 1,
377 count = found.len(),
378 "[forward] phase 1 candidates"
379 );
380
381 if found.len() < self.max_paths
384 && let Some(shorter) = std::num::NonZeroUsize::new(length.get() - 1)
385 {
386 let remaining = self.max_paths - found.len();
387 let extended = self.compute_extended_forward_paths(&src, &dest, shorter, remaining, &found);
388 tracing::debug!(
389 direction,
390 phase = 2,
391 count = extended.len(),
392 "[forward] phase 2 extended candidates"
393 );
394 found.extend(extended);
395 }
396
397 found
398 } else {
399 let found = compute_paths(
400 &self.graph,
401 &src,
402 &dest,
403 length,
404 self.max_paths,
405 MetricsValueFn {
406 inner: EdgeValueFn::returning(length, self.edge_penalty, self.min_ack_rate),
407 },
408 );
409 tracing::debug!(direction, count = found.len(), "[return] candidates");
410 found
411 };
412
413 for (i, pwm) in paths.iter().enumerate() {
414 tracing::debug!(
415 direction,
416 index = i,
417 path = ?pwm.path,
418 cost = pwm.cost,
419 total_latency_ms = ?pwm.total_latency_ms,
420 "[{direction}] candidate path"
421 );
422 }
423
424 if paths.is_empty() {
425 Err(PathPlannerError::Path(PathError::PathNotFound(
426 hops,
427 src.to_string(),
428 dest.to_string(),
429 )))
430 } else {
431 Ok(prune_for_consistency(paths, self.anonymity_floor, hops))
432 }
433 }
434}
435
436#[cfg(test)]
437mod tests {
438 use std::time::Duration;
439
440 use anyhow::Context;
441 use hex_literal::hex;
442 use hopr_api::{
443 graph::{
444 NetworkGraphWrite,
445 traits::{EdgeObservableWrite, EdgeWeightType},
446 },
447 types::{
448 crypto::prelude::{Keypair, OffchainKeypair},
449 internal::routing::RoutingOptions,
450 },
451 };
452 use hopr_network_graph::ChannelGraph;
453
454 use super::*;
455 use crate::path::{PathPlannerConfig, traits::PathSelector};
456
457 fn test_selector(
458 me: OffchainPublicKey,
459 graph: ChannelGraph,
460 max_paths: usize,
461 ) -> HoprGraphPathSelector<ChannelGraph> {
462 let cfg = PathPlannerConfig::default();
463 HoprGraphPathSelector::new(
464 me,
465 graph,
466 max_paths,
467 cfg.edge_penalty,
468 cfg.min_ack_rate,
469 cfg.min_paths_anonymity_floor,
470 )
471 }
472
473 const SECRET_0: [u8; 32] = hex!("60741b83b99e36aa0c1331578156e16b8e21166d01834abb6c64b103f885734d");
474 const SECRET_1: [u8; 32] = hex!("71bf1f42ebbfcd89c3e197a3fd7cda79b92499e509b6fefa0fe44d02821d146a");
475 const SECRET_2: [u8; 32] = hex!("c24bd833704dd2abdae3933fcc9962c2ac404f84132224c474147382d4db2299");
476 const SECRET_3: [u8; 32] = hex!("e0bf93e9c916104da00b1850adc4608bd7e9087bbd3f805451f4556aa6b3fd6e");
477 const SECRET_4: [u8; 32] = hex!("cfc66f718ec66fb822391775d749d7a0d66b690927673634816b63339bc12a3c");
478
479 const MAX_PATHS: usize = 4;
480
481 fn pubkey(secret: &[u8; 32]) -> OffchainPublicKey {
482 *OffchainKeypair::from_secret(secret).expect("valid secret").public()
483 }
484
485 fn mark_edge_full(graph: &ChannelGraph, src: &OffchainPublicKey, dst: &OffchainPublicKey) {
487 graph.upsert_edge(src, dst, |obs| {
488 obs.record(EdgeWeightType::Connected(true));
489 obs.record(EdgeWeightType::Immediate(Ok(Duration::from_millis(50))));
490 obs.record(EdgeWeightType::Intermediate(Ok(Duration::from_millis(50))));
491 obs.record(EdgeWeightType::Capacity(Some(1000)));
492 });
493 }
494
495 fn two_hop_graph() -> (OffchainPublicKey, OffchainPublicKey, OffchainPublicKey, ChannelGraph) {
497 let me = pubkey(&SECRET_0);
498 let hop = pubkey(&SECRET_1);
499 let dest = pubkey(&SECRET_2);
500 let graph = ChannelGraph::new(me);
501 graph.add_node(hop);
502 graph.add_node(dest);
503 graph.add_edge(&me, &hop).unwrap();
505 graph.add_edge(&hop, &dest).unwrap();
506 mark_edge_full(&graph, &me, &hop);
507 mark_edge_full(&graph, &hop, &dest);
508 graph.add_edge(&dest, &hop).unwrap();
510 graph.add_edge(&hop, &me).unwrap();
511 mark_edge_full(&graph, &dest, &hop);
512 mark_edge_full(&graph, &hop, &me);
513 (me, hop, dest, graph)
514 }
515
516 #[tokio::test]
517 async fn unreachable_dest_should_return_error() -> anyhow::Result<()> {
518 let me = pubkey(&SECRET_0);
519 let unreachable = pubkey(&SECRET_1);
520 let graph = ChannelGraph::new(me);
521 let selector = test_selector(me, graph, MAX_PATHS);
523
524 let fwd = selector.select_path(me, unreachable, 1);
525 assert!(fwd.is_err(), "forward: should error when destination is unreachable");
526 assert!(matches!(
527 fwd.unwrap_err(),
528 PathPlannerError::Path(PathError::PathNotFound(..))
529 ));
530
531 let rev = selector.select_path(unreachable, me, 1);
532 assert!(rev.is_err(), "reverse: should error when destination is unreachable");
533 assert!(matches!(
534 rev.unwrap_err(),
535 PathPlannerError::Path(PathError::PathNotFound(..))
536 ));
537
538 Ok(())
539 }
540
541 #[tokio::test]
542 async fn path_should_exclude_source() -> anyhow::Result<()> {
543 let (me, _hop, dest, graph) = two_hop_graph();
544 let selector = test_selector(me, graph, MAX_PATHS);
545
546 let fwd = selector.select_path(me, dest, 1).context("forward path")?;
547 assert!(!fwd.is_empty());
548 for pwm in &fwd {
549 assert!(!pwm.path.contains(&me), "forward path must not contain the source");
550 assert!(pwm.cost > 0.0, "cost must be positive");
551 }
552
553 let rev = selector.select_path(dest, me, 1).context("reverse path")?;
554 assert!(!rev.is_empty());
555 for pwm in &rev {
556 assert!(!pwm.path.contains(&dest), "reverse path must not contain the source");
557 assert!(pwm.cost > 0.0, "cost must be positive");
558 }
559
560 Ok(())
561 }
562
563 #[tokio::test]
564 async fn multi_hop_path_should_have_correct_length() -> anyhow::Result<()> {
565 let me = pubkey(&SECRET_0);
567 let a = pubkey(&SECRET_1);
568 let b = pubkey(&SECRET_2);
569 let dest = pubkey(&SECRET_3);
570 let graph = ChannelGraph::new(me);
571 for n in [a, b, dest] {
572 graph.add_node(n);
573 }
574 graph.add_edge(&me, &a).unwrap();
576 graph.add_edge(&a, &b).unwrap();
577 graph.add_edge(&b, &dest).unwrap();
578 mark_edge_full(&graph, &me, &a);
579 mark_edge_full(&graph, &a, &b);
580 mark_edge_full(&graph, &b, &dest);
581 graph.add_edge(&dest, &b).unwrap();
583 graph.add_edge(&b, &a).unwrap();
584 graph.add_edge(&a, &me).unwrap();
585 mark_edge_full(&graph, &dest, &b);
586 mark_edge_full(&graph, &b, &a);
587 mark_edge_full(&graph, &a, &me);
588
589 let selector = test_selector(me, graph, MAX_PATHS);
590
591 let fwd = selector.select_path(me, dest, 2).context("forward 2-hop path")?;
592 assert!(!fwd.is_empty());
593 for pwm in &fwd {
594 assert_eq!(pwm.path.len(), 3, "forward 2-hop path: [A, B, dest]");
595 assert_eq!(pwm.path.last(), Some(&dest));
596 }
597
598 let rev = selector.select_path(dest, me, 2).context("reverse 2-hop path")?;
599 assert!(!rev.is_empty());
600 for pwm in &rev {
601 assert_eq!(pwm.path.len(), 3, "reverse 2-hop path: [B, A, me]");
602 assert_eq!(pwm.path.last(), Some(&me));
603 }
604
605 Ok(())
606 }
607
608 #[tokio::test]
609 async fn one_hop_path_should_include_relay_and_destination() -> anyhow::Result<()> {
610 let (me, relay, dest, graph) = two_hop_graph();
612 let selector = test_selector(me, graph, MAX_PATHS);
613
614 let fwd = selector.select_path(me, dest, 1).context("forward 1-hop path")?;
615 assert!(!fwd.is_empty());
616 for pwm in &fwd {
617 assert_eq!(pwm.path.len(), 2, "forward: [relay, dest]");
618 assert_eq!(pwm.path.last(), Some(&dest));
619 assert!(!pwm.path.contains(&me));
620 }
621
622 let rev = selector.select_path(dest, me, 1).context("reverse 1-hop path")?;
623 assert!(!rev.is_empty());
624 for pwm in &rev {
625 assert_eq!(pwm.path.len(), 2, "reverse: [relay, me]");
626 assert_eq!(pwm.path.last(), Some(&me));
627 assert!(!pwm.path.contains(&dest));
628 }
629
630 let _ = relay;
631 Ok(())
632 }
633
634 #[tokio::test]
635 async fn diamond_topology_should_return_multiple_paths() -> anyhow::Result<()> {
636 let me = pubkey(&SECRET_0);
638 let a = pubkey(&SECRET_1);
639 let b = pubkey(&SECRET_2);
640 let dest = pubkey(&SECRET_3);
641 let graph = ChannelGraph::new(me);
642 for n in [a, b, dest] {
643 graph.add_node(n);
644 }
645 graph.add_edge(&me, &a).unwrap();
647 graph.add_edge(&me, &b).unwrap();
648 graph.add_edge(&a, &dest).unwrap();
649 graph.add_edge(&b, &dest).unwrap();
650 mark_edge_full(&graph, &me, &a);
651 mark_edge_full(&graph, &me, &b);
652 mark_edge_full(&graph, &a, &dest);
653 mark_edge_full(&graph, &b, &dest);
654 graph.add_edge(&dest, &a).unwrap();
656 graph.add_edge(&dest, &b).unwrap();
657 graph.add_edge(&a, &me).unwrap();
658 graph.add_edge(&b, &me).unwrap();
659 mark_edge_full(&graph, &dest, &a);
660 mark_edge_full(&graph, &dest, &b);
661 mark_edge_full(&graph, &a, &me);
662 mark_edge_full(&graph, &b, &me);
663
664 let selector = test_selector(me, graph, MAX_PATHS);
665
666 let fwd = selector.select_path(me, dest, 1).context("forward path")?;
667 assert_eq!(fwd.len(), 2, "forward: both paths via a and b should be returned");
668 for pwm in &fwd {
669 assert_eq!(pwm.path.last(), Some(&dest));
670 }
671
672 let rev = selector.select_path(dest, me, 1).context("reverse path")?;
673 assert_eq!(rev.len(), 2, "reverse: both paths via a and b should be returned");
674 for pwm in &rev {
675 assert_eq!(pwm.path.last(), Some(&me));
676 }
677
678 Ok(())
679 }
680
681 #[tokio::test]
682 async fn zero_cost_paths_should_return_error() -> anyhow::Result<()> {
683 let me = pubkey(&SECRET_0);
685 let dest = pubkey(&SECRET_1);
686 let graph = ChannelGraph::new(me);
687 graph.add_node(dest);
688 graph.add_edge(&me, &dest).unwrap();
689 graph.add_edge(&dest, &me).unwrap();
690 let selector = test_selector(me, graph, MAX_PATHS);
693 assert!(
694 selector.select_path(me, dest, 1).is_err(),
695 "forward: edge with no observations should produce no valid path"
696 );
697 assert!(
698 selector.select_path(dest, me, 1).is_err(),
699 "reverse: edge with no observations should produce no valid path"
700 );
701 Ok(())
702 }
703
704 #[tokio::test]
705 async fn no_path_at_requested_hop_count_should_return_error() -> anyhow::Result<()> {
706 let me = pubkey(&SECRET_0);
708 let dest = pubkey(&SECRET_1);
709 let graph = ChannelGraph::new(me);
710 graph.add_node(dest);
711 graph.add_edge(&me, &dest).unwrap();
712 graph.add_edge(&dest, &me).unwrap();
713 mark_edge_full(&graph, &me, &dest);
714 mark_edge_full(&graph, &dest, &me);
715
716 let selector = test_selector(me, graph, MAX_PATHS);
717 assert!(
718 selector.select_path(me, dest, 2).is_err(),
719 "forward: no 2-hop path should exist for a direct edge"
720 );
721 assert!(
722 selector.select_path(dest, me, 2).is_err(),
723 "reverse: no 2-hop path should exist for a direct edge"
724 );
725 Ok(())
726 }
727
728 #[tokio::test]
729 async fn forward_path_should_work_without_last_edge() -> anyhow::Result<()> {
730 let me = pubkey(&SECRET_0);
734 let relay = pubkey(&SECRET_1);
735 let dest = pubkey(&SECRET_2);
736 let graph = ChannelGraph::new(me);
737 graph.add_node(relay);
738 graph.add_node(dest);
739 graph.add_edge(&me, &relay).unwrap();
741 mark_edge_full(&graph, &me, &relay);
742 graph.add_edge(&dest, &relay).unwrap();
744 graph.add_edge(&relay, &me).unwrap();
745 mark_edge_full(&graph, &dest, &relay);
746 mark_edge_full(&graph, &relay, &me);
747
748 let selector = test_selector(me, graph, MAX_PATHS);
749
750 let fwd = selector
752 .select_path(me, dest, 1)
753 .context("forward path with virtual last hop")?;
754 assert!(!fwd.is_empty(), "forward path should find at least one route");
755 for pwm in &fwd {
756 assert_eq!(pwm.path.len(), 2, "forward: [relay, dest]");
757 assert_eq!(pwm.path[0], relay);
758 assert_eq!(pwm.path[1], dest);
759 }
760
761 let rev = selector.select_path(dest, me, 1).context("return path")?;
763 assert!(!rev.is_empty(), "return path should find at least one route");
764 for pwm in &rev {
765 assert_eq!(pwm.path.len(), 2, "return: [relay, me]");
766 assert_eq!(pwm.path.last(), Some(&me));
767 }
768
769 Ok(())
770 }
771
772 #[tokio::test]
773 async fn five_node_chain_should_support_max_hops() -> anyhow::Result<()> {
774 let me = pubkey(&SECRET_0);
776 let a = pubkey(&SECRET_1);
777 let b = pubkey(&SECRET_2);
778 let c = pubkey(&SECRET_3);
779 let dest = pubkey(&SECRET_4);
780 let graph = ChannelGraph::new(me);
781 for n in [a, b, c, dest] {
782 graph.add_node(n);
783 }
784 graph.add_edge(&me, &a).unwrap();
786 graph.add_edge(&a, &b).unwrap();
787 graph.add_edge(&b, &c).unwrap();
788 graph.add_edge(&c, &dest).unwrap();
789 mark_edge_full(&graph, &me, &a);
790 mark_edge_full(&graph, &a, &b);
791 mark_edge_full(&graph, &b, &c);
792 mark_edge_full(&graph, &c, &dest);
793 graph.add_edge(&dest, &c).unwrap();
795 graph.add_edge(&c, &b).unwrap();
796 graph.add_edge(&b, &a).unwrap();
797 graph.add_edge(&a, &me).unwrap();
798 mark_edge_full(&graph, &dest, &c);
799 mark_edge_full(&graph, &c, &b);
800 mark_edge_full(&graph, &b, &a);
801 mark_edge_full(&graph, &a, &me);
802
803 let selector = test_selector(me, graph, MAX_PATHS);
804
805 let fwd = selector
806 .select_path(me, dest, RoutingOptions::MAX_INTERMEDIATE_HOPS)
807 .context("forward 3-hop path")?;
808 assert!(!fwd.is_empty());
809 for pwm in &fwd {
810 assert_eq!(pwm.path.len(), 4, "forward: [a, b, c, dest]");
811 assert_eq!(pwm.path.last(), Some(&dest));
812 assert!(!pwm.path.contains(&me));
813 }
814
815 let rev = selector
816 .select_path(dest, me, RoutingOptions::MAX_INTERMEDIATE_HOPS)
817 .context("reverse 3-hop path")?;
818 assert!(!rev.is_empty());
819 for pwm in &rev {
820 assert_eq!(pwm.path.len(), 4, "reverse: [c, b, a, me]");
821 assert_eq!(pwm.path.last(), Some(&me));
822 assert!(!pwm.path.contains(&dest));
823 }
824
825 Ok(())
826 }
827
828 #[tokio::test]
829 async fn selector_should_reject_extended_path_containing_destination() -> anyhow::Result<()> {
830 let me = pubkey(&SECRET_0);
835 let relay = pubkey(&SECRET_1);
836 let dest = pubkey(&SECRET_2);
837 let graph = ChannelGraph::new(me);
838 graph.add_node(relay);
839 graph.add_node(dest);
840 graph.add_edge(&me, &dest).unwrap();
842 mark_edge_full(&graph, &me, &dest);
843 graph.add_edge(&me, &relay).unwrap();
845 mark_edge_full(&graph, &me, &relay);
846 graph.add_edge(&dest, &relay).unwrap();
848 graph.add_edge(&relay, &me).unwrap();
849 mark_edge_full(&graph, &dest, &relay);
850 mark_edge_full(&graph, &relay, &me);
851
852 let selector = test_selector(me, graph, MAX_PATHS);
853
854 let fwd = selector
855 .select_path(me, dest, 1)
856 .context("forward path with dest as direct neighbor")?;
857 assert!(!fwd.is_empty(), "should find at least one path via relay");
858 for pwm in &fwd {
859 assert_eq!(pwm.path.len(), 2, "path must be [relay, dest]");
860 assert_eq!(pwm.path[0], relay, "first node must be relay, not dest");
861 assert_eq!(pwm.path[1], dest);
862 }
863 Ok(())
864 }
865
866 #[tokio::test]
867 async fn selector_should_reject_one_hop_path_where_relay_equals_destination() -> anyhow::Result<()> {
868 let me = pubkey(&SECRET_0);
871 let relay = pubkey(&SECRET_1);
872 let dest = pubkey(&SECRET_2);
873 let graph = ChannelGraph::new(me);
874 graph.add_node(relay);
875 graph.add_node(dest);
876 graph.add_edge(&me, &dest).unwrap();
878 mark_edge_full(&graph, &me, &dest);
879 graph.add_edge(&me, &relay).unwrap();
881 mark_edge_full(&graph, &me, &relay);
882 graph.add_edge(&dest, &relay).unwrap();
884 graph.add_edge(&relay, &me).unwrap();
885 mark_edge_full(&graph, &dest, &relay);
886 mark_edge_full(&graph, &relay, &me);
887
888 let selector = test_selector(me, graph, MAX_PATHS);
889
890 let fwd = selector
891 .select_path(me, dest, 1)
892 .context("forward path — dest is direct neighbor, relay is intermediate")?;
893 assert!(!fwd.is_empty(), "should find path via relay (virtual last hop)");
894 for pwm in &fwd {
895 assert_eq!(pwm.path[0], relay, "intermediate must be relay, not dest");
896 assert_ne!(pwm.path[0], dest, "dest must not appear as intermediate");
897 }
898 Ok(())
899 }
900
901 #[tokio::test]
902 async fn selector_should_skip_zero_cost_paths() -> anyhow::Result<()> {
903 let me = pubkey(&SECRET_0);
905 let hop = pubkey(&SECRET_1);
906 let dest = pubkey(&SECRET_2);
907 let graph = ChannelGraph::new(me);
908 graph.add_node(hop);
909 graph.add_node(dest);
910 graph.add_edge(&me, &hop).context("adding edge me -> hop")?;
911 graph.add_edge(&hop, &dest).context("adding edge hop -> dest")?;
912 let selector = test_selector(me, graph, MAX_PATHS);
915
916 let err = selector
917 .select_path(me, dest, 1)
918 .expect_err("zero-cost paths should be filtered out");
919 anyhow::ensure!(
920 matches!(err, PathPlannerError::Path(PathError::PathNotFound(..))),
921 "expected PathNotFound, got: {err}"
922 );
923 Ok(())
924 }
925
926 fn make_path_with_latency(latency_ms: Option<u32>) -> PathWithMetrics {
929 PathWithMetrics {
930 path: vec![],
931 cost: 1.0,
932 total_latency_ms: latency_ms,
933 min_probe_success_rate: None,
934 min_ack_rate: None,
935 capacity_floor: None,
936 }
937 }
938
939 fn make_path_with_capacity(latency_ms: Option<u32>, capacity_floor: Option<u128>) -> PathWithMetrics {
940 PathWithMetrics {
941 path: vec![],
942 cost: 1.0,
943 total_latency_ms: latency_ms,
944 min_probe_success_rate: None,
945 min_ack_rate: None,
946 capacity_floor,
947 }
948 }
949
950 #[test]
951 fn prune_keeps_all_when_below_floor() {
952 let candidates: Vec<_> = (0..5).map(|i| make_path_with_latency(Some(i * 10))).collect();
953 let result = prune_for_consistency(candidates, 8, 1);
954 assert_eq!(result.len(), 5, "below floor: nothing should be dropped");
955 }
956
957 #[test]
958 fn prune_drops_high_latency_first() {
959 let candidates: Vec<_> = (0..30u32)
961 .map(|i| make_path_with_capacity(Some(i * 10), Some(1_000_000)))
962 .collect();
963 let result = prune_for_consistency(candidates, 8, 1);
964 assert_eq!(result.len(), 8);
965 for p in &result {
966 assert!(p.total_latency_ms.unwrap() < 80, "only the 8 lowest should survive");
967 }
968 }
969
970 #[test]
971 fn prune_preserves_populated_paths_over_unpopulated() {
972 let mut candidates: Vec<_> = vec![
976 make_path_with_capacity(Some(10), Some(1_000)),
977 make_path_with_capacity(Some(30), Some(1_000)),
978 make_path_with_capacity(Some(20), Some(1_000)),
979 ];
980 candidates.extend((0..6).map(|_| make_path_with_latency(None)));
981 let result = prune_for_consistency(candidates, 8, 1);
982 assert_eq!(result.len(), 8);
983 let populated: Vec<_> = result.iter().filter(|p| p.total_latency_ms.is_some()).collect();
985 assert_eq!(populated.len(), 3);
986 assert!(populated.iter().any(|p| p.total_latency_ms == Some(10)));
987 assert!(populated.iter().any(|p| p.total_latency_ms == Some(20)));
988 assert!(populated.iter().any(|p| p.total_latency_ms == Some(30)));
989 }
990
991 #[test]
992 fn prune_drops_unpopulated_when_all_populated_exhausted() {
993 let candidates: Vec<_> = (0..20).map(|_| make_path_with_latency(None)).collect();
995 let result = prune_for_consistency(candidates, 8, 1);
996 assert_eq!(result.len(), 8);
997 }
998
999 #[test]
1000 fn prune_keeps_populated_when_unpopulated_exceeds_floor() {
1001 let mut candidates: Vec<_> = vec![
1005 make_path_with_capacity(Some(10), Some(1_000)),
1006 make_path_with_capacity(Some(20), Some(1_000)),
1007 ];
1008 candidates.extend((0..10).map(|_| make_path_with_latency(None)));
1009 let result = prune_for_consistency(candidates, 8, 1);
1010 assert_eq!(result.len(), 8);
1011 let populated: Vec<_> = result.iter().filter(|p| p.total_latency_ms.is_some()).collect();
1012 assert_eq!(populated.len(), 2, "both measured paths must survive");
1013 assert!(populated.iter().any(|p| p.total_latency_ms == Some(10)));
1014 assert!(populated.iter().any(|p| p.total_latency_ms == Some(20)));
1015 }
1016
1017 #[test]
1018 fn prune_exact_floor_is_unchanged() {
1019 let candidates: Vec<_> = (0..8)
1020 .map(|i| make_path_with_capacity(Some(i * 10), Some(1_000)))
1021 .collect();
1022 let result = prune_for_consistency(candidates, 8, 1);
1023 assert_eq!(result.len(), 8);
1024 }
1025
1026 #[test]
1027 fn prune_0_hop_with_measured_latency_and_no_capacity_is_populated() {
1028 let mut candidates: Vec<_> = vec![
1031 make_path_with_capacity(Some(50), None), ];
1033 candidates.extend((0..10).map(|_| make_path_with_latency(None)));
1034 let result = prune_for_consistency(candidates, 8, 0);
1035 assert_eq!(result.len(), 8);
1036 let has_0_hop = result.iter().any(|p| p.total_latency_ms == Some(50));
1038 assert!(has_0_hop, "0-hop path with measured latency must survive pruning");
1039 }
1040
1041 #[test]
1042 fn prune_multi_hop_without_capacity_floor_is_unpopulated() {
1043 let candidates: Vec<_> = vec![
1046 make_path_with_capacity(Some(50), Some(1_000)), make_path_with_capacity(Some(50), Some(1_000)), make_path_with_capacity(Some(50), Some(1_000)), make_path_with_capacity(Some(50), Some(1_000)), make_path_with_capacity(Some(50), Some(1_000)), make_path_with_capacity(Some(50), Some(1_000)), make_path_with_capacity(Some(50), Some(1_000)), make_path_with_capacity(Some(50), Some(1_000)), make_path_with_capacity(Some(40), None), ];
1056 let result = prune_for_consistency(candidates, 8, 1);
1057 assert_eq!(result.len(), 8);
1058 let has_missing_cap = result.iter().any(|p| p.capacity_floor.is_none());
1061 assert!(
1062 !has_missing_cap,
1063 "path without capacity floor must be pruned when fully-measured paths fill the floor"
1064 );
1065 }
1066
1067 #[test]
1068 fn prune_for_consistency_floor_zero_returns_all() {
1069 let candidates = vec![
1071 make_path_with_capacity(Some(10), Some(1_000)),
1072 make_path_with_capacity(Some(20), None),
1073 make_path_with_capacity(None, None),
1074 ];
1075 let result = prune_for_consistency(candidates, 0, 1);
1076 assert_eq!(result.len(), 3, "floor=0 must return all candidates");
1077 }
1078
1079 #[tokio::test]
1082 async fn path_metrics_aggregate_latency_correctly() -> anyhow::Result<()> {
1083 let me = pubkey(&SECRET_0);
1086 let a = pubkey(&SECRET_1);
1087 let b = pubkey(&SECRET_2);
1088 let dest = pubkey(&SECRET_3);
1089 let graph = ChannelGraph::new(me);
1090 for n in [a, b, dest] {
1091 graph.add_node(n);
1092 }
1093
1094 let make_edge = |src: &OffchainPublicKey, dst: &OffchainPublicKey, lat_ms: u64| {
1095 graph.upsert_edge(src, dst, |obs| {
1096 obs.record(EdgeWeightType::Connected(true));
1097 obs.record(EdgeWeightType::Immediate(Ok(Duration::from_millis(lat_ms))));
1098 obs.record(EdgeWeightType::Capacity(Some(1000)));
1099 });
1100 };
1101
1102 for _ in 0..20 {
1104 make_edge(&me, &a, 30);
1105 make_edge(&a, &b, 40);
1106 make_edge(&b, &dest, 50);
1107 }
1108
1109 graph.add_edge(&me, &a).unwrap();
1111 graph.add_edge(&a, &b).unwrap();
1112 graph.add_edge(&b, &dest).unwrap();
1113
1114 let selector = test_selector(me, graph, MAX_PATHS);
1115 let paths = selector.select_path(me, dest, 2).context("forward 2-hop path")?;
1116 assert!(!paths.is_empty());
1117
1118 let total = paths[0].total_latency_ms.expect("latency must be Some");
1119 assert!(
1120 (100..=130).contains(&total),
1121 "expected ~120ms total latency, got {total}ms"
1122 );
1123 Ok(())
1124 }
1125
1126 #[tokio::test]
1127 async fn path_metrics_capacity_floor_is_min() -> anyhow::Result<()> {
1128 let me = pubkey(&SECRET_0);
1129 let hop = pubkey(&SECRET_1);
1130 let dest = pubkey(&SECRET_2);
1131 let graph = ChannelGraph::new(me);
1132 graph.add_node(hop);
1133 graph.add_node(dest);
1134
1135 graph.upsert_edge(&me, &hop, |obs| {
1136 obs.record(EdgeWeightType::Connected(true));
1137 obs.record(EdgeWeightType::Intermediate(Ok(Duration::from_millis(50))));
1138 obs.record(EdgeWeightType::Capacity(Some(500)));
1139 });
1140 graph.upsert_edge(&hop, &dest, |obs| {
1141 obs.record(EdgeWeightType::Connected(true));
1142 obs.record(EdgeWeightType::Intermediate(Ok(Duration::from_millis(50))));
1143 obs.record(EdgeWeightType::Capacity(Some(200)));
1144 });
1145 graph.add_edge(&me, &hop).unwrap();
1146 graph.add_edge(&hop, &dest).unwrap();
1147
1148 let selector = test_selector(me, graph, MAX_PATHS);
1149 let paths = selector.select_path(me, dest, 1).context("1-hop path")?;
1150 assert!(!paths.is_empty());
1151 assert_eq!(
1152 paths[0].capacity_floor,
1153 Some(200),
1154 "floor must be the smaller of 500 and 200"
1155 );
1156 Ok(())
1157 }
1158
1159 }