Skip to main content

devimint/
federation.rs

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