Skip to main content

fedimint_server/consensus/
api.rs

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