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    /// Returns the fedimintd version a peer is running
598    async fn fedimintd_version(&self, peer_id: PeerId) -> ServerResult<String>;
599
600    /// Returns one guardian's view of the other guardians' availability
601    async fn peer_availability(&self, peer_id: PeerId) -> ServerResult<PeerAvailability>;
602
603    /// Fetch the backup statistics from the federation (admin endpoint)
604    async fn backup_statistics(&self, auth: ApiAuth) -> FederationResult<BackupStatistics>;
605
606    /// Get the invite code for the federation guardian.
607    /// For instance, useful after DKG
608    async fn get_invite_code(&self, guardian: PeerId) -> ServerResult<InviteCode>;
609
610    /// Returns the chain ID (bitcoin block hash at height 1) from the
611    /// federation
612    async fn chain_id(&self) -> FederationResult<ChainId>;
613}
614
615pub fn deserialize_outcome<R>(
616    outcome: &SerdeOutputOutcome,
617    module_decoder: &Decoder,
618) -> OutputOutcomeResult<R>
619where
620    R: OutputOutcome + MaybeSend,
621{
622    let dyn_outcome = outcome.try_into_inner_known_module_kind(module_decoder)?;
623
624    let module_instance_id = dyn_outcome.module_instance_id();
625
626    dyn_outcome
627        .as_any()
628        .downcast_ref()
629        .cloned()
630        .ok_or(OutputOutcomeError::WrongOutcomeType {
631            module_instance_id,
632            expected_type: std::any::type_name::<R>(),
633        })
634}
635
636/// Federation API client
637///
638/// The core underlying object used to make API requests to a federation.
639///
640/// It has an `connectors` handle to actually making outgoing connections
641/// to given URLs, and knows which peers there are and what URLs to connect to
642/// to reach them.
643// TODO: As it is currently it mixes a bit the role of connecting to "peers" with
644// general purpose outgoing connection. Not a big deal, but might need refactor
645// in the future.
646#[derive(Clone, Debug)]
647pub struct FederationApi {
648    /// Map of known URLs to use to connect to peers
649    peers: BTreeMap<PeerId, SafeUrl>,
650    /// List of peer ids, redundant to avoid collecting all the time
651    peers_keys: BTreeSet<PeerId>,
652    /// Our own [`PeerId`] to use when making admin apis
653    admin_id: Option<PeerId>,
654    /// Set when this API is used to communicate with a module
655    module_id: Option<ModuleInstanceId>,
656    /// Api secret of the federation
657    api_secret: Option<String>,
658    /// Connection pool
659    connection_pool: ConnectionPool<dyn IGuardianConnection>,
660}
661
662impl FederationApi {
663    pub fn new(
664        connectors: ConnectorRegistry,
665        peers: BTreeMap<PeerId, SafeUrl>,
666        admin_peer_id: Option<PeerId>,
667        api_secret: Option<&str>,
668    ) -> Self {
669        Self {
670            peers_keys: peers.keys().copied().collect(),
671            peers,
672            admin_id: admin_peer_id,
673            module_id: None,
674            api_secret: api_secret.map(ToOwned::to_owned),
675            connection_pool: ConnectionPool::new(connectors),
676        }
677    }
678
679    async fn get_or_create_connection(
680        &self,
681        url: &SafeUrl,
682        api_secret: Option<&str>,
683    ) -> ServerResult<DynGuaridianConnection> {
684        self.connection_pool
685            .get_or_create_connection(url, api_secret, |url, api_secret, connectors| async move {
686                let conn = connectors
687                    .connect_guardian(&url, api_secret.as_deref())
688                    .await?;
689                Ok(conn)
690            })
691            .await
692    }
693
694    async fn request(
695        &self,
696        peer: PeerId,
697        method: ApiMethod,
698        request: ApiRequestErased,
699    ) -> ServerResult<Value> {
700        trace!(target: LOG_CLIENT_NET_API, %peer, %method, "Api request");
701        let url = self
702            .peers
703            .get(&peer)
704            .ok_or_else(|| ServerError::InvalidPeerId { peer_id: peer })?;
705        let conn = self
706            .get_or_create_connection(url, self.api_secret.as_deref())
707            .await?;
708
709        let method_str = method.to_string();
710        let peer_str = peer.to_string();
711        let timer = CLIENT_API_REQUEST_DURATION_SECONDS
712            .with_label_values(&[&method_str, &peer_str])
713            .start_timer_ext();
714
715        let res = conn.request(method.clone(), request).await;
716
717        timer.observe_duration();
718
719        let result_label = if res.is_ok() { "success" } else { "error" }.to_string();
720        CLIENT_API_REQUESTS_TOTAL
721            .with_label_values(&[&method_str, &peer_str, &result_label])
722            .inc();
723
724        trace!(target: LOG_CLIENT_NET_API, ?method, res_ok = res.is_ok(), "Api response");
725
726        res
727    }
728
729    /// Get receiver for changes in the active connections
730    ///
731    /// This allows real-time monitoring of connection status.
732    pub fn get_active_connection_receiver(&self) -> watch::Receiver<BTreeSet<SafeUrl>> {
733        self.connection_pool.get_active_connection_receiver()
734    }
735}
736
737impl IModuleFederationApi for FederationApi {}
738
739#[apply(async_trait_maybe_send!)]
740impl IRawFederationApi for FederationApi {
741    fn all_peers(&self) -> &BTreeSet<PeerId> {
742        &self.peers_keys
743    }
744
745    fn self_peer(&self) -> Option<PeerId> {
746        self.admin_id
747    }
748
749    fn with_module(&self, id: ModuleInstanceId) -> DynModuleApi {
750        FederationApi {
751            api_secret: self.api_secret.clone(),
752            peers: self.peers.clone(),
753            peers_keys: self.peers_keys.clone(),
754            admin_id: self.admin_id,
755            module_id: Some(id),
756            connection_pool: self.connection_pool.clone(),
757        }
758        .into()
759    }
760
761    #[instrument(
762        target = LOG_CLIENT_NET_API,
763        skip_all,
764        fields(
765            peer_id = %peer_id,
766            method = %method,
767            params = %params.params,
768        )
769    )]
770    async fn request_raw(
771        &self,
772        peer_id: PeerId,
773        method: &str,
774        params: &ApiRequestErased,
775    ) -> ServerResult<Value> {
776        let method = match self.module_id {
777            Some(module_id) => ApiMethod::Module(module_id, method.to_string()),
778            None => ApiMethod::Core(method.to_string()),
779        };
780
781        self.request(peer_id, method, params.clone()).await
782    }
783
784    fn connection_status_stream(&self) -> BoxStream<'static, BTreeMap<PeerId, PeerStatus>> {
785        let peers = self.peers.clone();
786        let pool = self.connection_pool.clone();
787        let active_rx = self.connection_pool.get_active_connection_receiver();
788
789        // Tick on either (a) a change to the set of active pooled
790        // connections, or (b) a transport-level path change reported by
791        // the underlying connectors (e.g. iroh relay → direct). Both
792        // streams emit their current value immediately on subscription,
793        // so consumers see a snapshot right away.
794        let membership_ticks = WatchStream::new(active_rx.clone()).map(|_| ());
795        let path_change_ticks =
796            WatchStream::new(self.connection_pool.connectivity_change_notifier()).map(|_| ());
797        let ticks = futures::stream::select(membership_ticks, path_change_ticks);
798
799        ticks
800            .map(move |()| {
801                let active_urls = active_rx.borrow().clone();
802                peers
803                    .iter()
804                    .map(|(peer_id, url)| {
805                        let status = if active_urls.contains(url) {
806                            // The active-set snapshot and the per-connector
807                            // path state are separate sources of truth, so a
808                            // disconnection can race between them. If we saw
809                            // the url as active but the connector no longer
810                            // reports a known path, treat the race as
811                            // "disconnection won" and report Disconnected
812                            // for a consistent view — the next stream tick
813                            // will confirm.
814                            match pool.connectivity(url) {
815                                Connectivity::Unknown => PeerStatus::Disconnected,
816                                connectivity => PeerStatus::Connected(connectivity),
817                            }
818                        } else {
819                            PeerStatus::Disconnected
820                        };
821                        (*peer_id, status)
822                    })
823                    .collect()
824            })
825            .boxed()
826    }
827    async fn wait_for_initialized_connections(&self) {
828        self.connection_pool
829            .wait_for_initialized_connections()
830            .await;
831    }
832
833    async fn get_peer_connection(&self, peer_id: PeerId) -> ServerResult<DynGuaridianConnection> {
834        let url = self
835            .peers
836            .get(&peer_id)
837            .ok_or_else(|| ServerError::InvalidPeerId { peer_id })?;
838        self.get_or_create_connection(url, self.api_secret.as_deref())
839            .await
840    }
841}
842
843/// The status of a server, including how it views its peers
844#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
845pub struct LegacyFederationStatus {
846    pub session_count: u64,
847    pub status_by_peer: BTreeMap<PeerId, LegacyPeerStatus>,
848    pub peers_online: u64,
849    pub peers_offline: u64,
850    /// This should always be 0 if everything is okay, so a monitoring tool
851    /// should generate an alert if this is not the case.
852    pub peers_flagged: u64,
853    pub scheduled_shutdown: Option<u64>,
854}
855
856#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
857pub struct LegacyPeerStatus {
858    pub last_contribution: Option<u64>,
859    pub connection_status: LegacyP2PConnectionStatus,
860    /// Indicates that this peer needs attention from the operator since
861    /// it has not contributed to the consensus in a long time
862    pub flagged: bool,
863}
864
865#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
866#[serde(rename_all = "snake_case")]
867pub enum LegacyP2PConnectionStatus {
868    #[default]
869    Disconnected,
870    Connected,
871}
872
873#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
874pub struct StatusResponse {
875    pub server: ServerStatusLegacy,
876    pub federation: Option<LegacyFederationStatus>,
877}
878
879#[cfg(test)]
880mod tests;