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    ) -> anyhow::Result<Self> {
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    ) -> anyhow::Result<Self> {
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                debug!(
213                    target: LOG_NET_IROH,
214                    node_id = %endpoint.node_id(),
215                    node_id_pkarr = %z32::encode(endpoint.node_id().as_bytes()),
216                    "Iroh api client endpoint (stable)"
217                );
218                Ok::<_, anyhow::Error>(endpoint)
219            }
220        });
221        let endpoint_next = Box::pin(async {
222            let mut builder = iroh_next::Endpoint::builder(iroh_next::endpoint::presets::Minimal);
223
224            if let Some(iroh_dns) = iroh_dns.map(SafeUrl::to_unsafe) {
225                builder = builder
226                    .address_lookup(iroh_next::address_lookup::PkarrResolver::builder(iroh_dns));
227            }
228
229            // Server iroh-next endpoints publish relay-only address records by
230            // default (the iroh 1.0 publishers filter out direct addresses), so
231            // the client must be able to dial via relays; `RelayMode::Disabled`
232            // would disable dialing them, not just registration.
233            let mut builder = builder.relay_mode(iroh_next::RelayMode::Default);
234
235            #[cfg(not(target_family = "wasm"))]
236            if iroh_enable_dht {
237                builder = builder
238                    .address_lookup(iroh_mainline_address_lookup::DhtAddressLookup::builder());
239            }
240
241            // Add only resolver services here; the iroh preset convenience also
242            // installs a publisher.
243            {
244                // Resolve using HTTPS requests to our DNS server's /pkarr path.
245                builder =
246                    builder.address_lookup(iroh_next::address_lookup::PkarrResolver::n0_dns());
247                // Resolve using DNS queries outside browsers.
248                #[cfg(not(target_family = "wasm"))]
249                {
250                    builder = builder
251                        .address_lookup(iroh_next::address_lookup::DnsAddressLookup::n0_dns());
252                }
253            }
254
255            let endpoint = builder
256                .transport_config(quic_transport_config_next())
257                .bind()
258                .await?;
259            debug!(
260                target: LOG_NET_IROH,
261                node_id = %endpoint.id(),
262                node_id_pkarr = %z32::encode(endpoint.id().as_bytes()),
263                "Iroh api client endpoint (next)"
264            );
265            Ok(endpoint)
266        });
267
268        let (endpoint_stable, endpoint_next) = tokio::try_join!(endpoint_stable, endpoint_next)?;
269
270        Ok(Self {
271            stable: endpoint_stable,
272            next: endpoint_next,
273            connection_overrides: BTreeMap::new(),
274            path_change,
275            next_connections: Arc::new(Mutex::new(BTreeMap::new())),
276        })
277    }
278
279    pub(crate) fn with_connection_override(mut self, node: NodeId, addr: NodeAddr) -> Self {
280        self.connection_overrides.insert(node, addr);
281        self
282    }
283
284    pub(crate) fn node_id_from_url(url: &SafeUrl) -> Result<NodeId, ConnectorError> {
285        if url.scheme() != "iroh" {
286            return Err(ConnectorError::UnsupportedScheme {
287                scheme: url.scheme().to_owned(),
288            });
289        }
290        let host = url.host_str().ok_or_else(|| ConnectorError::MissingHost {
291            url: url.to_owned(),
292        })?;
293
294        PublicKey::from_str(host).map_err(|source| ConnectorError::InvalidNodeId {
295            host: host.to_owned(),
296            source: Box::new(source),
297        })
298    }
299}
300
301#[async_trait::async_trait]
302impl crate::Connector for IrohConnector {
303    async fn connect_guardian(
304        &self,
305        url: &SafeUrl,
306        api_secret: Option<&str>,
307    ) -> ServerResult<DynGuaridianConnection> {
308        if api_secret.is_some() {
309            // There seem to be no way to pass secret over current Iroh calling
310            // convention. Connecting anyway would silently drop the credential and
311            // talk to an API that never authenticates us, so refuse instead.
312            return Err(ServerError::Connection(
313                "Iroh api secrets currently not supported".into(),
314            ));
315        }
316        let node_id =
317            Self::node_id_from_url(url).map_err(|source| ServerError::InvalidPeerUrl {
318                source: Box::new(source),
319                url: url.to_owned(),
320            })?;
321        let next_only = crate::is_iroh_next_endpoint_url(url).map_err(|source| {
322            ServerError::InvalidPeerUrl {
323                source: Box::new(source),
324                url: url.to_owned(),
325            }
326        })?;
327        let mut futures = FuturesUnordered::<
328            Pin<
329                Box<
330                    dyn Future<Output = (ServerResult<DynGuaridianConnection>, &'static str)>
331                        + Send,
332                >,
333            >,
334        >::new();
335        let connection_override = self.connection_overrides.get(&node_id).cloned();
336
337        // Advertised Iroh 1.0 identities carry an internal `/v1` marker so we
338        // avoid attempting the incompatible 0.35 stack for safety and efficiency.
339        if next_only {
340            return self
341                .make_new_connection_next(&self.next, node_id, connection_override)
342                .await
343                .map(super::IGuardianConnection::into_dyn);
344        }
345
346        let self_clone = self.clone();
347        futures.push(Box::pin({
348            let connection_override = connection_override.clone();
349            async move {
350                (
351                    self_clone
352                        .make_new_connection_stable(node_id, connection_override)
353                        .await
354                        .map(super::IGuardianConnection::into_dyn),
355                    "stable",
356                )
357            }
358        }));
359
360        let self_clone = self.clone();
361        let endpoint_next = self.next.clone();
362        futures.push(Box::pin(async move {
363            (
364                self_clone
365                    .make_new_connection_next(&endpoint_next, node_id, connection_override)
366                    .await
367                    .map(super::IGuardianConnection::into_dyn),
368                "next",
369            )
370        }));
371
372        // Remember last error, so we have something to return if
373        // neither connection works.
374        let mut prev_err = None;
375
376        // Loop until first success, or running out of connections.
377        while let Some((result, iroh_stack)) = futures.next().await {
378            match result {
379                Ok(connection) => return Ok(connection),
380                Err(err) => {
381                    warn!(
382                        target: LOG_NET_IROH,
383                        err = %err.fmt_compact(),
384                        %iroh_stack,
385                        "Join error in iroh connection task"
386                    );
387                    prev_err = Some(err);
388                }
389            }
390        }
391
392        Err(prev_err.unwrap_or_else(|| {
393            ServerError::ServerError("Both iroh connection attempts failed".to_string())
394        }))
395    }
396
397    async fn connect_gateway(&self, url: &SafeUrl) -> Result<DynGatewayConnection, ConnectorError> {
398        let node_id = Self::node_id_from_url(url)?;
399        if let Some(node_addr) = self.connection_overrides.get(&node_id).cloned() {
400            let conn = self
401                .stable
402                .connect(node_addr.clone(), FEDIMINT_GATEWAY_ALPN)
403                .await
404                .map_err(|err| ConnectorError::Transport(err.into()))?;
405
406            #[cfg(not(target_family = "wasm"))]
407            Self::spawn_connection_monitoring_stable(
408                &self.stable,
409                node_id,
410                self.path_change.clone(),
411            );
412
413            Ok(IGatewayConnection::into_dyn(conn))
414        } else {
415            let conn = self
416                .stable
417                .connect(node_id, FEDIMINT_GATEWAY_ALPN)
418                .await
419                .map_err(|err| ConnectorError::Transport(err.into()))?;
420            Ok(IGatewayConnection::into_dyn(conn))
421        }
422    }
423
424    fn connectivity(&self, url: &SafeUrl) -> Connectivity {
425        let Ok(node_id) = Self::node_id_from_url(url) else {
426            return Connectivity::Unknown;
427        };
428
429        // The next stack is consulted first: a peer reached over an advertised
430        // Iroh 1.0 endpoint never has a stable connection to report, and when
431        // both stacks were raced the next connection is the live one. Falling
432        // through to the stable endpoint in that case would report
433        // `Unknown`, which callers render as a disconnected peer.
434        if let Some(connectivity) = self.connectivity_next(node_id) {
435            return connectivity;
436        }
437
438        let Ok(watcher) = self.stable.conn_type(node_id) else {
439            return Connectivity::Unknown;
440        };
441        match watcher.get() {
442            Ok(iroh::endpoint::ConnectionType::Direct(_)) => Connectivity::Direct,
443            Ok(iroh::endpoint::ConnectionType::Relay(_)) => Connectivity::Relay,
444            Ok(iroh::endpoint::ConnectionType::Mixed(..)) => Connectivity::Mixed,
445            Ok(iroh::endpoint::ConnectionType::None) | Err(_) => Connectivity::Unknown,
446        }
447    }
448
449    async fn iroh_peer_info(
450        &self,
451        url: &SafeUrl,
452        path_timeout: Duration,
453    ) -> ServerResult<Option<IrohPeerInfo>> {
454        let node_id =
455            Self::node_id_from_url(url).map_err(|source| ServerError::InvalidPeerUrl {
456                source: Box::new(source),
457                url: url.to_owned(),
458            })?;
459        let connection_override = self.connection_overrides.get(&node_id).cloned();
460        let _connection = self
461            .make_new_connection_stable(node_id, connection_override)
462            .await?;
463
464        let mut conn_type_watcher = self
465            .stable
466            .conn_type(node_id)
467            .map_err(|err| ServerError::Connection(err.into()))?;
468        let mut conn_type = conn_type_watcher
469            .get()
470            .unwrap_or(iroh::endpoint::ConnectionType::None);
471
472        if path_timeout > Duration::ZERO {
473            let timeout = fedimint_core::runtime::sleep(path_timeout);
474            tokio::pin!(timeout);
475
476            while !matches!(
477                conn_type,
478                iroh::endpoint::ConnectionType::Direct(_)
479                    | iroh::endpoint::ConnectionType::Mixed(..)
480            ) {
481                tokio::select! {
482                    () = &mut timeout => break,
483                    updated = conn_type_watcher.updated() => {
484                        match updated {
485                            Ok(updated) => conn_type = updated,
486                            Err(_) => break,
487                        }
488                    }
489                }
490            }
491        }
492
493        Ok(Some(self.iroh_peer_info_from_conn_type(node_id, conn_type)))
494    }
495}
496
497impl IrohConnector {
498    /// Report how a retained next-stack connection is reaching `node_id`.
499    ///
500    /// Returns `None` when the next stack has nothing to say about the peer,
501    /// so the caller can fall back to the stable stack: no connection held,
502    /// one that has since closed, or one whose paths cannot be classified.
503    /// None of those mean "disconnected", which is what answering
504    /// [`Connectivity::Unknown`] would tell callers.
505    fn connectivity_next(&self, node_id: NodeId) -> Option<Connectivity> {
506        let connections = self
507            .next_connections
508            .lock()
509            .expect("Next connection mutex is never held across a panic");
510
511        let connection = connections.get(&node_id)?;
512
513        if connection.close_reason().is_some() {
514            return None;
515        }
516
517        // A path carries application data over either an IP or a relay
518        // address, so holding one of each is the next-stack spelling of the
519        // stable stack's `Mixed`.
520        let paths = connection.paths();
521        let direct = paths.iter().any(|path| path.is_ip());
522        let relay = paths.iter().any(|path| path.is_relay());
523
524        Some(match (direct, relay) {
525            (true, true) => Connectivity::Mixed,
526            (true, false) => Connectivity::Direct,
527            (false, true) => Connectivity::Relay,
528            // Nothing to classify. Fall back to the stable stack rather than
529            // answering `Unknown`, which callers render as a disconnected
530            // peer — the very thing this lookup exists to avoid.
531            (false, false) => return None,
532        })
533    }
534
535    fn iroh_peer_info_from_conn_type(
536        &self,
537        node_id: NodeId,
538        conn_type: iroh::endpoint::ConnectionType,
539    ) -> IrohPeerInfo {
540        let remote_info = self.stable.remote_info(node_id);
541
542        let direct_addr = match &conn_type {
543            iroh::endpoint::ConnectionType::Direct(addr)
544            | iroh::endpoint::ConnectionType::Mixed(addr, _) => Some(*addr),
545            iroh::endpoint::ConnectionType::Relay(_) | iroh::endpoint::ConnectionType::None => None,
546        };
547
548        let mut known_direct_addrs = remote_info
549            .as_ref()
550            .map(|info| {
551                info.addrs
552                    .iter()
553                    .map(|addr_info| addr_info.addr)
554                    .collect::<BTreeSet<_>>()
555            })
556            .unwrap_or_default();
557        if let Some(direct_addr) = direct_addr {
558            known_direct_addrs.insert(direct_addr);
559        }
560
561        let relay_url = match &conn_type {
562            iroh::endpoint::ConnectionType::Relay(relay_url)
563            | iroh::endpoint::ConnectionType::Mixed(_, relay_url) => Some(relay_url.to_string()),
564            iroh::endpoint::ConnectionType::Direct(_) | iroh::endpoint::ConnectionType::None => {
565                remote_info.and_then(|info| info.relay_url.map(|relay| relay.relay_url.to_string()))
566            }
567        };
568
569        IrohPeerInfo {
570            node_id: node_id.to_string(),
571            connectivity: connectivity_from_iroh_conn_type(&conn_type),
572            direct_addr,
573            known_direct_addrs: known_direct_addrs.into_iter().collect(),
574            relay_url,
575        }
576    }
577
578    #[cfg(not(target_family = "wasm"))]
579    fn spawn_connection_monitoring_stable(
580        endpoint: &Endpoint,
581        node_id: NodeId,
582        path_change: Arc<watch::Sender<u64>>,
583    ) {
584        if let Ok(mut conn_type_watcher) = endpoint.conn_type(node_id) {
585            #[allow(clippy::let_underscore_future)]
586            let _ = spawn("iroh connection (stable)", async move {
587                if let Ok(conn_type) = conn_type_watcher.get() {
588                    debug!(target: LOG_NET_IROH, %node_id, type = %conn_type, "Connection type (initial)");
589                }
590                while let Ok(event) = conn_type_watcher.updated().await {
591                    debug!(target: LOG_NET_IROH, %node_id, type = %event, "Connection type (changed)");
592                    path_change.send_modify(|c| *c = c.wrapping_add(1));
593                }
594            });
595        }
596    }
597
598    #[cfg(not(target_family = "wasm"))]
599    fn spawn_connection_monitoring_next(
600        conn: &iroh_next::endpoint::Connection,
601        node_id: iroh_next::EndpointId,
602        path_change: Arc<watch::Sender<u64>>,
603    ) {
604        let conn = conn.clone();
605        #[allow(clippy::let_underscore_future)]
606        let _ = spawn("iroh connection (next)", async move {
607            let mut paths = conn.paths_stream();
608            if let Some(paths) = paths.next().await {
609                debug!(target: LOG_NET_IROH, %node_id, ?paths, "Connection paths (initial)");
610            }
611            while let Some(paths) = paths.next().await {
612                debug!(target: LOG_NET_IROH, %node_id, ?paths, "Connection paths changed");
613                path_change.send_modify(|c| *c = c.wrapping_add(1));
614            }
615        });
616    }
617
618    async fn make_new_connection_stable(
619        &self,
620        node_id: NodeId,
621        node_addr: Option<NodeAddr>,
622    ) -> ServerResult<Connection> {
623        trace!(target: LOG_NET_IROH, %node_id, "Creating new stable connection");
624        let conn = match node_addr.clone() {
625            Some(node_addr) => {
626                trace!(target: LOG_NET_IROH, %node_id, "Using a connectivity override for connection");
627                let conn = self.stable
628                    .connect(node_addr.clone(), FEDIMINT_API_ALPN)
629                    .await;
630
631                #[cfg(not(target_family = "wasm"))]
632                if conn.is_ok() {
633                    Self::spawn_connection_monitoring_stable(
634                        &self.stable,
635                        node_id,
636                        self.path_change.clone(),
637                    );
638                }
639                conn
640            }
641            None => self.stable.connect(node_id, FEDIMINT_API_ALPN).await,
642        }.map_err(|err| ServerError::Connection(err.into()))?;
643
644        Ok(conn)
645    }
646
647    async fn make_new_connection_next(
648        &self,
649        endpoint_next: &iroh_next::Endpoint,
650        node_id: NodeId,
651        node_addr: Option<NodeAddr>,
652    ) -> ServerResult<iroh_next::endpoint::Connection> {
653        let next_node_id =
654            iroh_next::EndpointId::from_bytes(node_id.as_bytes()).expect("Can't fail");
655
656        let endpoint_next = endpoint_next.clone();
657
658        trace!(target: LOG_NET_IROH, %node_id, "Creating new next connection");
659        let conn = match node_addr.clone() {
660            Some(node_addr) => {
661                trace!(target: LOG_NET_IROH, %node_id, "Using a connectivity override for connection");
662                let node_addr = node_addr_stable_to_next(&node_addr);
663                let conn = endpoint_next
664                    .connect(node_addr.clone(), FEDIMINT_API_ALPN)
665                    .await;
666
667                #[cfg(not(target_family = "wasm"))]
668                if let Ok(conn) = &conn {
669                    Self::spawn_connection_monitoring_next(
670                        conn,
671                        node_addr.id,
672                        self.path_change.clone(),
673                    );
674                }
675
676                conn
677            }
678            None => endpoint_next.connect(
679                next_node_id,
680                FEDIMINT_API_ALPN
681            ).await,
682        }
683        .map_err(|err| ServerError::Connection(err.into()))?;
684
685        // Retain the connection so `connectivity` can read its paths back;
686        // iroh 1.0 offers no endpoint-level lookup to recover it from.
687        self.next_connections
688            .lock()
689            .expect("Next connection mutex is never held across a panic")
690            .insert(node_id, conn.clone());
691
692        Ok(conn)
693    }
694}
695
696/// QUIC transport config with explicit idle timeout and keep-alive
697/// for the stable iroh endpoint.
698fn quic_transport_config() -> iroh::endpoint::TransportConfig {
699    let mut config = iroh::endpoint::TransportConfig::default();
700    config.max_idle_timeout(Some(
701        IROH_IDLE_TIMEOUT
702            .try_into()
703            .expect("idle timeout fits in IdleTimeout"),
704    ));
705    config.keep_alive_interval(Some(IROH_KEEP_ALIVE_INTERVAL));
706    config
707}
708
709/// QUIC transport config with explicit idle timeout and keep-alive
710/// for the next iroh endpoint.
711fn quic_transport_config_next() -> iroh_next::endpoint::QuicTransportConfig {
712    iroh_next::endpoint::QuicTransportConfig::builder()
713        .max_idle_timeout(Some(
714            IROH_IDLE_TIMEOUT
715                .try_into()
716                .expect("idle timeout fits in IdleTimeout"),
717        ))
718        .keep_alive_interval(IROH_KEEP_ALIVE_INTERVAL)
719        .build()
720}
721
722fn connectivity_from_iroh_conn_type(conn_type: &iroh::endpoint::ConnectionType) -> Connectivity {
723    match conn_type {
724        iroh::endpoint::ConnectionType::Direct(_) => Connectivity::Direct,
725        iroh::endpoint::ConnectionType::Relay(_) => Connectivity::Relay,
726        iroh::endpoint::ConnectionType::Mixed(..) => Connectivity::Mixed,
727        iroh::endpoint::ConnectionType::None => Connectivity::Unknown,
728    }
729}
730
731fn node_addr_stable_to_next(stable: &iroh::NodeAddr) -> iroh_next::EndpointAddr {
732    let next_node_id =
733        iroh_next::EndpointId::from_bytes(stable.node_id.as_bytes()).expect("Can't fail");
734    let relay_addrs = stable.relay_url.iter().map(|u| {
735        iroh_next::TransportAddr::Relay(
736            iroh_next::RelayUrl::from_str(&u.to_string()).expect("Can't fail"),
737        )
738    });
739    let direct_addrs = stable
740        .direct_addresses
741        .iter()
742        .copied()
743        .map(iroh_next::TransportAddr::Ip);
744
745    iroh_next::EndpointAddr::from_parts(next_node_id, relay_addrs.chain(direct_addrs))
746}
747
748#[apply(async_trait_maybe_send!)]
749impl IConnection for Connection {
750    async fn await_disconnection(&self) {
751        self.closed().await;
752    }
753
754    fn is_connected(&self) -> bool {
755        self.close_reason().is_none()
756    }
757}
758
759#[async_trait]
760impl IGuardianConnection for Connection {
761    async fn request(&self, method: ApiMethod, request: ApiRequestErased) -> ServerResult<Value> {
762        let timeout = request_timeout_for_method(&method);
763        let method_str = method.to_string();
764        let json = serde_json::to_vec(&IrohApiRequest { method, request })
765            .expect("Serialization to vec can't fail");
766
767        let result = fedimint_core::runtime::timeout(timeout, async {
768            let (mut sink, mut stream) = self
769                .open_bi()
770                .await
771                .map_err(|e| ServerError::Transport(e.into()))?;
772
773            sink.write_all(&json)
774                .await
775                .map_err(|e| ServerError::Transport(e.into()))?;
776
777            sink.finish()
778                .map_err(|e| ServerError::Transport(e.into()))?;
779
780            stream
781                .read_to_end(IROH_MAX_RESPONSE_BYTES)
782                .await
783                .map_err(|e| ServerError::Transport(e.into()))
784        })
785        .await;
786
787        let response = match result {
788            Ok(Ok(bytes)) => bytes,
789            Ok(Err(err)) => return Err(err),
790            Err(_) => {
791                // The bi-stream stalled past our budget. Close the QUIC
792                // connection so [`Self::is_connected`] (which reads
793                // `close_reason`) starts returning false; the connection
794                // pool's `get_or_init_pool_entry` will then evict this
795                // entry on the next access and the upstream retry loop
796                // will get a fresh connection.
797                warn!(
798                    target: LOG_NET_IROH,
799                    method = %method_str,
800                    timeout_secs = timeout.as_secs(),
801                    "iroh request timed out, closing connection",
802                );
803                self.close(
804                    iroh::endpoint::VarInt::from_u32(IROH_REQUEST_TIMEOUT_ERROR_CODE),
805                    IROH_REQUEST_TIMEOUT_ERROR_REASON,
806                );
807                return Err(ServerError::Transport(
808                    format!("iroh request {method_str} timed out after {timeout:?}").into(),
809                ));
810            }
811        };
812
813        // TODO: We should not be serializing Results on the wire
814        let response = serde_json::from_slice::<Result<Value, ApiError>>(&response)
815            .map_err(|e| ServerError::InvalidResponse(e.fmt_compact().to_string()))?;
816
817        response.map_err(|e| ServerError::InvalidResponse(format!("Api Error: {e:?}")))
818    }
819}
820
821#[apply(async_trait_maybe_send!)]
822impl IConnection for iroh_next::endpoint::Connection {
823    async fn await_disconnection(&self) {
824        self.closed().await;
825    }
826
827    fn is_connected(&self) -> bool {
828        self.close_reason().is_none()
829    }
830}
831
832#[async_trait]
833impl IGuardianConnection for iroh_next::endpoint::Connection {
834    async fn request(&self, method: ApiMethod, request: ApiRequestErased) -> ServerResult<Value> {
835        let timeout = request_timeout_for_method(&method);
836        let method_str = method.to_string();
837        let json = serde_json::to_vec(&IrohApiRequest { method, request })
838            .expect("Serialization to vec can't fail");
839
840        let result = fedimint_core::runtime::timeout(timeout, async {
841            let (mut sink, mut stream) = self
842                .open_bi()
843                .await
844                .map_err(|e| ServerError::Transport(e.into()))?;
845
846            sink.write_all(&json)
847                .await
848                .map_err(|e| ServerError::Transport(e.into()))?;
849
850            sink.finish()
851                .map_err(|e| ServerError::Transport(e.into()))?;
852
853            stream
854                .read_to_end(IROH_MAX_RESPONSE_BYTES)
855                .await
856                .map_err(|e| ServerError::Transport(e.into()))
857        })
858        .await;
859
860        let response = match result {
861            Ok(Ok(bytes)) => bytes,
862            Ok(Err(err)) => return Err(err),
863            Err(_) => {
864                warn!(
865                    target: LOG_NET_IROH,
866                    method = %method_str,
867                    timeout_secs = timeout.as_secs(),
868                    "iroh request timed out, closing connection",
869                );
870                self.close(
871                    iroh_next::endpoint::VarInt::from_u32(IROH_REQUEST_TIMEOUT_ERROR_CODE),
872                    IROH_REQUEST_TIMEOUT_ERROR_REASON,
873                );
874                return Err(ServerError::Transport(
875                    format!("iroh request {method_str} timed out after {timeout:?}").into(),
876                ));
877            }
878        };
879
880        // TODO: We should not be serializing Results on the wire
881        let response = serde_json::from_slice::<Result<Value, ApiError>>(&response)
882            .map_err(|e| ServerError::InvalidResponse(e.fmt_compact().to_string()))?;
883
884        response.map_err(|e| ServerError::InvalidResponse(format!("Api Error: {e:?}")))
885    }
886}
887
888#[apply(async_trait_maybe_send!)]
889impl IGatewayConnection for Connection {
890    async fn request(
891        &self,
892        password: Option<String>,
893        _method: Method,
894        route: &str,
895        payload: Option<Value>,
896    ) -> ServerResult<Value> {
897        let iroh_request = IrohGatewayRequest {
898            route: route.to_string(),
899            params: payload,
900            password,
901        };
902        let json = serde_json::to_vec(&iroh_request).expect("serialization cant fail");
903
904        let (mut sink, mut stream) = self
905            .open_bi()
906            .await
907            .map_err(|e| ServerError::Transport(e.into()))?;
908
909        sink.write_all(&json)
910            .await
911            .map_err(|e| ServerError::Transport(e.into()))?;
912
913        sink.finish()
914            .map_err(|e| ServerError::Transport(e.into()))?;
915
916        let response = stream
917            .read_to_end(IROH_MAX_RESPONSE_BYTES)
918            .await
919            .map_err(|e| ServerError::Transport(e.into()))?;
920
921        let response = serde_json::from_slice::<IrohGatewayResponse>(&response)
922            .map_err(|e| ServerError::InvalidResponse(e.fmt_compact().to_string()))?;
923        match StatusCode::from_u16(response.status)
924            .map_err(|e| ServerError::InvalidResponse(format!("Invalid status code: {e}")))?
925        {
926            StatusCode::OK => Ok(response.body),
927            status => Err(ServerError::ServerError(format!(
928                "Server returned status code: {status}"
929            ))),
930        }
931    }
932}
933
934#[cfg(test)]
935mod tests {
936    use std::str::FromStr as _;
937
938    use fedimint_core::PeerId;
939    use fedimint_core::config::FederationId;
940    use fedimint_core::invite_code::InviteCode;
941    use fedimint_core::module::ApiMethod;
942    use fedimint_core::util::SafeUrl;
943
944    use super::{
945        IROH_REQUEST_TIMEOUT_DEFAULT, IROH_REQUEST_TIMEOUT_LONG_POLL, IrohConnector,
946        request_timeout_for_method,
947    };
948    use crate::error::ConnectorError;
949    use crate::{iroh_next_endpoint_url, is_iroh_next_endpoint_url, preserve_iroh_next_marker};
950
951    const TEST_ENDPOINT_ID: &str =
952        "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c";
953
954    #[test]
955    fn advertised_iroh_next_url_selects_only_the_next_stack() {
956        let next_url = iroh_next_endpoint_url(TEST_ENDPOINT_ID).expect("valid endpoint ID");
957        assert!(is_iroh_next_endpoint_url(&next_url).expect("valid Iroh API URL path"));
958
959        let invite = InviteCode::new(next_url, PeerId::from(0), FederationId::dummy(), None);
960        let round_tripped =
961            InviteCode::from_str(&invite.to_string()).expect("invite code round-trips");
962        assert!(is_iroh_next_endpoint_url(&round_tripped.url()).expect("valid Iroh API URL path"));
963
964        let stable_url =
965            SafeUrl::parse(&format!("iroh://{TEST_ENDPOINT_ID}")).expect("valid Iroh URL");
966        assert!(!is_iroh_next_endpoint_url(&stable_url).expect("valid Iroh API URL path"));
967
968        let replacement = SafeUrl::parse(
969            "iroh://d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
970        )
971        .expect("valid replacement URL");
972        let replacement = preserve_iroh_next_marker(&round_tripped.url(), &replacement);
973        assert!(is_iroh_next_endpoint_url(&replacement).expect("valid Iroh API URL path"));
974    }
975
976    #[test]
977    fn unsupported_iroh_url_path_is_typed() {
978        let url = SafeUrl::parse("iroh://someendpoint/v2").expect("valid url");
979        assert!(
980            matches!(
981                is_iroh_next_endpoint_url(&url),
982                Err(ConnectorError::UnsupportedUrlPath { .. })
983            ),
984            "{:?}",
985            is_iroh_next_endpoint_url(&url)
986        );
987    }
988
989    #[test]
990    fn garbage_endpoint_id_is_an_invalid_node_id() {
991        let err = iroh_next_endpoint_url("not-an-endpoint-id")
992            .expect_err("garbage is not an endpoint id");
993        assert!(
994            matches!(err, ConnectorError::InvalidNodeId { .. }),
995            "{err:?}"
996        );
997    }
998
999    #[test]
1000    fn a_non_iroh_url_has_an_unsupported_scheme() {
1001        let url = SafeUrl::parse("ws://example.com").expect("valid url");
1002        let err = IrohConnector::node_id_from_url(&url).expect_err("ws is not iroh");
1003        assert!(
1004            matches!(err, ConnectorError::UnsupportedScheme { .. }),
1005            "{err:?}"
1006        );
1007    }
1008
1009    /// Every `await_*` endpoint currently exposed by fedimint modules
1010    /// should be classified as long-poll. If a new endpoint is added
1011    /// without the prefix it will silently fall through to the default
1012    /// 60s budget — this list documents the contract and will surface
1013    /// renames as test churn.
1014    const AWAIT_ENDPOINTS: &[&str] = &[
1015        // fedimint-core
1016        "await_output_outcome",
1017        "await_outputs_outcomes",
1018        "await_session_outcome",
1019        "await_signed_session_outcome",
1020        "await_transaction",
1021        // fedimint-ln-common
1022        "await_account",
1023        "await_block_height",
1024        "await_offer",
1025        "await_outgoing_contract_cancelled",
1026        "await_preimage_decryption",
1027        // fedimint-lnv2-common
1028        "await_incoming_contract",
1029        "await_incoming_contracts",
1030        "await_preimage",
1031    ];
1032
1033    /// A representative sample of prompt endpoints — anything that is
1034    /// expected to respond without server-side blocking.
1035    const PROMPT_ENDPOINTS: &[&str] = &[
1036        "block_count",
1037        "session_count",
1038        "session_status",
1039        "status",
1040        "version",
1041        "client_config",
1042        "audit",
1043        "account",
1044        "offer",
1045        "list_gateways",
1046        "submit_transaction",
1047        "consensus_block_count",
1048    ];
1049
1050    #[test]
1051    fn await_prefix_gets_long_poll_timeout() {
1052        for name in AWAIT_ENDPOINTS {
1053            assert_eq!(
1054                request_timeout_for_method(&ApiMethod::Core((*name).to_owned())),
1055                IROH_REQUEST_TIMEOUT_LONG_POLL,
1056                "core endpoint {name} should map to the long-poll timeout"
1057            );
1058            assert_eq!(
1059                request_timeout_for_method(&ApiMethod::Module(0, (*name).to_owned())),
1060                IROH_REQUEST_TIMEOUT_LONG_POLL,
1061                "module endpoint {name} should map to the long-poll timeout"
1062            );
1063        }
1064    }
1065
1066    #[test]
1067    fn wait_prefix_also_gets_long_poll_timeout() {
1068        // No fedimint endpoint currently uses this prefix, but the
1069        // selector accepts it so future additions following the
1070        // alternate naming convention don't silently get the default.
1071        assert_eq!(
1072            request_timeout_for_method(&ApiMethod::Core("wait_for_event".to_owned())),
1073            IROH_REQUEST_TIMEOUT_LONG_POLL,
1074        );
1075    }
1076
1077    #[test]
1078    fn prompt_endpoints_get_default_timeout() {
1079        for name in PROMPT_ENDPOINTS {
1080            assert_eq!(
1081                request_timeout_for_method(&ApiMethod::Core((*name).to_owned())),
1082                IROH_REQUEST_TIMEOUT_DEFAULT,
1083                "endpoint {name} should map to the default timeout"
1084            );
1085        }
1086    }
1087
1088    #[test]
1089    fn endpoints_that_merely_contain_await_are_not_misclassified() {
1090        // The selector is prefix-based, so an endpoint name with
1091        // "await" elsewhere in the string must not get the long
1092        // budget by accident.
1093        assert_eq!(
1094            request_timeout_for_method(&ApiMethod::Core("submit_await_thing".to_owned())),
1095            IROH_REQUEST_TIMEOUT_DEFAULT,
1096        );
1097    }
1098}