Skip to main content

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;
4use std::future::pending;
5use std::path::{Path, PathBuf};
6use std::time::{Duration, SystemTime};
7
8use anyhow::{Context, Result, ensure};
9use async_trait::async_trait;
10use bitcoin::hashes::sha256;
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_OUTPUTS_OUTCOMES_ENDPOINT,
28    AWAIT_SESSION_OUTCOME_ENDPOINT, AWAIT_SIGNED_SESSION_OUTCOME_ENDPOINT,
29    AWAIT_TRANSACTION_ENDPOINT, BACKUP_ENDPOINT, BACKUP_STATISTICS_ENDPOINT, CHAIN_ID_ENDPOINT,
30    CLIENT_CONFIG_ENDPOINT, CLIENT_CONFIG_JSON_ENDPOINT, CONSENSUS_ORD_LATENCY_ENDPOINT,
31    FEDERATION_ID_ENDPOINT, FEDIMINTD_VERSION_ENDPOINT, GUARDIAN_CONFIG_BACKUP_ENDPOINT,
32    GUARDIAN_METADATA_ENDPOINT, INVITE_CODE_ENDPOINT, P2P_CONNECTION_STATUS_ENDPOINT,
33    PEER_AVAILABILITY_ENDPOINT, RECOVER_ENDPOINT, SERVER_CONFIG_CONSENSUS_HASH_ENDPOINT,
34    SESSION_COUNT_ENDPOINT, SESSION_STATUS_ENDPOINT, SESSION_STATUS_V2_ENDPOINT,
35    SETUP_STATUS_ENDPOINT, SHUTDOWN_ENDPOINT, SIGN_API_ANNOUNCEMENT_ENDPOINT,
36    SIGN_GUARDIAN_METADATA_ENDPOINT, STATUS_ENDPOINT, SUBMIT_API_ANNOUNCEMENT_ENDPOINT,
37    SUBMIT_GUARDIAN_METADATA_ENDPOINT, SUBMIT_TRANSACTION_ENDPOINT, VERSION_ENDPOINT,
38};
39use fedimint_core::invite_code::InviteCode;
40use fedimint_core::module::audit::{Audit, AuditSummary};
41use fedimint_core::module::{
42    ApiAuth, ApiEndpoint, ApiEndpointContext, ApiError, ApiRequestErased, ApiResult, ApiVersion,
43    SerdeModuleEncoding, SerdeModuleEncodingBase64, SupportedApiVersionsSummary,
44    admin_api_endpoint, public_api_endpoint,
45};
46use fedimint_core::net::api_announcement::{
47    ApiAnnouncement, SignedApiAnnouncement, SignedApiAnnouncementSubmission,
48};
49use fedimint_core::net::auth::GuardianAuthToken;
50use fedimint_core::peer_availability::PeerAvailability;
51use fedimint_core::secp256k1::{PublicKey, SECP256K1};
52use fedimint_core::session_outcome::{
53    ConsensusItem, SessionOutcome, SessionStatus, SessionStatusV2, SignedSessionOutcome,
54};
55use fedimint_core::task::TaskGroup;
56use fedimint_core::transaction::{
57    SerdeTransaction, Transaction, TransactionError, TransactionSubmissionOutcome,
58};
59use fedimint_core::util::{FmtCompact, SafeUrl};
60use fedimint_core::version::non_zero_version_hash;
61use fedimint_core::{ChainId, OutPoint, OutPointRange, PeerId, TransactionId, secp256k1};
62use fedimint_logging::LOG_NET_API;
63use fedimint_server_core::bitcoin_rpc::ServerBitcoinRpcMonitor;
64use fedimint_server_core::dashboard_ui::{
65    IDashboardApi, P2PConnectionStatus, ServerBitcoinRpcStatus,
66};
67use fedimint_server_core::{DynServerModule, ServerModuleRegistry, ServerModuleRegistryExt};
68use futures::StreamExt;
69use tokio::sync::watch::{self, Receiver, Sender};
70use tracing::{debug, info, warn};
71
72use crate::config::io::{CONSENSUS_CONFIG, JSON_EXT, LOCAL_CONFIG, PRIVATE_CONFIG};
73use crate::config::{ServerConfig, legacy_consensus_config_hash};
74use crate::consensus::db::{
75    AcceptedItemPrefix, AcceptedTransactionKey, PeerAvailabilityKey, SignedSessionOutcomeKey,
76};
77use crate::consensus::engine::get_finished_session_count_static;
78use crate::consensus::transaction::{TxProcessingMode, process_transaction_with_dbtx};
79use crate::metrics::{BACKUP_WRITE_SIZE_BYTES, STORED_BACKUPS_COUNT};
80use crate::net::api::HasApiContext;
81use crate::net::api::announcement::{ApiAnnouncementKey, ApiAnnouncementPrefix, get_api_urls};
82use crate::net::p2p::{P2PConnectionState, P2PStatusReceivers};
83
84/// Maximum number of output outcomes a single `await_outputs_outcomes` request
85/// may ask for. The endpoint is public and unauthenticated and the requested
86/// range is used verbatim to size a `Vec`, so without a cap one request can
87/// trigger a terabyte-scale allocation and abort the guardian process.
88///
89/// Real transactions have a handful of outputs; a client that somehow needs
90/// more can still ask for them one outpoint at a time.
91const MAX_OUTPUTS_OUTCOMES_BATCH: usize = 1024;
92
93/// Maximum clock lead accepted for a signed client backup request.
94///
95/// This matches the one-hour limit used for signed guardian metadata
96/// timestamps.
97const MAX_BACKUP_FUTURE_TIMESTAMP_SECS: u64 = 60 * 60;
98
99fn backup_timestamp_is_too_far_in_future(timestamp: SystemTime, now: SystemTime) -> bool {
100    timestamp
101        .duration_since(now)
102        .is_ok_and(|lead| lead > Duration::from_secs(MAX_BACKUP_FUTURE_TIMESTAMP_SECS))
103}
104
105/// Number of output outcomes `outpoint_range` asks for, if it is a range we are
106/// willing to serve.
107///
108/// The range is deserialized verbatim from an unauthenticated request, so it
109/// has to be bounded before anything is sized from it.
110fn checked_outputs_outcomes_count(outpoint_range: OutPointRange) -> Result<usize> {
111    let count = outpoint_range
112        .checked_count()
113        .context("Outpoint range is descending or too large")?;
114
115    ensure!(
116        count <= MAX_OUTPUTS_OUTCOMES_BATCH,
117        "Outpoint range must cover at most {MAX_OUTPUTS_OUTCOMES_BATCH} outputs, got {count}"
118    );
119
120    Ok(count)
121}
122
123/// Status of a single peer, read from the p2p and consensus-item watch
124/// channels it is tracked by.
125///
126/// Each channel is read exactly once, into a local. `watch::Receiver::borrow`
127/// returns a guard over a `parking_lot::RwLock`, and Rust drops temporaries at
128/// the end of the enclosing *statement* rather than the sub-expression, so
129/// borrowing inline in a struct literal would keep several guards alive at
130/// once. That deadlocks the whole process: `parking_lot`'s `RwLock` is
131/// write-preferring, so a second `borrow()` of a channel this thread already
132/// holds a guard on blocks behind any writer that queued in between, and that
133/// writer can never drain because the first guard is still held. These are
134/// blocking waits, so every stuck request burns a tokio worker until the
135/// runtime has none left to poll the reactor with.
136fn legacy_peer_status(
137    p2p_receiver: &watch::Receiver<P2PConnectionState>,
138    ci_receiver: &watch::Receiver<Option<u64>>,
139    session_count: u64,
140) -> LegacyPeerStatus {
141    let p2p_connected = p2p_receiver.borrow().connected.is_some();
142    let last_contribution = *ci_receiver.borrow();
143
144    LegacyPeerStatus {
145        connection_status: if p2p_connected {
146            LegacyP2PConnectionStatus::Connected
147        } else {
148            LegacyP2PConnectionStatus::Disconnected
149        },
150        last_contribution,
151        flagged: last_contribution.unwrap_or(0) + 1 < session_count,
152    }
153}
154
155#[derive(Clone)]
156pub struct ConsensusApi {
157    /// Our server configuration
158    pub cfg: ServerConfig,
159    /// Database for serving the API
160    pub db: Database,
161    /// Modules registered with the federation
162    pub modules: ServerModuleRegistry,
163    /// Cached client config
164    pub client_cfg: ClientConfig,
165    pub force_api_secret: Option<String>,
166    /// For sending API events to consensus such as transactions
167    pub submission_sender: async_channel::Sender<ConsensusItem>,
168    pub shutdown_receiver: Receiver<Option<u64>>,
169    pub shutdown_sender: Sender<Option<u64>>,
170    pub ord_latency_receiver: watch::Receiver<Option<Duration>>,
171    /// Notified whenever consensus accepts an item.
172    pub accepted_item_receiver: watch::Receiver<()>,
173    /// Notified whenever consensus completes a session.
174    pub completed_session_receiver: watch::Receiver<()>,
175    pub p2p_status_receivers: P2PStatusReceivers,
176    pub ci_status_receivers: BTreeMap<PeerId, Receiver<Option<u64>>>,
177    pub bitcoin_rpc_connection: ServerBitcoinRpcMonitor,
178    pub supported_api_versions: SupportedApiVersionsSummary,
179    pub auth_ui: Option<ApiAuth>,
180    pub auth_api: Option<ApiAuth>,
181    pub code_version_str: String,
182    pub code_version_hash: String,
183    pub task_group: TaskGroup,
184}
185
186impl ConsensusApi {
187    pub fn api_versions_summary(&self) -> &SupportedApiVersionsSummary {
188        &self.supported_api_versions
189    }
190
191    pub fn get_active_api_secret(&self) -> Option<String> {
192        // TODO: In the future, we might want to fetch it from the DB, so it's possible
193        // to customize from the UX
194        self.force_api_secret.clone()
195    }
196
197    // Submit a transaction and long-poll until it is either accepted by
198    // consensus or becomes invalid; we return an error if and only if the
199    // transaction is invalid against the current consensus state
200    pub async fn submit_transaction(
201        &self,
202        transaction: Transaction,
203    ) -> Result<TransactionId, TransactionError> {
204        let txid = transaction.tx_hash();
205
206        debug!(target: LOG_NET_API, %txid, "Received a submitted transaction");
207
208        // Marked as seen before the transaction is checked, so that every item
209        // accepted and every session completed from here on wakes the loop below.
210        let mut accepted_item = self.accepted_item_receiver.clone();
211        let mut completed_session = self.completed_session_receiver.clone();
212
213        accepted_item.mark_unchanged();
214        completed_session.mark_unchanged();
215
216        // Create read-only DB tx so that the read state is consistent
217        let mut dbtx = self.db.begin_transaction_nc().await;
218        // we already processed the transaction before
219        if dbtx
220            .get_value(&AcceptedTransactionKey(txid))
221            .await
222            .is_some()
223        {
224            debug!(target: LOG_NET_API, %txid, "Transaction already accepted");
225            return Ok(txid);
226        }
227
228        // We ignore any writes, as we only verify if the transaction is valid here
229        dbtx.ignore_uncommitted();
230
231        process_transaction_with_dbtx(
232            self.modules.clone(),
233            &mut dbtx,
234            &transaction,
235            self.cfg.consensus.version,
236            TxProcessingMode::Submission,
237        )
238        .await
239        .inspect_err(|err| {
240            debug!(target: LOG_NET_API, %txid, err = %err.fmt_compact(), "Transaction rejected");
241        })?;
242
243        drop(dbtx);
244
245        let _ = self
246            .submission_sender
247            .send(ConsensusItem::Transaction(transaction.clone()))
248            .await
249            .inspect_err(|err| {
250                warn!(target: LOG_NET_API, %txid, err = %err.fmt_compact(), "Unable to submit the tx into consensus");
251            });
252
253        loop {
254            let (changed, session_completed) = tokio::select! {
255                changed = accepted_item.changed() => (changed, false),
256                changed = completed_session.changed() => (changed, true),
257            };
258
259            // The engine owns the senders for the life of the server, so this only
260            // fails at shutdown, which cancels the request anyway.
261            if changed.is_err() {
262                pending::<()>().await;
263            }
264
265            let mut dbtx = self.db.begin_transaction_nc().await;
266
267            // Our transaction may be the item that was just accepted, in which
268            // case revalidation would fail against its own state changes
269            if dbtx
270                .get_value(&AcceptedTransactionKey(txid))
271                .await
272                .is_some()
273            {
274                return Ok(txid);
275            }
276
277            dbtx.ignore_uncommitted();
278
279            process_transaction_with_dbtx(
280                self.modules.clone(),
281                &mut dbtx,
282                &transaction,
283                self.cfg.consensus.version,
284                TxProcessingMode::Submission,
285            )
286            .await
287            .inspect_err(|err| {
288                debug!(target: LOG_NET_API, %txid, err = %err.fmt_compact(), "Transaction rejected");
289            })?;
290
291            drop(dbtx);
292
293            if session_completed {
294                // A pending transaction may have been dropped at the session
295                // boundary since the data provider is recreated every session,
296                // hence we resubmit it for the next session
297                let _ = self
298                    .submission_sender
299                    .send(ConsensusItem::Transaction(transaction.clone()))
300                    .await
301                    .inspect_err(|err| {
302                        warn!(target: LOG_NET_API, %txid, err = %err.fmt_compact(), "Unable to submit the tx into consensus");
303                    });
304            }
305        }
306    }
307
308    pub async fn await_transaction(
309        &self,
310        txid: TransactionId,
311    ) -> (Vec<ModuleInstanceId>, DatabaseTransaction<'_, Committable>) {
312        debug!(target: LOG_NET_API, %txid, "Awaiting transaction acceptance");
313        self.db
314            .wait_key_check(&AcceptedTransactionKey(txid), std::convert::identity)
315            .await
316    }
317
318    pub async fn await_output_outcome(
319        &self,
320        outpoint: OutPoint,
321    ) -> Result<SerdeModuleEncoding<DynOutputOutcome>> {
322        debug!(target: LOG_NET_API, %outpoint, "Awaiting output outcome");
323        let (module_ids, mut dbtx) = self.await_transaction(outpoint.txid).await;
324
325        let module_id = module_ids
326            .into_iter()
327            .nth(outpoint.out_idx as usize)
328            .with_context(|| format!("Outpoint index out of bounds {outpoint:?}"))?;
329
330        #[allow(deprecated)]
331        let outcome = self
332            .modules
333            .get_expect(module_id)
334            .output_status(
335                &mut dbtx.to_ref_with_prefix_module_id(module_id).0.into_nc(),
336                outpoint,
337                module_id,
338            )
339            .await
340            .context("No output outcome for outpoint")?;
341
342        Ok((&outcome).into())
343    }
344
345    pub async fn await_outputs_outcomes(
346        &self,
347        outpoint_range: OutPointRange,
348    ) -> Result<Vec<Option<SerdeModuleEncoding<DynOutputOutcome>>>> {
349        // Has to happen before `await_transaction`, which blocks until the
350        // transaction shows up.
351        let count = checked_outputs_outcomes_count(outpoint_range)?;
352
353        // Wait for the transaction to be accepted first
354        let (module_ids, mut dbtx) = self.await_transaction(outpoint_range.txid()).await;
355
356        let mut outcomes = Vec::with_capacity(count);
357
358        for outpoint in outpoint_range {
359            let module_id = module_ids
360                .get(outpoint.out_idx as usize)
361                .with_context(|| format!("Outpoint index out of bounds {outpoint:?}"))?;
362
363            #[allow(deprecated)]
364            let outcome = self
365                .modules
366                .get_expect(*module_id)
367                .output_status(
368                    &mut dbtx.to_ref_with_prefix_module_id(*module_id).0.into_nc(),
369                    outpoint,
370                    *module_id,
371                )
372                .await
373                .map(|outcome| (&outcome).into());
374
375            outcomes.push(outcome);
376        }
377
378        Ok(outcomes)
379    }
380
381    pub async fn session_count(&self) -> u64 {
382        get_finished_session_count_static(&mut self.db.begin_transaction_nc().await).await
383    }
384
385    pub async fn await_signed_session_outcome(&self, index: u64) -> SignedSessionOutcome {
386        self.db
387            .wait_key_check(&SignedSessionOutcomeKey(index), std::convert::identity)
388            .await
389            .0
390    }
391
392    pub async fn session_status(&self, session_index: u64) -> SessionStatusV2 {
393        let mut dbtx = self.db.begin_transaction_nc().await;
394
395        match session_index.cmp(&get_finished_session_count_static(&mut dbtx).await) {
396            Ordering::Greater => SessionStatusV2::Initial,
397            Ordering::Equal => SessionStatusV2::Pending(
398                dbtx.find_by_prefix(&AcceptedItemPrefix)
399                    .await
400                    .map(|entry| entry.1)
401                    .collect()
402                    .await,
403            ),
404            Ordering::Less => SessionStatusV2::Complete(
405                dbtx.get_value(&SignedSessionOutcomeKey(session_index))
406                    .await
407                    .expect("There are no gaps in session outcomes"),
408            ),
409        }
410    }
411
412    pub async fn get_federation_status(&self) -> ApiResult<LegacyFederationStatus> {
413        let session_count = self.session_count().await;
414        let scheduled_shutdown = self.shutdown_receiver.borrow().to_owned();
415
416        let status_by_peer = self
417            .p2p_status_receivers
418            .iter()
419            .map(|(peer, p2p_receiver)| {
420                let ci_receiver = self.ci_status_receivers.get(peer).unwrap();
421
422                (
423                    *peer,
424                    legacy_peer_status(p2p_receiver, ci_receiver, session_count),
425                )
426            })
427            .collect::<BTreeMap<_, _>>();
428
429        let peers_flagged = status_by_peer
430            .values()
431            .filter(|status| status.flagged)
432            .count() as u64;
433
434        let peers_online = status_by_peer
435            .values()
436            .filter(|status| status.connection_status == LegacyP2PConnectionStatus::Connected)
437            .count() as u64;
438
439        let peers_offline = status_by_peer
440            .values()
441            .filter(|status| status.connection_status == LegacyP2PConnectionStatus::Disconnected)
442            .count() as u64;
443
444        Ok(LegacyFederationStatus {
445            session_count,
446            status_by_peer,
447            peers_online,
448            peers_offline,
449            peers_flagged,
450            scheduled_shutdown,
451        })
452    }
453
454    fn shutdown(&self, index: Option<u64>) {
455        self.shutdown_sender.send_replace(index);
456    }
457
458    async fn get_federation_audit(&self) -> ApiResult<AuditSummary> {
459        let mut dbtx = self.db.begin_transaction_nc().await;
460        // Writes are related to compacting audit keys, which we can safely ignore
461        // within an API request since the compaction will happen when constructing an
462        // audit in the consensus server
463        dbtx.ignore_uncommitted();
464
465        let mut audit = Audit::default();
466        let mut module_instance_id_to_kind = BTreeMap::new();
467        for (module_instance_id, kind, module) in self.modules.iter_modules() {
468            module_instance_id_to_kind.insert(module_instance_id, kind.as_str().to_string());
469            module
470                .audit(
471                    &mut dbtx.to_ref_with_prefix_module_id(module_instance_id).0,
472                    &mut audit,
473                    module_instance_id,
474                )
475                .await;
476        }
477        Ok(AuditSummary::from_audit(
478            &audit,
479            &module_instance_id_to_kind,
480        ))
481    }
482
483    /// Uses the in-memory config to write a config backup tar archive that
484    /// guardians can download. Private keys are stored as plaintext JSON.
485    /// Operators should rely on disk encryption for at-rest protection.
486    fn get_guardian_config_backup(&self, _auth: &GuardianAuthToken) -> GuardianConfigBackup {
487        let mut tar_archive_builder = tar::Builder::new(Vec::new());
488
489        let mut append = |name: &Path, data: &[u8]| {
490            let mut header = tar::Header::new_gnu();
491            header.set_path(name).expect("Error setting path");
492            header.set_size(data.len() as u64);
493            header.set_mode(0o644);
494            header.set_cksum();
495            tar_archive_builder
496                .append(&header, data)
497                .expect("Error adding data to tar archive");
498        };
499
500        append(
501            &PathBuf::from(LOCAL_CONFIG).with_extension(JSON_EXT),
502            &serde_json::to_vec(&self.cfg.local).expect("Error encoding local config"),
503        );
504
505        append(
506            &PathBuf::from(CONSENSUS_CONFIG).with_extension(JSON_EXT),
507            &serde_json::to_vec(&self.cfg.consensus).expect("Error encoding consensus config"),
508        );
509
510        append(
511            &PathBuf::from(PRIVATE_CONFIG).with_extension(JSON_EXT),
512            &serde_json::to_vec(&self.cfg.private).expect("Error encoding private config"),
513        );
514
515        let tar_archive_bytes = tar_archive_builder
516            .into_inner()
517            .expect("Error building tar archive");
518
519        GuardianConfigBackup { tar_archive_bytes }
520    }
521
522    async fn handle_backup_request(
523        &self,
524        dbtx: &mut DatabaseTransaction<'_>,
525        request: SignedBackupRequest,
526    ) -> Result<(), ApiError> {
527        let request = request
528            .verify_valid(SECP256K1)
529            .map_err(|_| ApiError::bad_request("invalid request".into()))?;
530
531        if backup_timestamp_is_too_far_in_future(request.timestamp, fedimint_core::time::now()) {
532            return Err(ApiError::bad_request(
533                "timestamp too far in the future".into(),
534            ));
535        }
536
537        if request.payload.len() > BACKUP_REQUEST_MAX_PAYLOAD_SIZE_BYTES {
538            return Err(ApiError::bad_request("snapshot too large".into()));
539        }
540        debug!(target: LOG_NET_API, id = %request.id, len = request.payload.len(), "Received client backup request");
541        if let Some(prev) = dbtx.get_value(&ClientBackupKey(request.id)).await
542            && request.timestamp <= prev.timestamp
543        {
544            debug!(target: LOG_NET_API, id = %request.id, len = request.payload.len(), "Received client backup request with old timestamp - ignoring");
545            return Err(ApiError::bad_request("timestamp too small".into()));
546        }
547
548        info!(target: LOG_NET_API, id = %request.id, len = request.payload.len(), "Storing new client backup");
549        let overwritten = dbtx
550            .insert_entry(
551                &ClientBackupKey(request.id),
552                &ClientBackupSnapshot {
553                    timestamp: request.timestamp,
554                    data: request.payload.clone(),
555                },
556            )
557            .await
558            .is_some();
559        BACKUP_WRITE_SIZE_BYTES.observe(request.payload.len() as f64);
560        if !overwritten {
561            dbtx.on_commit(|| STORED_BACKUPS_COUNT.inc());
562        }
563
564        Ok(())
565    }
566
567    async fn handle_recover_request(
568        &self,
569        dbtx: &mut DatabaseTransaction<'_>,
570        id: PublicKey,
571    ) -> Option<ClientBackupSnapshot> {
572        dbtx.get_value(&ClientBackupKey(id)).await
573    }
574
575    /// List API URL announcements from all peers we have received them from (at
576    /// least ourselves)
577    async fn api_announcements(&self) -> BTreeMap<PeerId, SignedApiAnnouncement> {
578        self.db
579            .begin_transaction_nc()
580            .await
581            .find_by_prefix(&ApiAnnouncementPrefix)
582            .await
583            .map(|(announcement_key, announcement)| (announcement_key.0, announcement))
584            .collect()
585            .await
586    }
587
588    /// Returns the tagged fedimintd version currently running
589    fn fedimintd_version(&self) -> String {
590        self.code_version_str.clone()
591    }
592
593    /// Add an API URL announcement from a peer to our database to be returned
594    /// by [`ConsensusApi::api_announcements`].
595    async fn submit_api_announcement(
596        &self,
597        peer_id: PeerId,
598        announcement: SignedApiAnnouncement,
599    ) -> Result<(), ApiError> {
600        let Some(peer_key) = self.cfg.consensus.broadcast_public_keys.get(&peer_id) else {
601            return Err(ApiError::bad_request("Peer not in federation".into()));
602        };
603
604        if !announcement.verify(SECP256K1, peer_key) {
605            return Err(ApiError::bad_request("Invalid signature".into()));
606        }
607
608        // Use autocommit to handle potential transaction conflicts with retries
609        self.db
610            .autocommit(
611                |dbtx, _| {
612                    let announcement = announcement.clone();
613                    Box::pin(async move {
614                        if let Some(existing_announcement) =
615                            dbtx.get_value(&ApiAnnouncementKey(peer_id)).await
616                        {
617                            // If the current announcement is semantically identical to the new one
618                            // (except for potentially having a
619                            // different, valid signature) we return ok to allow
620                            // the caller to stop submitting the value if they are in a retry loop.
621                            if existing_announcement.api_announcement
622                                == announcement.api_announcement
623                            {
624                                return Ok(());
625                            }
626
627                            // We only accept announcements with a nonce higher than the current one
628                            // to avoid replay attacks.
629                            if existing_announcement.api_announcement.nonce
630                                >= announcement.api_announcement.nonce
631                            {
632                                return Err(ApiError::bad_request(
633                                    "Outdated or redundant announcement".into(),
634                                ));
635                            }
636                        }
637
638                        dbtx.insert_entry(&ApiAnnouncementKey(peer_id), &announcement)
639                            .await;
640                        Ok(())
641                    })
642                },
643                None,
644            )
645            .await
646            .map_err(|e| match e {
647                fedimint_core::db::AutocommitError::ClosureError { error, .. } => error,
648                fedimint_core::db::AutocommitError::CommitFailed { last_error, .. } => {
649                    ApiError::server_error(format!("Database commit failed: {last_error}"))
650                }
651            })
652    }
653
654    async fn sign_api_announcement(&self, new_url: SafeUrl) -> SignedApiAnnouncement {
655        self.db
656            .autocommit(
657                |dbtx, _| {
658                    let new_url_inner = new_url.clone();
659                    Box::pin(async move {
660                        let new_nonce = dbtx
661                            .get_value(&ApiAnnouncementKey(self.cfg.local.identity))
662                            .await
663                            .map_or(0, |a| a.api_announcement.nonce + 1);
664                        let announcement = ApiAnnouncement {
665                            api_url: new_url_inner,
666                            nonce: new_nonce,
667                        };
668                        let ctx = secp256k1::Secp256k1::new();
669                        let signed_announcement = announcement
670                            .sign(&ctx, &self.cfg.private.broadcast_secret_key.keypair(&ctx));
671
672                        dbtx.insert_entry(
673                            &ApiAnnouncementKey(self.cfg.local.identity),
674                            &signed_announcement,
675                        )
676                        .await;
677
678                        Result::<_, ()>::Ok(signed_announcement)
679                    })
680                },
681                None,
682            )
683            .await
684            .expect("Will not terminate on error")
685    }
686
687    async fn guardian_metadata_list(
688        &self,
689    ) -> BTreeMap<PeerId, fedimint_core::net::guardian_metadata::SignedGuardianMetadata> {
690        use crate::net::api::guardian_metadata::{GuardianMetadataKey, GuardianMetadataPrefix};
691
692        self.db
693            .begin_transaction_nc()
694            .await
695            .find_by_prefix(&GuardianMetadataPrefix)
696            .await
697            .map(|(key, metadata): (GuardianMetadataKey, _)| (key.0, metadata))
698            .collect()
699            .await
700    }
701
702    async fn submit_guardian_metadata(
703        &self,
704        peer_id: PeerId,
705        metadata: fedimint_core::net::guardian_metadata::SignedGuardianMetadata,
706    ) -> Result<(), ApiError> {
707        use crate::net::api::guardian_metadata::GuardianMetadataKey;
708
709        let Some(peer_key) = self.cfg.consensus.broadcast_public_keys.get(&peer_id) else {
710            return Err(ApiError::bad_request("Peer not in federation".into()));
711        };
712
713        let now = fedimint_core::time::duration_since_epoch();
714        if let Err(e) = metadata.verify(SECP256K1, peer_key, now) {
715            return Err(ApiError::bad_request(format!(
716                "Invalid signature or timestamp: {e}"
717            )));
718        }
719
720        let mut dbtx = self.db.begin_transaction().await;
721
722        if let Some(existing_metadata) = dbtx.get_value(&GuardianMetadataKey(peer_id)).await {
723            // If the current metadata is semantically identical to the new one (except
724            // for potentially having a different, valid signature) we return ok to allow
725            // the caller to stop submitting the value if they are in a retry loop.
726            if existing_metadata.bytes == metadata.bytes {
727                return Ok(());
728            }
729
730            // Only update if the new metadata has a newer timestamp
731            if metadata.guardian_metadata().timestamp_secs
732                <= existing_metadata.guardian_metadata().timestamp_secs
733            {
734                return Err(ApiError::bad_request(
735                    "New metadata timestamp is not newer than existing".into(),
736                ));
737            }
738        }
739
740        dbtx.insert_entry(&GuardianMetadataKey(peer_id), &metadata)
741            .await;
742        dbtx.commit_tx().await;
743
744        Ok(())
745    }
746
747    async fn sign_guardian_metadata(
748        &self,
749        new_metadata: fedimint_core::net::guardian_metadata::GuardianMetadata,
750    ) -> fedimint_core::net::guardian_metadata::SignedGuardianMetadata {
751        sign_guardian_metadata_preserving_iroh_endpoint(
752            &self.db,
753            self.cfg.local.identity,
754            &self.cfg.private.broadcast_secret_key,
755            new_metadata,
756        )
757        .await
758    }
759
760    async fn get_invite_code(&self, api_secret: Option<String>) -> InviteCode {
761        let identity = self.cfg.local.identity;
762        let mut api_urls = get_api_urls(&self.db, &self.cfg.consensus).await;
763
764        InviteCode::new(
765            api_urls
766                .remove(&identity)
767                .expect("API URL for our identity must be present"),
768            identity,
769            self.cfg.calculate_federation_id(),
770            api_secret,
771        )
772    }
773}
774
775async fn sign_guardian_metadata_preserving_iroh_endpoint(
776    db: &Database,
777    identity: PeerId,
778    broadcast_secret_key: &secp256k1::SecretKey,
779    new_metadata: fedimint_core::net::guardian_metadata::GuardianMetadata,
780) -> fedimint_core::net::guardian_metadata::SignedGuardianMetadata {
781    use crate::net::api::guardian_metadata::GuardianMetadataKey;
782
783    db.autocommit(
784        |dbtx, _| {
785            let mut new_metadata = new_metadata.clone();
786            Box::pin(async move {
787                new_metadata.iroh_next_endpoint = dbtx
788                    .get_value(&GuardianMetadataKey(identity))
789                    .await
790                    .and_then(|metadata| metadata.guardian_metadata().iroh_next_endpoint.clone());
791
792                let ctx = secp256k1::Secp256k1::new();
793                let signed_metadata = new_metadata.sign(&ctx, &broadcast_secret_key.keypair(&ctx));
794
795                dbtx.insert_entry(&GuardianMetadataKey(identity), &signed_metadata)
796                    .await;
797
798                Result::<_, ()>::Ok(signed_metadata)
799            })
800        },
801        None,
802    )
803    .await
804    .expect("Will not terminate on error")
805}
806
807#[async_trait]
808impl HasApiContext<ConsensusApi> for ConsensusApi {
809    async fn context(
810        &self,
811        request: &ApiRequestErased,
812        id: Option<ModuleInstanceId>,
813    ) -> (&ConsensusApi, ApiEndpointContext) {
814        let mut db = self.db.clone();
815        if let Some(id) = id {
816            db = self.db.with_prefix_module_id(id).0;
817        }
818        let has_auth = match (&self.auth_api, &request.auth) {
819            (Some(server_auth), Some(req_auth)) => server_auth.verify(req_auth.as_str()),
820            _ => false,
821        };
822
823        (self, ApiEndpointContext::new(db, has_auth))
824    }
825}
826
827#[async_trait]
828impl HasApiContext<DynServerModule> for ConsensusApi {
829    async fn context(
830        &self,
831        request: &ApiRequestErased,
832        id: Option<ModuleInstanceId>,
833    ) -> (&DynServerModule, ApiEndpointContext) {
834        let (_, context): (&ConsensusApi, _) = self.context(request, id).await;
835        (
836            self.modules.get_expect(id.expect("required module id")),
837            context,
838        )
839    }
840}
841
842#[async_trait]
843impl IDashboardApi for ConsensusApi {
844    fn auth_ui(&self) -> Option<ApiAuth> {
845        self.auth_ui.clone()
846    }
847
848    async fn guardian_id(&self) -> PeerId {
849        self.cfg.local.identity
850    }
851
852    async fn guardian_names(&self) -> BTreeMap<PeerId, String> {
853        self.cfg
854            .consensus
855            .api_endpoints()
856            .iter()
857            .map(|(peer_id, endpoint)| (*peer_id, endpoint.name.clone()))
858            .collect()
859    }
860
861    async fn federation_name(&self) -> String {
862        self.cfg
863            .consensus
864            .meta
865            .get(META_FEDERATION_NAME_KEY)
866            .cloned()
867            .expect("Federation name must be set")
868    }
869
870    async fn session_count(&self) -> u64 {
871        self.session_count().await
872    }
873
874    async fn get_session_status(&self, session_idx: u64) -> SessionStatusV2 {
875        self.session_status(session_idx).await
876    }
877
878    async fn consensus_ord_latency(&self) -> Option<Duration> {
879        *self.ord_latency_receiver.borrow()
880    }
881
882    async fn p2p_connection_status(&self) -> BTreeMap<PeerId, Option<P2PConnectionStatus>> {
883        self.p2p_status_receivers
884            .iter()
885            .map(|(peer, receiver)| (*peer, receiver.borrow().connected.clone()))
886            .collect()
887    }
888
889    async fn federation_invite_code(&self) -> String {
890        self.get_invite_code(self.get_active_api_secret())
891            .await
892            .to_string()
893    }
894
895    async fn federation_audit(&self) -> AuditSummary {
896        self.get_federation_audit()
897            .await
898            .expect("Failed to get federation audit")
899    }
900
901    async fn bitcoin_rpc_url(&self) -> SafeUrl {
902        self.bitcoin_rpc_connection.url()
903    }
904
905    async fn bitcoin_rpc_status(&self) -> Option<ServerBitcoinRpcStatus> {
906        self.bitcoin_rpc_connection.status()
907    }
908
909    async fn download_guardian_config_backup(
910        &self,
911        guardian_auth: &GuardianAuthToken,
912    ) -> GuardianConfigBackup {
913        self.get_guardian_config_backup(guardian_auth)
914    }
915
916    fn get_module_by_kind(&self, kind: ModuleKind) -> Option<&DynServerModule> {
917        self.modules
918            .iter_modules()
919            .find_map(|(_, module_kind, module)| {
920                if *module_kind == kind {
921                    Some(module)
922                } else {
923                    None
924                }
925            })
926    }
927
928    async fn fedimintd_version(&self) -> String {
929        self.code_version_str.clone()
930    }
931
932    async fn fedimintd_version_hash(&self) -> Option<String> {
933        non_zero_version_hash(&self.code_version_hash).map(str::to_owned)
934    }
935}
936
937pub fn server_endpoints() -> Vec<ApiEndpoint<ConsensusApi>> {
938    vec![
939        public_api_endpoint! {
940            VERSION_ENDPOINT,
941            ApiVersion::new(0, 0),
942            async |fedimint: &ConsensusApi, _context, _v: ()| -> SupportedApiVersionsSummary {
943                Ok(fedimint.api_versions_summary().to_owned())
944            }
945        },
946        public_api_endpoint! {
947            SUBMIT_TRANSACTION_ENDPOINT,
948            ApiVersion::new(0, 0),
949            async |fedimint: &ConsensusApi, _context, transaction: SerdeTransaction| -> SerdeModuleEncoding<TransactionSubmissionOutcome> {
950                let transaction = transaction
951                    .try_into_inner(&fedimint.modules.decoder_registry())
952                    .map_err(|e| ApiError::bad_request(e.to_string()))?;
953
954                // we return an inner error if and only if the submitted transaction is
955                // invalid and will be rejected if we were to submit it to consensus
956                Ok((&TransactionSubmissionOutcome(fedimint.submit_transaction(transaction).await)).into())
957            }
958        },
959        public_api_endpoint! {
960            AWAIT_TRANSACTION_ENDPOINT,
961            ApiVersion::new(0, 0),
962            async |fedimint: &ConsensusApi, _context, tx_hash: TransactionId| -> TransactionId {
963                fedimint.await_transaction(tx_hash).await;
964
965                Ok(tx_hash)
966            }
967        },
968        public_api_endpoint! {
969            AWAIT_OUTPUT_OUTCOME_ENDPOINT,
970            ApiVersion::new(0, 0),
971            async |fedimint: &ConsensusApi, _context, outpoint: OutPoint| -> SerdeModuleEncoding<DynOutputOutcome> {
972                let outcome = fedimint
973                    .await_output_outcome(outpoint)
974                    .await
975                    .map_err(|e| ApiError::bad_request(e.to_string()))?;
976
977                Ok(outcome)
978            }
979        },
980        public_api_endpoint! {
981            AWAIT_OUTPUTS_OUTCOMES_ENDPOINT,
982            ApiVersion::new(0, 8),
983            async |fedimint: &ConsensusApi, _context, outpoint_range: OutPointRange| -> Vec<Option<SerdeModuleEncoding<DynOutputOutcome>>> {
984                let outcomes = fedimint
985                    .await_outputs_outcomes(outpoint_range)
986                    .await
987                    .map_err(|e| ApiError::bad_request(e.to_string()))?;
988
989                Ok(outcomes)
990            }
991        },
992        public_api_endpoint! {
993            INVITE_CODE_ENDPOINT,
994            ApiVersion::new(0, 0),
995            async |fedimint: &ConsensusApi, _context,  _v: ()| -> String {
996                Ok(fedimint.get_invite_code(fedimint.get_active_api_secret()).await.to_string())
997            }
998        },
999        public_api_endpoint! {
1000            FEDERATION_ID_ENDPOINT,
1001            ApiVersion::new(0, 2),
1002            async |fedimint: &ConsensusApi, _context,  _v: ()| -> String {
1003                Ok(fedimint.cfg.calculate_federation_id().to_string())
1004            }
1005        },
1006        public_api_endpoint! {
1007            CLIENT_CONFIG_ENDPOINT,
1008            ApiVersion::new(0, 0),
1009            async |fedimint: &ConsensusApi, _context, _v: ()| -> ClientConfig {
1010                Ok(fedimint.client_cfg.clone())
1011            }
1012        },
1013        // Helper endpoint for Admin UI that can't parse consensus encoding
1014        public_api_endpoint! {
1015            CLIENT_CONFIG_JSON_ENDPOINT,
1016            ApiVersion::new(0, 0),
1017            async |fedimint: &ConsensusApi, _context, _v: ()| -> JsonClientConfig {
1018                Ok(fedimint.client_cfg.to_json())
1019            }
1020        },
1021        public_api_endpoint! {
1022            SERVER_CONFIG_CONSENSUS_HASH_ENDPOINT,
1023            ApiVersion::new(0, 0),
1024            async |fedimint: &ConsensusApi, _context, _v: ()| -> sha256::Hash {
1025                Ok(legacy_consensus_config_hash(&fedimint.cfg.consensus))
1026            }
1027        },
1028        public_api_endpoint! {
1029            STATUS_ENDPOINT,
1030            ApiVersion::new(0, 0),
1031            async |fedimint: &ConsensusApi, _context, _v: ()| -> StatusResponse {
1032                Ok(StatusResponse {
1033                    server: ServerStatusLegacy::ConsensusRunning,
1034                    federation: Some(fedimint.get_federation_status().await?)
1035                })}
1036        },
1037        public_api_endpoint! {
1038            SETUP_STATUS_ENDPOINT,
1039            ApiVersion::new(0, 0),
1040            async |_f: &ConsensusApi, _c, _v: ()| -> SetupStatus {
1041                Ok(SetupStatus::ConsensusIsRunning)
1042            }
1043        },
1044        public_api_endpoint! {
1045            CONSENSUS_ORD_LATENCY_ENDPOINT,
1046            ApiVersion::new(0, 0),
1047            async |fedimint: &ConsensusApi, _c, _v: ()| -> Option<Duration> {
1048                Ok(*fedimint.ord_latency_receiver.borrow())
1049            }
1050        },
1051        public_api_endpoint! {
1052            P2P_CONNECTION_STATUS_ENDPOINT,
1053            ApiVersion::new(0, 0),
1054            async |fedimint: &ConsensusApi, _c, _v: ()| -> BTreeMap<PeerId, Option<P2PConnectionStatus>> {
1055                Ok(fedimint.p2p_status_receivers
1056                    .iter()
1057                    .map(|(peer, receiver)| (*peer, receiver.borrow().connected.clone()))
1058                    .collect())
1059            }
1060        },
1061        public_api_endpoint! {
1062            PEER_AVAILABILITY_ENDPOINT,
1063            ApiVersion::new(0, 0),
1064            async |fedimint: &ConsensusApi, _c, _v: ()| -> PeerAvailability {
1065                Ok(fedimint
1066                    .db
1067                    .begin_transaction_nc()
1068                    .await
1069                    .get_value(&PeerAvailabilityKey)
1070                    .await
1071                    .unwrap_or_default()
1072                    .report(fedimint_core::time::duration_since_epoch().as_secs()))
1073            }
1074        },
1075        public_api_endpoint! {
1076            SESSION_COUNT_ENDPOINT,
1077            ApiVersion::new(0, 0),
1078            async |fedimint: &ConsensusApi, _context, _v: ()| -> u64 {
1079                Ok(fedimint.session_count().await)
1080            }
1081        },
1082        public_api_endpoint! {
1083            AWAIT_SESSION_OUTCOME_ENDPOINT,
1084            ApiVersion::new(0, 0),
1085            async |fedimint: &ConsensusApi, _context, index: u64| -> SerdeModuleEncoding<SessionOutcome> {
1086                Ok((&fedimint.await_signed_session_outcome(index).await.session_outcome).into())
1087            }
1088        },
1089        public_api_endpoint! {
1090            AWAIT_SIGNED_SESSION_OUTCOME_ENDPOINT,
1091            ApiVersion::new(0, 0),
1092            async |fedimint: &ConsensusApi, _context, index: u64| -> SerdeModuleEncoding<SignedSessionOutcome> {
1093                Ok((&fedimint.await_signed_session_outcome(index).await).into())
1094            }
1095        },
1096        public_api_endpoint! {
1097            SESSION_STATUS_ENDPOINT,
1098            ApiVersion::new(0, 1),
1099            async |fedimint: &ConsensusApi, _context, index: u64| -> SerdeModuleEncoding<SessionStatus> {
1100                Ok((&SessionStatus::from(fedimint.session_status(index).await)).into())
1101            }
1102        },
1103        public_api_endpoint! {
1104            SESSION_STATUS_V2_ENDPOINT,
1105            ApiVersion::new(0, 5),
1106            async |fedimint: &ConsensusApi, _context, index: u64| -> SerdeModuleEncodingBase64<SessionStatusV2> {
1107                Ok((&fedimint.session_status(index).await).into())
1108            }
1109        },
1110        admin_api_endpoint! {
1111            SHUTDOWN_ENDPOINT,
1112            ApiVersion::new(0, 3),
1113            async |fedimint: &ConsensusApi, context, index: Option<u64>| -> () {
1114                fedimint.shutdown(index);
1115                Ok(())
1116            }
1117        },
1118        admin_api_endpoint! {
1119            AUDIT_ENDPOINT,
1120            ApiVersion::new(0, 0),
1121            async |fedimint: &ConsensusApi, context, _v: ()| -> AuditSummary {
1122                Ok(fedimint.get_federation_audit().await?)
1123            }
1124        },
1125        admin_api_endpoint! {
1126            GUARDIAN_CONFIG_BACKUP_ENDPOINT,
1127            ApiVersion::new(0, 2),
1128            async |fedimint: &ConsensusApi, context, auth, _v: ()| -> GuardianConfigBackup {
1129                Ok(fedimint.get_guardian_config_backup(&auth))
1130            }
1131        },
1132        public_api_endpoint! {
1133            BACKUP_ENDPOINT,
1134            ApiVersion::new(0, 0),
1135            async |fedimint: &ConsensusApi, context, request: SignedBackupRequest| -> () {
1136                let db = context.db();
1137                let mut dbtx = db.begin_transaction().await;
1138                fedimint
1139                    .handle_backup_request(&mut dbtx.to_ref_nc(), request).await?;
1140                dbtx.commit_tx_result().await?;
1141                Ok(())
1142
1143            }
1144        },
1145        public_api_endpoint! {
1146            RECOVER_ENDPOINT,
1147            ApiVersion::new(0, 0),
1148            async |fedimint: &ConsensusApi, context, id: PublicKey| -> Option<ClientBackupSnapshot> {
1149                let db = context.db();
1150                let mut dbtx = db.begin_transaction_nc().await;
1151                Ok(fedimint
1152                    .handle_recover_request(&mut dbtx, id).await)
1153            }
1154        },
1155        admin_api_endpoint! {
1156            AUTH_ENDPOINT,
1157            ApiVersion::new(0, 0),
1158            async |_fedimint: &ConsensusApi, context, _v: ()| -> () {
1159                Ok(())
1160            }
1161        },
1162        public_api_endpoint! {
1163            API_ANNOUNCEMENTS_ENDPOINT,
1164            ApiVersion::new(0, 3),
1165            async |fedimint: &ConsensusApi, _context, _v: ()| -> BTreeMap<PeerId, SignedApiAnnouncement> {
1166                Ok(fedimint.api_announcements().await)
1167            }
1168        },
1169        public_api_endpoint! {
1170            SUBMIT_API_ANNOUNCEMENT_ENDPOINT,
1171            ApiVersion::new(0, 3),
1172            async |fedimint: &ConsensusApi, _context, submission: SignedApiAnnouncementSubmission| -> () {
1173                fedimint.submit_api_announcement(submission.peer_id, submission.signed_api_announcement).await
1174            }
1175        },
1176        admin_api_endpoint! {
1177            SIGN_API_ANNOUNCEMENT_ENDPOINT,
1178            ApiVersion::new(0, 3),
1179            async |fedimint: &ConsensusApi, context, new_url: SafeUrl| -> SignedApiAnnouncement {
1180                Ok(fedimint.sign_api_announcement(new_url).await)
1181            }
1182        },
1183        public_api_endpoint! {
1184            GUARDIAN_METADATA_ENDPOINT,
1185            ApiVersion::new(0, 9),
1186            async |fedimint: &ConsensusApi, _context, _v: ()| -> BTreeMap<PeerId, fedimint_core::net::guardian_metadata::SignedGuardianMetadata> {
1187                Ok(fedimint.guardian_metadata_list().await)
1188            }
1189        },
1190        public_api_endpoint! {
1191            SUBMIT_GUARDIAN_METADATA_ENDPOINT,
1192            ApiVersion::new(0, 9),
1193            async |fedimint: &ConsensusApi, _context, submission: fedimint_core::net::guardian_metadata::SignedGuardianMetadataSubmission| -> () {
1194                fedimint.submit_guardian_metadata(submission.peer_id, submission.signed_guardian_metadata).await
1195            }
1196        },
1197        admin_api_endpoint! {
1198            SIGN_GUARDIAN_METADATA_ENDPOINT,
1199            ApiVersion::new(0, 9),
1200            async |fedimint: &ConsensusApi, context, metadata: fedimint_core::net::guardian_metadata::GuardianMetadata| -> fedimint_core::net::guardian_metadata::SignedGuardianMetadata {
1201                Ok(fedimint.sign_guardian_metadata(metadata).await)
1202            }
1203        },
1204        public_api_endpoint! {
1205            FEDIMINTD_VERSION_ENDPOINT,
1206            ApiVersion::new(0, 4),
1207            async |fedimint: &ConsensusApi, _context, _v: ()| -> String {
1208                Ok(fedimint.fedimintd_version())
1209            }
1210        },
1211        admin_api_endpoint! {
1212            BACKUP_STATISTICS_ENDPOINT,
1213            ApiVersion::new(0, 5),
1214            async |_fedimint: &ConsensusApi, context, _v: ()| -> BackupStatistics {
1215                let db = context.db();
1216                let mut dbtx = db.begin_transaction_nc().await;
1217                Ok(backup_statistics_static(&mut dbtx).await)
1218            }
1219        },
1220        public_api_endpoint! {
1221            CHAIN_ID_ENDPOINT,
1222            ApiVersion::new(0, 9),
1223            async |fedimint: &ConsensusApi, _context, _v: ()| -> ChainId {
1224                fedimint
1225                    .bitcoin_rpc_connection
1226                    .get_chain_id()
1227                    .await
1228                    .map_err(|e| ApiError::server_error(e.to_string()))
1229            }
1230        },
1231    ]
1232}
1233
1234pub(crate) async fn backup_statistics_static(
1235    dbtx: &mut DatabaseTransaction<'_>,
1236) -> BackupStatistics {
1237    const DAY_SECS: u64 = 24 * 60 * 60;
1238    const WEEK_SECS: u64 = 7 * DAY_SECS;
1239    const MONTH_SECS: u64 = 30 * DAY_SECS;
1240    const QUARTER_SECS: u64 = 3 * MONTH_SECS;
1241
1242    let mut backup_stats = BackupStatistics::default();
1243
1244    let mut all_backups_stream = dbtx.find_by_prefix(&ClientBackupKeyPrefix).await;
1245    while let Some((_, backup)) = all_backups_stream.next().await {
1246        backup_stats.num_backups += 1;
1247        backup_stats.total_size += backup.data.len();
1248
1249        let age_secs = backup.timestamp.elapsed().unwrap_or_default().as_secs();
1250        if age_secs < DAY_SECS {
1251            backup_stats.refreshed_1d += 1;
1252        }
1253        if age_secs < WEEK_SECS {
1254            backup_stats.refreshed_1w += 1;
1255        }
1256        if age_secs < MONTH_SECS {
1257            backup_stats.refreshed_1m += 1;
1258        }
1259        if age_secs < QUARTER_SECS {
1260            backup_stats.refreshed_3m += 1;
1261        }
1262    }
1263
1264    backup_stats
1265}
1266
1267#[cfg(test)]
1268mod tests;