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            // As a client, we don't need to register on any relays
220            let mut builder = builder.relay_mode(iroh_next::RelayMode::Disabled);
221
222            #[cfg(not(target_family = "wasm"))]
223            if iroh_enable_dht {
224                builder = builder
225                    .address_lookup(iroh_mainline_address_lookup::DhtAddressLookup::builder());
226            }
227
228            // Add only resolver services here; the iroh preset convenience also
229            // installs a publisher.
230            {
231                // Resolve using HTTPS requests to our DNS server's /pkarr path.
232                builder =
233                    builder.address_lookup(iroh_next::address_lookup::PkarrResolver::n0_dns());
234                // Resolve using DNS queries outside browsers.
235                #[cfg(not(target_family = "wasm"))]
236                {
237                    builder = builder
238                        .address_lookup(iroh_next::address_lookup::DnsAddressLookup::n0_dns());
239                }
240            }
241
242            let endpoint = builder
243                .transport_config(quic_transport_config_next())
244                .bind()
245                .await?;
246            debug!(
247                target: LOG_NET_IROH,
248                node_id = %endpoint.id(),
249                node_id_pkarr = %z32::encode(endpoint.id().as_bytes()),
250                "Iroh api client endpoint (next)"
251            );
252            Ok(endpoint)
253        });
254
255        let (endpoint_stable, endpoint_next) = tokio::try_join!(endpoint_stable, endpoint_next)?;
256
257        Ok(Self {
258            stable: endpoint_stable,
259            next: endpoint_next,
260            connection_overrides: BTreeMap::new(),
261            path_change,
262        })
263    }
264
265    pub fn with_connection_override(mut self, node: NodeId, addr: NodeAddr) -> Self {
266        self.connection_overrides.insert(node, addr);
267        self
268    }
269
270    pub fn node_id_from_url(url: &SafeUrl) -> anyhow::Result<NodeId> {
271        if url.scheme() != "iroh" {
272            bail!(
273                "Unsupported scheme: {}, passed to iroh endpoint handler",
274                url.scheme()
275            );
276        }
277        let host = url.host_str().context("Missing host string in Iroh URL")?;
278
279        let node_id = PublicKey::from_str(host).context("Failed to parse node id")?;
280
281        Ok(node_id)
282    }
283}
284
285#[async_trait::async_trait]
286impl crate::Connector for IrohConnector {
287    async fn connect_guardian(
288        &self,
289        url: &SafeUrl,
290        api_secret: Option<&str>,
291    ) -> ServerResult<DynGuaridianConnection> {
292        if api_secret.is_some() {
293            // There seem to be no way to pass secret over current Iroh calling
294            // convention
295            ServerError::Connection(anyhow::format_err!(
296                "Iroh api secrets currently not supported"
297            ));
298        }
299        let node_id =
300            Self::node_id_from_url(url).map_err(|source| ServerError::InvalidPeerUrl {
301                source,
302                url: url.to_owned(),
303            })?;
304        let mut futures = FuturesUnordered::<
305            Pin<
306                Box<
307                    dyn Future<Output = (ServerResult<DynGuaridianConnection>, &'static str)>
308                        + Send,
309                >,
310            >,
311        >::new();
312        let connection_override = self.connection_overrides.get(&node_id).cloned();
313
314        let self_clone = self.clone();
315        futures.push(Box::pin({
316            let connection_override = connection_override.clone();
317            async move {
318                (
319                    self_clone
320                        .make_new_connection_stable(node_id, connection_override)
321                        .await
322                        .map(super::IGuardianConnection::into_dyn),
323                    "stable",
324                )
325            }
326        }));
327
328        let self_clone = self.clone();
329        let endpoint_next = self.next.clone();
330        futures.push(Box::pin(async move {
331            (
332                self_clone
333                    .make_new_connection_next(&endpoint_next, node_id, connection_override)
334                    .await
335                    .map(super::IGuardianConnection::into_dyn),
336                "next",
337            )
338        }));
339
340        // Remember last error, so we have something to return if
341        // neither connection works.
342        let mut prev_err = None;
343
344        // Loop until first success, or running out of connections.
345        while let Some((result, iroh_stack)) = futures.next().await {
346            match result {
347                Ok(connection) => return Ok(connection),
348                Err(err) => {
349                    warn!(
350                        target: LOG_NET_IROH,
351                        err = %err.fmt_compact(),
352                        %iroh_stack,
353                        "Join error in iroh connection task"
354                    );
355                    prev_err = Some(err);
356                }
357            }
358        }
359
360        Err(prev_err.unwrap_or_else(|| {
361            ServerError::ServerError(anyhow::anyhow!("Both iroh connection attempts failed"))
362        }))
363    }
364
365    async fn connect_gateway(&self, url: &SafeUrl) -> anyhow::Result<DynGatewayConnection> {
366        let node_id = Self::node_id_from_url(url)?;
367        if let Some(node_addr) = self.connection_overrides.get(&node_id).cloned() {
368            let conn = self
369                .stable
370                .connect(node_addr.clone(), FEDIMINT_GATEWAY_ALPN)
371                .await?;
372
373            #[cfg(not(target_family = "wasm"))]
374            Self::spawn_connection_monitoring_stable(
375                &self.stable,
376                node_id,
377                self.path_change.clone(),
378            );
379
380            Ok(IGatewayConnection::into_dyn(conn))
381        } else {
382            let conn = self.stable.connect(node_id, FEDIMINT_GATEWAY_ALPN).await?;
383            Ok(IGatewayConnection::into_dyn(conn))
384        }
385    }
386
387    fn connectivity(&self, url: &SafeUrl) -> Connectivity {
388        let Ok(node_id) = Self::node_id_from_url(url) else {
389            return Connectivity::Unknown;
390        };
391        let Ok(watcher) = self.stable.conn_type(node_id) else {
392            return Connectivity::Unknown;
393        };
394        match watcher.get() {
395            Ok(iroh::endpoint::ConnectionType::Direct(_)) => Connectivity::Direct,
396            Ok(iroh::endpoint::ConnectionType::Relay(_)) => Connectivity::Relay,
397            Ok(iroh::endpoint::ConnectionType::Mixed(..)) => Connectivity::Mixed,
398            Ok(iroh::endpoint::ConnectionType::None) | Err(_) => Connectivity::Unknown,
399        }
400    }
401
402    async fn iroh_peer_info(
403        &self,
404        url: &SafeUrl,
405        path_timeout: Duration,
406    ) -> ServerResult<Option<IrohPeerInfo>> {
407        let node_id =
408            Self::node_id_from_url(url).map_err(|source| ServerError::InvalidPeerUrl {
409                source,
410                url: url.to_owned(),
411            })?;
412        let connection_override = self.connection_overrides.get(&node_id).cloned();
413        let _connection = self
414            .make_new_connection_stable(node_id, connection_override)
415            .await?;
416
417        let mut conn_type_watcher = self
418            .stable
419            .conn_type(node_id)
420            .map_err(ServerError::Connection)?;
421        let mut conn_type = conn_type_watcher
422            .get()
423            .unwrap_or(iroh::endpoint::ConnectionType::None);
424
425        if path_timeout > Duration::ZERO {
426            let timeout = fedimint_core::runtime::sleep(path_timeout);
427            tokio::pin!(timeout);
428
429            while !matches!(
430                conn_type,
431                iroh::endpoint::ConnectionType::Direct(_)
432                    | iroh::endpoint::ConnectionType::Mixed(..)
433            ) {
434                tokio::select! {
435                    () = &mut timeout => break,
436                    updated = conn_type_watcher.updated() => {
437                        match updated {
438                            Ok(updated) => conn_type = updated,
439                            Err(_) => break,
440                        }
441                    }
442                }
443            }
444        }
445
446        Ok(Some(self.iroh_peer_info_from_conn_type(node_id, conn_type)))
447    }
448}
449
450impl IrohConnector {
451    fn iroh_peer_info_from_conn_type(
452        &self,
453        node_id: NodeId,
454        conn_type: iroh::endpoint::ConnectionType,
455    ) -> IrohPeerInfo {
456        let remote_info = self.stable.remote_info(node_id);
457
458        let direct_addr = match &conn_type {
459            iroh::endpoint::ConnectionType::Direct(addr)
460            | iroh::endpoint::ConnectionType::Mixed(addr, _) => Some(*addr),
461            iroh::endpoint::ConnectionType::Relay(_) | iroh::endpoint::ConnectionType::None => None,
462        };
463
464        let mut known_direct_addrs = remote_info
465            .as_ref()
466            .map(|info| {
467                info.addrs
468                    .iter()
469                    .map(|addr_info| addr_info.addr)
470                    .collect::<BTreeSet<_>>()
471            })
472            .unwrap_or_default();
473        if let Some(direct_addr) = direct_addr {
474            known_direct_addrs.insert(direct_addr);
475        }
476
477        let relay_url = match &conn_type {
478            iroh::endpoint::ConnectionType::Relay(relay_url)
479            | iroh::endpoint::ConnectionType::Mixed(_, relay_url) => Some(relay_url.to_string()),
480            iroh::endpoint::ConnectionType::Direct(_) | iroh::endpoint::ConnectionType::None => {
481                remote_info.and_then(|info| info.relay_url.map(|relay| relay.relay_url.to_string()))
482            }
483        };
484
485        IrohPeerInfo {
486            node_id: node_id.to_string(),
487            connectivity: connectivity_from_iroh_conn_type(&conn_type),
488            direct_addr,
489            known_direct_addrs: known_direct_addrs.into_iter().collect(),
490            relay_url,
491        }
492    }
493
494    #[cfg(not(target_family = "wasm"))]
495    fn spawn_connection_monitoring_stable(
496        endpoint: &Endpoint,
497        node_id: NodeId,
498        path_change: Arc<watch::Sender<u64>>,
499    ) {
500        if let Ok(mut conn_type_watcher) = endpoint.conn_type(node_id) {
501            #[allow(clippy::let_underscore_future)]
502            let _ = spawn("iroh connection (stable)", async move {
503                if let Ok(conn_type) = conn_type_watcher.get() {
504                    debug!(target: LOG_NET_IROH, %node_id, type = %conn_type, "Connection type (initial)");
505                }
506                while let Ok(event) = conn_type_watcher.updated().await {
507                    debug!(target: LOG_NET_IROH, %node_id, type = %event, "Connection type (changed)");
508                    path_change.send_modify(|c| *c = c.wrapping_add(1));
509                }
510            });
511        }
512    }
513
514    #[cfg(not(target_family = "wasm"))]
515    fn spawn_connection_monitoring_next(
516        conn: &iroh_next::endpoint::Connection,
517        node_id: iroh_next::EndpointId,
518        path_change: Arc<watch::Sender<u64>>,
519    ) {
520        let conn = conn.clone();
521        #[allow(clippy::let_underscore_future)]
522        let _ = spawn("iroh connection (next)", async move {
523            let mut paths = conn.paths_stream();
524            if let Some(paths) = paths.next().await {
525                debug!(target: LOG_NET_IROH, %node_id, ?paths, "Connection paths (initial)");
526            }
527            while let Some(paths) = paths.next().await {
528                debug!(target: LOG_NET_IROH, %node_id, ?paths, "Connection paths changed");
529                path_change.send_modify(|c| *c = c.wrapping_add(1));
530            }
531        });
532    }
533
534    async fn make_new_connection_stable(
535        &self,
536        node_id: NodeId,
537        node_addr: Option<NodeAddr>,
538    ) -> ServerResult<Connection> {
539        trace!(target: LOG_NET_IROH, %node_id, "Creating new stable connection");
540        let conn = match node_addr.clone() {
541            Some(node_addr) => {
542                trace!(target: LOG_NET_IROH, %node_id, "Using a connectivity override for connection");
543                let conn = self.stable
544                    .connect(node_addr.clone(), FEDIMINT_API_ALPN)
545                    .await;
546
547                #[cfg(not(target_family = "wasm"))]
548                if conn.is_ok() {
549                    Self::spawn_connection_monitoring_stable(
550                        &self.stable,
551                        node_id,
552                        self.path_change.clone(),
553                    );
554                }
555                conn
556            }
557            None => self.stable.connect(node_id, FEDIMINT_API_ALPN).await,
558        }.map_err(ServerError::Connection)?;
559
560        Ok(conn)
561    }
562
563    async fn make_new_connection_next(
564        &self,
565        endpoint_next: &iroh_next::Endpoint,
566        node_id: NodeId,
567        node_addr: Option<NodeAddr>,
568    ) -> ServerResult<iroh_next::endpoint::Connection> {
569        let next_node_id =
570            iroh_next::EndpointId::from_bytes(node_id.as_bytes()).expect("Can't fail");
571
572        let endpoint_next = endpoint_next.clone();
573
574        trace!(target: LOG_NET_IROH, %node_id, "Creating new next connection");
575        let conn = match node_addr.clone() {
576            Some(node_addr) => {
577                trace!(target: LOG_NET_IROH, %node_id, "Using a connectivity override for connection");
578                let node_addr = node_addr_stable_to_next(&node_addr);
579                let conn = endpoint_next
580                    .connect(node_addr.clone(), FEDIMINT_API_ALPN)
581                    .await;
582
583                #[cfg(not(target_family = "wasm"))]
584                if let Ok(conn) = &conn {
585                    Self::spawn_connection_monitoring_next(
586                        conn,
587                        node_addr.id,
588                        self.path_change.clone(),
589                    );
590                }
591
592                conn
593            }
594            None => endpoint_next.connect(
595                next_node_id,
596                FEDIMINT_API_ALPN
597            ).await,
598        }
599        .map_err(Into::into)
600        .map_err(ServerError::Connection)?;
601
602        Ok(conn)
603    }
604}
605
606/// QUIC transport config with explicit idle timeout and keep-alive
607/// for the stable iroh endpoint.
608fn quic_transport_config() -> iroh::endpoint::TransportConfig {
609    let mut config = iroh::endpoint::TransportConfig::default();
610    config.max_idle_timeout(Some(
611        IROH_IDLE_TIMEOUT
612            .try_into()
613            .expect("idle timeout fits in IdleTimeout"),
614    ));
615    config.keep_alive_interval(Some(IROH_KEEP_ALIVE_INTERVAL));
616    config
617}
618
619/// QUIC transport config with explicit idle timeout and keep-alive
620/// for the next iroh endpoint.
621fn quic_transport_config_next() -> iroh_next::endpoint::QuicTransportConfig {
622    iroh_next::endpoint::QuicTransportConfig::builder()
623        .max_idle_timeout(Some(
624            IROH_IDLE_TIMEOUT
625                .try_into()
626                .expect("idle timeout fits in IdleTimeout"),
627        ))
628        .keep_alive_interval(IROH_KEEP_ALIVE_INTERVAL)
629        .build()
630}
631
632fn connectivity_from_iroh_conn_type(conn_type: &iroh::endpoint::ConnectionType) -> Connectivity {
633    match conn_type {
634        iroh::endpoint::ConnectionType::Direct(_) => Connectivity::Direct,
635        iroh::endpoint::ConnectionType::Relay(_) => Connectivity::Relay,
636        iroh::endpoint::ConnectionType::Mixed(..) => Connectivity::Mixed,
637        iroh::endpoint::ConnectionType::None => Connectivity::Unknown,
638    }
639}
640
641fn node_addr_stable_to_next(stable: &iroh::NodeAddr) -> iroh_next::EndpointAddr {
642    let next_node_id =
643        iroh_next::EndpointId::from_bytes(stable.node_id.as_bytes()).expect("Can't fail");
644    let relay_addrs = stable.relay_url.iter().map(|u| {
645        iroh_next::TransportAddr::Relay(
646            iroh_next::RelayUrl::from_str(&u.to_string()).expect("Can't fail"),
647        )
648    });
649    let direct_addrs = stable
650        .direct_addresses
651        .iter()
652        .copied()
653        .map(iroh_next::TransportAddr::Ip);
654
655    iroh_next::EndpointAddr::from_parts(next_node_id, relay_addrs.chain(direct_addrs))
656}
657
658#[apply(async_trait_maybe_send!)]
659impl IConnection for Connection {
660    async fn await_disconnection(&self) {
661        self.closed().await;
662    }
663
664    fn is_connected(&self) -> bool {
665        self.close_reason().is_none()
666    }
667}
668
669#[async_trait]
670impl IGuardianConnection for Connection {
671    async fn request(&self, method: ApiMethod, request: ApiRequestErased) -> ServerResult<Value> {
672        let timeout = request_timeout_for_method(&method);
673        let method_str = method.to_string();
674        let json = serde_json::to_vec(&IrohApiRequest { method, request })
675            .expect("Serialization to vec can't fail");
676
677        let result = fedimint_core::runtime::timeout(timeout, async {
678            let (mut sink, mut stream) = self
679                .open_bi()
680                .await
681                .map_err(|e| ServerError::Transport(e.into()))?;
682
683            sink.write_all(&json)
684                .await
685                .map_err(|e| ServerError::Transport(e.into()))?;
686
687            sink.finish()
688                .map_err(|e| ServerError::Transport(e.into()))?;
689
690            stream
691                .read_to_end(IROH_MAX_RESPONSE_BYTES)
692                .await
693                .map_err(|e| ServerError::Transport(e.into()))
694        })
695        .await;
696
697        let response = match result {
698            Ok(Ok(bytes)) => bytes,
699            Ok(Err(err)) => return Err(err),
700            Err(_) => {
701                // The bi-stream stalled past our budget. Close the QUIC
702                // connection so [`Self::is_connected`] (which reads
703                // `close_reason`) starts returning false; the connection
704                // pool's `get_or_init_pool_entry` will then evict this
705                // entry on the next access and the upstream retry loop
706                // will get a fresh connection.
707                warn!(
708                    target: LOG_NET_IROH,
709                    method = %method_str,
710                    timeout_secs = timeout.as_secs(),
711                    "iroh request timed out, closing connection",
712                );
713                self.close(
714                    iroh::endpoint::VarInt::from_u32(IROH_REQUEST_TIMEOUT_ERROR_CODE),
715                    IROH_REQUEST_TIMEOUT_ERROR_REASON,
716                );
717                return Err(ServerError::Transport(anyhow::anyhow!(
718                    "iroh request {method_str} timed out after {timeout:?}"
719                )));
720            }
721        };
722
723        // TODO: We should not be serializing Results on the wire
724        let response = serde_json::from_slice::<Result<Value, ApiError>>(&response)
725            .map_err(|e| ServerError::InvalidResponse(e.into()))?;
726
727        response.map_err(|e| ServerError::InvalidResponse(anyhow::anyhow!("Api Error: {:?}", e)))
728    }
729}
730
731#[apply(async_trait_maybe_send!)]
732impl IConnection for iroh_next::endpoint::Connection {
733    async fn await_disconnection(&self) {
734        self.closed().await;
735    }
736
737    fn is_connected(&self) -> bool {
738        self.close_reason().is_none()
739    }
740}
741
742#[async_trait]
743impl IGuardianConnection for iroh_next::endpoint::Connection {
744    async fn request(&self, method: ApiMethod, request: ApiRequestErased) -> ServerResult<Value> {
745        let timeout = request_timeout_for_method(&method);
746        let method_str = method.to_string();
747        let json = serde_json::to_vec(&IrohApiRequest { method, request })
748            .expect("Serialization to vec can't fail");
749
750        let result = fedimint_core::runtime::timeout(timeout, async {
751            let (mut sink, mut stream) = self
752                .open_bi()
753                .await
754                .map_err(|e| ServerError::Transport(e.into()))?;
755
756            sink.write_all(&json)
757                .await
758                .map_err(|e| ServerError::Transport(e.into()))?;
759
760            sink.finish()
761                .map_err(|e| ServerError::Transport(e.into()))?;
762
763            stream
764                .read_to_end(IROH_MAX_RESPONSE_BYTES)
765                .await
766                .map_err(|e| ServerError::Transport(e.into()))
767        })
768        .await;
769
770        let response = match result {
771            Ok(Ok(bytes)) => bytes,
772            Ok(Err(err)) => return Err(err),
773            Err(_) => {
774                warn!(
775                    target: LOG_NET_IROH,
776                    method = %method_str,
777                    timeout_secs = timeout.as_secs(),
778                    "iroh request timed out, closing connection",
779                );
780                self.close(
781                    iroh_next::endpoint::VarInt::from_u32(IROH_REQUEST_TIMEOUT_ERROR_CODE),
782                    IROH_REQUEST_TIMEOUT_ERROR_REASON,
783                );
784                return Err(ServerError::Transport(anyhow::anyhow!(
785                    "iroh request {method_str} timed out after {timeout:?}"
786                )));
787            }
788        };
789
790        // TODO: We should not be serializing Results on the wire
791        let response = serde_json::from_slice::<Result<Value, ApiError>>(&response)
792            .map_err(|e| ServerError::InvalidResponse(e.into()))?;
793
794        response.map_err(|e| ServerError::InvalidResponse(anyhow::anyhow!("Api Error: {:?}", e)))
795    }
796}
797
798#[apply(async_trait_maybe_send!)]
799impl IGatewayConnection for Connection {
800    async fn request(
801        &self,
802        password: Option<String>,
803        _method: Method,
804        route: &str,
805        payload: Option<Value>,
806    ) -> ServerResult<Value> {
807        let iroh_request = IrohGatewayRequest {
808            route: route.to_string(),
809            params: payload,
810            password,
811        };
812        let json = serde_json::to_vec(&iroh_request).expect("serialization cant fail");
813
814        let (mut sink, mut stream) = self
815            .open_bi()
816            .await
817            .map_err(|e| ServerError::Transport(e.into()))?;
818
819        sink.write_all(&json)
820            .await
821            .map_err(|e| ServerError::Transport(e.into()))?;
822
823        sink.finish()
824            .map_err(|e| ServerError::Transport(e.into()))?;
825
826        let response = stream
827            .read_to_end(IROH_MAX_RESPONSE_BYTES)
828            .await
829            .map_err(|e| ServerError::Transport(e.into()))?;
830
831        let response = serde_json::from_slice::<IrohGatewayResponse>(&response)
832            .map_err(|e| ServerError::InvalidResponse(e.into()))?;
833        match StatusCode::from_u16(response.status).map_err(|e| {
834            ServerError::InvalidResponse(anyhow::anyhow!("Invalid status code: {}", e))
835        })? {
836            StatusCode::OK => Ok(response.body),
837            status => Err(ServerError::ServerError(anyhow::anyhow!(
838                "Server returned status code: {}",
839                status
840            ))),
841        }
842    }
843}
844
845#[cfg(test)]
846mod tests {
847    use fedimint_core::module::ApiMethod;
848
849    use super::{
850        IROH_REQUEST_TIMEOUT_DEFAULT, IROH_REQUEST_TIMEOUT_LONG_POLL, request_timeout_for_method,
851    };
852
853    /// Every `await_*` endpoint currently exposed by fedimint modules
854    /// should be classified as long-poll. If a new endpoint is added
855    /// without the prefix it will silently fall through to the default
856    /// 60s budget — this list documents the contract and will surface
857    /// renames as test churn.
858    const AWAIT_ENDPOINTS: &[&str] = &[
859        // fedimint-core
860        "await_output_outcome",
861        "await_outputs_outcomes",
862        "await_session_outcome",
863        "await_signed_session_outcome",
864        "await_transaction",
865        // fedimint-ln-common
866        "await_account",
867        "await_block_height",
868        "await_offer",
869        "await_outgoing_contract_cancelled",
870        "await_preimage_decryption",
871        // fedimint-lnv2-common
872        "await_incoming_contract",
873        "await_incoming_contracts",
874        "await_preimage",
875    ];
876
877    /// A representative sample of prompt endpoints — anything that is
878    /// expected to respond without server-side blocking.
879    const PROMPT_ENDPOINTS: &[&str] = &[
880        "block_count",
881        "session_count",
882        "session_status",
883        "status",
884        "version",
885        "client_config",
886        "audit",
887        "account",
888        "offer",
889        "list_gateways",
890        "submit_transaction",
891        "consensus_block_count",
892    ];
893
894    #[test]
895    fn await_prefix_gets_long_poll_timeout() {
896        for name in AWAIT_ENDPOINTS {
897            assert_eq!(
898                request_timeout_for_method(&ApiMethod::Core((*name).to_owned())),
899                IROH_REQUEST_TIMEOUT_LONG_POLL,
900                "core endpoint {name} should map to the long-poll timeout"
901            );
902            assert_eq!(
903                request_timeout_for_method(&ApiMethod::Module(0, (*name).to_owned())),
904                IROH_REQUEST_TIMEOUT_LONG_POLL,
905                "module endpoint {name} should map to the long-poll timeout"
906            );
907        }
908    }
909
910    #[test]
911    fn wait_prefix_also_gets_long_poll_timeout() {
912        // No fedimint endpoint currently uses this prefix, but the
913        // selector accepts it so future additions following the
914        // alternate naming convention don't silently get the default.
915        assert_eq!(
916            request_timeout_for_method(&ApiMethod::Core("wait_for_event".to_owned())),
917            IROH_REQUEST_TIMEOUT_LONG_POLL,
918        );
919    }
920
921    #[test]
922    fn prompt_endpoints_get_default_timeout() {
923        for name in PROMPT_ENDPOINTS {
924            assert_eq!(
925                request_timeout_for_method(&ApiMethod::Core((*name).to_owned())),
926                IROH_REQUEST_TIMEOUT_DEFAULT,
927                "endpoint {name} should map to the default timeout"
928            );
929        }
930    }
931
932    #[test]
933    fn endpoints_that_merely_contain_await_are_not_misclassified() {
934        // The selector is prefix-based, so an endpoint name with
935        // "await" elsewhere in the string must not get the long
936        // budget by accident.
937        assert_eq!(
938            request_timeout_for_method(&ApiMethod::Core("submit_await_thing".to_owned())),
939            IROH_REQUEST_TIMEOUT_DEFAULT,
940        );
941    }
942}