Skip to main content

fedimint_connectors/
iroh.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt;
3use std::net::SocketAddr;
4use std::pin::Pin;
5use std::str::FromStr;
6use std::sync::{Arc, Mutex};
7use std::time::Duration;
8
9use async_trait::async_trait;
10use fedimint_core::config::ALEPH_BFT_UNIT_BYTE_LIMIT;
11use fedimint_core::envs::{
12    FM_GW_IROH_CONNECT_OVERRIDES_PLAIN_ENV, FM_IROH_CONNECT_OVERRIDES_PLAIN_ENV,
13    FM_IROH_N0_DISCOVERY_ENABLE_ENV, FM_IROH_PKARR_RESOLVER_ENABLE_ENV, is_env_var_set_opt,
14    parse_kv_list_from_env,
15};
16use fedimint_core::module::{
17    ApiError, ApiMethod, ApiRequestErased, FEDIMINT_API_ALPN, FEDIMINT_GATEWAY_ALPN,
18    IrohApiRequest, IrohGatewayRequest, IrohGatewayResponse,
19};
20use fedimint_core::net::iroh::{IROH_IDLE_TIMEOUT, IROH_KEEP_ALIVE_INTERVAL};
21
22/// The maximum number of bytes we are willing to buffer when reading an API
23/// response from an iroh QUIC stream. This must be large enough to accommodate
24/// the largest possible signed session outcome. A session can contain up to
25/// `broadcast_rounds_per_session` (default 3600) rounds, each peer produces one
26/// unit per round, and each unit can be up to `ALEPH_BFT_UNIT_BYTE_LIMIT`
27/// bytes. The response is JSON-serialized which hex-encodes the consensus
28/// bytes, roughly doubling the size. We use 2x the raw max as a conservative
29/// upper bound. For a 4-peer federation this is ~1.44 GB.
30const IROH_MAX_RESPONSE_BYTES: usize = ALEPH_BFT_UNIT_BYTE_LIMIT * 3600 * 4 * 2;
31
32/// Wall-clock budget for a single iroh API request to make it through the QUIC
33/// bi-stream (open + write + finish + read response). If exceeded we close the
34/// underlying [`Connection`], which causes [`IConnection::is_connected`] to
35/// return false on the next pool lookup so a fresh connection is established
36/// for the retry. Used for endpoints that respond promptly (`block_count`,
37/// `status`, etc).
38const IROH_REQUEST_TIMEOUT_DEFAULT: Duration = Duration::from_secs(60);
39
40/// Wall-clock budget for an iroh API request to a server-side long-poll
41/// endpoint (`await_*` / `wait_*`). These wait on the server until an event
42/// fires (block height reached, contract cancelled, etc.) before responding,
43/// so they need a generous bound. Set well above realistic mainnet block
44/// intervals; if a long-poll legitimately needs longer than this the upstream
45/// `request_current_consensus_retry` loop will reconnect and retry.
46const IROH_REQUEST_TIMEOUT_LONG_POLL: Duration = Duration::from_secs(60 * 60);
47
48/// Application-level QUIC error code we use when closing a [`Connection`]
49/// after a request timeout. Recorded by the peer as the close reason; chosen
50/// arbitrarily but stable across stable and `iroh_next` impls so the two
51/// emit identical telemetry. The value 1 distinguishes us from a graceful
52/// close (0).
53const IROH_REQUEST_TIMEOUT_ERROR_CODE: u32 = 1;
54const IROH_REQUEST_TIMEOUT_ERROR_REASON: &[u8] = b"request timeout";
55
56/// Request timeout strategy: long-poll endpoints (`await_*` / `wait_*`)
57/// get the long bound, everything else gets the default. The string match
58/// is a heuristic; it covers all currently-defined fedimint long-poll
59/// endpoints and stays correct if new ones follow the existing naming
60/// convention. False positives (a non-long-poll endpoint that happens to
61/// match the prefix) just give that one method a longer leash; the worse
62/// case is a false negative — a long-poll method that doesn't match
63/// either prefix would get the 60s default and fail fast on legitimate
64/// waits, but the upstream retry loop would reconnect and try again.
65fn request_timeout_for_method(method: &ApiMethod) -> Duration {
66    let name = match method {
67        ApiMethod::Core(name) => name.as_str(),
68        ApiMethod::Module(_, name) => name.as_str(),
69    };
70    if name.starts_with("await_") || name.starts_with("wait_") {
71        IROH_REQUEST_TIMEOUT_LONG_POLL
72    } else {
73        IROH_REQUEST_TIMEOUT_DEFAULT
74    }
75}
76use fedimint_core::task::spawn;
77use fedimint_core::util::{FmtCompact as _, SafeUrl};
78use fedimint_core::{apply, async_trait_maybe_send};
79use fedimint_logging::LOG_NET_IROH;
80use futures::Future;
81use futures::stream::{FuturesUnordered, StreamExt};
82use iroh::discovery::pkarr::PkarrResolver;
83use iroh::endpoint::Connection;
84use iroh::{Endpoint, NodeAddr, NodeId, PublicKey};
85use reqwest::{Method, StatusCode};
86use serde_json::Value;
87use tokio::sync::watch;
88use tracing::{debug, trace, warn};
89
90use super::{DynGuaridianConnection, IGuardianConnection, ServerError, ServerResult};
91use crate::error::ConnectorError;
92use crate::{Connectivity, DynGatewayConnection, IConnection, IGatewayConnection, IrohPeerInfo};
93
94#[derive(Clone)]
95pub(crate) struct IrohConnector {
96    stable: iroh::endpoint::Endpoint,
97    next: iroh_next::endpoint::Endpoint,
98
99    /// List of overrides to use when attempting to connect to given
100    /// `NodeId`
101    ///
102    /// This is useful for testing, or forcing non-default network
103    /// connectivity.
104    connection_overrides: BTreeMap<NodeId, NodeAddr>,
105
106    /// Registry-owned signal bumped whenever any per-connection monitoring
107    /// task observes a transport-level path change (e.g. iroh relay →
108    /// direct). Consumers of [`crate::ConnectorRegistry`] subscribe via
109    /// [`crate::ConnectorRegistry::connectivity_change_notifier`].
110    path_change: Arc<watch::Sender<u64>>,
111
112    /// Live connections dialed over the next stack, kept so
113    /// [`crate::Connector::connectivity`] can read their paths back.
114    ///
115    /// Iroh 1.0 exposes path state per connection rather than per endpoint,
116    /// so unlike the stable stack there is no `endpoint.conn_type(node_id)`
117    /// to consult after the fact. Entries are keyed by the stable [`NodeId`]
118    /// because that is what callers derive from a peer url, and are replaced
119    /// on redial, so the map stays bounded by the number of distinct peers.
120    next_connections: Arc<Mutex<BTreeMap<NodeId, iroh_next::endpoint::Connection>>>,
121}
122
123impl fmt::Debug for IrohConnector {
124    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125        f.debug_struct("IrohEndpoint")
126            .field("stable-id", &self.stable.node_id())
127            .field("next-id", &self.next.id())
128            .finish_non_exhaustive()
129    }
130}
131
132impl IrohConnector {
133    pub(crate) async fn new(
134        iroh_dns: Option<SafeUrl>,
135        iroh_enable_dht: bool,
136        path_change: Arc<watch::Sender<u64>>,
137    ) -> Result<Self, ConnectorError> {
138        let mut s = Self::new_no_overrides(iroh_dns, iroh_enable_dht, path_change).await?;
139
140        // Overrides are `<node-id>=<socket-addr>` pairs: the node id is the key
141        // and the value is a single direct address. iroh 1.0 no longer ships
142        // the `NodeTicket` format, so we keep the override wire format version
143        // agnostic and build the (legacy) `NodeAddr` from its parts. Pre-0.12
144        // binaries read the `NodeTicket`-format `FM_IROH_CONNECT_OVERRIDES`
145        // instead; devimint emits both side by side.
146        for env_var in [
147            FM_IROH_CONNECT_OVERRIDES_PLAIN_ENV,
148            FM_GW_IROH_CONNECT_OVERRIDES_PLAIN_ENV,
149        ] {
150            for (k, v) in parse_kv_list_from_env::<NodeId, SocketAddr>(env_var) {
151                s = s.with_connection_override(k, NodeAddr::new(k).with_direct_addresses([v]));
152            }
153        }
154
155        Ok(s)
156    }
157
158    #[allow(clippy::too_many_lines)]
159    pub(crate) async fn new_no_overrides(
160        iroh_dns: Option<SafeUrl>,
161        iroh_enable_dht: bool,
162        path_change: Arc<watch::Sender<u64>>,
163    ) -> Result<Self, ConnectorError> {
164        let endpoint_stable = Box::pin({
165            let iroh_dns = iroh_dns.clone();
166            async {
167                let mut builder = Endpoint::builder();
168
169                if let Some(iroh_dns) = iroh_dns.map(SafeUrl::to_unsafe) {
170                    builder = builder.add_discovery(|_| Some(PkarrResolver::new(iroh_dns)));
171                }
172
173                // As a client, we don't need to register on any relays
174                let mut builder = builder.relay_mode(iroh::RelayMode::Disabled);
175
176                #[cfg(not(target_family = "wasm"))]
177                if iroh_enable_dht {
178                    builder = builder.discovery_dht();
179                }
180
181                // Add only resolver services here; the stable n0 convenience also
182                // installs a publisher.
183                {
184                    if is_env_var_set_opt(FM_IROH_PKARR_RESOLVER_ENABLE_ENV).unwrap_or(true) {
185                        builder = builder.add_discovery(move |_| Some(PkarrResolver::n0_dns()));
186                    } else {
187                        warn!(
188                            target: LOG_NET_IROH,
189                            "Iroh pkarr resolver is disabled"
190                        );
191                    }
192
193                    if is_env_var_set_opt(FM_IROH_N0_DISCOVERY_ENABLE_ENV).unwrap_or(true) {
194                        #[cfg(not(target_family = "wasm"))]
195                        {
196                            builder = builder.add_discovery(move |_| {
197                                Some(iroh::discovery::dns::DnsDiscovery::n0_dns())
198                            });
199                        }
200                    } else {
201                        warn!(
202                            target: LOG_NET_IROH,
203                            "Iroh n0 discovery is disabled"
204                        );
205                    }
206                }
207
208                let endpoint = builder
209                    .transport_config(quic_transport_config())
210                    .bind()
211                    .await
212                    .map_err(|err| ConnectorError::Transport(err.into()))?;
213                debug!(
214                    target: LOG_NET_IROH,
215                    node_id = %endpoint.node_id(),
216                    node_id_pkarr = %z32::encode(endpoint.node_id().as_bytes()),
217                    "Iroh api client endpoint (stable)"
218                );
219                Ok::<_, ConnectorError>(endpoint)
220            }
221        });
222        let endpoint_next = Box::pin(async {
223            let mut builder = iroh_next::Endpoint::builder(iroh_next::endpoint::presets::Minimal);
224
225            if let Some(iroh_dns) = iroh_dns.map(SafeUrl::to_unsafe) {
226                builder = builder
227                    .address_lookup(iroh_next::address_lookup::PkarrResolver::builder(iroh_dns));
228            }
229
230            // Server iroh-next endpoints publish relay-only address records by
231            // default (the iroh 1.0 publishers filter out direct addresses), so
232            // the client must be able to dial via relays; `RelayMode::Disabled`
233            // would disable dialing them, not just registration.
234            let mut builder = builder.relay_mode(iroh_next::RelayMode::Default);
235
236            #[cfg(not(target_family = "wasm"))]
237            if iroh_enable_dht {
238                builder = builder
239                    .address_lookup(iroh_mainline_address_lookup::DhtAddressLookup::builder());
240            }
241
242            // Add only resolver services here; the iroh preset convenience also
243            // installs a publisher.
244            {
245                // Resolve using HTTPS requests to our DNS server's /pkarr path.
246                builder =
247                    builder.address_lookup(iroh_next::address_lookup::PkarrResolver::n0_dns());
248                // Resolve using DNS queries outside browsers.
249                #[cfg(not(target_family = "wasm"))]
250                {
251                    builder = builder
252                        .address_lookup(iroh_next::address_lookup::DnsAddressLookup::n0_dns());
253                }
254            }
255
256            let endpoint = builder
257                .transport_config(quic_transport_config_next())
258                .bind()
259                .await
260                .map_err(|err| ConnectorError::Transport(err.into()))?;
261            debug!(
262                target: LOG_NET_IROH,
263                node_id = %endpoint.id(),
264                node_id_pkarr = %z32::encode(endpoint.id().as_bytes()),
265                "Iroh api client endpoint (next)"
266            );
267            Ok(endpoint)
268        });
269
270        let (endpoint_stable, endpoint_next) = tokio::try_join!(endpoint_stable, endpoint_next)?;
271
272        Ok(Self {
273            stable: endpoint_stable,
274            next: endpoint_next,
275            connection_overrides: BTreeMap::new(),
276            path_change,
277            next_connections: Arc::new(Mutex::new(BTreeMap::new())),
278        })
279    }
280
281    pub(crate) fn with_connection_override(mut self, node: NodeId, addr: NodeAddr) -> Self {
282        self.connection_overrides.insert(node, addr);
283        self
284    }
285
286    pub(crate) fn node_id_from_url(url: &SafeUrl) -> Result<NodeId, ConnectorError> {
287        if url.scheme() != "iroh" {
288            return Err(ConnectorError::UnsupportedScheme {
289                scheme: url.scheme().to_owned(),
290            });
291        }
292        let host = url.host_str().ok_or_else(|| ConnectorError::MissingHost {
293            url: url.to_owned(),
294        })?;
295
296        PublicKey::from_str(host).map_err(|source| ConnectorError::InvalidNodeId {
297            host: host.to_owned(),
298            source: Box::new(source),
299        })
300    }
301}
302
303#[async_trait::async_trait]
304impl crate::Connector for IrohConnector {
305    async fn connect_guardian(
306        &self,
307        url: &SafeUrl,
308        api_secret: Option<&str>,
309    ) -> ServerResult<DynGuaridianConnection> {
310        if api_secret.is_some() {
311            // There seem to be no way to pass secret over current Iroh calling
312            // convention. Connecting anyway would silently drop the credential and
313            // talk to an API that never authenticates us, so refuse instead.
314            return Err(ServerError::Connection(
315                "Iroh api secrets currently not supported".into(),
316            ));
317        }
318        let node_id =
319            Self::node_id_from_url(url).map_err(|source| ServerError::InvalidPeerUrl {
320                source: Box::new(source),
321                url: url.to_owned(),
322            })?;
323        let next_only = crate::is_iroh_next_endpoint_url(url).map_err(|source| {
324            ServerError::InvalidPeerUrl {
325                source: Box::new(source),
326                url: url.to_owned(),
327            }
328        })?;
329        let mut futures = FuturesUnordered::<
330            Pin<
331                Box<
332                    dyn Future<Output = (ServerResult<DynGuaridianConnection>, &'static str)>
333                        + Send,
334                >,
335            >,
336        >::new();
337        let connection_override = self.connection_overrides.get(&node_id).cloned();
338
339        // Advertised Iroh 1.0 identities carry an internal `/v1` marker so we
340        // avoid attempting the incompatible 0.35 stack for safety and efficiency.
341        if next_only {
342            return self
343                .make_new_connection_next(&self.next, node_id, connection_override)
344                .await
345                .map(super::IGuardianConnection::into_dyn);
346        }
347
348        let self_clone = self.clone();
349        futures.push(Box::pin({
350            let connection_override = connection_override.clone();
351            async move {
352                (
353                    self_clone
354                        .make_new_connection_stable(node_id, connection_override)
355                        .await
356                        .map(super::IGuardianConnection::into_dyn),
357                    "stable",
358                )
359            }
360        }));
361
362        let self_clone = self.clone();
363        let endpoint_next = self.next.clone();
364        futures.push(Box::pin(async move {
365            (
366                self_clone
367                    .make_new_connection_next(&endpoint_next, node_id, connection_override)
368                    .await
369                    .map(super::IGuardianConnection::into_dyn),
370                "next",
371            )
372        }));
373
374        // Remember last error, so we have something to return if
375        // neither connection works.
376        let mut prev_err = None;
377
378        // Loop until first success, or running out of connections.
379        while let Some((result, iroh_stack)) = futures.next().await {
380            match result {
381                Ok(connection) => return Ok(connection),
382                Err(err) => {
383                    warn!(
384                        target: LOG_NET_IROH,
385                        err = %err.fmt_compact(),
386                        %iroh_stack,
387                        "Join error in iroh connection task"
388                    );
389                    prev_err = Some(err);
390                }
391            }
392        }
393
394        Err(prev_err.unwrap_or_else(|| {
395            ServerError::ServerError("Both iroh connection attempts failed".to_string())
396        }))
397    }
398
399    async fn connect_gateway(&self, url: &SafeUrl) -> Result<DynGatewayConnection, ConnectorError> {
400        let node_id = Self::node_id_from_url(url)?;
401        if let Some(node_addr) = self.connection_overrides.get(&node_id).cloned() {
402            let conn = self
403                .stable
404                .connect(node_addr.clone(), FEDIMINT_GATEWAY_ALPN)
405                .await
406                .map_err(|err| ConnectorError::Transport(err.into()))?;
407
408            #[cfg(not(target_family = "wasm"))]
409            Self::spawn_connection_monitoring_stable(
410                &self.stable,
411                node_id,
412                self.path_change.clone(),
413            );
414
415            Ok(IGatewayConnection::into_dyn(conn))
416        } else {
417            let conn = self
418                .stable
419                .connect(node_id, FEDIMINT_GATEWAY_ALPN)
420                .await
421                .map_err(|err| ConnectorError::Transport(err.into()))?;
422            Ok(IGatewayConnection::into_dyn(conn))
423        }
424    }
425
426    fn connectivity(&self, url: &SafeUrl) -> Connectivity {
427        let Ok(node_id) = Self::node_id_from_url(url) else {
428            return Connectivity::Unknown;
429        };
430
431        // The next stack is consulted first: a peer reached over an advertised
432        // Iroh 1.0 endpoint never has a stable connection to report, and when
433        // both stacks were raced the next connection is the live one. Falling
434        // through to the stable endpoint in that case would report
435        // `Unknown`, which callers render as a disconnected peer.
436        if let Some(connectivity) = self.connectivity_next(node_id) {
437            return connectivity;
438        }
439
440        let Ok(watcher) = self.stable.conn_type(node_id) else {
441            return Connectivity::Unknown;
442        };
443        match watcher.get() {
444            Ok(iroh::endpoint::ConnectionType::Direct(_)) => Connectivity::Direct,
445            Ok(iroh::endpoint::ConnectionType::Relay(_)) => Connectivity::Relay,
446            Ok(iroh::endpoint::ConnectionType::Mixed(..)) => Connectivity::Mixed,
447            Ok(iroh::endpoint::ConnectionType::None) | Err(_) => Connectivity::Unknown,
448        }
449    }
450
451    async fn iroh_peer_info(
452        &self,
453        url: &SafeUrl,
454        path_timeout: Duration,
455    ) -> ServerResult<Option<IrohPeerInfo>> {
456        let node_id =
457            Self::node_id_from_url(url).map_err(|source| ServerError::InvalidPeerUrl {
458                source: Box::new(source),
459                url: url.to_owned(),
460            })?;
461        let connection_override = self.connection_overrides.get(&node_id).cloned();
462        let _connection = self
463            .make_new_connection_stable(node_id, connection_override)
464            .await?;
465
466        let mut conn_type_watcher = self
467            .stable
468            .conn_type(node_id)
469            .map_err(|err| ServerError::Connection(err.into()))?;
470        let mut conn_type = conn_type_watcher
471            .get()
472            .unwrap_or(iroh::endpoint::ConnectionType::None);
473
474        if path_timeout > Duration::ZERO {
475            let timeout = fedimint_core::runtime::sleep(path_timeout);
476            tokio::pin!(timeout);
477
478            while !matches!(
479                conn_type,
480                iroh::endpoint::ConnectionType::Direct(_)
481                    | iroh::endpoint::ConnectionType::Mixed(..)
482            ) {
483                tokio::select! {
484                    () = &mut timeout => break,
485                    updated = conn_type_watcher.updated() => {
486                        match updated {
487                            Ok(updated) => conn_type = updated,
488                            Err(_) => break,
489                        }
490                    }
491                }
492            }
493        }
494
495        Ok(Some(self.iroh_peer_info_from_conn_type(node_id, conn_type)))
496    }
497}
498
499impl IrohConnector {
500    /// Report how a retained next-stack connection is reaching `node_id`.
501    ///
502    /// Returns `None` when the next stack has nothing to say about the peer,
503    /// so the caller can fall back to the stable stack: no connection held,
504    /// one that has since closed, or one whose paths cannot be classified.
505    /// None of those mean "disconnected", which is what answering
506    /// [`Connectivity::Unknown`] would tell callers.
507    fn connectivity_next(&self, node_id: NodeId) -> Option<Connectivity> {
508        let connections = self
509            .next_connections
510            .lock()
511            .expect("Next connection mutex is never held across a panic");
512
513        let connection = connections.get(&node_id)?;
514
515        if connection.close_reason().is_some() {
516            return None;
517        }
518
519        // A path carries application data over either an IP or a relay
520        // address, so holding one of each is the next-stack spelling of the
521        // stable stack's `Mixed`.
522        let paths = connection.paths();
523        let direct = paths.iter().any(|path| path.is_ip());
524        let relay = paths.iter().any(|path| path.is_relay());
525
526        Some(match (direct, relay) {
527            (true, true) => Connectivity::Mixed,
528            (true, false) => Connectivity::Direct,
529            (false, true) => Connectivity::Relay,
530            // Nothing to classify. Fall back to the stable stack rather than
531            // answering `Unknown`, which callers render as a disconnected
532            // peer — the very thing this lookup exists to avoid.
533            (false, false) => return None,
534        })
535    }
536
537    fn iroh_peer_info_from_conn_type(
538        &self,
539        node_id: NodeId,
540        conn_type: iroh::endpoint::ConnectionType,
541    ) -> IrohPeerInfo {
542        let remote_info = self.stable.remote_info(node_id);
543
544        let direct_addr = match &conn_type {
545            iroh::endpoint::ConnectionType::Direct(addr)
546            | iroh::endpoint::ConnectionType::Mixed(addr, _) => Some(*addr),
547            iroh::endpoint::ConnectionType::Relay(_) | iroh::endpoint::ConnectionType::None => None,
548        };
549
550        let mut known_direct_addrs = remote_info
551            .as_ref()
552            .map(|info| {
553                info.addrs
554                    .iter()
555                    .map(|addr_info| addr_info.addr)
556                    .collect::<BTreeSet<_>>()
557            })
558            .unwrap_or_default();
559        if let Some(direct_addr) = direct_addr {
560            known_direct_addrs.insert(direct_addr);
561        }
562
563        let relay_url = match &conn_type {
564            iroh::endpoint::ConnectionType::Relay(relay_url)
565            | iroh::endpoint::ConnectionType::Mixed(_, relay_url) => Some(relay_url.to_string()),
566            iroh::endpoint::ConnectionType::Direct(_) | iroh::endpoint::ConnectionType::None => {
567                remote_info.and_then(|info| info.relay_url.map(|relay| relay.relay_url.to_string()))
568            }
569        };
570
571        IrohPeerInfo {
572            node_id: node_id.to_string(),
573            connectivity: connectivity_from_iroh_conn_type(&conn_type),
574            direct_addr,
575            known_direct_addrs: known_direct_addrs.into_iter().collect(),
576            relay_url,
577        }
578    }
579
580    #[cfg(not(target_family = "wasm"))]
581    fn spawn_connection_monitoring_stable(
582        endpoint: &Endpoint,
583        node_id: NodeId,
584        path_change: Arc<watch::Sender<u64>>,
585    ) {
586        if let Ok(mut conn_type_watcher) = endpoint.conn_type(node_id) {
587            #[allow(clippy::let_underscore_future)]
588            let _ = spawn("iroh connection (stable)", async move {
589                if let Ok(conn_type) = conn_type_watcher.get() {
590                    debug!(target: LOG_NET_IROH, %node_id, type = %conn_type, "Connection type (initial)");
591                }
592                while let Ok(event) = conn_type_watcher.updated().await {
593                    debug!(target: LOG_NET_IROH, %node_id, type = %event, "Connection type (changed)");
594                    path_change.send_modify(|c| *c = c.wrapping_add(1));
595                }
596            });
597        }
598    }
599
600    #[cfg(not(target_family = "wasm"))]
601    fn spawn_connection_monitoring_next(
602        conn: &iroh_next::endpoint::Connection,
603        node_id: iroh_next::EndpointId,
604        path_change: Arc<watch::Sender<u64>>,
605    ) {
606        let conn = conn.clone();
607        #[allow(clippy::let_underscore_future)]
608        let _ = spawn("iroh connection (next)", async move {
609            let mut paths = conn.paths_stream();
610            if let Some(paths) = paths.next().await {
611                debug!(target: LOG_NET_IROH, %node_id, ?paths, "Connection paths (initial)");
612            }
613            while let Some(paths) = paths.next().await {
614                debug!(target: LOG_NET_IROH, %node_id, ?paths, "Connection paths changed");
615                path_change.send_modify(|c| *c = c.wrapping_add(1));
616            }
617        });
618    }
619
620    async fn make_new_connection_stable(
621        &self,
622        node_id: NodeId,
623        node_addr: Option<NodeAddr>,
624    ) -> ServerResult<Connection> {
625        trace!(target: LOG_NET_IROH, %node_id, "Creating new stable connection");
626        let conn = match node_addr.clone() {
627            Some(node_addr) => {
628                trace!(target: LOG_NET_IROH, %node_id, "Using a connectivity override for connection");
629                let conn = self.stable
630                    .connect(node_addr.clone(), FEDIMINT_API_ALPN)
631                    .await;
632
633                #[cfg(not(target_family = "wasm"))]
634                if conn.is_ok() {
635                    Self::spawn_connection_monitoring_stable(
636                        &self.stable,
637                        node_id,
638                        self.path_change.clone(),
639                    );
640                }
641                conn
642            }
643            None => self.stable.connect(node_id, FEDIMINT_API_ALPN).await,
644        }.map_err(|err| ServerError::Connection(err.into()))?;
645
646        Ok(conn)
647    }
648
649    async fn make_new_connection_next(
650        &self,
651        endpoint_next: &iroh_next::Endpoint,
652        node_id: NodeId,
653        node_addr: Option<NodeAddr>,
654    ) -> ServerResult<iroh_next::endpoint::Connection> {
655        let next_node_id =
656            iroh_next::EndpointId::from_bytes(node_id.as_bytes()).expect("Can't fail");
657
658        let endpoint_next = endpoint_next.clone();
659
660        trace!(target: LOG_NET_IROH, %node_id, "Creating new next connection");
661        let conn = match node_addr.clone() {
662            Some(node_addr) => {
663                trace!(target: LOG_NET_IROH, %node_id, "Using a connectivity override for connection");
664                let node_addr = node_addr_stable_to_next(&node_addr);
665                let conn = endpoint_next
666                    .connect(node_addr.clone(), FEDIMINT_API_ALPN)
667                    .await;
668
669                #[cfg(not(target_family = "wasm"))]
670                if let Ok(conn) = &conn {
671                    Self::spawn_connection_monitoring_next(
672                        conn,
673                        node_addr.id,
674                        self.path_change.clone(),
675                    );
676                }
677
678                conn
679            }
680            None => endpoint_next.connect(
681                next_node_id,
682                FEDIMINT_API_ALPN
683            ).await,
684        }
685        .map_err(|err| ServerError::Connection(err.into()))?;
686
687        // Retain the connection so `connectivity` can read its paths back;
688        // iroh 1.0 offers no endpoint-level lookup to recover it from.
689        self.next_connections
690            .lock()
691            .expect("Next connection mutex is never held across a panic")
692            .insert(node_id, conn.clone());
693
694        Ok(conn)
695    }
696}
697
698/// QUIC transport config with explicit idle timeout and keep-alive
699/// for the stable iroh endpoint.
700fn quic_transport_config() -> iroh::endpoint::TransportConfig {
701    let mut config = iroh::endpoint::TransportConfig::default();
702    config.max_idle_timeout(Some(
703        IROH_IDLE_TIMEOUT
704            .try_into()
705            .expect("idle timeout fits in IdleTimeout"),
706    ));
707    config.keep_alive_interval(Some(IROH_KEEP_ALIVE_INTERVAL));
708    config
709}
710
711/// QUIC transport config with explicit idle timeout and keep-alive
712/// for the next iroh endpoint.
713fn quic_transport_config_next() -> iroh_next::endpoint::QuicTransportConfig {
714    iroh_next::endpoint::QuicTransportConfig::builder()
715        .max_idle_timeout(Some(
716            IROH_IDLE_TIMEOUT
717                .try_into()
718                .expect("idle timeout fits in IdleTimeout"),
719        ))
720        .keep_alive_interval(IROH_KEEP_ALIVE_INTERVAL)
721        .build()
722}
723
724fn connectivity_from_iroh_conn_type(conn_type: &iroh::endpoint::ConnectionType) -> Connectivity {
725    match conn_type {
726        iroh::endpoint::ConnectionType::Direct(_) => Connectivity::Direct,
727        iroh::endpoint::ConnectionType::Relay(_) => Connectivity::Relay,
728        iroh::endpoint::ConnectionType::Mixed(..) => Connectivity::Mixed,
729        iroh::endpoint::ConnectionType::None => Connectivity::Unknown,
730    }
731}
732
733fn node_addr_stable_to_next(stable: &iroh::NodeAddr) -> iroh_next::EndpointAddr {
734    let next_node_id =
735        iroh_next::EndpointId::from_bytes(stable.node_id.as_bytes()).expect("Can't fail");
736    let relay_addrs = stable.relay_url.iter().map(|u| {
737        iroh_next::TransportAddr::Relay(
738            iroh_next::RelayUrl::from_str(&u.to_string()).expect("Can't fail"),
739        )
740    });
741    let direct_addrs = stable
742        .direct_addresses
743        .iter()
744        .copied()
745        .map(iroh_next::TransportAddr::Ip);
746
747    iroh_next::EndpointAddr::from_parts(next_node_id, relay_addrs.chain(direct_addrs))
748}
749
750#[apply(async_trait_maybe_send!)]
751impl IConnection for Connection {
752    async fn await_disconnection(&self) {
753        self.closed().await;
754    }
755
756    fn is_connected(&self) -> bool {
757        self.close_reason().is_none()
758    }
759}
760
761#[async_trait]
762impl IGuardianConnection for Connection {
763    async fn request(&self, method: ApiMethod, request: ApiRequestErased) -> ServerResult<Value> {
764        let timeout = request_timeout_for_method(&method);
765        let method_str = method.to_string();
766        let json = serde_json::to_vec(&IrohApiRequest { method, request })
767            .expect("Serialization to vec can't fail");
768
769        let result = fedimint_core::runtime::timeout(timeout, async {
770            let (mut sink, mut stream) = self
771                .open_bi()
772                .await
773                .map_err(|e| ServerError::Transport(e.into()))?;
774
775            sink.write_all(&json)
776                .await
777                .map_err(|e| ServerError::Transport(e.into()))?;
778
779            sink.finish()
780                .map_err(|e| ServerError::Transport(e.into()))?;
781
782            stream
783                .read_to_end(IROH_MAX_RESPONSE_BYTES)
784                .await
785                .map_err(|e| ServerError::Transport(e.into()))
786        })
787        .await;
788
789        let response = match result {
790            Ok(Ok(bytes)) => bytes,
791            Ok(Err(err)) => return Err(err),
792            Err(_) => {
793                // The bi-stream stalled past our budget. Close the QUIC
794                // connection so [`Self::is_connected`] (which reads
795                // `close_reason`) starts returning false; the connection
796                // pool's `get_or_init_pool_entry` will then evict this
797                // entry on the next access and the upstream retry loop
798                // will get a fresh connection.
799                warn!(
800                    target: LOG_NET_IROH,
801                    method = %method_str,
802                    timeout_secs = timeout.as_secs(),
803                    "iroh request timed out, closing connection",
804                );
805                self.close(
806                    iroh::endpoint::VarInt::from_u32(IROH_REQUEST_TIMEOUT_ERROR_CODE),
807                    IROH_REQUEST_TIMEOUT_ERROR_REASON,
808                );
809                return Err(ServerError::Transport(
810                    format!("iroh request {method_str} timed out after {timeout:?}").into(),
811                ));
812            }
813        };
814
815        // TODO: We should not be serializing Results on the wire
816        let response = serde_json::from_slice::<Result<Value, ApiError>>(&response)
817            .map_err(|e| ServerError::InvalidResponse(e.fmt_compact().to_string()))?;
818
819        response.map_err(|e| ServerError::InvalidResponse(format!("Api Error: {e:?}")))
820    }
821}
822
823#[apply(async_trait_maybe_send!)]
824impl IConnection for iroh_next::endpoint::Connection {
825    async fn await_disconnection(&self) {
826        self.closed().await;
827    }
828
829    fn is_connected(&self) -> bool {
830        self.close_reason().is_none()
831    }
832}
833
834#[async_trait]
835impl IGuardianConnection for iroh_next::endpoint::Connection {
836    async fn request(&self, method: ApiMethod, request: ApiRequestErased) -> ServerResult<Value> {
837        let timeout = request_timeout_for_method(&method);
838        let method_str = method.to_string();
839        let json = serde_json::to_vec(&IrohApiRequest { method, request })
840            .expect("Serialization to vec can't fail");
841
842        let result = fedimint_core::runtime::timeout(timeout, async {
843            let (mut sink, mut stream) = self
844                .open_bi()
845                .await
846                .map_err(|e| ServerError::Transport(e.into()))?;
847
848            sink.write_all(&json)
849                .await
850                .map_err(|e| ServerError::Transport(e.into()))?;
851
852            sink.finish()
853                .map_err(|e| ServerError::Transport(e.into()))?;
854
855            stream
856                .read_to_end(IROH_MAX_RESPONSE_BYTES)
857                .await
858                .map_err(|e| ServerError::Transport(e.into()))
859        })
860        .await;
861
862        let response = match result {
863            Ok(Ok(bytes)) => bytes,
864            Ok(Err(err)) => return Err(err),
865            Err(_) => {
866                warn!(
867                    target: LOG_NET_IROH,
868                    method = %method_str,
869                    timeout_secs = timeout.as_secs(),
870                    "iroh request timed out, closing connection",
871                );
872                self.close(
873                    iroh_next::endpoint::VarInt::from_u32(IROH_REQUEST_TIMEOUT_ERROR_CODE),
874                    IROH_REQUEST_TIMEOUT_ERROR_REASON,
875                );
876                return Err(ServerError::Transport(
877                    format!("iroh request {method_str} timed out after {timeout:?}").into(),
878                ));
879            }
880        };
881
882        // TODO: We should not be serializing Results on the wire
883        let response = serde_json::from_slice::<Result<Value, ApiError>>(&response)
884            .map_err(|e| ServerError::InvalidResponse(e.fmt_compact().to_string()))?;
885
886        response.map_err(|e| ServerError::InvalidResponse(format!("Api Error: {e:?}")))
887    }
888}
889
890#[apply(async_trait_maybe_send!)]
891impl IGatewayConnection for Connection {
892    async fn request(
893        &self,
894        password: Option<String>,
895        _method: Method,
896        route: &str,
897        payload: Option<Value>,
898    ) -> ServerResult<Value> {
899        let iroh_request = IrohGatewayRequest {
900            route: route.to_string(),
901            params: payload,
902            password,
903        };
904        let json = serde_json::to_vec(&iroh_request).expect("serialization cant fail");
905
906        let (mut sink, mut stream) = self
907            .open_bi()
908            .await
909            .map_err(|e| ServerError::Transport(e.into()))?;
910
911        sink.write_all(&json)
912            .await
913            .map_err(|e| ServerError::Transport(e.into()))?;
914
915        sink.finish()
916            .map_err(|e| ServerError::Transport(e.into()))?;
917
918        let response = stream
919            .read_to_end(IROH_MAX_RESPONSE_BYTES)
920            .await
921            .map_err(|e| ServerError::Transport(e.into()))?;
922
923        let response = serde_json::from_slice::<IrohGatewayResponse>(&response)
924            .map_err(|e| ServerError::InvalidResponse(e.fmt_compact().to_string()))?;
925        match StatusCode::from_u16(response.status)
926            .map_err(|e| ServerError::InvalidResponse(format!("Invalid status code: {e}")))?
927        {
928            StatusCode::OK => Ok(response.body),
929            status => Err(ServerError::ServerError(format!(
930                "Server returned status code: {status}"
931            ))),
932        }
933    }
934}
935
936#[cfg(test)]
937mod tests;