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