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