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