1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt;
3use std::net::SocketAddr;
4use std::pin::Pin;
5use std::str::FromStr;
6use std::sync::{Arc, Mutex};
7use std::time::Duration;
8
9use async_trait::async_trait;
10use fedimint_core::config::ALEPH_BFT_UNIT_BYTE_LIMIT;
11use fedimint_core::envs::{
12 FM_GW_IROH_CONNECT_OVERRIDES_PLAIN_ENV, FM_IROH_CONNECT_OVERRIDES_PLAIN_ENV,
13 FM_IROH_N0_DISCOVERY_ENABLE_ENV, FM_IROH_PKARR_RESOLVER_ENABLE_ENV, is_env_var_set_opt,
14 parse_kv_list_from_env,
15};
16use fedimint_core::module::{
17 ApiError, ApiMethod, ApiRequestErased, FEDIMINT_API_ALPN, FEDIMINT_GATEWAY_ALPN,
18 IrohApiRequest, IrohGatewayRequest, IrohGatewayResponse,
19};
20use fedimint_core::net::iroh::{IROH_IDLE_TIMEOUT, IROH_KEEP_ALIVE_INTERVAL};
21
22const IROH_MAX_RESPONSE_BYTES: usize = ALEPH_BFT_UNIT_BYTE_LIMIT * 3600 * 4 * 2;
31
32const IROH_REQUEST_TIMEOUT_DEFAULT: Duration = Duration::from_secs(60);
39
40const IROH_REQUEST_TIMEOUT_LONG_POLL: Duration = Duration::from_secs(60 * 60);
47
48const IROH_REQUEST_TIMEOUT_ERROR_CODE: u32 = 1;
54const IROH_REQUEST_TIMEOUT_ERROR_REASON: &[u8] = b"request timeout";
55
56fn request_timeout_for_method(method: &ApiMethod) -> Duration {
66 let name = match method {
67 ApiMethod::Core(name) => name.as_str(),
68 ApiMethod::Module(_, name) => name.as_str(),
69 };
70 if name.starts_with("await_") || name.starts_with("wait_") {
71 IROH_REQUEST_TIMEOUT_LONG_POLL
72 } else {
73 IROH_REQUEST_TIMEOUT_DEFAULT
74 }
75}
76use fedimint_core::task::spawn;
77use fedimint_core::util::{FmtCompact as _, SafeUrl};
78use fedimint_core::{apply, async_trait_maybe_send};
79use fedimint_logging::LOG_NET_IROH;
80use futures::Future;
81use futures::stream::{FuturesUnordered, StreamExt};
82use iroh::discovery::pkarr::PkarrResolver;
83use iroh::endpoint::Connection;
84use iroh::{Endpoint, NodeAddr, NodeId, PublicKey};
85use reqwest::{Method, StatusCode};
86use serde_json::Value;
87use tokio::sync::watch;
88use tracing::{debug, trace, warn};
89
90use super::{DynGuaridianConnection, IGuardianConnection, ServerError, ServerResult};
91use crate::error::ConnectorError;
92use crate::{Connectivity, DynGatewayConnection, IConnection, IGatewayConnection, IrohPeerInfo};
93
94#[derive(Clone)]
95pub(crate) struct IrohConnector {
96 stable: iroh::endpoint::Endpoint,
97 next: iroh_next::endpoint::Endpoint,
98
99 connection_overrides: BTreeMap<NodeId, NodeAddr>,
105
106 path_change: Arc<watch::Sender<u64>>,
111
112 next_connections: Arc<Mutex<BTreeMap<NodeId, iroh_next::endpoint::Connection>>>,
121}
122
123impl fmt::Debug for IrohConnector {
124 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125 f.debug_struct("IrohEndpoint")
126 .field("stable-id", &self.stable.node_id())
127 .field("next-id", &self.next.id())
128 .finish_non_exhaustive()
129 }
130}
131
132impl IrohConnector {
133 pub(crate) async fn new(
134 iroh_dns: Option<SafeUrl>,
135 iroh_enable_dht: bool,
136 path_change: Arc<watch::Sender<u64>>,
137 ) -> anyhow::Result<Self> {
138 let mut s = Self::new_no_overrides(iroh_dns, iroh_enable_dht, path_change).await?;
139
140 for env_var in [
147 FM_IROH_CONNECT_OVERRIDES_PLAIN_ENV,
148 FM_GW_IROH_CONNECT_OVERRIDES_PLAIN_ENV,
149 ] {
150 for (k, v) in parse_kv_list_from_env::<NodeId, SocketAddr>(env_var) {
151 s = s.with_connection_override(k, NodeAddr::new(k).with_direct_addresses([v]));
152 }
153 }
154
155 Ok(s)
156 }
157
158 #[allow(clippy::too_many_lines)]
159 pub(crate) async fn new_no_overrides(
160 iroh_dns: Option<SafeUrl>,
161 iroh_enable_dht: bool,
162 path_change: Arc<watch::Sender<u64>>,
163 ) -> anyhow::Result<Self> {
164 let endpoint_stable = Box::pin({
165 let iroh_dns = iroh_dns.clone();
166 async {
167 let mut builder = Endpoint::builder();
168
169 if let Some(iroh_dns) = iroh_dns.map(SafeUrl::to_unsafe) {
170 builder = builder.add_discovery(|_| Some(PkarrResolver::new(iroh_dns)));
171 }
172
173 let mut builder = builder.relay_mode(iroh::RelayMode::Disabled);
175
176 #[cfg(not(target_family = "wasm"))]
177 if iroh_enable_dht {
178 builder = builder.discovery_dht();
179 }
180
181 {
184 if is_env_var_set_opt(FM_IROH_PKARR_RESOLVER_ENABLE_ENV).unwrap_or(true) {
185 builder = builder.add_discovery(move |_| Some(PkarrResolver::n0_dns()));
186 } else {
187 warn!(
188 target: LOG_NET_IROH,
189 "Iroh pkarr resolver is disabled"
190 );
191 }
192
193 if is_env_var_set_opt(FM_IROH_N0_DISCOVERY_ENABLE_ENV).unwrap_or(true) {
194 #[cfg(not(target_family = "wasm"))]
195 {
196 builder = builder.add_discovery(move |_| {
197 Some(iroh::discovery::dns::DnsDiscovery::n0_dns())
198 });
199 }
200 } else {
201 warn!(
202 target: LOG_NET_IROH,
203 "Iroh n0 discovery is disabled"
204 );
205 }
206 }
207
208 let endpoint = builder
209 .transport_config(quic_transport_config())
210 .bind()
211 .await?;
212 debug!(
213 target: LOG_NET_IROH,
214 node_id = %endpoint.node_id(),
215 node_id_pkarr = %z32::encode(endpoint.node_id().as_bytes()),
216 "Iroh api client endpoint (stable)"
217 );
218 Ok::<_, anyhow::Error>(endpoint)
219 }
220 });
221 let endpoint_next = Box::pin(async {
222 let mut builder = iroh_next::Endpoint::builder(iroh_next::endpoint::presets::Minimal);
223
224 if let Some(iroh_dns) = iroh_dns.map(SafeUrl::to_unsafe) {
225 builder = builder
226 .address_lookup(iroh_next::address_lookup::PkarrResolver::builder(iroh_dns));
227 }
228
229 let mut builder = builder.relay_mode(iroh_next::RelayMode::Default);
234
235 #[cfg(not(target_family = "wasm"))]
236 if iroh_enable_dht {
237 builder = builder
238 .address_lookup(iroh_mainline_address_lookup::DhtAddressLookup::builder());
239 }
240
241 {
244 builder =
246 builder.address_lookup(iroh_next::address_lookup::PkarrResolver::n0_dns());
247 #[cfg(not(target_family = "wasm"))]
249 {
250 builder = builder
251 .address_lookup(iroh_next::address_lookup::DnsAddressLookup::n0_dns());
252 }
253 }
254
255 let endpoint = builder
256 .transport_config(quic_transport_config_next())
257 .bind()
258 .await?;
259 debug!(
260 target: LOG_NET_IROH,
261 node_id = %endpoint.id(),
262 node_id_pkarr = %z32::encode(endpoint.id().as_bytes()),
263 "Iroh api client endpoint (next)"
264 );
265 Ok(endpoint)
266 });
267
268 let (endpoint_stable, endpoint_next) = tokio::try_join!(endpoint_stable, endpoint_next)?;
269
270 Ok(Self {
271 stable: endpoint_stable,
272 next: endpoint_next,
273 connection_overrides: BTreeMap::new(),
274 path_change,
275 next_connections: Arc::new(Mutex::new(BTreeMap::new())),
276 })
277 }
278
279 pub(crate) fn with_connection_override(mut self, node: NodeId, addr: NodeAddr) -> Self {
280 self.connection_overrides.insert(node, addr);
281 self
282 }
283
284 pub(crate) fn node_id_from_url(url: &SafeUrl) -> Result<NodeId, ConnectorError> {
285 if url.scheme() != "iroh" {
286 return Err(ConnectorError::UnsupportedScheme {
287 scheme: url.scheme().to_owned(),
288 });
289 }
290 let host = url.host_str().ok_or_else(|| ConnectorError::MissingHost {
291 url: url.to_owned(),
292 })?;
293
294 PublicKey::from_str(host).map_err(|source| ConnectorError::InvalidNodeId {
295 host: host.to_owned(),
296 source: Box::new(source),
297 })
298 }
299}
300
301#[async_trait::async_trait]
302impl crate::Connector for IrohConnector {
303 async fn connect_guardian(
304 &self,
305 url: &SafeUrl,
306 api_secret: Option<&str>,
307 ) -> ServerResult<DynGuaridianConnection> {
308 if api_secret.is_some() {
309 return Err(ServerError::Connection(
313 "Iroh api secrets currently not supported".into(),
314 ));
315 }
316 let node_id =
317 Self::node_id_from_url(url).map_err(|source| ServerError::InvalidPeerUrl {
318 source: Box::new(source),
319 url: url.to_owned(),
320 })?;
321 let next_only = crate::is_iroh_next_endpoint_url(url).map_err(|source| {
322 ServerError::InvalidPeerUrl {
323 source: Box::new(source),
324 url: url.to_owned(),
325 }
326 })?;
327 let mut futures = FuturesUnordered::<
328 Pin<
329 Box<
330 dyn Future<Output = (ServerResult<DynGuaridianConnection>, &'static str)>
331 + Send,
332 >,
333 >,
334 >::new();
335 let connection_override = self.connection_overrides.get(&node_id).cloned();
336
337 if next_only {
340 return self
341 .make_new_connection_next(&self.next, node_id, connection_override)
342 .await
343 .map(super::IGuardianConnection::into_dyn);
344 }
345
346 let self_clone = self.clone();
347 futures.push(Box::pin({
348 let connection_override = connection_override.clone();
349 async move {
350 (
351 self_clone
352 .make_new_connection_stable(node_id, connection_override)
353 .await
354 .map(super::IGuardianConnection::into_dyn),
355 "stable",
356 )
357 }
358 }));
359
360 let self_clone = self.clone();
361 let endpoint_next = self.next.clone();
362 futures.push(Box::pin(async move {
363 (
364 self_clone
365 .make_new_connection_next(&endpoint_next, node_id, connection_override)
366 .await
367 .map(super::IGuardianConnection::into_dyn),
368 "next",
369 )
370 }));
371
372 let mut prev_err = None;
375
376 while let Some((result, iroh_stack)) = futures.next().await {
378 match result {
379 Ok(connection) => return Ok(connection),
380 Err(err) => {
381 warn!(
382 target: LOG_NET_IROH,
383 err = %err.fmt_compact(),
384 %iroh_stack,
385 "Join error in iroh connection task"
386 );
387 prev_err = Some(err);
388 }
389 }
390 }
391
392 Err(prev_err.unwrap_or_else(|| {
393 ServerError::ServerError("Both iroh connection attempts failed".to_string())
394 }))
395 }
396
397 async fn connect_gateway(&self, url: &SafeUrl) -> Result<DynGatewayConnection, ConnectorError> {
398 let node_id = Self::node_id_from_url(url)?;
399 if let Some(node_addr) = self.connection_overrides.get(&node_id).cloned() {
400 let conn = self
401 .stable
402 .connect(node_addr.clone(), FEDIMINT_GATEWAY_ALPN)
403 .await
404 .map_err(|err| ConnectorError::Transport(err.into()))?;
405
406 #[cfg(not(target_family = "wasm"))]
407 Self::spawn_connection_monitoring_stable(
408 &self.stable,
409 node_id,
410 self.path_change.clone(),
411 );
412
413 Ok(IGatewayConnection::into_dyn(conn))
414 } else {
415 let conn = self
416 .stable
417 .connect(node_id, FEDIMINT_GATEWAY_ALPN)
418 .await
419 .map_err(|err| ConnectorError::Transport(err.into()))?;
420 Ok(IGatewayConnection::into_dyn(conn))
421 }
422 }
423
424 fn connectivity(&self, url: &SafeUrl) -> Connectivity {
425 let Ok(node_id) = Self::node_id_from_url(url) else {
426 return Connectivity::Unknown;
427 };
428
429 if let Some(connectivity) = self.connectivity_next(node_id) {
435 return connectivity;
436 }
437
438 let Ok(watcher) = self.stable.conn_type(node_id) else {
439 return Connectivity::Unknown;
440 };
441 match watcher.get() {
442 Ok(iroh::endpoint::ConnectionType::Direct(_)) => Connectivity::Direct,
443 Ok(iroh::endpoint::ConnectionType::Relay(_)) => Connectivity::Relay,
444 Ok(iroh::endpoint::ConnectionType::Mixed(..)) => Connectivity::Mixed,
445 Ok(iroh::endpoint::ConnectionType::None) | Err(_) => Connectivity::Unknown,
446 }
447 }
448
449 async fn iroh_peer_info(
450 &self,
451 url: &SafeUrl,
452 path_timeout: Duration,
453 ) -> ServerResult<Option<IrohPeerInfo>> {
454 let node_id =
455 Self::node_id_from_url(url).map_err(|source| ServerError::InvalidPeerUrl {
456 source: Box::new(source),
457 url: url.to_owned(),
458 })?;
459 let connection_override = self.connection_overrides.get(&node_id).cloned();
460 let _connection = self
461 .make_new_connection_stable(node_id, connection_override)
462 .await?;
463
464 let mut conn_type_watcher = self
465 .stable
466 .conn_type(node_id)
467 .map_err(|err| ServerError::Connection(err.into()))?;
468 let mut conn_type = conn_type_watcher
469 .get()
470 .unwrap_or(iroh::endpoint::ConnectionType::None);
471
472 if path_timeout > Duration::ZERO {
473 let timeout = fedimint_core::runtime::sleep(path_timeout);
474 tokio::pin!(timeout);
475
476 while !matches!(
477 conn_type,
478 iroh::endpoint::ConnectionType::Direct(_)
479 | iroh::endpoint::ConnectionType::Mixed(..)
480 ) {
481 tokio::select! {
482 () = &mut timeout => break,
483 updated = conn_type_watcher.updated() => {
484 match updated {
485 Ok(updated) => conn_type = updated,
486 Err(_) => break,
487 }
488 }
489 }
490 }
491 }
492
493 Ok(Some(self.iroh_peer_info_from_conn_type(node_id, conn_type)))
494 }
495}
496
497impl IrohConnector {
498 fn connectivity_next(&self, node_id: NodeId) -> Option<Connectivity> {
506 let connections = self
507 .next_connections
508 .lock()
509 .expect("Next connection mutex is never held across a panic");
510
511 let connection = connections.get(&node_id)?;
512
513 if connection.close_reason().is_some() {
514 return None;
515 }
516
517 let paths = connection.paths();
521 let direct = paths.iter().any(|path| path.is_ip());
522 let relay = paths.iter().any(|path| path.is_relay());
523
524 Some(match (direct, relay) {
525 (true, true) => Connectivity::Mixed,
526 (true, false) => Connectivity::Direct,
527 (false, true) => Connectivity::Relay,
528 (false, false) => return None,
532 })
533 }
534
535 fn iroh_peer_info_from_conn_type(
536 &self,
537 node_id: NodeId,
538 conn_type: iroh::endpoint::ConnectionType,
539 ) -> IrohPeerInfo {
540 let remote_info = self.stable.remote_info(node_id);
541
542 let direct_addr = match &conn_type {
543 iroh::endpoint::ConnectionType::Direct(addr)
544 | iroh::endpoint::ConnectionType::Mixed(addr, _) => Some(*addr),
545 iroh::endpoint::ConnectionType::Relay(_) | iroh::endpoint::ConnectionType::None => None,
546 };
547
548 let mut known_direct_addrs = remote_info
549 .as_ref()
550 .map(|info| {
551 info.addrs
552 .iter()
553 .map(|addr_info| addr_info.addr)
554 .collect::<BTreeSet<_>>()
555 })
556 .unwrap_or_default();
557 if let Some(direct_addr) = direct_addr {
558 known_direct_addrs.insert(direct_addr);
559 }
560
561 let relay_url = match &conn_type {
562 iroh::endpoint::ConnectionType::Relay(relay_url)
563 | iroh::endpoint::ConnectionType::Mixed(_, relay_url) => Some(relay_url.to_string()),
564 iroh::endpoint::ConnectionType::Direct(_) | iroh::endpoint::ConnectionType::None => {
565 remote_info.and_then(|info| info.relay_url.map(|relay| relay.relay_url.to_string()))
566 }
567 };
568
569 IrohPeerInfo {
570 node_id: node_id.to_string(),
571 connectivity: connectivity_from_iroh_conn_type(&conn_type),
572 direct_addr,
573 known_direct_addrs: known_direct_addrs.into_iter().collect(),
574 relay_url,
575 }
576 }
577
578 #[cfg(not(target_family = "wasm"))]
579 fn spawn_connection_monitoring_stable(
580 endpoint: &Endpoint,
581 node_id: NodeId,
582 path_change: Arc<watch::Sender<u64>>,
583 ) {
584 if let Ok(mut conn_type_watcher) = endpoint.conn_type(node_id) {
585 #[allow(clippy::let_underscore_future)]
586 let _ = spawn("iroh connection (stable)", async move {
587 if let Ok(conn_type) = conn_type_watcher.get() {
588 debug!(target: LOG_NET_IROH, %node_id, type = %conn_type, "Connection type (initial)");
589 }
590 while let Ok(event) = conn_type_watcher.updated().await {
591 debug!(target: LOG_NET_IROH, %node_id, type = %event, "Connection type (changed)");
592 path_change.send_modify(|c| *c = c.wrapping_add(1));
593 }
594 });
595 }
596 }
597
598 #[cfg(not(target_family = "wasm"))]
599 fn spawn_connection_monitoring_next(
600 conn: &iroh_next::endpoint::Connection,
601 node_id: iroh_next::EndpointId,
602 path_change: Arc<watch::Sender<u64>>,
603 ) {
604 let conn = conn.clone();
605 #[allow(clippy::let_underscore_future)]
606 let _ = spawn("iroh connection (next)", async move {
607 let mut paths = conn.paths_stream();
608 if let Some(paths) = paths.next().await {
609 debug!(target: LOG_NET_IROH, %node_id, ?paths, "Connection paths (initial)");
610 }
611 while let Some(paths) = paths.next().await {
612 debug!(target: LOG_NET_IROH, %node_id, ?paths, "Connection paths changed");
613 path_change.send_modify(|c| *c = c.wrapping_add(1));
614 }
615 });
616 }
617
618 async fn make_new_connection_stable(
619 &self,
620 node_id: NodeId,
621 node_addr: Option<NodeAddr>,
622 ) -> ServerResult<Connection> {
623 trace!(target: LOG_NET_IROH, %node_id, "Creating new stable connection");
624 let conn = match node_addr.clone() {
625 Some(node_addr) => {
626 trace!(target: LOG_NET_IROH, %node_id, "Using a connectivity override for connection");
627 let conn = self.stable
628 .connect(node_addr.clone(), FEDIMINT_API_ALPN)
629 .await;
630
631 #[cfg(not(target_family = "wasm"))]
632 if conn.is_ok() {
633 Self::spawn_connection_monitoring_stable(
634 &self.stable,
635 node_id,
636 self.path_change.clone(),
637 );
638 }
639 conn
640 }
641 None => self.stable.connect(node_id, FEDIMINT_API_ALPN).await,
642 }.map_err(|err| ServerError::Connection(err.into()))?;
643
644 Ok(conn)
645 }
646
647 async fn make_new_connection_next(
648 &self,
649 endpoint_next: &iroh_next::Endpoint,
650 node_id: NodeId,
651 node_addr: Option<NodeAddr>,
652 ) -> ServerResult<iroh_next::endpoint::Connection> {
653 let next_node_id =
654 iroh_next::EndpointId::from_bytes(node_id.as_bytes()).expect("Can't fail");
655
656 let endpoint_next = endpoint_next.clone();
657
658 trace!(target: LOG_NET_IROH, %node_id, "Creating new next connection");
659 let conn = match node_addr.clone() {
660 Some(node_addr) => {
661 trace!(target: LOG_NET_IROH, %node_id, "Using a connectivity override for connection");
662 let node_addr = node_addr_stable_to_next(&node_addr);
663 let conn = endpoint_next
664 .connect(node_addr.clone(), FEDIMINT_API_ALPN)
665 .await;
666
667 #[cfg(not(target_family = "wasm"))]
668 if let Ok(conn) = &conn {
669 Self::spawn_connection_monitoring_next(
670 conn,
671 node_addr.id,
672 self.path_change.clone(),
673 );
674 }
675
676 conn
677 }
678 None => endpoint_next.connect(
679 next_node_id,
680 FEDIMINT_API_ALPN
681 ).await,
682 }
683 .map_err(|err| ServerError::Connection(err.into()))?;
684
685 self.next_connections
688 .lock()
689 .expect("Next connection mutex is never held across a panic")
690 .insert(node_id, conn.clone());
691
692 Ok(conn)
693 }
694}
695
696fn quic_transport_config() -> iroh::endpoint::TransportConfig {
699 let mut config = iroh::endpoint::TransportConfig::default();
700 config.max_idle_timeout(Some(
701 IROH_IDLE_TIMEOUT
702 .try_into()
703 .expect("idle timeout fits in IdleTimeout"),
704 ));
705 config.keep_alive_interval(Some(IROH_KEEP_ALIVE_INTERVAL));
706 config
707}
708
709fn quic_transport_config_next() -> iroh_next::endpoint::QuicTransportConfig {
712 iroh_next::endpoint::QuicTransportConfig::builder()
713 .max_idle_timeout(Some(
714 IROH_IDLE_TIMEOUT
715 .try_into()
716 .expect("idle timeout fits in IdleTimeout"),
717 ))
718 .keep_alive_interval(IROH_KEEP_ALIVE_INTERVAL)
719 .build()
720}
721
722fn connectivity_from_iroh_conn_type(conn_type: &iroh::endpoint::ConnectionType) -> Connectivity {
723 match conn_type {
724 iroh::endpoint::ConnectionType::Direct(_) => Connectivity::Direct,
725 iroh::endpoint::ConnectionType::Relay(_) => Connectivity::Relay,
726 iroh::endpoint::ConnectionType::Mixed(..) => Connectivity::Mixed,
727 iroh::endpoint::ConnectionType::None => Connectivity::Unknown,
728 }
729}
730
731fn node_addr_stable_to_next(stable: &iroh::NodeAddr) -> iroh_next::EndpointAddr {
732 let next_node_id =
733 iroh_next::EndpointId::from_bytes(stable.node_id.as_bytes()).expect("Can't fail");
734 let relay_addrs = stable.relay_url.iter().map(|u| {
735 iroh_next::TransportAddr::Relay(
736 iroh_next::RelayUrl::from_str(&u.to_string()).expect("Can't fail"),
737 )
738 });
739 let direct_addrs = stable
740 .direct_addresses
741 .iter()
742 .copied()
743 .map(iroh_next::TransportAddr::Ip);
744
745 iroh_next::EndpointAddr::from_parts(next_node_id, relay_addrs.chain(direct_addrs))
746}
747
748#[apply(async_trait_maybe_send!)]
749impl IConnection for Connection {
750 async fn await_disconnection(&self) {
751 self.closed().await;
752 }
753
754 fn is_connected(&self) -> bool {
755 self.close_reason().is_none()
756 }
757}
758
759#[async_trait]
760impl IGuardianConnection for Connection {
761 async fn request(&self, method: ApiMethod, request: ApiRequestErased) -> ServerResult<Value> {
762 let timeout = request_timeout_for_method(&method);
763 let method_str = method.to_string();
764 let json = serde_json::to_vec(&IrohApiRequest { method, request })
765 .expect("Serialization to vec can't fail");
766
767 let result = fedimint_core::runtime::timeout(timeout, async {
768 let (mut sink, mut stream) = self
769 .open_bi()
770 .await
771 .map_err(|e| ServerError::Transport(e.into()))?;
772
773 sink.write_all(&json)
774 .await
775 .map_err(|e| ServerError::Transport(e.into()))?;
776
777 sink.finish()
778 .map_err(|e| ServerError::Transport(e.into()))?;
779
780 stream
781 .read_to_end(IROH_MAX_RESPONSE_BYTES)
782 .await
783 .map_err(|e| ServerError::Transport(e.into()))
784 })
785 .await;
786
787 let response = match result {
788 Ok(Ok(bytes)) => bytes,
789 Ok(Err(err)) => return Err(err),
790 Err(_) => {
791 warn!(
798 target: LOG_NET_IROH,
799 method = %method_str,
800 timeout_secs = timeout.as_secs(),
801 "iroh request timed out, closing connection",
802 );
803 self.close(
804 iroh::endpoint::VarInt::from_u32(IROH_REQUEST_TIMEOUT_ERROR_CODE),
805 IROH_REQUEST_TIMEOUT_ERROR_REASON,
806 );
807 return Err(ServerError::Transport(
808 format!("iroh request {method_str} timed out after {timeout:?}").into(),
809 ));
810 }
811 };
812
813 let response = serde_json::from_slice::<Result<Value, ApiError>>(&response)
815 .map_err(|e| ServerError::InvalidResponse(e.fmt_compact().to_string()))?;
816
817 response.map_err(|e| ServerError::InvalidResponse(format!("Api Error: {e:?}")))
818 }
819}
820
821#[apply(async_trait_maybe_send!)]
822impl IConnection for iroh_next::endpoint::Connection {
823 async fn await_disconnection(&self) {
824 self.closed().await;
825 }
826
827 fn is_connected(&self) -> bool {
828 self.close_reason().is_none()
829 }
830}
831
832#[async_trait]
833impl IGuardianConnection for iroh_next::endpoint::Connection {
834 async fn request(&self, method: ApiMethod, request: ApiRequestErased) -> ServerResult<Value> {
835 let timeout = request_timeout_for_method(&method);
836 let method_str = method.to_string();
837 let json = serde_json::to_vec(&IrohApiRequest { method, request })
838 .expect("Serialization to vec can't fail");
839
840 let result = fedimint_core::runtime::timeout(timeout, async {
841 let (mut sink, mut stream) = self
842 .open_bi()
843 .await
844 .map_err(|e| ServerError::Transport(e.into()))?;
845
846 sink.write_all(&json)
847 .await
848 .map_err(|e| ServerError::Transport(e.into()))?;
849
850 sink.finish()
851 .map_err(|e| ServerError::Transport(e.into()))?;
852
853 stream
854 .read_to_end(IROH_MAX_RESPONSE_BYTES)
855 .await
856 .map_err(|e| ServerError::Transport(e.into()))
857 })
858 .await;
859
860 let response = match result {
861 Ok(Ok(bytes)) => bytes,
862 Ok(Err(err)) => return Err(err),
863 Err(_) => {
864 warn!(
865 target: LOG_NET_IROH,
866 method = %method_str,
867 timeout_secs = timeout.as_secs(),
868 "iroh request timed out, closing connection",
869 );
870 self.close(
871 iroh_next::endpoint::VarInt::from_u32(IROH_REQUEST_TIMEOUT_ERROR_CODE),
872 IROH_REQUEST_TIMEOUT_ERROR_REASON,
873 );
874 return Err(ServerError::Transport(
875 format!("iroh request {method_str} timed out after {timeout:?}").into(),
876 ));
877 }
878 };
879
880 let response = serde_json::from_slice::<Result<Value, ApiError>>(&response)
882 .map_err(|e| ServerError::InvalidResponse(e.fmt_compact().to_string()))?;
883
884 response.map_err(|e| ServerError::InvalidResponse(format!("Api Error: {e:?}")))
885 }
886}
887
888#[apply(async_trait_maybe_send!)]
889impl IGatewayConnection for Connection {
890 async fn request(
891 &self,
892 password: Option<String>,
893 _method: Method,
894 route: &str,
895 payload: Option<Value>,
896 ) -> ServerResult<Value> {
897 let iroh_request = IrohGatewayRequest {
898 route: route.to_string(),
899 params: payload,
900 password,
901 };
902 let json = serde_json::to_vec(&iroh_request).expect("serialization cant fail");
903
904 let (mut sink, mut stream) = self
905 .open_bi()
906 .await
907 .map_err(|e| ServerError::Transport(e.into()))?;
908
909 sink.write_all(&json)
910 .await
911 .map_err(|e| ServerError::Transport(e.into()))?;
912
913 sink.finish()
914 .map_err(|e| ServerError::Transport(e.into()))?;
915
916 let response = stream
917 .read_to_end(IROH_MAX_RESPONSE_BYTES)
918 .await
919 .map_err(|e| ServerError::Transport(e.into()))?;
920
921 let response = serde_json::from_slice::<IrohGatewayResponse>(&response)
922 .map_err(|e| ServerError::InvalidResponse(e.fmt_compact().to_string()))?;
923 match StatusCode::from_u16(response.status)
924 .map_err(|e| ServerError::InvalidResponse(format!("Invalid status code: {e}")))?
925 {
926 StatusCode::OK => Ok(response.body),
927 status => Err(ServerError::ServerError(format!(
928 "Server returned status code: {status}"
929 ))),
930 }
931 }
932}
933
934#[cfg(test)]
935mod tests {
936 use std::str::FromStr as _;
937
938 use fedimint_core::PeerId;
939 use fedimint_core::config::FederationId;
940 use fedimint_core::invite_code::InviteCode;
941 use fedimint_core::module::ApiMethod;
942 use fedimint_core::util::SafeUrl;
943
944 use super::{
945 IROH_REQUEST_TIMEOUT_DEFAULT, IROH_REQUEST_TIMEOUT_LONG_POLL, IrohConnector,
946 request_timeout_for_method,
947 };
948 use crate::error::ConnectorError;
949 use crate::{iroh_next_endpoint_url, is_iroh_next_endpoint_url, preserve_iroh_next_marker};
950
951 const TEST_ENDPOINT_ID: &str =
952 "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c";
953
954 #[test]
955 fn advertised_iroh_next_url_selects_only_the_next_stack() {
956 let next_url = iroh_next_endpoint_url(TEST_ENDPOINT_ID).expect("valid endpoint ID");
957 assert!(is_iroh_next_endpoint_url(&next_url).expect("valid Iroh API URL path"));
958
959 let invite = InviteCode::new(next_url, PeerId::from(0), FederationId::dummy(), None);
960 let round_tripped =
961 InviteCode::from_str(&invite.to_string()).expect("invite code round-trips");
962 assert!(is_iroh_next_endpoint_url(&round_tripped.url()).expect("valid Iroh API URL path"));
963
964 let stable_url =
965 SafeUrl::parse(&format!("iroh://{TEST_ENDPOINT_ID}")).expect("valid Iroh URL");
966 assert!(!is_iroh_next_endpoint_url(&stable_url).expect("valid Iroh API URL path"));
967
968 let replacement = SafeUrl::parse(
969 "iroh://d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
970 )
971 .expect("valid replacement URL");
972 let replacement = preserve_iroh_next_marker(&round_tripped.url(), &replacement);
973 assert!(is_iroh_next_endpoint_url(&replacement).expect("valid Iroh API URL path"));
974 }
975
976 #[test]
977 fn unsupported_iroh_url_path_is_typed() {
978 let url = SafeUrl::parse("iroh://someendpoint/v2").expect("valid url");
979 assert!(
980 matches!(
981 is_iroh_next_endpoint_url(&url),
982 Err(ConnectorError::UnsupportedUrlPath { .. })
983 ),
984 "{:?}",
985 is_iroh_next_endpoint_url(&url)
986 );
987 }
988
989 #[test]
990 fn garbage_endpoint_id_is_an_invalid_node_id() {
991 let err = iroh_next_endpoint_url("not-an-endpoint-id")
992 .expect_err("garbage is not an endpoint id");
993 assert!(
994 matches!(err, ConnectorError::InvalidNodeId { .. }),
995 "{err:?}"
996 );
997 }
998
999 #[test]
1000 fn a_non_iroh_url_has_an_unsupported_scheme() {
1001 let url = SafeUrl::parse("ws://example.com").expect("valid url");
1002 let err = IrohConnector::node_id_from_url(&url).expect_err("ws is not iroh");
1003 assert!(
1004 matches!(err, ConnectorError::UnsupportedScheme { .. }),
1005 "{err:?}"
1006 );
1007 }
1008
1009 const AWAIT_ENDPOINTS: &[&str] = &[
1015 "await_output_outcome",
1017 "await_outputs_outcomes",
1018 "await_session_outcome",
1019 "await_signed_session_outcome",
1020 "await_transaction",
1021 "await_account",
1023 "await_block_height",
1024 "await_offer",
1025 "await_outgoing_contract_cancelled",
1026 "await_preimage_decryption",
1027 "await_incoming_contract",
1029 "await_incoming_contracts",
1030 "await_preimage",
1031 ];
1032
1033 const PROMPT_ENDPOINTS: &[&str] = &[
1036 "block_count",
1037 "session_count",
1038 "session_status",
1039 "status",
1040 "version",
1041 "client_config",
1042 "audit",
1043 "account",
1044 "offer",
1045 "list_gateways",
1046 "submit_transaction",
1047 "consensus_block_count",
1048 ];
1049
1050 #[test]
1051 fn await_prefix_gets_long_poll_timeout() {
1052 for name in AWAIT_ENDPOINTS {
1053 assert_eq!(
1054 request_timeout_for_method(&ApiMethod::Core((*name).to_owned())),
1055 IROH_REQUEST_TIMEOUT_LONG_POLL,
1056 "core endpoint {name} should map to the long-poll timeout"
1057 );
1058 assert_eq!(
1059 request_timeout_for_method(&ApiMethod::Module(0, (*name).to_owned())),
1060 IROH_REQUEST_TIMEOUT_LONG_POLL,
1061 "module endpoint {name} should map to the long-poll timeout"
1062 );
1063 }
1064 }
1065
1066 #[test]
1067 fn wait_prefix_also_gets_long_poll_timeout() {
1068 assert_eq!(
1072 request_timeout_for_method(&ApiMethod::Core("wait_for_event".to_owned())),
1073 IROH_REQUEST_TIMEOUT_LONG_POLL,
1074 );
1075 }
1076
1077 #[test]
1078 fn prompt_endpoints_get_default_timeout() {
1079 for name in PROMPT_ENDPOINTS {
1080 assert_eq!(
1081 request_timeout_for_method(&ApiMethod::Core((*name).to_owned())),
1082 IROH_REQUEST_TIMEOUT_DEFAULT,
1083 "endpoint {name} should map to the default timeout"
1084 );
1085 }
1086 }
1087
1088 #[test]
1089 fn endpoints_that_merely_contain_await_are_not_misclassified() {
1090 assert_eq!(
1094 request_timeout_for_method(&ApiMethod::Core("submit_await_thing".to_owned())),
1095 IROH_REQUEST_TIMEOUT_DEFAULT,
1096 );
1097 }
1098}