fedimint_server/net/api/
guardian_metadata.rs1use std::time::{Duration, UNIX_EPOCH};
2
3use fedimint_api_client::api::DynGlobalApi;
4use fedimint_connectors::ConnectorRegistry;
5use fedimint_core::db::{Database, IDatabaseTransactionOpsCoreTyped};
6use fedimint_core::encoding::{Decodable, Encodable};
7use fedimint_core::envs::is_running_in_test_env;
8use fedimint_core::net::guardian_metadata::{GuardianMetadata, SignedGuardianMetadata};
9use fedimint_core::task::{TaskGroup, sleep};
10use fedimint_core::util::FmtCompact;
11use fedimint_core::{PeerId, impl_db_lookup, impl_db_record, secp256k1};
12use fedimint_logging::LOG_NET_API;
13use futures::future::join_all;
14use futures::stream::StreamExt;
15use tokio::select;
16use tracing::{debug, info};
17
18use crate::IrohNextApiSettings;
19use crate::config::ServerConfig;
20use crate::db::DbKeyPrefix;
21use crate::net::iroh::derive_iroh_v1_api_secret_key;
22
23fn ensure_iroh_next_remains_available(
24 existing_endpoint: Option<&str>,
25 configured_endpoint: Option<&str>,
26) -> anyhow::Result<()> {
27 if let Some(existing_endpoint) = existing_endpoint {
28 anyhow::ensure!(
29 configured_endpoint == Some(existing_endpoint),
30 "Iroh 1.0 API endpoint {existing_endpoint} was previously advertised and must remain \
31 enabled unchanged; disabling or rotating it is unsupported"
32 );
33 }
34 Ok(())
35}
36
37fn reconcile_iroh_next_endpoint(
38 metadata: &mut GuardianMetadata,
39 configured_endpoint: Option<String>,
40) -> anyhow::Result<bool> {
41 ensure_iroh_next_remains_available(
42 metadata.iroh_next_endpoint.as_deref(),
43 configured_endpoint.as_deref(),
44 )?;
45 if metadata.iroh_next_endpoint == configured_endpoint {
46 return Ok(false);
47 }
48 metadata.iroh_next_endpoint = configured_endpoint;
49 Ok(true)
50}
51
52#[derive(Clone, Debug, Encodable, Decodable)]
53pub struct GuardianMetadataKey(pub PeerId);
54
55#[derive(Clone, Debug, Encodable, Decodable)]
56pub struct GuardianMetadataPrefix;
57
58impl_db_record!(
59 key = GuardianMetadataKey,
60 value = SignedGuardianMetadata,
61 db_prefix = DbKeyPrefix::GuardianMetadata,
62 notify_on_modify = true,
63);
64impl_db_lookup!(
65 key = GuardianMetadataKey,
66 query_prefix = GuardianMetadataPrefix
67);
68
69pub async fn prepare_guardian_metadata_service(
71 db: &Database,
72 cfg: &ServerConfig,
73 api_secret: Option<String>,
74) -> anyhow::Result<DynGlobalApi> {
75 DynGlobalApi::new(
76 ConnectorRegistry::build_from_server_env()?.bind().await?,
77 super::announcement::get_api_urls(db, &cfg.consensus).await,
78 api_secret.as_deref(),
79 )
80}
81
82pub fn start_guardian_metadata_service(
84 db: &Database,
85 tg: &TaskGroup,
86 cfg: &ServerConfig,
87 api_client: DynGlobalApi,
88 metadata_updated: bool,
89) {
90 const INITIAL_DELAY_SECONDS: u64 = 5;
91 const FAILURE_RETRY_SECONDS: u64 = 60;
92 const SUCCESS_RETRY_SECONDS: u64 = 600;
93
94 let initial_delay = if metadata_updated {
95 Duration::ZERO
96 } else {
97 Duration::from_secs(INITIAL_DELAY_SECONDS)
98 };
99
100 let db = db.clone();
101 let our_peer_id = cfg.local.identity;
102 tg.spawn_cancellable("submit-guardian-metadata", async move {
103 sleep(initial_delay).await;
105 loop {
106 let mut success = true;
107 let metadata_list = db
108 .begin_transaction_nc()
109 .await
110 .find_by_prefix(&GuardianMetadataPrefix)
111 .await
112 .map(|(peer_key, peer_metadata)| (peer_key.0, peer_metadata))
113 .collect::<Vec<(PeerId, SignedGuardianMetadata)>>()
114 .await;
115
116 info!(
117 target: LOG_NET_API,
118 len = %metadata_list.len(),
119 "Submitting guardian metadata"
120 );
121 let results = join_all(metadata_list.iter().map(|(peer, metadata)| {
125 let api_client = &api_client;
126 async move {
127 (*peer, api_client.submit_guardian_metadata(*peer, metadata.clone()).await)
128 }
129 }))
130 .await;
131
132 info!(
133 target: LOG_NET_API,
134 len = %metadata_list.len(),
135 "Done"
136 );
137 for (peer, result) in results {
138 if let Err(err) = result {
139 debug!(target: LOG_NET_API, ?peer, err = %err.fmt_compact(), "Submitting guardian metadata did not succeed for all peers, retrying in {FAILURE_RETRY_SECONDS} seconds");
140 success = false;
141 }
142 }
143
144 let our_metadata_key = GuardianMetadataKey(our_peer_id);
146 let our_metadata = db
147 .begin_transaction_nc()
148 .await
149 .get_value(&our_metadata_key)
150 .await
151 .expect("Our guardian metadata is always present");
152
153 let new_metadata = db.wait_key_check(&our_metadata_key, |new_metadata| {
154 new_metadata.and_then(|new_metadata| {
155 (new_metadata.tagged_hash() != our_metadata.tagged_hash()).then_some(())
156 })
157 });
158
159
160 let auto_announcement_delay = if success {
161 Duration::from_secs(SUCCESS_RETRY_SECONDS)
162 } else if is_running_in_test_env() {
163 Duration::from_secs(3)
164 } else {
165 Duration::from_secs(FAILURE_RETRY_SECONDS)
166 };
167
168 select! {
169 _ = new_metadata => {},
170 () = sleep(auto_announcement_delay) => {},
171 }
172 }
173 });
174}
175
176pub async fn reconcile_guardian_metadata(
181 db: &Database,
182 cfg: &ServerConfig,
183 iroh_next_api_settings: Option<&IrohNextApiSettings>,
184) -> anyhow::Result<bool> {
185 let key = GuardianMetadataKey(cfg.local.identity);
186 let mut dbtx = db.begin_transaction().await;
187 let existing = dbtx.get_value(&key).await;
188
189 let mut guardian_metadata = existing.as_ref().map_or_else(
190 || {
191 GuardianMetadata::new(
192 cfg.consensus
193 .api_endpoints()
194 .get(&cfg.local.identity)
195 .map(|endpoint| vec![endpoint.url.clone()])
196 .unwrap_or_default(),
197 super::pkarr_publish::pkarr_id_z32(&cfg.private.broadcast_secret_key),
198 0,
199 )
200 },
201 |existing| existing.guardian_metadata().clone(),
202 );
203
204 let iroh_next_endpoint = iroh_next_api_settings.map(|_| {
205 derive_iroh_v1_api_secret_key(&cfg.private.broadcast_secret_key)
206 .public()
207 .to_string()
208 });
209
210 let endpoint_changed =
211 reconcile_iroh_next_endpoint(&mut guardian_metadata, iroh_next_endpoint)?;
212 if existing.is_some() && !endpoint_changed {
213 return Ok(false);
214 }
215
216 let now = fedimint_core::time::now()
217 .duration_since(UNIX_EPOCH)
218 .expect("System time should be after UNIX_EPOCH")
219 .as_secs();
220 guardian_metadata.timestamp_secs = existing.as_ref().map_or(now, |metadata| {
221 now.max(
222 metadata
223 .guardian_metadata()
224 .timestamp_secs
225 .saturating_add(1),
226 )
227 });
228
229 let ctx = secp256k1::Secp256k1::new();
230 let signed_metadata =
231 guardian_metadata.sign(&ctx, &cfg.private.broadcast_secret_key.keypair(&ctx));
232
233 dbtx.insert_entry(&key, &signed_metadata).await;
234 dbtx.commit_tx().await;
235
236 Ok(true)
237}
238
239#[cfg(test)]
240mod tests {
241 use fedimint_core::net::guardian_metadata::GuardianMetadata;
242
243 use super::{ensure_iroh_next_remains_available, reconcile_iroh_next_endpoint};
244
245 #[test]
246 fn iroh_next_advertisement_is_forward_only() {
247 assert!(ensure_iroh_next_remains_available(None, None).is_ok());
248 assert!(ensure_iroh_next_remains_available(None, Some("new")).is_ok());
249 assert!(ensure_iroh_next_remains_available(Some("existing"), Some("existing")).is_ok());
250 assert!(ensure_iroh_next_remains_available(Some("existing"), Some("new")).is_err());
251 assert!(ensure_iroh_next_remains_available(Some("existing"), None).is_err());
252 }
253
254 #[test]
255 fn reconciliation_preserves_administrator_owned_metadata() {
256 let api_urls = vec!["wss://guardian.example".parse().expect("valid URL")];
257 let mut metadata = GuardianMetadata::new(api_urls.clone(), "pkarr-id".to_owned(), 42);
258
259 assert!(
260 reconcile_iroh_next_endpoint(&mut metadata, Some("iroh-id".to_owned()))
261 .expect("first advertisement is allowed")
262 );
263 assert_eq!(metadata.api_urls, api_urls);
264 assert_eq!(metadata.pkarr_id_z32, "pkarr-id");
265 assert_eq!(metadata.timestamp_secs, 42);
266 assert_eq!(metadata.iroh_next_endpoint.as_deref(), Some("iroh-id"));
267 assert!(
268 !reconcile_iroh_next_endpoint(&mut metadata, Some("iroh-id".to_owned()))
269 .expect("unchanged advertisement is allowed")
270 );
271 }
272}