Skip to main content

hopr_transport/
lib.rs

1//! The crate aggregates and composes individual transport level objects and functionality
2//! into a unified [`crate::HoprTransport`] object with the goal of isolating the transport layer
3//! and defining a fully specified transport API.
4//!
5//! See also the `hopr_protocol_start` crate for details on Start sub-protocol which initiates a Session.
6//!
7//! As such, the transport layer components should be only those that are directly necessary to:
8//!
9//! 1. send and receive a packet, acknowledgement or ticket aggregation request
10//! 2. send and receive a network telemetry request
11//! 3. automate transport level processes
12//! 4. algorithms associated with the transport layer operational management
13//! 5. interface specifications to allow modular behavioral extensions
14
15/// Configuration of the [crate::HoprTransport].
16pub mod config;
17/// Constants used and exposed by the crate.
18pub mod constants;
19/// Errors used by the crate.
20pub mod errors;
21/// Graph-based path planning for the HOPR transport layer.
22pub mod path;
23/// Transport binary protocol layer (codec, pipeline, heartbeat, stream).
24pub mod protocol;
25/// Test utilities: emulated peer wiring, stub chain API, shared keypair/payload fixtures.
26/// Enabled by the `testing` feature; every item is a zero-cost no-op stub when absent.
27#[cfg(feature = "testing")]
28pub mod testing;
29
30mod multiaddrs;
31
32#[cfg(feature = "capture")]
33mod capture;
34mod pipeline;
35pub mod socket;
36
37use std::{
38    sync::{Arc, OnceLock},
39    time::Duration,
40};
41
42use constants::MAXIMUM_MSG_OUTGOING_BUFFER_SIZE;
43use futures::{FutureExt, SinkExt, StreamExt, channel::mpsc::Sender, stream::select_with_strategy};
44pub use hopr_api::{
45    Multiaddr, PeerId,
46    network::{Health, traits::NetworkView},
47    types::{
48        crypto::{
49            keypairs::{ChainKeypair, Keypair, OffchainKeypair},
50            types::{HalfKeyChallenge, Hash, OffchainPublicKey},
51        },
52        internal::{prelude::HoprPseudonym, routing::RoutingOptions},
53    },
54};
55use hopr_api::{
56    chain::{ChainKeyOperations, ChainReadAccountOperations, ChainReadChannelOperations, ChainValues},
57    ct::{CoverTrafficGeneration, ProbingTrafficGeneration},
58    graph::{NetworkGraphUpdate, NetworkGraphView, traits::EdgeObservableRead},
59    network::{BoxedProcessFn, NetworkStreamControl},
60    types::primitive::prelude::*,
61};
62pub use hopr_protocol_app::prelude::{ApplicationData, ApplicationDataIn, ApplicationDataOut, Tag};
63pub use hopr_protocol_hopr::{MemorySurbStore, SurbStore};
64pub use hopr_transport_probe::{NeighborTelemetry, PathTelemetry, errors::ProbeError, ping::PingQueryReplier};
65use hopr_transport_probe::{
66    Probe,
67    ping::{PingConfig, Pinger},
68};
69pub use hopr_transport_session as session;
70pub use hopr_transport_session::{
71    Capabilities as SessionCapabilities, Capability as SessionCapability, FlowControlConfig, HoprSession,
72    IncomingSession, SESSION_MTU, SURB_SIZE, ServiceId, SessionClientConfig, SessionId, SessionTarget,
73    SurbBalancerConfig,
74    errors::{SessionManagerError, TransportSessionError},
75};
76use hopr_transport_session::{DispatchResult, SessionManager, SessionManagerConfig};
77#[cfg(feature = "telemetry")]
78pub use hopr_transport_session::{SessionAckMode, SessionLifecycleState};
79#[cfg(feature = "runtime-tokio")]
80pub use hopr_transport_session::{transfer_session, transfer_session_datagram};
81pub use hopr_transport_tag_allocator::TagAllocatorConfig;
82use hopr_utils::{
83    network_types::{
84        crossfire_sink::{CrossfireSink, bounded_sink_channel},
85        prelude::*,
86    },
87    runtime::AbortableList,
88};
89pub use multiaddr::Protocol;
90use tracing::{debug, warn};
91
92#[cfg(feature = "runtime-tokio")]
93use crate::path::BackgroundPathCacheRefreshable;
94pub use crate::{config::HoprProtocolConfig, protocol::PeerProtocolCounterRegistry};
95use crate::{
96    constants::SESSION_INITIATION_TIMEOUT_BASE,
97    errors::HoprTransportError,
98    multiaddrs::strip_p2p_protocol,
99    path::{HoprGraphPathSelector, PathPlanner},
100    pipeline::HoprPacketPipelineBuilder,
101    socket::HoprSocket,
102};
103
104pub const APPLICATION_TAG_RANGE: std::ops::Range<Tag> = Tag::APPLICATION_TAG_RANGE;
105
106pub use hopr_api as api;
107use hopr_api::{
108    chain::{ChainReadTicketOperations, ChainWriteTicketOperations},
109    tickets::TicketFactory,
110    types::internal::routing::DestinationRouting,
111};
112
113// Needs lazy-static, since Duration multiplication by a constant is yet not a const-operation.
114lazy_static::lazy_static! {
115    static ref SESSION_INITIATION_TIMEOUT_MAX: Duration = 2 * SESSION_INITIATION_TIMEOUT_BASE * RoutingOptions::MAX_INTERMEDIATE_HOPS as u32;
116
117    static ref PEER_ID_CACHE: moka::sync::Cache<PeerId, OffchainPublicKey> = moka::sync::Cache::builder()
118        .time_to_idle(Duration::from_mins(15))
119        .max_capacity(10_000)
120        .build();
121
122    static ref RANDOM_DATA: [u8; 400] = hopr_api::types::crypto_random::random_bytes();
123}
124
125/// PeerId -> OffchainPublicKey is a CPU-intensive blocking operation.
126///
127/// This helper uses a cached static object to speed up the lookup and avoid blocking the async
128/// runtime on repeated conversions for the same [`PeerId`]s.
129pub fn peer_id_to_public_key(peer_id: &PeerId) -> crate::errors::Result<OffchainPublicKey> {
130    PEER_ID_CACHE
131        .try_get_with_by_ref(peer_id, move || {
132            OffchainPublicKey::from_peerid(peer_id).map_err(|e| e.into())
133        })
134        .map_err(|e: Arc<HoprTransportError>| {
135            crate::errors::HoprTransportError::Other(anyhow::anyhow!(
136                "failed to convert peer_id ({:?}) to an offchain public key: {e}",
137                peer_id
138            ))
139        })
140}
141
142#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, strum::Display)]
143pub enum HoprTransportProcess {
144    #[strum(to_string = "component responsible for the transport medium (libp2p swarm)")]
145    Medium,
146    #[strum(to_string = "HOPR packet pipeline ({0})")]
147    Pipeline(protocol::PacketPipelineProcesses),
148    #[strum(to_string = "session manager sub-process #{0}")]
149    SessionsManagement(usize),
150    #[strum(to_string = "network probing sub-process: {0}")]
151    Probing(hopr_transport_probe::HoprProbeProcess),
152    #[cfg(feature = "runtime-tokio")]
153    #[strum(to_string = "path cache refresh")]
154    PathRefresh,
155    #[strum(to_string = "sync of outgoing ticket indices")]
156    OutgoingIndexSync,
157    #[strum(to_string = "periodic protocol counter flush")]
158    CounterFlush,
159    /// Periodically reports SURB round-trip counts into the network graph.
160    SurbFlush,
161    #[strum(to_string = "mixer→wire forwarder")]
162    MixerForwarder,
163    #[cfg(feature = "capture")]
164    #[strum(to_string = "packet capture")]
165    Capture,
166}
167
168/// HOPR protocol specific instantiation of the SessionManager.
169type HoprSessionManager = SessionManager<CrossfireSink<(DestinationRouting, ApplicationDataOut)>>;
170
171/// Allows configuration of one specific [`HoprSession`].
172///
173/// The configurator does not prevent the Session from being closed
174/// or the Session manager from being dropped.
175#[derive(Debug, Clone)]
176pub struct HoprSessionConfigurator {
177    id: SessionId,
178    // Makes sure configurator does not extend lifetime of the SessionManager.
179    smgr: std::sync::Weak<HoprSessionManager>,
180}
181
182impl HoprSessionConfigurator {
183    /// [`SessionId`] of the session this object can configure.
184    pub fn id(&self) -> &SessionId {
185        &self.id
186    }
187
188    /// Sends a Session Keep-Alive packet over the Session.
189    ///
190    /// NOTE: This usually carries at least 2 SURBs on the HOPR protocol level and can be
191    /// used for manual SURB balancing.
192    ///
193    /// NOTE: This operation only sends the Session Keep-Alive packet and **DOES NOT** guarantee the other side
194    /// has received it.
195    pub async fn ping(&self) -> errors::Result<()> {
196        Ok(self
197            .smgr
198            .upgrade()
199            .ok_or(HoprTransportError::Other(anyhow::anyhow!("session manager is dropped")))?
200            .ping_session(&self.id)
201            .await?)
202    }
203
204    /// Gets the configuration of the SURB balancer.
205    ///
206    /// Returns an error if the Session is closed, the Session manager is gone.
207    ///
208    /// Returns `Ok(None)` if the Session has been created without a SURB balancer.
209    pub fn get_surb_balancer_config(&self) -> errors::Result<Option<SurbBalancerConfig>> {
210        Ok(self
211            .smgr
212            .upgrade()
213            .ok_or(HoprTransportError::Other(anyhow::anyhow!("session manager is dropped")))?
214            .get_surb_balancer_config(&self.id)?)
215    }
216
217    /// Updates the configuration of the SURB balancer.
218    ///
219    /// Returns an error if the Session is closed, the Session manager is gone, or the
220    /// Session has been created without a SURB balancer.
221    pub fn update_surb_balancer_config(&self, config: SurbBalancerConfig) -> errors::Result<()> {
222        Ok(self
223            .smgr
224            .upgrade()
225            .ok_or(HoprTransportError::Other(anyhow::anyhow!("session manager is dropped")))?
226            .update_surb_balancer_config(&self.id, config)?)
227    }
228
229    /// Explicitly closes the underlying Session in the [`SessionManager`].
230    ///
231    /// Returns `true` if the session was found and closed, `false` if it was
232    /// already gone (or the manager is dropped). Frees the per-session state
233    /// (frame reassembly buffers, control channels, …) immediately rather than
234    /// waiting for the manager's idle-timeout eviction.
235    pub fn close(&self) -> bool {
236        match self.smgr.upgrade() {
237            Some(smgr) => smgr.close_session(&self.id),
238            None => false,
239        }
240    }
241}
242
243/// Interface into the physical transport mechanism allowing all off-chain HOPR-related tasks on
244/// the transport.
245pub struct HoprTransport<Chain, Graph, Net> {
246    packet_key: OffchainKeypair,
247    chain_key: ChainKeypair,
248    chain_api: Chain,
249    ping: Arc<OnceLock<Pinger>>,
250    network: Arc<OnceLock<Net>>,
251    graph: Graph,
252    path_planner: PathPlanner<MemorySurbStore, Chain, HoprGraphPathSelector<Graph>>,
253    my_multiaddresses: Vec<Multiaddr>,
254    smgr: Arc<HoprSessionManager>,
255    session_telemetry_tag_allocator: Arc<dyn hopr_transport_tag_allocator::TagAllocator + Send + Sync>,
256    probing_tag_allocator: Arc<dyn hopr_transport_tag_allocator::TagAllocator + Send + Sync>,
257    counters: PeerProtocolCounterRegistry,
258    cfg: HoprProtocolConfig,
259}
260
261impl<Chain, Graph, Net> HoprTransport<Chain, Graph, Net>
262where
263    Chain: ChainReadChannelOperations
264        + ChainReadAccountOperations
265        + ChainWriteTicketOperations
266        + ChainKeyOperations
267        + ChainReadTicketOperations
268        + ChainValues
269        + Clone
270        + Send
271        + Sync
272        + 'static,
273    Graph: NetworkGraphView<NodeId = OffchainPublicKey>
274        + NetworkGraphUpdate
275        + hopr_api::graph::NetworkGraphWrite<NodeId = OffchainPublicKey>
276        + hopr_api::graph::NetworkGraphTraverse<NodeId = OffchainPublicKey>
277        + Clone
278        + Send
279        + Sync
280        + 'static,
281    <Graph as NetworkGraphView>::Observed: hopr_api::graph::traits::EdgeObservableRead + Send,
282    <Graph as hopr_api::graph::NetworkGraphTraverse>::Observed:
283        hopr_api::graph::traits::EdgeObservableRead + Send + 'static,
284    <Graph as hopr_api::graph::NetworkGraphWrite>::Observed: hopr_api::graph::traits::EdgeObservableWrite + Send,
285    Net: NetworkView + NetworkStreamControl + Clone + Send + Sync + 'static,
286{
287    pub fn new(
288        identity: (&ChainKeypair, &OffchainKeypair),
289        resolver: Chain,
290        graph: Graph,
291        my_multiaddresses: Vec<Multiaddr>,
292        cfg: HoprProtocolConfig,
293    ) -> errors::Result<Self> {
294        let me_offchain = *identity.1.public();
295        let planner_config = cfg.path_planner;
296        let selector = HoprGraphPathSelector::new(
297            me_offchain,
298            graph.clone(),
299            planner_config.max_cached_paths,
300            planner_config.edge_penalty,
301            planner_config.min_ack_rate,
302            planner_config.min_paths_anonymity_floor,
303        );
304
305        let tag_allocators = hopr_transport_tag_allocator::create_allocators_from_config(&cfg.session.tag_allocator)?;
306
307        let mut session_telemetry_tag_allocator = None;
308        let mut probing_tag_allocator = None;
309        for (usage, alloc) in tag_allocators {
310            match usage {
311                // TODO: cleanup of Session tag allocators needed * (#8199)
312                hopr_transport_tag_allocator::Usage::Session => {}
313                hopr_transport_tag_allocator::Usage::SessionTerminalTelemetry => {
314                    session_telemetry_tag_allocator = Some(alloc)
315                }
316                hopr_transport_tag_allocator::Usage::ProvingTelemetry => probing_tag_allocator = Some(alloc),
317            }
318        }
319        let session_telemetry_tag_allocator = session_telemetry_tag_allocator
320            .ok_or_else(|| HoprTransportError::Api("session telemetry tag allocator missing".into()))?;
321        let probing_tag_allocator =
322            probing_tag_allocator.ok_or_else(|| HoprTransportError::Api("probing tag allocator missing".into()))?;
323
324        // A pseudonym's SURB ring buffer is dropped once no SURBs have arrived for
325        // `pseudonyms_lifetime` (600 s by default), which is the point at which that pseudonym's
326        // return path becomes permanently unresolvable. Nothing else in the node can be made to
327        // reach that state on a test timescale — the Session slot is evicted for idleness long
328        // before it — so without a way to shorten this timer, the behaviour past it is not
329        // observable end-to-end at all. Floored at the same `MINIMUM_SURB_LIFETIME` the config
330        // validator enforces, so this cannot reach a value the config file could not also express.
331        let surb_store_cfg = hopr_protocol_hopr::SurbStoreConfig {
332            pseudonyms_lifetime: std::env::var("HOPR_INTERNAL_SURB_PSEUDONYM_LIFETIME_MS")
333                .ok()
334                .and_then(|s| s.trim().parse::<u64>().ok())
335                .map(Duration::from_millis)
336                .map(|d| d.max(hopr_protocol_hopr::MINIMUM_SURB_LIFETIME))
337                .unwrap_or(cfg.packet.surb_store.pseudonyms_lifetime),
338            ..cfg.packet.surb_store
339        };
340
341        // Built before the session manager so the latter can be handed the seam that lets a
342        // session re-plan its return path on sustained loss.
343        let path_planner = PathPlanner::new(
344            me_offchain,
345            MemorySurbStore::new(surb_store_cfg),
346            resolver.clone(),
347            selector,
348            planner_config,
349        );
350
351        Ok(Self {
352            packet_key: identity.1.clone(),
353            chain_key: identity.0.clone(),
354            ping: Arc::new(OnceLock::new()),
355            network: Arc::new(OnceLock::new()),
356            graph,
357            path_planner,
358            my_multiaddresses,
359            smgr: Arc::new(SessionManager::new(SessionManagerConfig {
360                frame_mtu: std::env::var("HOPR_SESSION_FRAME_SIZE")
361                    .ok()
362                    .and_then(|s| s.parse::<usize>().ok())
363                    .unwrap_or_else(|| SessionManagerConfig::default().frame_mtu)
364                    .max(SESSION_MTU),
365                max_frame_timeout: std::env::var("HOPR_SESSION_FRAME_TIMEOUT_MS")
366                    .ok()
367                    .and_then(|s| s.parse::<u64>().ok().map(Duration::from_millis))
368                    .unwrap_or_else(|| SessionManagerConfig::default().max_frame_timeout)
369                    .max(Duration::from_millis(100)),
370                max_frames_behind_gap: cfg.session.max_frames_behind_gap,
371                max_buffered_segments: std::env::var("HOPR_SESSION_MAX_BUFFERED_SEGMENTS")
372                    .ok()
373                    .and_then(|s| s.parse::<usize>().ok())
374                    .unwrap_or_else(|| SessionManagerConfig::default().max_buffered_segments),
375                initiation_timeout_base: SESSION_INITIATION_TIMEOUT_BASE,
376                idle_timeout: cfg.session.idle_timeout,
377                balancer_sampling_interval: cfg.session.balancer_sampling_interval,
378                initial_return_session_egress_rate: 10,
379                minimum_surb_buffer_duration: cfg.session.balancer_minimum_surb_buffer_duration,
380                maximum_surb_buffer_size: cfg.packet.surb_store.rb_capacity,
381                // The lower bound is enforced once in `SessionManager::new` via
382                // `MIN_SURB_BUFFER_NOTIFICATION_PERIOD`; don't duplicate that floor as a literal here.
383                surb_balance_notify_period: std::env::var("HOPR_SESSION_SURB_BALANCE_NOTIFY_PERIOD_MS")
384                    .ok()
385                    .and_then(|s| s.parse::<u64>().ok())
386                    .map(|ms| Some(Duration::from_millis(ms)))
387                    .unwrap_or(cfg.session.surb_balance_notify_period),
388                surb_target_notify: true,
389                maximum_sessions: cfg.session.maximum_managed_sessions,
390                ..Default::default()
391            })),
392            chain_api: resolver,
393            session_telemetry_tag_allocator,
394            probing_tag_allocator,
395            counters: PeerProtocolCounterRegistry::default(),
396            cfg,
397        })
398    }
399
400    /// Execute all processes of the [`HoprTransport`] object as a **Relay** node.
401    ///
402    /// Relay nodes run the full packet pipeline including incoming ticket/acknowledgement
403    /// processing and require a [`futures::Sink`] for ticket events as well as an
404    /// `on_incoming_session` channel from the SessionManager (they can accept incoming sessions).
405    pub async fn run_relay<T, TFact, Ct>(
406        &self,
407        cover_traffic: Ct,
408        network: Net,
409        network_process: BoxedProcessFn,
410        ticket_events: T,
411        ticket_factory: TFact,
412        on_incoming_session: Sender<IncomingSession>,
413    ) -> errors::Result<(
414        HoprSocket<
415            futures::stream::BoxStream<'static, ApplicationDataIn>,
416            CrossfireSink<(DestinationRouting, ApplicationDataOut)>,
417        >,
418        AbortableList<HoprTransportProcess>,
419    )>
420    where
421        T: futures::Sink<hopr_api::node::TicketEvent> + Clone + Send + Unpin + 'static,
422        T::Error: std::error::Error + Clone + Send,
423        Ct: ProbingTrafficGeneration + CoverTrafficGeneration + Send + Sync + 'static,
424        TFact: TicketFactory + Clone + Send + Sync + 'static,
425    {
426        self.run_inner(
427            protocol::NodeType::Relay,
428            cover_traffic,
429            network,
430            network_process,
431            ticket_events,
432            ticket_factory,
433            Some(on_incoming_session),
434        )
435        .await
436    }
437
438    /// Execute all processes of the [`HoprTransport`] object as an **Exit** (destination) node.
439    ///
440    /// Exit nodes do not process tickets but keep the incoming acknowledgement
441    /// pipeline running and can accept incoming sessions via SessionManager.
442    pub async fn run_exit<TFact, Ct>(
443        &self,
444        cover_traffic: Ct,
445        network: Net,
446        network_process: BoxedProcessFn,
447        ticket_factory: TFact,
448        on_incoming_session: Sender<IncomingSession>,
449    ) -> errors::Result<(
450        HoprSocket<
451            futures::stream::BoxStream<'static, ApplicationDataIn>,
452            CrossfireSink<(DestinationRouting, ApplicationDataOut)>,
453        >,
454        AbortableList<HoprTransportProcess>,
455    )>
456    where
457        Ct: ProbingTrafficGeneration + CoverTrafficGeneration + Send + Sync + 'static,
458        TFact: TicketFactory + Clone + Send + Sync + 'static,
459    {
460        self.run_inner(
461            protocol::NodeType::Exit,
462            cover_traffic,
463            network,
464            network_process,
465            futures::sink::drain(),
466            ticket_factory,
467            Some(on_incoming_session),
468        )
469        .await
470    }
471
472    /// Execute all processes of the [`HoprTransport`] object as an **Entry** (source) node.
473    ///
474    /// Entry nodes do not process tickets, do not start the incoming acknowledgement
475    /// pipeline, and do not accept incoming sessions — therefore, they require neither a
476    /// `ticket_events` sink nor an `on_incoming_session` channel.
477    pub async fn run_entry<TFact, Ct>(
478        &self,
479        cover_traffic: Ct,
480        network: Net,
481        network_process: BoxedProcessFn,
482        ticket_factory: TFact,
483    ) -> errors::Result<(
484        HoprSocket<
485            futures::stream::BoxStream<'static, ApplicationDataIn>,
486            CrossfireSink<(DestinationRouting, ApplicationDataOut)>,
487        >,
488        AbortableList<HoprTransportProcess>,
489    )>
490    where
491        Ct: ProbingTrafficGeneration + CoverTrafficGeneration + Send + Sync + 'static,
492        TFact: TicketFactory + Clone + Send + Sync + 'static,
493    {
494        self.run_inner(
495            protocol::NodeType::Entry,
496            cover_traffic,
497            network,
498            network_process,
499            futures::sink::drain(),
500            ticket_factory,
501            None,
502        )
503        .await
504    }
505
506    /// Internal worker driving all node-type variants of `HoprTransport::run_*`.
507    ///
508    /// Branches on `role`:
509    /// - [`protocol::NodeType::Relay`]: full packet pipeline + SessionManager.
510    /// - [`protocol::NodeType::Exit`]: ack-drain pipeline + incoming Sessions.
511    /// - [`protocol::NodeType::Entry`]: no ack pipeline, no incoming Sessions.
512    #[allow(clippy::too_many_arguments)]
513    async fn run_inner<T, TFact, Ct>(
514        &self,
515        role: protocol::NodeType,
516        cover_traffic: Ct,
517        network: Net,
518        network_process: BoxedProcessFn,
519        ticket_events: T,
520        ticket_factory: TFact,
521        on_incoming_session: Option<Sender<IncomingSession>>,
522    ) -> errors::Result<(
523        HoprSocket<
524            futures::stream::BoxStream<'static, ApplicationDataIn>,
525            CrossfireSink<(DestinationRouting, ApplicationDataOut)>,
526        >,
527        AbortableList<HoprTransportProcess>,
528    )>
529    where
530        T: futures::Sink<hopr_api::node::TicketEvent> + Clone + Send + Unpin + 'static,
531        T::Error: std::error::Error + Clone + Send,
532        Ct: ProbingTrafficGeneration + CoverTrafficGeneration + Send + Sync + 'static,
533        TFact: TicketFactory + Clone + Send + Sync + 'static,
534    {
535        let mut processes = AbortableList::<HoprTransportProcess>::default();
536
537        let (unresolved_routing_msg_tx, unresolved_routing_msg_rx) =
538            bounded_sink_channel::<(DestinationRouting, ApplicationDataOut)>(MAXIMUM_MSG_OUTGOING_BUFFER_SIZE);
539
540        // -- transport medium
541
542        let transport_network = network;
543        let transport_layer_process = network_process;
544
545        let msg_codec = crate::protocol::HoprBinaryCodec {};
546        let (wire_msg_tx, wire_msg_rx) =
547            protocol::stream::process_stream_protocol(msg_codec, transport_network.clone(), self.cfg.stream).await?;
548
549        // Shared mixing channel: all per-destination clones of `mixing_channel_tx` push into one
550        // heap, so cross-destination packets are mixed together rather than each destination
551        // getting its own independent delay queue. The single forwarder task owns the receiver
552        // (and therefore the heap timer) — no per-clone waker coordination is needed.
553        let mut mixer_cfg = self.cfg.mixer;
554        mixer_cfg.metric_delay_window = u64::try_from(5 * mixer_cfg.delay_range.as_millis())
555            .unwrap_or(u64::MAX)
556            .max(1);
557        let (mixing_channel_tx, mix_rx) = hopr_transport_mixer::channel(mixer_cfg);
558        let transit_latency_cfg = self.cfg.transit_latency;
559        processes.insert(
560            HoprTransportProcess::MixerForwarder,
561            hopr_utils::spawn_as_abortable!(async move {
562                let mut mix_rx = mix_rx;
563                let mut wire_sink = wire_msg_tx;
564
565                if let Some(lat) = transit_latency_cfg {
566                    // Concurrent transit-latency fan-out: spawn one short-lived task per
567                    // packet so each packet ages through its own ~mean delay independently.
568                    // A burst of N packets at mean=50 ms takes ~50 ms total, not N×50 ms.
569                    //
570                    // Without a tokio runtime the latency is silently skipped (pass-through).
571                    #[cfg(feature = "runtime-tokio")]
572                    {
573                        let (tx, rx) = futures::channel::mpsc::unbounded();
574                        let fan_out = async move {
575                            while let Some(item) = futures::StreamExt::next(&mut mix_rx).await {
576                                let mean_us = lat.mean.as_micros() as f64;
577                                let std_us = lat.std_dev.as_micros() as f64;
578                                let delay_us = if std_us > 0.0 {
579                                    use rand_distr::{Distribution, Normal};
580                                    Normal::new(mean_us, std_us)
581                                        .expect("transit latency Normal params are valid")
582                                        .sample(&mut rand::rng())
583                                        .max(0.0_f64)
584                                } else {
585                                    mean_us.max(0.0)
586                                };
587                                let delay = Duration::from_micros(delay_us as u64);
588                                let item_tx = tx.clone();
589                                hopr_utils::runtime::prelude::spawn(async move {
590                                    if !delay.is_zero() {
591                                        futures_timer::Delay::new(delay).await;
592                                    }
593                                    let _ = item_tx.unbounded_send(item);
594                                });
595                            }
596                            // `tx` drops here; the channel closes once all per-packet tasks send
597                        };
598                        let fan_in = async move {
599                            let mut rx = rx;
600                            while let Some(item) = futures::StreamExt::next(&mut rx).await {
601                                if wire_sink.send(item).await.is_err() {
602                                    tracing::error!(
603                                        task = %HoprTransportProcess::MixerForwarder,
604                                        "wire sink dropped — discarding transit-delayed packet"
605                                    );
606                                    break;
607                                }
608                            }
609                        };
610                        futures::join!(fan_out, fan_in);
611                    }
612                    #[cfg(not(feature = "runtime-tokio"))]
613                    {
614                        let _ = lat;
615                        while let Some(item) = futures::StreamExt::next(&mut mix_rx).await {
616                            if wire_sink.send(item).await.is_err() {
617                                tracing::error!(
618                                    task = %HoprTransportProcess::MixerForwarder,
619                                    "wire sink dropped — discarding mixed packet"
620                                );
621                            }
622                        }
623                    }
624                } else {
625                    while let Some(item) = futures::StreamExt::next(&mut mix_rx).await {
626                        if wire_sink.send(item).await.is_err() {
627                            tracing::error!(
628                                task = %HoprTransportProcess::MixerForwarder,
629                                "wire sink dropped — discarding mixed packet"
630                            );
631                        }
632                    }
633                }
634
635                tracing::warn!(
636                    task = %HoprTransportProcess::MixerForwarder,
637                    "long-running background task finished"
638                );
639            }),
640        );
641
642        // -- path cache background refresh (only when tokio runtime is available)
643        #[cfg(feature = "runtime-tokio")]
644        processes.insert(
645            HoprTransportProcess::PathRefresh,
646            hopr_utils::spawn_as_abortable!(self.path_planner.run_background_refresh()),
647        );
648
649        processes.insert(
650            HoprTransportProcess::Medium,
651            hopr_utils::spawn_as_abortable!(transport_layer_process().inspect(|_| tracing::warn!(
652                task = %HoprTransportProcess::Medium,
653                "long-running background task finished"
654            ))),
655        );
656
657        let msg_protocol_bidirectional_channel_capacity =
658            std::env::var("HOPR_INTERNAL_PROTOCOL_BIDIRECTIONAL_CHANNEL_CAPACITY")
659                .ok()
660                .and_then(|s| s.trim().parse::<usize>().ok())
661                .filter(|&c| c > 0)
662                .unwrap_or(16_384);
663
664        debug!(
665            capacity = msg_protocol_bidirectional_channel_capacity,
666            "creating protocol bidirectional channel"
667        );
668        let (tx_from_protocol, rx_from_protocol) =
669            bounded_sink_channel::<(HoprPseudonym, ApplicationDataIn)>(msg_protocol_bidirectional_channel_capacity);
670
671        // === START === cover traffic control
672        // Allocate a cover traffic tag from the session telemetry partition to avoid
673        // collisions with session and probing tags.
674        let cover_traffic_allocated_tag = self
675            .session_telemetry_tag_allocator
676            .allocate()
677            .ok_or_else(|| HoprTransportError::Api("failed to allocate cover traffic tag".into()))?;
678        let cover_traffic_tag: Tag = cover_traffic_allocated_tag.value().into();
679
680        // filter out the known cover traffic not to lose processing time with it
681        // The allocated tag is moved into the closure to keep it alive for the transport lifetime.
682        let rx_from_protocol = rx_from_protocol.filter_map(move |(pseudonym, data)| {
683            let _keep_alive = &cover_traffic_allocated_tag;
684            async move { (data.data.application_tag != cover_traffic_tag).then_some((pseudonym, data)) }
685        });
686
687        // prepare a cover traffic stream
688        let cover_traffic_stream = CoverTrafficGeneration::build(&cover_traffic).filter_map(move |routing| {
689            let start =
690                hopr_api::types::crypto_random::random_integer(0, Some((RANDOM_DATA.len() - 100) as u64)) as usize;
691            let data = &RANDOM_DATA[start..start + 100];
692
693            futures::future::ready(if let Ok(data) = ApplicationData::new(cover_traffic_tag, data) {
694                Some((routing, ApplicationDataOut::with_no_packet_info(data)))
695            } else {
696                tracing::error!("failed to construct cover traffic packet");
697                None
698            })
699        });
700
701        // merge cover traffic with other outgoing data
702        let merged_unresolved_output_data =
703            select_with_strategy(unresolved_routing_msg_rx, cover_traffic_stream, |_: &mut ()| {
704                futures::stream::PollNext::Left
705            });
706
707        // === END === cover traffic control
708
709        // We have to resolve DestinationRouting -> ResolvedTransportRouting before
710        // sending the external packets to the transport pipeline. Concurrency matches
711        // the encoder stage (output_concurrency) to avoid head-of-line blocking on
712        // cache-miss path lookups.
713        let path_planner = self.path_planner.clone();
714        let distress_threshold = self.cfg.packet.surb_store.distress_threshold;
715        let routing_concurrency = {
716            let avail = std::thread::available_parallelism()
717                .ok()
718                .map(|n| n.get())
719                .unwrap_or(1)
720                .max(1)
721                * 8;
722            self.cfg
723                .packet
724                .pipeline
725                .output_concurrency
726                .filter(|&n| n > 0)
727                .unwrap_or(avail)
728        };
729        let all_resolved_external_msg_rx = crate::path::resolve::resolve_routing_stage(
730            merged_unresolved_output_data,
731            move |size_hint, max_surbs, unresolved| {
732                let path_planner = path_planner.clone();
733                async move { path_planner.resolve_routing(size_hint, max_surbs, unresolved).await }
734            },
735            distress_threshold,
736            routing_concurrency,
737            crate::path::resolve::surb_resolution_wait(self.cfg.packet.pipeline.surb_resolution_wait),
738        );
739
740        let channels_dst = self
741            .chain_api
742            .domain_separators()
743            .await
744            .map_err(HoprTransportError::chain)?
745            .channel;
746
747        let surb_round_trips = protocol::surb_telemetry::SurbRoundTripRegistry::default();
748
749        let pipeline_builder = HoprPacketPipelineBuilder::new()
750            .identity((&self.chain_key, &self.packet_key))
751            .transport((mixing_channel_tx, wire_msg_rx))
752            .api((tx_from_protocol, all_resolved_external_msg_rx))
753            .surb_store(self.path_planner.surb_store.clone())
754            .chain_api(self.chain_api.clone())
755            .ticket_factory(ticket_factory)
756            .channels_dst(channels_dst)
757            .with_counters(self.counters.clone())
758            .with_surb_telemetry(
759                surb_round_trips.clone(),
760                protocol::surb_telemetry::path_slots_of(self.graph.clone()),
761            )
762            .with_config(self.cfg.packet);
763
764        let pipeline_processes = match role {
765            protocol::NodeType::Relay => pipeline_builder.with_ticket_events(ticket_events).build_for_relay(),
766            protocol::NodeType::Exit => pipeline_builder.build_for_exit(),
767            protocol::NodeType::Entry => pipeline_builder.build_for_entry(),
768        };
769        processes.extend_from(pipeline_processes);
770
771        // -- periodic counter flush
772        let flush_counters = self.counters.clone();
773        let flush_graph = self.graph.clone();
774        let flush_me = *self.packet_key.public();
775        let flush_interval = self.cfg.counter_flush_interval;
776        processes.insert(
777            HoprTransportProcess::CounterFlush,
778            hopr_utils::spawn_as_abortable!(async move {
779                use hopr_api::graph::traits::{EdgeObservableWrite, EdgeWeightType};
780
781                futures_time::stream::interval(futures_time::time::Duration::from(flush_interval))
782                    .for_each(|_| {
783                        for (peer, num_packets, num_acks) in flush_counters.drain() {
784                            tracing::trace!(
785                                %peer,
786                                num_packets,
787                                num_acks,
788                                "flushing protocol conformance counters"
789                            );
790                            flush_graph.upsert_edge(&flush_me, &peer, |obs| {
791                                obs.record(EdgeWeightType::ImmediateProtocolConformance { num_packets, num_acks });
792                            });
793                        }
794                        futures::future::ready(())
795                    })
796                    .await;
797            }),
798        );
799
800        // -- periodic SURB round-trip flush
801        tracing::info!(?role, "starting surb round-trip flush task");
802        // Long enough to outlast the silence gate that produced the evidence, so a path that stays
803        // dead is re-marked before the previous mark lapses, and short enough that a path which
804        // recovers unnoticed returns to closed-loop control promptly.
805        const RETURN_PATH_DEGRADED_GRACE: std::time::Duration = std::time::Duration::from_secs(10);
806        let surb_flush_graph = self.graph.clone();
807        let surb_flush_interval = self.cfg.surb_flush_interval;
808        let surb_flush_smgr = self.smgr.clone();
809        let surb_flush_chain = self.chain_api.clone();
810        let surb_flush_planner = self.path_planner.clone();
811        processes.insert(
812            HoprTransportProcess::SurbFlush,
813            hopr_utils::spawn_as_abortable!(async move {
814                let mut episodes = protocol::return_path_recovery::ReturnPathEpisodes::new(RETURN_PATH_DEGRADED_GRACE);
815                let mut ticks = futures_time::stream::interval(futures_time::time::Duration::from(surb_flush_interval));
816
817                while ticks.next().await.is_some() {
818                    // Re-plan first, refill only if re-planning moved traffic. Run the other way
819                    // round the refill mints SURBs onto the very route the planner is abandoning --
820                    // see `protocol::return_path_recovery`. Detection/flush/tick ordering lives in
821                    // `run_flush_tick`.
822                    //
823                    // Borrowed rather than cloned per call: the callbacks are `FnMut`, so anything
824                    // they capture has to survive being invoked once per silent destination.
825                    let (planner, chain, smgr) = (&surb_flush_planner, &surb_flush_chain, &surb_flush_smgr);
826                    let now_ms = hopr_utils::platform::time::native::current_time()
827                        .as_unix_timestamp()
828                        .as_millis();
829                    for step in protocol::return_path_recovery::run_flush_tick(
830                        &surb_round_trips,
831                        &surb_flush_graph,
832                        now_ms,
833                        &mut episodes,
834                        |destination| async move { planner.recompute_paths_from(&destination).await },
835                        |destination| async move {
836                            // Sessions name their destination by its chain address, this
837                            // telemetry by its packet key, and a `NodeId` holding one is never
838                            // equal to a `NodeId` holding the other -- so the match has to be
839                            // made on a resolved form, not on the enum.
840                            //
841                            // The counterparty has to still be receiving SURBs to reply with,
842                            // and its silence has by now convinced our balancer that it is well
843                            // stocked -- so tell the Sessions routed there to stop believing
844                            // that estimate while the evidence says otherwise.
845                            match chain.packet_key_to_chain_key(&destination) {
846                                Ok(Some(address)) => smgr.mark_return_path_degraded(
847                                    &hopr_api::types::internal::prelude::NodeId::Chain(address),
848                                    RETURN_PATH_DEGRADED_GRACE,
849                                ),
850                                // A resolver error is exactly the failure this recovery path exists
851                                // to surface, so it must not be collapsed into "no chain key" — log
852                                // it rather than silently marking zero sessions.
853                                Err(error) => {
854                                    tracing::warn!(%destination, %error, "could not resolve a silent destination's chain key to mark it degraded");
855                                    0
856                                }
857                                Ok(None) => 0,
858                            }
859                        },
860                    )
861                    .await
862                    {
863                        match step {
864                            protocol::return_path_recovery::RecoveryStep::Replanned { destination, moved } => {
865                                tracing::info!(%destination, entries = moved, "return path silent, re-planned")
866                            }
867                            protocol::return_path_recovery::RecoveryStep::Refilled { destination, sessions } => {
868                                tracing::info!(%destination, sessions, "refilling behind the re-plan")
869                            }
870                        }
871                    }
872                }
873            }),
874        );
875
876        // -- network probing
877        let manual_ping_channel_capacity = std::env::var("HOPR_INTERNAL_MANUAL_PING_CHANNEL_CAPACITY")
878            .ok()
879            .and_then(|s| s.trim().parse::<usize>().ok())
880            .filter(|&c| c > 0)
881            .unwrap_or(128);
882        debug!(capacity = manual_ping_channel_capacity, "Creating manual ping channel");
883        let (manual_ping_tx, manual_ping_rx_raw) =
884            crossfire::mpsc::bounded_async::<(OffchainPublicKey, PingQueryReplier)>(manual_ping_channel_capacity);
885        let manual_ping_rx = manual_ping_rx_raw.into_stream();
886
887        let probe = Probe::new(self.cfg.probe, self.probing_tag_allocator.clone());
888
889        let (probing_processes, probe_classifier) = probe
890            .continuously_scan(
891                unresolved_routing_msg_tx.clone(),
892                manual_ping_rx,
893                cover_traffic,
894                self.graph.clone(),
895            )
896            .await;
897
898        processes.flat_map_extend_from(probing_processes, HoprTransportProcess::Probing);
899
900        // manual ping
901        self.ping
902            .clone()
903            .set(Pinger::new(
904                PingConfig {
905                    timeout: self.cfg.probe.timeout,
906                },
907                manual_ping_tx,
908            ))
909            .map_err(|_| HoprTransportError::Api("must set the ticket aggregation writer only once".into()))?;
910
911        // -- session management
912        let smgr_start_res = if role != protocol::NodeType::Entry {
913            // Relays and Exits can accept incoming Sessions
914            self.smgr.start(
915                unresolved_routing_msg_tx.clone(),
916                on_incoming_session.ok_or_else(|| {
917                    HoprTransportError::Api("on_incoming_session channel is required for relay/exit nodes".into())
918                })?,
919            )
920        } else {
921            // Entry nodes cannot accept incoming Sessions
922            self.smgr
923                .start(unresolved_routing_msg_tx.clone(), futures::sink::drain())
924        };
925
926        smgr_start_res
927            .map_err(|_| HoprTransportError::Api("failed to start session manager".into()))?
928            .into_iter()
929            .enumerate()
930            .map(|(i, jh)| (HoprTransportProcess::SessionsManagement(i + 1), jh))
931            .for_each(|(k, v)| {
932                processes.insert(k, v);
933            });
934
935        // Wire incoming: cover-traffic-filtered stream → probe classify → (session dispatch).
936        // This stage must run in a background task, so the pipeline drains even when the
937        // caller discards the returned HoprSocket (e.g. edge-node builder).
938        //
939        // The channel uses a resilient for_each rather than .forward() so that a disconnected
940        // receiver (HoprSocket dropped without consuming) logs an error and continues rather
941        // than collapsing the entire ingress pipeline. Callers should use HoprSocket::reader()
942        // and actively drain the stream; see hopr-lib builder for the reference drain.
943        let (on_incoming_data_tx, on_incoming_data_rx) =
944            bounded_sink_channel::<ApplicationDataIn>(msg_protocol_bidirectional_channel_capacity);
945        let smgr = self.smgr.clone();
946        let unresolved_routing_msg_tx_for_task = unresolved_routing_msg_tx.clone();
947        processes.insert(
948            HoprTransportProcess::SessionsManagement(0),
949            hopr_utils::spawn_as_abortable!(async move {
950                probe_classifier
951                    .filter_stream(unresolved_routing_msg_tx_for_task, rx_from_protocol)
952                    .filter_map(move |(pseudonym, data)| {
953                        hopr_transport_session::counters::DISPATCH_MESSAGE_CALLS
954                            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
955                        // `dispatch_message` is synchronous and lock-free (moka::sync + crossfire
956                        // try_send); it never blocks. However, the crossfire stream that feeds
957                        // this loop does not participate in tokio's cooperative budget, so
958                        // `future::ready` — which is always Poll::Ready — would let this task
959                        // monopolize its worker thread under a saturated inbound queue (Processed
960                        // items are filtered to None before the fold's tx.send().await, the only
961                        // other suspension point on the hot path). Calling consume_budget() here
962                        // integrates this loop into tokio's coop scheduler: it is a cheap
963                        // thread-local decrement on the fast path and only actually yields
964                        // (~every 128 polls) when the budget is exhausted, giving co-located
965                        // tasks (including the SPHINX-crypto Rayon pipeline) a chance to run.
966                        let result = match smgr.dispatch_message(pseudonym, data) {
967                            Ok(DispatchResult::Processed) => {
968                                tracing::trace!("message dispatch completed");
969                                None
970                            }
971                            Ok(DispatchResult::Unrelated(data)) => {
972                                tracing::trace!("unrelated message dispatch completed");
973                                Some(data)
974                            }
975                            // Benign drop: the session's sink has closed, its inbox is momentarily
976                            // full (backpressure), or the session is already deregistered. Counted
977                            // in the session manager and logged quietly here so a departing
978                            // counterparty cannot spam ERROR once per in-flight packet.
979                            Ok(DispatchResult::Dropped(reason)) => {
980                                tracing::trace!(?reason, "dropped session packet");
981                                None
982                            }
983                            Err(error) => {
984                                tracing::error!(%error, "error while dispatching packet in the session manager");
985                                None
986                            }
987                        };
988                        async move {
989                            hopr_utils::runtime::prelude::consume_budget().await;
990                            result
991                        }
992                    })
993                    .fold(on_incoming_data_tx, |mut tx, data| async move {
994                        if tx.send(data).await.is_err() {
995                            tracing::error!(
996                                task = %HoprTransportProcess::SessionsManagement(0),
997                                "incoming-data channel disconnected — dropping unrelated packet; \
998                                 HoprSocket must be consumed or drained by the caller"
999                            );
1000                        }
1001                        tx
1002                    })
1003                    .await;
1004                tracing::warn!(
1005                    task = %HoprTransportProcess::SessionsManagement(0),
1006                    "long-running background task finished"
1007                );
1008            }),
1009        );
1010
1011        // Populate the OnceLock at the end, making sure everything before didn't fail.
1012        self.network
1013            .clone()
1014            .set(transport_network)
1015            .map_err(|_| HoprTransportError::Api("transport network viewer already set".into()))?;
1016
1017        Ok((
1018            (on_incoming_data_rx.boxed(), unresolved_routing_msg_tx).into(),
1019            processes,
1020        ))
1021    }
1022
1023    #[tracing::instrument(level = "debug", skip(self))]
1024    pub async fn ping(
1025        &self,
1026        peer: &OffchainPublicKey,
1027    ) -> errors::Result<(std::time::Duration, <Graph as NetworkGraphView>::Observed)> {
1028        let me: &OffchainPublicKey = self.packet_key.public();
1029        if peer == me {
1030            return Err(HoprTransportError::Api("ping to self does not make sense".into()));
1031        }
1032
1033        let pinger = self
1034            .ping
1035            .get()
1036            .ok_or_else(|| HoprTransportError::Api("ping processing is not yet initialized".into()))?;
1037
1038        let latency = (*pinger).ping(peer).await?;
1039
1040        if let Some(observations) = self.graph.edge(me, peer) {
1041            Ok((latency, observations))
1042        } else {
1043            Err(HoprTransportError::Api(format!(
1044                "no observations available for peer {peer}",
1045            )))
1046        }
1047    }
1048
1049    #[tracing::instrument(level = "debug", skip(self))]
1050    pub async fn new_session(
1051        &self,
1052        destination: Address,
1053        target: SessionTarget,
1054        cfg: SessionClientConfig,
1055    ) -> errors::Result<(HoprSession, HoprSessionConfigurator)> {
1056        let session = self.smgr.new_session(destination, target, cfg).await?;
1057        let id = *session.id();
1058        Ok((
1059            session,
1060            HoprSessionConfigurator {
1061                id,
1062                smgr: Arc::downgrade(&self.smgr),
1063            },
1064        ))
1065    }
1066
1067    #[tracing::instrument(level = "debug", skip(self))]
1068    pub async fn listening_multiaddresses(&self) -> Vec<Multiaddr> {
1069        self.network
1070            .get()
1071            .ok_or_else(|| HoprTransportError::Api("transport network is not yet initialized".into()))
1072            .map(|network| network.listening_as().into_iter().collect())
1073            .unwrap_or_default()
1074    }
1075
1076    #[tracing::instrument(level = "debug", skip(self))]
1077    pub fn announceable_multiaddresses(&self) -> Vec<Multiaddr> {
1078        let mut mas = self
1079            .local_multiaddresses()
1080            .into_iter()
1081            .filter(|ma| {
1082                crate::multiaddrs::is_supported(ma)
1083                    && (self.cfg.transport.announce_local_addresses || is_public_address(ma))
1084            })
1085            .map(|ma| strip_p2p_protocol(&ma))
1086            .filter(|v| !v.is_empty())
1087            .collect::<Vec<_>>();
1088
1089        mas.sort_by(|l, r| {
1090            let is_left_dns = crate::multiaddrs::is_dns(l);
1091            let is_right_dns = crate::multiaddrs::is_dns(r);
1092
1093            if !(is_left_dns ^ is_right_dns) {
1094                std::cmp::Ordering::Equal
1095            } else if is_left_dns {
1096                std::cmp::Ordering::Less
1097            } else {
1098                std::cmp::Ordering::Greater
1099            }
1100        });
1101
1102        mas
1103    }
1104
1105    /// Returns a reference to the network graph.
1106    pub fn graph(&self) -> &Graph {
1107        &self.graph
1108    }
1109
1110    /// Returns a reference to the SURB store.
1111    ///
1112    /// Exposed so that chain-level channel events can invalidate stored SURBs whose return path
1113    /// starts with a relayer this node can no longer pay.
1114    pub fn surb_store(&self) -> &MemorySurbStore {
1115        &self.path_planner.surb_store
1116    }
1117
1118    #[tracing::instrument(level = "debug", skip(self))]
1119    pub fn local_multiaddresses(&self) -> Vec<Multiaddr> {
1120        self.network
1121            .get()
1122            .map(|network| network.listening_as().into_iter().collect())
1123            .unwrap_or_else(|| {
1124                tracing::error!("transport network is not yet initialized, cannot fetch announced multiaddresses");
1125                self.my_multiaddresses.clone()
1126            })
1127    }
1128
1129    #[tracing::instrument(level = "debug", skip(self))]
1130    pub async fn network_observed_multiaddresses(&self, peer: &OffchainPublicKey) -> Vec<Multiaddr> {
1131        match self
1132            .network
1133            .get()
1134            .ok_or_else(|| HoprTransportError::Api("transport network is not yet initialized".into()))
1135        {
1136            Ok(network) => network
1137                .multiaddress_of(&peer.into())
1138                .unwrap_or_default()
1139                .into_iter()
1140                .collect(),
1141            Err(error) => {
1142                tracing::error!(%error, "failed to get observed multiaddresses");
1143                return vec![];
1144            }
1145        }
1146    }
1147
1148    #[tracing::instrument(level = "debug", skip(self))]
1149    pub async fn network_health(&self) -> Health {
1150        self.network
1151            .get()
1152            .ok_or_else(|| HoprTransportError::Api("transport network is not yet initialized".into()))
1153            .map(|network| network.health())
1154            .unwrap_or(Health::Red)
1155    }
1156
1157    pub async fn network_connected_peers(&self) -> errors::Result<Vec<OffchainPublicKey>> {
1158        Ok(futures::stream::iter(
1159            self.network
1160                .get()
1161                .ok_or_else(|| {
1162                    tracing::error!("transport network is not yet initialized");
1163                    HoprTransportError::Api("transport network is not yet initialized".into())
1164                })?
1165                .connected_peers(),
1166        )
1167        .filter_map(|peer_id| async move {
1168            match peer_id_to_public_key(&peer_id) {
1169                Ok(key) => Some(key),
1170                Err(error) => {
1171                    tracing::warn!(%peer_id, %error, "failed to convert PeerId to OffchainPublicKey");
1172                    None
1173                }
1174            }
1175        })
1176        .collect()
1177        .await)
1178    }
1179
1180    #[tracing::instrument(level = "debug", skip(self))]
1181    pub fn network_peer_observations(&self, peer: &OffchainPublicKey) -> Option<<Graph as NetworkGraphView>::Observed> {
1182        self.graph.edge(self.packet_key.public(), peer)
1183    }
1184
1185    /// Get connected peers with quality higher than some value.
1186    #[tracing::instrument(level = "debug", skip(self))]
1187    pub async fn all_network_peers(
1188        &self,
1189        minimum_score: f64,
1190    ) -> errors::Result<Vec<(OffchainPublicKey, <Graph as NetworkGraphView>::Observed)>> {
1191        let me = self.packet_key.public();
1192        Ok(self
1193            .network_connected_peers()
1194            .await?
1195            .into_iter()
1196            .filter_map(|peer| {
1197                let observation = self.graph.edge(me, &peer);
1198                if let Some(info) = observation {
1199                    // An unobserved edge has no score and cannot clear any threshold.
1200                    if info.score().is_some_and(|score| score >= minimum_score) {
1201                        Some((peer, info))
1202                    } else {
1203                        None
1204                    }
1205                } else {
1206                    None
1207                }
1208            })
1209            .collect::<Vec<_>>())
1210    }
1211}
1212
1213// ---------------------------------------------------------------------------
1214// NetworkView impl for HoprTransport — wraps OnceLock<Net> access
1215// ---------------------------------------------------------------------------
1216
1217impl<Chain, Graph, Net> NetworkView for HoprTransport<Chain, Graph, Net>
1218where
1219    Net: NetworkView + Send + Sync + 'static,
1220{
1221    fn listening_as(&self) -> std::collections::HashSet<Multiaddr> {
1222        self.network.get().map(|n| n.listening_as()).unwrap_or_default()
1223    }
1224
1225    fn multiaddress_of(&self, peer: &PeerId) -> Option<std::collections::HashSet<Multiaddr>> {
1226        self.network.get()?.multiaddress_of(peer)
1227    }
1228
1229    fn discovered_peers(&self) -> std::collections::HashSet<PeerId> {
1230        self.network.get().map(|n| n.discovered_peers()).unwrap_or_default()
1231    }
1232
1233    fn connected_peers(&self) -> std::collections::HashSet<PeerId> {
1234        self.network.get().map(|n| n.connected_peers()).unwrap_or_default()
1235    }
1236
1237    fn is_connected(&self, peer: &PeerId) -> bool {
1238        self.network.get().map(|n| n.is_connected(peer)).unwrap_or(false)
1239    }
1240
1241    fn health(&self) -> Health {
1242        self.network.get().map(|n| n.health()).unwrap_or(Health::Red)
1243    }
1244
1245    fn subscribe_network_events(
1246        &self,
1247    ) -> impl futures::Stream<Item = hopr_api::network::NetworkEvent> + Send + 'static {
1248        match self.network.get() {
1249            Some(n) => futures::future::Either::Left(n.subscribe_network_events()),
1250            None => futures::future::Either::Right(futures::stream::empty()),
1251        }
1252    }
1253}
1254
1255// ---------------------------------------------------------------------------
1256// TransportOperations impl for HoprTransport
1257// ---------------------------------------------------------------------------
1258
1259#[async_trait::async_trait]
1260impl<Chain, Graph, Net> hopr_api::node::TransportOperations for HoprTransport<Chain, Graph, Net>
1261where
1262    Chain: ChainReadChannelOperations
1263        + ChainReadAccountOperations
1264        + hopr_api::chain::ChainWriteTicketOperations
1265        + ChainKeyOperations
1266        + hopr_api::chain::ChainReadTicketOperations
1267        + ChainValues
1268        + Clone
1269        + Send
1270        + Sync
1271        + 'static,
1272    Graph: NetworkGraphView<NodeId = OffchainPublicKey>
1273        + NetworkGraphUpdate
1274        + hopr_api::graph::NetworkGraphWrite<NodeId = OffchainPublicKey>
1275        + hopr_api::graph::NetworkGraphTraverse<NodeId = OffchainPublicKey>
1276        + Clone
1277        + Send
1278        + Sync
1279        + 'static,
1280    <Graph as NetworkGraphView>::Observed: EdgeObservableRead + Send,
1281    <Graph as hopr_api::graph::NetworkGraphTraverse>::Observed: EdgeObservableRead + Send + 'static,
1282    <Graph as hopr_api::graph::NetworkGraphWrite>::Observed: hopr_api::graph::traits::EdgeObservableWrite + Send,
1283    Net: NetworkView + NetworkStreamControl + Clone + Send + Sync + 'static,
1284{
1285    type Error = errors::HoprTransportError;
1286    type Observable = <Graph as NetworkGraphView>::Observed;
1287
1288    async fn ping(&self, key: &OffchainPublicKey) -> Result<(Duration, Self::Observable), Self::Error> {
1289        self.ping(key).await
1290    }
1291
1292    async fn observed_multiaddresses(&self, key: &OffchainPublicKey) -> Vec<Multiaddr> {
1293        self.network_observed_multiaddresses(key).await
1294    }
1295}
1296
1297/// Maximum application-layer payload that fits in a single HOPR sphinx packet (bytes).
1298pub const PACKET_PAYLOAD_SIZE: usize = hopr_crypto_packet::prelude::HoprPacket::PAYLOAD_SIZE;
1299
1300#[cfg(test)]
1301mod tests {
1302    use std::sync::{
1303        Arc,
1304        atomic::{AtomicU64, Ordering},
1305    };
1306
1307    use futures::StreamExt;
1308    use hopr_utils::runtime::prelude::{consume_budget, spawn, yield_now};
1309
1310    /// Verifies that the `filter_map` closure in `SessionsManagement(0)` does not monopolize
1311    /// the tokio worker thread under a continuously saturated inbound stream.
1312    ///
1313    /// ### Background
1314    ///
1315    /// The inbound dispatch loop (`transport/hopr/src/lib.rs`) drives:
1316    ///
1317    /// ```text
1318    /// rx_from_protocol (crossfire, no coop budget)
1319    ///   → filter_stream
1320    ///   → filter_map(sync dispatch_message + consume_budget().await)
1321    ///   → fold(tx.send().await)          ← only fires for Unrelated items
1322    /// ```
1323    ///
1324    /// On the `Processed` hot path (common case), items become `None` and are filtered
1325    /// out before the `fold`'s `tx.send().await`, so the only suspension point is the
1326    /// `consume_budget()` call inside `filter_map`.
1327    ///
1328    /// ### What this test proves
1329    ///
1330    /// On a `current_thread` runtime (single worker, fully cooperative scheduling) a tight
1331    /// stream loop that never returns `Poll::Pending` starves every other spawned task until
1332    /// the stream is drained.  By replacing `future::ready(result)` with an `async` block
1333    /// that calls `consume_budget().await` before returning, we give the scheduler a chance
1334    /// to preempt the loop every ≈128 polls.  The "canary" task — a plain `yield_now` loop —
1335    /// *must* make forward progress while the dispatch stream is draining for the fix to be
1336    /// considered effective.
1337    ///
1338    /// Reverting the `consume_budget().await` to `future::ready(result)` will make this
1339    /// test fail: the canary will never be scheduled and its counter will remain 0.
1340    #[tokio::test(flavor = "current_thread")]
1341    async fn sessions_management_dispatch_loop_yields_to_scheduler() {
1342        // N large enough to trigger ≥1 genuine yield (budget = 128 per reset).
1343        const N: usize = 1_000;
1344
1345        // Canary: counts how many times it was scheduled during the dispatch run.
1346        let counter = Arc::new(AtomicU64::new(0));
1347        let counter_clone = counter.clone();
1348        let canary = spawn(async move {
1349            loop {
1350                counter_clone.fetch_add(1, Ordering::Relaxed);
1351                yield_now().await;
1352            }
1353        });
1354
1355        // Dispatch loop — same shape as the fixed SessionsManagement(0) hot path:
1356        //   sync compute → consume_budget().await → None (Processed path)
1357        let unrelated_count = futures::stream::iter(0..N)
1358            .filter_map(|_item| {
1359                // Dispatch result computed synchronously (no clone); budget consumed async.
1360                let result: Option<()> = None; // every item is "Processed"
1361                async move {
1362                    consume_budget().await;
1363                    result
1364                }
1365            })
1366            .fold(0u64, |acc, _| async move { acc + 1 })
1367            .await;
1368
1369        assert_eq!(
1370            unrelated_count, 0,
1371            "all Processed items should be filtered out by filter_map"
1372        );
1373
1374        // The canary must have been scheduled at least once while the dispatch loop ran.
1375        // If consume_budget() is removed and future::ready() is used instead, the loop
1376        // never returns Poll::Pending on the current_thread executor and this assertion fails.
1377        let progress = counter.load(Ordering::Relaxed);
1378        assert!(
1379            progress > 0,
1380            "canary task made no forward progress during the {N}-item dispatch run (counter = {progress}); the \
1381             dispatch loop is monopolizing the executor thread — ensure filter_map returns `async move {{ \
1382             consume_budget().await; result }}` rather than `future::ready(result)`"
1383        );
1384
1385        canary.abort();
1386        canary.await.ok();
1387    }
1388}