Skip to main content

fedimint_server/net/api/
guardian_metadata.rs

1use 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
69/// Build the federation API client used to publish guardian metadata.
70pub 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
82/// Store and publish this guardian's current metadata.
83pub 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        // Give other servers some time to start up in case they were just restarted together
104        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            // Submit all metadata we know (including our own and other peers') to all
122            // federation members (in parallel). Each submit_guardian_metadata call
123            // broadcasts one piece of metadata to all peers.
124            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            // While we announce all peer metadata, we only want to immediately trigger in case ours changes
145            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
176/// Reconciles and signs the server-owned Iroh endpoint in guardian metadata.
177///
178/// Existing administrator-owned URLs and Pkarr ID are preserved. Returns `true`
179/// if metadata was inserted or updated and should be broadcast.
180pub 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}