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
34#[derive(Debug, Clone, Copy, PartialEq, smart_default::SmartDefault, Validate)]
36pub struct PathPlannerConfig {
37 #[default = 10_000]
39 pub max_cache_capacity: u64,
40 #[default(Duration::from_secs(60))]
43 pub cache_ttl: Duration,
44 #[default(Duration::from_secs(30))]
46 pub refresh_period: Duration,
47 #[default = 50]
50 pub max_cached_paths: usize,
51 #[default = 0.5]
55 #[validate(custom(function = "validate_unit_interval"))]
56 pub edge_penalty: f64,
57 #[default = 0.1]
61 #[validate(custom(function = "validate_unit_interval"))]
62 pub min_ack_rate: f64,
63 #[default = 8]
69 pub min_paths_anonymity_floor: usize,
70 #[default(Duration::from_millis(100))]
73 pub latency_halflife: Duration,
74 #[default = 10_000_000]
78 pub capacity_reference: u128,
79 #[default(Duration::from_secs(30))]
83 pub max_plausible_loopback_rtt: Duration,
84}
85
86fn validate_unit_interval(value: f64) -> std::result::Result<(), ValidationError> {
87 if value.is_finite() && (0.0..=1.0).contains(&value) {
88 Ok(())
89 } else {
90 Err(ValidationError::new("value must be finite and in 0.0..=1.0"))
91 }
92}
93
94#[derive(Debug, Clone, Copy)]
96struct WeightingParams {
97 latency_halflife: Duration,
98 capacity_reference: u128,
99}
100
101fn latency_factor(latency: Duration, halflife: Duration) -> f64 {
105 let ms = latency.as_millis() as f64;
106 let h = halflife.as_millis().max(1) as f64;
107 1.0 / (1.0 + ms / h)
108}
109
110fn capacity_factor(c: u128, reference: u128) -> f64 {
115 let log = (c as f64).max(1.0).log10();
116 let ref_log = (reference as f64).max(10.0).log10();
117 (log / ref_log).clamp(0.05, 1.0)
118}
119
120fn composite_weight(pwc: &PathWithMetrics, hops: usize, params: WeightingParams) -> f64 {
128 let lat = pwc
129 .total_latency_ms
130 .map(|ms| latency_factor(Duration::from_millis(ms as u64), params.latency_halflife))
131 .unwrap_or(1.0);
132 let cap = if hops == 0 {
133 1.0
134 } else {
135 pwc.capacity_floor
136 .map(|c| capacity_factor(c, params.capacity_reference))
137 .unwrap_or(1.0)
138 };
139 pwc.cost * lat * cap
140}
141
142type PlannerCacheKey = (NodeId, NodeId, u32);
147type PlannerCacheValue = Arc<hopr_utils::statistics::WeightedCollection<ValidatedPath>>;
148
149#[derive(Clone)]
163pub struct PathPlanner<Surb, R, S> {
164 me: OffchainPublicKey,
165 pub surb_store: Surb,
166 resolver: Arc<R>,
167 selector: Arc<S>,
168 cache: moka::future::Cache<PlannerCacheKey, PlannerCacheValue>,
169 refresh_period: Duration,
170 weighting: WeightingParams,
171}
172
173impl<Surb, R, S> PathPlanner<Surb, R, S>
174where
175 Surb: SurbStore + Send + Sync + 'static,
176 R: ChainKeyOperations + ChainReadChannelOperations + Send + Sync + 'static,
177 S: PathSelector + Send + Sync + 'static,
178{
179 pub fn new(me: OffchainPublicKey, surb_store: Surb, resolver: R, selector: S, config: PathPlannerConfig) -> Self {
183 let cache = moka::future::Cache::builder()
184 .max_capacity(config.max_cache_capacity)
185 .time_to_live(config.cache_ttl)
186 .build();
187
188 Self {
189 me,
190 surb_store,
191 resolver: Arc::new(resolver),
192 selector: Arc::new(selector),
193 cache,
194 refresh_period: config.refresh_period,
195 weighting: WeightingParams {
196 latency_halflife: config.latency_halflife,
197 capacity_reference: config.capacity_reference,
198 },
199 }
200 }
201
202 async fn resolve_node_id_to_offchain_key(&self, node_id: &NodeId) -> Result<OffchainPublicKey> {
204 match node_id {
205 NodeId::Offchain(key) => Ok(*key),
206 NodeId::Chain(addr) => {
207 let resolver = ChainPathResolver::from(&*self.resolver);
208 resolver
209 .resolve_transport_address(addr)
210 .await
211 .map_err(|e| PathPlannerError::Other(anyhow::anyhow!("{e}")))?
212 .ok_or_else(|| {
213 PathPlannerError::Other(anyhow::anyhow!("no offchain key found for chain address {addr}"))
214 })
215 }
216 }
217 }
218
219 #[tracing::instrument(level = "trace", skip(self))]
220 async fn resolve_path(
221 &self,
222 source: NodeId,
223 destination: NodeId,
224 options: RoutingOptions,
225 ) -> Result<ValidatedPath> {
226 let path = match options {
227 RoutingOptions::IntermediatePath(explicit_path) => {
228 tracing::debug!(
229 direction = "loopback",
230 ?source,
231 ?destination,
232 ?explicit_path,
233 "resolving intermediate path"
234 );
235 let resolver = ChainPathResolver::from(&*self.resolver);
236 ValidatedPath::new(
237 source,
238 explicit_path
239 .into_iter()
240 .chain(std::iter::once(destination))
241 .collect::<Vec<_>>(),
242 &resolver,
243 )
244 .await?
245 }
246
247 RoutingOptions::Hops(hops) if u32::from(hops) == 0 => {
248 trace!(hops = 0, "resolving zero-hop direct path");
249 let resolver = ChainPathResolver::from(&*self.resolver);
250 ValidatedPath::new(source, vec![destination], &resolver).await?
251 }
252
253 RoutingOptions::Hops(hops) => {
254 let hops_usize: usize = hops.into();
255 trace!(hops = hops_usize, "resolving path via planner cache");
256
257 let src_key = self.resolve_node_id_to_offchain_key(&source).await?;
258 let dest_key = self.resolve_node_id_to_offchain_key(&destination).await?;
259
260 let cache_key: PlannerCacheKey = (source, destination, u32::from(hops));
261
262 let resolver = self.resolver.clone();
263 let selector = self.selector.clone();
264 let weighting = self.weighting;
265
266 let paths = self
267 .cache
268 .try_get_with(cache_key, async move {
269 trace!(hops = hops_usize, "path cache miss, querying selector");
270 let candidates = selector.select_path(src_key, dest_key, hops_usize)?;
271
272 let chain_resolver = ChainPathResolver::from(&*resolver);
273 let mut valid_paths: Vec<(ValidatedPath, f64)> = Vec::with_capacity(candidates.len());
274 let mut path_metrics: Vec<PathWithMetrics> = Vec::with_capacity(candidates.len());
275 for mut pwc in candidates {
276 let path_nodes = std::mem::take(&mut pwc.path);
277 let node_ids: Vec<NodeId> =
278 path_nodes.into_iter().map(NodeId::Offchain).collect::<Vec<_>>();
279 match ValidatedPath::new(source, node_ids, &chain_resolver).await {
280 Ok(vp) => {
281 valid_paths.push((vp, composite_weight(&pwc, hops_usize, weighting)));
282 path_metrics.push(pwc);
283 }
284 Err(e) => tracing::debug!(error = %e, "path candidate failed validation"),
285 }
286 }
287
288 if valid_paths.is_empty() {
289 return Err(PathPlannerError::Path(PathError::PathNotFound(
290 hops_usize,
291 src_key.to_hex(),
292 dest_key.to_hex(),
293 )));
294 }
295
296 let weighted = hopr_utils::statistics::WeightedCollection::new(valid_paths);
297 let total_wt = weighted.total_weight();
298 for ((vp, w), pwm) in weighted.iter().zip(path_metrics.iter()) {
299 tracing::debug!(
300 %destination,
301 hops = hops_usize,
302 path = %vp,
303 cost = pwm.cost,
304 composite_weight = w,
305 sampling_probability = if total_wt > 0.0 && *w > 0.0 { *w / total_wt } else { 0.0 },
306 total_latency_ms = ?pwm.total_latency_ms,
307 min_probe_success_rate = ?pwm.min_probe_success_rate,
308 min_ack_rate = ?pwm.min_ack_rate,
309 capacity_floor = ?pwm.capacity_floor,
310 "weighted candidate path",
311 );
312 }
313 Ok(Arc::new(weighted))
314 })
315 .await
316 .map_err(PathPlannerError::CacheError)?;
317
318 paths.pick_one().ok_or_else(|| {
319 PathPlannerError::Path(PathError::PathNotFound(hops_usize, src_key.to_hex(), dest_key.to_hex()))
320 })?
321 }
322 };
323
324 #[cfg(all(feature = "telemetry", not(test)))]
325 {
326 hopr_api::types::telemetry::SimpleHistogram::observe(&METRIC_PATH_LENGTH, (path.num_hops() - 1) as f64);
327 }
328
329 trace!(%path, "validated resolved path");
330 Ok(path)
331 }
332
333 #[tracing::instrument(level = "trace", skip(self))]
337 pub async fn resolve_routing(
338 &self,
339 size_hint: usize,
340 max_surbs: usize,
341 routing: DestinationRouting,
342 ) -> Result<(ResolvedTransportRouting<HoprSurb>, Option<usize>)> {
343 match routing {
344 DestinationRouting::Forward {
345 destination,
346 pseudonym,
347 forward_options,
348 return_options,
349 } => {
350 tracing::debug!(direction = "forward", %destination, "resolving forward path");
351
352 let forward_path = self
353 .resolve_path(NodeId::Offchain(self.me), *destination, forward_options)
354 .await?;
355 tracing::debug!(direction = "forward", %destination, path = %forward_path, "resolved path");
356
357 let return_paths = if let Some(return_options) = return_options {
358 let num_possible_surbs = HoprPacket::max_surbs_with_message(size_hint).min(max_surbs);
359 trace!(
360 %destination,
361 %num_possible_surbs,
362 data_len = size_hint,
363 max_surbs,
364 "resolving packet return paths"
365 );
366
367 (0..num_possible_surbs)
368 .map(|_| self.resolve_path(*destination, NodeId::Offchain(self.me), return_options.clone()))
369 .collect::<FuturesUnordered<_>>()
370 .try_collect::<Vec<ValidatedPath>>()
371 .await?
372 .into_iter()
373 .enumerate()
374 .inspect(|(i, rp)| {
375 tracing::debug!(direction = "return", %destination, index = i, path = %rp, "resolved return path");
376 })
377 .map(|(_, rp)| rp)
378 .collect()
379 } else {
380 vec![]
381 };
382
383 trace!(%destination, num_surbs = return_paths.len(), data_len = size_hint, "resolved packet");
384
385 Ok((
386 ResolvedTransportRouting::Forward {
387 pseudonym: pseudonym.unwrap_or_else(HoprPseudonym::random),
388 forward_path,
389 return_paths,
390 },
391 None,
392 ))
393 }
394
395 DestinationRouting::Return(matcher) => {
396 let FoundSurb {
397 sender_id,
398 surb,
399 remaining,
400 } = self
401 .surb_store
402 .find_surb(matcher)
403 .ok_or_else(|| PathPlannerError::Surb(format!("no surb for pseudonym {}", matcher.pseudonym())))?;
404 Ok((ResolvedTransportRouting::Return(sender_id, surb), Some(remaining)))
405 }
406 }
407 }
408}
409
410impl<Surb, R, S> BackgroundPathCacheRefreshable for PathPlanner<Surb, R, S>
411where
412 Surb: SurbStore + Send + Sync + 'static,
413 R: ChainKeyOperations + ChainReadChannelOperations + Send + Sync + 'static,
414 S: PathSelector + Send + Sync + 'static,
415{
416 fn run_background_refresh(&self) -> impl std::future::Future<Output = ()> + Send + 'static {
422 let cache = self.cache.clone();
424 let resolver = self.resolver.clone();
425 let selector = self.selector.clone();
426 let refresh_period = self.refresh_period;
427 let weighting = self.weighting;
428
429 futures_time::stream::interval(futures_time::time::Duration::from_millis(
431 refresh_period.as_millis() as u64 + 1u64,
432 ))
433 .for_each(move |_| {
434 let cache = cache.clone();
435 let resolver = resolver.clone();
436 let selector = selector.clone();
437 let weighting = weighting;
438
439 async move {
440 for (key, _) in cache.iter() {
441 let (src, dest, hops_u32) = {
442 let k = key.as_ref();
443 (k.0, k.1, k.2)
444 };
445
446 if hops_u32 == 0 {
447 continue;
448 }
449 let hops_usize = hops_u32 as usize;
450
451 let resolve_key = |node: NodeId| {
452 let resolver = resolver.clone();
453
454 async move {
455 match node {
456 NodeId::Offchain(k) => Some(k),
457 NodeId::Chain(addr) => ChainPathResolver::from(&*resolver)
458 .resolve_transport_address(&addr)
459 .await
460 .ok()
461 .flatten(),
462 }
463 }
464 };
465
466 if let (Some(src_key), Some(dest_key)) = (resolve_key(src).await, resolve_key(dest).await)
467 && let Ok(candidates) = selector.select_path(src_key, dest_key, hops_usize)
468 {
469 let chain_resolver = ChainPathResolver::from(&*resolver);
470 let mut valid_paths: Vec<(ValidatedPath, f64)> = Vec::with_capacity(candidates.len());
471 let mut path_metrics: Vec<PathWithMetrics> = Vec::with_capacity(candidates.len());
472 for mut pwc in candidates {
473 let path_nodes = std::mem::take(&mut pwc.path);
474 let node_ids: Vec<NodeId> =
475 path_nodes.into_iter().map(NodeId::Offchain).collect::<Vec<_>>();
476 match ValidatedPath::new(src, node_ids, &chain_resolver).await {
477 Ok(vp) => {
478 valid_paths.push((vp, composite_weight(&pwc, hops_usize, weighting)));
479 path_metrics.push(pwc);
480 }
481 Err(e) => {
482 tracing::debug!(error = %e, "background refresh: path candidate failed validation")
483 }
484 }
485 }
486
487 if !valid_paths.is_empty() {
488 let weighted = hopr_utils::statistics::WeightedCollection::new(valid_paths);
489 let total_wt = weighted.total_weight();
490 for ((vp, w), pwm) in weighted.iter().zip(path_metrics.iter()) {
491 tracing::debug!(
492 kind = "background-refresh",
493 destination = %dest_key,
494 hops = hops_usize,
495 path = %vp,
496 cost = pwm.cost,
497 composite_weight = w,
498 sampling_probability = if total_wt > 0.0 && *w > 0.0 { *w / total_wt } else { 0.0 },
499 total_latency_ms = ?pwm.total_latency_ms,
500 min_probe_success_rate = ?pwm.min_probe_success_rate,
501 min_ack_rate = ?pwm.min_ack_rate,
502 capacity_floor = ?pwm.capacity_floor,
503 "weighted candidate path",
504 );
505 }
506 cache.insert((src, dest, hops_u32), Arc::new(weighted)).await;
507 }
508 }
509 }
510 }
511 })
512 }
513}
514
515#[cfg(test)]
516mod tests {
517 use std::str::FromStr;
518
519 use bimap::BiMap;
520 use futures::stream::{self, BoxStream};
521 use hex_literal::hex;
522 use hopr_api::{
523 chain::{ChainKeyOperations, ChainReadChannelOperations, ChannelSelector, HoprKeyIdent},
524 graph::{NetworkGraphWrite, traits::EdgeObservableWrite},
525 types::{
526 crypto::prelude::{Keypair, OffchainKeypair},
527 internal::channels::{ChannelEntry, ChannelStatus, generate_channel_id},
528 primitive::prelude::*,
529 },
530 };
531 use hopr_network_graph::ChannelGraph;
532
533 use super::*;
534 use crate::path::selector::HoprGraphPathSelector;
535
536 #[derive(Debug)]
537 struct TestError(String);
538
539 impl std::fmt::Display for TestError {
540 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
541 f.write_str(&self.0)
542 }
543 }
544
545 impl std::error::Error for TestError {}
546
547 const SECRET_ME: [u8; 32] = hex!("60741b83b99e36aa0c1331578156e16b8e21166d01834abb6c64b103f885734d");
548 const SECRET_A: [u8; 32] = hex!("71bf1f42ebbfcd89c3e197a3fd7cda79b92499e509b6fefa0fe44d02821d146a");
549 const SECRET_DEST: [u8; 32] = hex!("c24bd833704dd2abdae3933fcc9962c2ac404f84132224c474147382d4db2299");
550
551 fn pubkey(secret: &[u8; 32]) -> OffchainPublicKey {
552 *OffchainKeypair::from_secret(secret).expect("valid secret").public()
553 }
554
555 #[derive(Clone)]
556 struct Mapper {
557 map: Arc<BiMap<OffchainPublicKey, HoprKeyIdent>>,
558 }
559
560 impl KeyIdMapping<HoprKeyIdent, OffchainPublicKey> for Mapper {
561 fn map_key_to_id(&self, key: &OffchainPublicKey) -> Option<HoprKeyIdent> {
562 self.map.get_by_left(key).copied()
563 }
564
565 fn map_id_to_public(&self, id: &HoprKeyIdent) -> Option<OffchainPublicKey> {
566 self.map.get_by_right(id).copied()
567 }
568
569 fn map_keys_to_ids(&self, keys: &[OffchainPublicKey]) -> Vec<Option<HoprKeyIdent>> {
570 keys.iter().map(|key| self.map_key_to_id(key)).collect()
571 }
572
573 fn map_ids_to_keys(&self, ids: &[HoprKeyIdent]) -> Vec<Option<OffchainPublicKey>> {
574 ids.iter().map(|id| self.map_id_to_public(id)).collect()
575 }
576 }
577
578 struct TestChainApi {
579 me: Address,
580 key_addr_map: BiMap<OffchainPublicKey, Address>,
581 channels: Vec<ChannelEntry>,
582 id_mapper: Mapper,
583 }
584
585 impl TestChainApi {
586 fn new(me_key: OffchainPublicKey, me_addr: Address, peers: Vec<(OffchainPublicKey, Address)>) -> Self {
587 let mut key_addr_map = BiMap::new();
588 let mut key_id_map: BiMap<OffchainPublicKey, HoprKeyIdent> = BiMap::new();
589 key_addr_map.insert(me_key, me_addr);
590 key_id_map.insert(me_key, 0u32.into());
591 for (i, (k, a)) in peers.iter().enumerate() {
592 key_addr_map.insert(*k, *a);
593 key_id_map.insert(*k, ((i + 1) as u32).into());
594 }
595 Self {
596 me: me_addr,
597 key_addr_map,
598 channels: vec![],
599 id_mapper: Mapper {
600 map: Arc::new(key_id_map),
601 },
602 }
603 }
604
605 fn with_open_channel(mut self, src: Address, dst: Address) -> Self {
606 self.channels.push(
607 ChannelEntry::builder()
608 .between(src, dst)
609 .amount(100)
610 .ticket_index(1)
611 .status(ChannelStatus::Open)
612 .epoch(1)
613 .build()
614 .unwrap(),
615 );
616 self
617 }
618 }
619
620 impl ChainKeyOperations for TestChainApi {
621 type Error = TestError;
622 type Mapper = Mapper;
623
624 fn chain_key_to_packet_key(
625 &self,
626 chain: &Address,
627 ) -> std::result::Result<Option<OffchainPublicKey>, TestError> {
628 Ok(self.key_addr_map.get_by_right(chain).copied())
629 }
630
631 fn packet_key_to_chain_key(
632 &self,
633 packet: &OffchainPublicKey,
634 ) -> std::result::Result<Option<Address>, TestError> {
635 Ok(self.key_addr_map.get_by_left(packet).copied())
636 }
637
638 fn key_id_mapper_ref(&self) -> &Self::Mapper {
639 &self.id_mapper
640 }
641 }
642
643 impl ChainReadChannelOperations for TestChainApi {
644 type Error = TestError;
645
646 fn me(&self) -> &Address {
647 &self.me
648 }
649
650 fn channel_by_id(&self, channel_id: &ChannelId) -> std::result::Result<Option<ChannelEntry>, TestError> {
651 Ok(self
652 .channels
653 .iter()
654 .find(|c| generate_channel_id(&c.source, &c.destination) == *channel_id)
655 .cloned())
656 }
657
658 fn stream_channels<'a>(
659 &'a self,
660 _selector: ChannelSelector,
661 ) -> std::result::Result<BoxStream<'a, ChannelEntry>, TestError> {
662 Ok(Box::pin(stream::iter(self.channels.clone())))
663 }
664 }
665
666 fn me_addr() -> Address {
668 Address::from_str("0x1000d5786d9e6799b3297da1ad55605b91e2c882").expect("valid addr")
669 }
670 fn a_addr() -> Address {
671 Address::from_str("0x200060ddced1e33c9647a71f4fc2cf4ed33e4a9d").expect("valid addr")
672 }
673 fn dest_addr() -> Address {
674 Address::from_str("0x30004105095c8c10f804109b4d1199a9ac40ed46").expect("valid addr")
675 }
676
677 fn mark_edge_full(graph: &ChannelGraph, src: &OffchainPublicKey, dst: &OffchainPublicKey) {
679 use hopr_api::graph::traits::EdgeWeightType;
680 graph.upsert_edge(src, dst, |obs| {
681 obs.record(EdgeWeightType::Connected(true));
682 obs.record(EdgeWeightType::Immediate(Ok(std::time::Duration::from_millis(50))));
683 obs.record(EdgeWeightType::Intermediate(Ok(std::time::Duration::from_millis(50))));
684 obs.record(EdgeWeightType::Capacity(Some(1000)));
685 });
686 }
687
688 fn small_config() -> PathPlannerConfig {
689 PathPlannerConfig {
690 max_cache_capacity: 100,
691 cache_ttl: std::time::Duration::from_secs(60),
692 refresh_period: std::time::Duration::from_secs(60),
693 max_cached_paths: 2,
694 ..PathPlannerConfig::default()
695 }
696 }
697
698 #[tokio::test]
701 async fn zero_hop_path_should_bypass_selector() {
702 let me = pubkey(&SECRET_ME);
703 let dest = pubkey(&SECRET_DEST);
704
705 let graph = ChannelGraph::new(me);
707 let cfg = small_config();
708 let selector = HoprGraphPathSelector::new(
709 me,
710 graph,
711 cfg.max_cached_paths,
712 cfg.edge_penalty,
713 cfg.min_ack_rate,
714 cfg.min_paths_anonymity_floor,
715 );
716
717 let chain_api = TestChainApi::new(me, me_addr(), vec![(dest, dest_addr())]);
718 let surb_store = hopr_protocol_hopr::MemorySurbStore::default();
719
720 let planner = PathPlanner::new(me, surb_store, chain_api, selector, small_config());
721
722 let routing = DestinationRouting::Forward {
723 destination: Box::new(NodeId::Offchain(dest)),
724 pseudonym: None,
725 forward_options: RoutingOptions::Hops(0.try_into().expect("valid 0")),
726 return_options: None,
727 };
728
729 let result = planner.resolve_routing(100, 0, routing).await;
730 assert!(result.is_ok(), "zero-hop should succeed: {:?}", result.err());
731
732 let (resolved, rem) = result.unwrap();
733 assert!(rem.is_none());
734 if let ResolvedTransportRouting::Forward { forward_path, .. } = resolved {
735 assert_eq!(
736 forward_path.num_hops(),
737 1,
738 "zero-hop = 1 node in path (just destination)"
739 );
740 } else {
741 panic!("expected Forward routing");
742 }
743 }
744
745 #[tokio::test]
748 async fn one_hop_path_should_use_selector() {
749 let me = pubkey(&SECRET_ME);
750 let a = pubkey(&SECRET_A);
751 let dest = pubkey(&SECRET_DEST);
752
753 let graph = ChannelGraph::new(me);
754 graph.add_node(a);
755 graph.add_node(dest);
756 graph.add_edge(&me, &a).unwrap();
757 graph.add_edge(&a, &dest).unwrap();
758 mark_edge_full(&graph, &me, &a);
759 mark_edge_full(&graph, &a, &dest);
760
761 let cfg = small_config();
762 let selector = HoprGraphPathSelector::new(
763 me,
764 graph,
765 cfg.max_cached_paths,
766 cfg.edge_penalty,
767 cfg.min_ack_rate,
768 cfg.min_paths_anonymity_floor,
769 );
770
771 let chain_api = TestChainApi::new(me, me_addr(), vec![(a, a_addr()), (dest, dest_addr())])
772 .with_open_channel(me_addr(), a_addr())
773 .with_open_channel(a_addr(), dest_addr());
774
775 let surb_store = hopr_protocol_hopr::MemorySurbStore::default();
776 let planner = PathPlanner::new(me, surb_store, chain_api, selector, small_config());
777
778 let routing = DestinationRouting::Forward {
779 destination: Box::new(NodeId::Offchain(dest)),
780 pseudonym: None,
781 forward_options: RoutingOptions::Hops(1.try_into().expect("valid 1")),
782 return_options: None,
783 };
784
785 let result = planner.resolve_routing(100, 0, routing).await;
786 assert!(result.is_ok(), "1-hop routing should succeed: {:?}", result.err());
787
788 let (resolved, _) = result.unwrap();
789 if let ResolvedTransportRouting::Forward { forward_path, .. } = resolved {
790 assert_eq!(
791 forward_path.num_hops(),
792 2,
793 "1 intermediate hop means path has 2 nodes [a, dest]"
794 );
795 } else {
796 panic!("expected Forward routing");
797 }
798 }
799
800 #[tokio::test]
801 async fn explicit_intermediate_path_should_bypass_selector() {
802 let me = pubkey(&SECRET_ME);
803 let a = pubkey(&SECRET_A);
804 let dest = pubkey(&SECRET_DEST);
805
806 let graph = ChannelGraph::new(me);
808 let cfg = small_config();
809 let selector = HoprGraphPathSelector::new(
810 me,
811 graph,
812 cfg.max_cached_paths,
813 cfg.edge_penalty,
814 cfg.min_ack_rate,
815 cfg.min_paths_anonymity_floor,
816 );
817
818 let chain_api = TestChainApi::new(me, me_addr(), vec![(a, a_addr()), (dest, dest_addr())])
819 .with_open_channel(me_addr(), a_addr())
820 .with_open_channel(a_addr(), dest_addr());
821
822 let surb_store = hopr_protocol_hopr::MemorySurbStore::default();
823 let planner = PathPlanner::new(me, surb_store, chain_api, selector, small_config());
824
825 use hopr_api::types::primitive::prelude::BoundedVec;
826 let intermediate_path = BoundedVec::try_from(vec![NodeId::Offchain(a)]).expect("valid");
827
828 let routing = DestinationRouting::Forward {
829 destination: Box::new(NodeId::Offchain(dest)),
830 pseudonym: None,
831 forward_options: RoutingOptions::IntermediatePath(intermediate_path),
832 return_options: None,
833 };
834
835 let result = planner.resolve_routing(100, 0, routing).await;
836 assert!(result.is_ok(), "explicit path should succeed: {:?}", result.err());
837
838 let (resolved, _) = result.unwrap();
839 if let ResolvedTransportRouting::Forward { forward_path, .. } = resolved {
840 assert_eq!(forward_path.num_hops(), 2, "one intermediate + destination = 2 hops");
841 } else {
842 panic!("expected Forward routing");
843 }
844 }
845
846 #[tokio::test]
847 async fn return_routing_without_surb_should_return_error() {
848 let me = pubkey(&SECRET_ME);
849 let graph = ChannelGraph::new(me);
850 let cfg = small_config();
851 let selector = HoprGraphPathSelector::new(
852 me,
853 graph,
854 cfg.max_cached_paths,
855 cfg.edge_penalty,
856 cfg.min_ack_rate,
857 cfg.min_paths_anonymity_floor,
858 );
859 let chain_api = TestChainApi::new(me, me_addr(), vec![]);
860 let surb_store = hopr_protocol_hopr::MemorySurbStore::default();
861
862 let planner = PathPlanner::new(me, surb_store, chain_api, selector, small_config());
863
864 use hopr_api::types::internal::routing::SurbMatcher;
865 let matcher = SurbMatcher::Pseudonym(HoprPseudonym::random());
866 let routing = DestinationRouting::Return(matcher);
867
868 let result = planner.resolve_routing(0, 0, routing).await;
869 assert!(result.is_err(), "should fail when no SURB exists");
870 assert!(
871 matches!(result.unwrap_err(), PathPlannerError::Surb(_)),
872 "error should be Surb variant"
873 );
874 }
875
876 #[tokio::test]
879 async fn planner_cache_miss_should_populate_cache() {
880 let me = pubkey(&SECRET_ME);
881 let a = pubkey(&SECRET_A);
882 let dest = pubkey(&SECRET_DEST);
883
884 let graph = ChannelGraph::new(me);
885 graph.add_node(a);
886 graph.add_node(dest);
887 graph.add_edge(&me, &a).unwrap();
888 graph.add_edge(&a, &dest).unwrap();
889 mark_edge_full(&graph, &me, &a);
890 mark_edge_full(&graph, &a, &dest);
891
892 let cfg = small_config();
893 let selector = HoprGraphPathSelector::new(
894 me,
895 graph,
896 cfg.max_cached_paths,
897 cfg.edge_penalty,
898 cfg.min_ack_rate,
899 cfg.min_paths_anonymity_floor,
900 );
901 let chain_api = TestChainApi::new(me, me_addr(), vec![(a, a_addr()), (dest, dest_addr())])
902 .with_open_channel(me_addr(), a_addr())
903 .with_open_channel(a_addr(), dest_addr());
904 let surb_store = hopr_protocol_hopr::MemorySurbStore::default();
905 let planner = PathPlanner::new(me, surb_store, chain_api, selector, small_config());
906
907 let cache_key: PlannerCacheKey = (NodeId::Offchain(me), NodeId::Offchain(dest), 1);
908
909 assert!(
910 planner.cache.get(&cache_key).await.is_none(),
911 "cache should be empty before first call"
912 );
913
914 let routing = DestinationRouting::Forward {
915 destination: Box::new(NodeId::Offchain(dest)),
916 pseudonym: None,
917 forward_options: RoutingOptions::Hops(1.try_into().expect("valid 1")),
918 return_options: None,
919 };
920 planner.resolve_routing(100, 0, routing).await.expect("should succeed");
921
922 let cached = planner.cache.get(&cache_key).await;
923 assert!(cached.is_some(), "cache should be populated after first call");
924 let paths = cached.unwrap();
925 assert!(!paths.is_empty(), "cached paths must be non-empty");
926 let (first_path, first_cost) = paths.iter().next().expect("at least one cached path");
927 assert_eq!(first_path.num_hops(), 2, "path should have 2 hops [a, dest]");
928 assert!(*first_cost > 0.0, "cost should be positive");
929 }
930
931 #[tokio::test]
932 async fn planner_cache_hit_should_return_valid_path() {
933 let me = pubkey(&SECRET_ME);
934 let a = pubkey(&SECRET_A);
935 let dest = pubkey(&SECRET_DEST);
936
937 let graph = ChannelGraph::new(me);
938 graph.add_node(a);
939 graph.add_node(dest);
940 graph.add_edge(&me, &a).unwrap();
941 graph.add_edge(&a, &dest).unwrap();
942 mark_edge_full(&graph, &me, &a);
943 mark_edge_full(&graph, &a, &dest);
944
945 let cfg = small_config();
946 let selector = HoprGraphPathSelector::new(
947 me,
948 graph,
949 cfg.max_cached_paths,
950 cfg.edge_penalty,
951 cfg.min_ack_rate,
952 cfg.min_paths_anonymity_floor,
953 );
954 let chain_api = TestChainApi::new(me, me_addr(), vec![(a, a_addr()), (dest, dest_addr())])
955 .with_open_channel(me_addr(), a_addr())
956 .with_open_channel(a_addr(), dest_addr());
957 let surb_store = hopr_protocol_hopr::MemorySurbStore::default();
958 let planner = PathPlanner::new(me, surb_store, chain_api, selector, small_config());
959
960 let make_routing = || DestinationRouting::Forward {
961 destination: Box::new(NodeId::Offchain(dest)),
962 pseudonym: None,
963 forward_options: RoutingOptions::Hops(1.try_into().expect("valid 1")),
964 return_options: None,
965 };
966
967 let (r1, _) = planner.resolve_routing(100, 0, make_routing()).await.expect("call 1");
968 let (r2, _) = planner.resolve_routing(100, 0, make_routing()).await.expect("call 2");
969
970 let hops1 = if let ResolvedTransportRouting::Forward { forward_path, .. } = r1 {
971 forward_path.num_hops()
972 } else {
973 panic!("expected Forward");
974 };
975 let hops2 = if let ResolvedTransportRouting::Forward { forward_path, .. } = r2 {
976 forward_path.num_hops()
977 } else {
978 panic!("expected Forward");
979 };
980 assert_eq!(hops1, 2);
981 assert_eq!(hops2, 2);
982 }
983
984 #[tokio::test]
985 async fn background_refresh_should_produce_a_future() {
986 let me = pubkey(&SECRET_ME);
987 let graph = ChannelGraph::new(me);
988 let cfg = small_config();
989 let selector = HoprGraphPathSelector::new(
990 me,
991 graph,
992 cfg.max_cached_paths,
993 cfg.edge_penalty,
994 cfg.min_ack_rate,
995 cfg.min_paths_anonymity_floor,
996 );
997 let chain_api = TestChainApi::new(me, me_addr(), vec![]);
998 let surb_store = hopr_protocol_hopr::MemorySurbStore::default();
999
1000 let planner = PathPlanner::new(me, surb_store, chain_api, selector, small_config());
1001 let _future = planner.run_background_refresh();
1003 }
1004
1005 fn default_weighting() -> WeightingParams {
1008 WeightingParams {
1009 latency_halflife: Duration::from_millis(100),
1010 capacity_reference: 10_000_000,
1011 }
1012 }
1013
1014 fn make_pwm(cost: f64, latency_ms: Option<u32>, capacity_floor: Option<u128>) -> PathWithMetrics {
1015 PathWithMetrics {
1016 path: vec![],
1017 cost,
1018 total_latency_ms: latency_ms,
1019 min_probe_success_rate: None,
1020 min_ack_rate: None,
1021 capacity_floor,
1022 }
1023 }
1024
1025 #[test]
1026 fn latency_factor_is_monotonic_decreasing() {
1027 let halflife = Duration::from_millis(100);
1028 let f0 = latency_factor(Duration::ZERO, halflife);
1029 let f100 = latency_factor(Duration::from_millis(100), halflife);
1030 let f200 = latency_factor(Duration::from_millis(200), halflife);
1031 assert!(
1032 f0 > f100 && f100 > f200,
1033 "must be strictly decreasing: {f0} > {f100} > {f200}"
1034 );
1035 assert!(
1036 (f100 - 0.5).abs() < 1e-9,
1037 "at halflife factor should be 0.5, got {f100}"
1038 );
1039 assert!(f0 <= 1.0, "factor must never exceed 1.0, got {f0}");
1040 }
1041
1042 #[test]
1043 fn capacity_factor_is_monotonic_increasing() {
1044 let reference = 10_000_000u128;
1045 let f_low = capacity_factor(100, reference);
1046 let f_mid = capacity_factor(1_000_000, reference);
1047 let f_ref = capacity_factor(reference, reference);
1048 assert!(
1049 f_low < f_mid && f_mid <= f_ref,
1050 "must be non-decreasing: {f_low} < {f_mid} <= {f_ref}"
1051 );
1052 assert!(f_ref <= 1.0, "factor must not exceed 1.0 at reference, got {f_ref}");
1053 assert!(f_low >= 0.05, "minimum clamp is 0.05, got {f_low}");
1054 }
1055
1056 #[test]
1057 fn composite_weight_for_0_hop_skips_capacity_factor() {
1058 let params = default_weighting();
1059 let pwm = make_pwm(0.6, Some(100), None);
1060 let w = composite_weight(&pwm, 0, params);
1061 let expected = 0.6 * latency_factor(Duration::from_millis(100), params.latency_halflife);
1062 assert!(
1063 (w - expected).abs() < 1e-9,
1064 "0-hop weight should ignore capacity: {w} != {expected}"
1065 );
1066 assert!(w > 0.0, "0-hop weight must be positive");
1067 }
1068
1069 #[test]
1070 fn composite_weight_with_all_aggregates_is_below_cost() {
1071 let params = default_weighting();
1072 let pwm = make_pwm(0.8, Some(150), Some(5_000_000));
1073 let w = composite_weight(&pwm, 1, params);
1074 assert!(
1075 w < pwm.cost,
1076 "composite must be below raw cost when factors < 1.0: {w} >= {}",
1077 pwm.cost
1078 );
1079 assert!(w > 0.0, "composite weight must be positive");
1080 }
1081
1082 #[test]
1083 fn composite_weight_missing_capacity_on_multi_hop_neutral() {
1084 let params = default_weighting();
1085 let pwm_with = make_pwm(0.7, Some(80), Some(8_000_000));
1086 let pwm_without = make_pwm(0.7, Some(80), None);
1087 let w_with = composite_weight(&pwm_with, 2, params);
1088 let w_without = composite_weight(&pwm_without, 2, params);
1089 let expected_without = 0.7 * latency_factor(Duration::from_millis(80), params.latency_halflife);
1091 assert!((w_without - expected_without).abs() < 1e-9);
1092 assert!(w_with <= w_without, "known capacity should not increase the weight");
1094 }
1095}