Skip to main content

fedimint_gateway_server/
federation_manager.rs

1use std::collections::BTreeMap;
2use std::sync::Arc;
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::time::SystemTime;
5
6use bitcoin::secp256k1::Keypair;
7use fedimint_client::ClientHandleArc;
8use fedimint_core::config::{FederationId, FederationIdPrefix, JsonClientConfig};
9use fedimint_core::db::{Committable, DatabaseTransaction, NonCommittable};
10use fedimint_core::invite_code::InviteCode;
11use fedimint_core::module::AmountUnit;
12use fedimint_core::util::{FmtCompact as _, Spanned};
13use fedimint_core::{PeerId, TieredCounts};
14use fedimint_gateway_common::FederationInfo;
15use fedimint_gateway_server_db::GatewayDbtxNcExt as _;
16use fedimint_gw_client::GatewayClientModule;
17use fedimint_gwv2_client::{GatewayClientModuleV2, GatewayOperationMetaV2};
18use fedimint_logging::LOG_GATEWAY;
19use fedimint_mint_client::MintClientModule;
20use fedimint_mintv2_client::MintClientModule as MintV2ClientModule;
21use tracing::{info, warn};
22
23use crate::error::{AdminGatewayError, FederationNotConnected};
24use crate::{AdminResult, Registration};
25
26/// The first index that the gateway will assign to a federation.
27/// Note: This starts at 1 because LNv1 uses the `federation_index` as an SCID.
28/// An SCID of 0 is considered invalid by LND's HTLC interceptor.
29const INITIAL_INDEX: u64 = 1;
30
31// TODO: Add support for client lookup by payment hash (for LNv2).
32#[derive(Debug)]
33pub struct FederationManager {
34    /// Map of `FederationId` -> `Client`. Used for efficient retrieval of the
35    /// client while handling incoming HTLCs.
36    clients: BTreeMap<FederationId, Spanned<fedimint_client::ClientHandleArc>>,
37
38    /// Map of federation indices to `FederationId`. Use for efficient retrieval
39    /// of the client while handling incoming HTLCs.
40    /// Can be removed after LNv1 removal.
41    index_to_federation: BTreeMap<u64, FederationId>,
42
43    /// Tracker for federation index assignments. When connecting a new
44    /// federation, this value is incremented and assigned to the federation
45    /// as the `federation_index`
46    next_index: AtomicU64,
47}
48
49impl FederationManager {
50    pub fn new() -> Self {
51        Self {
52            clients: BTreeMap::new(),
53            index_to_federation: BTreeMap::new(),
54            next_index: AtomicU64::new(INITIAL_INDEX),
55        }
56    }
57
58    pub fn add_client(&mut self, index: u64, client: Spanned<fedimint_client::ClientHandleArc>) {
59        let federation_id = client.borrow().with_sync(|c| c.federation_id());
60        self.clients.insert(federation_id, client);
61        self.index_to_federation.insert(index, federation_id);
62    }
63
64    pub async fn leave_federation(
65        &mut self,
66        federation_id: FederationId,
67        dbtx: &mut DatabaseTransaction<'_, NonCommittable>,
68        registrations: Vec<&Registration>,
69    ) -> AdminResult<FederationInfo> {
70        let federation_info = self.federation_info(federation_id, dbtx).await?;
71
72        for registration in registrations {
73            self.unannounce_from_federation(federation_id, registration.keypair)
74                .await;
75        }
76
77        self.remove_client(federation_id).await?;
78
79        Ok(federation_info)
80    }
81
82    async fn remove_client(&mut self, federation_id: FederationId) -> AdminResult<()> {
83        let client = self
84            .clients
85            .remove(&federation_id)
86            .ok_or(FederationNotConnected {
87                federation_id_prefix: federation_id.to_prefix(),
88            })?
89            .into_value();
90
91        self.index_to_federation
92            .retain(|_, fid| *fid != federation_id);
93
94        match Arc::into_inner(client) {
95            Some(client) => {
96                client.shutdown().await;
97                Ok(())
98            }
99            _ => Err(AdminGatewayError::ClientRemovalError(format!(
100                "Federation client {federation_id} is not unique, failed to shutdown client"
101            ))),
102        }
103    }
104
105    /// Waits for ongoing incoming LNv1 and LNv2 payments to complete before
106    /// returning.
107    pub async fn wait_for_incoming_payments(&self) -> AdminResult<()> {
108        for client in self.clients.values() {
109            let active_operations = client.value().get_active_operations().await;
110            let operation_log = client.value().operation_log();
111            for op_id in active_operations {
112                let log_entry = operation_log.get_operation(op_id).await;
113                if let Some(entry) = log_entry {
114                    match entry.operation_module_kind() {
115                        "lnv2" => {
116                            let Ok(meta) = entry.try_meta::<GatewayOperationMetaV2>() else {
117                                warn!(
118                                    target: LOG_GATEWAY,
119                                    operation_id = %op_id.fmt_short(),
120                                    "Skipping LNv2 operation with invalid metadata while waiting for incoming payments",
121                                );
122                                continue;
123                            };
124                            if !meta.waits_for_completion() {
125                                continue;
126                            }
127                            let lnv2 = client
128                                .value()
129                                .get_first_module::<GatewayClientModuleV2>()
130                                .map_err(anyhow::Error::from)?;
131                            lnv2.await_completion(op_id).await;
132                        }
133                        "ln" => {
134                            let lnv1 = client
135                                .value()
136                                .get_first_module::<GatewayClientModule>()
137                                .map_err(anyhow::Error::from)?;
138                            lnv1.await_completion(op_id).await;
139                        }
140                        _ => {}
141                    }
142                }
143            }
144        }
145
146        info!(target: LOG_GATEWAY, "Finished waiting for incoming payments");
147        Ok(())
148    }
149
150    async fn unannounce_from_federation(
151        &self,
152        federation_id: FederationId,
153        gateway_keypair: Keypair,
154    ) {
155        if let Ok(client) = self
156            .clients
157            .get(&federation_id)
158            .ok_or(FederationNotConnected {
159                federation_id_prefix: federation_id.to_prefix(),
160            })
161            && let Ok(ln) = client.value().get_first_module::<GatewayClientModule>()
162        {
163            ln.remove_from_federation(gateway_keypair).await;
164        }
165    }
166
167    /// Iterates through all of the federations the gateway is registered with
168    /// and requests to remove the registration record.
169    pub async fn unannounce_from_all_federations(&self, gateway_keypair: Keypair) {
170        let removal_futures = self
171            .clients
172            .values()
173            .filter_map(|client| {
174                client
175                    .value()
176                    .get_first_module::<GatewayClientModule>()
177                    .ok()
178                    .map(|lnv1| async move {
179                        lnv1.remove_from_federation(gateway_keypair).await;
180                    })
181            })
182            .collect::<Vec<_>>();
183
184        futures::future::join_all(removal_futures).await;
185    }
186
187    pub fn get_client_for_index(&self, short_channel_id: u64) -> Option<Spanned<ClientHandleArc>> {
188        let federation_id = self.index_to_federation.get(&short_channel_id)?;
189        // TODO(tvolk131): Cloning the client here could cause issues with client
190        // shutdown (see `remove_client` above). Perhaps this function should take a
191        // lambda and pass it into `client.with_sync`.
192        match self.clients.get(federation_id).cloned() {
193            Some(client) => Some(client),
194            _ => {
195                panic!(
196                    "`FederationManager.index_to_federation` is out of sync with `FederationManager.clients`! This is a bug."
197                );
198            }
199        }
200    }
201
202    pub fn get_client_for_federation_id_prefix(
203        &self,
204        federation_id_prefix: FederationIdPrefix,
205    ) -> Option<Spanned<ClientHandleArc>> {
206        self.clients.iter().find_map(|(fid, client)| {
207            if fid.to_prefix() == federation_id_prefix {
208                Some(client.clone())
209            } else {
210                None
211            }
212        })
213    }
214
215    pub fn has_federation(&self, federation_id: FederationId) -> bool {
216        self.clients.contains_key(&federation_id)
217    }
218
219    pub fn client(&self, federation_id: &FederationId) -> Option<&Spanned<ClientHandleArc>> {
220        self.clients.get(federation_id)
221    }
222
223    pub async fn federation_info(
224        &self,
225        federation_id: FederationId,
226        dbtx: &mut DatabaseTransaction<'_, NonCommittable>,
227    ) -> std::result::Result<FederationInfo, FederationNotConnected> {
228        self.clients
229            .get(&federation_id)
230            .ok_or(FederationNotConnected {
231                federation_id_prefix: federation_id.to_prefix(),
232            })?
233            .borrow()
234            .with(|client| async move {
235                let balance_msat = client
236                    .get_balance_for_btc()
237                    .await
238                    // If primary module is not available, we're not really connected yet
239                    .map_err(|_err| FederationNotConnected {
240                        federation_id_prefix: federation_id.to_prefix(),
241                    })?;
242
243                let config = dbtx.load_federation_config(federation_id).await.ok_or(
244                    FederationNotConnected {
245                        federation_id_prefix: federation_id.to_prefix(),
246                    },
247                )?;
248                let last_backup_time =
249                    dbtx.load_backup_record(federation_id)
250                        .await
251                        .ok_or(FederationNotConnected {
252                            federation_id_prefix: federation_id.to_prefix(),
253                        })?;
254
255                Ok(FederationInfo {
256                    federation_id,
257                    federation_name: self.federation_name(client).await,
258                    balance_msat,
259                    config,
260                    last_backup_time,
261                })
262            })
263            .await
264    }
265
266    pub async fn federation_name(&self, client: &ClientHandleArc) -> Option<String> {
267        let client_config = client.config().await;
268        let federation_name = client_config.global.federation_name();
269        federation_name.map(String::from)
270    }
271
272    pub async fn federation_info_all_federations(
273        &self,
274        mut dbtx: DatabaseTransaction<'_, NonCommittable>,
275    ) -> Vec<FederationInfo> {
276        let mut federation_infos = Vec::new();
277        for (federation_id, client) in &self.clients {
278            let balance_msat = match client
279                .borrow()
280                .with(|client| client.get_balance_for_btc())
281                .await
282            {
283                Ok(balance_msat) => balance_msat,
284                Err(err) => {
285                    warn!(
286                        target: LOG_GATEWAY,
287                        err = %err.fmt_compact(),
288                        "Skipped Federation due to lack of primary module"
289                    );
290                    continue;
291                }
292            };
293
294            let config = dbtx.load_federation_config(*federation_id).await;
295            let last_backup_time = dbtx
296                .load_backup_record(*federation_id)
297                .await
298                .unwrap_or_default();
299            if let Some(config) = config {
300                federation_infos.push(FederationInfo {
301                    federation_id: *federation_id,
302                    federation_name: self.federation_name(client.value()).await,
303                    balance_msat,
304                    config,
305                    last_backup_time,
306                });
307            }
308        }
309        federation_infos
310    }
311
312    pub async fn get_federation_config(
313        &self,
314        federation_id: FederationId,
315    ) -> AdminResult<JsonClientConfig> {
316        let client = self
317            .clients
318            .get(&federation_id)
319            .ok_or(FederationNotConnected {
320                federation_id_prefix: federation_id.to_prefix(),
321            })?;
322        Ok(client
323            .borrow()
324            .with(|client| client.get_config_json())
325            .await)
326    }
327
328    pub async fn get_all_federation_configs(&self) -> BTreeMap<FederationId, JsonClientConfig> {
329        let mut federations = BTreeMap::new();
330        for (federation_id, client) in &self.clients {
331            federations.insert(
332                *federation_id,
333                client
334                    .borrow()
335                    .with(|client| client.get_config_json())
336                    .await,
337            );
338        }
339        federations
340    }
341
342    pub async fn backup_federation(
343        &self,
344        federation_id: &FederationId,
345        dbtx: &mut DatabaseTransaction<'_, Committable>,
346        now: SystemTime,
347    ) {
348        if let Some(client) = self.client(federation_id) {
349            let metadata: BTreeMap<String, String> = BTreeMap::new();
350            #[allow(deprecated)]
351            if client
352                .value()
353                .backup_to_federation(fedimint_client::backup::Metadata::from_json_serialized(
354                    metadata,
355                ))
356                .await
357                .is_ok()
358            {
359                dbtx.save_federation_backup_record(*federation_id, Some(now))
360                    .await;
361                info!(federation_id = %federation_id, "Successfully backed up federation");
362            }
363        }
364    }
365
366    pub async fn all_invite_codes(
367        &self,
368    ) -> BTreeMap<FederationId, BTreeMap<PeerId, (String, InviteCode)>> {
369        let mut invite_codes = BTreeMap::new();
370
371        for (federation_id, client) in &self.clients {
372            let config = client.value().config().await;
373            let api_endpoints = &config.global.api_endpoints;
374
375            let mut fed_invite_codes = BTreeMap::new();
376            for (peer_id, peer_url) in api_endpoints {
377                if let Some(code) = client.value().invite_code(*peer_id).await {
378                    fed_invite_codes.insert(*peer_id, (peer_url.name.clone(), code));
379                }
380            }
381
382            invite_codes.insert(*federation_id, fed_invite_codes);
383        }
384
385        invite_codes
386    }
387
388    pub async fn get_note_summary(
389        &self,
390        federation_id: &FederationId,
391    ) -> AdminResult<TieredCounts> {
392        let client = self.client(federation_id).ok_or(FederationNotConnected {
393            federation_id_prefix: federation_id.to_prefix(),
394        })?;
395        let counts = if let Ok(mint) = client.value().get_first_module::<MintClientModule>() {
396            let mut dbtx = mint.client_ctx.module_db().begin_transaction_nc().await;
397            mint.get_note_counts_by_denomination(&mut dbtx).await
398        } else if let Ok(mint) = client
399            .value()
400            .get_primary_module_for_unit::<MintV2ClientModule>(AmountUnit::BITCOIN)
401        {
402            let mut counts = TieredCounts::default();
403            for (denomination, count) in mint.get_count_by_denomination().await {
404                counts.inc(
405                    denomination.amount(),
406                    usize::try_from(count).expect("Note count fits in usize"),
407                );
408            }
409            counts
410        } else {
411            return Err(AdminGatewayError::Unexpected(anyhow::anyhow!(
412                "No mint module available"
413            )));
414        };
415        info!(target: LOG_GATEWAY, ?counts, "Note counts");
416        Ok(counts)
417    }
418
419    // TODO(tvolk131): Set this value in the constructor.
420    pub fn set_next_index(&self, next_index: u64) {
421        self.next_index.store(next_index, Ordering::SeqCst);
422    }
423
424    pub fn pop_next_index(&self) -> AdminResult<u64> {
425        let next_index = self.next_index.fetch_add(1, Ordering::Relaxed);
426
427        // Check for overflow.
428        if next_index == INITIAL_INDEX.wrapping_sub(1) {
429            return Err(AdminGatewayError::GatewayConfigurationError(
430                "Federation Index overflow".to_string(),
431            ));
432        }
433
434        Ok(next_index)
435    }
436}