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