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;
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
113impl fmt::Debug for IrohConnector {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        f.debug_struct("IrohEndpoint")
116            .field("stable-id", &self.stable.node_id())
117            .field("next-id", &self.next.id())
118            .finish_non_exhaustive()
119    }
120}
121
122impl IrohConnector {
123    pub async fn new(
124        iroh_dns: Option<SafeUrl>,
125        iroh_enable_dht: bool,
126        path_change: Arc<watch::Sender<u64>>,
127    ) -> anyhow::Result<Self> {
128        let mut s = Self::new_no_overrides(iroh_dns, iroh_enable_dht, path_change).await?;
129
130        // Overrides are `<node-id>=<socket-addr>` pairs: the node id is the key
131        // and the value is a single direct address. iroh 1.0 no longer ships
132        // the `NodeTicket` format, so we keep the override wire format version
133        // agnostic and build the (legacy) `NodeAddr` from its parts. Pre-0.12
134        // binaries read the `NodeTicket`-format `FM_IROH_CONNECT_OVERRIDES`
135        // instead; devimint emits both side by side.
136        for env_var in [
137            FM_IROH_CONNECT_OVERRIDES_PLAIN_ENV,
138            FM_GW_IROH_CONNECT_OVERRIDES_PLAIN_ENV,
139        ] {
140            for (k, v) in parse_kv_list_from_env::<NodeId, SocketAddr>(env_var)? {
141                s = s.with_connection_override(k, NodeAddr::new(k).with_direct_addresses([v]));
142            }
143        }
144
145        Ok(s)
146    }
147
148    #[allow(clippy::too_many_lines)]
149    pub async fn new_no_overrides(
150        iroh_dns: Option<SafeUrl>,
151        iroh_enable_dht: bool,
152        path_change: Arc<watch::Sender<u64>>,
153    ) -> anyhow::Result<Self> {
154        let endpoint_stable = Box::pin({
155            let iroh_dns = iroh_dns.clone();
156            async {
157                let mut builder = Endpoint::builder();
158
159                if let Some(iroh_dns) = iroh_dns.map(SafeUrl::to_unsafe) {
160                    builder = builder.add_discovery(|_| Some(PkarrResolver::new(iroh_dns)));
161                }
162
163                // As a client, we don't need to register on any relays
164                let mut builder = builder.relay_mode(iroh::RelayMode::Disabled);
165
166                #[cfg(not(target_family = "wasm"))]
167                if iroh_enable_dht {
168                    builder = builder.discovery_dht();
169                }
170
171                // Add only resolver services here; the stable n0 convenience also
172                // installs a publisher.
173                {
174                    if is_env_var_set_opt(FM_IROH_PKARR_RESOLVER_ENABLE_ENV).unwrap_or(true) {
175                        builder = builder.add_discovery(move |_| Some(PkarrResolver::n0_dns()));
176                    } else {
177                        warn!(
178                            target: LOG_NET_IROH,
179                            "Iroh pkarr resolver is disabled"
180                        );
181                    }
182
183                    if is_env_var_set_opt(FM_IROH_N0_DISCOVERY_ENABLE_ENV).unwrap_or(true) {
184                        #[cfg(not(target_family = "wasm"))]
185                        {
186                            builder = builder.add_discovery(move |_| {
187                                Some(iroh::discovery::dns::DnsDiscovery::n0_dns())
188                            });
189                        }
190                    } else {
191                        warn!(
192                            target: LOG_NET_IROH,
193                            "Iroh n0 discovery is disabled"
194                        );
195                    }
196                }
197
198                let endpoint = builder
199                    .transport_config(quic_transport_config())
200                    .bind()
201                    .await?;
202                debug!(
203                    target: LOG_NET_IROH,
204                    node_id = %endpoint.node_id(),
205                    node_id_pkarr = %z32::encode(endpoint.node_id().as_bytes()),
206                    "Iroh api client endpoint (stable)"
207                );
208                Ok::<_, anyhow::Error>(endpoint)
209            }
210        });
211        let endpoint_next = Box::pin(async {
212            let mut builder = iroh_next::Endpoint::builder(iroh_next::endpoint::presets::Minimal);
213
214            if let Some(iroh_dns) = iroh_dns.map(SafeUrl::to_unsafe) {
215                builder = builder
216                    .address_lookup(iroh_next::address_lookup::PkarrResolver::builder(iroh_dns));
217            }
218
219            // Server iroh-next endpoints publish relay-only address records by
220            // default (the iroh 1.0 publishers filter out direct addresses), so
221            // the client must be able to dial via relays; `RelayMode::Disabled`
222            // would disable dialing them, not just registration.
223            let mut builder = builder.relay_mode(iroh_next::RelayMode::Default);
224
225            #[cfg(not(target_family = "wasm"))]
226            if iroh_enable_dht {
227                builder = builder
228                    .address_lookup(iroh_mainline_address_lookup::DhtAddressLookup::builder());
229            }
230
231            // Add only resolver services here; the iroh preset convenience also
232            // installs a publisher.
233            {
234                // Resolve using HTTPS requests to our DNS server's /pkarr path.
235                builder =
236                    builder.address_lookup(iroh_next::address_lookup::PkarrResolver::n0_dns());
237                // Resolve using DNS queries outside browsers.
238                #[cfg(not(target_family = "wasm"))]
239                {
240                    builder = builder
241                        .address_lookup(iroh_next::address_lookup::DnsAddressLookup::n0_dns());
242                }
243            }
244
245            let endpoint = builder
246                .transport_config(quic_transport_config_next())
247                .bind()
248                .await?;
249            debug!(
250                target: LOG_NET_IROH,
251                node_id = %endpoint.id(),
252                node_id_pkarr = %z32::encode(endpoint.id().as_bytes()),
253                "Iroh api client endpoint (next)"
254            );
255            Ok(endpoint)
256        });
257
258        let (endpoint_stable, endpoint_next) = tokio::try_join!(endpoint_stable, endpoint_next)?;
259
260        Ok(Self {
261            stable: endpoint_stable,
262            next: endpoint_next,
263            connection_overrides: BTreeMap::new(),
264            path_change,
265        })
266    }
267
268    pub fn with_connection_override(mut self, node: NodeId, addr: NodeAddr) -> Self {
269        self.connection_overrides.insert(node, addr);
270        self
271    }
272
273    pub fn node_id_from_url(url: &SafeUrl) -> anyhow::Result<NodeId> {
274        if url.scheme() != "iroh" {
275            bail!(
276                "Unsupported scheme: {}, passed to iroh endpoint handler",
277                url.scheme()
278            );
279        }
280        let host = url.host_str().context("Missing host string in Iroh URL")?;
281
282        let node_id = PublicKey::from_str(host).context("Failed to parse node id")?;
283
284        Ok(node_id)
285    }
286}
287
288#[async_trait::async_trait]
289impl crate::Connector for IrohConnector {
290    async fn connect_guardian(
291        &self,
292        url: &SafeUrl,
293        api_secret: Option<&str>,
294    ) -> ServerResult<DynGuaridianConnection> {
295        if api_secret.is_some() {
296            // There seem to be no way to pass secret over current Iroh calling
297            // convention. Connecting anyway would silently drop the credential and
298            // talk to an API that never authenticates us, so refuse instead.
299            return Err(ServerError::Connection(anyhow::format_err!(
300                "Iroh api secrets currently not supported"
301            )));
302        }
303        let node_id =
304            Self::node_id_from_url(url).map_err(|source| ServerError::InvalidPeerUrl {
305                source,
306                url: url.to_owned(),
307            })?;
308        let next_only = crate::is_iroh_next_endpoint_url(url).map_err(|source| {
309            ServerError::InvalidPeerUrl {
310                source,
311                url: url.to_owned(),
312            }
313        })?;
314        let mut futures = FuturesUnordered::<
315            Pin<
316                Box<
317                    dyn Future<Output = (ServerResult<DynGuaridianConnection>, &'static str)>
318                        + Send,
319                >,
320            >,
321        >::new();
322        let connection_override = self.connection_overrides.get(&node_id).cloned();
323
324        // Advertised Iroh 1.0 identities carry an internal `/v1` marker so we
325        // avoid attempting the incompatible 0.35 stack for safety and efficiency.
326        if next_only {
327            return self
328                .make_new_connection_next(&self.next, node_id, connection_override)
329                .await
330                .map(super::IGuardianConnection::into_dyn);
331        }
332
333        let self_clone = self.clone();
334        futures.push(Box::pin({
335            let connection_override = connection_override.clone();
336            async move {
337                (
338                    self_clone
339                        .make_new_connection_stable(node_id, connection_override)
340                        .await
341                        .map(super::IGuardianConnection::into_dyn),
342                    "stable",
343                )
344            }
345        }));
346
347        let self_clone = self.clone();
348        let endpoint_next = self.next.clone();
349        futures.push(Box::pin(async move {
350            (
351                self_clone
352                    .make_new_connection_next(&endpoint_next, node_id, connection_override)
353                    .await
354                    .map(super::IGuardianConnection::into_dyn),
355                "next",
356            )
357        }));
358
359        // Remember last error, so we have something to return if
360        // neither connection works.
361        let mut prev_err = None;
362
363        // Loop until first success, or running out of connections.
364        while let Some((result, iroh_stack)) = futures.next().await {
365            match result {
366                Ok(connection) => return Ok(connection),
367                Err(err) => {
368                    warn!(
369                        target: LOG_NET_IROH,
370                        err = %err.fmt_compact(),
371                        %iroh_stack,
372                        "Join error in iroh connection task"
373                    );
374                    prev_err = Some(err);
375                }
376            }
377        }
378
379        Err(prev_err.unwrap_or_else(|| {
380            ServerError::ServerError(anyhow::anyhow!("Both iroh connection attempts failed"))
381        }))
382    }
383
384    async fn connect_gateway(&self, url: &SafeUrl) -> anyhow::Result<DynGatewayConnection> {
385        let node_id = Self::node_id_from_url(url)?;
386        if let Some(node_addr) = self.connection_overrides.get(&node_id).cloned() {
387            let conn = self
388                .stable
389                .connect(node_addr.clone(), FEDIMINT_GATEWAY_ALPN)
390                .await?;
391
392            #[cfg(not(target_family = "wasm"))]
393            Self::spawn_connection_monitoring_stable(
394                &self.stable,
395                node_id,
396                self.path_change.clone(),
397            );
398
399            Ok(IGatewayConnection::into_dyn(conn))
400        } else {
401            let conn = self.stable.connect(node_id, FEDIMINT_GATEWAY_ALPN).await?;
402            Ok(IGatewayConnection::into_dyn(conn))
403        }
404    }
405
406    fn connectivity(&self, url: &SafeUrl) -> Connectivity {
407        let Ok(node_id) = Self::node_id_from_url(url) else {
408            return Connectivity::Unknown;
409        };
410        let Ok(watcher) = self.stable.conn_type(node_id) else {
411            return Connectivity::Unknown;
412        };
413        match watcher.get() {
414            Ok(iroh::endpoint::ConnectionType::Direct(_)) => Connectivity::Direct,
415            Ok(iroh::endpoint::ConnectionType::Relay(_)) => Connectivity::Relay,
416            Ok(iroh::endpoint::ConnectionType::Mixed(..)) => Connectivity::Mixed,
417            Ok(iroh::endpoint::ConnectionType::None) | Err(_) => Connectivity::Unknown,
418        }
419    }
420
421    async fn iroh_peer_info(
422        &self,
423        url: &SafeUrl,
424        path_timeout: Duration,
425    ) -> ServerResult<Option<IrohPeerInfo>> {
426        let node_id =
427            Self::node_id_from_url(url).map_err(|source| ServerError::InvalidPeerUrl {
428                source,
429                url: url.to_owned(),
430            })?;
431        let connection_override = self.connection_overrides.get(&node_id).cloned();
432        let _connection = self
433            .make_new_connection_stable(node_id, connection_override)
434            .await?;
435
436        let mut conn_type_watcher = self
437            .stable
438            .conn_type(node_id)
439            .map_err(ServerError::Connection)?;
440        let mut conn_type = conn_type_watcher
441            .get()
442            .unwrap_or(iroh::endpoint::ConnectionType::None);
443
444        if path_timeout > Duration::ZERO {
445            let timeout = fedimint_core::runtime::sleep(path_timeout);
446            tokio::pin!(timeout);
447
448            while !matches!(
449                conn_type,
450                iroh::endpoint::ConnectionType::Direct(_)
451                    | iroh::endpoint::ConnectionType::Mixed(..)
452            ) {
453                tokio::select! {
454                    () = &mut timeout => break,
455                    updated = conn_type_watcher.updated() => {
456                        match updated {
457                            Ok(updated) => conn_type = updated,
458                            Err(_) => break,
459                        }
460                    }
461                }
462            }
463        }
464
465        Ok(Some(self.iroh_peer_info_from_conn_type(node_id, conn_type)))
466    }
467}
468
469impl IrohConnector {
470    fn iroh_peer_info_from_conn_type(
471        &self,
472        node_id: NodeId,
473        conn_type: iroh::endpoint::ConnectionType,
474    ) -> IrohPeerInfo {
475        let remote_info = self.stable.remote_info(node_id);
476
477        let direct_addr = match &conn_type {
478            iroh::endpoint::ConnectionType::Direct(addr)
479            | iroh::endpoint::ConnectionType::Mixed(addr, _) => Some(*addr),
480            iroh::endpoint::ConnectionType::Relay(_) | iroh::endpoint::ConnectionType::None => None,
481        };
482
483        let mut known_direct_addrs = remote_info
484            .as_ref()
485            .map(|info| {
486                info.addrs
487                    .iter()
488                    .map(|addr_info| addr_info.addr)
489                    .collect::<BTreeSet<_>>()
490            })
491            .unwrap_or_default();
492        if let Some(direct_addr) = direct_addr {
493            known_direct_addrs.insert(direct_addr);
494        }
495
496        let relay_url = match &conn_type {
497            iroh::endpoint::ConnectionType::Relay(relay_url)
498            | iroh::endpoint::ConnectionType::Mixed(_, relay_url) => Some(relay_url.to_string()),
499            iroh::endpoint::ConnectionType::Direct(_) | iroh::endpoint::ConnectionType::None => {
500                remote_info.and_then(|info| info.relay_url.map(|relay| relay.relay_url.to_string()))
501            }
502        };
503
504        IrohPeerInfo {
505            node_id: node_id.to_string(),
506            connectivity: connectivity_from_iroh_conn_type(&conn_type),
507            direct_addr,
508            known_direct_addrs: known_direct_addrs.into_iter().collect(),
509            relay_url,
510        }
511    }
512
513    #[cfg(not(target_family = "wasm"))]
514    fn spawn_connection_monitoring_stable(
515        endpoint: &Endpoint,
516        node_id: NodeId,
517        path_change: Arc<watch::Sender<u64>>,
518    ) {
519        if let Ok(mut conn_type_watcher) = endpoint.conn_type(node_id) {
520            #[allow(clippy::let_underscore_future)]
521            let _ = spawn("iroh connection (stable)", async move {
522                if let Ok(conn_type) = conn_type_watcher.get() {
523                    debug!(target: LOG_NET_IROH, %node_id, type = %conn_type, "Connection type (initial)");
524                }
525                while let Ok(event) = conn_type_watcher.updated().await {
526                    debug!(target: LOG_NET_IROH, %node_id, type = %event, "Connection type (changed)");
527                    path_change.send_modify(|c| *c = c.wrapping_add(1));
528                }
529            });
530        }
531    }
532
533    #[cfg(not(target_family = "wasm"))]
534    fn spawn_connection_monitoring_next(
535        conn: &iroh_next::endpoint::Connection,
536        node_id: iroh_next::EndpointId,
537        path_change: Arc<watch::Sender<u64>>,
538    ) {
539        let conn = conn.clone();
540        #[allow(clippy::let_underscore_future)]
541        let _ = spawn("iroh connection (next)", async move {
542            let mut paths = conn.paths_stream();
543            if let Some(paths) = paths.next().await {
544                debug!(target: LOG_NET_IROH, %node_id, ?paths, "Connection paths (initial)");
545            }
546            while let Some(paths) = paths.next().await {
547                debug!(target: LOG_NET_IROH, %node_id, ?paths, "Connection paths changed");
548                path_change.send_modify(|c| *c = c.wrapping_add(1));
549            }
550        });
551    }
552
553    async fn make_new_connection_stable(
554        &self,
555        node_id: NodeId,
556        node_addr: Option<NodeAddr>,
557    ) -> ServerResult<Connection> {
558        trace!(target: LOG_NET_IROH, %node_id, "Creating new stable connection");
559        let conn = match node_addr.clone() {
560            Some(node_addr) => {
561                trace!(target: LOG_NET_IROH, %node_id, "Using a connectivity override for connection");
562                let conn = self.stable
563                    .connect(node_addr.clone(), FEDIMINT_API_ALPN)
564                    .await;
565
566                #[cfg(not(target_family = "wasm"))]
567                if conn.is_ok() {
568                    Self::spawn_connection_monitoring_stable(
569                        &self.stable,
570                        node_id,
571                        self.path_change.clone(),
572                    );
573                }
574                conn
575            }
576            None => self.stable.connect(node_id, FEDIMINT_API_ALPN).await,
577        }.map_err(ServerError::Connection)?;
578
579        Ok(conn)
580    }
581
582    async fn make_new_connection_next(
583        &self,
584        endpoint_next: &iroh_next::Endpoint,
585        node_id: NodeId,
586        node_addr: Option<NodeAddr>,
587    ) -> ServerResult<iroh_next::endpoint::Connection> {
588        let next_node_id =
589            iroh_next::EndpointId::from_bytes(node_id.as_bytes()).expect("Can't fail");
590
591        let endpoint_next = endpoint_next.clone();
592
593        trace!(target: LOG_NET_IROH, %node_id, "Creating new next connection");
594        let conn = match node_addr.clone() {
595            Some(node_addr) => {
596                trace!(target: LOG_NET_IROH, %node_id, "Using a connectivity override for connection");
597                let node_addr = node_addr_stable_to_next(&node_addr);
598                let conn = endpoint_next
599                    .connect(node_addr.clone(), FEDIMINT_API_ALPN)
600                    .await;
601
602                #[cfg(not(target_family = "wasm"))]
603                if let Ok(conn) = &conn {
604                    Self::spawn_connection_monitoring_next(
605                        conn,
606                        node_addr.id,
607                        self.path_change.clone(),
608                    );
609                }
610
611                conn
612            }
613            None => endpoint_next.connect(
614                next_node_id,
615                FEDIMINT_API_ALPN
616            ).await,
617        }
618        .map_err(Into::into)
619        .map_err(ServerError::Connection)?;
620
621        Ok(conn)
622    }
623}
624
625/// QUIC transport config with explicit idle timeout and keep-alive
626/// for the stable iroh endpoint.
627fn quic_transport_config() -> iroh::endpoint::TransportConfig {
628    let mut config = iroh::endpoint::TransportConfig::default();
629    config.max_idle_timeout(Some(
630        IROH_IDLE_TIMEOUT
631            .try_into()
632            .expect("idle timeout fits in IdleTimeout"),
633    ));
634    config.keep_alive_interval(Some(IROH_KEEP_ALIVE_INTERVAL));
635    config
636}
637
638/// QUIC transport config with explicit idle timeout and keep-alive
639/// for the next iroh endpoint.
640fn quic_transport_config_next() -> iroh_next::endpoint::QuicTransportConfig {
641    iroh_next::endpoint::QuicTransportConfig::builder()
642        .max_idle_timeout(Some(
643            IROH_IDLE_TIMEOUT
644                .try_into()
645                .expect("idle timeout fits in IdleTimeout"),
646        ))
647        .keep_alive_interval(IROH_KEEP_ALIVE_INTERVAL)
648        .build()
649}
650
651fn connectivity_from_iroh_conn_type(conn_type: &iroh::endpoint::ConnectionType) -> Connectivity {
652    match conn_type {
653        iroh::endpoint::ConnectionType::Direct(_) => Connectivity::Direct,
654        iroh::endpoint::ConnectionType::Relay(_) => Connectivity::Relay,
655        iroh::endpoint::ConnectionType::Mixed(..) => Connectivity::Mixed,
656        iroh::endpoint::ConnectionType::None => Connectivity::Unknown,
657    }
658}
659
660fn node_addr_stable_to_next(stable: &iroh::NodeAddr) -> iroh_next::EndpointAddr {
661    let next_node_id =
662        iroh_next::EndpointId::from_bytes(stable.node_id.as_bytes()).expect("Can't fail");
663    let relay_addrs = stable.relay_url.iter().map(|u| {
664        iroh_next::TransportAddr::Relay(
665            iroh_next::RelayUrl::from_str(&u.to_string()).expect("Can't fail"),
666        )
667    });
668    let direct_addrs = stable
669        .direct_addresses
670        .iter()
671        .copied()
672        .map(iroh_next::TransportAddr::Ip);
673
674    iroh_next::EndpointAddr::from_parts(next_node_id, relay_addrs.chain(direct_addrs))
675}
676
677#[apply(async_trait_maybe_send!)]
678impl IConnection for Connection {
679    async fn await_disconnection(&self) {
680        self.closed().await;
681    }
682
683    fn is_connected(&self) -> bool {
684        self.close_reason().is_none()
685    }
686}
687
688#[async_trait]
689impl IGuardianConnection for Connection {
690    async fn request(&self, method: ApiMethod, request: ApiRequestErased) -> ServerResult<Value> {
691        let timeout = request_timeout_for_method(&method);
692        let method_str = method.to_string();
693        let json = serde_json::to_vec(&IrohApiRequest { method, request })
694            .expect("Serialization to vec can't fail");
695
696        let result = fedimint_core::runtime::timeout(timeout, async {
697            let (mut sink, mut stream) = self
698                .open_bi()
699                .await
700                .map_err(|e| ServerError::Transport(e.into()))?;
701
702            sink.write_all(&json)
703                .await
704                .map_err(|e| ServerError::Transport(e.into()))?;
705
706            sink.finish()
707                .map_err(|e| ServerError::Transport(e.into()))?;
708
709            stream
710                .read_to_end(IROH_MAX_RESPONSE_BYTES)
711                .await
712                .map_err(|e| ServerError::Transport(e.into()))
713        })
714        .await;
715
716        let response = match result {
717            Ok(Ok(bytes)) => bytes,
718            Ok(Err(err)) => return Err(err),
719            Err(_) => {
720                // The bi-stream stalled past our budget. Close the QUIC
721                // connection so [`Self::is_connected`] (which reads
722                // `close_reason`) starts returning false; the connection
723                // pool's `get_or_init_pool_entry` will then evict this
724                // entry on the next access and the upstream retry loop
725                // will get a fresh connection.
726                warn!(
727                    target: LOG_NET_IROH,
728                    method = %method_str,
729                    timeout_secs = timeout.as_secs(),
730                    "iroh request timed out, closing connection",
731                );
732                self.close(
733                    iroh::endpoint::VarInt::from_u32(IROH_REQUEST_TIMEOUT_ERROR_CODE),
734                    IROH_REQUEST_TIMEOUT_ERROR_REASON,
735                );
736                return Err(ServerError::Transport(anyhow::anyhow!(
737                    "iroh request {method_str} timed out after {timeout:?}"
738                )));
739            }
740        };
741
742        // TODO: We should not be serializing Results on the wire
743        let response = serde_json::from_slice::<Result<Value, ApiError>>(&response)
744            .map_err(|e| ServerError::InvalidResponse(e.into()))?;
745
746        response.map_err(|e| ServerError::InvalidResponse(anyhow::anyhow!("Api Error: {:?}", e)))
747    }
748}
749
750#[apply(async_trait_maybe_send!)]
751impl IConnection for iroh_next::endpoint::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 iroh_next::endpoint::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                warn!(
794                    target: LOG_NET_IROH,
795                    method = %method_str,
796                    timeout_secs = timeout.as_secs(),
797                    "iroh request timed out, closing connection",
798                );
799                self.close(
800                    iroh_next::endpoint::VarInt::from_u32(IROH_REQUEST_TIMEOUT_ERROR_CODE),
801                    IROH_REQUEST_TIMEOUT_ERROR_REASON,
802                );
803                return Err(ServerError::Transport(anyhow::anyhow!(
804                    "iroh request {method_str} timed out after {timeout:?}"
805                )));
806            }
807        };
808
809        // TODO: We should not be serializing Results on the wire
810        let response = serde_json::from_slice::<Result<Value, ApiError>>(&response)
811            .map_err(|e| ServerError::InvalidResponse(e.into()))?;
812
813        response.map_err(|e| ServerError::InvalidResponse(anyhow::anyhow!("Api Error: {:?}", e)))
814    }
815}
816
817#[apply(async_trait_maybe_send!)]
818impl IGatewayConnection for Connection {
819    async fn request(
820        &self,
821        password: Option<String>,
822        _method: Method,
823        route: &str,
824        payload: Option<Value>,
825    ) -> ServerResult<Value> {
826        let iroh_request = IrohGatewayRequest {
827            route: route.to_string(),
828            params: payload,
829            password,
830        };
831        let json = serde_json::to_vec(&iroh_request).expect("serialization cant fail");
832
833        let (mut sink, mut stream) = self
834            .open_bi()
835            .await
836            .map_err(|e| ServerError::Transport(e.into()))?;
837
838        sink.write_all(&json)
839            .await
840            .map_err(|e| ServerError::Transport(e.into()))?;
841
842        sink.finish()
843            .map_err(|e| ServerError::Transport(e.into()))?;
844
845        let response = stream
846            .read_to_end(IROH_MAX_RESPONSE_BYTES)
847            .await
848            .map_err(|e| ServerError::Transport(e.into()))?;
849
850        let response = serde_json::from_slice::<IrohGatewayResponse>(&response)
851            .map_err(|e| ServerError::InvalidResponse(e.into()))?;
852        match StatusCode::from_u16(response.status).map_err(|e| {
853            ServerError::InvalidResponse(anyhow::anyhow!("Invalid status code: {}", e))
854        })? {
855            StatusCode::OK => Ok(response.body),
856            status => Err(ServerError::ServerError(anyhow::anyhow!(
857                "Server returned status code: {}",
858                status
859            ))),
860        }
861    }
862}
863
864#[cfg(test)]
865mod tests {
866    use std::str::FromStr as _;
867
868    use fedimint_core::PeerId;
869    use fedimint_core::config::FederationId;
870    use fedimint_core::invite_code::InviteCode;
871    use fedimint_core::module::ApiMethod;
872    use fedimint_core::util::SafeUrl;
873
874    use super::{
875        IROH_REQUEST_TIMEOUT_DEFAULT, IROH_REQUEST_TIMEOUT_LONG_POLL, request_timeout_for_method,
876    };
877    use crate::{iroh_next_endpoint_url, is_iroh_next_endpoint_url, preserve_iroh_next_marker};
878
879    const TEST_ENDPOINT_ID: &str =
880        "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c";
881
882    #[test]
883    fn advertised_iroh_next_url_selects_only_the_next_stack() {
884        let next_url = iroh_next_endpoint_url(TEST_ENDPOINT_ID).expect("valid endpoint ID");
885        assert!(is_iroh_next_endpoint_url(&next_url).expect("valid Iroh API URL path"));
886
887        let invite = InviteCode::new(next_url, PeerId::from(0), FederationId::dummy(), None);
888        let round_tripped =
889            InviteCode::from_str(&invite.to_string()).expect("invite code round-trips");
890        assert!(is_iroh_next_endpoint_url(&round_tripped.url()).expect("valid Iroh API URL path"));
891
892        let stable_url =
893            SafeUrl::parse(&format!("iroh://{TEST_ENDPOINT_ID}")).expect("valid Iroh URL");
894        assert!(!is_iroh_next_endpoint_url(&stable_url).expect("valid Iroh API URL path"));
895
896        let replacement = SafeUrl::parse(
897            "iroh://d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
898        )
899        .expect("valid replacement URL");
900        let replacement = preserve_iroh_next_marker(&round_tripped.url(), &replacement);
901        assert!(is_iroh_next_endpoint_url(&replacement).expect("valid Iroh API URL path"));
902    }
903
904    #[test]
905    fn unknown_iroh_api_version_path_is_rejected() {
906        let url = SafeUrl::parse(&format!("iroh://{TEST_ENDPOINT_ID}/v2")).expect("valid Iroh URL");
907        assert!(is_iroh_next_endpoint_url(&url).is_err());
908    }
909
910    /// Every `await_*` endpoint currently exposed by fedimint modules
911    /// should be classified as long-poll. If a new endpoint is added
912    /// without the prefix it will silently fall through to the default
913    /// 60s budget — this list documents the contract and will surface
914    /// renames as test churn.
915    const AWAIT_ENDPOINTS: &[&str] = &[
916        // fedimint-core
917        "await_output_outcome",
918        "await_outputs_outcomes",
919        "await_session_outcome",
920        "await_signed_session_outcome",
921        "await_transaction",
922        // fedimint-ln-common
923        "await_account",
924        "await_block_height",
925        "await_offer",
926        "await_outgoing_contract_cancelled",
927        "await_preimage_decryption",
928        // fedimint-lnv2-common
929        "await_incoming_contract",
930        "await_incoming_contracts",
931        "await_preimage",
932    ];
933
934    /// A representative sample of prompt endpoints — anything that is
935    /// expected to respond without server-side blocking.
936    const PROMPT_ENDPOINTS: &[&str] = &[
937        "block_count",
938        "session_count",
939        "session_status",
940        "status",
941        "version",
942        "client_config",
943        "audit",
944        "account",
945        "offer",
946        "list_gateways",
947        "submit_transaction",
948        "consensus_block_count",
949    ];
950
951    #[test]
952    fn await_prefix_gets_long_poll_timeout() {
953        for name in AWAIT_ENDPOINTS {
954            assert_eq!(
955                request_timeout_for_method(&ApiMethod::Core((*name).to_owned())),
956                IROH_REQUEST_TIMEOUT_LONG_POLL,
957                "core endpoint {name} should map to the long-poll timeout"
958            );
959            assert_eq!(
960                request_timeout_for_method(&ApiMethod::Module(0, (*name).to_owned())),
961                IROH_REQUEST_TIMEOUT_LONG_POLL,
962                "module endpoint {name} should map to the long-poll timeout"
963            );
964        }
965    }
966
967    #[test]
968    fn wait_prefix_also_gets_long_poll_timeout() {
969        // No fedimint endpoint currently uses this prefix, but the
970        // selector accepts it so future additions following the
971        // alternate naming convention don't silently get the default.
972        assert_eq!(
973            request_timeout_for_method(&ApiMethod::Core("wait_for_event".to_owned())),
974            IROH_REQUEST_TIMEOUT_LONG_POLL,
975        );
976    }
977
978    #[test]
979    fn prompt_endpoints_get_default_timeout() {
980        for name in PROMPT_ENDPOINTS {
981            assert_eq!(
982                request_timeout_for_method(&ApiMethod::Core((*name).to_owned())),
983                IROH_REQUEST_TIMEOUT_DEFAULT,
984                "endpoint {name} should map to the default timeout"
985            );
986        }
987    }
988
989    #[test]
990    fn endpoints_that_merely_contain_await_are_not_misclassified() {
991        // The selector is prefix-based, so an endpoint name with
992        // "await" elsewhere in the string must not get the long
993        // budget by accident.
994        assert_eq!(
995            request_timeout_for_method(&ApiMethod::Core("submit_await_thing".to_owned())),
996            IROH_REQUEST_TIMEOUT_DEFAULT,
997        );
998    }
999}