fedimint_server/consensus/
api.rs

1//! Implements the client API through which users interact with the federation
2use std::cmp::Ordering;
3use std::collections::{BTreeMap, HashMap};
4use std::path::{Path, PathBuf};
5use std::time::Duration;
6
7use anyhow::{Context, Result};
8use async_trait::async_trait;
9use bitcoin::hashes::sha256;
10use fedimint_aead::{encrypt, get_encryption_key, random_salt};
11use fedimint_api_client::api::{
12    LegacyFederationStatus, LegacyP2PConnectionStatus, LegacyPeerStatus, StatusResponse,
13};
14use fedimint_core::admin_client::{GuardianConfigBackup, ServerStatusLegacy, SetupStatus};
15use fedimint_core::backup::{
16    BackupStatistics, ClientBackupKey, ClientBackupKeyPrefix, ClientBackupSnapshot,
17};
18use fedimint_core::config::{ClientConfig, JsonClientConfig, META_FEDERATION_NAME_KEY};
19use fedimint_core::core::backup::{BACKUP_REQUEST_MAX_PAYLOAD_SIZE_BYTES, SignedBackupRequest};
20use fedimint_core::core::{DynOutputOutcome, ModuleInstanceId, ModuleKind};
21use fedimint_core::db::{
22    Committable, Database, DatabaseTransaction, IDatabaseTransactionOpsCoreTyped,
23};
24#[allow(deprecated)]
25use fedimint_core::endpoint_constants::AWAIT_OUTPUT_OUTCOME_ENDPOINT;
26use fedimint_core::endpoint_constants::{
27    API_ANNOUNCEMENTS_ENDPOINT, AUDIT_ENDPOINT, AUTH_ENDPOINT, AWAIT_SESSION_OUTCOME_ENDPOINT,
28    AWAIT_SIGNED_SESSION_OUTCOME_ENDPOINT, AWAIT_TRANSACTION_ENDPOINT, BACKUP_ENDPOINT,
29    BACKUP_STATISTICS_ENDPOINT, CLIENT_CONFIG_ENDPOINT, CLIENT_CONFIG_JSON_ENDPOINT,
30    CONSENSUS_ORD_LATENCY_ENDPOINT, FEDERATION_ID_ENDPOINT, FEDIMINTD_VERSION_ENDPOINT,
31    GUARDIAN_CONFIG_BACKUP_ENDPOINT, INVITE_CODE_ENDPOINT, P2P_CONNECTION_STATUS_ENDPOINT,
32    RECOVER_ENDPOINT, SERVER_CONFIG_CONSENSUS_HASH_ENDPOINT, SESSION_COUNT_ENDPOINT,
33    SESSION_STATUS_ENDPOINT, SESSION_STATUS_V2_ENDPOINT, SETUP_STATUS_ENDPOINT, SHUTDOWN_ENDPOINT,
34    SIGN_API_ANNOUNCEMENT_ENDPOINT, STATUS_ENDPOINT, SUBMIT_API_ANNOUNCEMENT_ENDPOINT,
35    SUBMIT_TRANSACTION_ENDPOINT, VERSION_ENDPOINT,
36};
37use fedimint_core::epoch::ConsensusItem;
38use fedimint_core::module::audit::{Audit, AuditSummary};
39use fedimint_core::module::{
40    ApiAuth, ApiEndpoint, ApiEndpointContext, ApiError, ApiRequestErased, ApiResult, ApiVersion,
41    SerdeModuleEncoding, SerdeModuleEncodingBase64, SupportedApiVersionsSummary, api_endpoint,
42};
43use fedimint_core::net::api_announcement::{
44    ApiAnnouncement, SignedApiAnnouncement, SignedApiAnnouncementSubmission,
45};
46use fedimint_core::secp256k1::{PublicKey, SECP256K1};
47use fedimint_core::session_outcome::{
48    SessionOutcome, SessionStatus, SessionStatusV2, SignedSessionOutcome,
49};
50use fedimint_core::transaction::{
51    SerdeTransaction, Transaction, TransactionError, TransactionSubmissionOutcome,
52};
53use fedimint_core::util::{FmtCompact, SafeUrl};
54use fedimint_core::{OutPoint, PeerId, TransactionId, secp256k1};
55use fedimint_logging::LOG_NET_API;
56use fedimint_server_core::bitcoin_rpc::ServerBitcoinRpcMonitor;
57use fedimint_server_core::dashboard_ui::{IDashboardApi, ServerBitcoinRpcStatus};
58use fedimint_server_core::net::{GuardianAuthToken, check_auth};
59use fedimint_server_core::{DynServerModule, ServerModuleRegistry, ServerModuleRegistryExt};
60use futures::StreamExt;
61use tokio::sync::watch::{self, Receiver, Sender};
62use tracing::{debug, info, warn};
63
64use crate::config::io::{
65    CONSENSUS_CONFIG, ENCRYPTED_EXT, JSON_EXT, LOCAL_CONFIG, PRIVATE_CONFIG, SALT_FILE,
66};
67use crate::config::{ServerConfig, legacy_consensus_config_hash};
68use crate::consensus::db::{AcceptedItemPrefix, AcceptedTransactionKey, SignedSessionOutcomeKey};
69use crate::consensus::engine::get_finished_session_count_static;
70use crate::consensus::transaction::{TxProcessingMode, process_transaction_with_dbtx};
71use crate::metrics::{BACKUP_WRITE_SIZE_BYTES, STORED_BACKUPS_COUNT};
72use crate::net::api::HasApiContext;
73use crate::net::api::announcement::{ApiAnnouncementKey, ApiAnnouncementPrefix};
74use crate::net::p2p::P2PStatusReceivers;
75
76#[derive(Clone)]
77pub struct ConsensusApi {
78    /// Our server configuration
79    pub cfg: ServerConfig,
80    /// Database for serving the API
81    pub db: Database,
82    /// Modules registered with the federation
83    pub modules: ServerModuleRegistry,
84    /// Cached client config
85    pub client_cfg: ClientConfig,
86    pub force_api_secret: Option<String>,
87    /// For sending API events to consensus such as transactions
88    pub submission_sender: async_channel::Sender<ConsensusItem>,
89    pub shutdown_receiver: Receiver<Option<u64>>,
90    pub shutdown_sender: Sender<Option<u64>>,
91    pub ord_latency_receiver: watch::Receiver<Option<Duration>>,
92    pub p2p_status_receivers: P2PStatusReceivers,
93    pub ci_status_receivers: BTreeMap<PeerId, Receiver<Option<u64>>>,
94    pub bitcoin_rpc_connection: ServerBitcoinRpcMonitor,
95    pub supported_api_versions: SupportedApiVersionsSummary,
96    pub code_version_str: String,
97}
98
99impl ConsensusApi {
100    pub fn api_versions_summary(&self) -> &SupportedApiVersionsSummary {
101        &self.supported_api_versions
102    }
103
104    pub fn get_active_api_secret(&self) -> Option<String> {
105        // TODO: In the future, we might want to fetch it from the DB, so it's possible
106        // to customize from the UX
107        self.force_api_secret.clone()
108    }
109
110    // we want to return an error if and only if the submitted transaction is
111    // invalid and will be rejected if we were to submit it to consensus
112    pub async fn submit_transaction(
113        &self,
114        transaction: Transaction,
115    ) -> Result<TransactionId, TransactionError> {
116        let txid = transaction.tx_hash();
117
118        debug!(target: LOG_NET_API, %txid, "Received a submitted transaction");
119
120        // Create read-only DB tx so that the read state is consistent
121        let mut dbtx = self.db.begin_transaction_nc().await;
122        // we already processed the transaction before
123        if dbtx
124            .get_value(&AcceptedTransactionKey(txid))
125            .await
126            .is_some()
127        {
128            debug!(target: LOG_NET_API, %txid, "Transaction already accepted");
129            return Ok(txid);
130        }
131
132        // We ignore any writes, as we only verify if the transaction is valid here
133        dbtx.ignore_uncommitted();
134
135        process_transaction_with_dbtx(
136            self.modules.clone(),
137            &mut dbtx,
138            &transaction,
139            self.cfg.consensus.version,
140            TxProcessingMode::Submission,
141        )
142        .await
143        .inspect_err(|err| {
144            debug!(target: LOG_NET_API, %txid, err = %err.fmt_compact(), "Transaction rejected");
145        })?;
146
147        let _ = self
148            .submission_sender
149            .send(ConsensusItem::Transaction(transaction.clone()))
150            .await
151            .inspect_err(|err| {
152                warn!(target: LOG_NET_API, %txid, err = %err.fmt_compact(), "Unable to submit the tx into consensus");
153            });
154
155        Ok(txid)
156    }
157
158    pub async fn await_transaction(
159        &self,
160        txid: TransactionId,
161    ) -> (Vec<ModuleInstanceId>, DatabaseTransaction<'_, Committable>) {
162        self.db
163            .wait_key_check(&AcceptedTransactionKey(txid), std::convert::identity)
164            .await
165    }
166
167    pub async fn await_output_outcome(
168        &self,
169        outpoint: OutPoint,
170    ) -> Result<SerdeModuleEncoding<DynOutputOutcome>> {
171        let (module_ids, mut dbtx) = self.await_transaction(outpoint.txid).await;
172
173        let module_id = module_ids
174            .into_iter()
175            .nth(outpoint.out_idx as usize)
176            .with_context(|| format!("Outpoint index out of bounds {outpoint:?}"))?;
177
178        #[allow(deprecated)]
179        let outcome = self
180            .modules
181            .get_expect(module_id)
182            .output_status(
183                &mut dbtx.to_ref_with_prefix_module_id(module_id).0.into_nc(),
184                outpoint,
185                module_id,
186            )
187            .await
188            .context("No output outcome for outpoint")?;
189
190        Ok((&outcome).into())
191    }
192
193    pub async fn session_count(&self) -> u64 {
194        get_finished_session_count_static(&mut self.db.begin_transaction_nc().await).await
195    }
196
197    pub async fn await_signed_session_outcome(&self, index: u64) -> SignedSessionOutcome {
198        self.db
199            .wait_key_check(&SignedSessionOutcomeKey(index), std::convert::identity)
200            .await
201            .0
202    }
203
204    pub async fn session_status(&self, session_index: u64) -> SessionStatusV2 {
205        let mut dbtx = self.db.begin_transaction_nc().await;
206
207        match session_index.cmp(&get_finished_session_count_static(&mut dbtx).await) {
208            Ordering::Greater => SessionStatusV2::Initial,
209            Ordering::Equal => SessionStatusV2::Pending(
210                dbtx.find_by_prefix(&AcceptedItemPrefix)
211                    .await
212                    .map(|entry| entry.1)
213                    .collect()
214                    .await,
215            ),
216            Ordering::Less => SessionStatusV2::Complete(
217                dbtx.get_value(&SignedSessionOutcomeKey(session_index))
218                    .await
219                    .expect("There are no gaps in session outcomes"),
220            ),
221        }
222    }
223
224    pub async fn get_federation_status(&self) -> ApiResult<LegacyFederationStatus> {
225        let session_count = self.session_count().await;
226        let scheduled_shutdown = self.shutdown_receiver.borrow().to_owned();
227
228        let status_by_peer = self
229            .p2p_status_receivers
230            .iter()
231            .map(|(peer, p2p_receiver)| {
232                let ci_receiver = self.ci_status_receivers.get(peer).unwrap();
233
234                let consensus_status = LegacyPeerStatus {
235                    connection_status: match *p2p_receiver.borrow() {
236                        Some(..) => LegacyP2PConnectionStatus::Connected,
237                        None => LegacyP2PConnectionStatus::Disconnected,
238                    },
239                    last_contribution: *ci_receiver.borrow(),
240                    flagged: ci_receiver.borrow().unwrap_or(0) + 1 < session_count,
241                };
242
243                (*peer, consensus_status)
244            })
245            .collect::<HashMap<PeerId, LegacyPeerStatus>>();
246
247        let peers_flagged = status_by_peer
248            .values()
249            .filter(|status| status.flagged)
250            .count() as u64;
251
252        let peers_online = status_by_peer
253            .values()
254            .filter(|status| status.connection_status == LegacyP2PConnectionStatus::Connected)
255            .count() as u64;
256
257        let peers_offline = status_by_peer
258            .values()
259            .filter(|status| status.connection_status == LegacyP2PConnectionStatus::Disconnected)
260            .count() as u64;
261
262        Ok(LegacyFederationStatus {
263            session_count,
264            status_by_peer,
265            peers_online,
266            peers_offline,
267            peers_flagged,
268            scheduled_shutdown,
269        })
270    }
271
272    fn shutdown(&self, index: Option<u64>) {
273        self.shutdown_sender.send_replace(index);
274    }
275
276    async fn get_federation_audit(&self) -> ApiResult<AuditSummary> {
277        let mut dbtx = self.db.begin_transaction_nc().await;
278        // Writes are related to compacting audit keys, which we can safely ignore
279        // within an API request since the compaction will happen when constructing an
280        // audit in the consensus server
281        dbtx.ignore_uncommitted();
282
283        let mut audit = Audit::default();
284        let mut module_instance_id_to_kind: HashMap<ModuleInstanceId, String> = HashMap::new();
285        for (module_instance_id, kind, module) in self.modules.iter_modules() {
286            module_instance_id_to_kind.insert(module_instance_id, kind.as_str().to_string());
287            module
288                .audit(
289                    &mut dbtx.to_ref_with_prefix_module_id(module_instance_id).0,
290                    &mut audit,
291                    module_instance_id,
292                )
293                .await;
294        }
295        Ok(AuditSummary::from_audit(
296            &audit,
297            &module_instance_id_to_kind,
298        ))
299    }
300
301    /// Uses the in-memory config to write a config backup tar archive that
302    /// guardians can download. Private keys are encrypted with the guardian
303    /// password, so it should be safe to store anywhere, this also means the
304    /// backup is useless without the password.
305    fn get_guardian_config_backup(
306        &self,
307        password: &str,
308        _auth: &GuardianAuthToken,
309    ) -> GuardianConfigBackup {
310        let mut tar_archive_builder = tar::Builder::new(Vec::new());
311
312        let mut append = |name: &Path, data: &[u8]| {
313            let mut header = tar::Header::new_gnu();
314            header.set_path(name).expect("Error setting path");
315            header.set_size(data.len() as u64);
316            header.set_mode(0o644);
317            header.set_cksum();
318            tar_archive_builder
319                .append(&header, data)
320                .expect("Error adding data to tar archive");
321        };
322
323        append(
324            &PathBuf::from(LOCAL_CONFIG).with_extension(JSON_EXT),
325            &serde_json::to_vec(&self.cfg.local).expect("Error encoding local config"),
326        );
327
328        append(
329            &PathBuf::from(CONSENSUS_CONFIG).with_extension(JSON_EXT),
330            &serde_json::to_vec(&self.cfg.consensus).expect("Error encoding consensus config"),
331        );
332
333        // Note that the encrypted config returned here uses a different salt than the
334        // on-disk version. While this may be confusing it shouldn't be a problem since
335        // the content and encryption key are the same. It's unpractical to read the
336        // on-disk version here since the server/api aren't aware of the config dir and
337        // ideally we can keep it that way.
338        let encryption_salt = random_salt();
339        append(&PathBuf::from(SALT_FILE), encryption_salt.as_bytes());
340
341        let private_config_bytes =
342            serde_json::to_vec(&self.cfg.private).expect("Error encoding private config");
343        let encryption_key = get_encryption_key(password, &encryption_salt)
344            .expect("Generating key from password failed");
345        let private_config_encrypted =
346            hex::encode(encrypt(private_config_bytes, &encryption_key).expect("Encryption failed"));
347        append(
348            &PathBuf::from(PRIVATE_CONFIG).with_extension(ENCRYPTED_EXT),
349            private_config_encrypted.as_bytes(),
350        );
351
352        let tar_archive_bytes = tar_archive_builder
353            .into_inner()
354            .expect("Error building tar archive");
355
356        GuardianConfigBackup { tar_archive_bytes }
357    }
358
359    async fn handle_backup_request(
360        &self,
361        dbtx: &mut DatabaseTransaction<'_>,
362        request: SignedBackupRequest,
363    ) -> Result<(), ApiError> {
364        let request = request
365            .verify_valid(SECP256K1)
366            .map_err(|_| ApiError::bad_request("invalid request".into()))?;
367
368        if request.payload.len() > BACKUP_REQUEST_MAX_PAYLOAD_SIZE_BYTES {
369            return Err(ApiError::bad_request("snapshot too large".into()));
370        }
371        debug!(target: LOG_NET_API, id = %request.id, len = request.payload.len(), "Received client backup request");
372        if let Some(prev) = dbtx.get_value(&ClientBackupKey(request.id)).await
373            && request.timestamp <= prev.timestamp
374        {
375            debug!(target: LOG_NET_API, id = %request.id, len = request.payload.len(), "Received client backup request with old timestamp - ignoring");
376            return Err(ApiError::bad_request("timestamp too small".into()));
377        }
378
379        info!(target: LOG_NET_API, id = %request.id, len = request.payload.len(), "Storing new client backup");
380        let overwritten = dbtx
381            .insert_entry(
382                &ClientBackupKey(request.id),
383                &ClientBackupSnapshot {
384                    timestamp: request.timestamp,
385                    data: request.payload.clone(),
386                },
387            )
388            .await
389            .is_some();
390        BACKUP_WRITE_SIZE_BYTES.observe(request.payload.len() as f64);
391        if !overwritten {
392            dbtx.on_commit(|| STORED_BACKUPS_COUNT.inc());
393        }
394
395        Ok(())
396    }
397
398    async fn handle_recover_request(
399        &self,
400        dbtx: &mut DatabaseTransaction<'_>,
401        id: PublicKey,
402    ) -> Option<ClientBackupSnapshot> {
403        dbtx.get_value(&ClientBackupKey(id)).await
404    }
405
406    /// List API URL announcements from all peers we have received them from (at
407    /// least ourselves)
408    async fn api_announcements(&self) -> BTreeMap<PeerId, SignedApiAnnouncement> {
409        self.db
410            .begin_transaction_nc()
411            .await
412            .find_by_prefix(&ApiAnnouncementPrefix)
413            .await
414            .map(|(announcement_key, announcement)| (announcement_key.0, announcement))
415            .collect()
416            .await
417    }
418
419    /// Returns the tagged fedimintd version currently running
420    fn fedimintd_version(&self) -> String {
421        self.code_version_str.clone()
422    }
423
424    /// Add an API URL announcement from a peer to our database to be returned
425    /// by [`ConsensusApi::api_announcements`].
426    async fn submit_api_announcement(
427        &self,
428        peer_id: PeerId,
429        announcement: SignedApiAnnouncement,
430    ) -> Result<(), ApiError> {
431        let Some(peer_key) = self.cfg.consensus.broadcast_public_keys.get(&peer_id) else {
432            return Err(ApiError::bad_request("Peer not in federation".into()));
433        };
434
435        if !announcement.verify(SECP256K1, peer_key) {
436            return Err(ApiError::bad_request("Invalid signature".into()));
437        }
438
439        let mut dbtx = self.db.begin_transaction().await;
440
441        if let Some(existing_announcement) = dbtx.get_value(&ApiAnnouncementKey(peer_id)).await {
442            // If the current announcement is semantically identical to the new one (except
443            // for potentially having a different, valid signature) we return ok to allow
444            // the caller to stop submitting the value if they are in a retry loop.
445            if existing_announcement.api_announcement == announcement.api_announcement {
446                return Ok(());
447            }
448
449            // We only accept announcements with a nonce higher than the current one to
450            // avoid replay attacks.
451            if existing_announcement.api_announcement.nonce >= announcement.api_announcement.nonce {
452                return Err(ApiError::bad_request(
453                    "Outdated or redundant announcement".into(),
454                ));
455            }
456        }
457
458        dbtx.insert_entry(&ApiAnnouncementKey(peer_id), &announcement)
459            .await;
460        dbtx.commit_tx().await;
461        Ok(())
462    }
463
464    async fn sign_api_announcement(&self, new_url: SafeUrl) -> SignedApiAnnouncement {
465        self.db
466            .autocommit(
467                |dbtx, _| {
468                    let new_url_inner = new_url.clone();
469                    Box::pin(async move {
470                        let new_nonce = dbtx
471                            .get_value(&ApiAnnouncementKey(self.cfg.local.identity))
472                            .await
473                            .map_or(0, |a| a.api_announcement.nonce + 1);
474                        let announcement = ApiAnnouncement {
475                            api_url: new_url_inner,
476                            nonce: new_nonce,
477                        };
478                        let ctx = secp256k1::Secp256k1::new();
479                        let signed_announcement = announcement
480                            .sign(&ctx, &self.cfg.private.broadcast_secret_key.keypair(&ctx));
481
482                        dbtx.insert_entry(
483                            &ApiAnnouncementKey(self.cfg.local.identity),
484                            &signed_announcement,
485                        )
486                        .await;
487
488                        Result::<_, ()>::Ok(signed_announcement)
489                    })
490                },
491                None,
492            )
493            .await
494            .expect("Will not terminate on error")
495    }
496}
497
498#[async_trait]
499impl HasApiContext<ConsensusApi> for ConsensusApi {
500    async fn context(
501        &self,
502        request: &ApiRequestErased,
503        id: Option<ModuleInstanceId>,
504    ) -> (&ConsensusApi, ApiEndpointContext<'_>) {
505        let mut db = self.db.clone();
506        let mut dbtx = self.db.begin_transaction().await;
507        if let Some(id) = id {
508            db = self.db.with_prefix_module_id(id).0;
509            dbtx = dbtx.with_prefix_module_id(id).0;
510        }
511        (
512            self,
513            ApiEndpointContext::new(
514                db,
515                dbtx,
516                request.auth == Some(self.cfg.private.api_auth.clone()),
517                request.auth.clone(),
518            ),
519        )
520    }
521}
522
523#[async_trait]
524impl HasApiContext<DynServerModule> for ConsensusApi {
525    async fn context(
526        &self,
527        request: &ApiRequestErased,
528        id: Option<ModuleInstanceId>,
529    ) -> (&DynServerModule, ApiEndpointContext<'_>) {
530        let (_, context): (&ConsensusApi, _) = self.context(request, id).await;
531        (
532            self.modules.get_expect(id.expect("required module id")),
533            context,
534        )
535    }
536}
537
538#[async_trait]
539impl IDashboardApi for ConsensusApi {
540    async fn auth(&self) -> ApiAuth {
541        self.cfg.private.api_auth.clone()
542    }
543
544    async fn guardian_id(&self) -> PeerId {
545        self.cfg.local.identity
546    }
547
548    async fn guardian_names(&self) -> BTreeMap<PeerId, String> {
549        self.cfg
550            .consensus
551            .api_endpoints()
552            .iter()
553            .map(|(peer_id, endpoint)| (*peer_id, endpoint.name.clone()))
554            .collect()
555    }
556
557    async fn federation_name(&self) -> String {
558        self.cfg
559            .consensus
560            .meta
561            .get(META_FEDERATION_NAME_KEY)
562            .cloned()
563            .expect("Federation name must be set")
564    }
565
566    async fn session_count(&self) -> u64 {
567        self.session_count().await
568    }
569
570    async fn get_session_status(&self, session_idx: u64) -> SessionStatusV2 {
571        self.session_status(session_idx).await
572    }
573
574    async fn consensus_ord_latency(&self) -> Option<Duration> {
575        *self.ord_latency_receiver.borrow()
576    }
577
578    async fn p2p_connection_status(&self) -> BTreeMap<PeerId, Option<Duration>> {
579        self.p2p_status_receivers
580            .iter()
581            .map(|(peer, receiver)| (*peer, *receiver.borrow()))
582            .collect()
583    }
584
585    async fn federation_invite_code(&self) -> String {
586        self.cfg
587            .get_invite_code(self.get_active_api_secret())
588            .to_string()
589    }
590
591    async fn federation_audit(&self) -> AuditSummary {
592        self.get_federation_audit()
593            .await
594            .expect("Failed to get federation audit")
595    }
596
597    async fn bitcoin_rpc_url(&self) -> SafeUrl {
598        self.bitcoin_rpc_connection.url()
599    }
600
601    async fn bitcoin_rpc_status(&self) -> Option<ServerBitcoinRpcStatus> {
602        self.bitcoin_rpc_connection.status()
603    }
604
605    async fn download_guardian_config_backup(
606        &self,
607        password: &str,
608        guardian_auth: &GuardianAuthToken,
609    ) -> GuardianConfigBackup {
610        self.get_guardian_config_backup(password, guardian_auth)
611    }
612
613    fn get_module_by_kind(&self, kind: ModuleKind) -> Option<&DynServerModule> {
614        self.modules
615            .iter_modules()
616            .find_map(|(_, module_kind, module)| {
617                if *module_kind == kind {
618                    Some(module)
619                } else {
620                    None
621                }
622            })
623    }
624}
625
626pub fn server_endpoints() -> Vec<ApiEndpoint<ConsensusApi>> {
627    vec![
628        api_endpoint! {
629            VERSION_ENDPOINT,
630            ApiVersion::new(0, 0),
631            async |fedimint: &ConsensusApi, _context, _v: ()| -> SupportedApiVersionsSummary {
632                Ok(fedimint.api_versions_summary().to_owned())
633            }
634        },
635        api_endpoint! {
636            SUBMIT_TRANSACTION_ENDPOINT,
637            ApiVersion::new(0, 0),
638            async |fedimint: &ConsensusApi, _context, transaction: SerdeTransaction| -> SerdeModuleEncoding<TransactionSubmissionOutcome> {
639                let transaction = transaction
640                    .try_into_inner(&fedimint.modules.decoder_registry())
641                    .map_err(|e| ApiError::bad_request(e.to_string()))?;
642
643                // we return an inner error if and only if the submitted transaction is
644                // invalid and will be rejected if we were to submit it to consensus
645                Ok((&TransactionSubmissionOutcome(fedimint.submit_transaction(transaction).await)).into())
646            }
647        },
648        api_endpoint! {
649            AWAIT_TRANSACTION_ENDPOINT,
650            ApiVersion::new(0, 0),
651            async |fedimint: &ConsensusApi, _context, tx_hash: TransactionId| -> TransactionId {
652                fedimint.await_transaction(tx_hash).await;
653
654                Ok(tx_hash)
655            }
656        },
657        api_endpoint! {
658            AWAIT_OUTPUT_OUTCOME_ENDPOINT,
659            ApiVersion::new(0, 0),
660            async |fedimint: &ConsensusApi, _context, outpoint: OutPoint| -> SerdeModuleEncoding<DynOutputOutcome> {
661                let outcome = fedimint
662                    .await_output_outcome(outpoint)
663                    .await
664                    .map_err(|e| ApiError::bad_request(e.to_string()))?;
665
666                Ok(outcome)
667            }
668        },
669        api_endpoint! {
670            INVITE_CODE_ENDPOINT,
671            ApiVersion::new(0, 0),
672            async |fedimint: &ConsensusApi, _context,  _v: ()| -> String {
673                Ok(fedimint.cfg.get_invite_code(fedimint.get_active_api_secret()).to_string())
674            }
675        },
676        api_endpoint! {
677            FEDERATION_ID_ENDPOINT,
678            ApiVersion::new(0, 2),
679            async |fedimint: &ConsensusApi, _context,  _v: ()| -> String {
680                Ok(fedimint.cfg.calculate_federation_id().to_string())
681            }
682        },
683        api_endpoint! {
684            CLIENT_CONFIG_ENDPOINT,
685            ApiVersion::new(0, 0),
686            async |fedimint: &ConsensusApi, _context, _v: ()| -> ClientConfig {
687                Ok(fedimint.client_cfg.clone())
688            }
689        },
690        // Helper endpoint for Admin UI that can't parse consensus encoding
691        api_endpoint! {
692            CLIENT_CONFIG_JSON_ENDPOINT,
693            ApiVersion::new(0, 0),
694            async |fedimint: &ConsensusApi, _context, _v: ()| -> JsonClientConfig {
695                Ok(fedimint.client_cfg.to_json())
696            }
697        },
698        api_endpoint! {
699            SERVER_CONFIG_CONSENSUS_HASH_ENDPOINT,
700            ApiVersion::new(0, 0),
701            async |fedimint: &ConsensusApi, _context, _v: ()| -> sha256::Hash {
702                Ok(legacy_consensus_config_hash(&fedimint.cfg.consensus))
703            }
704        },
705        api_endpoint! {
706            STATUS_ENDPOINT,
707            ApiVersion::new(0, 0),
708            async |fedimint: &ConsensusApi, _context, _v: ()| -> StatusResponse {
709                Ok(StatusResponse {
710                    server: ServerStatusLegacy::ConsensusRunning,
711                    federation: Some(fedimint.get_federation_status().await?)
712                })}
713        },
714        api_endpoint! {
715            SETUP_STATUS_ENDPOINT,
716            ApiVersion::new(0, 0),
717            async |_f: &ConsensusApi, _c, _v: ()| -> SetupStatus {
718                Ok(SetupStatus::ConsensusIsRunning)
719            }
720        },
721        api_endpoint! {
722            CONSENSUS_ORD_LATENCY_ENDPOINT,
723            ApiVersion::new(0, 0),
724            async |fedimint: &ConsensusApi, _c, _v: ()| -> Option<Duration> {
725                Ok(*fedimint.ord_latency_receiver.borrow())
726            }
727        },
728        api_endpoint! {
729            P2P_CONNECTION_STATUS_ENDPOINT,
730            ApiVersion::new(0, 0),
731            async |fedimint: &ConsensusApi, _c, _v: ()| -> BTreeMap<PeerId, Option<Duration>> {
732                Ok(fedimint.p2p_status_receivers
733                    .iter()
734                    .map(|(peer, receiver)| (*peer, *receiver.borrow()))
735                    .collect())
736            }
737        },
738        api_endpoint! {
739            SESSION_COUNT_ENDPOINT,
740            ApiVersion::new(0, 0),
741            async |fedimint: &ConsensusApi, _context, _v: ()| -> u64 {
742                Ok(fedimint.session_count().await)
743            }
744        },
745        api_endpoint! {
746            AWAIT_SESSION_OUTCOME_ENDPOINT,
747            ApiVersion::new(0, 0),
748            async |fedimint: &ConsensusApi, _context, index: u64| -> SerdeModuleEncoding<SessionOutcome> {
749                Ok((&fedimint.await_signed_session_outcome(index).await.session_outcome).into())
750            }
751        },
752        api_endpoint! {
753            AWAIT_SIGNED_SESSION_OUTCOME_ENDPOINT,
754            ApiVersion::new(0, 0),
755            async |fedimint: &ConsensusApi, _context, index: u64| -> SerdeModuleEncoding<SignedSessionOutcome> {
756                Ok((&fedimint.await_signed_session_outcome(index).await).into())
757            }
758        },
759        api_endpoint! {
760            SESSION_STATUS_ENDPOINT,
761            ApiVersion::new(0, 1),
762            async |fedimint: &ConsensusApi, _context, index: u64| -> SerdeModuleEncoding<SessionStatus> {
763                Ok((&SessionStatus::from(fedimint.session_status(index).await)).into())
764            }
765        },
766        api_endpoint! {
767            SESSION_STATUS_V2_ENDPOINT,
768            ApiVersion::new(0, 5),
769            async |fedimint: &ConsensusApi, _context, index: u64| -> SerdeModuleEncodingBase64<SessionStatusV2> {
770                Ok((&fedimint.session_status(index).await).into())
771            }
772        },
773        api_endpoint! {
774            SHUTDOWN_ENDPOINT,
775            ApiVersion::new(0, 3),
776            async |fedimint: &ConsensusApi, context, index: Option<u64>| -> () {
777                check_auth(context)?;
778                fedimint.shutdown(index);
779                Ok(())
780            }
781        },
782        api_endpoint! {
783            AUDIT_ENDPOINT,
784            ApiVersion::new(0, 0),
785            async |fedimint: &ConsensusApi, context, _v: ()| -> AuditSummary {
786                check_auth(context)?;
787                Ok(fedimint.get_federation_audit().await?)
788            }
789        },
790        api_endpoint! {
791            GUARDIAN_CONFIG_BACKUP_ENDPOINT,
792            ApiVersion::new(0, 2),
793            async |fedimint: &ConsensusApi, context, _v: ()| -> GuardianConfigBackup {
794                let auth = check_auth(context)?;
795                let password = context.request_auth().expect("Auth was checked before").0;
796                Ok(fedimint.get_guardian_config_backup(&password, &auth))
797            }
798        },
799        api_endpoint! {
800            BACKUP_ENDPOINT,
801            ApiVersion::new(0, 0),
802            async |fedimint: &ConsensusApi, context, request: SignedBackupRequest| -> () {
803                fedimint
804                    .handle_backup_request(&mut context.dbtx().into_nc(), request).await?;
805                Ok(())
806
807            }
808        },
809        api_endpoint! {
810            RECOVER_ENDPOINT,
811            ApiVersion::new(0, 0),
812            async |fedimint: &ConsensusApi, context, id: PublicKey| -> Option<ClientBackupSnapshot> {
813                Ok(fedimint
814                    .handle_recover_request(&mut context.dbtx().into_nc(), id).await)
815            }
816        },
817        api_endpoint! {
818            AUTH_ENDPOINT,
819            ApiVersion::new(0, 0),
820            async |_fedimint: &ConsensusApi, context, _v: ()| -> () {
821                check_auth(context)?;
822                Ok(())
823            }
824        },
825        api_endpoint! {
826            API_ANNOUNCEMENTS_ENDPOINT,
827            ApiVersion::new(0, 3),
828            async |fedimint: &ConsensusApi, _context, _v: ()| -> BTreeMap<PeerId, SignedApiAnnouncement> {
829                Ok(fedimint.api_announcements().await)
830            }
831        },
832        api_endpoint! {
833            SUBMIT_API_ANNOUNCEMENT_ENDPOINT,
834            ApiVersion::new(0, 3),
835            async |fedimint: &ConsensusApi, _context, submission: SignedApiAnnouncementSubmission| -> () {
836                fedimint.submit_api_announcement(submission.peer_id, submission.signed_api_announcement).await
837            }
838        },
839        api_endpoint! {
840            SIGN_API_ANNOUNCEMENT_ENDPOINT,
841            ApiVersion::new(0, 3),
842            async |fedimint: &ConsensusApi, context, new_url: SafeUrl| -> SignedApiAnnouncement {
843                check_auth(context)?;
844                Ok(fedimint.sign_api_announcement(new_url).await)
845            }
846        },
847        api_endpoint! {
848            FEDIMINTD_VERSION_ENDPOINT,
849            ApiVersion::new(0, 4),
850            async |fedimint: &ConsensusApi, _context, _v: ()| -> String {
851                Ok(fedimint.fedimintd_version())
852            }
853        },
854        api_endpoint! {
855            BACKUP_STATISTICS_ENDPOINT,
856            ApiVersion::new(0, 5),
857            async |_fedimint: &ConsensusApi, context, _v: ()| -> BackupStatistics {
858                check_auth(context)?;
859                Ok(backup_statistics_static(&mut context.dbtx().into_nc()).await)
860            }
861        },
862    ]
863}
864
865pub(crate) async fn backup_statistics_static(
866    dbtx: &mut DatabaseTransaction<'_>,
867) -> BackupStatistics {
868    const DAY_SECS: u64 = 24 * 60 * 60;
869    const WEEK_SECS: u64 = 7 * DAY_SECS;
870    const MONTH_SECS: u64 = 30 * DAY_SECS;
871    const QUARTER_SECS: u64 = 3 * MONTH_SECS;
872
873    let mut backup_stats = BackupStatistics::default();
874
875    let mut all_backups_stream = dbtx.find_by_prefix(&ClientBackupKeyPrefix).await;
876    while let Some((_, backup)) = all_backups_stream.next().await {
877        backup_stats.num_backups += 1;
878        backup_stats.total_size += backup.data.len();
879
880        let age_secs = backup.timestamp.elapsed().unwrap_or_default().as_secs();
881        if age_secs < DAY_SECS {
882            backup_stats.refreshed_1d += 1;
883        }
884        if age_secs < WEEK_SECS {
885            backup_stats.refreshed_1w += 1;
886        }
887        if age_secs < MONTH_SECS {
888            backup_stats.refreshed_1m += 1;
889        }
890        if age_secs < QUARTER_SECS {
891            backup_stats.refreshed_3m += 1;
892        }
893    }
894
895    backup_stats
896}