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::peer_availability::PeerAvailability;
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 + MaybeSend, FR: Debug>(
179        &self,
180        mut strategy: impl QueryStrategy<PR, FR> + MaybeSend,
181        method: String,
182        params: ApiRequestErased,
183    ) -> FederationResult<FR> {
184        // NOTE: `FuturesUnordered` is a footgun, but all we do here is poll
185        // completed results from it. Processing a result may await its
186        // verification, during which the remaining requests simply pause.
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).await {
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: `FuturesUnordered` is a footgun, but all we do here is poll
271        // completed results from it. Processing a result may await its
272        // verification, during which the remaining requests simply pause.
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                        },
293                    )
294                    .await
295                    .expect("Number of retries has no limit");
296
297                    (*peer, response)
298                }
299            }));
300        }
301
302        loop {
303            let (peer, response) = match futures.next().await {
304                Some(t) => t,
305                None => pending().await,
306            };
307
308            match strategy.process(peer, response).await {
309                QueryStep::Retry(peers) => {
310                    for peer in peers {
311                        futures.push(Box::pin({
312                            let method = &method;
313                            let params = &params;
314                            async move {
315                                let response = util::retry(
316                                    format!("api-request-{method}-{peer}"),
317                                    api_networking_backoff(),
318                                    || async {
319                                        self.request_single_peer(
320                                            method.clone(),
321                                            params.clone(),
322                                            peer,
323                                        )
324                                        .await
325                                        .inspect_err(|err| {
326                                            if err.is_unusual() {
327                                                debug!(target: LOG_CLIENT_NET_API, err = %err.fmt_compact(), "Unusual peer error");
328                                            }
329                                        })
330                                    },
331                                )
332                                .await
333                                .expect("Number of retries has no limit");
334
335                                (peer, response)
336                            }
337                        }));
338                    }
339                }
340                QueryStep::Success(response) => return response,
341                QueryStep::Failure(e) => {
342                    warn!(target: LOG_CLIENT_NET_API, "Query strategy returned non-retryable failure for peer {peer}: {e}");
343                }
344                QueryStep::Continue => {}
345            }
346        }
347    }
348
349    async fn request_current_consensus<Ret>(
350        &self,
351        method: String,
352        params: ApiRequestErased,
353    ) -> FederationResult<Ret>
354    where
355        Ret: DeserializeOwned + Eq + Debug + Clone + MaybeSend,
356    {
357        self.request_with_strategy(
358            ThresholdConsensus::new(self.all_peers().to_num_peers()),
359            method,
360            params,
361        )
362        .await
363    }
364
365    async fn request_current_consensus_retry<Ret>(
366        &self,
367        method: String,
368        params: ApiRequestErased,
369    ) -> Ret
370    where
371        Ret: DeserializeOwned + Eq + Debug + Clone + MaybeSend,
372    {
373        self.request_with_strategy_retry(
374            ThresholdConsensus::new(self.all_peers().to_num_peers()),
375            method,
376            params,
377        )
378        .await
379    }
380
381    async fn request_admin<Ret>(
382        &self,
383        method: &str,
384        params: ApiRequestErased,
385        auth: ApiAuth,
386    ) -> FederationResult<Ret>
387    where
388        Ret: DeserializeOwned + Eq + Debug + Clone + MaybeSend,
389    {
390        let Some(self_peer_id) = self.self_peer() else {
391            return Err(FederationError::general(
392                method,
393                params,
394                FederationGeneralError::AdminPeerIdNotSet,
395            ));
396        };
397
398        self.request_single_peer_federation(method.into(), params.with_auth(auth), self_peer_id)
399            .await
400    }
401
402    async fn request_admin_no_auth<Ret>(
403        &self,
404        method: &str,
405        params: ApiRequestErased,
406    ) -> FederationResult<Ret>
407    where
408        Ret: DeserializeOwned + Eq + Debug + Clone + MaybeSend,
409    {
410        let Some(self_peer_id) = self.self_peer() else {
411            return Err(FederationError::general(
412                method,
413                params,
414                FederationGeneralError::AdminPeerIdNotSet,
415            ));
416        };
417
418        self.request_single_peer_federation(method.into(), params, self_peer_id)
419            .await
420    }
421}
422
423#[apply(async_trait_maybe_send!)]
424impl<T: ?Sized> FederationApiExt for T where T: IRawFederationApi {}
425
426/// Trait marker for the module (non-global) endpoints
427pub trait IModuleFederationApi: IRawFederationApi {}
428
429dyn_newtype_define! {
430    #[derive(Clone)]
431    pub DynModuleApi(Arc<IModuleFederationApi>)
432}
433
434dyn_newtype_define! {
435    #[derive(Clone)]
436    pub DynGlobalApi(Arc<IGlobalFederationApi>)
437}
438
439impl AsRef<dyn IGlobalFederationApi + 'static> for DynGlobalApi {
440    fn as_ref(&self) -> &(dyn IGlobalFederationApi + 'static) {
441        self.inner.as_ref()
442    }
443}
444
445impl DynGlobalApi {
446    pub fn new(
447        connectors: ConnectorRegistry,
448        peers: BTreeMap<PeerId, SafeUrl>,
449        api_secret: Option<&str>,
450    ) -> Self {
451        GlobalFederationApiWithCache::new(FederationApi::new(connectors, peers, None, api_secret))
452            .into()
453    }
454    pub fn new_admin(
455        connectors: ConnectorRegistry,
456        peer: PeerId,
457        url: SafeUrl,
458        api_secret: Option<&str>,
459    ) -> DynGlobalApi {
460        GlobalFederationApiWithCache::new(FederationApi::new(
461            connectors,
462            [(peer, url)].into(),
463            Some(peer),
464            api_secret,
465        ))
466        .into()
467    }
468
469    pub fn new_admin_setup(connectors: ConnectorRegistry, url: SafeUrl) -> Self {
470        // PeerIds are used only for informational purposes, but just in case, make a
471        // big number so it stands out
472        Self::new_admin(
473            connectors,
474            PeerId::from(1024),
475            url,
476            // Setup does not have api secrets yet
477            None,
478        )
479    }
480}
481
482/// The API for the global (non-module) endpoints
483#[apply(async_trait_maybe_send!)]
484pub trait IGlobalFederationApi: IRawFederationApi {
485    async fn submit_transaction(
486        &self,
487        tx: Transaction,
488    ) -> SerdeModuleEncoding<TransactionSubmissionOutcome>;
489
490    async fn await_block(
491        &self,
492        block_index: u64,
493        decoders: &ModuleDecoderRegistry,
494    ) -> FederationResult<SessionOutcome>;
495
496    async fn get_session_status(
497        &self,
498        block_index: u64,
499        decoders: &ModuleDecoderRegistry,
500        core_api_version: ApiVersion,
501        broadcast_public_keys: Option<&BTreeMap<PeerId, secp256k1::PublicKey>>,
502    ) -> FederationResult<SessionStatus>;
503
504    async fn session_count(&self) -> FederationResult<u64>;
505
506    async fn await_transaction(&self, txid: TransactionId) -> TransactionId;
507
508    async fn upload_backup(&self, request: &SignedBackupRequest) -> FederationResult<()>;
509
510    async fn download_backup(
511        &self,
512        id: &secp256k1::PublicKey,
513    ) -> FederationResult<BTreeMap<PeerId, Option<ClientBackupSnapshot>>>;
514
515    async fn setup_status(&self, auth: ApiAuth) -> FederationResult<SetupStatus>;
516
517    async fn set_local_params(
518        &self,
519        name: String,
520        federation_name: Option<String>,
521        disable_base_fees: Option<bool>,
522        enabled_modules: Option<BTreeSet<ModuleKind>>,
523        federation_size: Option<u32>,
524        auth: ApiAuth,
525    ) -> FederationResult<String>;
526
527    async fn add_peer_connection_info(
528        &self,
529        info: String,
530        auth: ApiAuth,
531    ) -> FederationResult<String>;
532
533    /// Reset the peer setup codes during the federation setup process
534    async fn reset_peer_setup_codes(&self, auth: ApiAuth) -> FederationResult<()>;
535
536    /// Returns the setup code if `set_local_params` was already called
537    async fn get_setup_code(&self, auth: ApiAuth) -> FederationResult<Option<String>>;
538
539    /// Runs DKG, can only be called once after configs have been generated in
540    /// `get_consensus_config_gen_params`.  If DKG fails this returns a 500
541    /// error and config gen must be restarted.
542    async fn start_dkg(&self, auth: ApiAuth) -> FederationResult<()>;
543
544    /// Returns the status of the server
545    async fn status(&self) -> FederationResult<StatusResponse>;
546
547    /// Show an audit across all modules
548    async fn audit(&self, auth: ApiAuth) -> FederationResult<AuditSummary>;
549
550    /// Download the guardian config to back it up
551    async fn guardian_config_backup(&self, auth: ApiAuth)
552    -> FederationResult<GuardianConfigBackup>;
553
554    /// Check auth credentials
555    async fn auth(&self, auth: ApiAuth) -> FederationResult<()>;
556
557    async fn restart_federation_setup(&self, auth: ApiAuth) -> FederationResult<()>;
558
559    /// Publish our signed API announcement to other guardians
560    async fn submit_api_announcement(
561        &self,
562        peer_id: PeerId,
563        announcement: SignedApiAnnouncement,
564    ) -> FederationResult<()>;
565
566    async fn api_announcements(
567        &self,
568        guardian: PeerId,
569    ) -> ServerResult<BTreeMap<PeerId, SignedApiAnnouncement>>;
570
571    async fn sign_api_announcement(
572        &self,
573        api_url: SafeUrl,
574        auth: ApiAuth,
575    ) -> FederationResult<SignedApiAnnouncement>;
576
577    /// Publish our signed guardian metadata to other guardians
578    async fn submit_guardian_metadata(
579        &self,
580        peer_id: PeerId,
581        metadata: SignedGuardianMetadata,
582    ) -> FederationResult<()>;
583
584    async fn guardian_metadata(
585        &self,
586        guardian: PeerId,
587    ) -> ServerResult<BTreeMap<PeerId, SignedGuardianMetadata>>;
588
589    async fn sign_guardian_metadata(
590        &self,
591        metadata: fedimint_core::net::guardian_metadata::GuardianMetadata,
592        auth: ApiAuth,
593    ) -> FederationResult<SignedGuardianMetadata>;
594
595    async fn shutdown(&self, session: Option<u64>, auth: ApiAuth) -> FederationResult<()>;
596
597    /// Ask the guardian to shut down once the session it is running
598    /// completes, without starting another one.
599    async fn shutdown_after_current_session(&self, auth: ApiAuth) -> FederationResult<()>;
600
601    /// Returns the fedimintd version a peer is running
602    async fn fedimintd_version(&self, peer_id: PeerId) -> ServerResult<String>;
603
604    /// Returns one guardian's view of the other guardians' availability
605    async fn peer_availability(&self, peer_id: PeerId) -> ServerResult<PeerAvailability>;
606
607    /// Fetch the backup statistics from the federation (admin endpoint)
608    async fn backup_statistics(&self, auth: ApiAuth) -> FederationResult<BackupStatistics>;
609
610    /// Get the invite code for the federation guardian.
611    /// For instance, useful after DKG
612    async fn get_invite_code(&self, guardian: PeerId) -> ServerResult<InviteCode>;
613
614    /// Returns the chain ID (bitcoin block hash at height 1) from the
615    /// federation
616    async fn chain_id(&self) -> FederationResult<ChainId>;
617}
618
619pub fn deserialize_outcome<R>(
620    outcome: &SerdeOutputOutcome,
621    module_decoder: &Decoder,
622) -> OutputOutcomeResult<R>
623where
624    R: OutputOutcome + MaybeSend,
625{
626    let dyn_outcome = outcome.try_into_inner_known_module_kind(module_decoder)?;
627
628    let module_instance_id = dyn_outcome.module_instance_id();
629
630    dyn_outcome
631        .as_any()
632        .downcast_ref()
633        .cloned()
634        .ok_or(OutputOutcomeError::WrongOutcomeType {
635            module_instance_id,
636            expected_type: std::any::type_name::<R>(),
637        })
638}
639
640/// Federation API client
641///
642/// The core underlying object used to make API requests to a federation.
643///
644/// It has an `connectors` handle to actually making outgoing connections
645/// to given URLs, and knows which peers there are and what URLs to connect to
646/// to reach them.
647// TODO: As it is currently it mixes a bit the role of connecting to "peers" with
648// general purpose outgoing connection. Not a big deal, but might need refactor
649// in the future.
650#[derive(Clone, Debug)]
651pub struct FederationApi {
652    /// Map of known URLs to use to connect to peers
653    peers: BTreeMap<PeerId, SafeUrl>,
654    /// List of peer ids, redundant to avoid collecting all the time
655    peers_keys: BTreeSet<PeerId>,
656    /// Our own [`PeerId`] to use when making admin apis
657    admin_id: Option<PeerId>,
658    /// Set when this API is used to communicate with a module
659    module_id: Option<ModuleInstanceId>,
660    /// Api secret of the federation
661    api_secret: Option<String>,
662    /// Connection pool
663    connection_pool: ConnectionPool<dyn IGuardianConnection>,
664}
665
666impl FederationApi {
667    pub fn new(
668        connectors: ConnectorRegistry,
669        peers: BTreeMap<PeerId, SafeUrl>,
670        admin_peer_id: Option<PeerId>,
671        api_secret: Option<&str>,
672    ) -> Self {
673        Self {
674            peers_keys: peers.keys().copied().collect(),
675            peers,
676            admin_id: admin_peer_id,
677            module_id: None,
678            api_secret: api_secret.map(ToOwned::to_owned),
679            connection_pool: ConnectionPool::new(connectors),
680        }
681    }
682
683    async fn get_or_create_connection(
684        &self,
685        url: &SafeUrl,
686        api_secret: Option<&str>,
687    ) -> ServerResult<DynGuaridianConnection> {
688        self.connection_pool
689            .get_or_create_connection(url, api_secret, |url, api_secret, connectors| async move {
690                let conn = connectors
691                    .connect_guardian(&url, api_secret.as_deref())
692                    .await?;
693                Ok(conn)
694            })
695            .await
696    }
697
698    async fn request(
699        &self,
700        peer: PeerId,
701        method: ApiMethod,
702        request: ApiRequestErased,
703    ) -> ServerResult<Value> {
704        trace!(target: LOG_CLIENT_NET_API, %peer, %method, "Api request");
705        let url = self
706            .peers
707            .get(&peer)
708            .ok_or_else(|| ServerError::InvalidPeerId { peer_id: peer })?;
709        let conn = self
710            .get_or_create_connection(url, self.api_secret.as_deref())
711            .await?;
712
713        let method_str = method.to_string();
714        let peer_str = peer.to_string();
715        let timer = CLIENT_API_REQUEST_DURATION_SECONDS
716            .with_label_values(&[&method_str, &peer_str])
717            .start_timer_ext();
718
719        let res = conn.request(method.clone(), request).await;
720
721        timer.observe_duration();
722
723        let result_label = if res.is_ok() { "success" } else { "error" }.to_string();
724        CLIENT_API_REQUESTS_TOTAL
725            .with_label_values(&[&method_str, &peer_str, &result_label])
726            .inc();
727
728        trace!(target: LOG_CLIENT_NET_API, ?method, res_ok = res.is_ok(), "Api response");
729
730        res
731    }
732
733    /// Get receiver for changes in the active connections
734    ///
735    /// This allows real-time monitoring of connection status.
736    pub fn get_active_connection_receiver(&self) -> watch::Receiver<BTreeSet<SafeUrl>> {
737        self.connection_pool.get_active_connection_receiver()
738    }
739}
740
741impl IModuleFederationApi for FederationApi {}
742
743#[apply(async_trait_maybe_send!)]
744impl IRawFederationApi for FederationApi {
745    fn all_peers(&self) -> &BTreeSet<PeerId> {
746        &self.peers_keys
747    }
748
749    fn self_peer(&self) -> Option<PeerId> {
750        self.admin_id
751    }
752
753    fn with_module(&self, id: ModuleInstanceId) -> DynModuleApi {
754        FederationApi {
755            api_secret: self.api_secret.clone(),
756            peers: self.peers.clone(),
757            peers_keys: self.peers_keys.clone(),
758            admin_id: self.admin_id,
759            module_id: Some(id),
760            connection_pool: self.connection_pool.clone(),
761        }
762        .into()
763    }
764
765    #[instrument(
766        target = LOG_CLIENT_NET_API,
767        skip_all,
768        fields(
769            peer_id = %peer_id,
770            method = %method,
771            params = %params.params,
772        )
773    )]
774    async fn request_raw(
775        &self,
776        peer_id: PeerId,
777        method: &str,
778        params: &ApiRequestErased,
779    ) -> ServerResult<Value> {
780        let method = match self.module_id {
781            Some(module_id) => ApiMethod::Module(module_id, method.to_string()),
782            None => ApiMethod::Core(method.to_string()),
783        };
784
785        self.request(peer_id, method, params.clone()).await
786    }
787
788    fn connection_status_stream(&self) -> BoxStream<'static, BTreeMap<PeerId, PeerStatus>> {
789        let peers = self.peers.clone();
790        let pool = self.connection_pool.clone();
791        let active_rx = self.connection_pool.get_active_connection_receiver();
792
793        // Tick on either (a) a change to the set of active pooled
794        // connections, or (b) a transport-level path change reported by
795        // the underlying connectors (e.g. iroh relay → direct). Both
796        // streams emit their current value immediately on subscription,
797        // so consumers see a snapshot right away.
798        let membership_ticks = WatchStream::new(active_rx.clone()).map(|_| ());
799        let path_change_ticks =
800            WatchStream::new(self.connection_pool.connectivity_change_notifier()).map(|_| ());
801        let ticks = futures::stream::select(membership_ticks, path_change_ticks);
802
803        ticks
804            .map(move |()| {
805                let active_urls = active_rx.borrow().clone();
806                peers
807                    .iter()
808                    .map(|(peer_id, url)| {
809                        let status = if active_urls.contains(url) {
810                            // The active-set snapshot and the per-connector
811                            // path state are separate sources of truth, so a
812                            // disconnection can race between them. If we saw
813                            // the url as active but the connector no longer
814                            // reports a known path, treat the race as
815                            // "disconnection won" and report Disconnected
816                            // for a consistent view — the next stream tick
817                            // will confirm.
818                            match pool.connectivity(url) {
819                                Connectivity::Unknown => PeerStatus::Disconnected,
820                                connectivity => PeerStatus::Connected(connectivity),
821                            }
822                        } else {
823                            PeerStatus::Disconnected
824                        };
825                        (*peer_id, status)
826                    })
827                    .collect()
828            })
829            .boxed()
830    }
831    async fn wait_for_initialized_connections(&self) {
832        self.connection_pool
833            .wait_for_initialized_connections()
834            .await;
835    }
836
837    async fn get_peer_connection(&self, peer_id: PeerId) -> ServerResult<DynGuaridianConnection> {
838        let url = self
839            .peers
840            .get(&peer_id)
841            .ok_or_else(|| ServerError::InvalidPeerId { peer_id })?;
842        self.get_or_create_connection(url, self.api_secret.as_deref())
843            .await
844    }
845}
846
847/// The status of a server, including how it views its peers
848#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
849pub struct LegacyFederationStatus {
850    pub session_count: u64,
851    pub status_by_peer: BTreeMap<PeerId, LegacyPeerStatus>,
852    pub peers_online: u64,
853    pub peers_offline: u64,
854    /// This should always be 0 if everything is okay, so a monitoring tool
855    /// should generate an alert if this is not the case.
856    pub peers_flagged: u64,
857    pub scheduled_shutdown: Option<u64>,
858}
859
860#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
861pub struct LegacyPeerStatus {
862    pub last_contribution: Option<u64>,
863    pub connection_status: LegacyP2PConnectionStatus,
864    /// Indicates that this peer needs attention from the operator since
865    /// it has not contributed to the consensus in a long time
866    pub flagged: bool,
867}
868
869#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
870#[serde(rename_all = "snake_case")]
871pub enum LegacyP2PConnectionStatus {
872    #[default]
873    Disconnected,
874    Connected,
875}
876
877#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
878pub struct StatusResponse {
879    pub server: ServerStatusLegacy,
880    pub federation: Option<LegacyFederationStatus>,
881}
882
883#[cfg(test)]
884mod tests;