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