1use 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
83const MAX_OUTPUTS_OUTCOMES_BATCH: usize = 1024;
91
92const 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
104fn 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 pub cfg: ServerConfig,
126 pub db: Database,
128 pub modules: ServerModuleRegistry,
130 pub client_cfg: ClientConfig,
132 pub force_api_secret: Option<String>,
133 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 self.force_api_secret.clone()
158 }
159
160 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 let mut dbtx = self.db.begin_transaction_nc().await;
173 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 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 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 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 let count = checked_outputs_outcomes_count(outpoint_range)?;
313
314 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 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 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 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 fn fedimintd_version(&self) -> String {
557 self.code_version_str.clone()
558 }
559
560 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 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 existing_announcement.api_announcement
589 == announcement.api_announcement
590 {
591 return Ok(());
592 }
593
594 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 existing_metadata.bytes == metadata.bytes {
694 return Ok(());
695 }
696
697 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 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 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 #[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 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}