Skip to main content

hopr_lib/
builder.rs

1//! Type-state builder for constructing a [`Hopr`] node.
2//!
3//! The builder guides construction through a series of mandatory phases:
4//!
5//! 1. **Identity** — `HoprBuilder` → `HoprBuilder::with_identity`
6//! 2. **Configuration** — `HoprBuilderWithIdentity::with_config`
7//! 3. **Component factories** — chain API, graph, network, and cover-traffic
8//! 4. **Session server** (when the `session-server` feature is enabled) — `HoprBuilderConfigured::with_session_server`
9//! 5. **Build** — `build_edge` for an entry/exit node or `build_full` for a relay node with ticket management.
10//!
11//! # Example
12//!
13//! ```rust,ignore
14//! use hopr_lib::{config::HoprLibConfig, builder::{HoprBuilder, ChainKeypair, OffchainKeypair, Keypair}};
15//!
16//! let chain_key = ChainKeypair::random();
17//! let offchain_key = OffchainKeypair::random();
18//! let config = HoprLibConfig::default();
19//!
20//! let builder = HoprBuilder
21//!     .with_identity(&chain_key, &offchain_key)
22//!     .with_config(config)
23//!     .with_chain_api(|_ctx| { /* ... */ todo!() })
24//!     .with_graph(|_ctx| { /* ... */ todo!() })
25//!     .with_network(|_ctx| Box::pin(async { /* ... */ Ok(todo!()) }))
26//!     .with_cover_traffic(|_ctx| { /* ... */ todo!() });
27//! ```
28
29mod chain_wiring;
30
31use std::{future::Future, pin::Pin, sync::Arc, time::Duration};
32
33use futures::{FutureExt, StreamExt};
34pub use hopr_api::types::crypto::{
35    keypairs::Keypair,
36    prelude::{ChainKeypair, OffchainKeypair},
37};
38use hopr_api::{
39    chain::{AnnouncementError, HoprChainApi, SafeRegistrationError, StateSyncOptions},
40    ct::{CoverTrafficGeneration, ProbingTrafficGeneration},
41    graph::HoprGraphApi,
42    network::{BoxedProcessFn, NetworkStreamControl, NetworkView},
43    node::{AtomicHoprState, HoprState, NodeOnchainIdentity, PixEvent, TicketEvent},
44    tickets::{TicketFactory, TicketManagement},
45    types::{chain::chain_events::ChainEvent, internal::prelude::ChannelDirection, primitive::prelude::Address},
46};
47use hopr_transport::{HoprTransport, IncomingSession};
48use hopr_utils::{
49    network_types::{
50        addr::is_public_address,
51        crossfire_sink::{CrossfireSink, bounded_sink_channel},
52    },
53    runtime::{AbortableList, prelude::spawn},
54};
55use validator::Validate;
56
57use crate::{
58    Hopr, HoprLibError, HoprLibProcess, MIN_NATIVE_BALANCE, NODE_READY_TIMEOUT, SUGGESTED_NATIVE_BALANCE,
59    config::HoprLibConfig, constants, helpers::BroadcastSenderSink,
60};
61
62#[cfg(all(feature = "telemetry", not(test)))]
63lazy_static::lazy_static! {
64    static ref METRIC_PROCESS_START_TIME:  hopr_api::types::telemetry::SimpleGauge =  hopr_api::types::telemetry::SimpleGauge::new(
65        "hopr_start_time",
66        "The unix timestamp in seconds at which the process was started"
67    ).unwrap();
68    static ref METRIC_HOPR_LIB_VERSION:  hopr_api::types::telemetry::MultiGauge =  hopr_api::types::telemetry::MultiGauge::new(
69        "hopr_lib_version",
70        "Executed version of hopr-lib",
71        &["version"]
72    ).unwrap();
73    static ref METRIC_HOPR_NODE_INFO:  hopr_api::types::telemetry::MultiGauge =  hopr_api::types::telemetry::MultiGauge::new(
74        "hopr_node_addresses",
75        "Node on-chain and off-chain addresses",
76        &["peerid", "address", "safe_address", "module_address"]
77    ).unwrap();
78}
79
80const PEER_DISCOVERY_CHANNEL_CAPACITY: usize = 2048;
81
82type PeerDiscoveryRx =
83    Arc<parking_lot::Mutex<Option<futures::stream::BoxStream<'static, (hopr_api::PeerId, Vec<hopr_api::Multiaddr>)>>>>;
84
85/// Type-erased factory closure producing `T` from a [`BuildCtx`] reference.
86type Factory<T> = Box<dyn FnOnce(&BuildCtx) -> T + Send>;
87type AsyncFactory<T> = Box<dyn FnOnce(BuildCtx) -> Pin<Box<dyn Future<Output = T> + Send>> + Send>;
88
89/// Context available to factory closures during the build step.
90#[derive(Clone)]
91pub struct BuildCtx {
92    /// Node's on-chain keypair.
93    pub chain_key: ChainKeypair,
94    /// Node's off-chain (packet) keypair.
95    pub packet_key: OffchainKeypair,
96    /// Node configuration.
97    pub cfg: HoprLibConfig,
98    peer_discovery_rx: PeerDiscoveryRx,
99}
100
101impl BuildCtx {
102    /// Take the peer-discovery receiver. Returns `Some` on the first call, `None` afterwards.
103    pub fn take_peer_discovery_rx(
104        &self,
105    ) -> Option<futures::stream::BoxStream<'static, (hopr_api::PeerId, Vec<hopr_api::Multiaddr>)>> {
106        self.peer_discovery_rx.lock().take()
107    }
108}
109
110// ---------------------------------------------------------------------------
111// Type-state builder phases
112// ---------------------------------------------------------------------------
113
114/// Initial builder — forces `with_identity` first.
115#[derive(Default)]
116pub struct HoprBuilder;
117
118impl HoprBuilder {
119    /// Sets the node's on-chain and off-chain identity.
120    pub fn with_identity(self, chain_key: &ChainKeypair, offchain_key: &OffchainKeypair) -> HoprBuilderWithIdentity {
121        HoprBuilderWithIdentity {
122            chain_key: chain_key.clone(),
123            packet_key: offchain_key.clone(),
124        }
125    }
126}
127
128/// Builder with identity set — forces `with_config` next.
129pub struct HoprBuilderWithIdentity {
130    chain_key: ChainKeypair,
131    packet_key: OffchainKeypair,
132}
133
134impl HoprBuilderWithIdentity {
135    /// Sets the node configuration and produces the configured builder.
136    pub fn with_config(self, cfg: HoprLibConfig) -> HoprBuilderConfigured {
137        let (peer_discovery_tx, peer_discovery_rx) =
138            bounded_sink_channel::<(hopr_api::PeerId, Vec<hopr_api::Multiaddr>)>(PEER_DISCOVERY_CHANNEL_CAPACITY);
139        HoprBuilderConfigured {
140            ctx: BuildCtx {
141                chain_key: self.chain_key,
142                packet_key: self.packet_key,
143                cfg,
144                peer_discovery_rx: Arc::new(parking_lot::Mutex::new(Some(peer_discovery_rx))),
145            },
146            safe_and_module: None,
147            chain_factory: None,
148            graph_factory: None,
149            network_factory: None,
150            ct_factory: None,
151            peer_discovery_tx,
152        }
153    }
154}
155
156// ---------------------------------------------------------------------------
157// HoprBuilderConfigured — stores factories, no session yet
158// ---------------------------------------------------------------------------
159
160/// Configured builder accepting factory closures for components.
161///
162/// When the `session-server` feature is enabled, `with_session_server`
163/// must be called before building — it returns a `HoprBuilderWithSession` which
164/// has the `build_edge` / `build_full` methods.
165///
166/// When the feature is disabled, `build_edge` / `build_full` are available directly.
167pub struct HoprBuilderConfigured<Chain = (), Graph = (), Net = (), Ct = ()> {
168    ctx: BuildCtx,
169    safe_and_module: Option<(Address, Address)>,
170    chain_factory: Option<Factory<Chain>>,
171    graph_factory: Option<Factory<Graph>>,
172    network_factory: Option<AsyncFactory<Result<(Net, BoxedProcessFn), HoprLibError>>>,
173    ct_factory: Option<Factory<Ct>>,
174    peer_discovery_tx: CrossfireSink<(hopr_api::PeerId, Vec<hopr_api::Multiaddr>)>,
175}
176
177impl<Chain, Graph, Net, Ct> HoprBuilderConfigured<Chain, Graph, Net, Ct> {
178    /// Sets the node Safe and module addresses.
179    pub fn with_safe_module(mut self, safe: &Address, module: &Address) -> Self {
180        self.safe_and_module = Some((*safe, *module));
181        self
182    }
183
184    /// Sets the chain API factory.
185    pub fn with_chain_api<NewChain>(
186        self,
187        f: impl FnOnce(&BuildCtx) -> NewChain + Send + 'static,
188    ) -> HoprBuilderConfigured<NewChain, Graph, Net, Ct> {
189        HoprBuilderConfigured {
190            ctx: self.ctx,
191            safe_and_module: self.safe_and_module,
192            chain_factory: Some(Box::new(f)),
193            graph_factory: self.graph_factory,
194            network_factory: self.network_factory,
195            ct_factory: self.ct_factory,
196            peer_discovery_tx: self.peer_discovery_tx,
197        }
198    }
199
200    /// Sets the graph factory.
201    pub fn with_graph<NewGraph>(
202        self,
203        f: impl FnOnce(&BuildCtx) -> NewGraph + Send + 'static,
204    ) -> HoprBuilderConfigured<Chain, NewGraph, Net, Ct> {
205        HoprBuilderConfigured {
206            ctx: self.ctx,
207            safe_and_module: self.safe_and_module,
208            chain_factory: self.chain_factory,
209            graph_factory: Some(Box::new(f)),
210            network_factory: self.network_factory,
211            ct_factory: self.ct_factory,
212            peer_discovery_tx: self.peer_discovery_tx,
213        }
214    }
215
216    /// Sets the network factory. Must resolve to `Ok((Net, BoxedProcessFn))`.
217    ///
218    /// The factory receives [`BuildCtx`] by value and returns a boxed future,
219    /// allowing async network construction without blocking the executor.
220    /// Failures are propagated as [`HoprLibError`] during the build step.
221    pub fn with_network<NewNet>(
222        self,
223        f: impl FnOnce(BuildCtx) -> Pin<Box<dyn Future<Output = Result<(NewNet, BoxedProcessFn), HoprLibError>> + Send>>
224        + Send
225        + 'static,
226    ) -> HoprBuilderConfigured<Chain, Graph, NewNet, Ct> {
227        HoprBuilderConfigured {
228            ctx: self.ctx,
229            safe_and_module: self.safe_and_module,
230            chain_factory: self.chain_factory,
231            graph_factory: self.graph_factory,
232            network_factory: Some(Box::new(f)),
233            ct_factory: self.ct_factory,
234            peer_discovery_tx: self.peer_discovery_tx,
235        }
236    }
237
238    /// Sets the cover traffic factory.
239    pub fn with_cover_traffic<NewCt>(
240        self,
241        f: impl FnOnce(&BuildCtx) -> NewCt + Send + 'static,
242    ) -> HoprBuilderConfigured<Chain, Graph, Net, NewCt> {
243        HoprBuilderConfigured {
244            ctx: self.ctx,
245            safe_and_module: self.safe_and_module,
246            chain_factory: self.chain_factory,
247            graph_factory: self.graph_factory,
248            network_factory: self.network_factory,
249            ct_factory: Some(Box::new(f)),
250            peer_discovery_tx: self.peer_discovery_tx,
251        }
252    }
253
254    /// Attaches a session server for handling incoming sessions.
255    ///
256    /// Eagerly spawns the server task and returns a [`HoprBuilderWithSession`]
257    /// that has the `build_edge` / `build_full` methods.
258    #[cfg(feature = "session-server")]
259    pub fn with_session_server(
260        self,
261        server: impl hopr_api::node::HoprSessionServer<Session = IncomingSession, Error: std::fmt::Display>
262        + Clone
263        + Send
264        + 'static,
265    ) -> HoprBuilderWithSession<Chain, Graph, Net, Ct> {
266        let incoming_session_capacity = self.ctx.cfg.incoming_session_capacity.max(1);
267
268        let (session_tx, session_rx) = futures::channel::mpsc::channel::<IncomingSession>(incoming_session_capacity);
269
270        tracing::debug!(capacity = incoming_session_capacity, "spawning session server");
271        let session_diag = hopr_utils::runtime::diagnostics::ConcurrentDiagnostics::new(
272            "session_server_for_each_concurrent",
273            module_path!(),
274            file!(),
275            line!(),
276        );
277        let handle = hopr_utils::spawn_as_abortable_named!(
278            "hopr_lib_session_server",
279            session_rx
280                .for_each_concurrent(None, move |session| {
281                    let server = server.clone();
282                    let session_diag = session_diag.clone();
283                    session_diag.wrap(|| async move {
284                        let session_id = *session.session.id();
285                        match server.process(session).await {
286                            Ok(()) => tracing::debug!(?session_id, "session processed successfully"),
287                            Err(error) => {
288                                tracing::error!(?session_id, %error, "session processing failed")
289                            }
290                        }
291                    })
292                })
293                .inspect(|_| tracing::warn!(
294                    task = %HoprLibProcess::SessionServer,
295                    "long-running background task finished"
296                ))
297        );
298
299        HoprBuilderWithSession {
300            inner: self,
301            session_tx,
302            session_handle: handle,
303        }
304    }
305}
306
307// ---------------------------------------------------------------------------
308// HoprBuilderWithSession — session server attached, ready to build
309// ---------------------------------------------------------------------------
310
311/// Builder with a session server attached. Has `build_edge` / `build_full`.
312///
313/// Only exists when the `session-server` feature is enabled.
314#[cfg(feature = "session-server")]
315pub struct HoprBuilderWithSession<Chain = (), Graph = (), Net = (), Ct = ()> {
316    inner: HoprBuilderConfigured<Chain, Graph, Net, Ct>,
317    session_tx: futures::channel::mpsc::Sender<IncomingSession>,
318    session_handle: hopr_utils::runtime::AbortHandle,
319}
320
321// ---------------------------------------------------------------------------
322// Intermediate pre-build state
323// ---------------------------------------------------------------------------
324
325struct PreHopr<Chain, Graph, Net, Ct> {
326    chain_id: ChainKeypair,
327    transport_id: OffchainKeypair,
328    cfg: HoprLibConfig,
329    state: Arc<AtomicHoprState>,
330    transport_api: HoprTransport<Chain, Graph, Net>,
331    chain_api: Chain,
332
333    ticket_event_subscribers: (
334        async_broadcast::Sender<TicketEvent>,
335        async_broadcast::InactiveReceiver<TicketEvent>,
336    ),
337    pix_event_subscribers: (
338        async_broadcast::Sender<PixEvent>,
339        async_broadcast::InactiveReceiver<PixEvent>,
340    ),
341    processes: AbortableList<HoprLibProcess>,
342    session_tx: futures::channel::mpsc::Sender<IncomingSession>,
343    cover_traffic: Ct,
344    network: Net,
345    network_process: BoxedProcessFn,
346}
347
348// ---------------------------------------------------------------------------
349// Shared pre_build logic
350// ---------------------------------------------------------------------------
351
352/// Drains a HoprSocket reader, discarding all packets and logging throughput every ~60 seconds.
353/// Runs until the stream ends (sender side dropped).
354async fn drain_incoming_data<S: futures::Stream + Unpin>(mut reader: S) {
355    let mut received: u64 = 0;
356    let mut last_report = std::time::Instant::now();
357    while reader.next().await.is_some() {
358        received += 1;
359        if last_report.elapsed().as_secs() >= 60 {
360            tracing::info!(
361                received,
362                "incoming-data drain: unrelated packets discarded in last ~1 min"
363            );
364            received = 0;
365            last_report = std::time::Instant::now();
366        }
367    }
368}
369
370async fn pre_build_inner<Chain, Graph, Net, Ct>(
371    configured: HoprBuilderConfigured<Chain, Graph, Net, Ct>,
372    session_tx: futures::channel::mpsc::Sender<IncomingSession>,
373    mut processes: AbortableList<HoprLibProcess>,
374) -> Result<PreHopr<Chain, Graph, Net, Ct>, HoprLibError>
375where
376    Chain: HoprChainApi + Clone + Send + Sync + 'static,
377    Graph: HoprGraphApi<HoprNodeId = hopr_api::OffchainPublicKey> + Clone + Send + Sync + 'static,
378    <Graph as hopr_api::graph::NetworkGraphTraverse>::Observed:
379        hopr_api::graph::traits::EdgeObservableRead + Send + 'static,
380    <Graph as hopr_api::graph::NetworkGraphWrite>::Observed: hopr_api::graph::traits::EdgeObservableWrite + Send,
381    Net: NetworkView + NetworkStreamControl + Send + Sync + Clone + 'static,
382    Ct: ProbingTrafficGeneration + CoverTrafficGeneration + Send + Sync + 'static,
383{
384    let peer_discovery_tx = Some(configured.peer_discovery_tx);
385    let ctx = configured.ctx;
386    ctx.cfg.validate()?;
387
388    let chain_api = (configured
389        .chain_factory
390        .ok_or(HoprLibError::BuilderError("missing chain factory"))?)(&ctx);
391    let graph = (configured
392        .graph_factory
393        .ok_or(HoprLibError::BuilderError("missing graph factory"))?)(&ctx);
394    let (network, network_process) =
395        (configured
396            .network_factory
397            .ok_or(HoprLibError::BuilderError("missing network factory"))?)(ctx.clone())
398        .await?;
399    let cover_traffic = (configured
400        .ct_factory
401        .ok_or(HoprLibError::BuilderError("missing cover traffic factory"))?)(&ctx);
402
403    let (chain_id, transport_id) = (ctx.chain_key.clone(), ctx.packet_key.clone());
404
405    let transport_api = HoprTransport::new(
406        (&chain_id, &transport_id),
407        chain_api.clone(),
408        graph.clone(),
409        vec![(&ctx.cfg.host).try_into().map_err(HoprLibError::TransportError)?],
410        ctx.cfg.protocol.clone(),
411    )
412    .map_err(HoprLibError::TransportError)?;
413
414    #[cfg(all(feature = "telemetry", not(test)))]
415    {
416        use hopr_api::types::primitive::traits::AsUnixTimestamp;
417        METRIC_PROCESS_START_TIME.set(std::time::SystemTime::now().as_unix_timestamp().as_secs_f64());
418        METRIC_HOPR_LIB_VERSION.set(
419            &[const_format::formatcp!("{}", constants::APP_VERSION)],
420            const_format::formatcp!(
421                "{}.{}",
422                env!("CARGO_PKG_VERSION_MAJOR"),
423                env!("CARGO_PKG_VERSION_MINOR")
424            )
425            .parse()
426            .unwrap_or(0.0),
427        );
428    }
429
430    let (mut new_tickets_tx, new_tickets_rx) = async_broadcast::broadcast(2048);
431    new_tickets_tx.set_await_active(false);
432    new_tickets_tx.set_overflow(true);
433
434    let (mut ssa_tx, ssa_rx) = async_broadcast::broadcast(2048);
435    ssa_tx.set_await_active(false);
436    ssa_tx.set_overflow(true);
437
438    let me_onchain = chain_id.public().to_address();
439
440    #[cfg(feature = "testing")]
441    tracing::warn!("!! FOR TESTING ONLY !! Node is running with some safety checks disabled!");
442
443    tracing::info!(
444        address = %me_onchain,
445        minimum_balance = %*SUGGESTED_NATIVE_BALANCE,
446        "node is not started, please fund this node",
447    );
448
449    tracing::info!(
450        suggested_minimum_balance = %*SUGGESTED_NATIVE_BALANCE,
451        "node about to start, checking for funds",
452    );
453    let funding_timeout = Duration::from_secs(200);
454    crate::helpers::wait_for_balance(*MIN_NATIVE_BALANCE, funding_timeout, me_onchain, &chain_api)
455        .await
456        .map_err(|_| {
457            HoprLibError::InsufficientFunds(format!(
458                "failed to fund the node within {} seconds",
459                funding_timeout.as_secs()
460            ))
461        })?;
462
463    tracing::info!("starting HOPR node...");
464    let balance: hopr_api::types::primitive::prelude::XDaiBalance =
465        chain_api.balance(me_onchain).await.map_err(HoprLibError::chain)?;
466    let minimum_balance = *constants::MIN_NATIVE_BALANCE;
467
468    tracing::info!(address = %me_onchain, %balance, %minimum_balance, "node information");
469
470    if balance.le(&minimum_balance) {
471        return Err(HoprLibError::InsufficientFunds(
472            "cannot start the node without a sufficiently funded wallet".into(),
473        ));
474    }
475
476    #[cfg(debug_assertions)]
477    let skip_protocol_checks = ctx.cfg.disable_protocol_checks;
478    #[cfg(not(debug_assertions))]
479    let skip_protocol_checks = false;
480
481    let network_min_ticket_price = chain_api.minimum_ticket_price().await.map_err(HoprLibError::chain)?;
482    let configured_ticket_price = ctx.cfg.protocol.packet.codec.outgoing_ticket_price;
483    if !skip_protocol_checks && configured_ticket_price.is_some_and(|c| c < network_min_ticket_price) {
484        return Err(HoprLibError::GeneralError(format!(
485            "configured outgoing ticket price < network minimum: {configured_ticket_price:?} < \
486             {network_min_ticket_price}"
487        )));
488    }
489
490    let network_min_win_prob = chain_api
491        .minimum_incoming_ticket_win_prob()
492        .await
493        .map_err(HoprLibError::chain)?;
494    let configured_win_prob = ctx.cfg.protocol.packet.codec.outgoing_win_prob;
495
496    if !skip_protocol_checks && configured_win_prob.is_some_and(|c| c.approx_cmp(&network_min_win_prob).is_lt()) {
497        return Err(HoprLibError::GeneralError(format!(
498            "configured outgoing win probability < network minimum: {configured_win_prob:?} < {network_min_win_prob}"
499        )));
500    }
501
502    tracing::info!(
503        peer_id = %transport_id.public().to_peerid_str(),
504        address = %me_onchain,
505        version = constants::APP_VERSION,
506        "Node information"
507    );
508
509    let safe_addr = ctx.cfg.safe_module.safe_address;
510    if me_onchain == safe_addr {
511        return Err(HoprLibError::GeneralError(
512            "cannot use self as staking safe address".into(),
513        ));
514    }
515
516    tracing::info!(%safe_addr, "registering safe with this node");
517    match chain_api.register_safe(&safe_addr).await {
518        Ok(awaiter) => {
519            awaiter.await.map_err(|error| {
520                tracing::error!(%safe_addr, %error, "safe registration failed");
521                HoprLibError::chain(error)
522            })?;
523            tracing::info!(%safe_addr, "safe successfully registered with this node");
524        }
525        Err(SafeRegistrationError::AlreadyRegistered(registered_safe)) => {
526            if registered_safe == safe_addr {
527                tracing::info!(%safe_addr, "this safe is already registered with this node");
528            } else {
529                tracing::error!(%safe_addr, %registered_safe, "node registered with different safe");
530                return Err(HoprLibError::GeneralError("node registered with different safe".into()));
531            }
532        }
533        Err(error) => {
534            tracing::error!(%safe_addr, %error, "safe registration failed");
535            return Err(HoprLibError::chain(error));
536        }
537    }
538
539    let multiaddresses_to_announce = if ctx.cfg.publish {
540        transport_api.announceable_multiaddresses()
541    } else {
542        Vec::new()
543    };
544
545    multiaddresses_to_announce
546        .iter()
547        .filter(|a| !is_public_address(a))
548        .for_each(|multi_addr| tracing::warn!(?multi_addr, "announcing private multiaddress"));
549
550    // Preflight for the on-chain announcement
551    if ctx.cfg.publish {
552        let key_binding_fee = chain_api.key_binding_fee().await.map_err(HoprLibError::chain)?;
553        if !key_binding_fee.is_zero() {
554            let safe_hopr_balance: hopr_api::types::primitive::prelude::HoprBalance =
555                chain_api.balance(safe_addr).await.map_err(HoprLibError::chain)?;
556            // The key-binding is a persistent on-chain state: if this node was already bound in a
557            // previous run, the fee has already been paid and we can announce even if the safe has
558            // since been drained below the fee. Hence we only wait for funds when the safe is
559            // underfunded *and* the node is not yet key-bound.
560            if safe_hopr_balance < key_binding_fee
561                && chain_api
562                    .await_key_binding(transport_id.public(), Duration::from_secs(1))
563                    .await
564                    .is_err()
565            {
566                tracing::warn!(
567                    %safe_addr,
568                    safe_balance = %safe_hopr_balance,
569                    required = %key_binding_fee,
570                    "the safe does not hold enough wxHOPR to pay the node announcement (key-binding) fee, waiting for \
571                     it to be funded before announcing the node",
572                );
573                let announce_timeout = Duration::from_secs(200);
574                crate::helpers::wait_for_balance(key_binding_fee, announce_timeout, safe_addr, &chain_api)
575                    .await
576                    .map_err(|_| {
577                        HoprLibError::InsufficientFunds(format!(
578                            "the safe {safe_addr} does not hold enough wxHOPR (needs at least {key_binding_fee}) to \
579                             announce the node on chain; fund the safe with wxHOPR and restart the node"
580                        ))
581                    })?;
582            }
583        }
584    }
585
586    let chain_api_clone = chain_api.clone();
587    let me_offchain = *transport_id.public();
588    let node_ready = spawn(async move {
589        chain_api_clone
590            .await_key_binding(&me_offchain, NODE_READY_TIMEOUT)
591            .await
592    });
593
594    tracing::info!(?multiaddresses_to_announce, "announcing node on chain");
595    match chain_api.announce(&multiaddresses_to_announce, &transport_id).await {
596        Ok(awaiter) => {
597            awaiter.await.map_err(|error| {
598                tracing::error!(
599                    ?multiaddresses_to_announce,
600                    %error,
601                    "node announcement failed; this is commonly caused by the safe not holding enough wxHOPR to pay \
602                     the key-binding fee",
603                );
604                HoprLibError::chain(error)
605            })?;
606            tracing::info!(?multiaddresses_to_announce, "node announced successfully");
607        }
608        Err(AnnouncementError::AlreadyAnnounced) => {
609            tracing::info!("node already announced on chain");
610        }
611        Err(error) => {
612            tracing::error!(%error, "failed to transmit node announcement");
613            return Err(HoprLibError::chain(error));
614        }
615    }
616
617    let this_node_account = node_ready
618        .await
619        .map_err(HoprLibError::other)?
620        .map_err(HoprLibError::chain)?;
621    if this_node_account.chain_addr != me_onchain || this_node_account.safe_address.is_none_or(|a| a != safe_addr) {
622        tracing::error!(%this_node_account, "account key-binding mismatch");
623        return Err(HoprLibError::GeneralError("account key-binding mismatch".into()));
624    }
625
626    tracing::info!(%this_node_account, "node account is ready");
627
628    // Network → graph event wiring (subscribe before transport starts)
629    {
630        let network_events = network.subscribe_network_events();
631        let graph_updater = graph.clone();
632        spawn(async move {
633            network_events
634                .for_each(|event| {
635                    let graph_updater = graph_updater.clone();
636                    async move {
637                        let (peer_id, connected) = match event {
638                            hopr_api::network::NetworkEvent::PeerConnected(p) => (p, true),
639                            hopr_api::network::NetworkEvent::PeerDisconnected(p) => (p, false),
640                        };
641                        if let Ok(opk) = hopr_api::OffchainPublicKey::from_peerid(&peer_id) {
642                            graph_updater.record_edge(hopr_api::graph::MeasurableEdge::<
643                                hopr_transport::NeighborTelemetry,
644                                hopr_transport::PathTelemetry,
645                            >::ConnectionStatus {
646                                peer: opk,
647                                connected,
648                            });
649                        } else {
650                            tracing::error!(%peer_id, "failed to convert peer ID to public key for graph update");
651                        }
652                    }
653                })
654                .await;
655        });
656    }
657
658    // Chain → graph event wiring
659    {
660        let chain_events = chain_api
661            .subscribe_with_state_sync([StateSyncOptions::PublicAccounts, StateSyncOptions::OpenedChannels])
662            .map_err(HoprLibError::chain)?;
663
664        let graph_updater = graph.clone();
665        let chain_reader = chain_api.clone();
666
667        let own_chain_addr = me_onchain;
668        let own_packet_key = *transport_id.public();
669
670        let ticket_price = Arc::new(parking_lot::RwLock::new(
671            chain_reader.minimum_ticket_price().await.unwrap_or_default(),
672        ));
673        let win_probability = Arc::new(parking_lot::RwLock::new(
674            chain_reader
675                .minimum_incoming_ticket_win_prob()
676                .await
677                .unwrap_or_default(),
678        ));
679
680        let proc = chain_wiring::process_chain_events(
681            chain_reader,
682            graph_updater,
683            transport_api.surb_store().clone(),
684            chain_events,
685            own_chain_addr,
686            own_packet_key,
687            ticket_price,
688            win_probability,
689            peer_discovery_tx,
690        )
691        .inspect(|_| {
692            tracing::warn!(
693                task = "chain-to-graph event wiring",
694                "long-running background task finished"
695            )
696        });
697        processes.insert(HoprLibProcess::ChannelEvents, hopr_utils::spawn_as_abortable!(proc));
698    }
699
700    Ok(PreHopr {
701        chain_id,
702        transport_id,
703        cfg: ctx.cfg,
704        state: Arc::new(AtomicHoprState::new(HoprState::Uninitialized)),
705        transport_api,
706        chain_api,
707        ticket_event_subscribers: (new_tickets_tx, new_tickets_rx.deactivate()),
708        pix_event_subscribers: (ssa_tx, ssa_rx.deactivate()),
709        processes,
710        session_tx,
711        cover_traffic,
712        network,
713        network_process,
714    })
715}
716
717// ---------------------------------------------------------------------------
718// Build methods — shared via macro to avoid duplicating edge/full logic
719// ---------------------------------------------------------------------------
720
721macro_rules! impl_build_methods {
722    () => {
723        /// Builds an entry [`Hopr`] node.
724        pub async fn build_edge<TFact>(
725            self,
726            ticket_factory: TFact,
727        ) -> Result<Hopr<Chain, Graph, Net, ()>, HoprLibError>
728        where
729            TFact: TicketFactory + Clone + Send + Sync + 'static,
730        {
731            let (configured, session_tx, processes) = self.into_parts();
732            let pre = pre_build_inner(configured, session_tx, processes).await?;
733
734            tracing::info!("starting transport for edge (entry) node");
735            let (socket, transport_processes) = pre
736                .transport_api
737                .run_entry(
738                    pre.cover_traffic,
739                    pre.network,
740                    pre.network_process,
741                    ticket_factory,
742                    Some(BroadcastSenderSink(pre.pix_event_subscribers.0.clone())),
743                )
744                .await?;
745
746            // Drain unrelated packets to avoid missing blackhole
747            spawn(drain_incoming_data(socket.reader()));
748
749            let mut processes = pre.processes;
750            processes.flat_map_extend_from(transport_processes, HoprLibProcess::Transport);
751
752            let hopr = Hopr {
753                chain_id: NodeOnchainIdentity {
754                    node_address: pre.chain_id.public().to_address(),
755                    safe_address: pre.cfg.safe_module.safe_address,
756                    module_address: pre.cfg.safe_module.module_address,
757                },
758                cfg: pre.cfg,
759                state: pre.state.clone(),
760                ticket_event_subscribers: pre.ticket_event_subscribers,
761                pix_event_subscribers: pre.pix_event_subscribers,
762                transport_id: pre.transport_id,
763                transport_api: pre.transport_api,
764                chain_api: pre.chain_api,
765                processes,
766                ticket_manager: (),
767            };
768
769            hopr.state.store(HoprState::Running, std::sync::atomic::Ordering::Relaxed);
770            tracing::info!(
771                id = %hopr.transport_id.public().to_peerid_str(),
772                version = constants::APP_VERSION,
773                "EDGE NODE STARTED AND RUNNING"
774            );
775
776            Ok(hopr)
777        }
778
779        /// Builds an entry (source) [`Hopr`] node.
780        ///
781        /// Entry nodes do not process tickets, do not have ticket manager state,
782        /// and do not accept incoming sessions.
783        pub async fn build_entry<TFact>(
784            self,
785            ticket_factory: TFact,
786        ) -> Result<Hopr<Chain, Graph, Net, ()>, HoprLibError>
787        where
788            TFact: TicketFactory + Clone + Send + Sync + 'static,
789        {
790            let (configured, session_tx, processes) = self.into_parts();
791            let pre = pre_build_inner(configured, session_tx, processes).await?;
792
793            tracing::info!("starting transport for entry node");
794            let (socket, transport_processes) = pre
795                .transport_api
796                .run_entry(
797                    pre.cover_traffic,
798                    pre.network,
799                    pre.network_process,
800                    ticket_factory,
801                    Some(BroadcastSenderSink(pre.pix_event_subscribers.0.clone())),
802                )
803                .await?;
804
805            // Drain unrelated packets to avoid missing blackhole
806            spawn(drain_incoming_data(socket.reader()));
807
808            let mut processes = pre.processes;
809            processes.flat_map_extend_from(transport_processes, HoprLibProcess::Transport);
810
811            let hopr = Hopr {
812                chain_id: NodeOnchainIdentity {
813                    node_address: pre.chain_id.public().to_address(),
814                    safe_address: pre.cfg.safe_module.safe_address,
815                    module_address: pre.cfg.safe_module.module_address,
816                },
817                cfg: pre.cfg,
818                state: pre.state.clone(),
819                ticket_event_subscribers: pre.ticket_event_subscribers,
820                pix_event_subscribers: pre.pix_event_subscribers,
821                transport_id: pre.transport_id,
822                transport_api: pre.transport_api,
823                chain_api: pre.chain_api,
824                processes,
825                ticket_manager: (),
826            };
827
828            hopr.state.store(HoprState::Running, std::sync::atomic::Ordering::Relaxed);
829            tracing::info!(
830                id = %hopr.transport_id.public().to_peerid_str(),
831                version = constants::APP_VERSION,
832                "ENTRY NODE STARTED AND RUNNING"
833            );
834
835            Ok(hopr)
836        }
837
838        /// Builds an exit (destination) [`Hopr`] node.
839        ///
840        /// Exit nodes accept incoming sessions and process PIX acknowledgements,
841        /// but do not process tickets (no ticket manager state).
842        pub async fn build_exit<TFact>(
843            self,
844            ticket_factory: TFact,
845        ) -> Result<Hopr<Chain, Graph, Net, ()>, HoprLibError>
846        where
847            TFact: TicketFactory + Clone + Send + Sync + 'static,
848        {
849            let (configured, session_tx, processes) = self.into_parts();
850            let pre = pre_build_inner(configured, session_tx, processes).await?;
851
852            tracing::info!("starting transport for exit node");
853            let (socket, transport_processes) = pre
854                .transport_api
855                .run_exit::<TFact, Ct, _>(
856                    pre.cover_traffic,
857                    pre.network,
858                    pre.network_process,
859                    ticket_factory,
860                    Some(BroadcastSenderSink(pre.pix_event_subscribers.0.clone())),
861                    pre.session_tx,
862                )
863                .await?;
864
865            // Drain unrelated packets to avoid missing blackhole
866            spawn(drain_incoming_data(socket.reader()));
867
868            let mut processes = pre.processes;
869            processes.flat_map_extend_from(transport_processes, HoprLibProcess::Transport);
870
871            let hopr = Hopr {
872                chain_id: NodeOnchainIdentity {
873                    node_address: pre.chain_id.public().to_address(),
874                    safe_address: pre.cfg.safe_module.safe_address,
875                    module_address: pre.cfg.safe_module.module_address,
876                },
877                cfg: pre.cfg,
878                state: pre.state.clone(),
879                ticket_event_subscribers: pre.ticket_event_subscribers,
880                pix_event_subscribers: pre.pix_event_subscribers,
881                transport_id: pre.transport_id,
882                transport_api: pre.transport_api,
883                chain_api: pre.chain_api,
884                processes,
885                ticket_manager: (),
886            };
887
888            hopr.state.store(HoprState::Running, std::sync::atomic::Ordering::Relaxed);
889            tracing::info!(
890                id = %hopr.transport_id.public().to_peerid_str(),
891                version = constants::APP_VERSION,
892                "EXIT NODE STARTED AND RUNNING"
893            );
894
895            Ok(hopr)
896        }
897
898        /// Builds a full (relay) [`Hopr`] node.
899        pub async fn build_full<TMgr, TFact>(
900            self,
901            ticket_manager: TMgr,
902            ticket_factory: TFact,
903        ) -> Result<Hopr<Chain, Graph, Net, TMgr>, HoprLibError>
904        where
905            TMgr: TicketManagement + Clone + Send + Sync + 'static,
906            TFact: TicketFactory + Clone + Send + Sync + 'static,
907        {
908            let (configured, session_tx, processes) = self.into_parts();
909            let pre = pre_build_inner(configured, session_tx, processes).await?;
910            let mut processes = pre.processes;
911
912            tracing::info!("starting ticket events processor");
913            let (tickets_tx, tickets_rx) = bounded_sink_channel::<TicketEvent>(8192);
914
915            // Need to use DropAbortable, so that the receiver is dropped when aborted and no new items can be sent by the senders.
916            let (tickets_rx_stream, tickets_handle) = hopr_utils::runtime::DropAbortable::new(tickets_rx);
917
918            processes.insert(HoprLibProcess::TicketEvents, tickets_handle);
919            let new_ticket_tx = pre.ticket_event_subscribers.0.clone();
920            let tmgr_clone = ticket_manager.clone();
921            spawn(
922                hopr_utils::runtime::diagnostics::instrument(
923                    tickets_rx_stream
924                    .for_each(move |event| {
925                        if let TicketEvent::WinningTicket(ticket) = &event
926                            && let Err(error) = tmgr_clone.insert_incoming_ticket(**ticket)
927                        {
928                            tracing::error!(%error, "failed to insert incoming ticket");
929                        }
930                        if let Err(error) = new_ticket_tx.try_broadcast(event) {
931                            tracing::error!(%error, "failed to broadcast ticket event");
932                        }
933                        futures::future::ready(())
934                    })
935                    .inspect(|_| {
936                        tracing::warn!(task = %HoprLibProcess::TicketEvents, "long-running background task finished")
937                    }),
938                    "hopr_lib_ticket_events",
939                    module_path!(),
940                    file!(),
941                    line!(),
942                ),
943            );
944
945            {
946                let chain_for_neglect = pre.chain_api.clone();
947                let tmgr_for_neglect = ticket_manager.clone();
948                let events = pre.chain_api.subscribe().map_err(HoprLibError::chain)?;
949                let (neglect_handle, neglect_reg) = hopr_utils::runtime::AbortHandle::new_pair();
950                let neglect_task = futures::stream::Abortable::new(
951                    events.filter_map(move |event| {
952                        futures::future::ready(match event {
953                            ChainEvent::ChannelClosed(ch) => Some(ch),
954                            _ => None,
955                        })
956                    }),
957                    neglect_reg,
958                )
959                .for_each(move |closed_channel| {
960                    let chain = chain_for_neglect.clone();
961                    let tmgr = tmgr_for_neglect.clone();
962                    async move {
963                        match closed_channel.direction(chain.me()) {
964                            Some(ChannelDirection::Incoming) => {
965                                match tmgr.neglect_tickets(closed_channel.get_id(), None) {
966                                    Ok(neglected) if !neglected.is_empty() => {
967                                        tracing::warn!(
968                                            num_neglected = neglected.len(),
969                                            %closed_channel,
970                                            "tickets on incoming closed channel were neglected"
971                                        );
972                                    }
973                                    Ok(_) => {}
974                                    Err(error) => {
975                                        tracing::error!(
976                                            %error, %closed_channel,
977                                            "failed to neglect tickets on closed channel"
978                                        );
979                                    }
980                                }
981                            }
982                            Some(ChannelDirection::Outgoing) => {}
983                            _ => {}
984                        }
985                    }
986                })
987                .inspect(|_| {
988                    tracing::warn!(
989                        task = %HoprLibProcess::ChannelClosureNeglect,
990                        "channel closure ticket neglect task finished"
991                    )
992                });
993                spawn(hopr_utils::runtime::diagnostics::instrument(
994                    neglect_task,
995                    "hopr_lib_channel_closure_neglect",
996                    module_path!(),
997                    file!(),
998                    line!(),
999                ));
1000                processes.insert(HoprLibProcess::ChannelClosureNeglect, neglect_handle);
1001            }
1002
1003            tracing::info!("starting transport for full (relay) node");
1004            let (socket, transport_processes) = pre
1005                .transport_api
1006                .run_relay(
1007                    pre.cover_traffic,
1008                    pre.network,
1009                    pre.network_process,
1010                    tickets_tx,
1011                    ticket_factory,
1012                    Some(BroadcastSenderSink(pre.pix_event_subscribers.0.clone())),
1013                    pre.session_tx,
1014                )
1015                .await?;
1016            // Drain unrelated packets to avoid missing blackhole
1017            spawn(drain_incoming_data(socket.reader()));
1018            processes.flat_map_extend_from(transport_processes, HoprLibProcess::Transport);
1019
1020            let hopr = Hopr {
1021                chain_id: NodeOnchainIdentity {
1022                    node_address: pre.chain_id.public().to_address(),
1023                    safe_address: pre.cfg.safe_module.safe_address,
1024                    module_address: pre.cfg.safe_module.module_address,
1025                },
1026                cfg: pre.cfg,
1027                state: pre.state.clone(),
1028                ticket_event_subscribers: pre.ticket_event_subscribers,
1029                pix_event_subscribers: pre.pix_event_subscribers,
1030                transport_id: pre.transport_id,
1031                transport_api: pre.transport_api,
1032                chain_api: pre.chain_api,
1033                processes,
1034                ticket_manager,
1035            };
1036
1037            hopr.state.store(HoprState::Running, std::sync::atomic::Ordering::Relaxed);
1038
1039            tracing::info!(
1040                id = %hopr.transport_id.public().to_peerid_str(),
1041                version = constants::APP_VERSION,
1042                "FULL NODE STARTED AND RUNNING"
1043            );
1044
1045            #[cfg(all(feature = "telemetry", not(test)))]
1046            METRIC_HOPR_NODE_INFO.set(
1047                &[
1048                    &hopr.transport_id.public().to_peerid_str(),
1049                    &hopr.chain_id.node_address.to_string(),
1050                    &hopr.chain_id.safe_address.to_string(),
1051                    &hopr.chain_id.module_address.to_string(),
1052                ],
1053                1.0,
1054            );
1055
1056            Ok(hopr)
1057        }
1058    };
1059}
1060
1061// When session-server is ON: build methods only on HoprBuilderWithSession
1062#[cfg(feature = "session-server")]
1063impl<Chain, Graph, Net, Ct> HoprBuilderWithSession<Chain, Graph, Net, Ct>
1064where
1065    Chain: HoprChainApi + Clone + Send + Sync + 'static,
1066    Graph: HoprGraphApi<HoprNodeId = hopr_api::OffchainPublicKey> + Clone + Send + Sync + 'static,
1067    <Graph as hopr_api::graph::NetworkGraphTraverse>::Observed:
1068        hopr_api::graph::traits::EdgeObservableRead + Send + 'static,
1069    <Graph as hopr_api::graph::NetworkGraphWrite>::Observed: hopr_api::graph::traits::EdgeObservableWrite + Send,
1070    Net: NetworkView + NetworkStreamControl + Send + Sync + Clone + 'static,
1071    Ct: ProbingTrafficGeneration + CoverTrafficGeneration + Send + Sync + 'static,
1072{
1073    impl_build_methods!();
1074
1075    fn into_parts(
1076        self,
1077    ) -> (
1078        HoprBuilderConfigured<Chain, Graph, Net, Ct>,
1079        futures::channel::mpsc::Sender<IncomingSession>,
1080        AbortableList<HoprLibProcess>,
1081    ) {
1082        let mut processes = AbortableList::<HoprLibProcess>::default();
1083        processes.insert(HoprLibProcess::SessionServer, self.session_handle);
1084        (self.inner, self.session_tx, processes)
1085    }
1086}
1087
1088// When session-server is OFF: build methods directly on HoprBuilderConfigured
1089#[cfg(not(feature = "session-server"))]
1090impl<Chain, Graph, Net, Ct> HoprBuilderConfigured<Chain, Graph, Net, Ct>
1091where
1092    Chain: HoprChainApi + Clone + Send + Sync + 'static,
1093    Graph: HoprGraphApi<HoprNodeId = hopr_api::OffchainPublicKey> + Clone + Send + Sync + 'static,
1094    <Graph as hopr_api::graph::NetworkGraphTraverse>::Observed:
1095        hopr_api::graph::traits::EdgeObservableRead + Send + 'static,
1096    <Graph as hopr_api::graph::NetworkGraphWrite>::Observed: hopr_api::graph::traits::EdgeObservableWrite + Send,
1097    Net: NetworkView + NetworkStreamControl + Send + Sync + Clone + 'static,
1098    Ct: ProbingTrafficGeneration + CoverTrafficGeneration + Send + Sync + 'static,
1099{
1100    impl_build_methods!();
1101
1102    fn into_parts(
1103        self,
1104    ) -> (
1105        HoprBuilderConfigured<Chain, Graph, Net, Ct>,
1106        futures::channel::mpsc::Sender<IncomingSession>,
1107        AbortableList<HoprLibProcess>,
1108    ) {
1109        let (tx, _rx) = futures::channel::mpsc::channel::<IncomingSession>(1);
1110        let processes = AbortableList::<HoprLibProcess>::default();
1111        (self, tx, processes)
1112    }
1113}