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