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    /// Add a gateway to every guardian's list of lnv2 gateways.
667    ///
668    /// Clients only see lnv2 gateways that guardians have added via the
669    /// authenticated lnv2 admin endpoint. Devimint has no human
670    /// guardians, so this calls that endpoint with the well-known test
671    /// credentials for each running guardian.
672    pub async fn add_lnv2_gateway(&self, gateway: &str) -> Result<()> {
673        let client = self.internal_client().await?;
674        for peer_id in self.members.keys() {
675            // The add runs concurrently with the rest of the dev-fed setup,
676            // so a single attempt can fail transiently, e.g. on an iroh
677            // connection under CI load.
678            poll("adding lnv2 gateway", || async {
679                cmd!(
680                    client,
681                    "--our-id",
682                    peer_id.to_string(),
683                    "--password",
684                    API_AUTH.as_str(),
685                    "module",
686                    "lnv2",
687                    "gateways",
688                    "add",
689                    gateway
690                )
691                .run()
692                .await
693                .map_err(ControlFlow::Continue)
694            })
695            .await?;
696        }
697        Ok(())
698    }
699
700    pub async fn start_server(&mut self, process_mgr: &ProcessManager, peer: usize) -> Result<()> {
701        if self.members.contains_key(&peer) {
702            bail!("fedimintd-{peer} already running");
703        }
704        self.members.insert(
705            peer,
706            Fedimintd::new(
707                process_mgr,
708                self.bitcoind.clone(),
709                peer,
710                &self.vars[&peer],
711                "default".to_string(),
712            )
713            .await?,
714        );
715        Ok(())
716    }
717
718    pub async fn terminate_server(&mut self, peer_id: usize) -> Result<()> {
719        let Some((_, fedimintd)) = self.members.remove_entry(&peer_id) else {
720            bail!("fedimintd-{peer_id} does not exist");
721        };
722        fedimintd.terminate().await?;
723        Ok(())
724    }
725
726    pub async fn await_server_terminated(&mut self, peer_id: usize) -> Result<()> {
727        let Some(fedimintd) = self.members.get_mut(&peer_id) else {
728            bail!("fedimintd-{peer_id} does not exist");
729        };
730        fedimintd.await_terminated().await?;
731        self.members.remove(&peer_id);
732        Ok(())
733    }
734
735    /// Starts all peers not currently running.
736    pub async fn start_all_servers(&mut self, process_mgr: &ProcessManager) -> Result<()> {
737        info!("starting all servers");
738        let fed_size = process_mgr.globals.FM_FED_SIZE;
739        for peer_id in 0..fed_size {
740            if self.members.contains_key(&peer_id) {
741                continue;
742            }
743            self.start_server(process_mgr, peer_id).await?;
744        }
745        self.await_all_peers().await?;
746        Ok(())
747    }
748
749    /// Terminates all running peers.
750    pub async fn terminate_all_servers(&mut self) -> Result<()> {
751        info!("terminating all servers");
752        let running_peer_ids: Vec<_> = self.members.keys().copied().collect();
753        for peer_id in running_peer_ids {
754            self.terminate_server(peer_id).await?;
755        }
756        Ok(())
757    }
758
759    /// Coordinated shutdown of all peers that restart using the provided
760    /// `bin_path`. Returns `Ok()` once all peers are online.
761    ///
762    /// Staggering the restart more closely simulates upgrades in the wild.
763    pub async fn restart_all_staggered_with_bin(
764        &mut self,
765        process_mgr: &ProcessManager,
766        bin_path: &PathBuf,
767    ) -> Result<()> {
768        let fed_size = process_mgr.globals.FM_FED_SIZE;
769
770        // ensure all peers are online
771        self.start_all_servers(process_mgr).await?;
772
773        // staggered shutdown of peers
774        while self.num_members() > 0 {
775            self.terminate_server(self.num_members() - 1).await?;
776            if self.num_members() > 0 {
777                fedimint_core::task::sleep_in_test(
778                    "waiting to shutdown remaining peers",
779                    Duration::from_secs(10),
780                )
781                .await;
782            }
783        }
784
785        // TODO: Audit that the environment access only happens in single-threaded code.
786        unsafe { std::env::set_var("FM_FEDIMINTD_BASE_EXECUTABLE", bin_path) };
787
788        // staggered restart
789        for peer_id in 0..fed_size {
790            self.start_server(process_mgr, peer_id).await?;
791            if peer_id < fed_size - 1 {
792                fedimint_core::task::sleep_in_test(
793                    "waiting to restart remaining peers",
794                    Duration::from_secs(10),
795                )
796                .await;
797            }
798        }
799
800        self.await_all_peers().await?;
801
802        let fedimintd_version = crate::util::FedimintdCmd::version_or_default().await;
803        info!("upgraded fedimintd to version: {}", fedimintd_version);
804        Ok(())
805    }
806
807    pub async fn restart_all_with_bin(
808        &mut self,
809        process_mgr: &ProcessManager,
810        bin_path: &PathBuf,
811    ) -> Result<()> {
812        // get the version we're upgrading to, temporarily updating the fedimintd path
813        let current_fedimintd_path = std::env::var("FM_FEDIMINTD_BASE_EXECUTABLE")?;
814        // TODO: Audit that the environment access only happens in single-threaded code.
815        unsafe { std::env::set_var("FM_FEDIMINTD_BASE_EXECUTABLE", bin_path) };
816        // TODO: Audit that the environment access only happens in single-threaded code.
817        unsafe { std::env::set_var("FM_FEDIMINTD_BASE_EXECUTABLE", current_fedimintd_path) };
818
819        self.restart_all_staggered_with_bin(process_mgr, bin_path)
820            .await
821    }
822
823    pub async fn degrade_federation(&mut self, process_mgr: &ProcessManager) -> Result<()> {
824        let fed_size = process_mgr.globals.FM_FED_SIZE;
825        let offline_nodes = process_mgr.globals.FM_OFFLINE_NODES;
826        anyhow::ensure!(
827            fed_size > 3 * offline_nodes,
828            "too many offline nodes ({offline_nodes}) to reach consensus"
829        );
830
831        while self.num_members() > fed_size - offline_nodes {
832            self.terminate_server(self.num_members() - 1).await?;
833        }
834
835        if offline_nodes > 0 {
836            info!(fed_size, offline_nodes, "federation is degraded");
837        }
838        Ok(())
839    }
840
841    pub async fn send_to_address(&self, address: String, amount: u64) -> Result<()> {
842        self.bitcoind.send_to(address, amount).await?;
843
844        self.bitcoind.mine_blocks(21).await?;
845
846        Ok(())
847    }
848
849    pub async fn pegin_client_no_wait(&self, amount: u64, client: &Client) -> Result<String> {
850        let deposit_fees_msat = self.deposit_fees()?.msats;
851        assert_eq!(
852            deposit_fees_msat % 1000,
853            0,
854            "Deposit fees expected to be whole sats in test suite"
855        );
856        let deposit_fees = deposit_fees_msat / 1000;
857        info!(amount, deposit_fees, "Pegging-in client funds");
858
859        // For walletv1 this is the deposit operation id; for walletv2 it is the
860        // event log position to wait for the receive from.
861        let (address, handle) = client.get_deposit_addr().await?;
862
863        self.bitcoind
864            .send_to(address, amount + deposit_fees)
865            .await?;
866        self.bitcoind.mine_blocks(21).await?;
867
868        Ok(handle)
869    }
870
871    pub async fn pegin_client(&self, amount: u64, client: &Client) -> Result<()> {
872        let walletv2_await_receive = crate::util::supports_wallet_v2()
873            && crate::util::FedimintCli::version_or_default().await >= *VERSION_0_12_0_ALPHA;
874
875        // Legacy walletv2 (<= 0.11) auto-claims deposits with no way to wait on
876        // them directly, so capture the balance before the deposit to wait for
877        // it to increase.
878        let initial_balance = if crate::util::supports_wallet_v2() && !walletv2_await_receive {
879            Some(client.balance().await?)
880        } else {
881            None
882        };
883
884        let handle = self.pegin_client_no_wait(amount, client).await?;
885
886        if walletv2_await_receive {
887            // `handle` is the event log position from which to wait for the
888            // receive.
889            client.await_receive(&handle).await?;
890        } else if let Some(initial) = initial_balance {
891            // Wait for the balance to increase. We expect slightly less than
892            // `amount` due to mint module fees when creating ecash notes.
893            let expected_balance = initial + (amount * 1000 * 9 / 10);
894            client.await_balance(expected_balance).await?;
895        } else {
896            client.await_deposit(&handle).await?;
897        }
898
899        Ok(())
900    }
901
902    /// Initiates multiple peg-ins to the same federation for the set of
903    /// gateways to save on mining blocks in parallel.
904    pub async fn pegin_gateways(
905        &self,
906        amount: u64,
907        gateways: Vec<&super::gatewayd::Gatewayd>,
908    ) -> Result<()> {
909        let deposit_fees_msat = self.deposit_fees()?.msats;
910        assert_eq!(
911            deposit_fees_msat % 1000,
912            0,
913            "Deposit fees expected to be whole sats in test suite"
914        );
915        let deposit_fees = deposit_fees_msat / 1000;
916        info!(amount, deposit_fees, "Pegging-in gateway funds");
917        let fed_id = self.calculate_federation_id();
918
919        let uses_walletv2 = crate::util::supports_wallet_v2();
920
921        // Gateways record the walletv2 receive events in their payment log
922        // (since v0.11), which lets devimint wait for each deposit to actually
923        // be claimed before polling balances. Reading the log filtered to
924        // those events needs `payment-log --event-kinds` (since v0.10).
925        let walletv2_claim_wait = uses_walletv2
926            && crate::util::GatewayCli::version_or_default().await >= *VERSION_0_10_0_ALPHA;
927
928        // For walletv2, capture initial balances since deposits are auto-claimed
929        // and we need to check balance change rather than absolute balance
930        // (same approach as pegin_client).
931        let mut initial_balances = Vec::new();
932        if uses_walletv2 {
933            for gw in &gateways {
934                let balance = gw
935                    .client()
936                    .ecash_balance(fed_id.clone())
937                    .await
938                    .expect("failed to fetch initial gateway balance");
939                initial_balances.push(balance);
940            }
941        }
942
943        let mut gateway_deposit_addrs = Vec::new();
944        for gw in gateways.clone() {
945            let pegin_addr = gw.client().get_pegin_addr(&fed_id).await?;
946            debug!(
947                gateway = %gw.gw_name,
948                ln = %gw.ln.ln_type(),
949                address = %pegin_addr,
950                amount_sats = amount + deposit_fees,
951                uses_walletv2,
952                "Sending gateway pegin"
953            );
954            self.bitcoind
955                .send_to(pegin_addr.clone(), amount + deposit_fees)
956                .await?;
957            gateway_deposit_addrs.push(pegin_addr);
958        }
959
960        let pegin_start = Instant::now();
961        self.bitcoind.mine_blocks(21).await?;
962        let bitcoind_block_height: u64 = self.bitcoind.get_block_count().await? - 1;
963        debug!(
964            bitcoind_block_height,
965            elapsed_ms = %pegin_start.elapsed().as_millis(),
966            "Mined gateway pegin blocks"
967        );
968
969        if walletv2_claim_wait {
970            // Block catch-up and deposit scanning are the slow parts of a
971            // gateway pegin and have pushed past the default 60s poll budget
972            // on degraded CI federations, so the claim wait gets a longer
973            // timeout of its own.
974            const CLAIM_TIMEOUT: Duration = Duration::from_secs(180);
975            const PAYMENT_LOG_PAGE_SIZE: usize = 100;
976
977            try_join_all(
978                gateways
979                    .iter()
980                    .enumerate()
981                    .filter(|(_, gw)| gw.gatewayd_version >= *VERSION_0_11_0_ALPHA)
982                    .map(|(i, gw)| {
983                        let fed_id = fed_id.clone();
984                        let gateway_name = gw.gw_name.clone();
985                        let gateway_ln = gw.ln.ln_type().to_string();
986                        let deposit_address = gateway_deposit_addrs[i].clone();
987                        poll_with_timeout("gateway pegin claim", CLAIM_TIMEOUT, move || {
988                            let fed_id = fed_id.clone();
989                            let gateway_name = gateway_name.clone();
990                            let gateway_ln = gateway_ln.clone();
991                            let deposit_address = deposit_address.clone();
992                            async move {
993                                let events = gw
994                                    .client()
995                                    .payment_log(
996                                        &fed_id,
997                                        &["payment-receive", "payment-receive-update"],
998                                        PAYMENT_LOG_PAGE_SIZE,
999                                    )
1000                                    .await
1001                                    .map_err(ControlFlow::Continue)?;
1002
1003                                if !walletv2_receive_claimed(&events, &deposit_address) {
1004                                    return Err(ControlFlow::Continue(anyhow::anyhow!(
1005                                        "Gateway {gateway_name} ({gateway_ln}) has not claimed the deposit to {deposit_address} yet"
1006                                    )));
1007                                }
1008
1009                                debug!(
1010                                    gateway = %gateway_name,
1011                                    ln = %gateway_ln,
1012                                    address = %deposit_address,
1013                                    elapsed_ms = %pegin_start.elapsed().as_millis(),
1014                                    "Walletv2 gateway pegin claimed"
1015                                );
1016
1017                                Ok(())
1018                            }
1019                        })
1020                    }),
1021            )
1022            .await?;
1023        }
1024
1025        try_join_all(gateways.into_iter().enumerate().map(|(i, gw)| {
1026            let initial_balance = if uses_walletv2 {
1027                initial_balances[i]
1028            } else {
1029                0
1030            };
1031            let gateway_name = gw.gw_name.clone();
1032            let gateway_ln = gw.ln.ln_type().to_string();
1033            let gateway_id = gw.gateway_id.clone();
1034            let gateway_index = gw.gateway_index;
1035            let deposit_address = gateway_deposit_addrs[i].clone();
1036            let fed_id = fed_id.clone();
1037            poll("gateway pegin", move || {
1038                let fed_id = fed_id.clone();
1039                let gateway_name = gateway_name.clone();
1040                let gateway_ln = gateway_ln.clone();
1041                let gateway_id = gateway_id.clone();
1042                let deposit_address = deposit_address.clone();
1043                async move {
1044                    let gw_info = gw
1045                        .client()
1046                        .get_info()
1047                        .await
1048                        .map_err(ControlFlow::Continue)?;
1049
1050                    let block_height: u64 = if gw.gatewayd_version < *VERSION_0_10_0_ALPHA {
1051                        gw_info["block_height"]
1052                            .as_u64()
1053                            .expect("Could not parse block height")
1054                    } else {
1055                        gw_info["lightning_info"]["connected"]["block_height"]
1056                            .as_u64()
1057                            .expect("Could not parse block height")
1058                    };
1059
1060                    if bitcoind_block_height != block_height {
1061                        debug!(
1062                            gateway = %gateway_name,
1063                            ln = %gateway_ln,
1064                            bitcoind_block_height,
1065                            gateway_block_height = block_height,
1066                            elapsed_ms = %pegin_start.elapsed().as_millis(),
1067                            "Waiting for gateway pegin block sync"
1068                        );
1069                        return Err(std::ops::ControlFlow::Continue(anyhow::anyhow!(
1070                            "Gateway {gateway_name} ({gateway_id}, index {gateway_index}) block height {block_height} has not reached bitcoind block height {bitcoind_block_height}"
1071                        )));
1072                    }
1073
1074                    let gateway_balance = gw
1075                        .client()
1076                        .ecash_balance(fed_id)
1077                        .await
1078                        .map_err(ControlFlow::Continue)?;
1079
1080                    if uses_walletv2 {
1081                        // Walletv2: check balance increased by approximately
1082                        // the expected amount. Use 90% threshold to account
1083                        // for mintv2 fees (same as pegin_client).
1084                        let expected = initial_balance + (amount * 1000 * 9 / 10);
1085                        debug!(
1086                            gateway = %gateway_name,
1087                            ln = %gateway_ln,
1088                            gateway_balance_msats = gateway_balance,
1089                            expected_msats = expected,
1090                            initial_balance_msats = initial_balance,
1091                            elapsed_ms = %pegin_start.elapsed().as_millis(),
1092                            "Checked walletv2 gateway pegin balance"
1093                        );
1094                        if expected <= gateway_balance {
1095                            Ok(())
1096                        } else {
1097                            Err(ControlFlow::Continue(anyhow::anyhow!(
1098                                "Gateway {gateway_name} ({gateway_id}, index {gateway_index}) balance {gateway_balance} has not reached expected {expected} for deposit address {deposit_address} (initial: {initial_balance})"
1099                            )))
1100                        }
1101                    } else {
1102                        poll_almost_equal!(gateway_balance, amount * 1000)
1103                    }
1104                }
1105            })
1106        }))
1107        .await?;
1108
1109        Ok(())
1110    }
1111
1112    /// Initiates multiple peg-outs from the same federation for the set of
1113    /// gateways to save on mining blocks in parallel.
1114    pub async fn pegout_gateways(
1115        &self,
1116        amount: u64,
1117        gateways: Vec<&super::gatewayd::Gatewayd>,
1118    ) -> Result<()> {
1119        info!(amount, "Pegging-out gateway funds");
1120        let fed_id = self.calculate_federation_id();
1121        let mut peg_outs: BTreeMap<LightningNodeType, (Amount, WithdrawResponse)> = BTreeMap::new();
1122        for gw in gateways.clone() {
1123            let prev_fed_ecash_balance = gw
1124                .client()
1125                .get_balances()
1126                .await?
1127                .ecash_balances
1128                .into_iter()
1129                .find(|fed| fed.federation_id.to_string() == fed_id)
1130                .expect("Gateway has not joined federation")
1131                .ecash_balance_msats;
1132
1133            let pegout_address = self.bitcoind.get_new_address().await?;
1134            let response = gw
1135                .client()
1136                .pegout(fed_id.clone(), amount, pegout_address)
1137                .await?;
1138            peg_outs.insert(gw.ln.ln_type(), (prev_fed_ecash_balance, response));
1139        }
1140        self.bitcoind.mine_blocks(21).await?;
1141
1142        try_join_all(
1143            peg_outs
1144                .values()
1145                .map(|(_, pegout)| self.bitcoind.poll_get_transaction(pegout.txid)),
1146        )
1147        .await?;
1148
1149        for gw in gateways.clone() {
1150            let after_fed_ecash_balance = gw
1151                .client()
1152                .get_balances()
1153                .await?
1154                .ecash_balances
1155                .into_iter()
1156                .find(|fed| fed.federation_id.to_string() == fed_id)
1157                .expect("Gateway has not joined federation")
1158                .ecash_balance_msats;
1159
1160            let ln_type = gw.ln.ln_type();
1161            let prev_balance = peg_outs
1162                .get(&ln_type)
1163                .expect("peg out does not exist")
1164                .0
1165                .msats;
1166            let fees = peg_outs
1167                .get(&ln_type)
1168                .expect("peg out does not exist")
1169                .1
1170                .fees;
1171            let total_fee = fees.amount().to_sat() * 1000;
1172            // Walletv2 charges a module fee on top of the on-chain fee:
1173            // 100 sats base + 1% of amount (amount is in msats)
1174            let tolerance = if crate::util::supports_wallet_v2() {
1175                let amount_sats = amount / 1000;
1176                let module_fee_sats = 100 + amount_sats / 100;
1177                module_fee_sats * 1000 + 2000
1178            } else if crate::util::supports_mint_v2() {
1179                4000
1180            } else {
1181                2000
1182            };
1183            crate::util::almost_equal(
1184                after_fed_ecash_balance.msats,
1185                prev_balance - amount - total_fee,
1186                tolerance,
1187            )
1188            .map_err(|e| {
1189                anyhow::anyhow!(
1190                    "new balance did not equal prev balance minus withdraw_amount minus fees: {e}"
1191                )
1192            })?;
1193        }
1194
1195        Ok(())
1196    }
1197
1198    pub fn calculate_federation_id(&self) -> String {
1199        self.client_config()
1200            .unwrap()
1201            .global
1202            .calculate_federation_id()
1203            .to_string()
1204    }
1205
1206    pub async fn await_block_sync(&self) -> Result<u64> {
1207        let finality_delay = self.get_finality_delay()?;
1208        let block_count = self.bitcoind.get_block_count().await?;
1209        let expected = block_count.saturating_sub(finality_delay.into());
1210
1211        if crate::util::supports_wallet_v2() {
1212            // Walletv2 doesn't have `dev wait-block-count`, poll using CLI instead
1213            let client = self.internal_client().await?;
1214            loop {
1215                let value = cmd!(client, "module", "walletv2", "info", "block-count")
1216                    .out_json()
1217                    .await?;
1218                let current: u64 = serde_json::from_value(value)?;
1219                if current >= expected {
1220                    break;
1221                }
1222                fedimint_core::task::sleep_in_test(
1223                    format!("Waiting for consensus block count to reach {expected}"),
1224                    std::time::Duration::from_secs(1),
1225                )
1226                .await;
1227            }
1228        } else {
1229            cmd!(
1230                self.internal_client().await?,
1231                "dev",
1232                "wait-block-count",
1233                expected
1234            )
1235            .run()
1236            .await?;
1237        }
1238
1239        Ok(expected)
1240    }
1241
1242    fn get_finality_delay(&self) -> Result<u32, anyhow::Error> {
1243        // Walletv2 uses a constant finality delay
1244        if crate::util::supports_wallet_v2() {
1245            return Ok(fedimint_walletv2_server::CONFIRMATION_FINALITY_DELAY as u32);
1246        }
1247
1248        let wallet_instance_id = self.module_instance_id_by_kind(&fedimint_wallet_client::KIND)?;
1249        let client_config = &self.client_config()?;
1250        let wallet_cfg = client_config
1251            .modules
1252            .get(&wallet_instance_id)
1253            .context("wallet module not found")?
1254            .clone()
1255            .redecode_raw(&ModuleDecoderRegistry::new([(
1256                wallet_instance_id,
1257                fedimint_wallet_client::KIND,
1258                fedimint_wallet_client::WalletModuleTypes::decoder(),
1259            )]))?;
1260        let wallet_cfg: &WalletClientConfig = wallet_cfg.cast()?;
1261
1262        let finality_delay = wallet_cfg.finality_delay;
1263        Ok(finality_delay)
1264    }
1265
1266    pub async fn await_gateways_registered(&self) -> Result<()> {
1267        // `list-gateways` is an LNv1 concept: gateways register with the LNv1
1268        // module and the client lists them. LNv2 instead addresses gateways
1269        // directly and vets them via an explicit consensus item, so there is
1270        // nothing to poll here when the LNv1 module isn't present. The gateway
1271        // connection itself is awaited separately (via `connect_fed`).
1272        if !crate::util::supports_lnv1() {
1273            return Ok(());
1274        }
1275
1276        let start_time = Instant::now();
1277        debug!(target: LOG_DEVIMINT, "Awaiting LN gateways registration");
1278
1279        poll("gateways registered", || async {
1280            let num_gateways = cmd!(
1281                self.internal_client()
1282                    .await
1283                    .map_err(ControlFlow::Continue)?,
1284                "list-gateways"
1285            )
1286            .out_json()
1287            .await
1288            .map_err(ControlFlow::Continue)?
1289            .as_array()
1290            .context("invalid output")
1291            .map_err(ControlFlow::Break)?
1292            .len();
1293
1294            // After version v0.10.0, the LND gateway will register twice. Once for the HTTP
1295            // server, and once for the iroh endpoint.
1296            let expected_gateways =
1297                if crate::util::Gatewayd::version_or_default().await < *VERSION_0_10_0_ALPHA {
1298                    1
1299                } else {
1300                    2
1301                };
1302
1303            poll_eq!(num_gateways, expected_gateways)
1304        })
1305        .await?;
1306        debug!(target: LOG_DEVIMINT,
1307            elapsed_ms = %start_time.elapsed().as_millis(),
1308            "Gateways registered");
1309        Ok(())
1310    }
1311
1312    pub async fn await_all_peers(&self) -> Result<()> {
1313        let (module_name, endpoint) = if crate::util::supports_wallet_v2() {
1314            ("walletv2", "consensus_block_count")
1315        } else {
1316            ("wallet", "block_count")
1317        };
1318        poll("Waiting for all peers to be online", || async {
1319            cmd!(
1320                self.internal_client()
1321                    .await
1322                    .map_err(ControlFlow::Continue)?,
1323                "dev",
1324                "api",
1325                "--module",
1326                module_name,
1327                endpoint
1328            )
1329            .run()
1330            .await
1331            .map_err(ControlFlow::Continue)?;
1332            Ok(())
1333        })
1334        .await
1335    }
1336
1337    pub async fn await_peer(&self, peer_id: usize) -> Result<()> {
1338        poll("Waiting for all peers to be online", || async {
1339            cmd!(
1340                self.internal_client()
1341                    .await
1342                    .map_err(ControlFlow::Continue)?,
1343                "dev",
1344                "api",
1345                "--peer-id",
1346                peer_id,
1347                "--module",
1348                "wallet",
1349                "block_count"
1350            )
1351            .run()
1352            .await
1353            .map_err(ControlFlow::Continue)?;
1354            Ok(())
1355        })
1356        .await
1357    }
1358
1359    /// Mines enough blocks to finalize mempool transactions, then waits for
1360    /// federation to process finalized blocks.
1361    ///
1362    /// ex:
1363    ///   tx submitted to mempool at height 100
1364    ///   finality delay = 10
1365    ///   mine finality delay blocks + 1 => new height 111
1366    ///   tx included in block 101
1367    ///   highest finalized height = 111 - 10 = 101
1368    pub async fn finalize_mempool_tx(&self) -> Result<()> {
1369        let finality_delay = self.get_finality_delay()?;
1370        let blocks_to_mine = finality_delay + 1;
1371        self.bitcoind.mine_blocks(blocks_to_mine.into()).await?;
1372        self.await_block_sync().await?;
1373        Ok(())
1374    }
1375
1376    pub async fn mine_then_wait_blocks_sync(&self, blocks: u64) -> Result<()> {
1377        self.bitcoind.mine_blocks(blocks).await?;
1378        self.await_block_sync().await?;
1379        Ok(())
1380    }
1381
1382    pub fn num_members(&self) -> usize {
1383        self.members.len()
1384    }
1385
1386    pub fn member_ids(&self) -> impl Iterator<Item = PeerId> + '_ {
1387        self.members
1388            .keys()
1389            .map(|&peer_id| PeerId::from(peer_id as u16))
1390    }
1391}
1392
1393/// Whether a gateway `payment-log` response contains a successful walletv2
1394/// receive for `deposit_address`.
1395///
1396/// An aborted receive is reprocessed into a new receive operation for the
1397/// same still-unspent deposit, so a single address can have several receive
1398/// events; the deposit is claimed once any of their operations reports a
1399/// `Success` update.
1400fn walletv2_receive_claimed(events: &serde_json::Value, deposit_address: &str) -> bool {
1401    let Some(events) = events.as_array() else {
1402        return false;
1403    };
1404
1405    let is_walletv2 = |event: &serde_json::Value| event["module"]["kind"] == "walletv2";
1406
1407    let receive_operations = events
1408        .iter()
1409        .filter(|event| {
1410            is_walletv2(event)
1411                && event["kind"] == "payment-receive"
1412                && event["payload"]["address"] == deposit_address
1413        })
1414        .map(|event| &event["payload"]["operation_id"])
1415        .collect::<Vec<_>>();
1416
1417    events.iter().any(|event| {
1418        is_walletv2(event)
1419            && event["kind"] == "payment-receive-update"
1420            && event["payload"]["status"] == "Success"
1421            && receive_operations.contains(&&event["payload"]["operation_id"])
1422    })
1423}
1424
1425#[derive(Clone)]
1426pub struct Fedimintd {
1427    _bitcoind: Bitcoind,
1428    process: ProcessHandle,
1429}
1430
1431impl Fedimintd {
1432    pub async fn new(
1433        process_mgr: &ProcessManager,
1434        bitcoind: Bitcoind,
1435        peer_id: usize,
1436        env: &vars::Fedimintd,
1437        fed_name: String,
1438    ) -> Result<Self> {
1439        debug!(target: LOG_DEVIMINT, "Starting fedimintd-{fed_name}-{peer_id}");
1440        let process = process_mgr
1441            .spawn_daemon(
1442                &format!("fedimintd-{fed_name}-{peer_id}"),
1443                cmd!(FedimintdCmd).envs(env.vars()),
1444            )
1445            .await?;
1446
1447        Ok(Self {
1448            _bitcoind: bitcoind,
1449            process,
1450        })
1451    }
1452
1453    pub async fn terminate(self) -> Result<()> {
1454        self.process.terminate().await
1455    }
1456
1457    pub async fn await_terminated(&self) -> Result<()> {
1458        self.process.await_terminated().await
1459    }
1460}
1461
1462pub async fn run_cli_dkg_v2(endpoints: BTreeMap<PeerId, String>) -> Result<()> {
1463    // Parallelize setup status checks
1464    let status_futures = endpoints.values().map(|endpoint| {
1465        let endpoint = endpoint.clone();
1466        async move {
1467            let status = poll("awaiting-setup-status-awaiting-local-params", || async {
1468                crate::util::FedimintCli
1469                    .setup_status(&API_AUTH, &endpoint)
1470                    .await
1471                    .map_err(ControlFlow::Continue)
1472            })
1473            .await
1474            .unwrap();
1475
1476            assert_eq!(status, SetupStatus::AwaitingLocalParams);
1477        }
1478    });
1479    join_all(status_futures).await;
1480
1481    debug!(target: LOG_DEVIMINT, "Setting local parameters...");
1482
1483    // Parallelize setting local parameters
1484    // --federation-size is only supported by fedimint-cli >= 0.11.0-alpha
1485    let federation_size =
1486        if crate::util::FedimintCli::version_or_default().await >= *VERSION_0_11_0_ALPHA {
1487            Some(endpoints.len())
1488        } else {
1489            None
1490        };
1491    let local_params_futures = endpoints.iter().map(|(peer, endpoint)| {
1492        let peer = *peer;
1493        let endpoint = endpoint.clone();
1494        async move {
1495            let info = if peer.to_usize() == 0 {
1496                crate::util::FedimintCli
1497                    .set_local_params_leader(&peer, &API_AUTH, &endpoint, federation_size)
1498                    .await
1499            } else {
1500                crate::util::FedimintCli
1501                    .set_local_params_follower(&peer, &API_AUTH, &endpoint)
1502                    .await
1503            };
1504            info.map(|i| (peer, i))
1505        }
1506    });
1507    let connection_info: BTreeMap<_, _> = try_join_all(local_params_futures)
1508        .await?
1509        .into_iter()
1510        .collect();
1511
1512    debug!(target: LOG_DEVIMINT, "Exchanging peer connection info...");
1513
1514    // Parallelize peer addition - flatten the nested loop into a single parallel
1515    // operation
1516    let add_peer_futures = connection_info.iter().flat_map(|(peer, info)| {
1517        endpoints
1518            .iter()
1519            .filter(move |(p, _)| *p != peer)
1520            .map(move |(_, endpoint)| {
1521                let endpoint = endpoint.clone();
1522                let info = info.clone();
1523                async move {
1524                    crate::util::FedimintCli
1525                        .add_peer(&info, &API_AUTH, &endpoint)
1526                        .await
1527                }
1528            })
1529    });
1530    try_join_all(add_peer_futures).await?;
1531
1532    debug!(target: LOG_DEVIMINT, "Starting DKG...");
1533
1534    // Parallelize DKG start
1535    let start_dkg_futures = endpoints.values().map(|endpoint| {
1536        let endpoint = endpoint.clone();
1537        async move {
1538            crate::util::FedimintCli
1539                .start_dkg(&API_AUTH, &endpoint)
1540                .await
1541        }
1542    });
1543    try_join_all(start_dkg_futures).await?;
1544
1545    Ok(())
1546}