Skip to main content

hopr_transport/
config.rs

1use std::{
2    fmt::{Display, Formatter},
3    net::ToSocketAddrs,
4    num::ParseIntError,
5    str::FromStr,
6    time::Duration,
7};
8
9use hopr_api::Multiaddr;
10pub use hopr_protocol_hopr::{HoprCodecConfig, HoprUnacknowledgedTicketProcessorConfig, SurbPopOrder, SurbStoreConfig};
11use hopr_protocol_pix::SsaReconstructorConfig;
12pub use hopr_transport_mixer::config::MixerConfig;
13pub use hopr_transport_probe::config::ProbeConfig;
14use hopr_transport_session::{
15    DEFAULT_MAX_SSAS_PER_SSA_REQUEST, DEFAULT_PIX_POLYS_PER_SSA, DEFAULT_PIX_SHARES_PER_POLY, IncomingSessionPixConfig,
16    MAX_SSA_BATCH_SIZE, MIN_BALANCER_SAMPLING_INTERVAL, MIN_SURB_BUFFER_DURATION,
17};
18use proc_macro_regex::regex;
19use validator::{Validate, ValidationError, ValidationErrors};
20
21use crate::{errors::HoprTransportError, protocol::PacketPipelineConfig};
22
23const DEFAULT_COUNTER_FLUSH_INTERVAL: Duration = Duration::from_secs(15);
24
25const DEFAULT_PER_PEER_CHANNEL_CAPACITY: usize = 5_000;
26const DEFAULT_STREAM_OPEN_TIMEOUT: Duration = Duration::from_secs(2);
27const DEFAULT_FRAME_WRITER_BACKPRESSURE_BYTES: usize = 131_072;
28const DEFAULT_EGRESS_BACKPRESSURE_TIMEOUT: Duration = Duration::from_secs(2);
29
30/// Minimum accepted value for [`StreamProtocolConfig::stream_open_timeout`].
31pub const MIN_STREAM_OPEN_TIMEOUT: Duration = Duration::from_millis(1);
32
33/// Minimum accepted value for [`StreamProtocolConfig::egress_backpressure_timeout`].
34pub const MIN_EGRESS_BACKPRESSURE_TIMEOUT: Duration = Duration::from_millis(1);
35
36fn default_per_peer_channel_capacity() -> usize {
37    DEFAULT_PER_PEER_CHANNEL_CAPACITY
38}
39
40fn default_stream_open_timeout() -> Duration {
41    DEFAULT_STREAM_OPEN_TIMEOUT
42}
43
44fn default_frame_writer_backpressure_bytes() -> usize {
45    DEFAULT_FRAME_WRITER_BACKPRESSURE_BYTES
46}
47
48#[inline]
49fn default_egress_backpressure_timeout() -> Duration {
50    DEFAULT_EGRESS_BACKPRESSURE_TIMEOUT
51}
52
53fn validate_stream_open_timeout(value: &Duration) -> Result<(), ValidationError> {
54    if MIN_STREAM_OPEN_TIMEOUT <= *value {
55        Ok(())
56    } else {
57        Err(ValidationError::new("stream open timeout must be at least 1 ms"))
58    }
59}
60
61fn validate_egress_backpressure_timeout(value: &Duration) -> Result<(), ValidationError> {
62    if MIN_EGRESS_BACKPRESSURE_TIMEOUT <= *value {
63        Ok(())
64    } else {
65        // A zero (or sub-millisecond) timeout would make every full channel fall straight into
66        // drop-newest, silently defeating the backpressure feature — reject it at config time.
67        Err(ValidationError::new(
68            "egress backpressure timeout must be at least 1 ms",
69        ))
70    }
71}
72
73/// Configuration of the per-peer egress stream layer.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Validate, smart_default::SmartDefault)]
75#[cfg_attr(
76    feature = "serde",
77    derive(serde::Serialize, serde::Deserialize),
78    serde(deny_unknown_fields)
79)]
80pub struct StreamProtocolConfig {
81    /// Capacity of the per-peer egress channel (in packets).
82    ///
83    /// The egress drain enqueues each outgoing packet via `try_send`. When the
84    /// channel is full the behaviour depends on the stream state: while the stream
85    /// is still opening it drops the newest packet (a slow open for one peer must
86    /// not head-of-line-block others); once the stream is open and its write pump
87    /// is draining, it instead applies bounded backpressure — waiting up to
88    /// `EGRESS_BACKPRESSURE_TIMEOUT` for space so wire-rate backpressure propagates
89    /// upstream — and only drops the newest packet if the peer stays full past that
90    /// timeout. The channel absorbs bursts while a stream is being opened; once open
91    /// the write pump continuously drains it, so it stays near-empty under normal load.
92    ///
93    /// Sized to absorb a typical SURB pre-fill burst (default SurbBalancer:
94    /// target 7 000 / max 5 000/s).
95    ///
96    /// Defaults to 5 000.
97    #[validate(range(min = 1))]
98    #[default(default_per_peer_channel_capacity())]
99    #[cfg_attr(feature = "serde", serde(default = "default_per_peer_channel_capacity"))]
100    pub per_peer_channel_capacity: usize,
101
102    /// Timeout for the `NetworkStreamControl::open` call when opening a new
103    /// outgoing stream to a peer.
104    ///
105    /// A timeout is mandatory: without it a permanently-unreachable peer would park
106    /// the opener task indefinitely. When the open attempt fails or times out the
107    /// buffered packets for that peer are dropped and a debug-level log entry is
108    /// emitted. The cache entry is then invalidated so the next send triggers a
109    /// fresh open attempt.
110    ///
111    /// Must be at least 1 ms. Defaults to 2 seconds.
112    #[validate(custom(function = "validate_stream_open_timeout"))]
113    #[default(default_stream_open_timeout())]
114    #[cfg_attr(
115        feature = "serde",
116        serde(default = "default_stream_open_timeout", with = "humantime_serde")
117    )]
118    pub stream_open_timeout: Duration,
119
120    /// Pending-write-buffer byte threshold on the framed writer before a flush is forced.
121    ///
122    /// A value of `1` flushes on every encoded frame (one syscall per message).
123    /// Larger values coalesce adjacent small frames into a single quinn write call,
124    /// reducing connection-mutex acquisitions and driver wake-ups on the hot path.
125    /// A HOPR packet is ~1 440 bytes; at the default 128 KiB threshold roughly 91
126    /// packets are coalesced per write, cutting driver wake frequency ~30×.
127    ///
128    /// Defaults to 131 072 bytes (128 KiB).
129    #[validate(range(min = 1))]
130    #[default(default_frame_writer_backpressure_bytes())]
131    #[cfg_attr(feature = "serde", serde(default = "default_frame_writer_backpressure_bytes"))]
132    pub frame_writer_backpressure_bytes: usize,
133
134    /// Maximum time the egress drain waits on a full — but open and draining — per-peer channel
135    /// before falling back to drop-newest.
136    ///
137    /// While the stream is open, a full channel means the wire is slower than the producer, so
138    /// waiting here propagates wire-rate backpressure up through the mixer and session socket to the
139    /// application writer (no packet loss). The bound ensures a single permanently-stalled peer cannot
140    /// head-of-line-block delivery to other peers indefinitely: after this timeout the packet is
141    /// dropped and the drain moves on. Healthy peers drain far faster than this, so the timeout is not
142    /// hit in normal operation.
143    ///
144    /// Defaults to 2 seconds. Must be at least 1 ms — a zero value would defeat the feature.
145    #[validate(custom(function = "validate_egress_backpressure_timeout"))]
146    #[default(default_egress_backpressure_timeout())]
147    #[cfg_attr(feature = "serde", serde(default = "default_egress_backpressure_timeout"))]
148    pub egress_backpressure_timeout: Duration,
149}
150
151fn default_counter_flush_interval() -> Duration {
152    DEFAULT_COUNTER_FLUSH_INTERVAL
153}
154
155/// How often SURB round-trip counts reach the network graph.
156///
157/// Much shorter than the protocol counter flush, which is the same order as the recovery window
158/// this signal exists to shorten -- evidence about a dead relayer sitting unreported for 15 s would
159/// defeat the point. Still well inside the graph's own bucket width, so batching adds no
160/// distortion while cutting graph write locks by orders of magnitude.
161const DEFAULT_SURB_FLUSH_INTERVAL: Duration = Duration::from_secs(1);
162
163fn default_surb_flush_interval() -> Duration {
164    DEFAULT_SURB_FLUSH_INTERVAL
165}
166
167/// Simulated per-packet transit latency inserted between the mixer and the wire.
168///
169/// When set on a node's config, every packet emitted by the mixer is held for a
170/// Gaussian-jittered delay before being forwarded to the transport layer.  The delay
171/// is **FIFO** (packets are never reordered): the release deadline is `max(prev_deadline,
172/// now) + sample`, so back-to-back bursts accumulate a monotonically non-decreasing
173/// offset rather than reordering.
174///
175/// **Intended for testing only** — simulates WAN-link transit latency (e.g. ~50 ms) in
176/// a local cluster.  Defaults to `None` (disabled; zero production overhead).
177#[derive(Debug, Clone, Copy, PartialEq, Eq, smart_default::SmartDefault)]
178#[cfg_attr(
179    feature = "serde",
180    derive(serde::Serialize, serde::Deserialize),
181    serde(deny_unknown_fields)
182)]
183pub struct TransitLatencyConfig {
184    /// Mean transit latency per packet.
185    #[default(Duration::from_millis(50))]
186    #[cfg_attr(feature = "serde", serde(with = "humantime_serde"))]
187    pub mean: Duration,
188    /// Standard deviation of the transit latency.
189    ///
190    /// Set to zero for a deterministic (fixed) delay equal to `mean`.
191    #[default(Duration::from_millis(5))]
192    #[cfg_attr(feature = "serde", serde(with = "humantime_serde"))]
193    pub std_dev: Duration,
194}
195
196/// Complete configuration of the HOPR protocol stack.
197#[derive(Debug, smart_default::SmartDefault, Validate, Clone, PartialEq)]
198#[cfg_attr(
199    feature = "serde",
200    derive(serde::Serialize, serde::Deserialize),
201    serde(deny_unknown_fields)
202)]
203pub struct HoprProtocolConfig {
204    /// Libp2p-related transport configuration
205    #[validate(nested)]
206    #[cfg_attr(feature = "serde", serde(default))]
207    pub transport: TransportConfig,
208    /// HOPR packet pipeline configuration
209    #[validate(nested)]
210    #[cfg_attr(feature = "serde", serde(default))]
211    pub packet: HoprPacketPipelineConfig,
212    /// Probing protocol configuration
213    #[validate(nested)]
214    #[cfg_attr(feature = "serde", serde(default))]
215    pub probe: ProbeConfig,
216    /// Session protocol global configuration
217    #[validate(nested)]
218    #[cfg_attr(feature = "serde", serde(default))]
219    pub session: SessionGlobalConfig,
220    /// Global configuration for the PIX.
221    #[validate(nested)]
222    #[cfg_attr(feature = "serde", serde(default))]
223    pub pix: PixGlobalConfig,
224    /// Per-node PIX session configuration for incoming sessions.
225    #[validate(custom(function = "validate_incoming_session_pix_config"))]
226    #[cfg_attr(feature = "serde", serde(default))]
227    pub incoming_session_pix_config: IncomingSessionPixConfig,
228    /// Mixer configuration.
229    #[cfg_attr(feature = "serde", serde(default))]
230    pub mixer: MixerConfig,
231    /// Simulated transit latency shim between the mixer output and the wire.
232    ///
233    /// When `Some`, a Gaussian-jittered FIFO delay is inserted before every forwarded
234    /// packet — simulating WAN-link transit time in a local cluster test run.
235    /// Set `None` (the default) in production: zero overhead.
236    #[cfg_attr(feature = "serde", serde(default))]
237    pub transit_latency: Option<TransitLatencyConfig>,
238    /// Per-peer egress stream configuration
239    #[validate(nested)]
240    #[cfg_attr(feature = "serde", serde(default))]
241    pub stream: StreamProtocolConfig,
242    /// Path planner configuration
243    #[validate(nested)]
244    #[cfg_attr(feature = "serde", serde(skip))]
245    pub path_planner: crate::path::PathPlannerConfig,
246    /// Interval at which per-peer protocol conformance counters are flushed
247    /// into the network graph.
248    ///
249    /// Default is 15 seconds.
250    #[default(default_counter_flush_interval())]
251    #[cfg_attr(
252        feature = "serde",
253        serde(default = "default_counter_flush_interval", with = "humantime_serde")
254    )]
255    pub counter_flush_interval: Duration,
256    /// Interval at which SURB round-trip counts are flushed into the network graph.
257    ///
258    /// Default is 1 second.
259    #[default(default_surb_flush_interval())]
260    #[cfg_attr(
261        feature = "serde",
262        serde(default = "default_surb_flush_interval", with = "humantime_serde")
263    )]
264    pub surb_flush_interval: Duration,
265}
266
267/// Rejects an [`IncomingSessionPixConfig`] whose acceptance range can never match anything, or whose
268/// SSA batch size is outside what the protocol supports.
269///
270/// `quota_range` is operator-settable, and an empty (inverted) range silently makes
271/// `check_pix_params` reject every offered PIX parameter set, which surfaces only as
272/// `UnacceptablePixParams` errors at Session establishment time.
273///
274/// `ssas_per_request` is checked here rather than with a `range` attribute because
275/// `IncomingSessionPixConfig` lives in `hopr-transport-session` and carries no `Validate` derive of
276/// its own. Zero would mean no `SsaRequest` is ever sent, and above [`MAX_SSA_BATCH_SIZE`] the
277/// per-cycle reconstructor state and the Start protocol channel pre-allocation both grow past what
278/// that ceiling exists to bound. `SessionManager::new` clamps rather than trusting this check, since
279/// nothing forces a programmatically built config through it.
280fn validate_incoming_session_pix_config(cfg: &IncomingSessionPixConfig) -> Result<(), ValidationError> {
281    if cfg.quota_range.is_empty() {
282        return Err(ValidationError::new(
283            "pix quota_range must be non-empty (start must not exceed end)",
284        ));
285    }
286    if !(1..=MAX_SSA_BATCH_SIZE).contains(&cfg.ssas_per_request) {
287        return Err(ValidationError::new(
288            "pix ssas_per_request must be between 1 and MAX_SSA_BATCH_SIZE",
289        ));
290    }
291    Ok(())
292}
293
294/// Headroom over the profiled dimension product that [`PixGlobalConfig`] will accept.
295///
296/// See [`validate_pix_dimension_product`] for why the ceiling is on the product and why this
297/// multiple does not have to move when the polynomial/threshold split is re-tuned.
298const MAX_PIX_DIMENSION_PRODUCT_FACTOR: usize = 4;
299
300/// Rejects dimensions whose *product* is far outside anything that has been measured.
301///
302/// `num_ssa_parts` and `ssa_part_size` are range-validated independently, and their ranges permit
303/// 16192 × 255 = 4 128 960 commitments, about 8× the profiled operating point of 8192 × 64 =
304/// 524 288 (≈49 MiB of peak reconstructor state and ≈1.25 s of commitment ingest per cycle). Nothing
305/// downstream catches that: the product *is* the per-cycle quota, and the only guard on it is the
306/// peer Exit's `quota_range` rejection — which protects the Exit, and arrives after this node has
307/// already generated the cycle.
308///
309/// The ceiling is deliberately on the product rather than on either field, and that is what makes it
310/// stable: re-tuning the split holds the product constant — 4096 × 128 and 8192 × 64 are both
311/// exactly 524 288, which is why the derived `quota_range` survived that change untouched. Only a
312/// deliberate decision to raise the per-cycle quota needs to revisit this, and such a decision has to
313/// widen the Exit's `quota_range` in concert regardless.
314///
315/// It binds less hard than it once did: `ssa_part_size` was capped at 4096 before the threshold was
316/// narrowed to a byte so it could share the negotiated `PixParams` word with the surplus, which took
317/// the field-range maximum product down from 126× the profiled point to under 8×. It still binds
318/// over most of the two ranges, which is the intended effect.
319fn validate_pix_dimension_product(cfg: &PixGlobalConfig) -> Result<(), ValidationError> {
320    const PROFILED: usize = DEFAULT_PIX_POLYS_PER_SSA as usize * DEFAULT_PIX_SHARES_PER_POLY as usize;
321
322    if cfg.num_ssa_parts.saturating_mul(cfg.ssa_part_size) > MAX_PIX_DIMENSION_PRODUCT_FACTOR * PROFILED {
323        return Err(ValidationError::new(
324            "num_ssa_parts * ssa_part_size exceeds the supported per-cycle dimension product",
325        ));
326    }
327
328    // An explicit surplus above the threshold buys more redundancy than payload, and — since H5 put
329    // the surplus in the negotiated `PixParams` and hence in the billed quota — pays for it on every
330    // deposit. `hopr-protocol-pix` enforces the same rule on `SsaGeneratorConfig`; checking it here
331    // too turns it into a config-load error naming the operator's own field, rather than one about a
332    // struct they never wrote.
333    if cfg.surplus_shares() > cfg.ssa_part_size {
334        return Err(ValidationError::new(
335            "additional_shares must not exceed ssa_part_size — the surplus is billed, so this pays for more \
336             redundancy than payload",
337        ));
338    }
339    Ok(())
340}
341
342/// Global configuration for the Protocol for Incentivization of eXits (PIX).
343#[derive(Clone, Copy, Debug, PartialEq, Validate, smart_default::SmartDefault)]
344#[cfg_attr(
345    feature = "serde",
346    derive(serde::Serialize, serde::Deserialize),
347    serde(default, deny_unknown_fields)
348)]
349#[validate(schema(function = "validate_pix_dimension_product", skip_on_field_errors = false))]
350pub struct PixGlobalConfig {
351    /// Number of parts an SSA is split into.
352    ///
353    /// This scales will with the CPU parallelism.
354    ///
355    /// Defaults to [`DEFAULT_PIX_POLYS_PER_SSA`], which is also what
356    /// [`IncomingSessionPixConfig::quota_range`] is derived from — changing this without
357    /// widening the peer Exit's `quota_range` accordingly will get the Session rejected.
358    ///
359    /// The range below bounds this field alone. What actually costs is the *product* with
360    /// [`ssa_part_size`](Self::ssa_part_size), which validation bounds separately at 4× the profiled
361    /// operating point — see `validate_pix_dimension_product`.
362    #[validate(range(min = 8, max = 16192))]
363    #[default(DEFAULT_PIX_POLYS_PER_SSA as usize)]
364    pub num_ssa_parts: usize,
365
366    /// Number of shares required to reconstruct an SSA part.
367    ///
368    /// This does not scale well with CPU parallelism.
369    ///
370    /// Defaults to [`DEFAULT_PIX_SHARES_PER_POLY`]. See [`num_ssa_parts`](Self::num_ssa_parts)
371    /// for the interaction with the Exit's accepted quota range, and
372    /// `validate_pix_dimension_product` for the bound on the two together.
373    /// Capped at 255 because the threshold is one byte of the negotiated
374    /// [`PixParams`](hopr_protocol_pix::PixParams) word — see
375    /// [`MAX_POLY_THRESHOLD`](hopr_protocol_pix::MAX_POLY_THRESHOLD).
376    #[validate(range(min = 2, max = 255))]
377    #[default(DEFAULT_PIX_SHARES_PER_POLY as usize)]
378    pub ssa_part_size: usize,
379
380    /// Number of shares sent in addition to `ssa_part_size` to reconstruct an SSA part.
381    ///
382    /// This is used to account for potential packet loss but makes it take longer for the
383    /// other side to reconstruct the entire SSA from all its parts. This is because if
384    /// no packet loss is present, the other side can reconstruct the SSA from fewer shares.
385    ///
386    /// **Leave unset unless you have measured your return-path loss.** `None` derives the surplus
387    /// from [`ssa_part_size`](Self::ssa_part_size) via
388    /// [`default_surplus_for`](hopr_protocol_pix::default_surplus_for), which sizes it to absorb
389    /// 20 % of a polynomial's shares going missing. Read
390    /// [`surplus_shares`](Self::surplus_shares) for the resolved value.
391    ///
392    /// It is a ratio because the physics is a ratio: a polynomial reconstructs from the first
393    /// `ssa_part_size` distinct shares to arrive out of `ssa_part_size + surplus` emitted, so
394    /// surviving loss rate `p` needs `surplus >= ssa_part_size · p/(1−p)`. Setting an absolute count
395    /// therefore means a different loss tolerance at every threshold — a flat 20 covers 24 % at
396    /// `ssa_part_size` 64 but 56 % at 16, where it exceeds the shares it insures and is rejected.
397    ///
398    /// The factor is what matters, because it is what this costs. A polynomial leaves the
399    /// generator's queue at `ssa_part_size + surplus` shares whether or not any were
400    /// lost, so this is service the Exit performs in every case — and since the surplus travels to
401    /// the peer as part of the negotiated [`PixParams`](hopr_protocol_pix::PixParams), the per-SSA
402    /// quota counts it and the deposit pays for it. It buys loss tolerance, and it is charged for
403    /// like any other insurance: on purchase, not on claim.
404    ///
405    /// Raising it therefore costs money rather than earning free service, which is the way round it
406    /// should be. It used to be the other way: the surplus was excluded from the quota, so the
407    /// rational Entry raised this dial to take traffic it was not billed for.
408    ///
409    /// Capped at 255 because it is the other byte of that word, and at `ssa_part_size` because
410    /// insurance costing more than the payload is a misconfiguration rather than a preference.
411    #[validate(range(min = 0, max = 255))]
412    pub additional_shares: Option<usize>,
413
414    /// Maximum number of SSA commitments this node, acting as an Entry, accepts in a single
415    /// `SsaRequest` from an Exit.
416    ///
417    /// This is a protection against a misbehaving Exit rather than a preference: each accepted entry
418    /// costs a full client commitment, its own burst of `SsaCommit` packets and its own on-chain
419    /// deposit, so an uncapped request would let one inbound packet amplify into minutes of CPU and
420    /// as many simultaneous deposits as the wire format admits (27). An over-cap request is rejected
421    /// in full before any of that work starts.
422    ///
423    /// **Must be at least the `ssas_per_request` of every Exit this node uses.** The batch size is not
424    /// negotiated — the Exit cannot learn this value — so an Exit batching above it has every request
425    /// rejected, and every such Session is lost. The refusal is reported to the Exit as an
426    /// `UnacceptablePixParams` `SessionError` so it fails in about a round trip rather than as a
427    /// deposit timeout minutes later, but raising the Exit side still requires raising this in step.
428    ///
429    /// Unlike its neighbours this is not a dimension, so `validate_pix_dimension_product` ignores it.
430    ///
431    /// Defaults to 2, minimum 1, maximum 20 (`MAX_SSA_BATCH_SIZE`).
432    #[validate(range(min = 1, max = 20))]
433    #[default(DEFAULT_MAX_SSAS_PER_SSA_REQUEST)]
434    pub max_ssas_per_request: usize,
435
436    /// Exit-side SSA reconstructor configuration.
437    ///
438    /// Nested rather than flattened so the whole PIX surface stays under one key, and so that
439    /// Exit-side capacity does not intermix with the Entry-side dimensions above.
440    #[validate(nested)]
441    #[cfg_attr(feature = "serde", serde(default))]
442    pub reconstructor: PixReconstructorConfig,
443}
444
445impl PixGlobalConfig {
446    /// Surplus shares per polynomial: the operator's value if set, otherwise derived from
447    /// [`ssa_part_size`](Self::ssa_part_size).
448    ///
449    /// Every reader must go through this rather than the field. The field is `Option` precisely
450    /// because serde cannot express "default to a function of a sibling", so the field alone is not
451    /// the configuration — reading it directly is how the unset case would silently become zero
452    /// surplus, i.e. no loss tolerance at all.
453    pub fn surplus_shares(&self) -> usize {
454        self.additional_shares.unwrap_or_else(|| {
455            hopr_protocol_pix::default_surplus_for(self.ssa_part_size.min(u8::MAX as usize) as u8) as usize
456        })
457    }
458}
459
460/// Rejects a reconstructor configuration that [`SsaReconstructorConfig`] itself would reject.
461///
462/// The mirror below deliberately carries no `range` attributes of its own. Every bound on those
463/// seven fields is a property of the reconstructor, not of this crate, so the protocol type stays
464/// the single source of truth for them and this delegates rather than restating. A restated range
465/// is simply a second place to forget when the first one moves.
466///
467/// `validator` schema functions return one [`ValidationError`], so the inner [`ValidationErrors`]
468/// is folded into its message — [`validate_incoming_session_pix_config`] above is the existing
469/// precedent in this file for a hand-written check that reaches into another crate.
470fn validate_pix_reconstructor_config(cfg: &PixReconstructorConfig) -> Result<(), ValidationError> {
471    SsaReconstructorConfig::from(*cfg).validate().map_err(|errors| {
472        let mut error = ValidationError::new("pix reconstructor configuration is out of range");
473        error.message = Some(errors.to_string().into());
474        error
475    })
476}
477
478/// Operator-facing mirror of [`SsaReconstructorConfig`], the Exit-side share reconstructor.
479///
480/// Stands in the same relationship to the protocol type that the fields of [`PixGlobalConfig`]
481/// stand in to `SsaGeneratorConfig`: `hopr-protocol-pix` owns the type the reconstructor is built
482/// from, and this crate owns the shape an operator writes. It exists because none of these seven
483/// values was reachable from a config file at all — both production constructors took
484/// `SsaReconstructorConfig::default()`, so the Exit side of PIX was unconfigurable while the Entry
485/// side was not.
486///
487/// Duplicating seven fields is the price of the mirror, and two guards pay it. The [`From`] impl
488/// below is written exhaustively, so a field added to [`SsaReconstructorConfig`] fails to compile
489/// until it is mirrored here; and `pix_reconstructor_mirror_matches_the_protocol_defaults` asserts
490/// the two default sets still agree. Validation is not duplicated at all — see
491// `validate_pix_reconstructor_config` is deliberately unlinked: it is module-private, so an
492// intra-doc link from this public type trips `rustdoc::private_intra_doc_links`, which the
493// `nix build .#docs` job builds as an error.
494/// `validate_pix_reconstructor_config`.
495///
496/// Each field's rationale lives on the protocol type and is linked rather than copied, since a
497/// third copy of the same prose is a third thing to keep true.
498#[derive(Clone, Copy, Debug, PartialEq, Validate, smart_default::SmartDefault)]
499#[cfg_attr(
500    feature = "serde",
501    derive(serde::Serialize, serde::Deserialize),
502    serde(default, deny_unknown_fields)
503)]
504#[validate(schema(function = "validate_pix_reconstructor_config", skip_on_field_errors = false))]
505pub struct PixReconstructorConfig {
506    /// Time until the complete commitment to an SSA must be received.
507    ///
508    /// Defaults to 2 minutes. See
509    /// [`SsaReconstructorConfig::incomplete_commitment_lifetime`].
510    #[default(SsaReconstructorConfig::DEFAULT_INCOMPLETE_COMMITMENT_LIFETIME)]
511    #[cfg_attr(feature = "serde", serde(with = "humantime_serde"))]
512    pub incomplete_commitment_lifetime: Duration,
513
514    /// Maximum time an SSA cycle can go without progress before it is discarded.
515    ///
516    /// Defaults to 30 minutes. See [`SsaReconstructorConfig::unused_verifier_lifetime`].
517    #[default(SsaReconstructorConfig::DEFAULT_UNUSED_VERIFIER_LIFETIME)]
518    #[cfg_attr(feature = "serde", serde(with = "humantime_serde"))]
519    pub unused_verifier_lifetime: Duration,
520
521    /// Maximum number of peers tracked simultaneously with unacknowledged shares.
522    ///
523    /// Defaults to 2000, minimum 10. See [`SsaReconstructorConfig::max_tracked_peers`].
524    #[default(SsaReconstructorConfig::DEFAULT_MAX_TRACKED_PEERS)]
525    pub max_tracked_peers: usize,
526
527    /// Maximum number of awaited acknowledgements held **per peer**.
528    ///
529    /// Defaults to 1 000 000, minimum 10 000. See [`SsaReconstructorConfig::max_awaiting_acks`].
530    #[default(SsaReconstructorConfig::DEFAULT_MAX_AWAITING_ACKS)]
531    pub max_awaiting_acks: usize,
532
533    /// Maximum time an acknowledgement is awaited before its share is discarded.
534    ///
535    /// Defaults to 30 seconds. See [`SsaReconstructorConfig::max_ack_await_time`].
536    #[default(SsaReconstructorConfig::DEFAULT_MAX_ACK_AWAIT_TIME)]
537    #[cfg_attr(feature = "serde", serde(with = "humantime_serde"))]
538    pub max_ack_await_time: Duration,
539
540    /// Whether to use the batch verification algorithm for acknowledgements.
541    ///
542    /// Defaults to `false`. See [`SsaReconstructorConfig::use_batch_verification`], which records
543    /// the measurements behind that default and why the knob was kept rather than removed.
544    #[default(SsaReconstructorConfig::DEFAULT_USE_BATCH_VERIFICATION)]
545    pub use_batch_verification: bool,
546
547    /// Fraction of reconstructed polynomials at which an early recovery notification is emitted.
548    ///
549    /// Defaults to 0.85, range 0.0..=1.0. See
550    /// [`SsaReconstructorConfig::early_recovery_threshold`].
551    #[default(SsaReconstructorConfig::DEFAULT_EARLY_RECOVERY_THRESHOLD)]
552    pub early_recovery_threshold: f64,
553
554    /// Ceiling on the total awaiting-acknowledgement state held across every peer, in bytes.
555    ///
556    /// Defaults to 1 GiB, minimum 25 600 B — 64 entries at the measured per-entry cost. That floor
557    /// is a sanity check rather than a sizing recommendation; see
558    /// [`SsaReconstructorConfig::max_ack_buffer_bytes`], which is where it is enforced and why it is
559    /// deliberately low.
560    ///
561    /// This — not the product of [`max_tracked_peers`](Self::max_tracked_peers) and
562    /// [`max_awaiting_acks`](Self::max_awaiting_acks) — is what bounds the reconstructor's
563    /// acknowledgement buffer, and the reconstructor enforces it as shares arrive rather than
564    /// checking a workload model here. A model would have to assume a Session count and a packet
565    /// rate; `maximum_managed_sessions` validates to 100 000 and `SessionCapability::NoRateControl`
566    /// removes the rate limiter, so neither assumption survives contact with a legal configuration.
567    #[default(SsaReconstructorConfig::DEFAULT_MAX_ACK_BUFFER_BYTES)]
568    pub max_ack_buffer_bytes: usize,
569}
570
571impl From<PixReconstructorConfig> for SsaReconstructorConfig {
572    /// Both sides are written out exhaustively, with no `..Default::default()` on either.
573    ///
574    /// That is the guard the mirror rests on: a field added to [`SsaReconstructorConfig`] leaves
575    /// this initialiser incomplete and a field added to [`PixReconstructorConfig`] leaves the
576    /// pattern incomplete, so either one fails to compile until it is mirrored. Struct update
577    /// syntax would compile in both directions and silently pin the new knob to its default.
578    fn from(cfg: PixReconstructorConfig) -> Self {
579        let PixReconstructorConfig {
580            incomplete_commitment_lifetime,
581            unused_verifier_lifetime,
582            max_tracked_peers,
583            max_awaiting_acks,
584            max_ack_await_time,
585            use_batch_verification,
586            early_recovery_threshold,
587            max_ack_buffer_bytes,
588        } = cfg;
589
590        Self {
591            incomplete_commitment_lifetime,
592            unused_verifier_lifetime,
593            max_tracked_peers,
594            max_awaiting_acks,
595            max_ack_await_time,
596            use_batch_verification,
597            early_recovery_threshold,
598            max_ack_buffer_bytes,
599        }
600    }
601}
602
603/// Configuration of the HOPR packet pipeline.
604#[derive(Clone, Copy, Debug, PartialEq, Validate, smart_default::SmartDefault)]
605#[cfg_attr(
606    feature = "serde",
607    derive(serde::Serialize, serde::Deserialize),
608    serde(deny_unknown_fields)
609)]
610pub struct HoprPacketPipelineConfig {
611    /// HOPR packet codec configuration
612    #[validate(nested)]
613    #[cfg_attr(feature = "serde", serde(default))]
614    pub codec: HoprCodecConfig,
615    /// Configuration of unacknowledged tickets processing.
616    #[validate(nested)]
617    #[cfg_attr(feature = "serde", serde(default))]
618    pub ack_processor: HoprUnacknowledgedTicketProcessorConfig,
619    /// Single Use Reply Block (SURB) handling configuration
620    #[validate(nested)]
621    #[cfg_attr(feature = "serde", serde(default))]
622    pub surb_store: SurbStoreConfig,
623    /// Packet pipeline configuration controlling output/input concurrency and acknowledgement processing
624    #[validate(nested)]
625    #[cfg_attr(feature = "serde", serde(default))]
626    pub pipeline: PacketPipelineConfig,
627}
628
629regex!(is_dns_address_regex "^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)*[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$");
630
631/// Check whether the string looks like a valid domain.
632#[inline]
633pub fn looks_like_domain(s: &str) -> bool {
634    is_dns_address_regex(s)
635}
636
637/// Check whether the string is an actual reachable domain.
638pub fn is_reachable_domain(host: &str) -> bool {
639    host.to_socket_addrs().is_ok_and(|i| i.into_iter().next().is_some())
640}
641
642/// Enumeration of possible host types.
643#[derive(Debug, Clone, PartialEq)]
644#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
645pub enum HostType {
646    /// IPv4 based host
647    IPv4(String),
648    /// DNS based host
649    Domain(String),
650}
651
652impl validator::Validate for HostType {
653    fn validate(&self) -> Result<(), ValidationErrors> {
654        match &self {
655            HostType::IPv4(ip4) => validate_ipv4_address(ip4).map_err(|e| {
656                let mut errs = ValidationErrors::new();
657                errs.add("ipv4", e);
658                errs
659            }),
660            HostType::Domain(domain) => validate_dns_address(domain).map_err(|e| {
661                let mut errs = ValidationErrors::new();
662                errs.add("domain", e);
663                errs
664            }),
665        }
666    }
667}
668
669impl Default for HostType {
670    fn default() -> Self {
671        HostType::IPv4("127.0.0.1".to_owned())
672    }
673}
674
675/// Configuration of the listening host.
676///
677/// This is used for the P2P and REST API listeners.
678///
679/// Intentionally has no default because it depends on the use case.
680#[derive(Debug, Validate, Clone, PartialEq)]
681#[cfg_attr(
682    feature = "serde",
683    derive(serde::Serialize, serde::Deserialize),
684    serde(deny_unknown_fields)
685)]
686pub struct HostConfig {
687    /// Host on which to listen
688    #[cfg_attr(feature = "serde", serde(default))]
689    pub address: HostType,
690    /// Listening TCP or UDP port (mandatory).
691    #[validate(range(min = 1u16))]
692    #[cfg_attr(feature = "serde", serde(default))]
693    pub port: u16,
694}
695
696impl FromStr for HostConfig {
697    type Err = String;
698
699    fn from_str(s: &str) -> Result<Self, Self::Err> {
700        let (ip_or_dns, str_port) = match s.split_once(':') {
701            None => return Err("Invalid host, is not in the '<host>:<port>' format".into()),
702            Some(split) => split,
703        };
704
705        let port = str_port.parse().map_err(|e: ParseIntError| e.to_string())?;
706
707        if validator::ValidateIp::validate_ipv4(&ip_or_dns) {
708            Ok(Self {
709                address: HostType::IPv4(ip_or_dns.to_owned()),
710                port,
711            })
712        } else if looks_like_domain(ip_or_dns) {
713            Ok(Self {
714                address: HostType::Domain(ip_or_dns.to_owned()),
715                port,
716            })
717        } else {
718            Err("Not a valid IPv4 or domain host".into())
719        }
720    }
721}
722
723impl Display for HostConfig {
724    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
725        write!(f, "{:?}:{}", self.address, self.port)
726    }
727}
728
729fn default_multiaddr_transport(port: u16) -> String {
730    cfg_if::cfg_if! {
731        if #[cfg(feature = "p2p-announce-quic")] {
732            // In case we run on a Dappnode-like device, presumably behind NAT, we fall back to TCP
733            // to circumvent issues with QUIC in such environments. To make this work reliably,
734            // we would need proper NAT traversal support.
735            let on_dappnode = std::env::var("DAPPNODE")
736                .map(|v| v.to_lowercase() == "true")
737                .unwrap_or(false);
738
739            // Using HOPRD_NAT a user can overwrite the default behaviour even on a Dappnode-like device
740            let uses_nat = std::env::var("HOPRD_NAT")
741                .map(|v| v.to_lowercase() == "true")
742                .unwrap_or(on_dappnode);
743
744            if uses_nat {
745                format!("tcp/{port}")
746            } else {
747                format!("udp/{port}/quic-v1")
748            }
749        } else {
750            format!("tcp/{port}")
751        }
752    }
753}
754
755impl TryFrom<&HostConfig> for Multiaddr {
756    type Error = HoprTransportError;
757
758    fn try_from(value: &HostConfig) -> Result<Self, Self::Error> {
759        match &value.address {
760            HostType::IPv4(ip) => Multiaddr::from_str(
761                format!("/ip4/{}/{}", ip.as_str(), default_multiaddr_transport(value.port)).as_str(),
762            )
763            .map_err(|e| HoprTransportError::Api(e.to_string())),
764            HostType::Domain(domain) => Multiaddr::from_str(
765                format!("/dns4/{}/{}", domain.as_str(), default_multiaddr_transport(value.port)).as_str(),
766            )
767            .map_err(|e| HoprTransportError::Api(e.to_string())),
768        }
769    }
770}
771
772fn validate_ipv4_address(s: &str) -> Result<(), ValidationError> {
773    if validator::ValidateIp::validate_ipv4(&s) {
774        let ipv4 = std::net::Ipv4Addr::from_str(s)
775            .map_err(|_| ValidationError::new("Failed to deserialize the string into an ipv4 address"))?;
776
777        if ipv4.is_private() || ipv4.is_multicast() || ipv4.is_unspecified() {
778            return Err(ValidationError::new(
779                "IPv4 cannot be private, multicast or unspecified (0.0.0.0)",
780            ))?;
781        }
782        Ok(())
783    } else {
784        Err(ValidationError::new("Invalid IPv4 address provided"))
785    }
786}
787
788fn validate_dns_address(s: &str) -> Result<(), ValidationError> {
789    if looks_like_domain(s) || is_reachable_domain(s) {
790        Ok(())
791    } else {
792        Err(ValidationError::new("Invalid DNS address provided"))
793    }
794}
795
796/// Configuration of the physical transport mechanism.
797#[derive(Debug, Default, Validate, Clone, Copy, PartialEq)]
798#[cfg_attr(
799    feature = "serde",
800    derive(serde::Serialize, serde::Deserialize),
801    serde(deny_unknown_fields)
802)]
803pub struct TransportConfig {
804    /// When true, assume that the node is running in an isolated network and does
805    /// not need any connection to nodes outside the subnet
806    #[cfg_attr(feature = "serde", serde(default))]
807    pub announce_local_addresses: bool,
808    /// When true, assume a testnet with multiple nodes running on the same machine
809    /// or in the same private IPv4 network
810    #[cfg_attr(feature = "serde", serde(default))]
811    pub prefer_local_addresses: bool,
812}
813
814const DEFAULT_SESSION_IDLE_TIMEOUT: Duration = Duration::from_mins(3);
815
816const SESSION_IDLE_MIN_TIMEOUT: Duration = Duration::from_secs(2);
817
818const DEFAULT_SESSION_ESTABLISH_RETRY_DELAY: Duration = Duration::from_secs(2);
819
820const DEFAULT_SESSION_ESTABLISH_MAX_RETRIES: usize = 3;
821
822const DEFAULT_SESSION_BALANCER_SAMPLING: Duration = Duration::from_millis(100);
823
824const DEFAULT_SESSION_BALANCER_BUFFER_DURATION: Duration = Duration::from_secs(5);
825
826const DEFAULT_MAXIMUM_MANAGED_SESSIONS: usize = 100;
827
828fn default_session_balancer_buffer_duration() -> Duration {
829    DEFAULT_SESSION_BALANCER_BUFFER_DURATION
830}
831
832fn default_session_establish_max_retries() -> usize {
833    DEFAULT_SESSION_ESTABLISH_MAX_RETRIES
834}
835
836fn default_session_idle_timeout() -> Duration {
837    DEFAULT_SESSION_IDLE_TIMEOUT
838}
839
840fn default_session_establish_retry_delay() -> Duration {
841    DEFAULT_SESSION_ESTABLISH_RETRY_DELAY
842}
843
844fn default_session_balancer_sampling() -> Duration {
845    DEFAULT_SESSION_BALANCER_SAMPLING
846}
847
848fn default_max_managed_sessions() -> usize {
849    DEFAULT_MAXIMUM_MANAGED_SESSIONS
850}
851
852/// Transport-layer default for the SURB balance notification period; this is the effective default
853/// for [`SessionGlobalConfig::surb_balance_notify_period`] (15s). It deliberately overrides the
854/// lower-level fallback in `SessionManagerConfig` (whose own field default is 60s) with a tighter
855/// 15s cadence, so the Entry's dead-reckoned estimate of the Exit's SURB buffer is corrected often
856/// enough to keep the SURB balancer from under-producing (and starving the Exit) under drift,
857/// without the per-session keep-alive overhead of the previous 2s cadence. The 1s floor is enforced
858/// downstream by `SessionManager::new` (`MIN_SURB_BUFFER_NOTIFICATION_PERIOD`).
859fn default_session_surb_balance_notify_period() -> Option<Duration> {
860    Some(Duration::from_secs(15))
861}
862
863fn default_session_max_frames_behind_gap() -> Option<usize> {
864    Some(256)
865}
866
867fn validate_session_idle_timeout(value: &Duration) -> Result<(), ValidationError> {
868    if SESSION_IDLE_MIN_TIMEOUT <= *value {
869        Ok(())
870    } else {
871        Err(ValidationError::new("session idle timeout is too low"))
872    }
873}
874
875fn validate_balancer_sampling(value: &Duration) -> Result<(), ValidationError> {
876    if MIN_BALANCER_SAMPLING_INTERVAL <= *value {
877        Ok(())
878    } else {
879        Err(ValidationError::new("balancer sampling interval is too low"))
880    }
881}
882
883fn validate_balancer_buffer_duration(value: &Duration) -> Result<(), ValidationError> {
884    if MIN_SURB_BUFFER_DURATION <= *value {
885        Ok(())
886    } else {
887        Err(ValidationError::new("minmum SURB buffer duration is too low"))
888    }
889}
890
891fn validate_surb_balance_notify_period(value: &Duration) -> Result<(), ValidationError> {
892    // `custom` on an `Option` field skips `None` and passes the inner value on `Some`.
893    if *value >= Duration::from_secs(1) {
894        Ok(())
895    } else {
896        Err(ValidationError::new(
897            "SURB balance notify period must be at least 1 second",
898        ))
899    }
900}
901
902/// Global configuration of Sessions and the Session manager.
903#[derive(Clone, Copy, Debug, PartialEq, Eq, Validate, smart_default::SmartDefault)]
904#[cfg_attr(
905    feature = "serde",
906    derive(serde::Serialize, serde::Deserialize),
907    serde(deny_unknown_fields)
908)]
909pub struct SessionGlobalConfig {
910    /// Maximum time before an idle Session is closed.
911    ///
912    /// Defaults to 3 minutes.
913    #[validate(custom(function = "validate_session_idle_timeout"))]
914    #[default(default_session_idle_timeout())]
915    #[cfg_attr(
916        feature = "serde",
917        serde(default = "default_session_idle_timeout", with = "humantime_serde")
918    )]
919    pub idle_timeout: Duration,
920
921    /// Maximum number of Sessions that can be managed by the Session manager.
922    ///
923    /// Default is 100, minimum is 2, maximum is 100 000.
924    #[validate(range(min = 2, max = 100_000))]
925    #[default(default_max_managed_sessions())]
926    #[cfg_attr(feature = "serde", serde(default = "default_max_managed_sessions"))]
927    pub maximum_managed_sessions: usize,
928
929    /// Maximum retries to attempt to establish the Session
930    /// Set 0 for no retries.
931    ///
932    /// Defaults to 3, maximum is 20.
933    #[validate(range(min = 0, max = 20))]
934    #[default(default_session_establish_max_retries())]
935    #[cfg_attr(feature = "serde", serde(default = "default_session_establish_max_retries"))]
936    pub establish_max_retries: usize,
937
938    /// Delay between Session establishment retries.
939    ///
940    /// Default is 2 seconds.
941    #[default(default_session_establish_retry_delay())]
942    #[cfg_attr(
943        feature = "serde",
944        serde(default = "default_session_establish_retry_delay", with = "humantime_serde")
945    )]
946    pub establish_retry_timeout: Duration,
947
948    /// Sampling interval for SURB balancer in milliseconds.
949    ///
950    /// Default is 100 milliseconds.
951    #[validate(custom(function = "validate_balancer_sampling"))]
952    #[default(default_session_balancer_sampling())]
953    #[cfg_attr(
954        feature = "serde",
955        serde(default = "default_session_balancer_sampling", with = "humantime_serde")
956    )]
957    pub balancer_sampling_interval: Duration,
958
959    /// Minimum runway of received SURBs in seconds.
960    ///
961    /// This applies to incoming Sessions on Exit nodes only and is the main indicator of how
962    /// the egress traffic will be shaped, unless the `NoRateControl` Session
963    /// capability is specified during initiation.
964    ///
965    /// Default is 5 seconds, minimum is 1 second.
966    #[validate(custom(function = "validate_balancer_buffer_duration"))]
967    #[default(default_session_balancer_buffer_duration())]
968    #[cfg_attr(
969        feature = "serde",
970        serde(default = "default_session_balancer_buffer_duration", with = "humantime_serde")
971    )]
972    pub balancer_minimum_surb_buffer_duration: Duration,
973
974    /// How often the Exit reports its true SURB buffer level to the Entry, as an absolute
975    /// correction of the Entry's dead-reckoned estimate. Without it, cumulative packet loss
976    /// silently inflates the estimate until the Exit runs out of SURBs and can no longer
977    /// send reply data.
978    ///
979    /// Default is 15 seconds. Set to `null` to disable; minimum effective period is 1 second.
980    #[validate(custom(function = "validate_surb_balance_notify_period"))]
981    #[default(default_session_surb_balance_notify_period())]
982    #[cfg_attr(
983        feature = "serde",
984        serde(
985            default = "default_session_surb_balance_notify_period",
986            with = "humantime_serde::option"
987        )
988    )]
989    pub surb_balance_notify_period: Option<Duration>,
990
991    /// How many later frames may queue behind a missing one before the reassembler gives up on it
992    /// and releases what it already has.
993    ///
994    /// Only applies to Sessions without retransmission, where a missing frame is never coming and
995    /// waiting out the frame timeout cannot change the outcome — it only holds everything behind
996    /// it. The right value tracks reordering depth (throughput × latency spread ÷ frame size), so
997    /// a bulk-data Session and a control Session on the same node differ by orders of magnitude;
998    /// an individual Session may override it.
999    ///
1000    /// Default is 256. Set to `null` to disable the bound and wait out the frame timeout instead.
1001    #[default(default_session_max_frames_behind_gap())]
1002    #[cfg_attr(feature = "serde", serde(default = "default_session_max_frames_behind_gap"))]
1003    pub max_frames_behind_gap: Option<usize>,
1004
1005    /// Tag allocator partition configuration.
1006    #[validate(nested)]
1007    #[cfg_attr(feature = "serde", serde(default))]
1008    pub tag_allocator: hopr_transport_tag_allocator::TagAllocatorConfig,
1009}
1010
1011#[cfg(test)]
1012mod tests {
1013    use super::*;
1014
1015    /// The Exit computes the offered quota as `polys × (shares + surplus) × HoprPacket::PAYLOAD_SIZE`
1016    /// and rejects the Session when it falls outside `quota_range`. An Entry running the default
1017    /// `PixGlobalConfig` must therefore always be acceptable to an Exit running the default
1018    /// `IncomingSessionPixConfig`, otherwise PIX cannot be used at all out of the box — and
1019    /// before both structs became `serde(default)` there was no way for an operator to fix it.
1020    ///
1021    /// The surplus is in that product, so this also guards the alias that gives the two crates one
1022    /// default surplus: if `additional_shares` and `hopr-protocol-pix`'s own default drift apart
1023    /// again, the quota computed here stops matching the one the range is anchored on.
1024    #[test]
1025    fn default_pix_dimensions_must_be_inside_default_incoming_quota_range() {
1026        let pix = PixGlobalConfig::default();
1027        let incoming = IncomingSessionPixConfig::default();
1028
1029        let quota = pix.num_ssa_parts as u64
1030            * (pix.ssa_part_size + pix.surplus_shares()) as u64
1031            * hopr_crypto_packet::prelude::HoprPacket::PAYLOAD_SIZE as u64;
1032
1033        assert!(
1034            incoming.quota_range.contains(&quota),
1035            "default PIX quota {quota} is outside the default accepted range {:?} — every PIX session would be \
1036             rejected with UnacceptablePixParams",
1037            incoming.quota_range
1038        );
1039
1040        // Both sides are derived from the same constants, so the range must be anchored exactly
1041        // at the nominal quota. Asserting the relationship rather than a literal keeps this test
1042        // correct if `HoprPacket::PAYLOAD_SIZE` ever changes, while still failing loudly if the
1043        // range or the dimensions stop being derived from a shared source.
1044        assert_eq!(
1045            quota,
1046            *incoming.quota_range.end(),
1047            "the accepted range must be anchored at the nominal default quota"
1048        );
1049    }
1050
1051    #[test]
1052    fn default_pix_configs_must_validate() {
1053        PixGlobalConfig::default()
1054            .validate()
1055            .expect("default PixGlobalConfig must be valid");
1056        validate_incoming_session_pix_config(&IncomingSessionPixConfig::default())
1057            .expect("default IncomingSessionPixConfig must be valid");
1058        HoprProtocolConfig::default()
1059            .validate()
1060            .expect("default HoprProtocolConfig must be valid");
1061    }
1062
1063    /// The operator-facing mirror must round-trip to the protocol type it stands for.
1064    ///
1065    /// Both sides now read the same `SsaReconstructorConfig::DEFAULT_*` constants, so this is
1066    /// structurally true rather than merely observed — which is the point of asserting it. What the
1067    /// test actually guards is a future edit that replaces one of those references with a literal:
1068    /// the mirror would still compile, still validate, and quietly install a different Exit.
1069    ///
1070    /// The field *set* is guarded by the compiler instead — `From` is written exhaustively in both
1071    /// directions, so neither struct can grow a field the other lacks.
1072    #[test]
1073    fn pix_reconstructor_mirror_matches_the_protocol_defaults() {
1074        assert_eq!(
1075            SsaReconstructorConfig::default(),
1076            SsaReconstructorConfig::from(PixReconstructorConfig::default()),
1077            "the operator-facing mirror and the reconstructor it configures have drifted apart"
1078        );
1079    }
1080
1081    /// The acknowledgement-budget floor named in this crate's operator documentation must be the
1082    /// one the protocol crate actually enforces.
1083    ///
1084    /// The doc comment above once said 16 MiB against a validated 25 600 B, from a floor lowered on
1085    /// one side only. A number written in prose cannot be checked by the compiler, so it gets
1086    /// checked here instead — the same lesson L20 recorded about a `SAFETY` comment quoting a
1087    /// constant it did not reference.
1088    #[test]
1089    fn the_documented_ack_budget_floor_is_the_enforced_one() {
1090        const DOCUMENTED_FLOOR: usize = 25_600;
1091
1092        PixReconstructorConfig {
1093            max_ack_buffer_bytes: DOCUMENTED_FLOOR,
1094            ..Default::default()
1095        }
1096        .validate()
1097        .expect("the documented floor itself must be accepted");
1098
1099        assert!(
1100            PixReconstructorConfig {
1101                max_ack_buffer_bytes: DOCUMENTED_FLOOR - 1,
1102                ..Default::default()
1103            }
1104            .validate()
1105            .is_err(),
1106            "one byte below the documented floor must be rejected — the prose and the validator have drifted"
1107        );
1108    }
1109
1110    /// Each dimension range is satisfiable on its own well past anything measured, so the product
1111    /// needs its own bound.
1112    ///
1113    /// Both fields are operator-settable, and the product *is* the per-cycle quota: it decides how
1114    /// many polynomials the Entry builds and how many commitments and part builders the Exit holds.
1115    /// The peer's `quota_range` refusal is no defence — it fires after this node has generated.
1116    #[test]
1117    fn pix_dimensions_are_bounded_by_their_product_not_only_field_by_field() {
1118        const MAX_NUM_SSA_PARTS: usize = 16192;
1119        const MAX_SSA_PART_SIZE: usize = hopr_protocol_pix::MAX_POLY_THRESHOLD as usize;
1120
1121        // The extremes of the two field ranges, each individually valid.
1122        let extreme = PixGlobalConfig {
1123            num_ssa_parts: MAX_NUM_SSA_PARTS,
1124            ssa_part_size: MAX_SSA_PART_SIZE,
1125            ..Default::default()
1126        };
1127        assert!(
1128            extreme.validate().is_err(),
1129            "16192 x 255 is ~8x the profiled product and must be rejected"
1130        );
1131
1132        // Re-splitting at a constant product is exactly what a re-tune does, and must stay valid —
1133        // this is why the ceiling is on the product rather than on either field. The splits are
1134        // fewer than they were: `ssa_part_size` is now capped at 255, so the 2048 x 256 and
1135        // 1024 x 512 re-splits this used to cover are no longer expressible at all.
1136        let profiled = DEFAULT_PIX_POLYS_PER_SSA as usize * DEFAULT_PIX_SHARES_PER_POLY as usize;
1137        for (polys, shares) in [(4096usize, 128usize), (8192, 64)] {
1138            assert_eq!(polys * shares, profiled, "test case must hold the product constant");
1139            let cfg = PixGlobalConfig {
1140                num_ssa_parts: polys,
1141                ssa_part_size: shares,
1142                ..Default::default()
1143            };
1144            assert!(
1145                cfg.validate().is_ok(),
1146                "{polys} x {shares} is the profiled product re-split and must stay valid"
1147            );
1148        }
1149
1150        // The headroom is real: the ceiling is straddled rather than hit, because hitting it exactly
1151        // is no longer possible. `4 x profiled` is 2^21, and every factorisation of it with
1152        // `ssa_part_size <= 255` needs `num_ssa_parts >= 16384`, past that field's own maximum. So
1153        // the pair below is the largest accepted product and the next one up, one share apart.
1154        let ceiling = MAX_PIX_DIMENSION_PRODUCT_FACTOR * profiled;
1155        let just_under = PixGlobalConfig {
1156            num_ssa_parts: MAX_NUM_SSA_PARTS,
1157            ssa_part_size: ceiling / MAX_NUM_SSA_PARTS,
1158            ..Default::default()
1159        };
1160        assert!(
1161            just_under.num_ssa_parts * just_under.ssa_part_size <= ceiling,
1162            "test case must sit under the ceiling"
1163        );
1164        assert!(
1165            just_under.validate().is_ok(),
1166            "the largest reachable product under the ceiling must be accepted"
1167        );
1168
1169        let past_ceiling = PixGlobalConfig {
1170            ssa_part_size: just_under.ssa_part_size + 1,
1171            ..just_under
1172        };
1173        assert!(
1174            past_ceiling.num_ssa_parts * past_ceiling.ssa_part_size > ceiling,
1175            "test case must sit over the ceiling"
1176        );
1177        assert!(past_ceiling.validate().is_err(), "past the ceiling must be rejected");
1178    }
1179
1180    // The reversed range is the point of the test: `quota_range` is operator-settable, so an
1181    // inverted range is reachable from a config file and must be rejected by validation rather
1182    // than silently matching nothing.
1183    #[allow(clippy::reversed_empty_ranges)]
1184    #[test]
1185    fn empty_pix_quota_range_is_rejected() {
1186        let cfg = IncomingSessionPixConfig {
1187            quota_range: 100..=10,
1188            ..Default::default()
1189        };
1190        assert!(validate_incoming_session_pix_config(&cfg).is_err());
1191
1192        let cfg = HoprProtocolConfig {
1193            incoming_session_pix_config: IncomingSessionPixConfig {
1194                quota_range: 100..=10,
1195                ..Default::default()
1196            },
1197            ..Default::default()
1198        };
1199        assert!(cfg.validate().is_err());
1200    }
1201
1202    #[cfg(feature = "serde")]
1203    #[test]
1204    fn pix_configs_are_reachable_from_serialized_config() {
1205        // Regression guard: these two fields used to be `serde(skip)`, which pinned them to
1206        // their defaults and made PIX unconfigurable.
1207        let json = r#"{
1208            "pix": { "num_ssa_parts": 2048 },
1209            "incoming_session_pix_config": { "enforce_pix": true, "max_deposit_wait": "90s" }
1210        }"#;
1211        let cfg: HoprProtocolConfig = serde_json::from_str(json).expect("PIX config must deserialize");
1212
1213        assert_eq!(2048, cfg.pix.num_ssa_parts);
1214        // Container-level `serde(default)` keeps unspecified fields at their defaults.
1215        assert_eq!(
1216            PixGlobalConfig::default().ssa_part_size,
1217            cfg.pix.ssa_part_size,
1218            "unspecified PIX fields must fall back to their defaults"
1219        );
1220        assert!(cfg.incoming_session_pix_config.enforce_pix);
1221        assert_eq!(
1222            Duration::from_secs(90),
1223            cfg.incoming_session_pix_config.max_deposit_wait
1224        );
1225        assert_eq!(
1226            IncomingSessionPixConfig::default().quota_range,
1227            cfg.incoming_session_pix_config.quota_range
1228        );
1229
1230        // Both SSA batch knobs must be settable from a config file too, since raising one without the
1231        // other is a silently fatal misconfiguration and an operator needs to be able to do both.
1232        let json = r#"{
1233            "pix": { "max_ssas_per_request": 5 },
1234            "incoming_session_pix_config": { "ssas_per_request": 5 }
1235        }"#;
1236        let cfg: HoprProtocolConfig = serde_json::from_str(json).expect("SSA batch config must deserialize");
1237        assert_eq!(5, cfg.pix.max_ssas_per_request);
1238        assert_eq!(5, cfg.incoming_session_pix_config.ssas_per_request);
1239        cfg.validate().expect("a matched pair of batch knobs must validate");
1240
1241        // Same defect one struct down: every Exit-side reconstructor dial used to be unreachable
1242        // because both production constructors took `SsaReconstructorConfig::default()`. The
1243        // durations must come through `humantime_serde` rather than as a struct of secs/nanos.
1244        let json = r#"{
1245            "pix": { "reconstructor": { "max_ack_await_time": "45s", "max_tracked_peers": 500 } }
1246        }"#;
1247        let cfg: HoprProtocolConfig = serde_json::from_str(json).expect("reconstructor config must deserialize");
1248        assert_eq!(Duration::from_secs(45), cfg.pix.reconstructor.max_ack_await_time);
1249        assert_eq!(500, cfg.pix.reconstructor.max_tracked_peers);
1250        assert_eq!(
1251            PixReconstructorConfig::default().unused_verifier_lifetime,
1252            cfg.pix.reconstructor.unused_verifier_lifetime,
1253            "unspecified reconstructor fields must fall back to their defaults"
1254        );
1255        cfg.validate().expect("a narrowed reconstructor must validate");
1256
1257        // And it must reach the reconstructor, not merely parse: the conversion is what the two
1258        // production constructors consume.
1259        assert_eq!(
1260            Duration::from_secs(45),
1261            SsaReconstructorConfig::from(cfg.pix.reconstructor).max_ack_await_time
1262        );
1263
1264        // An unset surplus must derive from the configured threshold, not fall to zero. This is the
1265        // one field whose `serde(default)` cannot express its own default, so "absent" and "zero"
1266        // are the two readings that must not be confused — zero surplus is legal and means no loss
1267        // tolerance at all.
1268        let json = r#"{ "pix": { "ssa_part_size": 32 } }"#;
1269        let cfg: HoprProtocolConfig = serde_json::from_str(json).expect("PIX dimensions must deserialize");
1270        assert_eq!(None, cfg.pix.additional_shares, "the field itself stays unset");
1271        assert_eq!(8, cfg.pix.surplus_shares(), "and resolves to ssa_part_size / 4");
1272
1273        let json = r#"{ "pix": { "ssa_part_size": 32, "additional_shares": 30 } }"#;
1274        let cfg: HoprProtocolConfig = serde_json::from_str(json).expect("an explicit surplus must deserialize");
1275        assert_eq!(30, cfg.pix.surplus_shares(), "an explicit surplus is passed through");
1276
1277        let json = r#"{ "pix": { "ssa_part_size": 16, "additional_shares": 20 } }"#;
1278        let cfg: HoprProtocolConfig = serde_json::from_str(json).expect("it parses; validation is what rejects it");
1279        assert!(
1280            cfg.validate().is_err(),
1281            "a surplus above the threshold must be rejected — 20 shares of insurance against 16 of payload"
1282        );
1283    }
1284
1285    /// The surplus has two upper bounds, and the tighter one is `ssa_part_size`.
1286    ///
1287    /// `range(max = 255)` is the byte the negotiated `PixParams` word gives the field; the schema
1288    /// check is "insurance must not exceed the payload it insures". Since `ssa_part_size` is itself
1289    /// capped at 255, the second subsumes the first — the range attribute survives because it fails
1290    /// *keyed on the field*, naming the limit, where the schema check can only report against the
1291    /// struct.
1292    ///
1293    /// Both are asserted because a `range` attribute on an `Option` is not obviously live: the
1294    /// crate has no other instance of one, and an attribute that silently validated nothing would
1295    /// look exactly like this one. It does fire — validator 0.21 unwraps the `Option` — and this is
1296    /// what would notice if that changed.
1297    #[test]
1298    fn the_surplus_is_bounded_by_the_threshold_it_insures() {
1299        let at_bound = PixGlobalConfig {
1300            ssa_part_size: 64,
1301            additional_shares: Some(64),
1302            ..Default::default()
1303        };
1304        at_bound
1305            .validate()
1306            .expect("a surplus equal to the threshold is allowed — over-insuring a lossy path is a real choice");
1307
1308        let past_bound = PixGlobalConfig {
1309            additional_shares: Some(65),
1310            ..at_bound
1311        };
1312        let errors = past_bound
1313            .validate()
1314            .expect_err("one share past the threshold must be rejected");
1315        assert!(
1316            errors.field_errors().contains_key("__all__"),
1317            "the schema check is what enforces the tighter bound"
1318        );
1319
1320        let past_the_wire_byte = PixGlobalConfig {
1321            additional_shares: Some(300),
1322            ..at_bound
1323        };
1324        let errors = past_the_wire_byte
1325            .validate()
1326            .expect_err("a surplus that cannot fit the PixParams byte must be rejected");
1327        assert!(
1328            errors.field_errors().contains_key("additional_shares"),
1329            "the field range must still fire through the Option, not only the schema check"
1330        );
1331    }
1332
1333    /// The derived surplus tracks the configured threshold, which is the whole point of deriving it.
1334    ///
1335    /// Stated as the loss rate it covers rather than as four literals: `surplus/(threshold+surplus)`
1336    /// is the fraction of a polynomial's shares that may go missing before it cannot reconstruct,
1337    /// and that — not the raw count — is what an operator would compare against measured loss.
1338    ///
1339    /// Swept over every accepted `ssa_part_size`, not just the deployed multiples of four. Sampling
1340    /// only those is what let the underlying ratio round down unnoticed — they are exactly the
1341    /// values on which integer division and the intended ratio agree.
1342    ///
1343    /// `num_ssa_parts` is pinned at its minimum for the sweep so that only the surplus rule is under
1344    /// test: at the default 8192 the top of the `ssa_part_size` range lands within 0.4 % of
1345    /// `validate_pix_dimension_product`'s ceiling, so a future re-tune of the split would fail this
1346    /// test for a reason that has nothing to do with the surplus.
1347    #[test]
1348    fn the_derived_surplus_covers_a_fifth_of_a_polynomial_at_every_threshold() {
1349        for ssa_part_size in 2usize..=255 {
1350            let cfg = PixGlobalConfig {
1351                num_ssa_parts: 8,
1352                ssa_part_size,
1353                ..Default::default()
1354            };
1355            let surplus = cfg.surplus_shares();
1356            let tolerated = surplus as f64 / (ssa_part_size + surplus) as f64;
1357            assert!(
1358                tolerated >= 0.20,
1359                "ssa_part_size {ssa_part_size} derives surplus {surplus}, tolerating only {tolerated:.4} loss"
1360            );
1361            assert!(
1362                surplus > 0,
1363                "ssa_part_size {ssa_part_size} derives no surplus, i.e. no loss tolerance at all"
1364            );
1365            assert!(
1366                cfg.validate().is_ok(),
1367                "a derived surplus must never fail the bound it is derived under"
1368            );
1369        }
1370
1371        // On the deployed multiples of four the tolerance is the documented 20 % exactly.
1372        for ssa_part_size in [16usize, 32, 48, 64] {
1373            let cfg = PixGlobalConfig {
1374                ssa_part_size,
1375                ..Default::default()
1376            };
1377            let surplus = cfg.surplus_shares();
1378            let tolerated = surplus as f64 / (ssa_part_size + surplus) as f64;
1379            assert!(
1380                (0.19..=0.21).contains(&tolerated),
1381                "ssa_part_size {ssa_part_size} derives surplus {surplus}, tolerating {tolerated:.3} loss"
1382            );
1383        }
1384    }
1385
1386    /// Both SSA batch knobs are bounded by [`MAX_SSA_BATCH_SIZE`], and the `range` attribute on
1387    /// `max_ssas_per_request` has to spell that ceiling out as a literal — so assert the two agree.
1388    ///
1389    /// Zero is rejected on both sides for different reasons: an Exit asking for zero SSAs would never
1390    /// send an `SsaRequest` at all, and an Entry accepting zero would reject every request it ever
1391    /// receives. Either way PIX silently stops working.
1392    #[test]
1393    fn ssa_batch_knobs_are_bounded_by_the_shared_ceiling() {
1394        // The literal in the `range` attribute must track the constant it stands for.
1395        let at_ceiling = PixGlobalConfig {
1396            max_ssas_per_request: MAX_SSA_BATCH_SIZE,
1397            ..Default::default()
1398        };
1399        assert!(
1400            at_ceiling.validate().is_ok(),
1401            "MAX_SSA_BATCH_SIZE itself must be accepted — the range literal has drifted below it"
1402        );
1403        let past_ceiling = PixGlobalConfig {
1404            max_ssas_per_request: MAX_SSA_BATCH_SIZE + 1,
1405            ..Default::default()
1406        };
1407        assert!(
1408            past_ceiling.validate().is_err(),
1409            "above MAX_SSA_BATCH_SIZE must be rejected — the range literal has drifted above it"
1410        );
1411
1412        assert!(
1413            PixGlobalConfig {
1414                max_ssas_per_request: 0,
1415                ..Default::default()
1416            }
1417            .validate()
1418            .is_err(),
1419            "an Entry accepting zero SSAs per request would reject every request"
1420        );
1421
1422        for ssas_per_request in [0, MAX_SSA_BATCH_SIZE + 1] {
1423            let cfg = IncomingSessionPixConfig {
1424                ssas_per_request,
1425                ..Default::default()
1426            };
1427            assert!(
1428                validate_incoming_session_pix_config(&cfg).is_err(),
1429                "ssas_per_request of {ssas_per_request} is outside 1..={MAX_SSA_BATCH_SIZE} and must be rejected"
1430            );
1431
1432            let cfg = HoprProtocolConfig {
1433                incoming_session_pix_config: IncomingSessionPixConfig {
1434                    ssas_per_request,
1435                    ..Default::default()
1436                },
1437                ..Default::default()
1438            };
1439            assert!(
1440                cfg.validate().is_err(),
1441                "an out-of-range ssas_per_request must fail the whole protocol config"
1442            );
1443        }
1444
1445        assert!(
1446            validate_incoming_session_pix_config(&IncomingSessionPixConfig {
1447                ssas_per_request: MAX_SSA_BATCH_SIZE,
1448                ..Default::default()
1449            })
1450            .is_ok(),
1451            "the ceiling itself must be accepted"
1452        );
1453    }
1454
1455    #[test]
1456    fn egress_backpressure_timeout_rejects_sub_minimum_values() {
1457        assert!(validate_egress_backpressure_timeout(&Duration::ZERO).is_err());
1458        assert!(validate_egress_backpressure_timeout(&Duration::from_micros(500)).is_err());
1459        assert!(validate_egress_backpressure_timeout(&MIN_EGRESS_BACKPRESSURE_TIMEOUT).is_ok());
1460        assert!(validate_egress_backpressure_timeout(&DEFAULT_EGRESS_BACKPRESSURE_TIMEOUT).is_ok());
1461    }
1462
1463    #[test]
1464    fn stream_protocol_config_default_is_valid() {
1465        assert!(StreamProtocolConfig::default().validate().is_ok());
1466    }
1467
1468    #[test]
1469    fn test_valid_domains_for_looks_like_a_domain() {
1470        assert!(looks_like_domain("localhost"));
1471        assert!(looks_like_domain("hoprnet.org"));
1472        assert!(looks_like_domain("hub.hoprnet.org"));
1473    }
1474
1475    #[test]
1476    fn test_valid_domains_for_does_not_look_like_a_domain() {
1477        assert!(!looks_like_domain(".org"));
1478        assert!(!looks_like_domain("-hoprnet-.org"));
1479    }
1480
1481    #[test]
1482    fn test_valid_domains_should_be_reachable() {
1483        assert!(!is_reachable_domain("google.com"));
1484    }
1485
1486    #[test]
1487    fn test_verify_valid_ip4_addresses() {
1488        assert!(validate_ipv4_address("1.1.1.1").is_ok());
1489        assert!(validate_ipv4_address("1.255.1.1").is_ok());
1490        assert!(validate_ipv4_address("187.1.1.255").is_ok());
1491        assert!(validate_ipv4_address("127.0.0.1").is_ok());
1492    }
1493
1494    #[test]
1495    fn test_verify_invalid_ip4_addresses() {
1496        assert!(validate_ipv4_address("1.256.1.1").is_err());
1497        assert!(validate_ipv4_address("-1.1.1.255").is_err());
1498        assert!(validate_ipv4_address("127.0.0.256").is_err());
1499        assert!(validate_ipv4_address("1").is_err());
1500        assert!(validate_ipv4_address("1.1").is_err());
1501        assert!(validate_ipv4_address("1.1.1").is_err());
1502        assert!(validate_ipv4_address("1.1.1.1.1").is_err());
1503    }
1504
1505    #[test]
1506    fn test_verify_valid_dns_addresses() {
1507        assert!(validate_dns_address("localhost").is_ok());
1508        assert!(validate_dns_address("google.com").is_ok());
1509        assert!(validate_dns_address("hub.hoprnet.org").is_ok());
1510    }
1511
1512    #[test]
1513    fn test_verify_invalid_dns_addresses() {
1514        assert!(validate_dns_address("-hoprnet-.org").is_err());
1515    }
1516
1517    #[test]
1518    fn test_multiaddress_on_dappnode_default() {
1519        temp_env::with_var("DAPPNODE", Some("true"), || {
1520            assert_eq!(default_multiaddr_transport(1234), "tcp/1234");
1521        });
1522    }
1523
1524    #[cfg(feature = "p2p-announce-quic")]
1525    #[test]
1526    fn test_multiaddress_on_non_dappnode_default() {
1527        temp_env::with_vars([("DAPPNODE", Some("false")), ("HOPRD_NAT", Some("false"))], || {
1528            assert_eq!(default_multiaddr_transport(1234), "udp/1234/quic-v1");
1529        });
1530    }
1531
1532    #[cfg(not(feature = "p2p-announce-quic"))]
1533    #[test]
1534    fn test_multiaddress_on_non_dappnode_default() {
1535        assert_eq!(default_multiaddr_transport(1234), "tcp/1234");
1536    }
1537
1538    #[test]
1539    fn test_multiaddress_on_non_dappnode_uses_nat() {
1540        temp_env::with_var("HOPRD_NAT", Some("true"), || {
1541            assert_eq!(default_multiaddr_transport(1234), "tcp/1234");
1542        });
1543    }
1544
1545    #[cfg(feature = "p2p-announce-quic")]
1546    #[test]
1547    fn test_multiaddress_on_non_dappnode_not_uses_nat() {
1548        temp_env::with_var("HOPRD_NAT", Some("false"), || {
1549            assert_eq!(default_multiaddr_transport(1234), "udp/1234/quic-v1");
1550        });
1551    }
1552
1553    #[cfg(not(feature = "p2p-announce-quic"))]
1554    #[test]
1555    fn test_multiaddress_on_non_dappnode_not_uses_nat() {
1556        temp_env::with_var("HOPRD_NAT", Some("false"), || {
1557            assert_eq!(default_multiaddr_transport(1234), "tcp/1234");
1558        });
1559    }
1560
1561    #[cfg(feature = "p2p-announce-quic")]
1562    #[test]
1563    fn test_multiaddress_on_dappnode_not_uses_nat() {
1564        temp_env::with_vars([("DAPPNODE", Some("true")), ("HOPRD_NAT", Some("false"))], || {
1565            assert_eq!(default_multiaddr_transport(1234), "udp/1234/quic-v1");
1566        });
1567    }
1568
1569    #[cfg(not(feature = "p2p-announce-quic"))]
1570    #[test]
1571    fn test_multiaddress_on_dappnode_not_uses_nat() {
1572        temp_env::with_vars([("DAPPNODE", Some("true")), ("HOPRD_NAT", Some("false"))], || {
1573            assert_eq!(default_multiaddr_transport(1234), "tcp/1234");
1574        });
1575    }
1576
1577    // --- HostConfig::FromStr tests ---
1578
1579    #[test]
1580    fn host_config_parses_ipv4_address() {
1581        let cfg = HostConfig::from_str("1.2.3.4:9091").unwrap();
1582        insta::assert_debug_snapshot!(cfg);
1583    }
1584
1585    #[test]
1586    fn host_config_parses_domain() {
1587        let cfg = HostConfig::from_str("example.com:443").unwrap();
1588        insta::assert_debug_snapshot!(cfg);
1589    }
1590
1591    #[test]
1592    fn host_config_rejects_missing_port() {
1593        assert!(HostConfig::from_str("1.2.3.4").is_err());
1594    }
1595
1596    #[test]
1597    fn host_config_rejects_invalid_port() {
1598        assert!(HostConfig::from_str("1.2.3.4:abc").is_err());
1599    }
1600
1601    #[test]
1602    fn host_config_rejects_invalid_host() {
1603        assert!(HostConfig::from_str("-invalid-.com:80").is_err());
1604    }
1605
1606    #[test]
1607    fn host_config_display_roundtrip() {
1608        let cfg = HostConfig {
1609            address: HostType::IPv4("10.0.0.1".into()),
1610            port: 8080,
1611        };
1612        insta::assert_yaml_snapshot!(cfg.to_string());
1613    }
1614
1615    // --- TryFrom<&HostConfig> for Multiaddr tests ---
1616
1617    #[test]
1618    fn multiaddr_from_ipv4_host_config() {
1619        let cfg = HostConfig {
1620            address: HostType::IPv4("1.2.3.4".into()),
1621            port: 9091,
1622        };
1623        let addr = Multiaddr::try_from(&cfg).unwrap();
1624        insta::assert_yaml_snapshot!(addr.to_string());
1625    }
1626
1627    #[test]
1628    fn multiaddr_from_domain_host_config() {
1629        let cfg = HostConfig {
1630            address: HostType::Domain("example.com".into()),
1631            port: 443,
1632        };
1633        let addr = Multiaddr::try_from(&cfg).unwrap();
1634        insta::assert_yaml_snapshot!(addr.to_string());
1635    }
1636
1637    // --- SessionGlobalConfig validation tests ---
1638
1639    #[test]
1640    fn session_global_config_default_is_valid() {
1641        let cfg = SessionGlobalConfig::default();
1642        assert!(cfg.validate().is_ok());
1643    }
1644
1645    #[test]
1646    fn session_global_config_too_low_idle_timeout_is_rejected() {
1647        let cfg = SessionGlobalConfig {
1648            idle_timeout: Duration::from_millis(100),
1649            ..Default::default()
1650        };
1651        assert!(cfg.validate().is_err());
1652    }
1653
1654    #[test]
1655    fn session_global_config_too_many_retries_is_rejected() {
1656        let cfg = SessionGlobalConfig {
1657            establish_max_retries: 21,
1658            ..Default::default()
1659        };
1660        assert!(cfg.validate().is_err());
1661    }
1662
1663    #[test]
1664    fn stream_protocol_config_default_has_expected_values() {
1665        let cfg = StreamProtocolConfig::default();
1666        assert_eq!(cfg.per_peer_channel_capacity, DEFAULT_PER_PEER_CHANNEL_CAPACITY);
1667        assert_eq!(cfg.stream_open_timeout, DEFAULT_STREAM_OPEN_TIMEOUT);
1668        assert_eq!(
1669            cfg.frame_writer_backpressure_bytes,
1670            DEFAULT_FRAME_WRITER_BACKPRESSURE_BYTES
1671        );
1672        cfg.validate().expect("default StreamProtocolConfig must be valid");
1673    }
1674
1675    #[test]
1676    fn stream_protocol_config_zero_capacity_is_rejected() {
1677        let cfg = StreamProtocolConfig {
1678            per_peer_channel_capacity: 0,
1679            ..Default::default()
1680        };
1681        assert!(cfg.validate().is_err());
1682    }
1683
1684    #[test]
1685    fn stream_protocol_config_zero_backpressure_bytes_is_rejected() {
1686        let cfg = StreamProtocolConfig {
1687            frame_writer_backpressure_bytes: 0,
1688            ..Default::default()
1689        };
1690        assert!(cfg.validate().is_err());
1691    }
1692
1693    #[test]
1694    fn stream_protocol_config_zero_stream_open_timeout_is_rejected() {
1695        let cfg = StreamProtocolConfig {
1696            stream_open_timeout: Duration::ZERO,
1697            ..Default::default()
1698        };
1699        assert!(cfg.validate().is_err());
1700    }
1701
1702    #[test]
1703    fn stream_protocol_config_zero_backpressure_timeout_is_rejected() {
1704        // Proves the field `#[validate(custom)]` attribute is actually wired into the derived
1705        // `StreamProtocolConfig::validate()` — which the parent `HoprProtocolConfig`/`HoprLibConfig`
1706        // invoke via `#[validate(nested)]` at node build time (builder.rs `cfg.validate()?`).
1707        let cfg = StreamProtocolConfig {
1708            egress_backpressure_timeout: Duration::ZERO,
1709            ..Default::default()
1710        };
1711        assert!(cfg.validate().is_err());
1712    }
1713}