Skip to main content

fedimint_client/
api_announcements.rs

1use std::collections::BTreeMap;
2use std::sync::Arc;
3use std::time::Duration;
4
5use anyhow::{Context, bail};
6use fedimint_api_client::api::DynGlobalApi;
7use fedimint_connectors::iroh_next_endpoint_url;
8use fedimint_core::config::ClientConfig;
9use fedimint_core::db::{Database, IDatabaseTransactionOpsCoreTyped};
10use fedimint_core::encoding::{Decodable, Encodable};
11use fedimint_core::envs::is_running_in_test_env;
12use fedimint_core::net::api_announcement::SignedApiAnnouncement;
13use fedimint_core::net::guardian_metadata::SignedGuardianMetadata;
14use fedimint_core::runtime::{self, sleep};
15use fedimint_core::secp256k1::SECP256K1;
16use fedimint_core::util::backoff_util::custom_backoff;
17use fedimint_core::util::{FmtCompactAnyhow as _, SafeUrl};
18use fedimint_core::{NumPeersExt as _, PeerId, impl_db_lookup, impl_db_record};
19use fedimint_logging::LOG_CLIENT;
20use futures::stream::{FuturesUnordered, StreamExt as _};
21use tracing::{debug, warn};
22
23use crate::Client;
24use crate::db::DbKeyPrefix;
25use crate::guardian_metadata::GuardianMetadataPrefix;
26
27#[cfg(test)]
28mod tests;
29
30#[derive(Clone, Debug, Encodable, Decodable)]
31pub struct ApiAnnouncementKey(pub PeerId);
32
33#[derive(Clone, Debug, Encodable, Decodable)]
34pub struct ApiAnnouncementPrefix;
35
36impl_db_record!(
37    key = ApiAnnouncementKey,
38    value = SignedApiAnnouncement,
39    db_prefix = DbKeyPrefix::ApiUrlAnnouncement,
40    notify_on_modify = false,
41);
42impl_db_lookup!(
43    key = ApiAnnouncementKey,
44    query_prefix = ApiAnnouncementPrefix
45);
46
47/// Fetches API URL announcements from guardians, validates them and updates the
48/// DB if any new more upt to date ones are found.
49pub(crate) async fn run_api_announcement_refresh_task(client_inner: Arc<Client>) {
50    // Wait for the guardian keys to be available
51    let guardian_pub_keys = client_inner.get_guardian_public_keys_blocking().await;
52    loop {
53        if let Err(err) = {
54            let api: &DynGlobalApi = &client_inner.api;
55            let results = fetch_api_announcements_from_at_least_num_of_peers(
56                1,
57                api,
58                &guardian_pub_keys,
59                if is_running_in_test_env() {
60                    Duration::from_millis(1)
61                } else {
62                    Duration::from_secs(30)
63                },
64            )
65            .await;
66            store_api_announcements_updates_from_peers(client_inner.db(), &results).await
67        } {
68            debug!(target: LOG_CLIENT, err = %err.fmt_compact_anyhow(), "Refreshing api announcements failed");
69        }
70
71        let duration = if is_running_in_test_env() {
72            Duration::from_secs(1)
73        } else {
74            // Check once an hour if there are new announcements
75            Duration::from_secs(3600)
76        };
77        sleep(duration).await;
78    }
79}
80
81pub(crate) async fn store_api_announcements_updates_from_peers(
82    db: &Database,
83    updates: &[BTreeMap<PeerId, SignedApiAnnouncement>],
84) -> Result<(), anyhow::Error> {
85    for announcements in updates {
86        store_api_announcement_updates(db, announcements).await;
87    }
88
89    Ok(())
90}
91
92pub(crate) type PeersSignedApiAnnouncements = BTreeMap<PeerId, SignedApiAnnouncement>;
93
94/// Fetch responses from at least `num_responses_required` of peers.
95///
96/// Will wait a little bit extra in hopes of collecting more than strictly
97/// needed responses.
98pub(crate) async fn fetch_api_announcements_from_at_least_num_of_peers(
99    num_responses_required: usize,
100    api: &DynGlobalApi,
101    guardian_pub_keys: &BTreeMap<PeerId, bitcoin::secp256k1::PublicKey>,
102    extra_response_wait: Duration,
103) -> Vec<PeersSignedApiAnnouncements> {
104    let num_peers = guardian_pub_keys.to_num_peers();
105    // Keep trying, initially somewhat aggressively, but after a while retry very
106    // slowly, because chances for response are getting lower and lower.
107    let mut backoff = custom_backoff(Duration::from_millis(200), Duration::from_secs(600), None);
108
109    // Make a single request to a peer after a delay
110    async fn make_request(
111        delay: Duration,
112        peer_id: PeerId,
113        api: &DynGlobalApi,
114        guardian_pub_keys: &BTreeMap<PeerId, bitcoin::secp256k1::PublicKey>,
115    ) -> (PeerId, anyhow::Result<PeersSignedApiAnnouncements>) {
116        runtime::sleep(delay).await;
117
118        let result = async {
119            let announcements = api.api_announcements(peer_id).await.with_context(move || {
120                format!("Fetching API announcements from peer {peer_id} failed")
121            })?;
122
123            // If any of the announcements is invalid something is fishy with that
124            // guardian and we ignore all its responses
125            for (peer_id, announcement) in &announcements {
126                let Some(guardian_pub_key) = guardian_pub_keys.get(peer_id) else {
127                    bail!("Guardian public key not found for peer {}", peer_id);
128                };
129
130                if !announcement.verify(SECP256K1, guardian_pub_key) {
131                    bail!("Failed to verify announcement for peer {}", peer_id);
132                }
133            }
134            Ok(announcements)
135        }
136        .await;
137
138        (peer_id, result)
139    }
140
141    let mut requests = FuturesUnordered::new();
142
143    for peer_id in num_peers.peer_ids() {
144        requests.push(make_request(
145            Duration::ZERO,
146            peer_id,
147            api,
148            guardian_pub_keys,
149        ));
150    }
151
152    let mut responses = Vec::new();
153
154    loop {
155        let next_response = if responses.len() < num_responses_required {
156            // If we don't have enough responses yet, we wait
157            requests.next().await
158        } else {
159            // if we do have responses we need, we wait opportunistically just for a small
160            // duration if any other responses are ready anyway, just to not
161            // throw them away
162            fedimint_core::runtime::timeout(extra_response_wait, requests.next())
163                .await
164                .ok()
165                .flatten()
166        };
167
168        let Some((peer_id, response)) = next_response else {
169            break;
170        };
171
172        match response {
173            Err(err) => {
174                debug!(
175                    target: LOG_CLIENT,
176                    %peer_id,
177                    err = %err.fmt_compact_anyhow(),
178                    "Failed to fetch API announcements from peer"
179                );
180                requests.push(make_request(
181                    backoff.next().expect("Keeps retrying"),
182                    peer_id,
183                    api,
184                    guardian_pub_keys,
185                ));
186            }
187            Ok(announcements) => {
188                responses.push(announcements);
189            }
190        }
191    }
192
193    responses
194}
195
196pub(crate) async fn store_api_announcement_updates(
197    db: &Database,
198    announcements: &BTreeMap<PeerId, SignedApiAnnouncement>,
199) {
200    db
201        .autocommit(
202            |dbtx, _|{
203                let announcements_inner = announcements.clone();
204            Box::pin(async move {
205                for (peer, new_announcement) in announcements_inner {
206                    let replace_current_announcement = dbtx
207                        .get_value(&ApiAnnouncementKey(peer))
208                        .await.is_none_or(|current_announcement| {
209                            current_announcement.api_announcement.nonce
210                                < new_announcement.api_announcement.nonce
211                        });
212                    if replace_current_announcement {
213                        debug!(target: LOG_CLIENT, ?peer, %new_announcement.api_announcement.api_url, "Updating API announcement");
214                        dbtx.insert_entry(&ApiAnnouncementKey(peer), &new_announcement)
215                            .await;
216                    }
217                }
218
219                Result::<(), ()>::Ok(())
220            })},
221            None,
222        )
223        .await
224        .expect("Will never return an error");
225}
226
227/// Returns a list of all peers and their respective API URLs taking into
228/// account guardian metadata and API announcements overwriting the URLs
229/// contained in the original configuration.
230///
231/// Priority order:
232/// 1. Guardian metadata (if available) - uses first URL from api_urls
233/// 2. API announcement (if available)
234/// 3. Configured URL (fallback)
235pub async fn get_api_urls(
236    db: &Database,
237    cfg: &ClientConfig,
238    client_iroh_next_enabled: bool,
239) -> BTreeMap<PeerId, SafeUrl> {
240    let mut dbtx = db.begin_transaction_nc().await;
241
242    // Load guardian metadata for all peers
243    let guardian_metadata: BTreeMap<PeerId, SignedGuardianMetadata> = dbtx
244        .find_by_prefix(&GuardianMetadataPrefix)
245        .await
246        .map(|(key, metadata)| (key.0, metadata))
247        .collect()
248        .await;
249
250    // Load API announcements for all peers
251    let api_announcements: BTreeMap<PeerId, SignedApiAnnouncement> = dbtx
252        .find_by_prefix(&ApiAnnouncementPrefix)
253        .await
254        .map(|(key, announcement)| (key.0, announcement))
255        .collect()
256        .await;
257
258    // For each peer: prefer guardian metadata, then API announcement, then config.
259    // When the client supports iroh-next and the guardian advertises an
260    // iroh-next endpoint, replace the original iroh:// identity with the
261    // advertised identity. The original identity is not retained as a fallback.
262    cfg.global
263        .api_endpoints
264        .iter()
265        .filter_map(|(peer_id, peer_url)| {
266            let metadata = guardian_metadata.get(peer_id);
267
268            let mut url = metadata
269                .and_then(|m| m.guardian_metadata().api_urls.first().cloned())
270                .or_else(|| {
271                    api_announcements
272                        .get(peer_id)
273                        .map(|a| a.api_announcement.api_url.clone())
274                })
275                .unwrap_or_else(|| peer_url.url.clone());
276
277            // If the resolved URL is iroh:// and iroh-next endpoint preference is
278            // enabled, swap in the advertised iroh-next endpoint.
279            if url.scheme() == "iroh"
280                && client_iroh_next_enabled
281                && let Some(m) = metadata
282            {
283                let gm = m.guardian_metadata();
284                if let Some(endpoint) = &gm.iroh_next_endpoint {
285                    match iroh_next_endpoint_url(endpoint) {
286                        Ok(next_url) => {
287                            debug!(
288                                target: LOG_CLIENT,
289                                %peer_id,
290                                %next_url,
291                                "Using iroh-next endpoint from guardian metadata",
292                            );
293                            url = next_url;
294                        }
295                        Err(err) => {
296                            warn!(
297                                target: LOG_CLIENT,
298                                %peer_id,
299                                err = %err.fmt_compact_anyhow(),
300                                "Ignoring peer with invalid advertised iroh-next endpoint",
301                            );
302                            return None;
303                        }
304                    }
305                }
306            }
307
308            Some((*peer_id, url))
309        })
310        .collect()
311}