Skip to main content

fedimint_api_client/api/
mod.rs

1mod error;
2pub mod global_api;
3
4use std::collections::{BTreeMap, BTreeSet};
5use std::fmt::Debug;
6use std::future::pending;
7use std::pin::Pin;
8use std::result;
9use std::sync::Arc;
10
11use anyhow::{Context, anyhow};
12use bitcoin::secp256k1;
13pub use error::{FederationError, OutputOutcomeError};
14pub use fedimint_connectors::ServerResult;
15pub use fedimint_connectors::error::ServerError;
16use fedimint_connectors::{
17    ConnectionPool, Connectivity, ConnectorRegistry, DynGuaridianConnection, IGuardianConnection,
18    PeerStatus,
19};
20use fedimint_core::admin_client::{GuardianConfigBackup, ServerStatusLegacy, SetupStatus};
21use fedimint_core::backup::{BackupStatistics, ClientBackupSnapshot};
22use fedimint_core::core::backup::SignedBackupRequest;
23use fedimint_core::core::{Decoder, DynOutputOutcome, ModuleInstanceId, ModuleKind, OutputOutcome};
24use fedimint_core::encoding::{Decodable, Encodable};
25use fedimint_core::invite_code::InviteCode;
26use fedimint_core::module::audit::AuditSummary;
27use fedimint_core::module::registry::ModuleDecoderRegistry;
28use fedimint_core::module::{
29    ApiAuth, ApiMethod, ApiRequestErased, ApiVersion, SerdeModuleEncoding,
30};
31use fedimint_core::net::api_announcement::SignedApiAnnouncement;
32use fedimint_core::net::guardian_metadata::SignedGuardianMetadata;
33use fedimint_core::session_outcome::{SessionOutcome, SessionStatus};
34use fedimint_core::task::{MaybeSend, MaybeSync};
35use fedimint_core::transaction::{Transaction, TransactionSubmissionOutcome};
36use fedimint_core::util::backoff_util::api_networking_backoff;
37use fedimint_core::util::{FmtCompact as _, SafeUrl};
38use fedimint_core::{
39    ChainId, NumPeersExt, PeerId, TransactionId, apply, async_trait_maybe_send, dyn_newtype_define,
40    util,
41};
42use fedimint_logging::LOG_CLIENT_NET_API;
43use fedimint_metrics::HistogramExt as _;
44use futures::stream::{BoxStream, FuturesUnordered};
45use futures::{Future, StreamExt};
46use global_api::with_cache::GlobalFederationApiWithCache;
47use jsonrpsee_core::DeserializeOwned;
48use serde::{Deserialize, Serialize};
49use serde_json::Value;
50use tokio::sync::watch;
51use tokio_stream::wrappers::WatchStream;
52use tracing::{debug, instrument, trace, warn};
53
54use crate::metrics::{CLIENT_API_REQUEST_DURATION_SECONDS, CLIENT_API_REQUESTS_TOTAL};
55use crate::query::{QueryStep, QueryStrategy, ThresholdConsensus};
56
57pub const VERSION_THAT_INTRODUCED_GET_SESSION_STATUS_V2: ApiVersion = ApiVersion::new(0, 5);
58
59pub const VERSION_THAT_INTRODUCED_GET_SESSION_STATUS: ApiVersion =
60    ApiVersion { major: 0, minor: 1 };
61
62pub const VERSION_THAT_INTRODUCED_AWAIT_OUTPUTS_OUTCOMES: ApiVersion = ApiVersion::new(0, 8);
63pub type FederationResult<T> = Result<T, FederationError>;
64pub type SerdeOutputOutcome = SerdeModuleEncoding<DynOutputOutcome>;
65
66pub type OutputOutcomeResult<O> = result::Result<O, OutputOutcomeError>;
67
68/// Set of api versions for each component (core + modules)
69///
70/// E.g. result of federated common api versions discovery.
71#[derive(Debug, Clone, Serialize, Deserialize, Encodable, Decodable)]
72pub struct ApiVersionSet {
73    pub core: ApiVersion,
74    pub modules: BTreeMap<ModuleInstanceId, ApiVersion>,
75}
76
77/// An API (module or global) that can query a federation
78#[apply(async_trait_maybe_send!)]
79pub trait IRawFederationApi: Debug + MaybeSend + MaybeSync {
80    /// List of all federation peers for the purpose of iterating each peer
81    /// in the federation.
82    ///
83    /// The underlying implementation is responsible for knowing how many
84    /// and `PeerId`s of each. The caller of this interface most probably
85    /// have some idea as well, but passing this set across every
86    /// API call to the federation would be inconvenient.
87    fn all_peers(&self) -> &BTreeSet<PeerId>;
88
89    /// `PeerId` of the Guardian node, if set
90    ///
91    /// This is for using Client in a "Admin" mode, making authenticated
92    /// calls to own `fedimintd` instance.
93    fn self_peer(&self) -> Option<PeerId>;
94
95    fn with_module(&self, id: ModuleInstanceId) -> DynModuleApi;
96
97    /// Make request to a specific federation peer by `peer_id`
98    async fn request_raw(
99        &self,
100        peer_id: PeerId,
101        method: &str,
102        params: &ApiRequestErased,
103    ) -> ServerResult<Value>;
104
105    /// Returns a stream of connection status for each peer.
106    ///
107    /// The stream emits a new value whenever either the set of active
108    /// connections in the pool changes, or a connector observes a
109    /// transport-level path change on an existing connection (e.g. an iroh
110    /// connection upgrading from relay to direct).
111    ///
112    /// Each peer's entry is [`PeerStatus::Disconnected`] if there is no
113    /// active pooled connection, or [`PeerStatus::Connected`] carrying the
114    /// current [`fedimint_connectors::Connectivity`] otherwise. If the pool
115    /// reports the peer as active but the underlying connector no longer
116    /// has a known path for it (a disconnection racing the pool update),
117    /// the peer is reported as [`PeerStatus::Disconnected`] — the next
118    /// emission will confirm.
119    fn connection_status_stream(&self) -> BoxStream<'static, BTreeMap<PeerId, PeerStatus>>;
120    /// Wait for some connections being initialized
121    ///
122    /// This is useful to avoid initializing networking by
123    /// tasks that are not high priority.
124    async fn wait_for_initialized_connections(&self);
125
126    /// Get or create a connection to a specific peer, returning the connection
127    /// object.
128    ///
129    /// This can be used to monitor connection status and await disconnection
130    /// for proactive reconnection strategies.
131    async fn get_peer_connection(&self, peer_id: PeerId) -> ServerResult<DynGuaridianConnection>;
132}
133
134/// An extension trait allowing to making federation-wide API call on top
135/// [`IRawFederationApi`].
136#[apply(async_trait_maybe_send!)]
137pub trait FederationApiExt: IRawFederationApi {
138    async fn request_single_peer<Ret>(
139        &self,
140        method: String,
141        params: ApiRequestErased,
142        peer: PeerId,
143    ) -> ServerResult<Ret>
144    where
145        Ret: DeserializeOwned,
146    {
147        self.request_raw(peer, &method, &params)
148            .await
149            .and_then(|v| {
150                serde_json::from_value(v)
151                    .map_err(|e| ServerError::ResponseDeserialization(e.into()))
152            })
153    }
154
155    async fn request_single_peer_federation<FedRet>(
156        &self,
157        method: String,
158        params: ApiRequestErased,
159        peer_id: PeerId,
160    ) -> FederationResult<FedRet>
161    where
162        FedRet: serde::de::DeserializeOwned + Eq + Debug + Clone + MaybeSend,
163    {
164        self.request_raw(peer_id, &method, &params)
165            .await
166            .and_then(|v| {
167                serde_json::from_value(v)
168                    .map_err(|e| ServerError::ResponseDeserialization(e.into()))
169            })
170            .map_err(|e| error::FederationError::new_one_peer(peer_id, method, params, e))
171    }
172
173    /// Make an aggregate request to federation, using `strategy` to logically
174    /// merge the responses.
175    #[instrument(target = LOG_CLIENT_NET_API, skip_all, fields(method=method))]
176    async fn request_with_strategy<PR: DeserializeOwned, FR: Debug>(
177        &self,
178        mut strategy: impl QueryStrategy<PR, FR> + MaybeSend,
179        method: String,
180        params: ApiRequestErased,
181    ) -> FederationResult<FR> {
182        // NOTE: `FuturesUnorderded` is a footgun, but all we do here is polling
183        // completed results from it and we don't do any `await`s when
184        // processing them, it should be totally OK.
185        #[cfg(not(target_family = "wasm"))]
186        let mut futures = FuturesUnordered::<Pin<Box<dyn Future<Output = _> + Send>>>::new();
187        #[cfg(target_family = "wasm")]
188        let mut futures = FuturesUnordered::<Pin<Box<dyn Future<Output = _>>>>::new();
189
190        for peer in self.all_peers() {
191            futures.push(Box::pin({
192                let method = &method;
193                let params = &params;
194                async move {
195                    let result = self
196                        .request_single_peer(method.clone(), params.clone(), *peer)
197                        .await;
198
199                    (*peer, result)
200                }
201            }));
202        }
203
204        let mut peer_errors = BTreeMap::new();
205        let peer_error_threshold = self.all_peers().to_num_peers().one_honest();
206
207        loop {
208            let (peer, result) = futures
209                .next()
210                .await
211                .expect("Query strategy ran out of peers to query without returning a result");
212
213            match result {
214                Ok(response) => match strategy.process(peer, response) {
215                    QueryStep::Retry(peers) => {
216                        for peer in peers {
217                            futures.push(Box::pin({
218                                let method = &method;
219                                let params = &params;
220                                async move {
221                                    let result = self
222                                        .request_single_peer(method.clone(), params.clone(), peer)
223                                        .await;
224
225                                    (peer, result)
226                                }
227                            }));
228                        }
229                    }
230                    QueryStep::Success(response) => return Ok(response),
231                    QueryStep::Failure(e) => {
232                        peer_errors.insert(peer, e);
233                    }
234                    QueryStep::Continue => {}
235                },
236                Err(e) => {
237                    e.report_if_unusual(peer, "RequestWithStrategy");
238                    peer_errors.insert(peer, e);
239                }
240            }
241
242            if peer_errors.len() == peer_error_threshold {
243                return Err(FederationError::peer_errors(
244                    method.clone(),
245                    params.params.clone(),
246                    peer_errors,
247                ));
248            }
249        }
250    }
251
252    #[instrument(target = LOG_CLIENT_NET_API, level = "debug", skip(self, strategy))]
253    async fn request_with_strategy_retry<PR: DeserializeOwned + MaybeSend, FR: Debug>(
254        &self,
255        mut strategy: impl QueryStrategy<PR, FR> + MaybeSend,
256        method: String,
257        params: ApiRequestErased,
258    ) -> FR {
259        // NOTE: `FuturesUnorderded` is a footgun, but all we do here is polling
260        // completed results from it and we don't do any `await`s when
261        // processing them, it should be totally OK.
262        #[cfg(not(target_family = "wasm"))]
263        let mut futures = FuturesUnordered::<Pin<Box<dyn Future<Output = _> + Send>>>::new();
264        #[cfg(target_family = "wasm")]
265        let mut futures = FuturesUnordered::<Pin<Box<dyn Future<Output = _>>>>::new();
266
267        for peer in self.all_peers() {
268            futures.push(Box::pin({
269                let method = &method;
270                let params = &params;
271                async move {
272                    let response = util::retry(
273                        format!("api-request-{method}-{peer}"),
274                        api_networking_backoff(),
275                        || async {
276                            self.request_single_peer(method.clone(), params.clone(), *peer)
277                                .await
278                                .inspect_err(|e| {
279                                    e.report_if_unusual(*peer, "QueryWithStrategyRetry");
280                                })
281                                .map_err(|e| anyhow!(e.to_string()))
282                        },
283                    )
284                    .await
285                    .expect("Number of retries has no limit");
286
287                    (*peer, response)
288                }
289            }));
290        }
291
292        loop {
293            let (peer, response) = match futures.next().await {
294                Some(t) => t,
295                None => pending().await,
296            };
297
298            match strategy.process(peer, response) {
299                QueryStep::Retry(peers) => {
300                    for peer in peers {
301                        futures.push(Box::pin({
302                            let method = &method;
303                            let params = &params;
304                            async move {
305                                let response = util::retry(
306                                    format!("api-request-{method}-{peer}"),
307                                    api_networking_backoff(),
308                                    || async {
309                                        self.request_single_peer(
310                                            method.clone(),
311                                            params.clone(),
312                                            peer,
313                                        )
314                                        .await
315                                        .inspect_err(|err| {
316                                            if err.is_unusual() {
317                                                debug!(target: LOG_CLIENT_NET_API, err = %err.fmt_compact(), "Unusual peer error");
318                                            }
319                                        })
320                                        .map_err(|e| anyhow!(e.to_string()))
321                                    },
322                                )
323                                .await
324                                .expect("Number of retries has no limit");
325
326                                (peer, response)
327                            }
328                        }));
329                    }
330                }
331                QueryStep::Success(response) => return response,
332                QueryStep::Failure(e) => {
333                    warn!(target: LOG_CLIENT_NET_API, "Query strategy returned non-retryable failure for peer {peer}: {e}");
334                }
335                QueryStep::Continue => {}
336            }
337        }
338    }
339
340    async fn request_current_consensus<Ret>(
341        &self,
342        method: String,
343        params: ApiRequestErased,
344    ) -> FederationResult<Ret>
345    where
346        Ret: DeserializeOwned + Eq + Debug + Clone + MaybeSend,
347    {
348        self.request_with_strategy(
349            ThresholdConsensus::new(self.all_peers().to_num_peers()),
350            method,
351            params,
352        )
353        .await
354    }
355
356    async fn request_current_consensus_retry<Ret>(
357        &self,
358        method: String,
359        params: ApiRequestErased,
360    ) -> Ret
361    where
362        Ret: DeserializeOwned + Eq + Debug + Clone + MaybeSend,
363    {
364        self.request_with_strategy_retry(
365            ThresholdConsensus::new(self.all_peers().to_num_peers()),
366            method,
367            params,
368        )
369        .await
370    }
371
372    async fn request_admin<Ret>(
373        &self,
374        method: &str,
375        params: ApiRequestErased,
376        auth: ApiAuth,
377    ) -> FederationResult<Ret>
378    where
379        Ret: DeserializeOwned + Eq + Debug + Clone + MaybeSend,
380    {
381        let Some(self_peer_id) = self.self_peer() else {
382            return Err(FederationError::general(
383                method,
384                params,
385                anyhow::format_err!("Admin peer_id not set"),
386            ));
387        };
388
389        self.request_single_peer_federation(method.into(), params.with_auth(auth), self_peer_id)
390            .await
391    }
392
393    async fn request_admin_no_auth<Ret>(
394        &self,
395        method: &str,
396        params: ApiRequestErased,
397    ) -> FederationResult<Ret>
398    where
399        Ret: DeserializeOwned + Eq + Debug + Clone + MaybeSend,
400    {
401        let Some(self_peer_id) = self.self_peer() else {
402            return Err(FederationError::general(
403                method,
404                params,
405                anyhow::format_err!("Admin peer_id not set"),
406            ));
407        };
408
409        self.request_single_peer_federation(method.into(), params, self_peer_id)
410            .await
411    }
412}
413
414#[apply(async_trait_maybe_send!)]
415impl<T: ?Sized> FederationApiExt for T where T: IRawFederationApi {}
416
417/// Trait marker for the module (non-global) endpoints
418pub trait IModuleFederationApi: IRawFederationApi {}
419
420dyn_newtype_define! {
421    #[derive(Clone)]
422    pub DynModuleApi(Arc<IModuleFederationApi>)
423}
424
425dyn_newtype_define! {
426    #[derive(Clone)]
427    pub DynGlobalApi(Arc<IGlobalFederationApi>)
428}
429
430impl AsRef<dyn IGlobalFederationApi + 'static> for DynGlobalApi {
431    fn as_ref(&self) -> &(dyn IGlobalFederationApi + 'static) {
432        self.inner.as_ref()
433    }
434}
435
436impl DynGlobalApi {
437    pub fn new(
438        connectors: ConnectorRegistry,
439        peers: BTreeMap<PeerId, SafeUrl>,
440        api_secret: Option<&str>,
441    ) -> anyhow::Result<Self> {
442        Ok(GlobalFederationApiWithCache::new(FederationApi::new(
443            connectors, peers, None, api_secret,
444        ))
445        .into())
446    }
447    pub fn new_admin(
448        connectors: ConnectorRegistry,
449        peer: PeerId,
450        url: SafeUrl,
451        api_secret: Option<&str>,
452    ) -> anyhow::Result<DynGlobalApi> {
453        Ok(GlobalFederationApiWithCache::new(FederationApi::new(
454            connectors,
455            [(peer, url)].into(),
456            Some(peer),
457            api_secret,
458        ))
459        .into())
460    }
461
462    pub fn new_admin_setup(connectors: ConnectorRegistry, url: SafeUrl) -> anyhow::Result<Self> {
463        // PeerIds are used only for informational purposes, but just in case, make a
464        // big number so it stands out
465        Self::new_admin(
466            connectors,
467            PeerId::from(1024),
468            url,
469            // Setup does not have api secrets yet
470            None,
471        )
472    }
473}
474
475/// The API for the global (non-module) endpoints
476#[apply(async_trait_maybe_send!)]
477pub trait IGlobalFederationApi: IRawFederationApi {
478    async fn submit_transaction(
479        &self,
480        tx: Transaction,
481    ) -> SerdeModuleEncoding<TransactionSubmissionOutcome>;
482
483    async fn await_block(
484        &self,
485        block_index: u64,
486        decoders: &ModuleDecoderRegistry,
487    ) -> anyhow::Result<SessionOutcome>;
488
489    async fn get_session_status(
490        &self,
491        block_index: u64,
492        decoders: &ModuleDecoderRegistry,
493        core_api_version: ApiVersion,
494        broadcast_public_keys: Option<&BTreeMap<PeerId, secp256k1::PublicKey>>,
495    ) -> anyhow::Result<SessionStatus>;
496
497    async fn session_count(&self) -> FederationResult<u64>;
498
499    async fn await_transaction(&self, txid: TransactionId) -> TransactionId;
500
501    async fn upload_backup(&self, request: &SignedBackupRequest) -> FederationResult<()>;
502
503    async fn download_backup(
504        &self,
505        id: &secp256k1::PublicKey,
506    ) -> FederationResult<BTreeMap<PeerId, Option<ClientBackupSnapshot>>>;
507
508    async fn setup_status(&self, auth: ApiAuth) -> FederationResult<SetupStatus>;
509
510    async fn set_local_params(
511        &self,
512        name: String,
513        federation_name: Option<String>,
514        disable_base_fees: Option<bool>,
515        enabled_modules: Option<BTreeSet<ModuleKind>>,
516        federation_size: Option<u32>,
517        auth: ApiAuth,
518    ) -> FederationResult<String>;
519
520    async fn add_peer_connection_info(
521        &self,
522        info: String,
523        auth: ApiAuth,
524    ) -> FederationResult<String>;
525
526    /// Reset the peer setup codes during the federation setup process
527    async fn reset_peer_setup_codes(&self, auth: ApiAuth) -> FederationResult<()>;
528
529    /// Returns the setup code if `set_local_params` was already called
530    async fn get_setup_code(&self, auth: ApiAuth) -> FederationResult<Option<String>>;
531
532    /// Runs DKG, can only be called once after configs have been generated in
533    /// `get_consensus_config_gen_params`.  If DKG fails this returns a 500
534    /// error and config gen must be restarted.
535    async fn start_dkg(&self, auth: ApiAuth) -> FederationResult<()>;
536
537    /// Returns the status of the server
538    async fn status(&self) -> FederationResult<StatusResponse>;
539
540    /// Show an audit across all modules
541    async fn audit(&self, auth: ApiAuth) -> FederationResult<AuditSummary>;
542
543    /// Download the guardian config to back it up
544    async fn guardian_config_backup(&self, auth: ApiAuth)
545    -> FederationResult<GuardianConfigBackup>;
546
547    /// Check auth credentials
548    async fn auth(&self, auth: ApiAuth) -> FederationResult<()>;
549
550    async fn restart_federation_setup(&self, auth: ApiAuth) -> FederationResult<()>;
551
552    /// Publish our signed API announcement to other guardians
553    async fn submit_api_announcement(
554        &self,
555        peer_id: PeerId,
556        announcement: SignedApiAnnouncement,
557    ) -> FederationResult<()>;
558
559    async fn api_announcements(
560        &self,
561        guardian: PeerId,
562    ) -> ServerResult<BTreeMap<PeerId, SignedApiAnnouncement>>;
563
564    async fn sign_api_announcement(
565        &self,
566        api_url: SafeUrl,
567        auth: ApiAuth,
568    ) -> FederationResult<SignedApiAnnouncement>;
569
570    /// Publish our signed guardian metadata to other guardians
571    async fn submit_guardian_metadata(
572        &self,
573        peer_id: PeerId,
574        metadata: SignedGuardianMetadata,
575    ) -> FederationResult<()>;
576
577    async fn guardian_metadata(
578        &self,
579        guardian: PeerId,
580    ) -> ServerResult<BTreeMap<PeerId, SignedGuardianMetadata>>;
581
582    async fn sign_guardian_metadata(
583        &self,
584        metadata: fedimint_core::net::guardian_metadata::GuardianMetadata,
585        auth: ApiAuth,
586    ) -> FederationResult<SignedGuardianMetadata>;
587
588    async fn shutdown(&self, session: Option<u64>, auth: ApiAuth) -> FederationResult<()>;
589
590    /// Returns the fedimintd version a peer is running
591    async fn fedimintd_version(&self, peer_id: PeerId) -> ServerResult<String>;
592
593    /// Fetch the backup statistics from the federation (admin endpoint)
594    async fn backup_statistics(&self, auth: ApiAuth) -> FederationResult<BackupStatistics>;
595
596    /// Get the invite code for the federation guardian.
597    /// For instance, useful after DKG
598    async fn get_invite_code(&self, guardian: PeerId) -> ServerResult<InviteCode>;
599
600    /// Returns the chain ID (bitcoin block hash at height 1) from the
601    /// federation
602    async fn chain_id(&self) -> FederationResult<ChainId>;
603}
604
605pub fn deserialize_outcome<R>(
606    outcome: &SerdeOutputOutcome,
607    module_decoder: &Decoder,
608) -> OutputOutcomeResult<R>
609where
610    R: OutputOutcome + MaybeSend,
611{
612    let dyn_outcome = outcome
613        .try_into_inner_known_module_kind(module_decoder)
614        .map_err(|e| OutputOutcomeError::ResponseDeserialization(e.into()))?;
615
616    let source_instance = dyn_outcome.module_instance_id();
617
618    dyn_outcome.as_any().downcast_ref().cloned().ok_or_else(|| {
619        let target_type = std::any::type_name::<R>();
620        OutputOutcomeError::ResponseDeserialization(anyhow!(
621            "Could not downcast output outcome with instance id {source_instance} to {target_type}"
622        ))
623    })
624}
625
626/// Federation API client
627///
628/// The core underlying object used to make API requests to a federation.
629///
630/// It has an `connectors` handle to actually making outgoing connections
631/// to given URLs, and knows which peers there are and what URLs to connect to
632/// to reach them.
633// TODO: As it is currently it mixes a bit the role of connecting to "peers" with
634// general purpose outgoing connection. Not a big deal, but might need refactor
635// in the future.
636#[derive(Clone, Debug)]
637pub struct FederationApi {
638    /// Map of known URLs to use to connect to peers
639    peers: BTreeMap<PeerId, SafeUrl>,
640    /// List of peer ids, redundant to avoid collecting all the time
641    peers_keys: BTreeSet<PeerId>,
642    /// Our own [`PeerId`] to use when making admin apis
643    admin_id: Option<PeerId>,
644    /// Set when this API is used to communicate with a module
645    module_id: Option<ModuleInstanceId>,
646    /// Api secret of the federation
647    api_secret: Option<String>,
648    /// Connection pool
649    connection_pool: ConnectionPool<dyn IGuardianConnection>,
650}
651
652impl FederationApi {
653    pub fn new(
654        connectors: ConnectorRegistry,
655        peers: BTreeMap<PeerId, SafeUrl>,
656        admin_peer_id: Option<PeerId>,
657        api_secret: Option<&str>,
658    ) -> Self {
659        Self {
660            peers_keys: peers.keys().copied().collect(),
661            peers,
662            admin_id: admin_peer_id,
663            module_id: None,
664            api_secret: api_secret.map(ToOwned::to_owned),
665            connection_pool: ConnectionPool::new(connectors),
666        }
667    }
668
669    async fn get_or_create_connection(
670        &self,
671        url: &SafeUrl,
672        api_secret: Option<&str>,
673    ) -> ServerResult<DynGuaridianConnection> {
674        self.connection_pool
675            .get_or_create_connection(url, api_secret, |url, api_secret, connectors| async move {
676                let conn = connectors
677                    .connect_guardian(&url, api_secret.as_deref())
678                    .await?;
679                Ok(conn)
680            })
681            .await
682    }
683
684    async fn request(
685        &self,
686        peer: PeerId,
687        method: ApiMethod,
688        request: ApiRequestErased,
689    ) -> ServerResult<Value> {
690        trace!(target: LOG_CLIENT_NET_API, %peer, %method, "Api request");
691        let url = self
692            .peers
693            .get(&peer)
694            .ok_or_else(|| ServerError::InvalidPeerId { peer_id: peer })?;
695        let conn = self
696            .get_or_create_connection(url, self.api_secret.as_deref())
697            .await
698            .context("Failed to connect to peer")
699            .map_err(ServerError::Connection)?;
700
701        let method_str = method.to_string();
702        let peer_str = peer.to_string();
703        let timer = CLIENT_API_REQUEST_DURATION_SECONDS
704            .with_label_values(&[&method_str, &peer_str])
705            .start_timer_ext();
706
707        let res = conn.request(method.clone(), request).await;
708
709        timer.observe_duration();
710
711        let result_label = if res.is_ok() { "success" } else { "error" }.to_string();
712        CLIENT_API_REQUESTS_TOTAL
713            .with_label_values(&[&method_str, &peer_str, &result_label])
714            .inc();
715
716        trace!(target: LOG_CLIENT_NET_API, ?method, res_ok = res.is_ok(), "Api response");
717
718        res
719    }
720
721    /// Get receiver for changes in the active connections
722    ///
723    /// This allows real-time monitoring of connection status.
724    pub fn get_active_connection_receiver(&self) -> watch::Receiver<BTreeSet<SafeUrl>> {
725        self.connection_pool.get_active_connection_receiver()
726    }
727}
728
729impl IModuleFederationApi for FederationApi {}
730
731#[apply(async_trait_maybe_send!)]
732impl IRawFederationApi for FederationApi {
733    fn all_peers(&self) -> &BTreeSet<PeerId> {
734        &self.peers_keys
735    }
736
737    fn self_peer(&self) -> Option<PeerId> {
738        self.admin_id
739    }
740
741    fn with_module(&self, id: ModuleInstanceId) -> DynModuleApi {
742        FederationApi {
743            api_secret: self.api_secret.clone(),
744            peers: self.peers.clone(),
745            peers_keys: self.peers_keys.clone(),
746            admin_id: self.admin_id,
747            module_id: Some(id),
748            connection_pool: self.connection_pool.clone(),
749        }
750        .into()
751    }
752
753    #[instrument(
754        target = LOG_CLIENT_NET_API,
755        skip_all,
756        fields(
757            peer_id = %peer_id,
758            method = %method,
759            params = %params.params,
760        )
761    )]
762    async fn request_raw(
763        &self,
764        peer_id: PeerId,
765        method: &str,
766        params: &ApiRequestErased,
767    ) -> ServerResult<Value> {
768        let method = match self.module_id {
769            Some(module_id) => ApiMethod::Module(module_id, method.to_string()),
770            None => ApiMethod::Core(method.to_string()),
771        };
772
773        self.request(peer_id, method, params.clone()).await
774    }
775
776    fn connection_status_stream(&self) -> BoxStream<'static, BTreeMap<PeerId, PeerStatus>> {
777        let peers = self.peers.clone();
778        let pool = self.connection_pool.clone();
779        let active_rx = self.connection_pool.get_active_connection_receiver();
780
781        // Tick on either (a) a change to the set of active pooled
782        // connections, or (b) a transport-level path change reported by
783        // the underlying connectors (e.g. iroh relay → direct). Both
784        // streams emit their current value immediately on subscription,
785        // so consumers see a snapshot right away.
786        let membership_ticks = WatchStream::new(active_rx.clone()).map(|_| ());
787        let path_change_ticks =
788            WatchStream::new(self.connection_pool.connectivity_change_notifier()).map(|_| ());
789        let ticks = futures::stream::select(membership_ticks, path_change_ticks);
790
791        ticks
792            .map(move |()| {
793                let active_urls = active_rx.borrow().clone();
794                peers
795                    .iter()
796                    .map(|(peer_id, url)| {
797                        let status = if active_urls.contains(url) {
798                            // The active-set snapshot and the per-connector
799                            // path state are separate sources of truth, so a
800                            // disconnection can race between them. If we saw
801                            // the url as active but the connector no longer
802                            // reports a known path, treat the race as
803                            // "disconnection won" and report Disconnected
804                            // for a consistent view — the next stream tick
805                            // will confirm.
806                            match pool.connectivity(url) {
807                                Connectivity::Unknown => PeerStatus::Disconnected,
808                                connectivity => PeerStatus::Connected(connectivity),
809                            }
810                        } else {
811                            PeerStatus::Disconnected
812                        };
813                        (*peer_id, status)
814                    })
815                    .collect()
816            })
817            .boxed()
818    }
819    async fn wait_for_initialized_connections(&self) {
820        self.connection_pool
821            .wait_for_initialized_connections()
822            .await;
823    }
824
825    async fn get_peer_connection(&self, peer_id: PeerId) -> ServerResult<DynGuaridianConnection> {
826        let url = self
827            .peers
828            .get(&peer_id)
829            .ok_or_else(|| ServerError::InvalidPeerId { peer_id })?;
830        self.get_or_create_connection(url, self.api_secret.as_deref())
831            .await
832    }
833}
834
835/// The status of a server, including how it views its peers
836#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
837pub struct LegacyFederationStatus {
838    pub session_count: u64,
839    pub status_by_peer: BTreeMap<PeerId, LegacyPeerStatus>,
840    pub peers_online: u64,
841    pub peers_offline: u64,
842    /// This should always be 0 if everything is okay, so a monitoring tool
843    /// should generate an alert if this is not the case.
844    pub peers_flagged: u64,
845    pub scheduled_shutdown: Option<u64>,
846}
847
848#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
849pub struct LegacyPeerStatus {
850    pub last_contribution: Option<u64>,
851    pub connection_status: LegacyP2PConnectionStatus,
852    /// Indicates that this peer needs attention from the operator since
853    /// it has not contributed to the consensus in a long time
854    pub flagged: bool,
855}
856
857#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
858#[serde(rename_all = "snake_case")]
859pub enum LegacyP2PConnectionStatus {
860    #[default]
861    Disconnected,
862    Connected,
863}
864
865#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
866pub struct StatusResponse {
867    pub server: ServerStatusLegacy,
868    pub federation: Option<LegacyFederationStatus>,
869}
870
871#[cfg(test)]
872mod tests;