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