Skip to main content

devimint/
federation.rs

1use std::collections::BTreeMap;
2use std::ops::ControlFlow;
3use std::path::{Path, PathBuf};
4use std::str::FromStr;
5use std::time::Duration;
6use std::{env, fs};
7
8use anyhow::{Context, Result, bail};
9use fedimint_api_client::api::DynGlobalApi;
10use fedimint_api_client::download_from_invite_code;
11use fedimint_client_module::module::ClientModule;
12use fedimint_connectors::ConnectorRegistry;
13use fedimint_core::admin_client::SetupStatus;
14use fedimint_core::config::{ClientConfig, load_from_file};
15use fedimint_core::core::{ModuleInstanceId, ModuleKind};
16use fedimint_core::invite_code::InviteCode;
17use fedimint_core::module::ModuleCommon;
18use fedimint_core::module::registry::ModuleDecoderRegistry;
19use fedimint_core::runtime::block_in_place;
20use fedimint_core::task::block_on;
21use fedimint_core::task::jit::JitTryAnyhow;
22use fedimint_core::util::SafeUrl;
23use fedimint_core::{Amount, NumPeers, PeerId};
24use fedimint_gateway_common::WithdrawResponse;
25use fedimint_logging::LOG_DEVIMINT;
26use fedimint_server::config::io::{CONSENSUS_CONFIG, JSON_EXT, LOCAL_CONFIG, PRIVATE_CONFIG};
27use fedimint_testing_core::config::API_AUTH;
28use fedimint_testing_core::node_type::LightningNodeType;
29use fedimint_wallet_client::WalletClientModule;
30use fedimint_wallet_client::config::WalletClientConfig;
31use fs_lock::FileLock;
32use futures::future::{join_all, try_join_all};
33use tokio::task::{JoinSet, spawn_blocking};
34use tokio::time::Instant;
35use tracing::{debug, info};
36
37use super::external::Bitcoind;
38use super::util::{Command, ProcessHandle, ProcessManager, cmd};
39use super::vars::utf8;
40use crate::envs::{FM_CLIENT_DIR_ENV, FM_DATA_DIR_ENV};
41use crate::util::{FedimintdCmd, poll, poll_simple, poll_with_timeout};
42use crate::version_constants::{VERSION_0_10_0_ALPHA, VERSION_0_11_0_ALPHA, VERSION_0_12_0_ALPHA};
43use crate::{poll_almost_equal, poll_eq, vars};
44
45// TODO: Are we still using the 3rd port for anything?
46/// Number of ports we allocate for every `fedimintd` instance
47pub const PORTS_PER_FEDIMINTD: u16 = 4;
48/// Which port is for p2p inside the range from [`PORTS_PER_FEDIMINTD`]
49pub const FEDIMINTD_P2P_PORT_OFFSET: u16 = 0;
50/// Which port is for api inside the range from [`PORTS_PER_FEDIMINTD`]
51pub const FEDIMINTD_API_PORT_OFFSET: u16 = 1;
52/// Which port is for the web ui inside the range from [`PORTS_PER_FEDIMINTD`]
53pub const FEDIMINTD_UI_PORT_OFFSET: u16 = 2;
54/// Which port is for prometheus inside the range from [`PORTS_PER_FEDIMINTD`]
55pub const FEDIMINTD_METRICS_PORT_OFFSET: u16 = 3;
56
57#[derive(Clone)]
58pub struct Federation {
59    // client is only for internal use, use cli commands instead
60    pub members: BTreeMap<usize, Fedimintd>,
61    pub vars: BTreeMap<usize, vars::Fedimintd>,
62    pub bitcoind: Bitcoind,
63
64    /// Built in [`Client`], already joined
65    client: JitTryAnyhow<Client>,
66    #[allow(dead_code)] // Will need it later, maybe
67    connectors: ConnectorRegistry,
68}
69
70impl Drop for Federation {
71    fn drop(&mut self) {
72        block_in_place(|| {
73            block_on(async {
74                let mut set = JoinSet::new();
75
76                while let Some((_id, fedimintd)) = self.members.pop_first() {
77                    set.spawn(async { drop(fedimintd) });
78                }
79                while (set.join_next().await).is_some() {}
80            });
81        });
82    }
83}
84/// `fedimint-cli` instance (basically path with client state: config + db)
85#[derive(Clone)]
86pub struct Client {
87    name: String,
88}
89
90impl Client {
91    fn clients_dir() -> PathBuf {
92        let data_dir: PathBuf = env::var(FM_DATA_DIR_ENV)
93            .expect("FM_DATA_DIR_ENV not set")
94            .parse()
95            .expect("FM_DATA_DIR_ENV invalid");
96        data_dir.join("clients")
97    }
98
99    fn client_dir(&self) -> PathBuf {
100        Self::clients_dir().join(&self.name)
101    }
102
103    pub fn client_name_lock(name: &str) -> Result<FileLock> {
104        let lock_path = Self::clients_dir().join(format!(".{name}.lock"));
105        let file_lock = std::fs::OpenOptions::new()
106            .write(true)
107            .create(true)
108            .truncate(true)
109            .open(&lock_path)
110            .with_context(|| format!("Failed to open {}", lock_path.display()))?;
111
112        fs_lock::FileLock::new_exclusive(file_lock)
113            .with_context(|| format!("Failed to lock {}", lock_path.display()))
114    }
115
116    /// Create a [`Client`] that starts with a fresh state.
117    pub async fn create(name: impl ToString) -> Result<Client> {
118        let name = name.to_string();
119        spawn_blocking(move || {
120            let _lock = Self::client_name_lock(&name);
121            for i in 0u64.. {
122                let client = Self {
123                    name: format!("{name}-{i}"),
124                };
125
126                if !client.client_dir().exists() {
127                    std::fs::create_dir_all(client.client_dir())?;
128                    return Ok(client);
129                }
130            }
131            unreachable!()
132        })
133        .await?
134    }
135
136    /// Open or create a [`Client`] that starts with a fresh state.
137    pub fn open_or_create(name: &str) -> Result<Client> {
138        block_in_place(|| {
139            let _lock = Self::client_name_lock(name);
140            let client = Self {
141                name: format!("{name}-0"),
142            };
143            if !client.client_dir().exists() {
144                std::fs::create_dir_all(client.client_dir())?;
145            }
146            Ok(client)
147        })
148    }
149
150    /// Client to join a federation
151    pub async fn join_federation(&self, invite_code: String) -> Result<()> {
152        debug!(target: LOG_DEVIMINT, "Joining federation with the main client");
153        cmd!(self, "join-federation", invite_code).run().await?;
154
155        Ok(())
156    }
157
158    /// Client to join a federation with a restore procedure
159    pub async fn restore_federation(&self, invite_code: String, mnemonic: String) -> Result<()> {
160        debug!(target: LOG_DEVIMINT, "Joining federation with restore procedure");
161        cmd!(
162            self,
163            "restore",
164            "--invite-code",
165            invite_code,
166            "--mnemonic",
167            mnemonic
168        )
169        .run()
170        .await?;
171
172        Ok(())
173    }
174
175    /// Client to join a federation
176    pub async fn new_restored(&self, name: &str, invite_code: String) -> Result<Self> {
177        let restored = Self::open_or_create(name)?;
178
179        let mnemonic = cmd!(self, "print-secret").out_json().await?["secret"]
180            .as_str()
181            .unwrap()
182            .to_owned();
183
184        debug!(target: LOG_DEVIMINT, name, "Restoring from mnemonic");
185        cmd!(
186            restored,
187            "restore",
188            "--invite-code",
189            invite_code,
190            "--mnemonic",
191            mnemonic
192        )
193        .run()
194        .await?;
195
196        Ok(restored)
197    }
198
199    /// Create a [`Client`] that starts with a state that is a copy of
200    /// of another one.
201    pub async fn new_forked(&self, name: impl ToString) -> Result<Client> {
202        let new = Client::create(name).await?;
203
204        cmd!(
205            "cp",
206            "-R",
207            self.client_dir().join("client.db").display(),
208            new.client_dir().display()
209        )
210        .run()
211        .await?;
212
213        Ok(new)
214    }
215
216    pub async fn balance(&self) -> Result<u64> {
217        Ok(cmd!(self, "info").out_json().await?["total_amount_msat"]
218            .as_u64()
219            .unwrap())
220    }
221
222    /// Waits for the client balance to reach at least `min_balance_msat`.
223    pub async fn await_balance(&self, min_balance_msat: u64) -> Result<()> {
224        loop {
225            cmd!(self, "dev", "wait", "3").out_json().await?;
226
227            let balance = self.balance().await?;
228            if balance >= min_balance_msat {
229                return Ok(());
230            }
231
232            info!(
233                target: LOG_DEVIMINT,
234                balance,
235                min_balance_msat,
236                "Waiting for client balance to reach minimum"
237            );
238        }
239    }
240
241    /// Waits for the next walletv2 receive recorded at or after `position` (as
242    /// returned by [`Self::get_deposit_addr`] or a prior `await_receive`) to be
243    /// claimed, and returns the event log position to use for the following
244    /// wait.
245    pub async fn await_receive(&self, position: &str) -> Result<String> {
246        let output = cmd!(self, "module", "walletv2", "await-receive", position)
247            .out_json()
248            .await?;
249
250        // Walletv2 `await-receive` returns `[final_state, next_position]`.
251        Ok(output[1].to_string())
252    }
253
254    pub async fn get_deposit_addr(&self) -> Result<(String, String)> {
255        if crate::util::supports_wallet_v2() {
256            if crate::util::FedimintCli::version_or_default().await >= *VERSION_0_12_0_ALPHA {
257                // Capture the event log position *before* deriving the address so
258                // `await_receive` can wait from it; there is no operation id as
259                // deposits are auto-claimed.
260                let position = cmd!(self, "dev", "next-event-log-id").out_json().await?;
261
262                let address = cmd!(self, "module", "walletv2", "receive")
263                    .out_json()
264                    .await?;
265
266                Ok((address.as_str().unwrap().to_string(), position.to_string()))
267            } else {
268                // Legacy walletv2 (<= 0.11): `receive` returns the bare address
269                // and deposits are auto-claimed, so there is no position or
270                // operation id to wait on.
271                let address = cmd!(self, "module", "walletv2", "receive")
272                    .out_json()
273                    .await?;
274
275                Ok((address.as_str().unwrap().to_string(), String::new()))
276            }
277        } else {
278            let deposit = cmd!(self, "deposit-address").out_json().await?;
279            Ok((
280                deposit["address"].as_str().unwrap().to_string(),
281                deposit["operation_id"].as_str().unwrap().to_string(),
282            ))
283        }
284    }
285
286    pub async fn await_deposit(&self, operation_id: &str) -> Result<()> {
287        cmd!(self, "await-deposit", operation_id).run().await
288    }
289
290    pub fn cmd(&self) -> Command {
291        cmd!(
292            crate::util::get_fedimint_cli_path(),
293            format!("--data-dir={}", self.client_dir().display())
294        )
295    }
296
297    pub fn get_name(&self) -> &str {
298        &self.name
299    }
300
301    /// Returns the current consensus session count
302    pub async fn get_session_count(&self) -> Result<u64> {
303        cmd!(self, "dev", "session-count").out_json().await?["count"]
304            .as_u64()
305            .context("count field wasn't a number")
306    }
307
308    /// Returns once all active state machines complete
309    pub async fn wait_complete(&self) -> Result<()> {
310        cmd!(self, "dev", "wait-complete").run().await
311    }
312
313    /// Returns once the current session completes
314    pub async fn wait_session(&self) -> anyhow::Result<()> {
315        info!("Waiting for a new session");
316        let session_count = self.get_session_count().await?;
317        self.wait_session_outcome(session_count).await?;
318        Ok(())
319    }
320
321    /// Returns once the provided session count completes
322    pub async fn wait_session_outcome(&self, session_count: u64) -> anyhow::Result<()> {
323        let timeout = {
324            let current_session_count = self.get_session_count().await?;
325            let sessions_to_wait = session_count.saturating_sub(current_session_count) + 1;
326            let session_duration_seconds = 180;
327            Duration::from_secs(sessions_to_wait * session_duration_seconds)
328        };
329
330        let start = Instant::now();
331        poll_with_timeout("Waiting for a new session", timeout, || async {
332            info!("Awaiting session outcome {session_count}");
333            match cmd!(self, "dev", "api", "await_session_outcome", session_count)
334                .run()
335                .await
336            {
337                Err(e) => Err(ControlFlow::Continue(e)),
338                Ok(()) => Ok(()),
339            }
340        })
341        .await?;
342
343        let session_found_in = start.elapsed();
344        info!("session found in {session_found_in:?}");
345        Ok(())
346    }
347}
348
349impl Federation {
350    pub async fn new(
351        process_mgr: &ProcessManager,
352        bitcoind: Bitcoind,
353        skip_setup: bool,
354        pre_dkg: bool,
355        pre_restore: bool,
356        // Which of the pre-allocated federations to use (most tests just use single `0` one)
357        fed_index: usize,
358        federation_name: String,
359    ) -> Result<Self> {
360        let num_peers = NumPeers::from(process_mgr.globals.FM_FED_SIZE);
361        let mut members = BTreeMap::new();
362        let mut peer_to_env_vars_map = BTreeMap::new();
363
364        let mut admin_clients: BTreeMap<PeerId, DynGlobalApi> = BTreeMap::new();
365        let mut api_endpoints: BTreeMap<PeerId, _> = BTreeMap::new();
366
367        let connectors = ConnectorRegistry::build_from_testing_env()?.bind().await?;
368        for peer_id in num_peers.peer_ids() {
369            let peer_env_vars = vars::Fedimintd::init(
370                &process_mgr.globals,
371                federation_name.clone(),
372                peer_id,
373                process_mgr
374                    .globals
375                    .fedimintd_overrides
376                    .peer_expect(fed_index, peer_id),
377            )
378            .await?;
379            members.insert(
380                peer_id.to_usize(),
381                Fedimintd::new(
382                    process_mgr,
383                    bitcoind.clone(),
384                    peer_id.to_usize(),
385                    &peer_env_vars,
386                    federation_name.clone(),
387                )
388                .await?,
389            );
390            let admin_client = DynGlobalApi::new_admin_setup(
391                connectors.clone(),
392                SafeUrl::parse(&peer_env_vars.FM_API_URL)?,
393                // TODO: will need it somewhere
394                // &process_mgr.globals.FM_FORCE_API_SECRETS.get_active(),
395            )?;
396            api_endpoints.insert(peer_id, peer_env_vars.FM_API_URL.clone());
397            admin_clients.insert(peer_id, admin_client);
398            peer_to_env_vars_map.insert(peer_id.to_usize(), peer_env_vars);
399        }
400
401        if !skip_setup && !pre_dkg {
402            // we don't guarantee backwards-compatibility for dkg, so we use the
403            // fedimint-cli version that matches fedimintd
404            let (original_fedimint_cli_path, original_fm_mint_client) =
405                crate::util::use_matching_fedimint_cli_for_dkg().await?;
406
407            run_cli_dkg_v2(api_endpoints).await?;
408
409            // we're done with dkg, so we can reset the fedimint-cli version
410            crate::util::use_fedimint_cli(original_fedimint_cli_path, original_fm_mint_client);
411
412            // move configs to config directory
413            let client_dir = utf8(&process_mgr.globals.FM_CLIENT_DIR);
414            let invite_code_filename_original = "invite-code";
415
416            for peer_env_vars in peer_to_env_vars_map.values() {
417                let peer_data_dir = utf8(&peer_env_vars.FM_DATA_DIR);
418
419                let invite_code = poll_simple("awaiting-invite-code", || async {
420                    let path = format!("{peer_data_dir}/{invite_code_filename_original}");
421                    tokio::fs::read_to_string(&path)
422                        .await
423                        .with_context(|| format!("Awaiting invite code file: {path}"))
424                })
425                .await
426                .context("Awaiting invite code file")?;
427
428                download_from_invite_code(&connectors, &InviteCode::from_str(&invite_code)?)
429                    .await?;
430            }
431
432            // copy over invite-code file to client directory
433            let peer_data_dir = utf8(&peer_to_env_vars_map[&0].FM_DATA_DIR);
434
435            tokio::fs::copy(
436                format!("{peer_data_dir}/{invite_code_filename_original}"),
437                format!("{client_dir}/{invite_code_filename_original}"),
438            )
439            .await
440            .context("copying invite-code file")?;
441
442            // move each guardian's invite-code file to the client's directory
443            // appending the peer id to the end
444            for (index, peer_env_vars) in &peer_to_env_vars_map {
445                let peer_data_dir = utf8(&peer_env_vars.FM_DATA_DIR);
446
447                let invite_code_filename_indexed =
448                    format!("{invite_code_filename_original}-{index}");
449                tokio::fs::rename(
450                    format!("{peer_data_dir}/{invite_code_filename_original}"),
451                    format!("{client_dir}/{invite_code_filename_indexed}"),
452                )
453                .await
454                .context("moving invite-code file")?;
455            }
456
457            debug!("Moved invite-code files to client data directory");
458
459            if pre_restore {
460                Self::restart_guardian_for_manual_restore(
461                    process_mgr,
462                    &mut members,
463                    &peer_to_env_vars_map,
464                    &bitcoind,
465                )
466                .await?;
467            }
468        }
469
470        let client = JitTryAnyhow::new_try({
471            move || async move {
472                let client = Client::open_or_create(federation_name.as_str())?;
473                let invite_code = Self::invite_code_static()?;
474                if !skip_setup && !pre_dkg {
475                    cmd!(client, "join-federation", invite_code).run().await?;
476                }
477                Ok(client)
478            }
479        });
480
481        Ok(Self {
482            members,
483            vars: peer_to_env_vars_map,
484            bitcoind,
485            client,
486            connectors,
487        })
488    }
489
490    async fn restart_guardian_for_manual_restore(
491        process_mgr: &ProcessManager,
492        members: &mut BTreeMap<usize, Fedimintd>,
493        peer_to_env_vars_map: &BTreeMap<usize, vars::Fedimintd>,
494        bitcoind: &Bitcoind,
495    ) -> Result<()> {
496        const RESTORE_PEER: usize = 0;
497
498        let peer_env_vars = &peer_to_env_vars_map[&RESTORE_PEER];
499        let backup_dir = process_mgr.globals.FM_TEST_DIR.join("fedimintd-backups");
500        tokio::fs::create_dir_all(&backup_dir)
501            .await
502            .context("Creating fedimintd backup directory")?;
503        let backup_path = backup_dir.join("fedimint-0-guardian-backup.tar");
504
505        Self::write_guardian_backup_tar(&peer_env_vars.FM_DATA_DIR, &backup_path).await?;
506        info!(
507            target: LOG_DEVIMINT,
508            path = %backup_path.display(),
509            "Wrote guardian backup for manual restore"
510        );
511
512        let fedimintd = members
513            .remove(&RESTORE_PEER)
514            .context("Missing fedimint-0 process")?;
515        fedimintd.terminate().await?;
516        tokio::fs::remove_dir_all(&peer_env_vars.FM_DATA_DIR)
517            .await
518            .with_context(|| format!("Removing {}", peer_env_vars.FM_DATA_DIR.display()))?;
519        tokio::fs::create_dir_all(&peer_env_vars.FM_DATA_DIR)
520            .await
521            .with_context(|| format!("Creating {}", peer_env_vars.FM_DATA_DIR.display()))?;
522
523        let fedimintd = Fedimintd::new(
524            process_mgr,
525            bitcoind.clone(),
526            RESTORE_PEER,
527            peer_env_vars,
528            "default".to_string(),
529        )
530        .await?;
531        members.insert(RESTORE_PEER, fedimintd);
532
533        info!(
534            target: LOG_DEVIMINT,
535            ui = %peer_env_vars.FM_BIND_UI,
536            backup = %backup_path.display(),
537            "fedimint-0 restarted in setup mode for manual restore"
538        );
539
540        Ok(())
541    }
542
543    async fn write_guardian_backup_tar(data_dir: &Path, backup_path: &Path) -> Result<()> {
544        let data_dir = data_dir.to_path_buf();
545        let backup_path = backup_path.to_path_buf();
546        spawn_blocking(move || {
547            let file = fs::File::options()
548                .write(true)
549                .create_new(true)
550                .open(&backup_path)
551                .with_context(|| format!("Creating {}", backup_path.display()))?;
552            let mut archive = tar::Builder::new(file);
553            for path in [
554                PathBuf::from(LOCAL_CONFIG).with_extension(JSON_EXT),
555                PathBuf::from(CONSENSUS_CONFIG).with_extension(JSON_EXT),
556                PathBuf::from(PRIVATE_CONFIG).with_extension(JSON_EXT),
557            ] {
558                archive
559                    .append_path_with_name(data_dir.join(&path), &path)
560                    .with_context(|| format!("Adding {} to backup", path.display()))?;
561            }
562            archive.finish().context("Finishing guardian backup tar")?;
563            Ok::<_, anyhow::Error>(())
564        })
565        .await?
566    }
567
568    pub fn client_config(&self) -> Result<ClientConfig> {
569        let cfg_path = self.vars[&0].FM_DATA_DIR.join("client.json");
570        load_from_file(&cfg_path)
571    }
572
573    /// Get the module instance ID for a given module kind
574    pub fn module_instance_id_by_kind(&self, kind: &ModuleKind) -> Result<ModuleInstanceId> {
575        self.client_config()?
576            .modules
577            .iter()
578            .find_map(|(id, cfg)| if &cfg.kind == kind { Some(*id) } else { None })
579            .with_context(|| format!("Module kind {kind} not found"))
580    }
581
582    pub fn module_client_config<M: ClientModule>(
583        &self,
584    ) -> Result<Option<<M::Common as ModuleCommon>::ClientConfig>> {
585        self.client_config()?
586            .modules
587            .iter()
588            .find_map(|(module_instance_id, module_cfg)| {
589                if module_cfg.kind == M::kind() {
590                    let decoders = ModuleDecoderRegistry::new(vec![(
591                        *module_instance_id,
592                        M::kind(),
593                        M::decoder(),
594                    )]);
595                    Some(
596                        module_cfg
597                            .config
598                            .clone()
599                            .redecode_raw(&decoders)
600                            .expect("Decoding client cfg failed")
601                            .expect_decoded_ref()
602                            .as_any()
603                            .downcast_ref::<<M::Common as ModuleCommon>::ClientConfig>()
604                            .cloned()
605                            .context("Cast to module config failed"),
606                    )
607                } else {
608                    None
609                }
610            })
611            .transpose()
612    }
613
614    pub fn deposit_fees(&self) -> Result<Amount> {
615        if crate::util::supports_wallet_v2() {
616            Ok(self
617                .module_client_config::<fedimint_walletv2_client::WalletClientModule>()?
618                .context("No walletv2 module found")?
619                .fee_consensus
620                .base)
621        } else {
622            Ok(self
623                .module_client_config::<WalletClientModule>()?
624                .context("No wallet module found")?
625                .fee_consensus
626                .peg_in_abs)
627        }
628    }
629
630    /// Read the invite code from the client data dir
631    pub fn invite_code(&self) -> Result<String> {
632        let data_dir: PathBuf = env::var(FM_CLIENT_DIR_ENV)?.parse()?;
633        let invite_code = fs::read_to_string(data_dir.join("invite-code"))?;
634        Ok(invite_code)
635    }
636
637    pub fn invite_code_static() -> Result<String> {
638        let data_dir: PathBuf = env::var(FM_CLIENT_DIR_ENV)?.parse()?;
639        let invite_code = fs::read_to_string(data_dir.join("invite-code"))?;
640        Ok(invite_code)
641    }
642    pub fn invite_code_for(peer_id: PeerId) -> Result<String> {
643        let data_dir: PathBuf = env::var(FM_CLIENT_DIR_ENV)?.parse()?;
644        let name = format!("invite-code-{peer_id}");
645        let invite_code = fs::read_to_string(data_dir.join(name))?;
646        Ok(invite_code)
647    }
648
649    /// Built-in, default, internal [`Client`]
650    ///
651    /// We should be moving away from using it for anything.
652    pub async fn internal_client(&self) -> Result<&Client> {
653        self.client
654            .get_try()
655            .await
656            .context("Internal client joining Federation")
657    }
658
659    /// New [`Client`] that already joined `self`
660    pub async fn new_joined_client(&self, name: impl ToString) -> Result<Client> {
661        let client = Client::create(name).await?;
662        client.join_federation(self.invite_code()?).await?;
663        Ok(client)
664    }
665
666    pub async fn start_server(&mut self, process_mgr: &ProcessManager, peer: usize) -> Result<()> {
667        if self.members.contains_key(&peer) {
668            bail!("fedimintd-{peer} already running");
669        }
670        self.members.insert(
671            peer,
672            Fedimintd::new(
673                process_mgr,
674                self.bitcoind.clone(),
675                peer,
676                &self.vars[&peer],
677                "default".to_string(),
678            )
679            .await?,
680        );
681        Ok(())
682    }
683
684    pub async fn terminate_server(&mut self, peer_id: usize) -> Result<()> {
685        let Some((_, fedimintd)) = self.members.remove_entry(&peer_id) else {
686            bail!("fedimintd-{peer_id} does not exist");
687        };
688        fedimintd.terminate().await?;
689        Ok(())
690    }
691
692    pub async fn await_server_terminated(&mut self, peer_id: usize) -> Result<()> {
693        let Some(fedimintd) = self.members.get_mut(&peer_id) else {
694            bail!("fedimintd-{peer_id} does not exist");
695        };
696        fedimintd.await_terminated().await?;
697        self.members.remove(&peer_id);
698        Ok(())
699    }
700
701    /// Starts all peers not currently running.
702    pub async fn start_all_servers(&mut self, process_mgr: &ProcessManager) -> Result<()> {
703        info!("starting all servers");
704        let fed_size = process_mgr.globals.FM_FED_SIZE;
705        for peer_id in 0..fed_size {
706            if self.members.contains_key(&peer_id) {
707                continue;
708            }
709            self.start_server(process_mgr, peer_id).await?;
710        }
711        self.await_all_peers().await?;
712        Ok(())
713    }
714
715    /// Terminates all running peers.
716    pub async fn terminate_all_servers(&mut self) -> Result<()> {
717        info!("terminating all servers");
718        let running_peer_ids: Vec<_> = self.members.keys().copied().collect();
719        for peer_id in running_peer_ids {
720            self.terminate_server(peer_id).await?;
721        }
722        Ok(())
723    }
724
725    /// Coordinated shutdown of all peers that restart using the provided
726    /// `bin_path`. Returns `Ok()` once all peers are online.
727    ///
728    /// Staggering the restart more closely simulates upgrades in the wild.
729    pub async fn restart_all_staggered_with_bin(
730        &mut self,
731        process_mgr: &ProcessManager,
732        bin_path: &PathBuf,
733    ) -> Result<()> {
734        let fed_size = process_mgr.globals.FM_FED_SIZE;
735
736        // ensure all peers are online
737        self.start_all_servers(process_mgr).await?;
738
739        // staggered shutdown of peers
740        while self.num_members() > 0 {
741            self.terminate_server(self.num_members() - 1).await?;
742            if self.num_members() > 0 {
743                fedimint_core::task::sleep_in_test(
744                    "waiting to shutdown remaining peers",
745                    Duration::from_secs(10),
746                )
747                .await;
748            }
749        }
750
751        // TODO: Audit that the environment access only happens in single-threaded code.
752        unsafe { std::env::set_var("FM_FEDIMINTD_BASE_EXECUTABLE", bin_path) };
753
754        // staggered restart
755        for peer_id in 0..fed_size {
756            self.start_server(process_mgr, peer_id).await?;
757            if peer_id < fed_size - 1 {
758                fedimint_core::task::sleep_in_test(
759                    "waiting to restart remaining peers",
760                    Duration::from_secs(10),
761                )
762                .await;
763            }
764        }
765
766        self.await_all_peers().await?;
767
768        let fedimintd_version = crate::util::FedimintdCmd::version_or_default().await;
769        info!("upgraded fedimintd to version: {}", fedimintd_version);
770        Ok(())
771    }
772
773    pub async fn restart_all_with_bin(
774        &mut self,
775        process_mgr: &ProcessManager,
776        bin_path: &PathBuf,
777    ) -> Result<()> {
778        // get the version we're upgrading to, temporarily updating the fedimintd path
779        let current_fedimintd_path = std::env::var("FM_FEDIMINTD_BASE_EXECUTABLE")?;
780        // TODO: Audit that the environment access only happens in single-threaded code.
781        unsafe { std::env::set_var("FM_FEDIMINTD_BASE_EXECUTABLE", bin_path) };
782        // TODO: Audit that the environment access only happens in single-threaded code.
783        unsafe { std::env::set_var("FM_FEDIMINTD_BASE_EXECUTABLE", current_fedimintd_path) };
784
785        self.restart_all_staggered_with_bin(process_mgr, bin_path)
786            .await
787    }
788
789    pub async fn degrade_federation(&mut self, process_mgr: &ProcessManager) -> Result<()> {
790        let fed_size = process_mgr.globals.FM_FED_SIZE;
791        let offline_nodes = process_mgr.globals.FM_OFFLINE_NODES;
792        anyhow::ensure!(
793            fed_size > 3 * offline_nodes,
794            "too many offline nodes ({offline_nodes}) to reach consensus"
795        );
796
797        while self.num_members() > fed_size - offline_nodes {
798            self.terminate_server(self.num_members() - 1).await?;
799        }
800
801        if offline_nodes > 0 {
802            info!(fed_size, offline_nodes, "federation is degraded");
803        }
804        Ok(())
805    }
806
807    pub async fn send_to_address(&self, address: String, amount: u64) -> Result<()> {
808        self.bitcoind.send_to(address, amount).await?;
809
810        self.bitcoind.mine_blocks(21).await?;
811
812        Ok(())
813    }
814
815    pub async fn pegin_client_no_wait(&self, amount: u64, client: &Client) -> Result<String> {
816        let deposit_fees_msat = self.deposit_fees()?.msats;
817        assert_eq!(
818            deposit_fees_msat % 1000,
819            0,
820            "Deposit fees expected to be whole sats in test suite"
821        );
822        let deposit_fees = deposit_fees_msat / 1000;
823        info!(amount, deposit_fees, "Pegging-in client funds");
824
825        // For walletv1 this is the deposit operation id; for walletv2 it is the
826        // event log position to wait for the receive from.
827        let (address, handle) = client.get_deposit_addr().await?;
828
829        self.bitcoind
830            .send_to(address, amount + deposit_fees)
831            .await?;
832        self.bitcoind.mine_blocks(21).await?;
833
834        Ok(handle)
835    }
836
837    pub async fn pegin_client(&self, amount: u64, client: &Client) -> Result<()> {
838        let walletv2_await_receive = crate::util::supports_wallet_v2()
839            && crate::util::FedimintCli::version_or_default().await >= *VERSION_0_12_0_ALPHA;
840
841        // Legacy walletv2 (<= 0.11) auto-claims deposits with no way to wait on
842        // them directly, so capture the balance before the deposit to wait for
843        // it to increase.
844        let initial_balance = if crate::util::supports_wallet_v2() && !walletv2_await_receive {
845            Some(client.balance().await?)
846        } else {
847            None
848        };
849
850        let handle = self.pegin_client_no_wait(amount, client).await?;
851
852        if walletv2_await_receive {
853            // `handle` is the event log position from which to wait for the
854            // receive.
855            client.await_receive(&handle).await?;
856        } else if let Some(initial) = initial_balance {
857            // Wait for the balance to increase. We expect slightly less than
858            // `amount` due to mint module fees when creating ecash notes.
859            let expected_balance = initial + (amount * 1000 * 9 / 10);
860            client.await_balance(expected_balance).await?;
861        } else {
862            client.await_deposit(&handle).await?;
863        }
864
865        Ok(())
866    }
867
868    /// Initiates multiple peg-ins to the same federation for the set of
869    /// gateways to save on mining blocks in parallel.
870    pub async fn pegin_gateways(
871        &self,
872        amount: u64,
873        gateways: Vec<&super::gatewayd::Gatewayd>,
874    ) -> Result<()> {
875        let deposit_fees_msat = self.deposit_fees()?.msats;
876        assert_eq!(
877            deposit_fees_msat % 1000,
878            0,
879            "Deposit fees expected to be whole sats in test suite"
880        );
881        let deposit_fees = deposit_fees_msat / 1000;
882        info!(amount, deposit_fees, "Pegging-in gateway funds");
883        let fed_id = self.calculate_federation_id();
884
885        // For walletv2, capture initial balances since deposits are auto-claimed
886        // and we need to check balance change rather than absolute balance
887        // (same approach as pegin_client).
888        let uses_walletv2 = crate::util::supports_wallet_v2();
889        let mut initial_balances = Vec::new();
890        if uses_walletv2 {
891            for gw in &gateways {
892                let balance = gw
893                    .client()
894                    .ecash_balance(fed_id.clone())
895                    .await
896                    .expect("failed to fetch initial gateway balance");
897                initial_balances.push(balance);
898            }
899        }
900
901        let mut gateway_deposit_addrs = Vec::new();
902        for gw in gateways.clone() {
903            let pegin_addr = gw.client().get_pegin_addr(&fed_id).await?;
904            debug!(
905                gateway = %gw.gw_name,
906                ln = %gw.ln.ln_type(),
907                address = %pegin_addr,
908                amount_sats = amount + deposit_fees,
909                uses_walletv2,
910                "Sending gateway pegin"
911            );
912            self.bitcoind
913                .send_to(pegin_addr.clone(), amount + deposit_fees)
914                .await?;
915            gateway_deposit_addrs.push(pegin_addr);
916        }
917
918        let pegin_start = Instant::now();
919        self.bitcoind.mine_blocks(21).await?;
920        let bitcoind_block_height: u64 = self.bitcoind.get_block_count().await? - 1;
921        debug!(
922            bitcoind_block_height,
923            elapsed_ms = %pegin_start.elapsed().as_millis(),
924            "Mined gateway pegin blocks"
925        );
926
927        try_join_all(gateways.into_iter().enumerate().map(|(i, gw)| {
928            let initial_balance = if uses_walletv2 {
929                initial_balances[i]
930            } else {
931                0
932            };
933            let gateway_name = gw.gw_name.clone();
934            let gateway_ln = gw.ln.ln_type().to_string();
935            let gateway_id = gw.gateway_id.clone();
936            let gateway_index = gw.gateway_index;
937            let deposit_address = gateway_deposit_addrs[i].clone();
938            let fed_id = fed_id.clone();
939            poll("gateway pegin", move || {
940                let fed_id = fed_id.clone();
941                let gateway_name = gateway_name.clone();
942                let gateway_ln = gateway_ln.clone();
943                let gateway_id = gateway_id.clone();
944                let deposit_address = deposit_address.clone();
945                async move {
946                    let gw_info = gw
947                        .client()
948                        .get_info()
949                        .await
950                        .map_err(ControlFlow::Continue)?;
951
952                    let block_height: u64 = if gw.gatewayd_version < *VERSION_0_10_0_ALPHA {
953                        gw_info["block_height"]
954                            .as_u64()
955                            .expect("Could not parse block height")
956                    } else {
957                        gw_info["lightning_info"]["connected"]["block_height"]
958                            .as_u64()
959                            .expect("Could not parse block height")
960                    };
961
962                    if bitcoind_block_height != block_height {
963                        debug!(
964                            gateway = %gateway_name,
965                            ln = %gateway_ln,
966                            bitcoind_block_height,
967                            gateway_block_height = block_height,
968                            elapsed_ms = %pegin_start.elapsed().as_millis(),
969                            "Waiting for gateway pegin block sync"
970                        );
971                        return Err(std::ops::ControlFlow::Continue(anyhow::anyhow!(
972                            "Gateway {gateway_name} ({gateway_id}, index {gateway_index}) block height {block_height} has not reached bitcoind block height {bitcoind_block_height}"
973                        )));
974                    }
975
976                    let gateway_balance = gw
977                        .client()
978                        .ecash_balance(fed_id)
979                        .await
980                        .map_err(ControlFlow::Continue)?;
981
982                    if uses_walletv2 {
983                        // Walletv2: check balance increased by approximately
984                        // the expected amount. Use 90% threshold to account
985                        // for mintv2 fees (same as pegin_client).
986                        let expected = initial_balance + (amount * 1000 * 9 / 10);
987                        debug!(
988                            gateway = %gateway_name,
989                            ln = %gateway_ln,
990                            gateway_balance_msats = gateway_balance,
991                            expected_msats = expected,
992                            initial_balance_msats = initial_balance,
993                            elapsed_ms = %pegin_start.elapsed().as_millis(),
994                            "Checked walletv2 gateway pegin balance"
995                        );
996                        if expected <= gateway_balance {
997                            Ok(())
998                        } else {
999                            Err(ControlFlow::Continue(anyhow::anyhow!(
1000                                "Gateway {gateway_name} ({gateway_id}, index {gateway_index}) balance {gateway_balance} has not reached expected {expected} for deposit address {deposit_address} (initial: {initial_balance})"
1001                            )))
1002                        }
1003                    } else {
1004                        poll_almost_equal!(gateway_balance, amount * 1000)
1005                    }
1006                }
1007            })
1008        }))
1009        .await?;
1010
1011        Ok(())
1012    }
1013
1014    /// Initiates multiple peg-outs from the same federation for the set of
1015    /// gateways to save on mining blocks in parallel.
1016    pub async fn pegout_gateways(
1017        &self,
1018        amount: u64,
1019        gateways: Vec<&super::gatewayd::Gatewayd>,
1020    ) -> Result<()> {
1021        info!(amount, "Pegging-out gateway funds");
1022        let fed_id = self.calculate_federation_id();
1023        let mut peg_outs: BTreeMap<LightningNodeType, (Amount, WithdrawResponse)> = BTreeMap::new();
1024        for gw in gateways.clone() {
1025            let prev_fed_ecash_balance = gw
1026                .client()
1027                .get_balances()
1028                .await?
1029                .ecash_balances
1030                .into_iter()
1031                .find(|fed| fed.federation_id.to_string() == fed_id)
1032                .expect("Gateway has not joined federation")
1033                .ecash_balance_msats;
1034
1035            let pegout_address = self.bitcoind.get_new_address().await?;
1036            let response = gw
1037                .client()
1038                .pegout(fed_id.clone(), amount, pegout_address)
1039                .await?;
1040            peg_outs.insert(gw.ln.ln_type(), (prev_fed_ecash_balance, response));
1041        }
1042        self.bitcoind.mine_blocks(21).await?;
1043
1044        try_join_all(
1045            peg_outs
1046                .values()
1047                .map(|(_, pegout)| self.bitcoind.poll_get_transaction(pegout.txid)),
1048        )
1049        .await?;
1050
1051        for gw in gateways.clone() {
1052            let after_fed_ecash_balance = gw
1053                .client()
1054                .get_balances()
1055                .await?
1056                .ecash_balances
1057                .into_iter()
1058                .find(|fed| fed.federation_id.to_string() == fed_id)
1059                .expect("Gateway has not joined federation")
1060                .ecash_balance_msats;
1061
1062            let ln_type = gw.ln.ln_type();
1063            let prev_balance = peg_outs
1064                .get(&ln_type)
1065                .expect("peg out does not exist")
1066                .0
1067                .msats;
1068            let fees = peg_outs
1069                .get(&ln_type)
1070                .expect("peg out does not exist")
1071                .1
1072                .fees;
1073            let total_fee = fees.amount().to_sat() * 1000;
1074            // Walletv2 charges a module fee on top of the on-chain fee:
1075            // 100 sats base + 1% of amount (amount is in msats)
1076            let tolerance = if crate::util::supports_wallet_v2() {
1077                let amount_sats = amount / 1000;
1078                let module_fee_sats = 100 + amount_sats / 100;
1079                module_fee_sats * 1000 + 2000
1080            } else if crate::util::supports_mint_v2() {
1081                4000
1082            } else {
1083                2000
1084            };
1085            crate::util::almost_equal(
1086                after_fed_ecash_balance.msats,
1087                prev_balance - amount - total_fee,
1088                tolerance,
1089            )
1090            .map_err(|e| {
1091                anyhow::anyhow!(
1092                    "new balance did not equal prev balance minus withdraw_amount minus fees: {e}"
1093                )
1094            })?;
1095        }
1096
1097        Ok(())
1098    }
1099
1100    pub fn calculate_federation_id(&self) -> String {
1101        self.client_config()
1102            .unwrap()
1103            .global
1104            .calculate_federation_id()
1105            .to_string()
1106    }
1107
1108    pub async fn await_block_sync(&self) -> Result<u64> {
1109        let finality_delay = self.get_finality_delay()?;
1110        let block_count = self.bitcoind.get_block_count().await?;
1111        let expected = block_count.saturating_sub(finality_delay.into());
1112
1113        if crate::util::supports_wallet_v2() {
1114            // Walletv2 doesn't have `dev wait-block-count`, poll using CLI instead
1115            let client = self.internal_client().await?;
1116            loop {
1117                let value = cmd!(client, "module", "walletv2", "info", "block-count")
1118                    .out_json()
1119                    .await?;
1120                let current: u64 = serde_json::from_value(value)?;
1121                if current >= expected {
1122                    break;
1123                }
1124                fedimint_core::task::sleep_in_test(
1125                    format!("Waiting for consensus block count to reach {expected}"),
1126                    std::time::Duration::from_secs(1),
1127                )
1128                .await;
1129            }
1130        } else {
1131            cmd!(
1132                self.internal_client().await?,
1133                "dev",
1134                "wait-block-count",
1135                expected
1136            )
1137            .run()
1138            .await?;
1139        }
1140
1141        Ok(expected)
1142    }
1143
1144    fn get_finality_delay(&self) -> Result<u32, anyhow::Error> {
1145        // Walletv2 uses a constant finality delay
1146        if crate::util::supports_wallet_v2() {
1147            return Ok(fedimint_walletv2_server::CONFIRMATION_FINALITY_DELAY as u32);
1148        }
1149
1150        let wallet_instance_id = self.module_instance_id_by_kind(&fedimint_wallet_client::KIND)?;
1151        let client_config = &self.client_config()?;
1152        let wallet_cfg = client_config
1153            .modules
1154            .get(&wallet_instance_id)
1155            .context("wallet module not found")?
1156            .clone()
1157            .redecode_raw(&ModuleDecoderRegistry::new([(
1158                wallet_instance_id,
1159                fedimint_wallet_client::KIND,
1160                fedimint_wallet_client::WalletModuleTypes::decoder(),
1161            )]))?;
1162        let wallet_cfg: &WalletClientConfig = wallet_cfg.cast()?;
1163
1164        let finality_delay = wallet_cfg.finality_delay;
1165        Ok(finality_delay)
1166    }
1167
1168    pub async fn await_gateways_registered(&self) -> Result<()> {
1169        let start_time = Instant::now();
1170        debug!(target: LOG_DEVIMINT, "Awaiting LN gateways registration");
1171
1172        poll("gateways registered", || async {
1173            let num_gateways = cmd!(
1174                self.internal_client()
1175                    .await
1176                    .map_err(ControlFlow::Continue)?,
1177                "list-gateways"
1178            )
1179            .out_json()
1180            .await
1181            .map_err(ControlFlow::Continue)?
1182            .as_array()
1183            .context("invalid output")
1184            .map_err(ControlFlow::Break)?
1185            .len();
1186
1187            // After version v0.10.0, the LND gateway will register twice. Once for the HTTP
1188            // server, and once for the iroh endpoint.
1189            let expected_gateways =
1190                if crate::util::Gatewayd::version_or_default().await < *VERSION_0_10_0_ALPHA {
1191                    1
1192                } else {
1193                    2
1194                };
1195
1196            poll_eq!(num_gateways, expected_gateways)
1197        })
1198        .await?;
1199        debug!(target: LOG_DEVIMINT,
1200            elapsed_ms = %start_time.elapsed().as_millis(),
1201            "Gateways registered");
1202        Ok(())
1203    }
1204
1205    pub async fn await_all_peers(&self) -> Result<()> {
1206        let (module_name, endpoint) = if crate::util::supports_wallet_v2() {
1207            ("walletv2", "consensus_block_count")
1208        } else {
1209            ("wallet", "block_count")
1210        };
1211        poll("Waiting for all peers to be online", || async {
1212            cmd!(
1213                self.internal_client()
1214                    .await
1215                    .map_err(ControlFlow::Continue)?,
1216                "dev",
1217                "api",
1218                "--module",
1219                module_name,
1220                endpoint
1221            )
1222            .run()
1223            .await
1224            .map_err(ControlFlow::Continue)?;
1225            Ok(())
1226        })
1227        .await
1228    }
1229
1230    pub async fn await_peer(&self, peer_id: usize) -> Result<()> {
1231        poll("Waiting for all peers to be online", || async {
1232            cmd!(
1233                self.internal_client()
1234                    .await
1235                    .map_err(ControlFlow::Continue)?,
1236                "dev",
1237                "api",
1238                "--peer-id",
1239                peer_id,
1240                "--module",
1241                "wallet",
1242                "block_count"
1243            )
1244            .run()
1245            .await
1246            .map_err(ControlFlow::Continue)?;
1247            Ok(())
1248        })
1249        .await
1250    }
1251
1252    /// Mines enough blocks to finalize mempool transactions, then waits for
1253    /// federation to process finalized blocks.
1254    ///
1255    /// ex:
1256    ///   tx submitted to mempool at height 100
1257    ///   finality delay = 10
1258    ///   mine finality delay blocks + 1 => new height 111
1259    ///   tx included in block 101
1260    ///   highest finalized height = 111 - 10 = 101
1261    pub async fn finalize_mempool_tx(&self) -> Result<()> {
1262        let finality_delay = self.get_finality_delay()?;
1263        let blocks_to_mine = finality_delay + 1;
1264        self.bitcoind.mine_blocks(blocks_to_mine.into()).await?;
1265        self.await_block_sync().await?;
1266        Ok(())
1267    }
1268
1269    pub async fn mine_then_wait_blocks_sync(&self, blocks: u64) -> Result<()> {
1270        self.bitcoind.mine_blocks(blocks).await?;
1271        self.await_block_sync().await?;
1272        Ok(())
1273    }
1274
1275    pub fn num_members(&self) -> usize {
1276        self.members.len()
1277    }
1278
1279    pub fn member_ids(&self) -> impl Iterator<Item = PeerId> + '_ {
1280        self.members
1281            .keys()
1282            .map(|&peer_id| PeerId::from(peer_id as u16))
1283    }
1284}
1285
1286#[derive(Clone)]
1287pub struct Fedimintd {
1288    _bitcoind: Bitcoind,
1289    process: ProcessHandle,
1290}
1291
1292impl Fedimintd {
1293    pub async fn new(
1294        process_mgr: &ProcessManager,
1295        bitcoind: Bitcoind,
1296        peer_id: usize,
1297        env: &vars::Fedimintd,
1298        fed_name: String,
1299    ) -> Result<Self> {
1300        debug!(target: LOG_DEVIMINT, "Starting fedimintd-{fed_name}-{peer_id}");
1301        let process = process_mgr
1302            .spawn_daemon(
1303                &format!("fedimintd-{fed_name}-{peer_id}"),
1304                cmd!(FedimintdCmd).envs(env.vars()),
1305            )
1306            .await?;
1307
1308        Ok(Self {
1309            _bitcoind: bitcoind,
1310            process,
1311        })
1312    }
1313
1314    pub async fn terminate(self) -> Result<()> {
1315        self.process.terminate().await
1316    }
1317
1318    pub async fn await_terminated(&self) -> Result<()> {
1319        self.process.await_terminated().await
1320    }
1321}
1322
1323pub async fn run_cli_dkg_v2(endpoints: BTreeMap<PeerId, String>) -> Result<()> {
1324    // Parallelize setup status checks
1325    let status_futures = endpoints.values().map(|endpoint| {
1326        let endpoint = endpoint.clone();
1327        async move {
1328            let status = poll("awaiting-setup-status-awaiting-local-params", || async {
1329                crate::util::FedimintCli
1330                    .setup_status(&API_AUTH, &endpoint)
1331                    .await
1332                    .map_err(ControlFlow::Continue)
1333            })
1334            .await
1335            .unwrap();
1336
1337            assert_eq!(status, SetupStatus::AwaitingLocalParams);
1338        }
1339    });
1340    join_all(status_futures).await;
1341
1342    debug!(target: LOG_DEVIMINT, "Setting local parameters...");
1343
1344    // Parallelize setting local parameters
1345    // --federation-size is only supported by fedimint-cli >= 0.11.0-alpha
1346    let federation_size =
1347        if crate::util::FedimintCli::version_or_default().await >= *VERSION_0_11_0_ALPHA {
1348            Some(endpoints.len())
1349        } else {
1350            None
1351        };
1352    let local_params_futures = endpoints.iter().map(|(peer, endpoint)| {
1353        let peer = *peer;
1354        let endpoint = endpoint.clone();
1355        async move {
1356            let info = if peer.to_usize() == 0 {
1357                crate::util::FedimintCli
1358                    .set_local_params_leader(&peer, &API_AUTH, &endpoint, federation_size)
1359                    .await
1360            } else {
1361                crate::util::FedimintCli
1362                    .set_local_params_follower(&peer, &API_AUTH, &endpoint)
1363                    .await
1364            };
1365            info.map(|i| (peer, i))
1366        }
1367    });
1368    let connection_info: BTreeMap<_, _> = try_join_all(local_params_futures)
1369        .await?
1370        .into_iter()
1371        .collect();
1372
1373    debug!(target: LOG_DEVIMINT, "Exchanging peer connection info...");
1374
1375    // Parallelize peer addition - flatten the nested loop into a single parallel
1376    // operation
1377    let add_peer_futures = connection_info.iter().flat_map(|(peer, info)| {
1378        endpoints
1379            .iter()
1380            .filter(move |(p, _)| *p != peer)
1381            .map(move |(_, endpoint)| {
1382                let endpoint = endpoint.clone();
1383                let info = info.clone();
1384                async move {
1385                    crate::util::FedimintCli
1386                        .add_peer(&info, &API_AUTH, &endpoint)
1387                        .await
1388                }
1389            })
1390    });
1391    try_join_all(add_peer_futures).await?;
1392
1393    debug!(target: LOG_DEVIMINT, "Starting DKG...");
1394
1395    // Parallelize DKG start
1396    let start_dkg_futures = endpoints.values().map(|endpoint| {
1397        let endpoint = endpoint.clone();
1398        async move {
1399            crate::util::FedimintCli
1400                .start_dkg(&API_AUTH, &endpoint)
1401                .await
1402        }
1403    });
1404    try_join_all(start_dkg_futures).await?;
1405
1406    Ok(())
1407}