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