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};
44use futures_concurrency::stream::StreamExt as ConcurrentStreamExt;
45pub use hopr_api::{
46    Multiaddr, PeerId,
47    network::{Health, traits::NetworkView},
48    types::{
49        crypto::{
50            keypairs::{ChainKeypair, Keypair, OffchainKeypair},
51            types::{HalfKeyChallenge, Hash, OffchainPublicKey, SimplePseudonym},
52        },
53        internal::{prelude::HoprPseudonym, routing::RoutingOptions},
54    },
55};
56use hopr_api::{
57    chain::{ChainKeyOperations, ChainReadAccountOperations, ChainReadChannelOperations, ChainValues},
58    ct::{CoverTrafficGeneration, ProbingTrafficGeneration},
59    graph::{NetworkGraphUpdate, NetworkGraphView, traits::EdgeObservableRead},
60    network::{BoxedProcessFn, NetworkStreamControl},
61    types::primitive::prelude::*,
62};
63pub use hopr_crypto_packet::HoprPixSpec;
64pub use hopr_protocol_app::prelude::{ApplicationData, ApplicationDataIn, ApplicationDataOut, Tag};
65pub use hopr_protocol_hopr::{MemorySurbStore, SurbStore};
66/// Re-exported so a consumer can name `<HoprPixSpec as PixSpec>::DepositAddress` and assert at
67/// compile time that it is the variant it can actually settle. Which instantiation is in play is
68/// a feature-graph outcome rather than a local decision, and getting it wrong is otherwise silent
69/// until deposits stop happening at runtime.
70pub use hopr_protocol_pix::PixSpec;
71pub use hopr_protocol_pix::RecoveredSsa;
72use hopr_protocol_pix::ShareResolution;
73pub use hopr_transport_probe::{NeighborTelemetry, PathTelemetry, errors::ProbeError, ping::PingQueryReplier};
74use hopr_transport_probe::{
75    Probe,
76    ping::{PingConfig, Pinger},
77};
78pub use hopr_transport_session as session;
79#[cfg(feature = "runtime-tokio")]
80pub use hopr_transport_session::transfer_session;
81use hopr_transport_session::{
82    AgreedSsaQuota, DispatchResult, HoprSessionInPixEvent, HoprSessionOutPixEvent, PixToolbox, SessionManager,
83    SessionManagerConfig,
84};
85pub use hopr_transport_session::{
86    Capabilities as SessionCapabilities, Capability as SessionCapability, FlowControlConfig, HoprSession,
87    IncomingSession, InvalidPixParams, LOCAL_PIX_SUITE, PixParams, SESSION_MTU, SURB_SIZE, ServiceId,
88    SessionClientConfig, SessionId, SessionTarget, SurbBalancerConfig,
89    errors::{SessionManagerError, TransportSessionError},
90};
91#[cfg(feature = "telemetry")]
92pub use hopr_transport_session::{SessionAckMode, SessionLifecycleState};
93pub use hopr_transport_tag_allocator::TagAllocatorConfig;
94use hopr_utils::{
95    network_types::{
96        crossfire_sink::{CrossfireSink, bounded_sink_channel},
97        prelude::*,
98    },
99    runtime::AbortableList,
100};
101pub use multiaddr::Protocol;
102use tracing::{debug, warn};
103
104#[cfg(feature = "runtime-tokio")]
105use crate::path::BackgroundPathCacheRefreshable;
106pub use crate::{config::HoprProtocolConfig, protocol::PeerProtocolCounterRegistry};
107use crate::{
108    config::PixGlobalConfig,
109    constants::SESSION_INITIATION_TIMEOUT_BASE,
110    errors::HoprTransportError,
111    multiaddrs::strip_p2p_protocol,
112    path::{HoprGraphPathSelector, PathPlanner},
113    pipeline::HoprPacketPipelineBuilder,
114    socket::HoprSocket,
115};
116
117pub const APPLICATION_TAG_RANGE: std::ops::Range<Tag> = Tag::APPLICATION_TAG_RANGE;
118
119pub use hopr_api as api;
120use hopr_api::{
121    chain::{ChainReadTicketOperations, ChainWriteTicketOperations, PixDepositSecret},
122    node::{PixDepositAddressReceived, PixEvent, PixNewDepositAddress, PixPrivateKeyRecovered},
123    tickets::TicketFactory,
124    types::internal::routing::DestinationRouting,
125};
126use hopr_crypto_packet::HoprShareResolution;
127use rust_stream_ext_concurrent::then_concurrent::StreamThenConcurrentExt;
128
129// Needs lazy-static, since Duration multiplication by a constant is yet not a const-operation.
130lazy_static::lazy_static! {
131    static ref SESSION_INITIATION_TIMEOUT_MAX: Duration = 2 * SESSION_INITIATION_TIMEOUT_BASE * RoutingOptions::MAX_INTERMEDIATE_HOPS as u32;
132
133    static ref PEER_ID_CACHE: moka::sync::Cache<PeerId, OffchainPublicKey> = moka::sync::Cache::builder()
134        .time_to_idle(Duration::from_mins(15))
135        .max_capacity(10_000)
136        .build();
137
138    static ref RANDOM_DATA: [u8; 400] = hopr_api::types::crypto_random::random_bytes();
139}
140
141/// Fraction of the SURB ring buffer a Session's balancer target may occupy.
142///
143/// The remainder is headroom for balancer overshoot. The target used to be the ring buffer capacity
144/// itself, which left none: a Session whose buffer sat at target — the normal state when Entry → Exit
145/// traffic far exceeds the reverse, since the Exit then drains almost nothing — turned every
146/// overshot SURB into an immediate eviction of the oldest entry. Under PIX that is a lost SSA share,
147/// not just a lost SURB, because a share reaches the reconstructor only when its SURB is used.
148const SURB_BUFFER_TARGET_NUMERATOR: usize = 2;
149const SURB_BUFFER_TARGET_DENOMINATOR: usize = 3;
150
151/// Largest SURB buffer target a Session may request, given the ring buffer capacity backing it.
152///
153/// Kept strictly below `rb_capacity` so the balancer has somewhere to overshoot into before the
154/// ring buffer starts overwriting. See [`SURB_BUFFER_TARGET_NUMERATOR`].
155const fn surb_buffer_target_ceiling(rb_capacity: usize) -> usize {
156    // Saturating rather than wrapping: an operator-supplied capacity is only range-validated at the
157    // low end, and the multiplication would otherwise overflow on an absurd value.
158    rb_capacity.saturating_mul(SURB_BUFFER_TARGET_NUMERATOR) / SURB_BUFFER_TARGET_DENOMINATOR
159}
160
161/// PeerId -> OffchainPublicKey is a CPU-intensive blocking operation.
162///
163/// This helper uses a cached static object to speed up the lookup and avoid blocking the async
164/// runtime on repeated conversions for the same [`PeerId`]s.
165pub fn peer_id_to_public_key(peer_id: &PeerId) -> crate::errors::Result<OffchainPublicKey> {
166    PEER_ID_CACHE
167        .try_get_with_by_ref(peer_id, move || {
168            OffchainPublicKey::from_peerid(peer_id).map_err(|e| e.into())
169        })
170        .map_err(|e: Arc<HoprTransportError>| {
171            crate::errors::HoprTransportError::Other(anyhow::anyhow!(
172                "failed to convert peer_id ({:?}) to an offchain public key: {e}",
173                peer_id
174            ))
175        })
176}
177
178#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, strum::Display)]
179pub enum HoprTransportProcess {
180    #[strum(to_string = "component responsible for the transport medium (libp2p swarm)")]
181    Medium,
182    #[strum(to_string = "HOPR packet pipeline ({0})")]
183    Pipeline(protocol::PacketPipelineProcesses),
184    #[strum(to_string = "session manager sub-process #{0}")]
185    SessionsManagement(usize),
186    #[strum(to_string = "network probing sub-process: {0}")]
187    Probing(hopr_transport_probe::HoprProbeProcess),
188    #[cfg(feature = "runtime-tokio")]
189    #[strum(to_string = "path cache refresh")]
190    PathRefresh,
191    #[strum(to_string = "pix protocol event transformation")]
192    PixEvents,
193    #[strum(to_string = "sync of outgoing ticket indices")]
194    OutgoingIndexSync,
195    #[strum(to_string = "periodic protocol counter flush")]
196    CounterFlush,
197    /// Periodically reports SURB round-trip counts into the network graph.
198    SurbFlush,
199    #[strum(to_string = "mixer→wire forwarder")]
200    MixerForwarder,
201    #[cfg(feature = "capture")]
202    #[strum(to_string = "packet capture")]
203    Capture,
204}
205
206/// HOPR protocol specific instantiation of the SessionManager.
207type HoprSessionManager = SessionManager<CrossfireSink<(DestinationRouting, ApplicationDataOut)>>;
208
209/// Allows configuration of one specific [`HoprSession`].
210///
211/// The configurator does not prevent the Session from being closed
212/// or the Session manager from being dropped.
213#[derive(Debug, Clone)]
214pub struct HoprSessionConfigurator {
215    id: SessionId,
216    // Makes sure configurator does not extend lifetime of the SessionManager.
217    smgr: std::sync::Weak<HoprSessionManager>,
218}
219
220impl HoprSessionConfigurator {
221    /// [`SessionId`] of the session this object can configure.
222    pub fn id(&self) -> &SessionId {
223        &self.id
224    }
225
226    /// Sends a Session Keep-Alive packet over the Session.
227    ///
228    /// NOTE: This usually carries at least 2 SURBs on the HOPR protocol level and can be
229    /// used for manual SURB balancing.
230    ///
231    /// NOTE: This operation only sends the Session Keep-Alive packet and **DOES NOT** guarantee the other side
232    /// has received it.
233    pub async fn ping(&self) -> errors::Result<()> {
234        Ok(self
235            .smgr
236            .upgrade()
237            .ok_or(HoprTransportError::Other(anyhow::anyhow!("session manager is dropped")))?
238            .ping_session(&self.id)
239            .await?)
240    }
241
242    /// Gets the configuration of the SURB balancer.
243    ///
244    /// Returns an error if the Session is closed, the Session manager is gone.
245    ///
246    /// Returns `Ok(None)` if the Session has been created without a SURB balancer.
247    pub fn get_surb_balancer_config(&self) -> errors::Result<Option<SurbBalancerConfig>> {
248        Ok(self
249            .smgr
250            .upgrade()
251            .ok_or(HoprTransportError::Other(anyhow::anyhow!("session manager is dropped")))?
252            .get_surb_balancer_config(&self.id)?)
253    }
254
255    /// Updates the configuration of the SURB balancer.
256    ///
257    /// Returns an error if the Session is closed, the Session manager is gone, or the
258    /// Session has been created without a SURB balancer.
259    pub fn update_surb_balancer_config(&self, config: SurbBalancerConfig) -> errors::Result<()> {
260        Ok(self
261            .smgr
262            .upgrade()
263            .ok_or(HoprTransportError::Other(anyhow::anyhow!("session manager is dropped")))?
264            .update_surb_balancer_config(&self.id, config)?)
265    }
266
267    /// Explicitly closes the underlying Session in the [`SessionManager`].
268    ///
269    /// Returns `true` if the session was found and closed, `false` if it was
270    /// already gone (or the manager is dropped). Frees the per-session state
271    /// (frame reassembly buffers, control channels, …) immediately rather than
272    /// waiting for the manager's idle-timeout eviction.
273    pub fn close(&self) -> bool {
274        match self.smgr.upgrade() {
275            Some(smgr) => smgr.close_session(&self.id),
276            None => false,
277        }
278    }
279}
280
281/// Interface into the physical transport mechanism allowing all off-chain HOPR-related tasks on
282/// the transport.
283pub struct HoprTransport<Chain, Graph, Net> {
284    packet_key: OffchainKeypair,
285    chain_key: ChainKeypair,
286    chain_api: Chain,
287    ping: Arc<OnceLock<Pinger>>,
288    network: Arc<OnceLock<Net>>,
289    graph: Graph,
290    path_planner: PathPlanner<MemorySurbStore, Chain, HoprGraphPathSelector<Graph>>,
291    my_multiaddresses: Vec<Multiaddr>,
292    smgr: Arc<HoprSessionManager>,
293    session_telemetry_tag_allocator: Arc<dyn hopr_transport_tag_allocator::TagAllocator + Send + Sync>,
294    probing_tag_allocator: Arc<dyn hopr_transport_tag_allocator::TagAllocator + Send + Sync>,
295    counters: PeerProtocolCounterRegistry,
296    cfg: HoprProtocolConfig,
297}
298
299impl<Chain, Graph, Net> HoprTransport<Chain, Graph, Net>
300where
301    Chain: ChainReadChannelOperations
302        + ChainReadAccountOperations
303        + ChainWriteTicketOperations
304        + ChainKeyOperations
305        + ChainReadTicketOperations
306        + ChainValues
307        + Clone
308        + Send
309        + Sync
310        + 'static,
311    Graph: NetworkGraphView<NodeId = OffchainPublicKey>
312        + NetworkGraphUpdate
313        + hopr_api::graph::NetworkGraphWrite<NodeId = OffchainPublicKey>
314        + hopr_api::graph::NetworkGraphTraverse<NodeId = OffchainPublicKey>
315        + Clone
316        + Send
317        + Sync
318        + 'static,
319    <Graph as NetworkGraphView>::Observed: hopr_api::graph::traits::EdgeObservableRead + Send,
320    <Graph as hopr_api::graph::NetworkGraphTraverse>::Observed:
321        hopr_api::graph::traits::EdgeObservableRead + Send + 'static,
322    <Graph as hopr_api::graph::NetworkGraphWrite>::Observed: hopr_api::graph::traits::EdgeObservableWrite + Send,
323    Net: NetworkView + NetworkStreamControl + Clone + Send + Sync + 'static,
324{
325    pub fn new(
326        identity: (&ChainKeypair, &OffchainKeypair),
327        resolver: Chain,
328        graph: Graph,
329        my_multiaddresses: Vec<Multiaddr>,
330        cfg: HoprProtocolConfig,
331    ) -> errors::Result<Self> {
332        let me_offchain = *identity.1.public();
333        let planner_config = cfg.path_planner;
334        let selector = HoprGraphPathSelector::new(
335            me_offchain,
336            graph.clone(),
337            planner_config.max_cached_paths,
338            planner_config.edge_penalty,
339            planner_config.min_ack_rate,
340            planner_config.min_paths_anonymity_floor,
341        );
342
343        let tag_allocators = hopr_transport_tag_allocator::create_allocators_from_config(&cfg.session.tag_allocator)?;
344
345        let mut session_telemetry_tag_allocator = None;
346        let mut probing_tag_allocator = None;
347        for (usage, alloc) in tag_allocators {
348            match usage {
349                // TODO: cleanup of Session tag allocators needed * (#8199)
350                hopr_transport_tag_allocator::Usage::Session => {}
351                hopr_transport_tag_allocator::Usage::SessionTerminalTelemetry => {
352                    session_telemetry_tag_allocator = Some(alloc)
353                }
354                hopr_transport_tag_allocator::Usage::ProvingTelemetry => probing_tag_allocator = Some(alloc),
355            }
356        }
357        let session_telemetry_tag_allocator = session_telemetry_tag_allocator
358            .ok_or_else(|| HoprTransportError::Api("session telemetry tag allocator missing".into()))?;
359        let probing_tag_allocator =
360            probing_tag_allocator.ok_or_else(|| HoprTransportError::Api("probing tag allocator missing".into()))?;
361
362        // A pseudonym's SURB ring buffer is dropped once no SURBs have arrived for
363        // `pseudonyms_lifetime` (600 s by default), which is the point at which that pseudonym's
364        // return path becomes permanently unresolvable. Nothing else in the node can be made to
365        // reach that state on a test timescale — the Session slot is evicted for idleness long
366        // before it — so without a way to shorten this timer, the behaviour past it is not
367        // observable end-to-end at all. Floored at the same `MINIMUM_SURB_LIFETIME` the config
368        // validator enforces, so this cannot reach a value the config file could not also express.
369        let surb_store_cfg = hopr_protocol_hopr::SurbStoreConfig {
370            pseudonyms_lifetime: std::env::var("HOPR_INTERNAL_SURB_PSEUDONYM_LIFETIME_MS")
371                .ok()
372                .and_then(|s| s.trim().parse::<u64>().ok())
373                .map(Duration::from_millis)
374                .map(|d| d.max(hopr_protocol_hopr::MINIMUM_SURB_LIFETIME))
375                .unwrap_or(cfg.packet.surb_store.pseudonyms_lifetime),
376            ..cfg.packet.surb_store
377        };
378
379        // Built before the session manager so the latter can be handed the seam that lets a
380        // session re-plan its return path on sustained loss.
381        let path_planner = PathPlanner::new(
382            me_offchain,
383            MemorySurbStore::new(surb_store_cfg),
384            resolver.clone(),
385            selector,
386            planner_config,
387        );
388
389        Ok(Self {
390            packet_key: identity.1.clone(),
391            chain_key: identity.0.clone(),
392            ping: Arc::new(OnceLock::new()),
393            network: Arc::new(OnceLock::new()),
394            graph,
395            path_planner,
396            my_multiaddresses,
397            smgr: Arc::new(SessionManager::new(SessionManagerConfig {
398                frame_mtu: std::env::var("HOPR_SESSION_FRAME_SIZE")
399                    .ok()
400                    .and_then(|s| s.parse::<usize>().ok())
401                    .unwrap_or_else(|| SessionManagerConfig::default().frame_mtu)
402                    .max(SESSION_MTU),
403                max_frame_timeout: std::env::var("HOPR_SESSION_FRAME_TIMEOUT_MS")
404                    .ok()
405                    .and_then(|s| s.parse::<u64>().ok().map(Duration::from_millis))
406                    .unwrap_or_else(|| SessionManagerConfig::default().max_frame_timeout)
407                    .max(Duration::from_millis(100)),
408                max_frames_behind_gap: cfg.session.max_frames_behind_gap,
409                max_buffered_segments: std::env::var("HOPR_SESSION_MAX_BUFFERED_SEGMENTS")
410                    .ok()
411                    .and_then(|s| s.parse::<usize>().ok())
412                    .unwrap_or_else(|| SessionManagerConfig::default().max_buffered_segments),
413                initiation_timeout_base: SESSION_INITIATION_TIMEOUT_BASE,
414                idle_timeout: cfg.session.idle_timeout,
415                balancer_sampling_interval: cfg.session.balancer_sampling_interval,
416                initial_return_session_egress_rate: 10,
417                minimum_surb_buffer_duration: cfg.session.balancer_minimum_surb_buffer_duration,
418                maximum_surb_buffer_size: surb_buffer_target_ceiling(cfg.packet.surb_store.rb_capacity),
419                // The lower bound is enforced once in `SessionManager::new` via
420                // `MIN_SURB_BUFFER_NOTIFICATION_PERIOD`; don't duplicate that floor as a literal here.
421                surb_balance_notify_period: std::env::var("HOPR_SESSION_SURB_BALANCE_NOTIFY_PERIOD_MS")
422                    .ok()
423                    .and_then(|s| s.parse::<u64>().ok())
424                    .map(|ms| Some(Duration::from_millis(ms)))
425                    .unwrap_or(cfg.session.surb_balance_notify_period),
426                surb_target_notify: true,
427                maximum_sessions: cfg.session.maximum_managed_sessions,
428                pix_config: cfg.incoming_session_pix_config.clone(),
429                max_ssas_per_ssa_request: cfg.pix.max_ssas_per_request,
430                ..Default::default()
431            })),
432            chain_api: resolver,
433            session_telemetry_tag_allocator,
434            probing_tag_allocator,
435            counters: PeerProtocolCounterRegistry::default(),
436            cfg,
437        })
438    }
439
440    /// Execute all processes of the [`HoprTransport`] object as a **Relay** node.
441    ///
442    /// Relay nodes run the full packet pipeline including incoming ticket/acknowledgement
443    /// processing and require a [`futures::Sink`] for ticket events as well as an
444    /// `on_incoming_session` channel from the SessionManager (they can accept incoming sessions).
445    ///
446    /// The Relay node may also opt in to allow itself to use the PIX protocol by setting
447    /// the `exit_ack_share` if it wishes to also act as an Exit node.
448    #[allow(clippy::too_many_arguments)]
449    pub async fn run_relay<T, TFact, Ct, PixEvt>(
450        &self,
451        cover_traffic: Ct,
452        network: Net,
453        network_process: BoxedProcessFn,
454        ticket_events: T,
455        ticket_factory: TFact,
456        exit_ack_share: Option<PixEvt>,
457        on_incoming_session: Sender<IncomingSession>,
458    ) -> errors::Result<(
459        HoprSocket<
460            futures::stream::BoxStream<'static, ApplicationDataIn>,
461            CrossfireSink<(DestinationRouting, ApplicationDataOut)>,
462        >,
463        AbortableList<HoprTransportProcess>,
464    )>
465    where
466        T: futures::Sink<hopr_api::node::TicketEvent> + Clone + Send + Unpin + 'static,
467        T::Error: std::error::Error + Clone + Send,
468        Ct: ProbingTrafficGeneration + CoverTrafficGeneration + Send + Sync + 'static,
469        TFact: TicketFactory + Clone + Send + Sync + 'static,
470        PixEvt: futures::Sink<PixEvent> + Clone + Unpin + Send + 'static,
471        PixEvt::Error: std::error::Error + Clone + Sync + Send + 'static,
472    {
473        self.run_inner(
474            protocol::NodeType::Relay,
475            cover_traffic,
476            network,
477            network_process,
478            ticket_events,
479            ticket_factory,
480            exit_ack_share,
481            Some(on_incoming_session),
482        )
483        .await
484    }
485
486    /// Execute all processes of the [`HoprTransport`] object as an **Exit** (destination) node.
487    ///
488    /// Exit nodes do not process tickets but keep the incoming acknowledgement
489    /// pipeline running and can accept incoming sessions via SessionManager.
490    ///
491    /// The Exit nodes also work with the PIX protocol, so they process incoming acknowledgements
492    /// to decrypt PIX shares.
493    pub async fn run_exit<TFact, Ct, PixEvt>(
494        &self,
495        cover_traffic: Ct,
496        network: Net,
497        network_process: BoxedProcessFn,
498        ticket_factory: TFact,
499        pix_events: Option<PixEvt>,
500        on_incoming_session: Sender<IncomingSession>,
501    ) -> errors::Result<(
502        HoprSocket<
503            futures::stream::BoxStream<'static, ApplicationDataIn>,
504            CrossfireSink<(DestinationRouting, ApplicationDataOut)>,
505        >,
506        AbortableList<HoprTransportProcess>,
507    )>
508    where
509        Ct: ProbingTrafficGeneration + CoverTrafficGeneration + Send + Sync + 'static,
510        TFact: TicketFactory + Clone + Send + Sync + 'static,
511        PixEvt: futures::Sink<PixEvent> + Clone + Unpin + Send + 'static,
512        PixEvt::Error: std::error::Error + Clone + Sync + Send + 'static,
513    {
514        self.run_inner(
515            protocol::NodeType::Exit,
516            cover_traffic,
517            network,
518            network_process,
519            futures::sink::drain(),
520            ticket_factory,
521            pix_events,
522            Some(on_incoming_session),
523        )
524        .await
525    }
526
527    /// Execute all processes of the [`HoprTransport`] object as an **Entry** (source) node.
528    ///
529    /// Entry nodes do not process tickets, do not start the incoming acknowledgement
530    /// pipeline, and do not accept incoming sessions — therefore, they require neither a
531    /// `ticket_events` sink nor an `on_incoming_session` channel.
532    pub async fn run_entry<TFact, Ct, PixEvt>(
533        &self,
534        cover_traffic: Ct,
535        network: Net,
536        network_process: BoxedProcessFn,
537        ticket_factory: TFact,
538        pix_events: Option<PixEvt>,
539    ) -> errors::Result<(
540        HoprSocket<
541            futures::stream::BoxStream<'static, ApplicationDataIn>,
542            CrossfireSink<(DestinationRouting, ApplicationDataOut)>,
543        >,
544        AbortableList<HoprTransportProcess>,
545    )>
546    where
547        Ct: ProbingTrafficGeneration + CoverTrafficGeneration + Send + Sync + 'static,
548        TFact: TicketFactory + Clone + Send + Sync + 'static,
549        PixEvt: futures::Sink<PixEvent> + Clone + Unpin + Send + 'static,
550        PixEvt::Error: std::error::Error + Clone + Sync + Send + 'static,
551    {
552        self.run_inner(
553            protocol::NodeType::Entry,
554            cover_traffic,
555            network,
556            network_process,
557            futures::sink::drain(),
558            ticket_factory,
559            pix_events,
560            None,
561        )
562        .await
563    }
564
565    /// Internal worker driving all node-type variants of `HoprTransport::run_*`.
566    ///
567    /// Branches on `role`:
568    /// - [`protocol::NodeType::Relay`]: full packet pipeline + SessionManager.
569    /// - [`protocol::NodeType::Exit`]: ack-drain pipeline + incoming Sessions.
570    /// - [`protocol::NodeType::Entry`]: no ack pipeline, no incoming Sessions.
571    #[allow(clippy::too_many_arguments)]
572    async fn run_inner<T, TFact, Ct, PixEvt>(
573        &self,
574        role: protocol::NodeType,
575        cover_traffic: Ct,
576        network: Net,
577        network_process: BoxedProcessFn,
578        ticket_events: T,
579        ticket_factory: TFact,
580        exit_ack_share: Option<PixEvt>,
581        on_incoming_session: Option<Sender<IncomingSession>>,
582    ) -> errors::Result<(
583        HoprSocket<
584            futures::stream::BoxStream<'static, ApplicationDataIn>,
585            CrossfireSink<(DestinationRouting, ApplicationDataOut)>,
586        >,
587        AbortableList<HoprTransportProcess>,
588    )>
589    where
590        T: futures::Sink<hopr_api::node::TicketEvent> + Clone + Send + Unpin + 'static,
591        T::Error: std::error::Error + Clone + Send,
592        Ct: ProbingTrafficGeneration + CoverTrafficGeneration + Send + Sync + 'static,
593        TFact: TicketFactory + Clone + Send + Sync + 'static,
594        PixEvt: futures::Sink<PixEvent> + Clone + Unpin + Send + 'static,
595        PixEvt::Error: std::error::Error + Clone + Sync + Send + 'static,
596    {
597        let mut processes = AbortableList::<HoprTransportProcess>::default();
598
599        let (unresolved_routing_msg_tx, unresolved_routing_msg_rx) =
600            bounded_sink_channel::<(DestinationRouting, ApplicationDataOut)>(MAXIMUM_MSG_OUTGOING_BUFFER_SIZE);
601
602        // -- transport medium
603
604        let transport_network = network;
605        let transport_layer_process = network_process;
606
607        let msg_codec = crate::protocol::HoprBinaryCodec {};
608        let (wire_msg_tx, wire_msg_rx) =
609            protocol::stream::process_stream_protocol(msg_codec, transport_network.clone(), self.cfg.stream).await?;
610
611        // Shared mixing channel: all per-destination clones of `mixing_channel_tx` push into one
612        // heap, so cross-destination packets are mixed together rather than each destination
613        // getting its own independent delay queue. The single forwarder task owns the receiver
614        // (and therefore the heap timer) — no per-clone waker coordination is needed.
615        let mut mixer_cfg = self.cfg.mixer;
616        mixer_cfg.metric_delay_window = u64::try_from(5 * mixer_cfg.delay_range.as_millis())
617            .unwrap_or(u64::MAX)
618            .max(1);
619        let (mixing_channel_tx, mix_rx) = hopr_transport_mixer::channel(mixer_cfg);
620        let transit_latency_cfg = self.cfg.transit_latency;
621        processes.insert(
622            HoprTransportProcess::MixerForwarder,
623            hopr_utils::spawn_as_abortable!(async move {
624                let mut mix_rx = mix_rx;
625                let mut wire_sink = wire_msg_tx;
626
627                if let Some(lat) = transit_latency_cfg {
628                    // Concurrent transit-latency fan-out: spawn one short-lived task per
629                    // packet so each packet ages through its own ~mean delay independently.
630                    // A burst of N packets at mean=50 ms takes ~50 ms total, not N×50 ms.
631                    //
632                    // Without a tokio runtime the latency is silently skipped (pass-through).
633                    #[cfg(feature = "runtime-tokio")]
634                    {
635                        let (tx, rx) = futures::channel::mpsc::unbounded();
636                        let fan_out = async move {
637                            while let Some(item) = futures::StreamExt::next(&mut mix_rx).await {
638                                let mean_us = lat.mean.as_micros() as f64;
639                                let std_us = lat.std_dev.as_micros() as f64;
640                                let delay_us = if std_us > 0.0 {
641                                    use rand_distr::{Distribution, Normal};
642                                    Normal::new(mean_us, std_us)
643                                        .expect("transit latency Normal params are valid")
644                                        .sample(&mut rand::rng())
645                                        .max(0.0_f64)
646                                } else {
647                                    mean_us.max(0.0)
648                                };
649                                let delay = Duration::from_micros(delay_us as u64);
650                                let item_tx = tx.clone();
651                                hopr_utils::runtime::prelude::spawn(async move {
652                                    if !delay.is_zero() {
653                                        futures_timer::Delay::new(delay).await;
654                                    }
655                                    let _ = item_tx.unbounded_send(item);
656                                });
657                            }
658                            // `tx` drops here; the channel closes once all per-packet tasks send
659                        };
660                        let fan_in = async move {
661                            let mut rx = rx;
662                            while let Some(item) = futures::StreamExt::next(&mut rx).await {
663                                if wire_sink.send(item).await.is_err() {
664                                    tracing::error!(
665                                        task = %HoprTransportProcess::MixerForwarder,
666                                        "wire sink dropped — discarding transit-delayed packet"
667                                    );
668                                    break;
669                                }
670                            }
671                        };
672                        futures::join!(fan_out, fan_in);
673                    }
674                    #[cfg(not(feature = "runtime-tokio"))]
675                    {
676                        let _ = lat;
677                        while let Some(item) = futures::StreamExt::next(&mut mix_rx).await {
678                            if wire_sink.send(item).await.is_err() {
679                                tracing::error!(
680                                    task = %HoprTransportProcess::MixerForwarder,
681                                    "wire sink dropped — discarding mixed packet"
682                                );
683                            }
684                        }
685                    }
686                } else {
687                    while let Some(item) = futures::StreamExt::next(&mut mix_rx).await {
688                        if wire_sink.send(item).await.is_err() {
689                            tracing::error!(
690                                task = %HoprTransportProcess::MixerForwarder,
691                                "wire sink dropped — discarding mixed packet"
692                            );
693                        }
694                    }
695                }
696
697                tracing::warn!(
698                    task = %HoprTransportProcess::MixerForwarder,
699                    "long-running background task finished"
700                );
701            }),
702        );
703
704        // -- path cache background refresh (only when tokio runtime is available)
705        #[cfg(feature = "runtime-tokio")]
706        processes.insert(
707            HoprTransportProcess::PathRefresh,
708            hopr_utils::spawn_as_abortable!(self.path_planner.run_background_refresh()),
709        );
710
711        processes.insert(
712            HoprTransportProcess::Medium,
713            hopr_utils::spawn_as_abortable!(transport_layer_process().inspect(|_| tracing::warn!(
714                task = %HoprTransportProcess::Medium,
715                "long-running background task finished"
716            ))),
717        );
718
719        let msg_protocol_bidirectional_channel_capacity =
720            std::env::var("HOPR_INTERNAL_PROTOCOL_BIDIRECTIONAL_CHANNEL_CAPACITY")
721                .ok()
722                .and_then(|s| s.trim().parse::<usize>().ok())
723                .filter(|&c| c > 0)
724                .unwrap_or(16_384);
725
726        debug!(
727            capacity = msg_protocol_bidirectional_channel_capacity,
728            "creating protocol bidirectional channel"
729        );
730        let (tx_from_protocol, rx_from_protocol) =
731            bounded_sink_channel::<(HoprPseudonym, ApplicationDataIn)>(msg_protocol_bidirectional_channel_capacity);
732
733        // === START === cover traffic control
734        // Allocate a cover traffic tag from the session telemetry partition to avoid
735        // collisions with session and probing tags.
736        let cover_traffic_allocated_tag = self
737            .session_telemetry_tag_allocator
738            .allocate()
739            .ok_or_else(|| HoprTransportError::Api("failed to allocate cover traffic tag".into()))?;
740        let cover_traffic_tag: Tag = cover_traffic_allocated_tag.value().into();
741
742        // filter out the known cover traffic not to lose processing time with it
743        // The allocated tag is moved into the closure to keep it alive for the transport lifetime.
744        let rx_from_protocol = rx_from_protocol.filter_map(move |(pseudonym, data)| {
745            let _keep_alive = &cover_traffic_allocated_tag;
746            async move { (data.data.application_tag != cover_traffic_tag).then_some((pseudonym, data)) }
747        });
748
749        // prepare a cover traffic stream
750        let cover_traffic_stream = CoverTrafficGeneration::build(&cover_traffic).filter_map(move |routing| {
751            let start =
752                hopr_api::types::crypto_random::random_integer(0, Some((RANDOM_DATA.len() - 100) as u64)) as usize;
753            let data = &RANDOM_DATA[start..start + 100];
754
755            futures::future::ready(if let Ok(data) = ApplicationData::new(cover_traffic_tag, data) {
756                Some((routing, ApplicationDataOut::with_no_packet_info(data)))
757            } else {
758                tracing::error!("failed to construct cover traffic packet");
759                None
760            })
761        });
762
763        // merge cover traffic with other outgoing data
764        let merged_unresolved_output_data =
765            select_with_strategy(unresolved_routing_msg_rx, cover_traffic_stream, |_: &mut ()| {
766                futures::stream::PollNext::Left
767            });
768
769        // === END === cover traffic control
770
771        // We have to resolve DestinationRouting -> ResolvedTransportRouting before
772        // sending the external packets to the transport pipeline. Concurrency matches
773        // the encoder stage (output_concurrency) to avoid head-of-line blocking on
774        // cache-miss path lookups.
775        let path_planner = self.path_planner.clone();
776        let distress_threshold = self.cfg.packet.surb_store.distress_threshold;
777        let routing_concurrency = {
778            let avail = std::thread::available_parallelism()
779                .ok()
780                .map(|n| n.get())
781                .unwrap_or(1)
782                .max(1)
783                * 8;
784            self.cfg
785                .packet
786                .pipeline
787                .output_concurrency
788                .filter(|&n| n > 0)
789                .unwrap_or(avail)
790        };
791        let all_resolved_external_msg_rx = crate::path::resolve::resolve_routing_stage(
792            merged_unresolved_output_data,
793            move |size_hint, max_surbs, unresolved| {
794                let path_planner = path_planner.clone();
795                async move { path_planner.resolve_routing(size_hint, max_surbs, unresolved).await }
796            },
797            distress_threshold,
798            routing_concurrency,
799            crate::path::resolve::surb_resolution_wait(self.cfg.packet.pipeline.surb_resolution_wait),
800        );
801
802        let channels_dst = self
803            .chain_api
804            .domain_separators()
805            .await
806            .map_err(HoprTransportError::chain)?
807            .channel;
808
809        // The SSA generator is dimensioned from the global PIX config (not per
810        // session) because `handle_ssa_request` (SessionManager) validates that the
811        // Exit's negotiated quota matches the session's `pix_ssa_quota` before any
812        // client commitments are generated, and the Exit's `new_exit_commitment`
813        // bounds-checks polys_per_ssa and shares_per_poly.  The session quota is
814        // a subset of what the global generator covers, so one generator suffices.
815        //
816        // Validated here rather than left to the constructor: `PixGlobalConfig` carries more than
817        // the three fields `SsaGeneratorConfig` covers, and this used to be a SAFETY comment
818        // asserting that it had already been validated "before this code runs" via
819        // `#[validate(nested)]` — but nothing in this crate calls `validate()`, so the guarantee
820        // rested entirely on every caller remembering to.
821        validator::Validate::validate(&self.cfg.pix)
822            .map_err(|error| HoprTransportError::Api(format!("invalid PIX configuration: {error}")))?;
823        // Checked rather than `as`-cast: the validation above already bounds all three, but a
824        // truncating cast would turn a future widening of any of those ranges into a silently
825        // wrong-but-valid-looking dimension rather than a startup error.
826        fn narrow<T: TryFrom<usize>>(value: usize, field: &str) -> errors::Result<T> {
827            T::try_from(value).map_err(|_| HoprTransportError::Api(format!("PIX {field} out of range: {value}")))
828        }
829        // `try_new`, not `new`: the dimensions come from operator configuration, so a range
830        // violation is a startup error to report rather than a panic to take down the node.
831        let ssa_generator = Arc::new(
832            hopr_protocol_pix::SsaShareGenerator::<HoprPixSpec>::try_new(hopr_protocol_pix::SsaGeneratorConfig {
833                polynomials_per_ssa: narrow(self.cfg.pix.num_ssa_parts, "num_ssa_parts")?,
834                threshold: narrow(self.cfg.pix.ssa_part_size, "ssa_part_size")?,
835                surplus_shares: narrow(self.cfg.pix.surplus_shares(), "additional_shares")?,
836            })
837            .map_err(|error| HoprTransportError::Api(format!("invalid SSA generator configuration: {error}")))?,
838        );
839
840        let surb_round_trips = protocol::surb_telemetry::SurbRoundTripRegistry::default();
841
842        let pipeline_builder = HoprPacketPipelineBuilder::new()
843            .identity((&self.chain_key, &self.packet_key))
844            .transport((mixing_channel_tx, wire_msg_rx))
845            .api((tx_from_protocol, all_resolved_external_msg_rx))
846            .surb_store(self.path_planner.surb_store.clone())
847            .chain_api(self.chain_api.clone())
848            .ticket_factory(ticket_factory)
849            .ssa_generator(ssa_generator.clone())
850            .channels_dst(channels_dst)
851            .with_counters(self.counters.clone())
852            .with_surb_telemetry(
853                surb_round_trips.clone(),
854                protocol::surb_telemetry::path_slots_of(self.graph.clone()),
855            )
856            .with_config(self.cfg.packet);
857
858        // ── PixToolbox for the SessionManager ────────────────────────────
859        // The SessionManager needs a PixToolbox on all node types to handle
860        // PIX protocol messages (SsaRequest on Entry, SsaCommit on Exit).
861        // Only Exit nodes construct an SsaReconstructor and wire SSA recovery
862        // events back through the packet pipeline. Relay nodes do NOT
863        // participate in PIX share processing at the pipeline level.
864        // Entry nodes get a bare-bones PixToolbox (share_generator + dummy
865        // reconstructor) to handle SsaRequest, but do not use the
866        // reconstructor for SSA recovery.
867        let pix_toolbox = match (role, exit_ack_share) {
868            (protocol::NodeType::Exit, Some(ref ssa_events)) => {
869                let (pix_tools, pipeline_builder) = wire_exit_pix(
870                    pipeline_builder,
871                    ssa_generator.clone(),
872                    ssa_reconstructor(&self.cfg.pix)?,
873                    ssa_events,
874                    self.smgr.clone(),
875                    &mut processes,
876                );
877
878                let pipeline_processes = pipeline_builder.build_for_exit();
879                processes.extend_from(pipeline_processes);
880                Some(pix_tools)
881            }
882            (protocol::NodeType::Relay, None) => {
883                // Pure relay nodes do not participate in PIX — do not create a
884                // PixToolbox so the SessionManager rejects UsePIX
885                // pre-emptively during session initiation.
886                let pipeline_processes = pipeline_builder.with_ticket_events(ticket_events).build_for_relay();
887                processes.extend_from(pipeline_processes);
888                None
889            }
890            (protocol::NodeType::Relay, Some(ref ssa_events)) => {
891                // A relay that also acts as an Exit (has exit_ack_share) needs
892                // a full PixToolbox to handle the PIX handshake — the same wiring as the
893                // `(Exit, Some)` arm above, differing only in the terminal below.
894                let (pix_tools, pipeline_builder) = wire_exit_pix(
895                    pipeline_builder,
896                    ssa_generator.clone(),
897                    ssa_reconstructor(&self.cfg.pix)?,
898                    ssa_events,
899                    self.smgr.clone(),
900                    &mut processes,
901                );
902
903                let pipeline_processes = pipeline_builder.with_ticket_events(ticket_events).build_for_relay();
904                processes.extend_from(pipeline_processes);
905                Some(pix_tools)
906            }
907            (protocol::NodeType::Entry, Some(ref ssa_events)) => {
908                // Entry nodes need a bare-bones PixToolbox (share_generator only)
909                // to handle incoming SsaRequest messages from the Exit.
910                // No SSA reconstruction needed on Entry — forward events to the
911                // PIX event broadcast so subscribers (e.g. tests) see them.
912                // Configured like any other, not left on the defaults: this reconstructor is called
913                // "dummy" because Entry does not reconstruct, but it is not provably unreachable —
914                // the comment below records that an inbound `UsePIX` does reach `handle_ssa_commit`
915                // here. A knob that binds on some node roles and not others is the same defect
916                // shape as a comment quoting a constant it does not reference.
917                let dummy_reconstructor = ssa_reconstructor(&self.cfg.pix)?;
918                let (pix_tools, session_pix_events) = PixToolbox::new(ssa_generator.clone(), dummy_reconstructor);
919                processes.insert(
920                    HoprTransportProcess::PixEvents,
921                    hopr_utils::spawn_as_abortable!(
922                        // The same mapping as the Exit and Relay arms, deliberately: `DepositNeeded`
923                        // is not *expected* here, but it is reachable. `SessionManager` has no
924                        // notion of node role and this one runs with a live toolbox — only the
925                        // incoming-session notification is drained below — so an inbound
926                        // `StartSession` carrying `UsePIX` is accepted and ends in
927                        // `handle_ssa_commit` emitting it. Panicking would take the whole PIX event
928                        // task with it, stranding this node's own outbound deposits.
929                        session_pix_events
930                            .map(session_pix_event_to_pix_event)
931                            .map(Ok)
932                            .forward(ssa_events.clone().sink_map_err(HoprTransportError::other))
933                    ),
934                );
935
936                processes.extend_from(pipeline_builder.build_for_entry());
937                Some(pix_tools)
938            }
939            (_, None) => {
940                // Nodes without pix_events sink (no PIX configured at all)
941                let pipeline_processes = match role {
942                    protocol::NodeType::Relay => pipeline_builder.with_ticket_events(ticket_events).build_for_relay(),
943                    protocol::NodeType::Exit => pipeline_builder.build_for_exit(),
944                    protocol::NodeType::Entry => pipeline_builder.build_for_entry(),
945                };
946                processes.extend_from(pipeline_processes);
947                None
948            }
949        };
950
951        // ── Ssmgr startup ─────────────────────────────────────────────────
952        // Entry nodes don't accept incoming sessions, relay/exit nodes do.
953        let smgr_start_res = if role != protocol::NodeType::Entry {
954            self.smgr.start(
955                unresolved_routing_msg_tx.clone(),
956                on_incoming_session.ok_or_else(|| {
957                    HoprTransportError::Api("on_incoming_session channel is required for relay/exit nodes".into())
958                })?,
959                pix_toolbox,
960            )
961        } else {
962            self.smgr
963                .start(unresolved_routing_msg_tx.clone(), futures::sink::drain(), pix_toolbox)
964        };
965
966        smgr_start_res
967            .map_err(|_| HoprTransportError::Api("failed to start session manager".into()))?
968            .into_iter()
969            .enumerate()
970            .map(|(i, jh)| (HoprTransportProcess::SessionsManagement(i + 1), jh))
971            .for_each(|(k, v)| {
972                processes.insert(k, v);
973            });
974        // -- periodic counter flush
975        let flush_counters = self.counters.clone();
976        let flush_graph = self.graph.clone();
977        let flush_me = *self.packet_key.public();
978        let flush_interval = self.cfg.counter_flush_interval;
979        processes.insert(
980            HoprTransportProcess::CounterFlush,
981            hopr_utils::spawn_as_abortable!(async move {
982                use hopr_api::graph::traits::{EdgeObservableWrite, EdgeWeightType};
983
984                futures_time::stream::interval(futures_time::time::Duration::from(flush_interval))
985                    .for_each(|_| {
986                        for (peer, num_packets, num_acks) in flush_counters.drain() {
987                            tracing::trace!(
988                                %peer,
989                                num_packets,
990                                num_acks,
991                                "flushing protocol conformance counters"
992                            );
993                            flush_graph.upsert_edge(&flush_me, &peer, |obs| {
994                                obs.record(EdgeWeightType::ImmediateProtocolConformance { num_packets, num_acks });
995                            });
996                        }
997                        futures::future::ready(())
998                    })
999                    .await;
1000            }),
1001        );
1002
1003        // -- periodic SURB round-trip flush
1004        tracing::info!(?role, "starting surb round-trip flush task");
1005        // Long enough to outlast the silence gate that produced the evidence, so a path that stays
1006        // dead is re-marked before the previous mark lapses, and short enough that a path which
1007        // recovers unnoticed returns to closed-loop control promptly.
1008        const RETURN_PATH_DEGRADED_GRACE: std::time::Duration = std::time::Duration::from_secs(10);
1009        let surb_flush_graph = self.graph.clone();
1010        let surb_flush_interval = self.cfg.surb_flush_interval;
1011        let surb_flush_smgr = self.smgr.clone();
1012        let surb_flush_chain = self.chain_api.clone();
1013        let surb_flush_planner = self.path_planner.clone();
1014        processes.insert(
1015            HoprTransportProcess::SurbFlush,
1016            hopr_utils::spawn_as_abortable!(async move {
1017                let mut episodes = protocol::return_path_recovery::ReturnPathEpisodes::new(RETURN_PATH_DEGRADED_GRACE);
1018                let mut ticks = futures_time::stream::interval(futures_time::time::Duration::from(surb_flush_interval));
1019
1020                while ticks.next().await.is_some() {
1021                    // Detection before the drain: `degraded_destinations` reads the counts the
1022                    // flush is about to reset.
1023                    let silent = surb_round_trips.degraded_destinations();
1024
1025                    // The graph has to see this interval's counts *before* anything re-plans on it,
1026                    // otherwise the re-plan that the silence just triggered reads a graph one whole
1027                    // tick behind the evidence that triggered it.
1028                    protocol::surb_telemetry::flush_into(
1029                        &surb_round_trips,
1030                        &surb_flush_graph,
1031                        hopr_utils::platform::time::native::current_time()
1032                            .as_unix_timestamp()
1033                            .as_millis(),
1034                    );
1035
1036                    // Re-plan first, refill only if re-planning moved traffic. Run the other way
1037                    // round the refill mints SURBs onto the very route the planner is abandoning --
1038                    // see `protocol::return_path_recovery`.
1039                    //
1040                    // Borrowed rather than cloned per call: the callbacks are `FnMut`, so anything
1041                    // they capture has to survive being invoked once per silent destination.
1042                    let (planner, chain, smgr) = (&surb_flush_planner, &surb_flush_chain, &surb_flush_smgr);
1043                    for step in episodes
1044                        .tick(
1045                            silent,
1046                            |destination| async move { planner.recompute_paths_from(&destination).await },
1047                            |destination| async move {
1048                                // Sessions name their destination by its chain address, this
1049                                // telemetry by its packet key, and a `NodeId` holding one is never
1050                                // equal to a `NodeId` holding the other -- so the match has to be
1051                                // made on a resolved form, not on the enum.
1052                                //
1053                                // The counterparty has to still be receiving SURBs to reply with,
1054                                // and its silence has by now convinced our balancer that it is well
1055                                // stocked -- so tell the Sessions routed there to stop believing
1056                                // that estimate while the evidence says otherwise.
1057                                chain
1058                                    .packet_key_to_chain_key(&destination)
1059                                    .ok()
1060                                    .flatten()
1061                                    .map(hopr_api::types::internal::prelude::NodeId::Chain)
1062                                    .map(|n| smgr.mark_return_path_degraded(&n, RETURN_PATH_DEGRADED_GRACE))
1063                                    .unwrap_or(0)
1064                            },
1065                        )
1066                        .await
1067                    {
1068                        match step {
1069                            protocol::return_path_recovery::RecoveryStep::Replanned { destination, moved } => {
1070                                tracing::info!(%destination, entries = moved, "return path silent, re-planned")
1071                            }
1072                            protocol::return_path_recovery::RecoveryStep::Refilled { destination, sessions } => {
1073                                tracing::info!(%destination, sessions, "refilling behind the re-plan")
1074                            }
1075                        }
1076                    }
1077                }
1078            }),
1079        );
1080
1081        // -- network probing
1082        let manual_ping_channel_capacity = std::env::var("HOPR_INTERNAL_MANUAL_PING_CHANNEL_CAPACITY")
1083            .ok()
1084            .and_then(|s| s.trim().parse::<usize>().ok())
1085            .filter(|&c| c > 0)
1086            .unwrap_or(128);
1087        debug!(capacity = manual_ping_channel_capacity, "Creating manual ping channel");
1088        let (manual_ping_tx, manual_ping_rx_raw) =
1089            crossfire::mpsc::bounded_async::<(OffchainPublicKey, PingQueryReplier)>(manual_ping_channel_capacity);
1090        let manual_ping_rx = manual_ping_rx_raw.into_stream();
1091
1092        let probe = Probe::new(self.cfg.probe, self.probing_tag_allocator.clone());
1093
1094        let (probing_processes, probe_classifier) = probe
1095            .continuously_scan(
1096                unresolved_routing_msg_tx.clone(),
1097                manual_ping_rx,
1098                cover_traffic,
1099                self.graph.clone(),
1100            )
1101            .await;
1102
1103        processes.flat_map_extend_from(probing_processes, HoprTransportProcess::Probing);
1104
1105        // manual ping
1106        self.ping
1107            .clone()
1108            .set(Pinger::new(
1109                PingConfig {
1110                    timeout: self.cfg.probe.timeout,
1111                },
1112                manual_ping_tx,
1113            ))
1114            .map_err(|_| HoprTransportError::Api("must set the ticket aggregation writer only once".into()))?;
1115
1116        // Wire incoming: cover-traffic-filtered stream → probe classify → (session dispatch).
1117        // This stage must run in a background task, so the pipeline drains even when the
1118        // caller discards the returned HoprSocket (e.g. edge-node builder).
1119        //
1120        // The channel uses a resilient for_each rather than .forward() so that a disconnected
1121        // receiver (HoprSocket dropped without consuming) logs an error and continues rather
1122        // than collapsing the entire ingress pipeline. Callers should use HoprSocket::reader()
1123        // and actively drain the stream; see hopr-lib builder for the reference drain.
1124        let (on_incoming_data_tx, on_incoming_data_rx) =
1125            bounded_sink_channel::<ApplicationDataIn>(msg_protocol_bidirectional_channel_capacity);
1126        let smgr = self.smgr.clone();
1127        let unresolved_routing_msg_tx_for_task = unresolved_routing_msg_tx.clone();
1128        processes.insert(
1129            HoprTransportProcess::SessionsManagement(0),
1130            hopr_utils::spawn_as_abortable!(async move {
1131                probe_classifier
1132                    .filter_stream(unresolved_routing_msg_tx_for_task, rx_from_protocol)
1133                    .filter_map(move |(pseudonym, data)| {
1134                        hopr_transport_session::counters::DISPATCH_MESSAGE_CALLS
1135                            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1136                        // `dispatch_message` is synchronous and lock-free (moka::sync + crossfire
1137                        // try_send); it never blocks. However, the crossfire stream that feeds
1138                        // this loop does not participate in tokio's cooperative budget, so
1139                        // `future::ready` — which is always Poll::Ready — would let this task
1140                        // monopolize its worker thread under a saturated inbound queue (Processed
1141                        // items are filtered to None before the fold's tx.send().await, the only
1142                        // other suspension point on the hot path). Calling consume_budget() here
1143                        // integrates this loop into tokio's coop scheduler: it is a cheap
1144                        // thread-local decrement on the fast path and only actually yields
1145                        // (~every 128 polls) when the budget is exhausted, giving co-located
1146                        // tasks (including the SPHINX-crypto Rayon pipeline) a chance to run.
1147                        let result = match smgr.dispatch_message(pseudonym, data) {
1148                            Ok(DispatchResult::Processed) => {
1149                                tracing::trace!("message dispatch completed");
1150                                None
1151                            }
1152                            Ok(DispatchResult::Unrelated(data)) => {
1153                                tracing::trace!("unrelated message dispatch completed");
1154                                Some(data)
1155                            }
1156                            Err(error) => {
1157                                tracing::error!(%error, "error while dispatching packet in the session manager");
1158                                None
1159                            }
1160                        };
1161                        async move {
1162                            hopr_utils::runtime::prelude::consume_budget().await;
1163                            result
1164                        }
1165                    })
1166                    .fold(on_incoming_data_tx, |mut tx, data| async move {
1167                        if tx.send(data).await.is_err() {
1168                            tracing::error!(
1169                                task = %HoprTransportProcess::SessionsManagement(0),
1170                                "incoming-data channel disconnected — dropping unrelated packet; \
1171                                 HoprSocket must be consumed or drained by the caller"
1172                            );
1173                        }
1174                        tx
1175                    })
1176                    .await;
1177                tracing::warn!(
1178                    task = %HoprTransportProcess::SessionsManagement(0),
1179                    "long-running background task finished"
1180                );
1181            }),
1182        );
1183
1184        // Populate the OnceLock at the end, making sure everything before didn't fail.
1185        self.network
1186            .clone()
1187            .set(transport_network)
1188            .map_err(|_| HoprTransportError::Api("transport network viewer already set".into()))?;
1189
1190        Ok((
1191            (on_incoming_data_rx.boxed(), unresolved_routing_msg_tx).into(),
1192            processes,
1193        ))
1194    }
1195
1196    #[tracing::instrument(level = "debug", skip(self))]
1197    pub async fn ping(
1198        &self,
1199        peer: &OffchainPublicKey,
1200    ) -> errors::Result<(std::time::Duration, <Graph as NetworkGraphView>::Observed)> {
1201        let me: &OffchainPublicKey = self.packet_key.public();
1202        if peer == me {
1203            return Err(HoprTransportError::Api("ping to self does not make sense".into()));
1204        }
1205
1206        let pinger = self
1207            .ping
1208            .get()
1209            .ok_or_else(|| HoprTransportError::Api("ping processing is not yet initialized".into()))?;
1210
1211        let latency = (*pinger).ping(peer).await?;
1212
1213        if let Some(observations) = self.graph.edge(me, peer) {
1214            Ok((latency, observations))
1215        } else {
1216            Err(HoprTransportError::Api(format!(
1217                "no observations available for peer {peer}",
1218            )))
1219        }
1220    }
1221
1222    #[tracing::instrument(level = "debug", skip(self))]
1223    pub async fn new_session(
1224        &self,
1225        destination: Address,
1226        target: SessionTarget,
1227        cfg: SessionClientConfig,
1228    ) -> errors::Result<(HoprSession, HoprSessionConfigurator)> {
1229        let session = self.smgr.new_session(destination, target, cfg).await?;
1230        let id = *session.id();
1231        Ok((
1232            session,
1233            HoprSessionConfigurator {
1234                id,
1235                smgr: Arc::downgrade(&self.smgr),
1236            },
1237        ))
1238    }
1239
1240    #[tracing::instrument(level = "debug", skip(self))]
1241    pub async fn listening_multiaddresses(&self) -> Vec<Multiaddr> {
1242        self.network
1243            .get()
1244            .ok_or_else(|| HoprTransportError::Api("transport network is not yet initialized".into()))
1245            .map(|network| network.listening_as().into_iter().collect())
1246            .unwrap_or_default()
1247    }
1248
1249    #[tracing::instrument(level = "debug", skip(self))]
1250    pub fn announceable_multiaddresses(&self) -> Vec<Multiaddr> {
1251        let mut mas = self
1252            .local_multiaddresses()
1253            .into_iter()
1254            .filter(|ma| {
1255                crate::multiaddrs::is_supported(ma)
1256                    && (self.cfg.transport.announce_local_addresses || is_public_address(ma))
1257            })
1258            .map(|ma| strip_p2p_protocol(&ma))
1259            .filter(|v| !v.is_empty())
1260            .collect::<Vec<_>>();
1261
1262        mas.sort_by(|l, r| {
1263            let is_left_dns = crate::multiaddrs::is_dns(l);
1264            let is_right_dns = crate::multiaddrs::is_dns(r);
1265
1266            if !(is_left_dns ^ is_right_dns) {
1267                std::cmp::Ordering::Equal
1268            } else if is_left_dns {
1269                std::cmp::Ordering::Less
1270            } else {
1271                std::cmp::Ordering::Greater
1272            }
1273        });
1274
1275        mas
1276    }
1277
1278    /// Returns a reference to the network graph.
1279    pub fn graph(&self) -> &Graph {
1280        &self.graph
1281    }
1282
1283    /// Returns a reference to the SURB store.
1284    ///
1285    /// Exposed so that chain-level channel events can invalidate stored SURBs whose return path
1286    /// starts with a relayer this node can no longer pay.
1287    pub fn surb_store(&self) -> &MemorySurbStore {
1288        &self.path_planner.surb_store
1289    }
1290
1291    #[tracing::instrument(level = "debug", skip(self))]
1292    pub fn local_multiaddresses(&self) -> Vec<Multiaddr> {
1293        self.network
1294            .get()
1295            .map(|network| network.listening_as().into_iter().collect())
1296            .unwrap_or_else(|| {
1297                tracing::error!("transport network is not yet initialized, cannot fetch announced multiaddresses");
1298                self.my_multiaddresses.clone()
1299            })
1300    }
1301
1302    #[tracing::instrument(level = "debug", skip(self))]
1303    pub async fn network_observed_multiaddresses(&self, peer: &OffchainPublicKey) -> Vec<Multiaddr> {
1304        match self
1305            .network
1306            .get()
1307            .ok_or_else(|| HoprTransportError::Api("transport network is not yet initialized".into()))
1308        {
1309            Ok(network) => network
1310                .multiaddress_of(&peer.into())
1311                .unwrap_or_default()
1312                .into_iter()
1313                .collect(),
1314            Err(error) => {
1315                tracing::error!(%error, "failed to get observed multiaddresses");
1316                return vec![];
1317            }
1318        }
1319    }
1320
1321    #[tracing::instrument(level = "debug", skip(self))]
1322    pub async fn network_health(&self) -> Health {
1323        self.network
1324            .get()
1325            .ok_or_else(|| HoprTransportError::Api("transport network is not yet initialized".into()))
1326            .map(|network| network.health())
1327            .unwrap_or(Health::Red)
1328    }
1329
1330    pub async fn network_connected_peers(&self) -> errors::Result<Vec<OffchainPublicKey>> {
1331        Ok(futures::stream::iter(
1332            self.network
1333                .get()
1334                .ok_or_else(|| {
1335                    tracing::error!("transport network is not yet initialized");
1336                    HoprTransportError::Api("transport network is not yet initialized".into())
1337                })?
1338                .connected_peers(),
1339        )
1340        .filter_map(|peer_id| async move {
1341            match peer_id_to_public_key(&peer_id) {
1342                Ok(key) => Some(key),
1343                Err(error) => {
1344                    tracing::warn!(%peer_id, %error, "failed to convert PeerId to OffchainPublicKey");
1345                    None
1346                }
1347            }
1348        })
1349        .collect()
1350        .await)
1351    }
1352
1353    #[tracing::instrument(level = "debug", skip(self))]
1354    pub fn network_peer_observations(&self, peer: &OffchainPublicKey) -> Option<<Graph as NetworkGraphView>::Observed> {
1355        self.graph.edge(self.packet_key.public(), peer)
1356    }
1357
1358    /// Get connected peers with quality higher than some value.
1359    #[tracing::instrument(level = "debug", skip(self))]
1360    pub async fn all_network_peers(
1361        &self,
1362        minimum_score: f64,
1363    ) -> errors::Result<Vec<(OffchainPublicKey, <Graph as NetworkGraphView>::Observed)>> {
1364        let me = self.packet_key.public();
1365        Ok(self
1366            .network_connected_peers()
1367            .await?
1368            .into_iter()
1369            .filter_map(|peer| {
1370                let observation = self.graph.edge(me, &peer);
1371                if let Some(info) = observation {
1372                    // An unobserved edge has no score and cannot clear any threshold.
1373                    if info.score().is_some_and(|score| score >= minimum_score) {
1374                        Some((peer, info))
1375                    } else {
1376                        None
1377                    }
1378                } else {
1379                    None
1380                }
1381            })
1382            .collect::<Vec<_>>())
1383    }
1384}
1385
1386/// Maps a fully recovered SSA into the downstream event that carries the recovered deposit
1387/// key to the withdrawal strategy.
1388pub(crate) fn recovered_ssa_to_pix_event(
1389    rec: &RecoveredSsa<SimplePseudonym, <HoprPixSpec as PixSpec>::AddressPrivateKey>,
1390) -> PixEvent {
1391    PixEvent::PrivateKeyRecovered(PixPrivateKeyRecovered {
1392        id: (*rec.ssa_id.pseudonym(), rec.ssa_id.ssa_index()),
1393        secret: PixDepositSecret(rec.ssa.secret().clone()),
1394    })
1395}
1396
1397/// How many PIX share resolutions may be dispatched into the [`SessionManager`] at once.
1398///
1399/// A dispatch is not cheap: `SsaAlmostRecovered` / `SsaRecovered` reach `request_next_ssa`, which
1400/// acquires a per-session lock (with a 30 s timeout), generates an Exit commitment on the blocking
1401/// pool and sends an `SsaRequest` over the network. All sessions share this one stream, so
1402/// dispatching sequentially lets a single slow or stalled session hold up PIX progress — and
1403/// therefore the pipelined next-SSA request — for every other session on the node.
1404const PIX_EVENT_DISPATCH_CONCURRENCY: usize = 64;
1405
1406/// Maps a [`HoprSessionOutPixEvent`] into the upper-layer [`PixEvent`] carrying the deposit
1407/// instruction for the funding strategy.
1408fn session_pix_event_to_pix_event(event: HoprSessionOutPixEvent) -> PixEvent {
1409    match event {
1410        HoprSessionOutPixEvent::ReadyToDeposit(AgreedSsaQuota {
1411            ssa_id,
1412            deposit_address,
1413            quota_per_ssa,
1414        }) => PixEvent::NewDepositAddress(PixNewDepositAddress {
1415            id: (*ssa_id.pseudonym(), ssa_id.ssa_index()),
1416            address: deposit_address.into(),
1417            quota: quota_per_ssa,
1418            // `None` rather than the handshake's `deposit_data`: that field is carried by the Start
1419            // protocol's `SsaRequest` (`hopr-protocol-start`) and is not surfaced through
1420            // `AgreedSsaQuota`, so there is nothing here to forward yet. Threading it through is a
1421            // change to the Session layer's event types, not to this mapping.
1422            additional_data: None,
1423        }),
1424        HoprSessionOutPixEvent::DepositNeeded(
1425            AgreedSsaQuota {
1426                ssa_id,
1427                deposit_address,
1428                quota_per_ssa,
1429            },
1430            notifier,
1431        ) => PixEvent::DepositAddressReceived(PixDepositAddressReceived {
1432            id: (*ssa_id.pseudonym(), ssa_id.ssa_index()),
1433            address: deposit_address.into(),
1434            quota: quota_per_ssa,
1435            // See `NewDepositAddress` above: the handshake's `deposit_data` does not reach
1436            // `AgreedSsaQuota`, so there is nothing to forward yet.
1437            additional_data: None,
1438            deposit_updated: Some(notifier),
1439        }),
1440    }
1441}
1442
1443/// Notifies the [`SessionManager`] about a single PIX share resolution and returns the
1444/// upper-layer [`PixEvent`] it produces, if any.
1445async fn dispatch_share_resolution(smgr: Arc<HoprSessionManager>, resolution: HoprShareResolution) -> Option<PixEvent> {
1446    match resolution {
1447        ShareResolution::RecoveredSsa(ssa_recovery_event) => {
1448            if let Err(error) = smgr
1449                .dispatch_pix_event(HoprSessionInPixEvent::SsaRecovered(ssa_recovery_event.ssa_id))
1450                .await
1451            {
1452                tracing::error!(%error, "failed to dispatch SSA recovery PIX event to the SessionManager");
1453            }
1454            Some(recovered_ssa_to_pix_event(&ssa_recovery_event))
1455        }
1456        ShareResolution::AlmostRecoveredSsa(ssa_id) => {
1457            if let Err(error) = smgr
1458                .dispatch_pix_event(HoprSessionInPixEvent::SsaAlmostRecovered(ssa_id))
1459                .await
1460            {
1461                tracing::error!(%error, %ssa_id, "failed to dispatch early SSA recovery event to the SessionManager");
1462            }
1463            None
1464        }
1465        ShareResolution::InvalidShares {
1466            peer,
1467            ssa_id,
1468            observed_total,
1469        } => {
1470            tracing::error!(
1471                %peer, %ssa_id, observed_total,
1472                "first RP relayer sent acknowledgement indicating invalid PIX share from Entry"
1473            );
1474            if let Err(error) = smgr
1475                .dispatch_pix_event(HoprSessionInPixEvent::UnverifiableShare(ssa_id))
1476                .await
1477            {
1478                tracing::error!(%error, %ssa_id, "failed to dispatch invalid share PIX event to the SessionManager");
1479            }
1480            None
1481        }
1482        // Nothing consumes recovery progress yet — the Exit-side PIX supervisor is what will, and it
1483        // is the only thing that can act on a running total. Dropped here rather than suppressed at
1484        // the reconstructor so that the emission contract (and its tests) live with the producer.
1485        //
1486        // Cheap to discard: this branch awaits nothing, so the resolution channel is drained faster
1487        // than the acknowledgement path can fill it.
1488        ShareResolution::Progress(progress) => {
1489            tracing::trace!(
1490                ssa_id = %progress.ssa_id,
1491                useful_shares = progress.useful_shares,
1492                target = progress.target_useful_shares,
1493                recovered_polynomials = progress.recovered_polynomials,
1494                "pix recovery progress"
1495            );
1496            None
1497        }
1498    }
1499}
1500
1501/// Builds the Exit-side SSA reconstructor from operator configuration.
1502///
1503/// The single place `PixReconstructorConfig` becomes an `SsaReconstructorConfig`, called once per
1504/// node role that needs a reconstructor. Both production sites used to take
1505/// `SsaReconstructorConfig::default()` instead, which left all seven of the Exit's dials — capacity,
1506/// lifetimes, the acknowledgement window — unreachable from a config file.
1507///
1508/// `try_new` rather than `new`, for the same reason the generator uses it at the one call site
1509/// above: these values now come from an operator, so an out-of-range one is a startup error to
1510/// report and not a panic to take the node down with. `run_inner` has already validated
1511/// `self.cfg.pix` — `reconstructor` is `#[validate(nested)]` — so in practice this reports what that
1512/// validation would have caught anyway; it stays fallible because nothing in the type system says
1513/// the two must be called in that order.
1514fn ssa_reconstructor(cfg: &PixGlobalConfig) -> errors::Result<Arc<hopr_protocol_pix::SsaReconstructor<HoprPixSpec>>> {
1515    hopr_protocol_pix::SsaReconstructor::<HoprPixSpec>::try_new(cfg.reconstructor.into())
1516        .map(Arc::new)
1517        .map_err(|error| HoprTransportError::Api(format!("invalid SSA reconstructor configuration: {error}")))
1518}
1519
1520/// Wires the Exit-side PIX machinery: [`PixToolbox`], the recovered-share channel and the
1521/// [`HoprTransportProcess::PixEvents`] task, returning the toolbox and the pipeline builder with
1522/// share processing attached.
1523///
1524/// Shared by the `(Exit, Some)` and `(Relay, Some)` arms of `run_inner`, which differ only in the
1525/// terminal `build_for_*` call and the relay's extra `with_ticket_events` step. Both need the exact
1526/// same wiring, and keeping it in one place is not cosmetic: the previous two copies had already
1527/// drifted, with the comment on the reconstructor config fixed in the Exit copy only.
1528///
1529/// The reconstructor arrives built, like the generator, so that [`ssa_reconstructor`] stays the one
1530/// place a config turns into one and this stays infallible.
1531///
1532/// The caller keeps the terminal, so this returns the builder rather than the built processes.
1533/// Only the `exit_ack_proc` and `ssa_events` type parameters change; the rest pass through
1534/// untouched, which is why none of them carry bounds here.
1535#[allow(clippy::type_complexity)]
1536fn wire_exit_pix<WIn, WOut, Chain, S, TFact, G, AppOut, AppIn, TEvt, PixEvt>(
1537    pipeline_builder: HoprPacketPipelineBuilder<WIn, WOut, Chain, S, TFact, G, AppOut, AppIn, TEvt>,
1538    ssa_generator: Arc<hopr_protocol_pix::SsaShareGenerator<HoprPixSpec>>,
1539    ssa_reconstructor: Arc<hopr_protocol_pix::SsaReconstructor<HoprPixSpec>>,
1540    ssa_events: &PixEvt,
1541    smgr: Arc<HoprSessionManager>,
1542    processes: &mut AbortableList<HoprTransportProcess>,
1543) -> (
1544    PixToolbox,
1545    HoprPacketPipelineBuilder<
1546        WIn,
1547        WOut,
1548        Chain,
1549        S,
1550        TFact,
1551        G,
1552        AppOut,
1553        AppIn,
1554        TEvt,
1555        Arc<hopr_protocol_pix::SsaReconstructor<HoprPixSpec>>,
1556        CrossfireSink<HoprShareResolution>,
1557    >,
1558)
1559where
1560    PixEvt: futures::Sink<PixEvent> + Clone + Unpin + Send + 'static,
1561    PixEvt::Error: std::error::Error + Clone + Sync + Send + 'static,
1562{
1563    let (pix_tools, session_pix_events) = PixToolbox::new(ssa_generator, ssa_reconstructor.clone());
1564    let (ssa_share_resolution_events_tx, ssa_share_resolution_events_rx) = bounded_sink_channel(1024);
1565    processes.insert(
1566        HoprTransportProcess::PixEvents,
1567        hopr_utils::spawn_as_abortable!(
1568            pix_event_stream(session_pix_events, ssa_share_resolution_events_rx, smgr)
1569                .map(Ok)
1570                .forward(ssa_events.clone().sink_map_err(HoprTransportError::other))
1571        ),
1572    );
1573
1574    (
1575        pix_tools,
1576        pipeline_builder.with_exit_ack_share_processing(ssa_reconstructor, ssa_share_resolution_events_tx),
1577    )
1578}
1579
1580/// Builds the merged PIX event stream feeding the upper layer.
1581///
1582/// Combines the Session-originated deposit instructions with the share resolutions coming out of
1583/// the packet pipeline. Share resolutions are dispatched into the `SessionManager` concurrently
1584/// (see [`PIX_EVENT_DISPATCH_CONCURRENCY`]).
1585///
1586/// Concurrency does not require ordering guarantees here: `request_next_ssa` serializes on a
1587/// per-session lock and re-checks the SSA index under it, so of the events belonging to one cycle
1588/// exactly one advances the index and the rest are recognised as stale and become no-ops,
1589/// regardless of the order in which they arrive.
1590fn pix_event_stream(
1591    session_pix_events: impl futures::Stream<Item = HoprSessionOutPixEvent> + Send + 'static,
1592    ssa_share_resolutions: impl futures::Stream<Item = HoprShareResolution> + Send + 'static,
1593    smgr: Arc<HoprSessionManager>,
1594) -> impl futures::Stream<Item = PixEvent> + Send + 'static {
1595    session_pix_events.map(session_pix_event_to_pix_event).merge(
1596        ssa_share_resolutions
1597            .then_concurrent(
1598                move |resolution| dispatch_share_resolution(smgr.clone(), resolution),
1599                PIX_EVENT_DISPATCH_CONCURRENCY,
1600            )
1601            .filter_map(futures::future::ready),
1602    )
1603}
1604
1605// ---------------------------------------------------------------------------
1606// NetworkView impl for HoprTransport — wraps OnceLock<Net> access
1607// ---------------------------------------------------------------------------
1608
1609impl<Chain, Graph, Net> NetworkView for HoprTransport<Chain, Graph, Net>
1610where
1611    Net: NetworkView + Send + Sync + 'static,
1612{
1613    fn listening_as(&self) -> std::collections::HashSet<Multiaddr> {
1614        self.network.get().map(|n| n.listening_as()).unwrap_or_default()
1615    }
1616
1617    fn multiaddress_of(&self, peer: &PeerId) -> Option<std::collections::HashSet<Multiaddr>> {
1618        self.network.get()?.multiaddress_of(peer)
1619    }
1620
1621    fn discovered_peers(&self) -> std::collections::HashSet<PeerId> {
1622        self.network.get().map(|n| n.discovered_peers()).unwrap_or_default()
1623    }
1624
1625    fn connected_peers(&self) -> std::collections::HashSet<PeerId> {
1626        self.network.get().map(|n| n.connected_peers()).unwrap_or_default()
1627    }
1628
1629    fn is_connected(&self, peer: &PeerId) -> bool {
1630        self.network.get().map(|n| n.is_connected(peer)).unwrap_or(false)
1631    }
1632
1633    fn health(&self) -> Health {
1634        self.network.get().map(|n| n.health()).unwrap_or(Health::Red)
1635    }
1636
1637    fn subscribe_network_events(
1638        &self,
1639    ) -> impl futures::Stream<Item = hopr_api::network::NetworkEvent> + Send + 'static {
1640        match self.network.get() {
1641            Some(n) => futures::future::Either::Left(n.subscribe_network_events()),
1642            None => futures::future::Either::Right(futures::stream::empty()),
1643        }
1644    }
1645}
1646
1647// ---------------------------------------------------------------------------
1648// TransportOperations impl for HoprTransport
1649// ---------------------------------------------------------------------------
1650
1651#[async_trait::async_trait]
1652impl<Chain, Graph, Net> hopr_api::node::TransportOperations for HoprTransport<Chain, Graph, Net>
1653where
1654    Chain: ChainReadChannelOperations
1655        + ChainReadAccountOperations
1656        + hopr_api::chain::ChainWriteTicketOperations
1657        + ChainKeyOperations
1658        + hopr_api::chain::ChainReadTicketOperations
1659        + ChainValues
1660        + Clone
1661        + Send
1662        + Sync
1663        + 'static,
1664    Graph: NetworkGraphView<NodeId = OffchainPublicKey>
1665        + NetworkGraphUpdate
1666        + hopr_api::graph::NetworkGraphWrite<NodeId = OffchainPublicKey>
1667        + hopr_api::graph::NetworkGraphTraverse<NodeId = OffchainPublicKey>
1668        + Clone
1669        + Send
1670        + Sync
1671        + 'static,
1672    <Graph as NetworkGraphView>::Observed: EdgeObservableRead + Send,
1673    <Graph as hopr_api::graph::NetworkGraphTraverse>::Observed: EdgeObservableRead + Send + 'static,
1674    <Graph as hopr_api::graph::NetworkGraphWrite>::Observed: hopr_api::graph::traits::EdgeObservableWrite + Send,
1675    Net: NetworkView + NetworkStreamControl + Clone + Send + Sync + 'static,
1676{
1677    type Error = errors::HoprTransportError;
1678    type Observable = <Graph as NetworkGraphView>::Observed;
1679
1680    async fn ping(&self, key: &OffchainPublicKey) -> Result<(Duration, Self::Observable), Self::Error> {
1681        self.ping(key).await
1682    }
1683
1684    async fn observed_multiaddresses(&self, key: &OffchainPublicKey) -> Vec<Multiaddr> {
1685        self.network_observed_multiaddresses(key).await
1686    }
1687}
1688
1689/// Maximum application-layer payload that fits in a single HOPR sphinx packet (bytes).
1690pub const PACKET_PAYLOAD_SIZE: usize = hopr_crypto_packet::prelude::HoprPacket::PAYLOAD_SIZE;
1691
1692#[cfg(test)]
1693mod pix_recovery_event_tests {
1694    use hopr_api::{
1695        node::PixEvent,
1696        types::{
1697            crypto::{
1698                keypairs::{Keypair, OffchainKeypair},
1699                types::{HalfKey, SimplePseudonym},
1700            },
1701            crypto_random::{Randomizable, random_bytes},
1702            internal::prelude::VerifiedAcknowledgement,
1703        },
1704    };
1705    use hopr_crypto_packet::HoprPixSpec;
1706    use hopr_protocol_pix::{
1707        EntryShareGenerator, ExitAcknowledgementShareProcessor, PixSpec, SsaGeneratorConfig, SsaId, SsaIndex,
1708        SsaReconstructor, SsaReconstructorConfig, SsaShareGenerator, TaggedEncryptedPartialSsaShare,
1709    };
1710
1711    use super::recovered_ssa_to_pix_event;
1712
1713    #[test]
1714    fn recovered_ssa_maps_to_private_key_event_with_correct_secret_and_id() -> anyhow::Result<()> {
1715        let cfg = SsaGeneratorConfig {
1716            polynomials_per_ssa: 2,
1717            threshold: 2,
1718            surplus_shares: 0,
1719        };
1720        let generator = SsaShareGenerator::<HoprPixSpec>::new(cfg);
1721        let reconstructor = SsaReconstructor::<HoprPixSpec>::new(SsaReconstructorConfig {
1722            early_recovery_threshold: 1.0,
1723            ..Default::default()
1724        });
1725
1726        let pseudonym = SimplePseudonym::random();
1727        let peer = OffchainKeypair::random();
1728        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
1729
1730        let client = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
1731        let server_commitment = reconstructor.new_exit_commitment(ssa_id, 2, 2)?;
1732        let expected_addr = HoprPixSpec::group_to_deposit_address(client.ssa_commitment + server_commitment)
1733            .ok_or_else(|| anyhow::anyhow!("deposit address"))?;
1734        client.process_into_reconstructor(&reconstructor)?;
1735
1736        let mut acks = Vec::new();
1737        while let Some((msg, share)) = {
1738            let msg = random_bytes::<20>();
1739            generator.next_share(&pseudonym, &msg).map(|v| v.map(|u| (msg, u)))
1740        }? {
1741            let ack = HalfKey::random();
1742            let enc = share.share.encrypt(&share.id, &ack)?;
1743            reconstructor.insert_encrypted_share(
1744                peer.public(),
1745                ack.to_challenge()?,
1746                TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc)?,
1747            )?;
1748            acks.push(VerifiedAcknowledgement::new(ack, &peer).leak());
1749        }
1750
1751        let resolutions = reconstructor.acknowledge_shares(*peer.public(), acks)?;
1752        let rec = resolutions
1753            .into_iter()
1754            .find_map(|r| r.try_as_recovered_ssa())
1755            .ok_or_else(|| anyhow::anyhow!("expected a RecoveredSsa resolution"))?;
1756
1757        assert_eq!(<HoprPixSpec as PixSpec>::DepositAddress::from(&rec.ssa), expected_addr);
1758
1759        let PixEvent::PrivateKeyRecovered(pk) = recovered_ssa_to_pix_event(&rec) else {
1760            anyhow::bail!("expected PrivateKeyRecovered");
1761        };
1762
1763        assert_eq!(pk.id, (pseudonym, ssa_id.ssa_index()));
1764        assert_eq!(pk.secret.0.as_ref(), rec.ssa.secret().as_ref());
1765
1766        Ok(())
1767    }
1768}
1769
1770#[cfg(test)]
1771mod tests {
1772    use std::sync::{
1773        Arc,
1774        atomic::{AtomicU64, Ordering},
1775    };
1776
1777    use futures::StreamExt;
1778    use hopr_utils::runtime::prelude::{consume_budget, spawn, yield_now};
1779
1780    /// Verifies that the `filter_map` closure in `SessionsManagement(0)` does not monopolize
1781    /// the tokio worker thread under a continuously saturated inbound stream.
1782    ///
1783    /// ### Background
1784    ///
1785    /// The inbound dispatch loop (`transport/hopr/src/lib.rs`) drives:
1786    ///
1787    /// ```text
1788    /// rx_from_protocol (crossfire, no coop budget)
1789    ///   → filter_stream
1790    ///   → filter_map(sync dispatch_message + consume_budget().await)
1791    ///   → fold(tx.send().await)          ← only fires for Unrelated items
1792    /// ```
1793    ///
1794    /// On the `Processed` hot path (common case), items become `None` and are filtered
1795    /// out before the `fold`'s `tx.send().await`, so the only suspension point is the
1796    /// `consume_budget()` call inside `filter_map`.
1797    ///
1798    /// ### What this test proves
1799    ///
1800    /// On a `current_thread` runtime (single worker, fully cooperative scheduling) a tight
1801    /// stream loop that never returns `Poll::Pending` starves every other spawned task until
1802    /// the stream is drained.  By replacing `future::ready(result)` with an `async` block
1803    /// that calls `consume_budget().await` before returning, we give the scheduler a chance
1804    /// to preempt the loop every ≈128 polls.  The "canary" task — a plain `yield_now` loop —
1805    /// *must* make forward progress while the dispatch stream is draining for the fix to be
1806    /// considered effective.
1807    ///
1808    /// Reverting the `consume_budget().await` to `future::ready(result)` will make this
1809    /// test fail: the canary will never be scheduled and its counter will remain 0.
1810    #[tokio::test(flavor = "current_thread")]
1811    async fn sessions_management_dispatch_loop_yields_to_scheduler() {
1812        // N large enough to trigger ≥1 genuine yield (budget = 128 per reset).
1813        const N: usize = 1_000;
1814
1815        // Canary: counts how many times it was scheduled during the dispatch run.
1816        let counter = Arc::new(AtomicU64::new(0));
1817        let counter_clone = counter.clone();
1818        let canary = spawn(async move {
1819            loop {
1820                counter_clone.fetch_add(1, Ordering::Relaxed);
1821                yield_now().await;
1822            }
1823        });
1824
1825        // Dispatch loop — same shape as the fixed SessionsManagement(0) hot path:
1826        //   sync compute → consume_budget().await → None (Processed path)
1827        let unrelated_count = futures::stream::iter(0..N)
1828            .filter_map(|_item| {
1829                // Dispatch result computed synchronously (no clone); budget consumed async.
1830                let result: Option<()> = None; // every item is "Processed"
1831                async move {
1832                    consume_budget().await;
1833                    result
1834                }
1835            })
1836            .fold(0u64, |acc, _| async move { acc + 1 })
1837            .await;
1838
1839        assert_eq!(
1840            unrelated_count, 0,
1841            "all Processed items should be filtered out by filter_map"
1842        );
1843
1844        // The canary must have been scheduled at least once while the dispatch loop ran.
1845        // If consume_budget() is removed and future::ready() is used instead, the loop
1846        // never returns Poll::Pending on the current_thread executor and this assertion fails.
1847        let progress = counter.load(Ordering::Relaxed);
1848        assert!(
1849            progress > 0,
1850            "canary task made no forward progress during the {N}-item dispatch run (counter = {progress}); the \
1851             dispatch loop is monopolizing the executor thread — ensure filter_map returns `async move {{ \
1852             consume_budget().await; result }}` rather than `future::ready(result)`"
1853        );
1854
1855        canary.abort();
1856        canary.await.ok();
1857    }
1858}