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 anyhow::{Context, bail};
10use async_trait::async_trait;
11use fedimint_core::config::ALEPH_BFT_UNIT_BYTE_LIMIT;
12use fedimint_core::envs::{
13    FM_GW_IROH_CONNECT_OVERRIDES_PLAIN_ENV, FM_IROH_CONNECT_OVERRIDES_PLAIN_ENV,
14    FM_IROH_N0_DISCOVERY_ENABLE_ENV, FM_IROH_PKARR_RESOLVER_ENABLE_ENV, is_env_var_set_opt,
15    parse_kv_list_from_env,
16};
17use fedimint_core::module::{
18    ApiError, ApiMethod, ApiRequestErased, FEDIMINT_API_ALPN, FEDIMINT_GATEWAY_ALPN,
19    IrohApiRequest, IrohGatewayRequest, IrohGatewayResponse,
20};
21use fedimint_core::net::iroh::{IROH_IDLE_TIMEOUT, IROH_KEEP_ALIVE_INTERVAL};
22
23/// The maximum number of bytes we are willing to buffer when reading an API
24/// response from an iroh QUIC stream. This must be large enough to accommodate
25/// the largest possible signed session outcome. A session can contain up to
26/// `broadcast_rounds_per_session` (default 3600) rounds, each peer produces one
27/// unit per round, and each unit can be up to `ALEPH_BFT_UNIT_BYTE_LIMIT`
28/// bytes. The response is JSON-serialized which hex-encodes the consensus
29/// bytes, roughly doubling the size. We use 2x the raw max as a conservative
30/// upper bound. For a 4-peer federation this is ~1.44 GB.
31const IROH_MAX_RESPONSE_BYTES: usize = ALEPH_BFT_UNIT_BYTE_LIMIT * 3600 * 4 * 2;
32
33/// Wall-clock budget for a single iroh API request to make it through the QUIC
34/// bi-stream (open + write + finish + read response). If exceeded we close the
35/// underlying [`Connection`], which causes [`IConnection::is_connected`] to
36/// return false on the next pool lookup so a fresh connection is established
37/// for the retry. Used for endpoints that respond promptly (`block_count`,
38/// `status`, etc).
39const IROH_REQUEST_TIMEOUT_DEFAULT: Duration = Duration::from_secs(60);
40
41/// Wall-clock budget for an iroh API request to a server-side long-poll
42/// endpoint (`await_*` / `wait_*`). These wait on the server until an event
43/// fires (block height reached, contract cancelled, etc.) before responding,
44/// so they need a generous bound. Set well above realistic mainnet block
45/// intervals; if a long-poll legitimately needs longer than this the upstream
46/// `request_current_consensus_retry` loop will reconnect and retry.
47const IROH_REQUEST_TIMEOUT_LONG_POLL: Duration = Duration::from_secs(60 * 60);
48
49/// Application-level QUIC error code we use when closing a [`Connection`]
50/// after a request timeout. Recorded by the peer as the close reason; chosen
51/// arbitrarily but stable across stable and `iroh_next` impls so the two
52/// emit identical telemetry. The value 1 distinguishes us from a graceful
53/// close (0).
54const IROH_REQUEST_TIMEOUT_ERROR_CODE: u32 = 1;
55const IROH_REQUEST_TIMEOUT_ERROR_REASON: &[u8] = b"request timeout";
56
57/// Request timeout strategy: long-poll endpoints (`await_*` / `wait_*`)
58/// get the long bound, everything else gets the default. The string match
59/// is a heuristic; it covers all currently-defined fedimint long-poll
60/// endpoints and stays correct if new ones follow the existing naming
61/// convention. False positives (a non-long-poll endpoint that happens to
62/// match the prefix) just give that one method a longer leash; the worse
63/// case is a false negative — a long-poll method that doesn't match
64/// either prefix would get the 60s default and fail fast on legitimate
65/// waits, but the upstream retry loop would reconnect and try again.
66fn request_timeout_for_method(method: &ApiMethod) -> Duration {
67    let name = match method {
68        ApiMethod::Core(name) => name.as_str(),
69        ApiMethod::Module(_, name) => name.as_str(),
70    };
71    if name.starts_with("await_") || name.starts_with("wait_") {
72        IROH_REQUEST_TIMEOUT_LONG_POLL
73    } else {
74        IROH_REQUEST_TIMEOUT_DEFAULT
75    }
76}
77use fedimint_core::task::spawn;
78use fedimint_core::util::{FmtCompact as _, SafeUrl};
79use fedimint_core::{apply, async_trait_maybe_send};
80use fedimint_logging::LOG_NET_IROH;
81use futures::Future;
82use futures::stream::{FuturesUnordered, StreamExt};
83use iroh::discovery::pkarr::PkarrResolver;
84use iroh::endpoint::Connection;
85use iroh::{Endpoint, NodeAddr, NodeId, PublicKey};
86use reqwest::{Method, StatusCode};
87use serde_json::Value;
88use tokio::sync::watch;
89use tracing::{debug, trace, warn};
90
91use super::{DynGuaridianConnection, IGuardianConnection, ServerError, ServerResult};
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 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 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 fn with_connection_override(mut self, node: NodeId, addr: NodeAddr) -> Self {
280        self.connection_overrides.insert(node, addr);
281        self
282    }
283
284    pub fn node_id_from_url(url: &SafeUrl) -> anyhow::Result<NodeId> {
285        if url.scheme() != "iroh" {
286            bail!(
287                "Unsupported scheme: {}, passed to iroh endpoint handler",
288                url.scheme()
289            );
290        }
291        let host = url.host_str().context("Missing host string in Iroh URL")?;
292
293        let node_id = PublicKey::from_str(host).context("Failed to parse node id")?;
294
295        Ok(node_id)
296    }
297}
298
299#[async_trait::async_trait]
300impl crate::Connector for IrohConnector {
301    async fn connect_guardian(
302        &self,
303        url: &SafeUrl,
304        api_secret: Option<&str>,
305    ) -> ServerResult<DynGuaridianConnection> {
306        if api_secret.is_some() {
307            // There seem to be no way to pass secret over current Iroh calling
308            // convention. Connecting anyway would silently drop the credential and
309            // talk to an API that never authenticates us, so refuse instead.
310            return Err(ServerError::Connection(anyhow::format_err!(
311                "Iroh api secrets currently not supported"
312            )));
313        }
314        let node_id =
315            Self::node_id_from_url(url).map_err(|source| ServerError::InvalidPeerUrl {
316                source,
317                url: url.to_owned(),
318            })?;
319        let next_only = crate::is_iroh_next_endpoint_url(url).map_err(|source| {
320            ServerError::InvalidPeerUrl {
321                source,
322                url: url.to_owned(),
323            }
324        })?;
325        let mut futures = FuturesUnordered::<
326            Pin<
327                Box<
328                    dyn Future<Output = (ServerResult<DynGuaridianConnection>, &'static str)>
329                        + Send,
330                >,
331            >,
332        >::new();
333        let connection_override = self.connection_overrides.get(&node_id).cloned();
334
335        // Advertised Iroh 1.0 identities carry an internal `/v1` marker so we
336        // avoid attempting the incompatible 0.35 stack for safety and efficiency.
337        if next_only {
338            return self
339                .make_new_connection_next(&self.next, node_id, connection_override)
340                .await
341                .map(super::IGuardianConnection::into_dyn);
342        }
343
344        let self_clone = self.clone();
345        futures.push(Box::pin({
346            let connection_override = connection_override.clone();
347            async move {
348                (
349                    self_clone
350                        .make_new_connection_stable(node_id, connection_override)
351                        .await
352                        .map(super::IGuardianConnection::into_dyn),
353                    "stable",
354                )
355            }
356        }));
357
358        let self_clone = self.clone();
359        let endpoint_next = self.next.clone();
360        futures.push(Box::pin(async move {
361            (
362                self_clone
363                    .make_new_connection_next(&endpoint_next, node_id, connection_override)
364                    .await
365                    .map(super::IGuardianConnection::into_dyn),
366                "next",
367            )
368        }));
369
370        // Remember last error, so we have something to return if
371        // neither connection works.
372        let mut prev_err = None;
373
374        // Loop until first success, or running out of connections.
375        while let Some((result, iroh_stack)) = futures.next().await {
376            match result {
377                Ok(connection) => return Ok(connection),
378                Err(err) => {
379                    warn!(
380                        target: LOG_NET_IROH,
381                        err = %err.fmt_compact(),
382                        %iroh_stack,
383                        "Join error in iroh connection task"
384                    );
385                    prev_err = Some(err);
386                }
387            }
388        }
389
390        Err(prev_err.unwrap_or_else(|| {
391            ServerError::ServerError(anyhow::anyhow!("Both iroh connection attempts failed"))
392        }))
393    }
394
395    async fn connect_gateway(&self, url: &SafeUrl) -> anyhow::Result<DynGatewayConnection> {
396        let node_id = Self::node_id_from_url(url)?;
397        if let Some(node_addr) = self.connection_overrides.get(&node_id).cloned() {
398            let conn = self
399                .stable
400                .connect(node_addr.clone(), FEDIMINT_GATEWAY_ALPN)
401                .await?;
402
403            #[cfg(not(target_family = "wasm"))]
404            Self::spawn_connection_monitoring_stable(
405                &self.stable,
406                node_id,
407                self.path_change.clone(),
408            );
409
410            Ok(IGatewayConnection::into_dyn(conn))
411        } else {
412            let conn = self.stable.connect(node_id, FEDIMINT_GATEWAY_ALPN).await?;
413            Ok(IGatewayConnection::into_dyn(conn))
414        }
415    }
416
417    fn connectivity(&self, url: &SafeUrl) -> Connectivity {
418        let Ok(node_id) = Self::node_id_from_url(url) else {
419            return Connectivity::Unknown;
420        };
421
422        // The next stack is consulted first: a peer reached over an advertised
423        // Iroh 1.0 endpoint never has a stable connection to report, and when
424        // both stacks were raced the next connection is the live one. Falling
425        // through to the stable endpoint in that case would report
426        // `Unknown`, which callers render as a disconnected peer.
427        if let Some(connectivity) = self.connectivity_next(node_id) {
428            return connectivity;
429        }
430
431        let Ok(watcher) = self.stable.conn_type(node_id) else {
432            return Connectivity::Unknown;
433        };
434        match watcher.get() {
435            Ok(iroh::endpoint::ConnectionType::Direct(_)) => Connectivity::Direct,
436            Ok(iroh::endpoint::ConnectionType::Relay(_)) => Connectivity::Relay,
437            Ok(iroh::endpoint::ConnectionType::Mixed(..)) => Connectivity::Mixed,
438            Ok(iroh::endpoint::ConnectionType::None) | Err(_) => Connectivity::Unknown,
439        }
440    }
441
442    async fn iroh_peer_info(
443        &self,
444        url: &SafeUrl,
445        path_timeout: Duration,
446    ) -> ServerResult<Option<IrohPeerInfo>> {
447        let node_id =
448            Self::node_id_from_url(url).map_err(|source| ServerError::InvalidPeerUrl {
449                source,
450                url: url.to_owned(),
451            })?;
452        let connection_override = self.connection_overrides.get(&node_id).cloned();
453        let _connection = self
454            .make_new_connection_stable(node_id, connection_override)
455            .await?;
456
457        let mut conn_type_watcher = self
458            .stable
459            .conn_type(node_id)
460            .map_err(ServerError::Connection)?;
461        let mut conn_type = conn_type_watcher
462            .get()
463            .unwrap_or(iroh::endpoint::ConnectionType::None);
464
465        if path_timeout > Duration::ZERO {
466            let timeout = fedimint_core::runtime::sleep(path_timeout);
467            tokio::pin!(timeout);
468
469            while !matches!(
470                conn_type,
471                iroh::endpoint::ConnectionType::Direct(_)
472                    | iroh::endpoint::ConnectionType::Mixed(..)
473            ) {
474                tokio::select! {
475                    () = &mut timeout => break,
476                    updated = conn_type_watcher.updated() => {
477                        match updated {
478                            Ok(updated) => conn_type = updated,
479                            Err(_) => break,
480                        }
481                    }
482                }
483            }
484        }
485
486        Ok(Some(self.iroh_peer_info_from_conn_type(node_id, conn_type)))
487    }
488}
489
490impl IrohConnector {
491    /// Report how a retained next-stack connection is reaching `node_id`.
492    ///
493    /// Returns `None` when the next stack has nothing to say about the peer,
494    /// so the caller can fall back to the stable stack: no connection held,
495    /// one that has since closed, or one whose paths cannot be classified.
496    /// None of those mean "disconnected", which is what answering
497    /// [`Connectivity::Unknown`] would tell callers.
498    fn connectivity_next(&self, node_id: NodeId) -> Option<Connectivity> {
499        let connections = self
500            .next_connections
501            .lock()
502            .expect("Next connection mutex is never held across a panic");
503
504        let connection = connections.get(&node_id)?;
505
506        if connection.close_reason().is_some() {
507            return None;
508        }
509
510        // A path carries application data over either an IP or a relay
511        // address, so holding one of each is the next-stack spelling of the
512        // stable stack's `Mixed`.
513        let paths = connection.paths();
514        let direct = paths.iter().any(|path| path.is_ip());
515        let relay = paths.iter().any(|path| path.is_relay());
516
517        Some(match (direct, relay) {
518            (true, true) => Connectivity::Mixed,
519            (true, false) => Connectivity::Direct,
520            (false, true) => Connectivity::Relay,
521            // Nothing to classify. Fall back to the stable stack rather than
522            // answering `Unknown`, which callers render as a disconnected
523            // peer — the very thing this lookup exists to avoid.
524            (false, false) => return None,
525        })
526    }
527
528    fn iroh_peer_info_from_conn_type(
529        &self,
530        node_id: NodeId,
531        conn_type: iroh::endpoint::ConnectionType,
532    ) -> IrohPeerInfo {
533        let remote_info = self.stable.remote_info(node_id);
534
535        let direct_addr = match &conn_type {
536            iroh::endpoint::ConnectionType::Direct(addr)
537            | iroh::endpoint::ConnectionType::Mixed(addr, _) => Some(*addr),
538            iroh::endpoint::ConnectionType::Relay(_) | iroh::endpoint::ConnectionType::None => None,
539        };
540
541        let mut known_direct_addrs = remote_info
542            .as_ref()
543            .map(|info| {
544                info.addrs
545                    .iter()
546                    .map(|addr_info| addr_info.addr)
547                    .collect::<BTreeSet<_>>()
548            })
549            .unwrap_or_default();
550        if let Some(direct_addr) = direct_addr {
551            known_direct_addrs.insert(direct_addr);
552        }
553
554        let relay_url = match &conn_type {
555            iroh::endpoint::ConnectionType::Relay(relay_url)
556            | iroh::endpoint::ConnectionType::Mixed(_, relay_url) => Some(relay_url.to_string()),
557            iroh::endpoint::ConnectionType::Direct(_) | iroh::endpoint::ConnectionType::None => {
558                remote_info.and_then(|info| info.relay_url.map(|relay| relay.relay_url.to_string()))
559            }
560        };
561
562        IrohPeerInfo {
563            node_id: node_id.to_string(),
564            connectivity: connectivity_from_iroh_conn_type(&conn_type),
565            direct_addr,
566            known_direct_addrs: known_direct_addrs.into_iter().collect(),
567            relay_url,
568        }
569    }
570
571    #[cfg(not(target_family = "wasm"))]
572    fn spawn_connection_monitoring_stable(
573        endpoint: &Endpoint,
574        node_id: NodeId,
575        path_change: Arc<watch::Sender<u64>>,
576    ) {
577        if let Ok(mut conn_type_watcher) = endpoint.conn_type(node_id) {
578            #[allow(clippy::let_underscore_future)]
579            let _ = spawn("iroh connection (stable)", async move {
580                if let Ok(conn_type) = conn_type_watcher.get() {
581                    debug!(target: LOG_NET_IROH, %node_id, type = %conn_type, "Connection type (initial)");
582                }
583                while let Ok(event) = conn_type_watcher.updated().await {
584                    debug!(target: LOG_NET_IROH, %node_id, type = %event, "Connection type (changed)");
585                    path_change.send_modify(|c| *c = c.wrapping_add(1));
586                }
587            });
588        }
589    }
590
591    #[cfg(not(target_family = "wasm"))]
592    fn spawn_connection_monitoring_next(
593        conn: &iroh_next::endpoint::Connection,
594        node_id: iroh_next::EndpointId,
595        path_change: Arc<watch::Sender<u64>>,
596    ) {
597        let conn = conn.clone();
598        #[allow(clippy::let_underscore_future)]
599        let _ = spawn("iroh connection (next)", async move {
600            let mut paths = conn.paths_stream();
601            if let Some(paths) = paths.next().await {
602                debug!(target: LOG_NET_IROH, %node_id, ?paths, "Connection paths (initial)");
603            }
604            while let Some(paths) = paths.next().await {
605                debug!(target: LOG_NET_IROH, %node_id, ?paths, "Connection paths changed");
606                path_change.send_modify(|c| *c = c.wrapping_add(1));
607            }
608        });
609    }
610
611    async fn make_new_connection_stable(
612        &self,
613        node_id: NodeId,
614        node_addr: Option<NodeAddr>,
615    ) -> ServerResult<Connection> {
616        trace!(target: LOG_NET_IROH, %node_id, "Creating new stable connection");
617        let conn = match node_addr.clone() {
618            Some(node_addr) => {
619                trace!(target: LOG_NET_IROH, %node_id, "Using a connectivity override for connection");
620                let conn = self.stable
621                    .connect(node_addr.clone(), FEDIMINT_API_ALPN)
622                    .await;
623
624                #[cfg(not(target_family = "wasm"))]
625                if conn.is_ok() {
626                    Self::spawn_connection_monitoring_stable(
627                        &self.stable,
628                        node_id,
629                        self.path_change.clone(),
630                    );
631                }
632                conn
633            }
634            None => self.stable.connect(node_id, FEDIMINT_API_ALPN).await,
635        }.map_err(ServerError::Connection)?;
636
637        Ok(conn)
638    }
639
640    async fn make_new_connection_next(
641        &self,
642        endpoint_next: &iroh_next::Endpoint,
643        node_id: NodeId,
644        node_addr: Option<NodeAddr>,
645    ) -> ServerResult<iroh_next::endpoint::Connection> {
646        let next_node_id =
647            iroh_next::EndpointId::from_bytes(node_id.as_bytes()).expect("Can't fail");
648
649        let endpoint_next = endpoint_next.clone();
650
651        trace!(target: LOG_NET_IROH, %node_id, "Creating new next connection");
652        let conn = match node_addr.clone() {
653            Some(node_addr) => {
654                trace!(target: LOG_NET_IROH, %node_id, "Using a connectivity override for connection");
655                let node_addr = node_addr_stable_to_next(&node_addr);
656                let conn = endpoint_next
657                    .connect(node_addr.clone(), FEDIMINT_API_ALPN)
658                    .await;
659
660                #[cfg(not(target_family = "wasm"))]
661                if let Ok(conn) = &conn {
662                    Self::spawn_connection_monitoring_next(
663                        conn,
664                        node_addr.id,
665                        self.path_change.clone(),
666                    );
667                }
668
669                conn
670            }
671            None => endpoint_next.connect(
672                next_node_id,
673                FEDIMINT_API_ALPN
674            ).await,
675        }
676        .map_err(Into::into)
677        .map_err(ServerError::Connection)?;
678
679        // Retain the connection so `connectivity` can read its paths back;
680        // iroh 1.0 offers no endpoint-level lookup to recover it from.
681        self.next_connections
682            .lock()
683            .expect("Next connection mutex is never held across a panic")
684            .insert(node_id, conn.clone());
685
686        Ok(conn)
687    }
688}
689
690/// QUIC transport config with explicit idle timeout and keep-alive
691/// for the stable iroh endpoint.
692fn quic_transport_config() -> iroh::endpoint::TransportConfig {
693    let mut config = iroh::endpoint::TransportConfig::default();
694    config.max_idle_timeout(Some(
695        IROH_IDLE_TIMEOUT
696            .try_into()
697            .expect("idle timeout fits in IdleTimeout"),
698    ));
699    config.keep_alive_interval(Some(IROH_KEEP_ALIVE_INTERVAL));
700    config
701}
702
703/// QUIC transport config with explicit idle timeout and keep-alive
704/// for the next iroh endpoint.
705fn quic_transport_config_next() -> iroh_next::endpoint::QuicTransportConfig {
706    iroh_next::endpoint::QuicTransportConfig::builder()
707        .max_idle_timeout(Some(
708            IROH_IDLE_TIMEOUT
709                .try_into()
710                .expect("idle timeout fits in IdleTimeout"),
711        ))
712        .keep_alive_interval(IROH_KEEP_ALIVE_INTERVAL)
713        .build()
714}
715
716fn connectivity_from_iroh_conn_type(conn_type: &iroh::endpoint::ConnectionType) -> Connectivity {
717    match conn_type {
718        iroh::endpoint::ConnectionType::Direct(_) => Connectivity::Direct,
719        iroh::endpoint::ConnectionType::Relay(_) => Connectivity::Relay,
720        iroh::endpoint::ConnectionType::Mixed(..) => Connectivity::Mixed,
721        iroh::endpoint::ConnectionType::None => Connectivity::Unknown,
722    }
723}
724
725fn node_addr_stable_to_next(stable: &iroh::NodeAddr) -> iroh_next::EndpointAddr {
726    let next_node_id =
727        iroh_next::EndpointId::from_bytes(stable.node_id.as_bytes()).expect("Can't fail");
728    let relay_addrs = stable.relay_url.iter().map(|u| {
729        iroh_next::TransportAddr::Relay(
730            iroh_next::RelayUrl::from_str(&u.to_string()).expect("Can't fail"),
731        )
732    });
733    let direct_addrs = stable
734        .direct_addresses
735        .iter()
736        .copied()
737        .map(iroh_next::TransportAddr::Ip);
738
739    iroh_next::EndpointAddr::from_parts(next_node_id, relay_addrs.chain(direct_addrs))
740}
741
742#[apply(async_trait_maybe_send!)]
743impl IConnection for Connection {
744    async fn await_disconnection(&self) {
745        self.closed().await;
746    }
747
748    fn is_connected(&self) -> bool {
749        self.close_reason().is_none()
750    }
751}
752
753#[async_trait]
754impl IGuardianConnection for Connection {
755    async fn request(&self, method: ApiMethod, request: ApiRequestErased) -> ServerResult<Value> {
756        let timeout = request_timeout_for_method(&method);
757        let method_str = method.to_string();
758        let json = serde_json::to_vec(&IrohApiRequest { method, request })
759            .expect("Serialization to vec can't fail");
760
761        let result = fedimint_core::runtime::timeout(timeout, async {
762            let (mut sink, mut stream) = self
763                .open_bi()
764                .await
765                .map_err(|e| ServerError::Transport(e.into()))?;
766
767            sink.write_all(&json)
768                .await
769                .map_err(|e| ServerError::Transport(e.into()))?;
770
771            sink.finish()
772                .map_err(|e| ServerError::Transport(e.into()))?;
773
774            stream
775                .read_to_end(IROH_MAX_RESPONSE_BYTES)
776                .await
777                .map_err(|e| ServerError::Transport(e.into()))
778        })
779        .await;
780
781        let response = match result {
782            Ok(Ok(bytes)) => bytes,
783            Ok(Err(err)) => return Err(err),
784            Err(_) => {
785                // The bi-stream stalled past our budget. Close the QUIC
786                // connection so [`Self::is_connected`] (which reads
787                // `close_reason`) starts returning false; the connection
788                // pool's `get_or_init_pool_entry` will then evict this
789                // entry on the next access and the upstream retry loop
790                // will get a fresh connection.
791                warn!(
792                    target: LOG_NET_IROH,
793                    method = %method_str,
794                    timeout_secs = timeout.as_secs(),
795                    "iroh request timed out, closing connection",
796                );
797                self.close(
798                    iroh::endpoint::VarInt::from_u32(IROH_REQUEST_TIMEOUT_ERROR_CODE),
799                    IROH_REQUEST_TIMEOUT_ERROR_REASON,
800                );
801                return Err(ServerError::Transport(anyhow::anyhow!(
802                    "iroh request {method_str} timed out after {timeout:?}"
803                )));
804            }
805        };
806
807        // TODO: We should not be serializing Results on the wire
808        let response = serde_json::from_slice::<Result<Value, ApiError>>(&response)
809            .map_err(|e| ServerError::InvalidResponse(e.into()))?;
810
811        response.map_err(|e| ServerError::InvalidResponse(anyhow::anyhow!("Api Error: {:?}", e)))
812    }
813}
814
815#[apply(async_trait_maybe_send!)]
816impl IConnection for iroh_next::endpoint::Connection {
817    async fn await_disconnection(&self) {
818        self.closed().await;
819    }
820
821    fn is_connected(&self) -> bool {
822        self.close_reason().is_none()
823    }
824}
825
826#[async_trait]
827impl IGuardianConnection for iroh_next::endpoint::Connection {
828    async fn request(&self, method: ApiMethod, request: ApiRequestErased) -> ServerResult<Value> {
829        let timeout = request_timeout_for_method(&method);
830        let method_str = method.to_string();
831        let json = serde_json::to_vec(&IrohApiRequest { method, request })
832            .expect("Serialization to vec can't fail");
833
834        let result = fedimint_core::runtime::timeout(timeout, async {
835            let (mut sink, mut stream) = self
836                .open_bi()
837                .await
838                .map_err(|e| ServerError::Transport(e.into()))?;
839
840            sink.write_all(&json)
841                .await
842                .map_err(|e| ServerError::Transport(e.into()))?;
843
844            sink.finish()
845                .map_err(|e| ServerError::Transport(e.into()))?;
846
847            stream
848                .read_to_end(IROH_MAX_RESPONSE_BYTES)
849                .await
850                .map_err(|e| ServerError::Transport(e.into()))
851        })
852        .await;
853
854        let response = match result {
855            Ok(Ok(bytes)) => bytes,
856            Ok(Err(err)) => return Err(err),
857            Err(_) => {
858                warn!(
859                    target: LOG_NET_IROH,
860                    method = %method_str,
861                    timeout_secs = timeout.as_secs(),
862                    "iroh request timed out, closing connection",
863                );
864                self.close(
865                    iroh_next::endpoint::VarInt::from_u32(IROH_REQUEST_TIMEOUT_ERROR_CODE),
866                    IROH_REQUEST_TIMEOUT_ERROR_REASON,
867                );
868                return Err(ServerError::Transport(anyhow::anyhow!(
869                    "iroh request {method_str} timed out after {timeout:?}"
870                )));
871            }
872        };
873
874        // TODO: We should not be serializing Results on the wire
875        let response = serde_json::from_slice::<Result<Value, ApiError>>(&response)
876            .map_err(|e| ServerError::InvalidResponse(e.into()))?;
877
878        response.map_err(|e| ServerError::InvalidResponse(anyhow::anyhow!("Api Error: {:?}", e)))
879    }
880}
881
882#[apply(async_trait_maybe_send!)]
883impl IGatewayConnection for Connection {
884    async fn request(
885        &self,
886        password: Option<String>,
887        _method: Method,
888        route: &str,
889        payload: Option<Value>,
890    ) -> ServerResult<Value> {
891        let iroh_request = IrohGatewayRequest {
892            route: route.to_string(),
893            params: payload,
894            password,
895        };
896        let json = serde_json::to_vec(&iroh_request).expect("serialization cant fail");
897
898        let (mut sink, mut stream) = self
899            .open_bi()
900            .await
901            .map_err(|e| ServerError::Transport(e.into()))?;
902
903        sink.write_all(&json)
904            .await
905            .map_err(|e| ServerError::Transport(e.into()))?;
906
907        sink.finish()
908            .map_err(|e| ServerError::Transport(e.into()))?;
909
910        let response = stream
911            .read_to_end(IROH_MAX_RESPONSE_BYTES)
912            .await
913            .map_err(|e| ServerError::Transport(e.into()))?;
914
915        let response = serde_json::from_slice::<IrohGatewayResponse>(&response)
916            .map_err(|e| ServerError::InvalidResponse(e.into()))?;
917        match StatusCode::from_u16(response.status).map_err(|e| {
918            ServerError::InvalidResponse(anyhow::anyhow!("Invalid status code: {}", e))
919        })? {
920            StatusCode::OK => Ok(response.body),
921            status => Err(ServerError::ServerError(anyhow::anyhow!(
922                "Server returned status code: {}",
923                status
924            ))),
925        }
926    }
927}
928
929#[cfg(test)]
930mod tests {
931    use std::str::FromStr as _;
932
933    use fedimint_core::PeerId;
934    use fedimint_core::config::FederationId;
935    use fedimint_core::invite_code::InviteCode;
936    use fedimint_core::module::ApiMethod;
937    use fedimint_core::util::SafeUrl;
938
939    use super::{
940        IROH_REQUEST_TIMEOUT_DEFAULT, IROH_REQUEST_TIMEOUT_LONG_POLL, request_timeout_for_method,
941    };
942    use crate::{iroh_next_endpoint_url, is_iroh_next_endpoint_url, preserve_iroh_next_marker};
943
944    const TEST_ENDPOINT_ID: &str =
945        "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c";
946
947    #[test]
948    fn advertised_iroh_next_url_selects_only_the_next_stack() {
949        let next_url = iroh_next_endpoint_url(TEST_ENDPOINT_ID).expect("valid endpoint ID");
950        assert!(is_iroh_next_endpoint_url(&next_url).expect("valid Iroh API URL path"));
951
952        let invite = InviteCode::new(next_url, PeerId::from(0), FederationId::dummy(), None);
953        let round_tripped =
954            InviteCode::from_str(&invite.to_string()).expect("invite code round-trips");
955        assert!(is_iroh_next_endpoint_url(&round_tripped.url()).expect("valid Iroh API URL path"));
956
957        let stable_url =
958            SafeUrl::parse(&format!("iroh://{TEST_ENDPOINT_ID}")).expect("valid Iroh URL");
959        assert!(!is_iroh_next_endpoint_url(&stable_url).expect("valid Iroh API URL path"));
960
961        let replacement = SafeUrl::parse(
962            "iroh://d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
963        )
964        .expect("valid replacement URL");
965        let replacement = preserve_iroh_next_marker(&round_tripped.url(), &replacement);
966        assert!(is_iroh_next_endpoint_url(&replacement).expect("valid Iroh API URL path"));
967    }
968
969    #[test]
970    fn unknown_iroh_api_version_path_is_rejected() {
971        let url = SafeUrl::parse(&format!("iroh://{TEST_ENDPOINT_ID}/v2")).expect("valid Iroh URL");
972        assert!(is_iroh_next_endpoint_url(&url).is_err());
973    }
974
975    /// Every `await_*` endpoint currently exposed by fedimint modules
976    /// should be classified as long-poll. If a new endpoint is added
977    /// without the prefix it will silently fall through to the default
978    /// 60s budget — this list documents the contract and will surface
979    /// renames as test churn.
980    const AWAIT_ENDPOINTS: &[&str] = &[
981        // fedimint-core
982        "await_output_outcome",
983        "await_outputs_outcomes",
984        "await_session_outcome",
985        "await_signed_session_outcome",
986        "await_transaction",
987        // fedimint-ln-common
988        "await_account",
989        "await_block_height",
990        "await_offer",
991        "await_outgoing_contract_cancelled",
992        "await_preimage_decryption",
993        // fedimint-lnv2-common
994        "await_incoming_contract",
995        "await_incoming_contracts",
996        "await_preimage",
997    ];
998
999    /// A representative sample of prompt endpoints — anything that is
1000    /// expected to respond without server-side blocking.
1001    const PROMPT_ENDPOINTS: &[&str] = &[
1002        "block_count",
1003        "session_count",
1004        "session_status",
1005        "status",
1006        "version",
1007        "client_config",
1008        "audit",
1009        "account",
1010        "offer",
1011        "list_gateways",
1012        "submit_transaction",
1013        "consensus_block_count",
1014    ];
1015
1016    #[test]
1017    fn await_prefix_gets_long_poll_timeout() {
1018        for name in AWAIT_ENDPOINTS {
1019            assert_eq!(
1020                request_timeout_for_method(&ApiMethod::Core((*name).to_owned())),
1021                IROH_REQUEST_TIMEOUT_LONG_POLL,
1022                "core endpoint {name} should map to the long-poll timeout"
1023            );
1024            assert_eq!(
1025                request_timeout_for_method(&ApiMethod::Module(0, (*name).to_owned())),
1026                IROH_REQUEST_TIMEOUT_LONG_POLL,
1027                "module endpoint {name} should map to the long-poll timeout"
1028            );
1029        }
1030    }
1031
1032    #[test]
1033    fn wait_prefix_also_gets_long_poll_timeout() {
1034        // No fedimint endpoint currently uses this prefix, but the
1035        // selector accepts it so future additions following the
1036        // alternate naming convention don't silently get the default.
1037        assert_eq!(
1038            request_timeout_for_method(&ApiMethod::Core("wait_for_event".to_owned())),
1039            IROH_REQUEST_TIMEOUT_LONG_POLL,
1040        );
1041    }
1042
1043    #[test]
1044    fn prompt_endpoints_get_default_timeout() {
1045        for name in PROMPT_ENDPOINTS {
1046            assert_eq!(
1047                request_timeout_for_method(&ApiMethod::Core((*name).to_owned())),
1048                IROH_REQUEST_TIMEOUT_DEFAULT,
1049                "endpoint {name} should map to the default timeout"
1050            );
1051        }
1052    }
1053
1054    #[test]
1055    fn endpoints_that_merely_contain_await_are_not_misclassified() {
1056        // The selector is prefix-based, so an endpoint name with
1057        // "await" elsewhere in the string must not get the long
1058        // budget by accident.
1059        assert_eq!(
1060            request_timeout_for_method(&ApiMethod::Core("submit_await_thing".to_owned())),
1061            IROH_REQUEST_TIMEOUT_DEFAULT,
1062        );
1063    }
1064}