Skip to main content

fedimint_server/consensus/
api.rs

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