1use std::{sync::Arc, time::Duration};
2
3use futures::{StreamExt as _, TryStreamExt, stream::FuturesUnordered};
4#[cfg(all(feature = "telemetry", not(test)))]
5use hopr_api::types::internal::path::Path;
6use hopr_api::{
7 OffchainPublicKey,
8 chain::{ChainKeyOperations, ChainPathResolver, ChainReadChannelOperations},
9 types::{
10 crypto::crypto_traits::Randomizable,
11 internal::{errors::PathError, prelude::*},
12 primitive::traits::ToHex,
13 },
14};
15use hopr_crypto_packet::prelude::*;
16use hopr_protocol_hopr::{FoundSurb, SurbStore};
17use tracing::trace;
18use validator::{Validate, ValidationError};
19
20use super::{
21 errors::{PathPlannerError, Result},
22 traits::{BackgroundPathCacheRefreshable, PathSelector, PathWithMetrics},
23};
24
25#[cfg(all(feature = "telemetry", not(test)))]
26lazy_static::lazy_static! {
27 static ref METRIC_PATH_LENGTH: hopr_api::types::telemetry::SimpleHistogram = hopr_api::types::telemetry::SimpleHistogram::new(
28 "hopr_path_length",
29 "Distribution of number of hops of sent messages",
30 vec![0.0, 1.0, 2.0, 3.0, 4.0]
31 ).unwrap();
32}
33
34fn validate_weight_temper(temper: f64) -> std::result::Result<(), ValidationError> {
43 if temper > 0.0 && temper <= 1.0 {
44 return Ok(());
45 }
46 let mut err = ValidationError::new("weight_temper_out_of_range");
47 err.message = Some(
48 format!(
49 "return_path_weight_temper ({temper}) must be in (0, 1]: 1.0 samples by raw path value, smaller values \
50 flatten towards uniform"
51 )
52 .into(),
53 );
54 Err(err)
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, smart_default::SmartDefault, Validate)]
59pub struct PathPlannerConfig {
60 #[default = 10_000]
62 pub max_cache_capacity: u64,
63 #[default(Duration::from_secs(10))]
72 pub cache_ttl: Duration,
73 #[default(Duration::from_secs(5))]
78 pub refresh_period: Duration,
79 #[default = 50]
82 pub max_cached_paths: usize,
83 #[default = 0.5]
87 #[validate(custom(function = "validate_unit_interval"))]
88 pub edge_penalty: f64,
89 #[default = 0.1]
93 #[validate(custom(function = "validate_unit_interval"))]
94 pub min_ack_rate: f64,
95 #[default = 8]
101 pub min_paths_anonymity_floor: usize,
102 #[default(Duration::from_millis(100))]
105 pub latency_halflife: Duration,
106 #[default = 10_000_000]
110 pub capacity_reference: u128,
111 #[validate(custom(function = "validate_weight_temper"))]
125 #[default = 0.5]
126 pub return_path_weight_temper: f64,
127 #[validate(custom(function = "validate_unit_interval"))]
140 #[default = 0.1]
141 pub return_path_exploration: f64,
142 #[default(Duration::from_secs(30))]
146 pub max_plausible_loopback_rtt: Duration,
147}
148
149fn validate_unit_interval(value: f64) -> std::result::Result<(), ValidationError> {
150 if value.is_finite() && (0.0..=1.0).contains(&value) {
151 Ok(())
152 } else {
153 Err(ValidationError::new("value must be finite and in 0.0..=1.0"))
154 }
155}
156
157#[derive(Debug, Clone, Copy)]
159struct WeightingParams {
160 latency_halflife: Duration,
161 capacity_reference: u128,
162}
163
164fn latency_factor(latency: Duration, halflife: Duration) -> f64 {
168 let ms = latency.as_millis() as f64;
169 let h = halflife.as_millis().max(1) as f64;
170 1.0 / (1.0 + ms / h)
171}
172
173fn capacity_factor(c: u128, reference: u128) -> f64 {
178 let log = (c as f64).max(1.0).log10();
179 let ref_log = (reference as f64).max(10.0).log10();
180 (log / ref_log).clamp(0.05, 1.0)
181}
182
183fn composite_weight(pwc: &PathWithMetrics, hops: usize, params: WeightingParams) -> f64 {
191 let lat = pwc
192 .total_latency_ms
193 .map(|ms| latency_factor(Duration::from_millis(ms as u64), params.latency_halflife))
194 .unwrap_or(1.0);
195 let cap = if hops == 0 {
196 1.0
197 } else {
198 pwc.fundable_tickets_floor
199 .map(|c| capacity_factor(c, params.capacity_reference))
200 .unwrap_or(1.0)
201 };
202 pwc.cost * lat * cap
203}
204
205fn pick_weighted_index(weights: &[f64]) -> Option<usize> {
211 let total: f64 = weights.iter().map(|w| w.max(0.0)).sum();
212 if total <= 0.0 {
213 return None;
214 }
215
216 let r = hopr_api::types::crypto_random::random_float_in_range(0.0..total);
218 let mut cumulative = 0.0;
219 for (i, w) in weights.iter().enumerate() {
220 cumulative += w.max(0.0);
221 if r < cumulative {
222 return Some(i);
223 }
224 }
225
226 weights.iter().rposition(|w| *w > 0.0)
228}
229
230fn should_explore(exploration: f64) -> bool {
237 exploration > 0.0 && hopr_api::types::crypto_random::random_float_in_range(0.0..1.0) < exploration
238}
239
240fn pick_uniform_index(len: usize) -> Option<usize> {
242 (len > 0).then(|| (hopr_api::types::crypto_random::random_float_in_range(0.0..len as f64) as usize).min(len - 1))
243}
244
245fn temper_weights(weights: &[f64], temper: f64) -> Vec<f64> {
252 weights.iter().map(|w| w.max(0.0).powf(temper)).collect()
253}
254
255#[allow(clippy::too_many_arguments)]
266async fn rebuild_candidates<R, S>(
267 resolver: &R,
268 selector: &S,
269 weighting: WeightingParams,
270 me: OffchainPublicKey,
271 src_key: OffchainPublicKey,
272 dest_key: OffchainPublicKey,
273 hops: usize,
274 kind: &'static str,
275) -> Result<Option<hopr_utils::statistics::WeightedCollection<ValidatedPath>>>
276where
277 R: ChainKeyOperations + ChainReadChannelOperations + Send + Sync,
278 S: PathSelector,
279{
280 let candidates = selector.select_path(src_key, dest_key, hops)?;
281
282 let chain_resolver = ChainPathResolver::from(resolver);
283 let mut valid_paths: Vec<(ValidatedPath, f64)> = Vec::with_capacity(candidates.len());
284 let mut path_metrics: Vec<PathWithMetrics> = Vec::with_capacity(candidates.len());
285 for mut pwc in candidates {
286 let path_nodes = std::mem::take(&mut pwc.path);
287 let node_ids: Vec<NodeId> = path_nodes.into_iter().map(NodeId::Offchain).collect::<Vec<_>>();
288 match ValidatedPath::new(NodeId::Offchain(src_key), node_ids, &chain_resolver).await {
289 Ok(vp) => {
290 valid_paths.push((vp, composite_weight(&pwc, hops, weighting)));
291 path_metrics.push(pwc);
292 }
293 Err(e) => tracing::debug!(kind, error = %e, "path candidate failed validation"),
294 }
295 }
296
297 if src_key != me && hops > 0 && !path_metrics.is_empty() {
304 let relayers = super::selector::distinct_first_relayers(&path_metrics);
305 if relayers <= 1 {
306 tracing::warn!(
307 src = %src_key,
308 kind,
309 distinct_relayers = relayers,
310 candidates = path_metrics.len(),
311 "return-path relayer diversity collapsed to a single relayer; degradation detection \
312 cannot corroborate a dead relayer for this destination",
313 );
314 }
315 }
316
317 if valid_paths.is_empty() {
318 return Ok(None);
319 }
320
321 let weighted = hopr_utils::statistics::WeightedCollection::new(valid_paths);
322 let total_wt = weighted.total_weight();
323 for ((vp, w), pwm) in weighted.iter().zip(path_metrics.iter()) {
324 tracing::debug!(
325 kind,
326 destination = %dest_key,
327 hops,
328 path = %vp,
329 cost = pwm.cost,
330 composite_weight = w,
331 sampling_probability = if total_wt > 0.0 && *w > 0.0 { *w / total_wt } else { 0.0 },
332 total_latency_ms = ?pwm.total_latency_ms,
333 min_probe_success_rate = ?pwm.min_probe_success_rate,
334 min_ack_rate = ?pwm.min_ack_rate,
335 fundable_tickets_floor = ?pwm.fundable_tickets_floor,
336 "weighted candidate path",
337 );
338 }
339 Ok(Some(weighted))
340}
341
342const MIN_SHARE_SHIFT: f64 = 0.01;
348
349fn shares_by_route(paths: &hopr_utils::statistics::WeightedCollection<ValidatedPath>) -> Vec<(String, f64)> {
351 let total: f64 = paths.iter().map(|(_, w)| w.max(0.0)).sum();
352 paths
353 .iter()
354 .map(|(vp, w)| {
355 let share = if total > 0.0 { w.max(0.0) / total } else { 0.0 };
356 (vp.to_string(), share)
357 })
358 .collect()
359}
360
361fn weights_moved(
368 before: &hopr_utils::statistics::WeightedCollection<ValidatedPath>,
369 after: &hopr_utils::statistics::WeightedCollection<ValidatedPath>,
370) -> bool {
371 let (before, after) = (shares_by_route(before), shares_by_route(after));
372 if before.len() != after.len() {
373 return true;
374 }
375 before.iter().any(|(route, was)| {
376 after
378 .iter()
379 .find(|(other, _)| other == route)
380 .is_none_or(|(_, now)| (now - was).abs() >= MIN_SHARE_SHIFT)
381 })
382}
383
384type PlannerCacheKey = (OffchainPublicKey, OffchainPublicKey, u32);
395type PlannerCacheValue = Arc<hopr_utils::statistics::WeightedCollection<ValidatedPath>>;
396
397#[derive(Clone)]
411pub struct PathPlanner<Surb, R, S> {
412 me: OffchainPublicKey,
413 pub surb_store: Surb,
414 resolver: Arc<R>,
415 selector: Arc<S>,
416 cache: moka::future::Cache<PlannerCacheKey, PlannerCacheValue>,
417 refresh_period: Duration,
418 weighting: WeightingParams,
419 return_path_weight_temper: f64,
420 return_path_exploration: f64,
421}
422
423impl<Surb, R, S> PathPlanner<Surb, R, S>
424where
425 Surb: SurbStore + Send + Sync + 'static,
426 R: ChainKeyOperations + ChainReadChannelOperations + Send + Sync + 'static,
427 S: PathSelector + Send + Sync + 'static,
428{
429 pub fn new(me: OffchainPublicKey, surb_store: Surb, resolver: R, selector: S, config: PathPlannerConfig) -> Self {
433 let cache = moka::future::Cache::builder()
434 .max_capacity(config.max_cache_capacity)
435 .time_to_live(config.cache_ttl)
436 .build();
437
438 Self {
439 me,
440 surb_store,
441 resolver: Arc::new(resolver),
442 selector: Arc::new(selector),
443 cache,
444 refresh_period: config.refresh_period,
445 weighting: WeightingParams {
446 latency_halflife: config.latency_halflife,
447 capacity_reference: config.capacity_reference,
448 },
449 return_path_weight_temper: config.return_path_weight_temper,
450 return_path_exploration: config.return_path_exploration,
451 }
452 }
453
454 async fn resolve_node_id_to_offchain_key(&self, node_id: &NodeId) -> Result<OffchainPublicKey> {
456 match node_id {
457 NodeId::Offchain(key) => Ok(*key),
458 NodeId::Chain(addr) => {
459 let resolver = ChainPathResolver::from(&*self.resolver);
460 resolver
461 .resolve_transport_address(addr)
462 .await
463 .map_err(|e| PathPlannerError::Other(anyhow::anyhow!("{e}")))?
464 .ok_or_else(|| {
465 PathPlannerError::Other(anyhow::anyhow!("no offchain key found for chain address {addr}"))
466 })
467 }
468 }
469 }
470
471 #[tracing::instrument(level = "trace", skip(self))]
472 async fn resolve_path(
473 &self,
474 source: NodeId,
475 destination: NodeId,
476 options: RoutingOptions,
477 ) -> Result<ValidatedPath> {
478 let path = match options {
479 RoutingOptions::IntermediatePath(explicit_path) => {
480 tracing::debug!(
481 direction = "loopback",
482 ?source,
483 ?destination,
484 ?explicit_path,
485 "resolving intermediate path"
486 );
487 let resolver = ChainPathResolver::from(&*self.resolver);
488 ValidatedPath::new(
489 source,
490 explicit_path
491 .into_iter()
492 .chain(std::iter::once(destination))
493 .collect::<Vec<_>>(),
494 &resolver,
495 )
496 .await?
497 }
498
499 RoutingOptions::Hops(hops) if u32::from(hops) == 0 => {
500 trace!(hops = 0, "resolving zero-hop direct path");
501 let resolver = ChainPathResolver::from(&*self.resolver);
502 ValidatedPath::new(source, vec![destination], &resolver).await?
503 }
504
505 RoutingOptions::Hops(hops) => {
506 let hops_usize: usize = hops.into();
507 let paths = self.cached_paths(source, destination, hops).await?;
508
509 paths.pick_one().ok_or_else(|| {
512 PathPlannerError::Path(PathError::PathNotFound(
513 hops_usize,
514 source.to_string(),
515 destination.to_string(),
516 ))
517 })?
518 }
519 };
520
521 #[cfg(all(feature = "telemetry", not(test)))]
522 {
523 hopr_api::types::telemetry::SimpleHistogram::observe(&METRIC_PATH_LENGTH, (path.num_hops() - 1) as f64);
524 }
525
526 trace!(%path, "validated resolved path");
527 Ok(path)
528 }
529
530 #[tracing::instrument(level = "trace", skip(self))]
537 async fn cached_paths(
538 &self,
539 source: NodeId,
540 destination: NodeId,
541 hops: hopr_api::types::primitive::bounded::BoundedSize<{ RoutingOptions::MAX_INTERMEDIATE_HOPS }>,
542 ) -> Result<PlannerCacheValue> {
543 let hops_usize: usize = hops.into();
544 trace!(hops = hops_usize, "resolving path via planner cache");
545
546 let src_key = self.resolve_node_id_to_offchain_key(&source).await?;
547 let dest_key = self.resolve_node_id_to_offchain_key(&destination).await?;
548
549 let cache_key: PlannerCacheKey = (src_key, dest_key, u32::from(hops));
550
551 let resolver = self.resolver.clone();
552 let selector = self.selector.clone();
553 let weighting = self.weighting;
554 let me = self.me;
555
556 self.cache
557 .try_get_with(cache_key, async move {
558 trace!(hops = hops_usize, "path cache miss, querying selector");
559 rebuild_candidates(
560 &*resolver, &*selector, weighting, me, src_key, dest_key, hops_usize, "fill",
561 )
562 .await?
563 .map(Arc::new)
564 .ok_or_else(|| {
565 PathPlannerError::Path(PathError::PathNotFound(hops_usize, src_key.to_hex(), dest_key.to_hex()))
566 })
567 })
568 .await
569 .map_err(PathPlannerError::CacheError)
570 }
571
572 pub async fn recompute_paths_from(&self, source: &OffchainPublicKey) -> usize {
588 let keys = self
590 .cache
591 .iter()
592 .map(|(key, _)| *key.as_ref())
593 .filter(|(src, _, hops)| src == source && *hops > 0)
594 .collect::<Vec<PlannerCacheKey>>();
595
596 let mut moved = 0usize;
597 for (src_key, dest_key, hops) in keys {
598 if let Ok(Some(weighted)) = rebuild_candidates(
599 &*self.resolver,
600 &*self.selector,
601 self.weighting,
602 self.me,
603 src_key,
604 dest_key,
605 hops as usize,
606 "recompute",
607 )
608 .await
609 {
610 let key = (src_key, dest_key, hops);
611 let shifted = match self.cache.get(&key).await {
614 Some(previous) => weights_moved(&previous, &weighted),
615 None => true,
616 };
617 self.cache.insert(key, Arc::new(weighted)).await;
618 if shifted {
619 moved += 1;
620 }
621 }
622 }
623
624 tracing::debug!(%source, moved, "recomputed cached paths originating at peer");
625 moved
626 }
627
628 #[tracing::instrument(level = "trace", skip(self))]
643 async fn resolve_diverse_return_paths(
644 &self,
645 destination: NodeId,
646 options: RoutingOptions,
647 count: usize,
648 ) -> Result<Vec<ValidatedPath>> {
649 if count == 0 {
653 return Ok(Vec::new());
654 }
655
656 let me = NodeId::Offchain(self.me);
657
658 let hops = match options {
659 RoutingOptions::Hops(hops) if u32::from(hops) > 0 => hops,
660 other => {
662 return (0..count)
663 .map(|_| self.resolve_path(destination, me, other.clone()))
664 .collect::<FuturesUnordered<_>>()
665 .try_collect::<Vec<_>>()
666 .await;
667 }
668 };
669
670 let candidates = self.cached_paths(destination, me, hops).await?;
671 let items: Vec<&(ValidatedPath, f64)> = candidates.iter().collect();
672 let weights = temper_weights(
673 &items.iter().map(|(_, w)| *w).collect::<Vec<_>>(),
674 self.return_path_weight_temper,
675 );
676
677 if weights.iter().all(|w| *w <= 0.0) {
678 return Err(PathPlannerError::Path(PathError::PathNotFound(
679 hops.into(),
680 destination.to_string(),
681 me.to_string(),
682 )));
683 }
684
685 let log_relayer_diversity = tracing::enabled!(tracing::Level::DEBUG);
691 let candidate_relayers = log_relayer_diversity.then(|| {
692 items
693 .iter()
694 .filter_map(|item| item.0.first())
695 .collect::<std::collections::HashSet<_>>()
696 });
697
698 tracing::debug!(
699 %destination,
700 count,
701 candidates = items.len(),
702 distinct_relayers = candidate_relayers.as_ref().map_or(0, |r| r.len()),
703 temper = self.return_path_weight_temper,
704 exploration = self.return_path_exploration,
705 "drawing return paths from tempered weights"
706 );
707
708 let drawn = (0..count)
709 .filter_map(|_| {
710 if should_explore(self.return_path_exploration) {
711 pick_uniform_index(items.len())
712 } else {
713 pick_weighted_index(&weights)
714 }
715 .map(|i| items[i].0.clone())
716 })
717 .collect::<Vec<_>>();
718
719 let drawn_relayers = log_relayer_diversity.then(|| {
723 drawn
724 .iter()
725 .filter_map(|vp| vp.first())
726 .collect::<std::collections::HashSet<_>>()
727 });
728 tracing::debug!(
729 %destination,
730 drawn = drawn.len(),
731 distinct_relayers = drawn_relayers.as_ref().map_or(0, |r| r.len()),
732 of_candidates = candidate_relayers.as_ref().map_or(0, |r| r.len()),
733 "drew return paths"
734 );
735
736 Ok(drawn)
737 }
738
739 #[tracing::instrument(level = "trace", skip(self))]
743 pub async fn resolve_routing(
744 &self,
745 size_hint: usize,
746 max_surbs: usize,
747 routing: DestinationRouting,
748 ) -> Result<(ResolvedTransportRouting<HoprSurb>, Option<usize>)> {
749 match routing {
750 DestinationRouting::Forward {
751 destination,
752 pseudonym,
753 forward_options,
754 return_options,
755 } => {
756 tracing::debug!(direction = "forward", %destination, "resolving forward path");
757
758 let forward_path = self
759 .resolve_path(NodeId::Offchain(self.me), *destination, forward_options)
760 .await?;
761 tracing::debug!(direction = "forward", %destination, path = %forward_path, "resolved path");
762
763 let return_paths = if let Some(return_options) = return_options {
764 let num_possible_surbs = HoprPacket::max_surbs_with_message(size_hint).min(max_surbs);
765 trace!(
766 %destination,
767 %num_possible_surbs,
768 data_len = size_hint,
769 max_surbs,
770 "resolving packet return paths"
771 );
772
773 self.resolve_diverse_return_paths(*destination, return_options, num_possible_surbs)
774 .await?
775 .into_iter()
776 .enumerate()
777 .inspect(|(i, rp)| {
778 tracing::debug!(direction = "return", %destination, index = i, path = %rp, "resolved return path");
779 })
780 .map(|(_, rp)| rp)
781 .collect()
782 } else {
783 vec![]
784 };
785
786 trace!(%destination, num_surbs = return_paths.len(), data_len = size_hint, "resolved packet");
787
788 Ok((
789 ResolvedTransportRouting::Forward {
790 pseudonym: pseudonym.unwrap_or_else(HoprPseudonym::random),
791 forward_path,
792 return_paths,
793 },
794 None,
795 ))
796 }
797
798 DestinationRouting::Return(matcher) => {
799 let FoundSurb {
800 sender_id,
801 surb,
802 remaining,
803 } = self
804 .surb_store
805 .find_surb(matcher)
806 .ok_or_else(|| PathPlannerError::Surb(format!("no surb for pseudonym {}", matcher.pseudonym())))?;
807 Ok((ResolvedTransportRouting::Return(sender_id, surb), Some(remaining)))
808 }
809 }
810 }
811}
812
813impl<Surb, R, S> BackgroundPathCacheRefreshable for PathPlanner<Surb, R, S>
814where
815 Surb: SurbStore + Send + Sync + 'static,
816 R: ChainKeyOperations + ChainReadChannelOperations + Send + Sync + 'static,
817 S: PathSelector + Send + Sync + 'static,
818{
819 fn run_background_refresh(&self) -> impl std::future::Future<Output = ()> + Send + 'static {
825 let cache = self.cache.clone();
827 let resolver = self.resolver.clone();
828 let selector = self.selector.clone();
829 let refresh_period = self.refresh_period;
830 let weighting = self.weighting;
831 let me = self.me;
832
833 futures_time::stream::interval(futures_time::time::Duration::from_millis(
835 refresh_period.as_millis() as u64 + 1u64,
836 ))
837 .for_each(move |_| {
838 let cache = cache.clone();
839 let resolver = resolver.clone();
840 let selector = selector.clone();
841 let weighting = weighting;
842
843 async move {
844 for (key, _) in cache.iter() {
845 let (src_key, dest_key, hops_u32) = {
846 let k = key.as_ref();
847 (k.0, k.1, k.2)
848 };
849
850 if hops_u32 == 0 {
851 continue;
852 }
853
854 if let Ok(Some(weighted)) = rebuild_candidates(
857 &*resolver,
858 &*selector,
859 weighting,
860 me,
861 src_key,
862 dest_key,
863 hops_u32 as usize,
864 "background-refresh",
865 )
866 .await
867 {
868 cache.insert((src_key, dest_key, hops_u32), Arc::new(weighted)).await;
869 }
870 }
871 }
872 })
873 }
874}
875
876#[cfg(test)]
877mod tests {
878 use std::str::FromStr;
879
880 use bimap::BiMap;
881 use futures::stream::{self, BoxStream};
882 use hex_literal::hex;
883 use hopr_api::{
884 chain::{ChainKeyOperations, ChainReadChannelOperations, ChannelSelector, HoprKeyIdent},
885 graph::{NetworkGraphWrite, traits::EdgeObservableWrite},
886 types::{
887 crypto::prelude::{Keypair, OffchainKeypair},
888 internal::channels::{ChannelEntry, ChannelStatus, generate_channel_id},
889 primitive::prelude::*,
890 },
891 };
892 use hopr_network_graph::ChannelGraph;
893
894 use super::*;
895 use crate::path::selector::HoprGraphPathSelector;
896
897 #[derive(Debug)]
898 struct TestError(String);
899
900 impl std::fmt::Display for TestError {
901 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
902 f.write_str(&self.0)
903 }
904 }
905
906 impl std::error::Error for TestError {}
907
908 const SECRET_ME: [u8; 32] = hex!("60741b83b99e36aa0c1331578156e16b8e21166d01834abb6c64b103f885734d");
909 const SECRET_A: [u8; 32] = hex!("71bf1f42ebbfcd89c3e197a3fd7cda79b92499e509b6fefa0fe44d02821d146a");
910 const SECRET_DEST: [u8; 32] = hex!("c24bd833704dd2abdae3933fcc9962c2ac404f84132224c474147382d4db2299");
911 const SECRET_B: [u8; 32] = hex!("3d5f2c8a91b4e07d6a3c1f8e2b95d40c7e6a1938f2c5b8d04e7a1c396b2f8d05");
913
914 fn pubkey(secret: &[u8; 32]) -> OffchainPublicKey {
915 *OffchainKeypair::from_secret(secret).expect("valid secret").public()
916 }
917
918 #[derive(Clone)]
919 struct Mapper {
920 map: Arc<BiMap<OffchainPublicKey, HoprKeyIdent>>,
921 }
922
923 impl KeyIdMapping<HoprKeyIdent, OffchainPublicKey> for Mapper {
924 fn map_key_to_id(&self, key: &OffchainPublicKey) -> Option<HoprKeyIdent> {
925 self.map.get_by_left(key).copied()
926 }
927
928 fn map_id_to_public(&self, id: &HoprKeyIdent) -> Option<OffchainPublicKey> {
929 self.map.get_by_right(id).copied()
930 }
931
932 fn map_keys_to_ids(&self, keys: &[OffchainPublicKey]) -> Vec<Option<HoprKeyIdent>> {
933 keys.iter().map(|key| self.map_key_to_id(key)).collect()
934 }
935
936 fn map_ids_to_keys(&self, ids: &[HoprKeyIdent]) -> Vec<Option<OffchainPublicKey>> {
937 ids.iter().map(|id| self.map_id_to_public(id)).collect()
938 }
939 }
940
941 struct TestChainApi {
942 me: Address,
943 key_addr_map: BiMap<OffchainPublicKey, Address>,
944 channels: Vec<ChannelEntry>,
945 id_mapper: Mapper,
946 }
947
948 impl TestChainApi {
949 fn new(me_key: OffchainPublicKey, me_addr: Address, peers: Vec<(OffchainPublicKey, Address)>) -> Self {
950 let mut key_addr_map = BiMap::new();
951 let mut key_id_map: BiMap<OffchainPublicKey, HoprKeyIdent> = BiMap::new();
952 key_addr_map.insert(me_key, me_addr);
953 key_id_map.insert(me_key, 0u32.into());
954 for (i, (k, a)) in peers.iter().enumerate() {
955 key_addr_map.insert(*k, *a);
956 key_id_map.insert(*k, ((i + 1) as u32).into());
957 }
958 Self {
959 me: me_addr,
960 key_addr_map,
961 channels: vec![],
962 id_mapper: Mapper {
963 map: Arc::new(key_id_map),
964 },
965 }
966 }
967
968 fn with_open_channel(mut self, src: Address, dst: Address) -> Self {
969 self.channels.push(
970 ChannelEntry::builder()
971 .between(src, dst)
972 .amount(100)
973 .ticket_index(1)
974 .status(ChannelStatus::Open)
975 .epoch(1)
976 .build()
977 .unwrap(),
978 );
979 self
980 }
981 }
982
983 impl ChainKeyOperations for TestChainApi {
984 type Error = TestError;
985 type Mapper = Mapper;
986
987 fn chain_key_to_packet_key(
988 &self,
989 chain: &Address,
990 ) -> std::result::Result<Option<OffchainPublicKey>, TestError> {
991 Ok(self.key_addr_map.get_by_right(chain).copied())
992 }
993
994 fn packet_key_to_chain_key(
995 &self,
996 packet: &OffchainPublicKey,
997 ) -> std::result::Result<Option<Address>, TestError> {
998 Ok(self.key_addr_map.get_by_left(packet).copied())
999 }
1000
1001 fn key_id_mapper_ref(&self) -> &Self::Mapper {
1002 &self.id_mapper
1003 }
1004 }
1005
1006 impl ChainReadChannelOperations for TestChainApi {
1007 type Error = TestError;
1008
1009 fn me(&self) -> &Address {
1010 &self.me
1011 }
1012
1013 fn channel_by_id(&self, channel_id: &ChannelId) -> std::result::Result<Option<ChannelEntry>, TestError> {
1014 Ok(self
1015 .channels
1016 .iter()
1017 .find(|c| generate_channel_id(&c.source, &c.destination) == *channel_id)
1018 .cloned())
1019 }
1020
1021 fn stream_channels<'a>(
1022 &'a self,
1023 _selector: ChannelSelector,
1024 ) -> std::result::Result<BoxStream<'a, ChannelEntry>, TestError> {
1025 Ok(Box::pin(stream::iter(self.channels.clone())))
1026 }
1027 }
1028
1029 #[test]
1030 fn exploration_should_be_off_at_zero_and_certain_at_one() {
1031 assert!((0..200).all(|_| !should_explore(0.0)));
1035 assert!((0..200).all(|_| should_explore(1.0)));
1036 }
1037
1038 #[test]
1039 fn exploration_rate_should_be_near_the_configured_fraction() {
1040 let explored = (0..10_000).filter(|_| should_explore(0.1)).count();
1041 assert!(
1044 (700..1_300).contains(&explored),
1045 "expected roughly 1000 of 10000 draws to explore, got {explored}"
1046 );
1047 }
1048
1049 #[test]
1050 fn uniform_index_should_stay_in_range_and_reach_every_candidate() {
1051 assert_eq!(None, pick_uniform_index(0), "nothing to pick from");
1052
1053 let mut seen = std::collections::HashSet::new();
1054 for _ in 0..1_000 {
1055 let i = pick_uniform_index(4).expect("non-empty");
1056 assert!(i < 4, "index {i} out of range");
1057 seen.insert(i);
1058 }
1059 assert_eq!(4, seen.len(), "every candidate must be reachable");
1060 }
1061
1062 #[test]
1063 fn exploration_should_reach_a_candidate_that_weighting_would_never_pick() {
1064 let weights = [1.0, 0.0];
1067 assert!(
1068 (0..200).all(|_| pick_weighted_index(&weights) == Some(0)),
1069 "a zero weight is never drawn by weight"
1070 );
1071 assert!(
1072 (0..1_000).any(|_| pick_uniform_index(weights.len()) == Some(1)),
1073 "an exploratory draw must be able to reach it"
1074 );
1075 }
1076
1077 #[test]
1080 fn the_path_cache_should_expire_faster_than_a_session_can_be_lost() {
1081 let cfg = PathPlannerConfig::default();
1082
1083 assert!(
1087 cfg.cache_ttl <= Duration::from_secs(15),
1088 "path cache TTL {:?} outlives the evidence that should displace it",
1089 cfg.cache_ttl
1090 );
1091 assert!(
1092 cfg.refresh_period < cfg.cache_ttl,
1093 "the background sweep ({:?}) must re-weight entries before they expire ({:?})",
1094 cfg.refresh_period,
1095 cfg.cache_ttl
1096 );
1097 }
1098
1099 #[test]
1100 fn config_should_reject_a_weight_temper_outside_the_unit_range() {
1101 assert!(PathPlannerConfig::default().validate().is_ok());
1102
1103 assert!(
1105 PathPlannerConfig {
1106 return_path_weight_temper: 1.0,
1107 ..PathPlannerConfig::default()
1108 }
1109 .validate()
1110 .is_ok()
1111 );
1112
1113 let sharpening = PathPlannerConfig {
1115 return_path_weight_temper: 1.5,
1116 ..PathPlannerConfig::default()
1117 };
1118 let err = sharpening
1120 .validate()
1121 .expect_err("a sharpening exponent must be rejected");
1122 assert!(err.to_string().contains("must be in (0, 1]"), "{err}");
1123
1124 assert!(
1126 PathPlannerConfig {
1127 return_path_weight_temper: 0.0,
1128 ..PathPlannerConfig::default()
1129 }
1130 .validate()
1131 .is_err()
1132 );
1133 }
1134
1135 #[test]
1136 fn temper_should_compress_the_weight_spread_without_reordering() {
1137 let raw = [0.4, 0.3, 0.2, 0.1];
1138
1139 let untouched = temper_weights(&raw, 1.0);
1140 assert_eq!(raw.to_vec(), untouched, "temper 1.0 must be the identity");
1141
1142 let flattened = temper_weights(&raw, 0.5);
1143 assert!(flattened.windows(2).all(|w| w[0] > w[1]), "{flattened:?}");
1145 let raw_ratio = raw[0] / raw[3];
1148 let tempered_ratio = flattened[0] / flattened[3];
1149 assert!(
1150 tempered_ratio < raw_ratio,
1151 "{tempered_ratio} should be below {raw_ratio}"
1152 );
1153 assert!((tempered_ratio - 2.0).abs() < 1e-9, "{tempered_ratio}");
1154 }
1155
1156 #[test]
1157 fn temper_should_clamp_negative_weights_instead_of_producing_nan() {
1158 let out = temper_weights(&[-1.0, 0.0, 4.0], 0.5);
1161 assert!(out.iter().all(|w| w.is_finite()), "{out:?}");
1162 assert_eq!(vec![0.0, 0.0, 2.0], out);
1163 }
1164
1165 #[test]
1166 fn pick_weighted_index_should_reject_a_non_positive_total() {
1167 assert_eq!(None, pick_weighted_index(&[]));
1168 assert_eq!(None, pick_weighted_index(&[0.0, 0.0]));
1169 assert_eq!(Some(1), pick_weighted_index(&[0.0, 1.0]));
1170 }
1171
1172 fn me_addr() -> Address {
1174 Address::from_str("0x1000d5786d9e6799b3297da1ad55605b91e2c882").expect("valid addr")
1175 }
1176 fn a_addr() -> Address {
1177 Address::from_str("0x200060ddced1e33c9647a71f4fc2cf4ed33e4a9d").expect("valid addr")
1178 }
1179 fn dest_addr() -> Address {
1180 Address::from_str("0x30004105095c8c10f804109b4d1199a9ac40ed46").expect("valid addr")
1181 }
1182 fn b_addr() -> Address {
1183 Address::from_str("0x40001a7ec3d5b28f9047c6b1e83d5a2f9c71b0e4").expect("valid addr")
1184 }
1185
1186 fn mark_edge_full(graph: &ChannelGraph, src: &OffchainPublicKey, dst: &OffchainPublicKey) {
1188 use hopr_api::graph::traits::EdgeWeightType;
1189 graph.upsert_edge(src, dst, |obs| {
1190 obs.record(EdgeWeightType::Connected(true));
1191 obs.record(EdgeWeightType::Immediate(Ok(std::time::Duration::from_millis(50))));
1192 obs.record(EdgeWeightType::Intermediate(Ok(std::time::Duration::from_millis(50))));
1193 obs.record(EdgeWeightType::Balance(Some(hopr_api::graph::traits::Balance::from(
1194 1000u64,
1195 ))));
1196 });
1197 }
1198
1199 fn small_config() -> PathPlannerConfig {
1200 PathPlannerConfig {
1201 max_cache_capacity: 100,
1202 cache_ttl: std::time::Duration::from_secs(60),
1203 refresh_period: std::time::Duration::from_secs(60),
1204 max_cached_paths: 2,
1205 ..PathPlannerConfig::default()
1206 }
1207 }
1208
1209 #[tokio::test]
1217 async fn a_return_path_should_cache_under_one_key_whichever_form_names_the_node() {
1218 let me = pubkey(&SECRET_ME);
1219 let a = pubkey(&SECRET_A);
1220 let dest = pubkey(&SECRET_DEST);
1221
1222 let graph = ChannelGraph::new(me);
1223 graph.add_node(a);
1224 graph.add_node(dest);
1225 graph.add_edge(&dest, &a).unwrap();
1227 graph.add_edge(&a, &me).unwrap();
1228 mark_edge_full(&graph, &dest, &a);
1229 mark_edge_full(&graph, &a, &me);
1230
1231 let cfg = small_config();
1232 let selector = HoprGraphPathSelector::new(
1233 me,
1234 graph,
1235 cfg.max_cached_paths,
1236 cfg.edge_penalty,
1237 cfg.min_ack_rate,
1238 cfg.min_paths_anonymity_floor,
1239 );
1240 let chain_api = TestChainApi::new(me, me_addr(), vec![(a, a_addr()), (dest, dest_addr())])
1241 .with_open_channel(dest_addr(), a_addr())
1242 .with_open_channel(a_addr(), me_addr());
1243 let surb_store = hopr_protocol_hopr::MemorySurbStore::default();
1244 let planner = PathPlanner::new(me, surb_store, chain_api, selector, small_config());
1245
1246 let _ = planner
1248 .resolve_diverse_return_paths(
1249 NodeId::Chain(dest_addr()),
1250 RoutingOptions::Hops(1.try_into().expect("valid 1")),
1251 1,
1252 )
1253 .await
1254 .expect("return path resolution should succeed");
1255
1256 let cache_key: PlannerCacheKey = (dest, me, 1);
1257 assert!(
1258 planner.cache.get(&cache_key).await.is_some(),
1259 "the return path should be cached after resolution"
1260 );
1261
1262 planner.cache.run_pending_tasks().await;
1265 assert_eq!(planner.cache.entry_count(), 1, "one resolution, one entry");
1266
1267 let _ = planner
1268 .resolve_diverse_return_paths(
1269 NodeId::Offchain(dest),
1270 RoutingOptions::Hops(1.try_into().expect("valid 1")),
1271 1,
1272 )
1273 .await
1274 .expect("return path resolution should succeed");
1275
1276 planner.cache.run_pending_tasks().await;
1277 assert_eq!(
1278 planner.cache.entry_count(),
1279 1,
1280 "naming the destination by packet key must hit the entry cached from its chain address"
1281 );
1282 }
1283
1284 #[tokio::test]
1285 async fn zero_hop_path_should_bypass_selector() {
1286 let me = pubkey(&SECRET_ME);
1287 let dest = pubkey(&SECRET_DEST);
1288
1289 let graph = ChannelGraph::new(me);
1291 let cfg = small_config();
1292 let selector = HoprGraphPathSelector::new(
1293 me,
1294 graph,
1295 cfg.max_cached_paths,
1296 cfg.edge_penalty,
1297 cfg.min_ack_rate,
1298 cfg.min_paths_anonymity_floor,
1299 );
1300
1301 let chain_api = TestChainApi::new(me, me_addr(), vec![(dest, dest_addr())]);
1302 let surb_store = hopr_protocol_hopr::MemorySurbStore::default();
1303
1304 let planner = PathPlanner::new(me, surb_store, chain_api, selector, small_config());
1305
1306 let routing = DestinationRouting::Forward {
1307 destination: Box::new(NodeId::Offchain(dest)),
1308 pseudonym: None,
1309 forward_options: RoutingOptions::Hops(0.try_into().expect("valid 0")),
1310 return_options: None,
1311 };
1312
1313 let result = planner.resolve_routing(100, 0, routing).await;
1314 assert!(result.is_ok(), "zero-hop should succeed: {:?}", result.err());
1315
1316 let (resolved, rem) = result.unwrap();
1317 assert!(rem.is_none());
1318 if let ResolvedTransportRouting::Forward { forward_path, .. } = resolved {
1319 assert_eq!(
1320 forward_path.num_hops(),
1321 1,
1322 "zero-hop = 1 node in path (just destination)"
1323 );
1324 } else {
1325 panic!("expected Forward routing");
1326 }
1327 }
1328
1329 #[tokio::test]
1332 async fn one_hop_path_should_use_selector() {
1333 let me = pubkey(&SECRET_ME);
1334 let a = pubkey(&SECRET_A);
1335 let dest = pubkey(&SECRET_DEST);
1336
1337 let graph = ChannelGraph::new(me);
1338 graph.add_node(a);
1339 graph.add_node(dest);
1340 graph.add_edge(&me, &a).unwrap();
1341 graph.add_edge(&a, &dest).unwrap();
1342 mark_edge_full(&graph, &me, &a);
1343 mark_edge_full(&graph, &a, &dest);
1344
1345 let cfg = small_config();
1346 let selector = HoprGraphPathSelector::new(
1347 me,
1348 graph,
1349 cfg.max_cached_paths,
1350 cfg.edge_penalty,
1351 cfg.min_ack_rate,
1352 cfg.min_paths_anonymity_floor,
1353 );
1354
1355 let chain_api = TestChainApi::new(me, me_addr(), vec![(a, a_addr()), (dest, dest_addr())])
1356 .with_open_channel(me_addr(), a_addr())
1357 .with_open_channel(a_addr(), dest_addr());
1358
1359 let surb_store = hopr_protocol_hopr::MemorySurbStore::default();
1360 let planner = PathPlanner::new(me, surb_store, chain_api, selector, small_config());
1361
1362 let routing = DestinationRouting::Forward {
1363 destination: Box::new(NodeId::Offchain(dest)),
1364 pseudonym: None,
1365 forward_options: RoutingOptions::Hops(1.try_into().expect("valid 1")),
1366 return_options: None,
1367 };
1368
1369 let result = planner.resolve_routing(100, 0, routing).await;
1370 assert!(result.is_ok(), "1-hop routing should succeed: {:?}", result.err());
1371
1372 let (resolved, _) = result.unwrap();
1373 if let ResolvedTransportRouting::Forward { forward_path, .. } = resolved {
1374 assert_eq!(
1375 forward_path.num_hops(),
1376 2,
1377 "1 intermediate hop means path has 2 nodes [a, dest]"
1378 );
1379 } else {
1380 panic!("expected Forward routing");
1381 }
1382 }
1383
1384 #[tokio::test]
1385 async fn explicit_intermediate_path_should_bypass_selector() {
1386 let me = pubkey(&SECRET_ME);
1387 let a = pubkey(&SECRET_A);
1388 let dest = pubkey(&SECRET_DEST);
1389
1390 let graph = ChannelGraph::new(me);
1392 let cfg = small_config();
1393 let selector = HoprGraphPathSelector::new(
1394 me,
1395 graph,
1396 cfg.max_cached_paths,
1397 cfg.edge_penalty,
1398 cfg.min_ack_rate,
1399 cfg.min_paths_anonymity_floor,
1400 );
1401
1402 let chain_api = TestChainApi::new(me, me_addr(), vec![(a, a_addr()), (dest, dest_addr())])
1403 .with_open_channel(me_addr(), a_addr())
1404 .with_open_channel(a_addr(), dest_addr());
1405
1406 let surb_store = hopr_protocol_hopr::MemorySurbStore::default();
1407 let planner = PathPlanner::new(me, surb_store, chain_api, selector, small_config());
1408
1409 use hopr_api::types::primitive::prelude::BoundedVec;
1410 let intermediate_path = BoundedVec::try_from(vec![NodeId::Offchain(a)]).expect("valid");
1411
1412 let routing = DestinationRouting::Forward {
1413 destination: Box::new(NodeId::Offchain(dest)),
1414 pseudonym: None,
1415 forward_options: RoutingOptions::IntermediatePath(intermediate_path),
1416 return_options: None,
1417 };
1418
1419 let result = planner.resolve_routing(100, 0, routing).await;
1420 assert!(result.is_ok(), "explicit path should succeed: {:?}", result.err());
1421
1422 let (resolved, _) = result.unwrap();
1423 if let ResolvedTransportRouting::Forward { forward_path, .. } = resolved {
1424 assert_eq!(forward_path.num_hops(), 2, "one intermediate + destination = 2 hops");
1425 } else {
1426 panic!("expected Forward routing");
1427 }
1428 }
1429
1430 #[tokio::test]
1431 async fn return_routing_without_surb_should_return_error() {
1432 let me = pubkey(&SECRET_ME);
1433 let graph = ChannelGraph::new(me);
1434 let cfg = small_config();
1435 let selector = HoprGraphPathSelector::new(
1436 me,
1437 graph,
1438 cfg.max_cached_paths,
1439 cfg.edge_penalty,
1440 cfg.min_ack_rate,
1441 cfg.min_paths_anonymity_floor,
1442 );
1443 let chain_api = TestChainApi::new(me, me_addr(), vec![]);
1444 let surb_store = hopr_protocol_hopr::MemorySurbStore::default();
1445
1446 let planner = PathPlanner::new(me, surb_store, chain_api, selector, small_config());
1447
1448 use hopr_api::types::internal::routing::SurbMatcher;
1449 let matcher = SurbMatcher::Pseudonym(HoprPseudonym::random());
1450 let routing = DestinationRouting::Return(matcher);
1451
1452 let result = planner.resolve_routing(0, 0, routing).await;
1453 assert!(result.is_err(), "should fail when no SURB exists");
1454 assert!(
1455 matches!(result.unwrap_err(), PathPlannerError::Surb(_)),
1456 "error should be Surb variant"
1457 );
1458 }
1459
1460 fn diversity_planner(
1464 cfg: PathPlannerConfig,
1465 ) -> PathPlanner<hopr_protocol_hopr::MemorySurbStore, TestChainApi, HoprGraphPathSelector<ChannelGraph>> {
1466 let (me, a, dest) = (pubkey(&SECRET_ME), pubkey(&SECRET_A), pubkey(&SECRET_DEST));
1467
1468 let graph = ChannelGraph::new(me);
1469 graph.add_node(a);
1470 graph.add_node(dest);
1471 graph.add_edge(&me, &a).unwrap();
1472 graph.add_edge(&a, &dest).unwrap();
1473 graph.add_edge(&dest, &a).unwrap();
1474 graph.add_edge(&a, &me).unwrap();
1475 for (from, to) in [(me, a), (a, dest), (dest, a), (a, me)] {
1476 mark_edge_full(&graph, &from, &to);
1477 }
1478
1479 let selector = HoprGraphPathSelector::new(
1480 me,
1481 graph,
1482 cfg.max_cached_paths,
1483 cfg.edge_penalty,
1484 cfg.min_ack_rate,
1485 cfg.min_paths_anonymity_floor,
1486 );
1487 let chain_api = TestChainApi::new(me, me_addr(), vec![(a, a_addr()), (dest, dest_addr())])
1488 .with_open_channel(me_addr(), a_addr())
1489 .with_open_channel(a_addr(), dest_addr())
1490 .with_open_channel(dest_addr(), a_addr())
1491 .with_open_channel(a_addr(), me_addr());
1492
1493 PathPlanner::new(
1494 me,
1495 hopr_protocol_hopr::MemorySurbStore::default(),
1496 chain_api,
1497 selector,
1498 cfg,
1499 )
1500 }
1501
1502 #[tokio::test]
1503 async fn resolve_diverse_return_paths_should_return_empty_without_querying_when_none_requested() {
1504 let planner = diversity_planner(small_config());
1505 let dest = NodeId::Offchain(pubkey(&SECRET_DEST));
1506 let hops = RoutingOptions::Hops(1.try_into().expect("valid 1"));
1507
1508 let paths = planner
1511 .resolve_diverse_return_paths(dest, hops, 0)
1512 .await
1513 .expect("zero return paths is not an error");
1514 assert!(paths.is_empty());
1515
1516 let dest_key = match dest {
1518 NodeId::Offchain(k) => k,
1519 NodeId::Chain(_) => unreachable!("the fixture names the destination by its packet key"),
1520 };
1521 let cache_key: PlannerCacheKey = (dest_key, pubkey(&SECRET_ME), 1);
1522 assert!(
1523 planner.cache.get(&cache_key).await.is_none(),
1524 "nothing requested must not query the planner"
1525 );
1526 }
1527
1528 #[tokio::test]
1529 async fn resolve_diverse_return_paths_should_return_count_paths_at_any_temper() {
1530 for temper in [1.0, 0.5, 0.05] {
1533 let planner = diversity_planner(PathPlannerConfig {
1534 return_path_weight_temper: temper,
1535 ..small_config()
1536 });
1537 let dest = NodeId::Offchain(pubkey(&SECRET_DEST));
1538 let hops = RoutingOptions::Hops(1.try_into().expect("valid 1"));
1539
1540 let paths = planner
1541 .resolve_diverse_return_paths(dest, hops, 3)
1542 .await
1543 .expect("weighted resolution should succeed");
1544 assert_eq!(3, paths.len(), "temper={temper} must still return `count` paths");
1545 }
1546 }
1547
1548 #[tokio::test]
1549 async fn resolve_diverse_return_paths_should_fall_back_for_routing_without_alternatives() {
1550 let planner = diversity_planner(small_config());
1551 let dest = NodeId::Offchain(pubkey(&SECRET_DEST));
1552
1553 let direct = planner
1555 .resolve_diverse_return_paths(dest, RoutingOptions::Hops(0.try_into().expect("valid 0")), 2)
1556 .await
1557 .expect("zero-hop return should resolve");
1558 assert_eq!(2, direct.len());
1559
1560 let explicit = planner
1562 .resolve_diverse_return_paths(
1563 dest,
1564 RoutingOptions::IntermediatePath(vec![NodeId::Offchain(pubkey(&SECRET_A))].try_into().expect("valid")),
1565 2,
1566 )
1567 .await
1568 .expect("explicit return path should resolve");
1569 assert_eq!(2, explicit.len());
1570 }
1571
1572 #[tokio::test]
1573 async fn resolve_diverse_return_paths_should_return_the_requested_count() {
1574 let planner = diversity_planner(small_config());
1575 let dest = NodeId::Offchain(pubkey(&SECRET_DEST));
1576 let hops = RoutingOptions::Hops(1.try_into().expect("valid 1"));
1577
1578 for count in [1usize, 2, 5] {
1580 let paths = planner
1581 .resolve_diverse_return_paths(dest, hops.clone(), count)
1582 .await
1583 .expect("should resolve");
1584 assert_eq!(count, paths.len(), "count={count}");
1585 }
1586 }
1587
1588 #[tokio::test]
1589 async fn planner_cache_miss_should_populate_cache() {
1590 let me = pubkey(&SECRET_ME);
1591 let a = pubkey(&SECRET_A);
1592 let dest = pubkey(&SECRET_DEST);
1593
1594 let graph = ChannelGraph::new(me);
1595 graph.add_node(a);
1596 graph.add_node(dest);
1597 graph.add_edge(&me, &a).unwrap();
1598 graph.add_edge(&a, &dest).unwrap();
1599 mark_edge_full(&graph, &me, &a);
1600 mark_edge_full(&graph, &a, &dest);
1601
1602 let cfg = small_config();
1603 let selector = HoprGraphPathSelector::new(
1604 me,
1605 graph,
1606 cfg.max_cached_paths,
1607 cfg.edge_penalty,
1608 cfg.min_ack_rate,
1609 cfg.min_paths_anonymity_floor,
1610 );
1611 let chain_api = TestChainApi::new(me, me_addr(), vec![(a, a_addr()), (dest, dest_addr())])
1612 .with_open_channel(me_addr(), a_addr())
1613 .with_open_channel(a_addr(), dest_addr());
1614 let surb_store = hopr_protocol_hopr::MemorySurbStore::default();
1615 let planner = PathPlanner::new(me, surb_store, chain_api, selector, small_config());
1616
1617 let cache_key: PlannerCacheKey = (me, dest, 1);
1618
1619 assert!(
1620 planner.cache.get(&cache_key).await.is_none(),
1621 "cache should be empty before first call"
1622 );
1623
1624 let routing = DestinationRouting::Forward {
1625 destination: Box::new(NodeId::Offchain(dest)),
1626 pseudonym: None,
1627 forward_options: RoutingOptions::Hops(1.try_into().expect("valid 1")),
1628 return_options: None,
1629 };
1630 planner.resolve_routing(100, 0, routing).await.expect("should succeed");
1631
1632 let cached = planner.cache.get(&cache_key).await;
1633 assert!(cached.is_some(), "cache should be populated after first call");
1634 let paths = cached.unwrap();
1635 assert!(!paths.is_empty(), "cached paths must be non-empty");
1636 let (first_path, first_cost) = paths.iter().next().expect("at least one cached path");
1637 assert_eq!(first_path.num_hops(), 2, "path should have 2 hops [a, dest]");
1638 assert!(*first_cost > 0.0, "cost should be positive");
1639 }
1640
1641 #[tokio::test]
1642 async fn planner_cache_hit_should_return_valid_path() {
1643 let me = pubkey(&SECRET_ME);
1644 let a = pubkey(&SECRET_A);
1645 let dest = pubkey(&SECRET_DEST);
1646
1647 let graph = ChannelGraph::new(me);
1648 graph.add_node(a);
1649 graph.add_node(dest);
1650 graph.add_edge(&me, &a).unwrap();
1651 graph.add_edge(&a, &dest).unwrap();
1652 mark_edge_full(&graph, &me, &a);
1653 mark_edge_full(&graph, &a, &dest);
1654
1655 let cfg = small_config();
1656 let selector = HoprGraphPathSelector::new(
1657 me,
1658 graph,
1659 cfg.max_cached_paths,
1660 cfg.edge_penalty,
1661 cfg.min_ack_rate,
1662 cfg.min_paths_anonymity_floor,
1663 );
1664 let chain_api = TestChainApi::new(me, me_addr(), vec![(a, a_addr()), (dest, dest_addr())])
1665 .with_open_channel(me_addr(), a_addr())
1666 .with_open_channel(a_addr(), dest_addr());
1667 let surb_store = hopr_protocol_hopr::MemorySurbStore::default();
1668 let planner = PathPlanner::new(me, surb_store, chain_api, selector, small_config());
1669
1670 let make_routing = || DestinationRouting::Forward {
1671 destination: Box::new(NodeId::Offchain(dest)),
1672 pseudonym: None,
1673 forward_options: RoutingOptions::Hops(1.try_into().expect("valid 1")),
1674 return_options: None,
1675 };
1676
1677 let (r1, _) = planner.resolve_routing(100, 0, make_routing()).await.expect("call 1");
1678 let (r2, _) = planner.resolve_routing(100, 0, make_routing()).await.expect("call 2");
1679
1680 let hops1 = if let ResolvedTransportRouting::Forward { forward_path, .. } = r1 {
1681 forward_path.num_hops()
1682 } else {
1683 panic!("expected Forward");
1684 };
1685 let hops2 = if let ResolvedTransportRouting::Forward { forward_path, .. } = r2 {
1686 forward_path.num_hops()
1687 } else {
1688 panic!("expected Forward");
1689 };
1690 assert_eq!(hops1, 2);
1691 assert_eq!(hops2, 2);
1692 }
1693
1694 #[tokio::test]
1695 async fn background_refresh_should_produce_a_future() {
1696 let me = pubkey(&SECRET_ME);
1697 let graph = ChannelGraph::new(me);
1698 let cfg = small_config();
1699 let selector = HoprGraphPathSelector::new(
1700 me,
1701 graph,
1702 cfg.max_cached_paths,
1703 cfg.edge_penalty,
1704 cfg.min_ack_rate,
1705 cfg.min_paths_anonymity_floor,
1706 );
1707 let chain_api = TestChainApi::new(me, me_addr(), vec![]);
1708 let surb_store = hopr_protocol_hopr::MemorySurbStore::default();
1709
1710 let planner = PathPlanner::new(me, surb_store, chain_api, selector, small_config());
1711 let _future = planner.run_background_refresh();
1713 }
1714
1715 type TestPlanner =
1718 PathPlanner<hopr_protocol_hopr::MemorySurbStore, TestChainApi, HoprGraphPathSelector<ChannelGraph>>;
1719
1720 fn two_relayer_return_planner() -> (TestPlanner, ChannelGraph) {
1729 two_relayer_return_planner_with_floor(small_config().min_paths_anonymity_floor)
1730 }
1731
1732 fn two_relayer_return_planner_with_floor(floor: usize) -> (TestPlanner, ChannelGraph) {
1735 let me = pubkey(&SECRET_ME);
1736 let a = pubkey(&SECRET_A);
1737 let b = pubkey(&SECRET_B);
1738 let dest = pubkey(&SECRET_DEST);
1739
1740 let graph = ChannelGraph::new(me);
1741 for node in [a, b, dest] {
1742 graph.add_node(node);
1743 }
1744 for (src, dst) in [(dest, a), (a, me), (dest, b), (b, me)] {
1745 graph.add_edge(&src, &dst).expect("edge should be addable");
1746 mark_edge_full(&graph, &src, &dst);
1747 }
1748
1749 let cfg = PathPlannerConfig {
1750 min_paths_anonymity_floor: floor,
1751 ..small_config()
1752 };
1753 let selector = HoprGraphPathSelector::new(
1754 me,
1755 graph.clone(),
1756 cfg.max_cached_paths,
1757 cfg.edge_penalty,
1758 cfg.min_ack_rate,
1759 cfg.min_paths_anonymity_floor,
1760 );
1761 let chain_api = TestChainApi::new(me, me_addr(), vec![(a, a_addr()), (b, b_addr()), (dest, dest_addr())])
1762 .with_open_channel(dest_addr(), a_addr())
1763 .with_open_channel(a_addr(), me_addr())
1764 .with_open_channel(dest_addr(), b_addr())
1765 .with_open_channel(b_addr(), me_addr());
1766
1767 let planner = PathPlanner::new(
1768 me,
1769 hopr_protocol_hopr::MemorySurbStore::default(),
1770 chain_api,
1771 selector,
1772 cfg,
1773 );
1774 (planner, graph)
1775 }
1776
1777 fn two_relayer_return_planner_with_unresolvable_relayer(
1783 unresolvable: OffchainPublicKey,
1784 ) -> (TestPlanner, ChannelGraph) {
1785 let me = pubkey(&SECRET_ME);
1786 let a = pubkey(&SECRET_A);
1787 let b = pubkey(&SECRET_B);
1788 let dest = pubkey(&SECRET_DEST);
1789
1790 let graph = ChannelGraph::new(me);
1791 for node in [a, b, dest] {
1792 graph.add_node(node);
1793 }
1794 for (src, dst) in [(dest, a), (a, me), (dest, b), (b, me)] {
1795 graph.add_edge(&src, &dst).expect("edge should be addable");
1796 mark_edge_full(&graph, &src, &dst);
1797 }
1798
1799 let cfg = PathPlannerConfig {
1800 min_paths_anonymity_floor: 0,
1801 ..small_config()
1802 };
1803 let selector = HoprGraphPathSelector::new(
1804 me,
1805 graph.clone(),
1806 cfg.max_cached_paths,
1807 cfg.edge_penalty,
1808 cfg.min_ack_rate,
1809 cfg.min_paths_anonymity_floor,
1810 );
1811 let resolvable_peers = [(a, a_addr()), (b, b_addr()), (dest, dest_addr())]
1812 .into_iter()
1813 .filter(|(key, _)| *key != unresolvable)
1814 .collect::<Vec<_>>();
1815 let chain_api = TestChainApi::new(me, me_addr(), resolvable_peers)
1816 .with_open_channel(dest_addr(), a_addr())
1817 .with_open_channel(a_addr(), me_addr())
1818 .with_open_channel(dest_addr(), b_addr())
1819 .with_open_channel(b_addr(), me_addr());
1820
1821 let planner = PathPlanner::new(
1822 me,
1823 hopr_protocol_hopr::MemorySurbStore::default(),
1824 chain_api,
1825 selector,
1826 cfg,
1827 );
1828 (planner, graph)
1829 }
1830
1831 async fn fill_return_cache(planner: &TestPlanner) -> PlannerCacheValue {
1833 planner
1834 .resolve_diverse_return_paths(
1835 NodeId::Offchain(pubkey(&SECRET_DEST)),
1836 RoutingOptions::Hops(1.try_into().expect("valid 1")),
1837 1,
1838 )
1839 .await
1840 .expect("return path resolution should succeed");
1841 planner
1842 .cache
1843 .get(&(pubkey(&SECRET_DEST), pubkey(&SECRET_ME), 1))
1844 .await
1845 .expect("the return path should be cached after resolution")
1846 }
1847
1848 fn share_of(paths: &PlannerCacheValue, relayer: &OffchainPublicKey) -> f64 {
1853 let total: f64 = paths.iter().map(|(_, w)| *w).sum();
1854 let held: f64 = paths
1855 .iter()
1856 .filter(|(vp, _)| vp.first() == Some(relayer))
1857 .map(|(_, w)| *w)
1858 .sum();
1859 if total > 0.0 { held / total } else { 0.0 }
1860 }
1861
1862 fn candidate_set(paths: &PlannerCacheValue) -> std::collections::BTreeSet<String> {
1864 paths.iter().map(|(vp, _)| vp.to_string()).collect()
1865 }
1866
1867 fn record_surbs(
1869 graph: &ChannelGraph,
1870 src: &OffchainPublicKey,
1871 dst: &OffchainPublicKey,
1872 expected: u64,
1873 observed: u64,
1874 ) {
1875 use hopr_api::graph::traits::EdgeWeightType;
1876 graph.upsert_edge(src, dst, |obs| {
1877 obs.record(EdgeWeightType::SurbRoundTrips { expected, observed });
1878 });
1879 }
1880
1881 #[tokio::test]
1888 async fn recomputing_an_entry_should_reweight_the_candidates_without_changing_the_set() {
1889 let (planner, graph) = two_relayer_return_planner();
1890 let (me, b, dest) = (pubkey(&SECRET_ME), pubkey(&SECRET_B), pubkey(&SECRET_DEST));
1891
1892 let before = fill_return_cache(&planner).await;
1893 let set_before = candidate_set(&before);
1894 let share_before = share_of(&before, &b);
1895
1896 assert_eq!(
1897 set_before.len(),
1898 2,
1899 "the fixture must offer both relayers as candidates"
1900 );
1901 assert!(
1902 share_before > 0.2,
1903 "vacuity guard: B must hold a material share before the collapse, held {share_before}"
1904 );
1905
1906 record_surbs(&graph, &dest, &pubkey(&SECRET_A), 1_000, 1_000);
1908 record_surbs(&graph, &dest, &b, 1_000, 1_000);
1909 record_surbs(&graph, &dest, &b, 4_000, 0);
1910
1911 let replaced = planner.recompute_paths_from(&dest).await;
1912 assert_eq!(
1913 replaced, 1,
1914 "exactly the one cached return entry should have been rebuilt"
1915 );
1916
1917 let after = planner
1918 .cache
1919 .get(&(dest, me, 1))
1920 .await
1921 .expect("the entry must still exist after a recompute");
1922
1923 assert_eq!(
1924 candidate_set(&after),
1925 set_before,
1926 "a recompute must re-weight the candidates, never replace the set"
1927 );
1928
1929 let share_after = share_of(&after, &b);
1930 assert!(
1931 share_after < share_before,
1932 "the relay that stopped delivering must lose share: {share_before} -> {share_after}"
1933 );
1934 }
1935
1936 #[tokio::test]
1942 async fn recomputing_an_entry_should_keep_the_old_candidates_when_it_finds_none() {
1943 let (planner, graph) = two_relayer_return_planner();
1944 let (me, dest) = (pubkey(&SECRET_ME), pubkey(&SECRET_DEST));
1945
1946 let before = fill_return_cache(&planner).await;
1947 let set_before = candidate_set(&before);
1948
1949 for relay in [pubkey(&SECRET_A), pubkey(&SECRET_B)] {
1952 graph.remove_edge(&dest, &relay);
1953 }
1954
1955 let replaced = planner.recompute_paths_from(&dest).await;
1956 assert_eq!(replaced, 0, "a recompute that finds nothing must replace nothing");
1957
1958 let after = planner
1959 .cache
1960 .get(&(dest, me, 1))
1961 .await
1962 .expect("an entry must never be dropped by a recompute that found nothing");
1963 assert_eq!(
1964 candidate_set(&after),
1965 set_before,
1966 "the previous candidates must survive a fruitless recompute"
1967 );
1968 }
1969
1970 #[tokio::test]
1981 async fn a_recompute_that_lands_on_the_same_weights_should_report_nothing_moved() {
1982 let (planner, graph) = two_relayer_return_planner();
1983 let dest = pubkey(&SECRET_DEST);
1984
1985 let _ = fill_return_cache(&planner).await;
1986
1987 assert_eq!(
1988 planner.recompute_paths_from(&dest).await,
1989 0,
1990 "nothing about the graph changed, so no traffic can have moved"
1991 );
1992
1993 record_surbs(&graph, &dest, &pubkey(&SECRET_A), 1_000, 1_000);
1997 record_surbs(&graph, &dest, &pubkey(&SECRET_B), 1_000, 1_000);
1998 record_surbs(&graph, &dest, &pubkey(&SECRET_B), 4_000, 0);
1999 assert_eq!(
2000 planner.recompute_paths_from(&dest).await,
2001 1,
2002 "a collapse on one relayer must register as moved"
2003 );
2004 }
2005
2006 #[tokio::test]
2011 async fn floor_zero_keeps_every_return_relayer_but_a_cap_of_one_collapses_it() {
2012 let (a, b) = (pubkey(&SECRET_A), pubkey(&SECRET_B));
2013
2014 let (uncapped, _g) = two_relayer_return_planner_with_floor(0);
2015 let paths = fill_return_cache(&uncapped).await;
2016 assert_eq!(2, candidate_set(&paths).len(), "floor 0 keeps both return relayers");
2017 assert!(
2018 share_of(&paths, &a) > 0.0,
2019 "relayer a carries part of the return stream"
2020 );
2021 assert!(
2022 share_of(&paths, &b) > 0.0,
2023 "relayer b carries part of the return stream"
2024 );
2025
2026 let (capped, _g) = two_relayer_return_planner_with_floor(1);
2027 let paths = fill_return_cache(&capped).await;
2028 assert_eq!(1, candidate_set(&paths).len(), "floor 1 collapses to a single relayer");
2029 }
2030
2031 #[tokio::test]
2038 async fn validation_rejecting_one_relayer_should_collapse_diversity_even_though_the_selector_saw_two() {
2039 let (a, b) = (pubkey(&SECRET_A), pubkey(&SECRET_B));
2040
2041 let (planner, _g) = two_relayer_return_planner_with_unresolvable_relayer(b);
2042 let paths = fill_return_cache(&planner).await;
2043
2044 assert_eq!(
2045 1,
2046 candidate_set(&paths).len(),
2047 "only the relayer that resolves on-chain should survive validation"
2048 );
2049 assert!(
2050 share_of(&paths, &a) > 0.0,
2051 "the resolvable relayer must carry the whole return stream"
2052 );
2053 assert_eq!(
2054 0.0,
2055 share_of(&paths, &b),
2056 "the unresolvable relayer must not appear among the validated candidates"
2057 );
2058 }
2059
2060 #[tokio::test]
2061 async fn recomputing_should_rebuild_only_the_entries_originating_at_that_peer() {
2062 let (planner, graph) = two_relayer_return_planner();
2063 let (me, a, dest) = (pubkey(&SECRET_ME), pubkey(&SECRET_A), pubkey(&SECRET_DEST));
2064
2065 let _ = fill_return_cache(&planner).await;
2066 record_surbs(&graph, &dest, &a, 1_000, 1_000);
2068 record_surbs(&graph, &dest, &pubkey(&SECRET_B), 1_000, 1_000);
2069 record_surbs(&graph, &dest, &pubkey(&SECRET_B), 4_000, 0);
2070 let _ = planner
2072 .cached_paths(
2073 NodeId::Offchain(me),
2074 NodeId::Offchain(a),
2075 1.try_into().expect("valid 1"),
2076 )
2077 .await;
2078
2079 assert_eq!(
2080 planner.recompute_paths_from(&dest).await,
2081 1,
2082 "only the entry whose paths start at the named peer should be re-weighted"
2083 );
2084 assert_eq!(
2085 planner.recompute_paths_from(&pubkey(&SECRET_B)).await,
2086 0,
2087 "a peer with no cached entries of its own must rebuild nothing"
2088 );
2089 }
2090
2091 fn default_weighting() -> WeightingParams {
2094 WeightingParams {
2095 latency_halflife: Duration::from_millis(100),
2096 capacity_reference: 10_000_000,
2097 }
2098 }
2099
2100 fn make_pwm(cost: f64, latency_ms: Option<u32>, fundable_tickets_floor: Option<u128>) -> PathWithMetrics {
2101 PathWithMetrics {
2102 path: vec![],
2103 cost,
2104 total_latency_ms: latency_ms,
2105 min_probe_success_rate: None,
2106 min_ack_rate: None,
2107 fundable_tickets_floor,
2108 }
2109 }
2110
2111 #[test]
2112 fn latency_factor_is_monotonic_decreasing() {
2113 let halflife = Duration::from_millis(100);
2114 let f0 = latency_factor(Duration::ZERO, halflife);
2115 let f100 = latency_factor(Duration::from_millis(100), halflife);
2116 let f200 = latency_factor(Duration::from_millis(200), halflife);
2117 assert!(
2118 f0 > f100 && f100 > f200,
2119 "must be strictly decreasing: {f0} > {f100} > {f200}"
2120 );
2121 assert!(
2122 (f100 - 0.5).abs() < 1e-9,
2123 "at halflife factor should be 0.5, got {f100}"
2124 );
2125 assert!(f0 <= 1.0, "factor must never exceed 1.0, got {f0}");
2126 }
2127
2128 #[test]
2129 fn capacity_factor_is_monotonic_increasing() {
2130 let reference = 10_000_000u128;
2131 let f_low = capacity_factor(100, reference);
2132 let f_mid = capacity_factor(1_000_000, reference);
2133 let f_ref = capacity_factor(reference, reference);
2134 assert!(
2135 f_low < f_mid && f_mid <= f_ref,
2136 "must be non-decreasing: {f_low} < {f_mid} <= {f_ref}"
2137 );
2138 assert!(f_ref <= 1.0, "factor must not exceed 1.0 at reference, got {f_ref}");
2139 assert!(f_low >= 0.05, "minimum clamp is 0.05, got {f_low}");
2140 }
2141
2142 #[test]
2143 fn composite_weight_for_0_hop_skips_capacity_factor() {
2144 let params = default_weighting();
2145 let pwm = make_pwm(0.6, Some(100), None);
2146 let w = composite_weight(&pwm, 0, params);
2147 let expected = 0.6 * latency_factor(Duration::from_millis(100), params.latency_halflife);
2148 assert!(
2149 (w - expected).abs() < 1e-9,
2150 "0-hop weight should ignore capacity: {w} != {expected}"
2151 );
2152 assert!(w > 0.0, "0-hop weight must be positive");
2153 }
2154
2155 #[test]
2156 fn composite_weight_with_all_aggregates_is_below_cost() {
2157 let params = default_weighting();
2158 let pwm = make_pwm(0.8, Some(150), Some(5_000_000));
2159 let w = composite_weight(&pwm, 1, params);
2160 assert!(
2161 w < pwm.cost,
2162 "composite must be below raw cost when factors < 1.0: {w} >= {}",
2163 pwm.cost
2164 );
2165 assert!(w > 0.0, "composite weight must be positive");
2166 }
2167
2168 #[test]
2169 fn composite_weight_missing_capacity_on_multi_hop_neutral() {
2170 let params = default_weighting();
2171 let pwm_with = make_pwm(0.7, Some(80), Some(8_000_000));
2172 let pwm_without = make_pwm(0.7, Some(80), None);
2173 let w_with = composite_weight(&pwm_with, 2, params);
2174 let w_without = composite_weight(&pwm_without, 2, params);
2175 let expected_without = 0.7 * latency_factor(Duration::from_millis(80), params.latency_halflife);
2177 assert!((w_without - expected_without).abs() < 1e-9);
2178 assert!(w_with <= w_without, "known capacity should not increase the weight");
2180 }
2181}