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