Skip to main content

devimint/
tests.rs

1use std::collections::{BTreeMap, HashSet};
2use std::io::Write;
3use std::ops::ControlFlow;
4use std::path::{Path, PathBuf};
5use std::str::FromStr;
6use std::time::{Duration, Instant};
7use std::{env, ffi};
8
9use anyhow::{Context, Result, anyhow, bail};
10use bitcoin::Txid;
11use clap::Subcommand;
12use fedimint_core::core::OperationId;
13use fedimint_core::encoding::{Decodable, Encodable};
14use fedimint_core::envs::{FM_DISABLE_BASE_FEES_ENV, FM_ENABLE_MODULE_LNV2_ENV, is_env_var_set};
15use fedimint_core::module::registry::ModuleRegistry;
16use fedimint_core::net::api_announcement::SignedApiAnnouncement;
17use fedimint_core::task::block_in_place;
18use fedimint_core::util::backoff_util::aggressive_backoff;
19use fedimint_core::util::{retry, write_overwrite_async};
20use fedimint_core::{Amount, PeerId};
21use fedimint_ln_client::LightningPaymentOutcome;
22use fedimint_ln_client::cli::LnInvoiceResponse;
23use fedimint_ln_server::common::lightning_invoice::Bolt11Invoice;
24use fedimint_lnv2_client::FinalSendOperationState;
25use fedimint_logging::LOG_DEVIMINT;
26use fedimint_testing_core::node_type::LightningNodeType;
27use futures::future::try_join_all;
28use serde_json::json;
29use substring::Substring;
30use tokio::net::TcpStream;
31use tokio::{fs, try_join};
32use tracing::{debug, error, info};
33
34use crate::cli::{CommonArgs, cleanup_on_exit, exec_user_command, setup};
35use crate::envs::{FM_DATA_DIR_ENV, FM_DEVIMINT_RUN_DEPRECATED_TESTS_ENV};
36use crate::federation::Client;
37use crate::util::{LoadTestTool, ProcessManager, almost_equal, poll};
38use crate::version_constants::{VERSION_0_10_0_ALPHA, VERSION_0_11_0_ALPHA, VERSION_0_12_0_ALPHA};
39use crate::{DevFed, Gatewayd, LightningNode, Lnd, cmd, dev_fed};
40
41pub struct Stats {
42    pub min: Duration,
43    pub avg: Duration,
44    pub median: Duration,
45    pub p90: Duration,
46    pub max: Duration,
47    pub sum: Duration,
48}
49
50impl std::fmt::Display for Stats {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        write!(f, "min: {:.1}s", self.min.as_secs_f32())?;
53        write!(f, ", avg: {:.1}s", self.avg.as_secs_f32())?;
54        write!(f, ", median: {:.1}s", self.median.as_secs_f32())?;
55        write!(f, ", p90: {:.1}s", self.p90.as_secs_f32())?;
56        write!(f, ", max: {:.1}s", self.max.as_secs_f32())?;
57        write!(f, ", sum: {:.1}s", self.sum.as_secs_f32())?;
58        Ok(())
59    }
60}
61
62pub fn stats_for(mut v: Vec<Duration>) -> Stats {
63    assert!(!v.is_empty());
64    v.sort();
65    let n = v.len();
66    let min = v.first().unwrap().to_owned();
67    let max = v.iter().last().unwrap().to_owned();
68    let median = v[n / 2];
69    let sum: Duration = v.iter().sum();
70    let avg = sum / n as u32;
71    let p90 = v[(n as f32 * 0.9) as usize];
72    Stats {
73        min,
74        avg,
75        median,
76        p90,
77        max,
78        sum,
79    }
80}
81
82pub async fn log_binary_versions() -> Result<()> {
83    let fedimint_cli_version = cmd!(crate::util::get_fedimint_cli_path(), "--version")
84        .out_string()
85        .await?;
86    info!(?fedimint_cli_version);
87    let fedimint_cli_version_hash = cmd!(crate::util::get_fedimint_cli_path(), "version-hash")
88        .out_string()
89        .await?;
90    info!(?fedimint_cli_version_hash);
91    let gateway_cli_version = cmd!(crate::util::get_gateway_cli_path(), "--version")
92        .out_string()
93        .await?;
94    info!(?gateway_cli_version);
95    let gateway_cli_version_hash = cmd!(crate::util::get_gateway_cli_path(), "version-hash")
96        .out_string()
97        .await?;
98    info!(?gateway_cli_version_hash);
99    let fedimintd_version_hash = cmd!(crate::util::FedimintdCmd, "version-hash")
100        .out_string()
101        .await?;
102    info!(?fedimintd_version_hash);
103    let gatewayd_version_hash = cmd!(crate::util::Gatewayd, "version-hash")
104        .out_string()
105        .await?;
106    info!(?gatewayd_version_hash);
107    Ok(())
108}
109
110pub async fn latency_tests(
111    dev_fed: DevFed,
112    r#type: LatencyTest,
113    upgrade_clients: Option<&UpgradeClients>,
114    iterations: usize,
115    assert_thresholds: bool,
116) -> Result<()> {
117    log_binary_versions().await?;
118
119    let DevFed {
120        fed,
121        gw_lnd,
122        gw_ldk,
123        ..
124    } = dev_fed;
125
126    let max_p90_factor = 10.0;
127    let p90_median_factor = 10;
128
129    let client = match upgrade_clients {
130        Some(c) => match r#type {
131            LatencyTest::Reissue => c.reissue_client.clone(),
132            LatencyTest::LnSend => c.ln_send_client.clone(),
133            LatencyTest::LnReceive => c.ln_receive_client.clone(),
134            LatencyTest::FmPay => c.fm_pay_client.clone(),
135            LatencyTest::Restore => bail!("no reusable upgrade client for restore"),
136        },
137        None => fed.new_joined_client("latency-tests-client").await?,
138    };
139
140    let initial_balance_sats = 100_000_000;
141    fed.pegin_client(initial_balance_sats, &client).await?;
142
143    let lnd_gw_id = gw_lnd.gateway_id.clone();
144
145    let gw_lnd = gw_lnd.client();
146    let gw_ldk = gw_ldk.client();
147
148    match r#type {
149        LatencyTest::Reissue => {
150            info!("Testing latency of reissue");
151            let mut reissues = Vec::with_capacity(iterations);
152            let amount_per_iteration_msats =
153                // use a highest 2^-1 amount that fits, to try to use as many notes as possible
154                ((initial_balance_sats * 1000 / iterations as u64).next_power_of_two() >> 1) - 1;
155            for _ in 0..iterations {
156                let notes = cmd!(client, "spend", amount_per_iteration_msats.to_string())
157                    .out_json()
158                    .await?["notes"]
159                    .as_str()
160                    .context("note must be a string")?
161                    .to_owned();
162
163                let start_time = Instant::now();
164                cmd!(client, "reissue", notes).run().await?;
165                reissues.push(start_time.elapsed());
166            }
167            let reissue_stats = stats_for(reissues);
168            println!("### LATENCY REISSUE: {reissue_stats}");
169
170            if assert_thresholds {
171                assert!(reissue_stats.median < Duration::from_secs(10));
172                assert!(reissue_stats.p90 < reissue_stats.median * p90_median_factor);
173                assert!(
174                    reissue_stats.max.as_secs_f64()
175                        < reissue_stats.p90.as_secs_f64() * max_p90_factor
176                );
177            }
178        }
179        LatencyTest::LnSend => {
180            info!("Testing latency of ln send");
181            let mut ln_sends = Vec::with_capacity(iterations);
182            for _ in 0..iterations {
183                let invoice = gw_ldk.create_invoice(1_000_000).await?;
184                let start_time = Instant::now();
185                ln_pay(&client, invoice.to_string(), lnd_gw_id.clone()).await?;
186                gw_ldk
187                    .wait_bolt11_invoice(invoice.payment_hash().consensus_encode_to_vec())
188                    .await?;
189                ln_sends.push(start_time.elapsed());
190
191                if crate::util::supports_lnv2() {
192                    let invoice = gw_lnd.create_invoice(1_000_000).await?;
193
194                    let start_time = Instant::now();
195
196                    lnv2_send(&client, &gw_ldk.address(), &invoice.to_string()).await?;
197
198                    ln_sends.push(start_time.elapsed());
199                }
200            }
201            let ln_sends_stats = stats_for(ln_sends);
202            println!("### LATENCY LN SEND: {ln_sends_stats}");
203
204            if assert_thresholds {
205                assert!(ln_sends_stats.median < Duration::from_secs(10));
206                assert!(ln_sends_stats.p90 < ln_sends_stats.median * p90_median_factor);
207                assert!(
208                    ln_sends_stats.max.as_secs_f64()
209                        < ln_sends_stats.p90.as_secs_f64() * max_p90_factor
210                );
211            }
212        }
213        LatencyTest::LnReceive => {
214            info!("Testing latency of ln receive");
215            let mut ln_receives = Vec::with_capacity(iterations);
216
217            // give lnd some funds
218            let invoice = gw_ldk.create_invoice(10_000_000).await?;
219            ln_pay(&client, invoice.to_string(), lnd_gw_id.clone()).await?;
220
221            for _ in 0..iterations {
222                let invoice = ln_invoice(
223                    &client,
224                    Amount::from_msats(100_000),
225                    "latency-over-lnd-gw".to_string(),
226                    lnd_gw_id.clone(),
227                )
228                .await?
229                .invoice;
230
231                let start_time = Instant::now();
232                gw_ldk
233                    .pay_invoice(
234                        Bolt11Invoice::from_str(&invoice).expect("Could not parse invoice"),
235                    )
236                    .await?;
237                ln_receives.push(start_time.elapsed());
238
239                if crate::util::supports_lnv2() {
240                    let invoice = lnv2_receive(&client, &gw_lnd.address(), 100_000).await?.0;
241
242                    let start_time = Instant::now();
243
244                    gw_ldk.pay_invoice(invoice).await?;
245
246                    ln_receives.push(start_time.elapsed());
247                }
248            }
249            let ln_receives_stats = stats_for(ln_receives);
250            println!("### LATENCY LN RECV: {ln_receives_stats}");
251
252            if assert_thresholds {
253                assert!(ln_receives_stats.median < Duration::from_secs(10));
254                assert!(ln_receives_stats.p90 < ln_receives_stats.median * p90_median_factor);
255                assert!(
256                    ln_receives_stats.max.as_secs_f64()
257                        < ln_receives_stats.p90.as_secs_f64() * max_p90_factor
258                );
259            }
260        }
261        LatencyTest::FmPay => {
262            info!("Testing latency of internal payments within a federation");
263            let mut fm_internal_pay = Vec::with_capacity(iterations);
264            let sender = fed.new_joined_client("internal-swap-sender").await?;
265            fed.pegin_client(10_000_000, &sender).await?;
266            for _ in 0..iterations {
267                let recv = cmd!(
268                    client,
269                    "ln-invoice",
270                    "--amount=1000000msat",
271                    "--description=internal-swap-invoice",
272                    "--force-internal"
273                )
274                .out_json()
275                .await?;
276
277                let invoice = recv["invoice"]
278                    .as_str()
279                    .context("invoice must be string")?
280                    .to_owned();
281                let recv_op = recv["operation_id"]
282                    .as_str()
283                    .context("operation id must be string")?
284                    .to_owned();
285
286                let start_time = Instant::now();
287                cmd!(sender, "ln-pay", invoice, "--force-internal")
288                    .run()
289                    .await?;
290
291                cmd!(client, "await-invoice", recv_op).run().await?;
292                fm_internal_pay.push(start_time.elapsed());
293            }
294            let fm_pay_stats = stats_for(fm_internal_pay);
295
296            println!("### LATENCY FM PAY: {fm_pay_stats}");
297
298            if assert_thresholds {
299                assert!(fm_pay_stats.median < Duration::from_secs(15));
300                assert!(fm_pay_stats.p90 < fm_pay_stats.median * p90_median_factor);
301                assert!(
302                    fm_pay_stats.max.as_secs_f64()
303                        < fm_pay_stats.p90.as_secs_f64() * max_p90_factor
304                );
305            }
306        }
307        LatencyTest::Restore => {
308            info!("Testing latency of restore");
309            let backup_secret = cmd!(client, "print-secret").out_json().await?["secret"]
310                .as_str()
311                .map(ToOwned::to_owned)
312                .unwrap();
313            if !is_env_var_set(FM_DEVIMINT_RUN_DEPRECATED_TESTS_ENV) {
314                info!("Skipping tests, as in previous versions restore was very slow to test");
315                return Ok(());
316            }
317
318            let start_time = Instant::now();
319            let restore_client = Client::create("restore").await?;
320            cmd!(
321                restore_client,
322                "restore",
323                "--mnemonic",
324                &backup_secret,
325                "--invite-code",
326                fed.invite_code()?
327            )
328            .run()
329            .await?;
330            let restore_time = start_time.elapsed();
331
332            println!("### LATENCY RESTORE: {restore_time:?}");
333
334            if assert_thresholds {
335                if crate::util::is_backwards_compatibility_test() {
336                    assert!(restore_time < Duration::from_secs(160));
337                } else {
338                    assert!(restore_time < Duration::from_secs(30));
339                }
340            }
341        }
342    }
343
344    Ok(())
345}
346
347pub async fn lnurl_recovery_test(dev_fed: DevFed) -> Result<()> {
348    let DevFed {
349        fed,
350        gw_lnd,
351        recurringd,
352        ..
353    } = dev_fed;
354
355    const LNURL_AMOUNT: Amount = Amount::from_msats(500_000);
356    const PRE_RECOVERY_RECEIVES: u64 = 3;
357    const POST_RECOVERY_RECEIVES: u64 = 2;
358
359    let receiver = fed.new_joined_client("lnurl-recovery-receiver").await?;
360    if !client_has_module(&receiver, "ln").await? {
361        info!("ln module is not present, skipping LNv1 LNURL recovery test");
362        return Ok(());
363    }
364
365    let payer = fed.new_joined_client("lnurl-recovery-payer").await?;
366    fed.pegin_client(100_000, &payer).await?;
367    fed.pegin_gateways(100_000, vec![&gw_lnd]).await?;
368
369    let lnurl = register_lnv1_lnurl(&receiver, recurringd.api_url().as_str()).await?;
370
371    for invoice_idx in 1..=PRE_RECOVERY_RECEIVES {
372        pay_lnv1_lnurl(&payer, &lnurl, LNURL_AMOUNT, &gw_lnd.gateway_id).await?;
373        let operation_id = await_lnv1_lnurl_invoice(&receiver, invoice_idx).await?;
374        await_lnv1_lnurl_invoice_paid(&receiver, operation_id).await?;
375    }
376
377    let pre_recovery_balance = receiver.balance().await?;
378    let mnemonic = cmd!(receiver, "print-secret").out_json().await?["secret"]
379        .as_str()
380        .context("secret must be a string")?
381        .to_owned();
382
383    let restored = Client::create("lnurl-recovery-restored").await?;
384    restored
385        .restore_federation(fed.invite_code()?, mnemonic)
386        .await?;
387
388    poll(
389        "waiting for LNURL recovery client balance to be restored",
390        || async {
391            let restored_balance = restored.balance().await.map_err(ControlFlow::Break)?;
392            if almost_equal(restored_balance, pre_recovery_balance, 2_000).is_ok() {
393                return Ok(());
394            }
395
396            info!("Waiting for LNURL recovery client balance to be restored");
397            cmd!(restored, "dev", "wait", "1")
398                .out_json()
399                .await
400                .map_err(ControlFlow::Break)?;
401
402            Err(ControlFlow::Continue(anyhow!(
403                "LNURL recovery client balance is not restored yet"
404            )))
405        },
406    )
407    .await?;
408
409    assert!(
410        list_lnv1_lnurl_codes(&restored)
411            .await?
412            .as_object()
413            .context("codes must be an object")?
414            .is_empty(),
415        "LN module recovery should not restore recurring payment code registrations"
416    );
417
418    let restored_lnurl = register_lnv1_lnurl(&restored, recurringd.api_url().as_str()).await?;
419    assert_eq!(
420        restored_lnurl, lnurl,
421        "LNURL registration should be idempotent for a recovered deterministic root key"
422    );
423
424    let mut old_operation_ids = Vec::with_capacity(PRE_RECOVERY_RECEIVES as usize);
425    for invoice_idx in 1..=PRE_RECOVERY_RECEIVES {
426        old_operation_ids.push(await_lnv1_lnurl_invoice(&restored, invoice_idx).await?);
427    }
428
429    for operation_id in &old_operation_ids {
430        assert_lnv1_operation_has_no_outcome(&restored, *operation_id).await?;
431    }
432
433    let post_recovery_balance = restored.balance().await?;
434    let mut post_recovery_operation_ids = Vec::with_capacity(POST_RECOVERY_RECEIVES as usize);
435    for invoice_idx in PRE_RECOVERY_RECEIVES + 1..=PRE_RECOVERY_RECEIVES + POST_RECOVERY_RECEIVES {
436        pay_lnv1_lnurl(&payer, &restored_lnurl, LNURL_AMOUNT, &gw_lnd.gateway_id).await?;
437        let operation_id = await_lnv1_lnurl_invoice(&restored, invoice_idx).await?;
438        await_lnv1_lnurl_invoice_paid(&restored, operation_id).await?;
439        post_recovery_operation_ids.push(operation_id);
440    }
441
442    let expected_final_balance =
443        post_recovery_balance + LNURL_AMOUNT.msats * POST_RECOVERY_RECEIVES;
444    let final_balance = restored.balance().await?;
445    almost_equal(final_balance, expected_final_balance, 2_000).map_err(|error| {
446        anyhow!(
447            "restored client balance {final_balance} did not include post-recovery LNURL receives: {error}"
448        )
449    })?;
450
451    for operation_id in post_recovery_operation_ids {
452        assert_lnv1_recurring_receive_operation_logged(&restored, operation_id).await?;
453    }
454
455    Ok(())
456}
457
458async fn client_has_module(client: &Client, kind: &str) -> Result<bool> {
459    let modules = cmd!(client, "module").out_json().await?;
460    let modules = modules["list"]
461        .as_array()
462        .context("module list must be an array")?;
463
464    Ok(modules
465        .iter()
466        .any(|module| module["kind"].as_str() == Some(kind)))
467}
468
469async fn register_lnv1_lnurl(client: &Client, recurringd_api: &str) -> Result<String> {
470    cmd!(client, "module", "ln", "lnurl", "register", recurringd_api)
471        .out_json()
472        .await?["lnurl"]
473        .as_str()
474        .context("lnurl must be a string")
475        .map(ToOwned::to_owned)
476}
477
478async fn list_lnv1_lnurl_codes(client: &Client) -> Result<serde_json::Value> {
479    Ok(cmd!(client, "module", "ln", "lnurl", "list")
480        .out_json()
481        .await?["codes"]
482        .clone())
483}
484
485async fn pay_lnv1_lnurl(
486    client: &Client,
487    lnurl: &str,
488    amount: Amount,
489    gateway_id: &str,
490) -> Result<()> {
491    let value = cmd!(
492        client,
493        "module",
494        "ln",
495        "pay",
496        lnurl,
497        "--amount",
498        amount.msats,
499        "--gateway-id",
500        gateway_id,
501    )
502    .out_json()
503    .await?;
504    let outcome = serde_json::from_value::<LightningPaymentOutcome>(value)
505        .context("could not deserialize Lightning payment outcome")?;
506    match outcome {
507        LightningPaymentOutcome::Success { .. } => Ok(()),
508        LightningPaymentOutcome::Failure { error_message } => {
509            Err(anyhow!("failed to pay LNURL invoice: {error_message}"))
510        }
511    }
512}
513
514async fn await_lnv1_lnurl_invoice(client: &Client, invoice_idx: u64) -> Result<OperationId> {
515    poll("waiting for LNv1 LNURL invoice operation", || async {
516        cmd!(client, "dev", "wait", "1")
517            .out_json()
518            .await
519            .map_err(ControlFlow::Break)?;
520
521        let invoices = cmd!(client, "module", "ln", "lnurl", "invoices", "0")
522            .out_json()
523            .await
524            .map_err(ControlFlow::Break)?;
525        let Some(operation_id) = invoices["invoices"][invoice_idx.to_string()]["operation_id"]
526            .as_str()
527            .map(ToOwned::to_owned)
528        else {
529            return Err(ControlFlow::Continue(anyhow!(
530                "LNURL invoice index {invoice_idx} not found"
531            )));
532        };
533
534        serde_json::from_value::<OperationId>(json!(operation_id))
535            .map_err(anyhow::Error::from)
536            .map_err(ControlFlow::Break)
537    })
538    .await
539}
540
541async fn await_lnv1_lnurl_invoice_paid(client: &Client, operation_id: OperationId) -> Result<()> {
542    cmd!(
543        client,
544        "module",
545        "ln",
546        "lnurl",
547        "await-invoice-paid",
548        operation_id.fmt_full()
549    )
550    .run()
551    .await
552}
553
554async fn assert_lnv1_operation_has_no_outcome(
555    client: &Client,
556    operation_id: OperationId,
557) -> Result<()> {
558    let operation = get_lnv1_operation_from_log(client, operation_id).await?;
559
560    assert_eq!(
561        operation["operation_kind"].as_str(),
562        Some("ln"),
563        "replayed LNURL invoice operation must be an ln operation"
564    );
565    assert!(
566        operation.get("outcome").is_none(),
567        "replayed pre-recovery LNURL invoice operation should not have a terminal outcome"
568    );
569
570    Ok(())
571}
572
573async fn assert_lnv1_recurring_receive_operation_logged(
574    client: &Client,
575    operation_id: OperationId,
576) -> Result<()> {
577    let operation = get_lnv1_operation_from_log(client, operation_id).await?;
578
579    assert_eq!(
580        operation["operation_kind"].as_str(),
581        Some("ln"),
582        "post-recovery LNURL invoice operation must be an ln operation"
583    );
584    assert!(
585        operation["operation_meta"]["variant"]["recurring_payment_receive"].is_object(),
586        "post-recovery LNURL receive must be logged as recurring_payment_receive"
587    );
588
589    Ok(())
590}
591
592async fn get_lnv1_operation_from_log(
593    client: &Client,
594    operation_id: OperationId,
595) -> Result<serde_json::Value> {
596    let operation_id = operation_id.fmt_full().to_string();
597    let operations = cmd!(client, "list-operations", "--limit", "100")
598        .out_json()
599        .await?;
600    operations["operations"]
601        .as_array()
602        .context("operations must be an array")?
603        .iter()
604        .find(|operation| operation["id"].as_str() == Some(operation_id.as_str()))
605        .cloned()
606        .with_context(|| format!("operation {operation_id} not found"))
607}
608
609#[allow(clippy::struct_field_names)]
610/// Clients reused for upgrade tests
611pub struct UpgradeClients {
612    reissue_client: Client,
613    ln_send_client: Client,
614    ln_receive_client: Client,
615    fm_pay_client: Client,
616}
617
618async fn stress_test_fed(dev_fed: &DevFed, clients: Option<&UpgradeClients>) -> anyhow::Result<()> {
619    use futures::FutureExt;
620
621    // local environments can fail due to latency thresholds, however this shouldn't
622    // cause the upgrade test to fail
623    let assert_thresholds = false;
624
625    // running only one iteration greatly improves the total test time while still
626    // testing the same types of database entries
627    let iterations = 1;
628
629    // skip restore test for client upgrades, since restoring a client doesn't
630    // require a persistent data dir
631    let restore_test = if clients.is_some() {
632        futures::future::ok(()).right_future()
633    } else {
634        latency_tests(
635            dev_fed.clone(),
636            LatencyTest::Restore,
637            clients,
638            iterations,
639            assert_thresholds,
640        )
641        .left_future()
642    };
643
644    // tests are run in sequence so parallelism is controlled using gnu `parallel`
645    // in `upgrade-test.sh`
646    latency_tests(
647        dev_fed.clone(),
648        LatencyTest::Reissue,
649        clients,
650        iterations,
651        assert_thresholds,
652    )
653    .await?;
654
655    latency_tests(
656        dev_fed.clone(),
657        LatencyTest::LnSend,
658        clients,
659        iterations,
660        assert_thresholds,
661    )
662    .await?;
663
664    latency_tests(
665        dev_fed.clone(),
666        LatencyTest::LnReceive,
667        clients,
668        iterations,
669        assert_thresholds,
670    )
671    .await?;
672
673    latency_tests(
674        dev_fed.clone(),
675        LatencyTest::FmPay,
676        clients,
677        iterations,
678        assert_thresholds,
679    )
680    .await?;
681
682    restore_test.await?;
683
684    Ok(())
685}
686
687pub async fn upgrade_tests(process_mgr: &ProcessManager, binary: UpgradeTest) -> Result<()> {
688    match binary {
689        UpgradeTest::Fedimintd { paths } => {
690            if let Some(oldest_fedimintd) = paths.first() {
691                // TODO: Audit that the environment access only happens in single-threaded code.
692                unsafe { std::env::set_var("FM_FEDIMINTD_BASE_EXECUTABLE", oldest_fedimintd) };
693            } else {
694                bail!("Must provide at least 1 binary path");
695            }
696
697            let fedimintd_version = crate::util::FedimintdCmd::version_or_default().await;
698            info!(
699                "running first stress test for fedimintd version: {}",
700                fedimintd_version
701            );
702
703            let mut dev_fed = dev_fed(process_mgr).await?;
704            let client = dev_fed.fed.new_joined_client("test-client").await?;
705            try_join!(stress_test_fed(&dev_fed, None), client.wait_session())?;
706
707            for path in paths.iter().skip(1) {
708                dev_fed.fed.restart_all_with_bin(process_mgr, path).await?;
709
710                // stress test with all peers online
711                try_join!(stress_test_fed(&dev_fed, None), client.wait_session())?;
712
713                let fedimintd_version = crate::util::FedimintdCmd::version_or_default().await;
714                info!(
715                    "### fedimintd passed stress test for version {}",
716                    fedimintd_version
717                );
718            }
719            info!("## fedimintd upgraded all binaries successfully");
720        }
721        UpgradeTest::FedimintCli { paths } => {
722            let set_fedimint_cli_path = |path: &PathBuf| {
723                // TODO: Audit that the environment access only happens in single-threaded code.
724                unsafe { std::env::set_var("FM_FEDIMINT_CLI_BASE_EXECUTABLE", path) };
725                let fm_mint_client: String = format!(
726                    "{fedimint_cli} --data-dir {datadir}",
727                    fedimint_cli = crate::util::get_fedimint_cli_path().join(" "),
728                    datadir = crate::vars::utf8(&process_mgr.globals.FM_CLIENT_DIR)
729                );
730                // TODO: Audit that the environment access only happens in single-threaded code.
731                unsafe { std::env::set_var("FM_MINT_CLIENT", fm_mint_client) };
732            };
733
734            if let Some(oldest_fedimint_cli) = paths.first() {
735                set_fedimint_cli_path(oldest_fedimint_cli);
736            } else {
737                bail!("Must provide at least 1 binary path");
738            }
739
740            let fedimint_cli_version = crate::util::FedimintCli::version_or_default().await;
741            info!(
742                "running first stress test for fedimint-cli version: {}",
743                fedimint_cli_version
744            );
745
746            let dev_fed = dev_fed(process_mgr).await?;
747
748            let wait_session_client = dev_fed.fed.new_joined_client("wait-session-client").await?;
749            let reusable_upgrade_clients = UpgradeClients {
750                reissue_client: dev_fed.fed.new_joined_client("reissue-client").await?,
751                ln_send_client: dev_fed.fed.new_joined_client("ln-send-client").await?,
752                ln_receive_client: dev_fed.fed.new_joined_client("ln-receive-client").await?,
753                fm_pay_client: dev_fed.fed.new_joined_client("fm-pay-client").await?,
754            };
755
756            try_join!(
757                stress_test_fed(&dev_fed, Some(&reusable_upgrade_clients)),
758                wait_session_client.wait_session()
759            )?;
760
761            for path in paths.iter().skip(1) {
762                set_fedimint_cli_path(path);
763                let fedimint_cli_version = crate::util::FedimintCli::version_or_default().await;
764                info!("upgraded fedimint-cli to version: {}", fedimint_cli_version);
765                try_join!(
766                    stress_test_fed(&dev_fed, Some(&reusable_upgrade_clients)),
767                    wait_session_client.wait_session()
768                )?;
769                info!(
770                    "### fedimint-cli passed stress test for version {}",
771                    fedimint_cli_version
772                );
773            }
774            info!("## fedimint-cli upgraded all binaries successfully");
775        }
776        UpgradeTest::Gatewayd {
777            gatewayd_paths,
778            gateway_cli_paths,
779        } => {
780            if let Some(oldest_gatewayd) = gatewayd_paths.first() {
781                // TODO: Audit that the environment access only happens in single-threaded code.
782                unsafe { std::env::set_var("FM_GATEWAYD_BASE_EXECUTABLE", oldest_gatewayd) };
783            } else {
784                bail!("Must provide at least 1 gatewayd path");
785            }
786
787            if let Some(oldest_gateway_cli) = gateway_cli_paths.first() {
788                // TODO: Audit that the environment access only happens in single-threaded code.
789                unsafe { std::env::set_var("FM_GATEWAY_CLI_BASE_EXECUTABLE", oldest_gateway_cli) };
790            } else {
791                bail!("Must provide at least 1 gateway-cli path");
792            }
793
794            let gatewayd_version = crate::util::Gatewayd::version_or_default().await;
795            let gateway_cli_version = crate::util::GatewayCli::version_or_default().await;
796            info!(
797                ?gatewayd_version,
798                ?gateway_cli_version,
799                "running first stress test for gateway",
800            );
801
802            let mut dev_fed = dev_fed(process_mgr).await?;
803            let client = dev_fed.fed.new_joined_client("test-client").await?;
804            try_join!(stress_test_fed(&dev_fed, None), client.wait_session())?;
805
806            for i in 1..gatewayd_paths.len() {
807                info!(
808                    "running stress test with gatewayd path {:?}",
809                    gatewayd_paths.get(i)
810                );
811                let new_gatewayd_path = gatewayd_paths.get(i).expect("Not enough gatewayd paths");
812                let new_gateway_cli_path = gateway_cli_paths
813                    .get(i)
814                    .expect("Not enough gateway-cli paths");
815
816                let gateways = vec![&mut dev_fed.gw_lnd];
817
818                try_join_all(gateways.into_iter().map(|gateway| {
819                    gateway.restart_with_bin(process_mgr, new_gatewayd_path, new_gateway_cli_path)
820                }))
821                .await?;
822
823                dev_fed.fed.await_gateways_registered().await?;
824                try_join!(stress_test_fed(&dev_fed, None), client.wait_session())?;
825                let gatewayd_version = crate::util::Gatewayd::version_or_default().await;
826                let gateway_cli_version = crate::util::GatewayCli::version_or_default().await;
827                info!(
828                    ?gatewayd_version,
829                    ?gateway_cli_version,
830                    "### gateway passed stress test for version",
831                );
832            }
833
834            info!("## gatewayd upgraded all binaries successfully");
835        }
836    }
837    Ok(())
838}
839
840pub async fn cli_tests(dev_fed: DevFed) -> Result<()> {
841    log_binary_versions().await?;
842    let DevFed {
843        bitcoind,
844        lnd,
845        fed,
846        gw_lnd,
847        gw_ldk,
848        ..
849    } = dev_fed;
850
851    let fedimintd_version = crate::util::FedimintdCmd::version_or_default().await;
852
853    let client = fed.new_joined_client("cli-tests-client").await?;
854    let lnd_gw_id = gw_lnd.gateway_id.clone();
855
856    fed.pegin_gateways(10_000_000, vec![&gw_lnd]).await?;
857
858    let iroh_lnd_id = gw_lnd.iroh_gateway_id.clone();
859    let gw_lnd = gw_lnd.client();
860    let gw_ldk = gw_ldk.client();
861
862    let fed_id = fed.calculate_federation_id();
863    let invite = fed.invite_code()?;
864
865    let invite_code = cmd!(client, "dev", "decode", "invite-code", invite.clone())
866        .out_json()
867        .await?;
868
869    let encode_invite_output = cmd!(
870        client,
871        "dev",
872        "encode",
873        "invite-code",
874        format!("--url={}", invite_code["url"].as_str().unwrap()),
875        "--federation_id={fed_id}",
876        "--peer=0"
877    )
878    .out_json()
879    .await?;
880
881    anyhow::ensure!(
882        encode_invite_output["invite_code"]
883            .as_str()
884            .expect("invite_code must be a string")
885            == invite,
886        "failed to decode and encode the client invite code",
887    );
888
889    // Test that LND and LDK can still send directly to each other
890
891    // LND can pay LDK directly
892    info!("Testing LND can pay LDK directly");
893    let invoice = gw_ldk.create_invoice(1_200_000).await?;
894    lnd.pay_bolt11_invoice(invoice.to_string()).await?;
895    gw_ldk
896        .wait_bolt11_invoice(invoice.payment_hash().consensus_encode_to_vec())
897        .await?;
898
899    // LDK can pay LND directly
900    info!("Testing LDK can pay LND directly");
901    let (invoice, payment_hash) = lnd.invoice(1_000_000).await?;
902    gw_ldk
903        .pay_invoice(Bolt11Invoice::from_str(&invoice).expect("Could not parse invoice"))
904        .await?;
905    gw_lnd.wait_bolt11_invoice(payment_hash).await?;
906
907    // # Test the correct descriptor is used
908    let config = cmd!(client, "config").out_json().await?;
909    let guardian_count = config["global"]["api_endpoints"].as_object().unwrap().len();
910    let wallet_module = config["modules"]
911        .as_object()
912        .unwrap()
913        .values()
914        .find(|m| m["kind"].as_str() == Some("wallet"))
915        .expect("wallet module not found");
916    let descriptor = wallet_module["peg_in_descriptor"]
917        .as_str()
918        .unwrap()
919        .to_owned();
920
921    info!("Testing generated descriptor for {guardian_count} guardian federation");
922    if guardian_count == 1 {
923        assert!(descriptor.contains("wpkh("));
924    } else {
925        assert!(descriptor.contains("wsh(sortedmulti("));
926    }
927
928    // # Client tests
929    info!("Testing Client");
930
931    // E-cash tests are v1-specific - skip when MintV2 is enabled
932    if crate::util::supports_mint_v2() {
933        info!("Skipping ecash tests - MintV2 enabled, these tests are v1-specific");
934    } else {
935        // ## reissue e-cash
936        info!("Testing reissuing e-cash");
937        const CLIENT_START_AMOUNT: u64 = 5_000_000_000;
938        const CLIENT_SPEND_AMOUNT: u64 = 1_100_000;
939
940        let initial_client_balance = client.balance().await?;
941        assert_eq!(initial_client_balance, 0);
942
943        fed.pegin_client(CLIENT_START_AMOUNT / 1000, &client)
944            .await?;
945
946        // # Spend from client
947        info!("Testing spending from client");
948        let notes = cmd!(client, "spend", CLIENT_SPEND_AMOUNT)
949            .out_json()
950            .await?
951            .get("notes")
952            .expect("Output didn't contain e-cash notes")
953            .as_str()
954            .unwrap()
955            .to_owned();
956
957        let client_post_spend_balance = client.balance().await?;
958        almost_equal(
959            client_post_spend_balance,
960            CLIENT_START_AMOUNT - CLIENT_SPEND_AMOUNT,
961            10_000,
962        )
963        .unwrap();
964
965        // Test we can reissue our own notes
966        cmd!(client, "reissue", notes).out_json().await?;
967
968        let client_post_spend_balance = client.balance().await?;
969        almost_equal(client_post_spend_balance, CLIENT_START_AMOUNT, 10_000).unwrap();
970
971        let reissue_amount: u64 = 409_600;
972
973        // Ensure that client can reissue after spending
974        info!("Testing reissuing e-cash after spending");
975        let _notes = cmd!(client, "spend", CLIENT_SPEND_AMOUNT)
976            .out_json()
977            .await?
978            .as_object()
979            .unwrap()
980            .get("notes")
981            .expect("Output didn't contain e-cash notes")
982            .as_str()
983            .unwrap();
984
985        let reissue_notes = cmd!(client, "spend", reissue_amount).out_json().await?["notes"]
986            .as_str()
987            .map(ToOwned::to_owned)
988            .unwrap();
989        let client_reissue_amt = cmd!(client, "reissue", reissue_notes)
990            .out_json()
991            .await?
992            .as_u64()
993            .unwrap();
994        assert_eq!(client_reissue_amt, reissue_amount);
995
996        // Ensure that client can reissue via module commands
997        info!("Testing reissuing e-cash via module commands");
998        let reissue_notes = cmd!(client, "spend", reissue_amount).out_json().await?["notes"]
999            .as_str()
1000            .map(ToOwned::to_owned)
1001            .unwrap();
1002        let client_reissue_amt = cmd!(client, "module", "mint", "reissue", reissue_notes)
1003            .out_json()
1004            .await?
1005            .as_u64()
1006            .unwrap();
1007        assert_eq!(client_reissue_amt, reissue_amount);
1008    }
1009
1010    // LND gateway tests
1011    info!("Testing LND gateway");
1012
1013    // OUTGOING: fedimint-cli pays LDK via LND gateway
1014    if let Some(iroh_gw_id) = &iroh_lnd_id
1015        && crate::util::FedimintCli::version_or_default().await >= *VERSION_0_10_0_ALPHA
1016    {
1017        info!("Testing outgoing payment from client to LDK via IROH LND Gateway");
1018
1019        let initial_lnd_gateway_balance = gw_lnd.ecash_balance(fed_id.clone()).await?;
1020        let invoice = gw_ldk.create_invoice(2_000_000).await?;
1021        ln_pay(&client, invoice.to_string(), iroh_gw_id.clone()).await?;
1022        gw_ldk
1023            .wait_bolt11_invoice(invoice.payment_hash().consensus_encode_to_vec())
1024            .await?;
1025
1026        // Assert balances changed by 2_000_000 msat (amount sent) + 0 msat (fee)
1027        let final_lnd_outgoing_gateway_balance = gw_lnd.ecash_balance(fed_id.clone()).await?;
1028        info!(
1029            ?final_lnd_outgoing_gateway_balance,
1030            "Final LND ecash balance after iroh payment"
1031        );
1032        anyhow::ensure!(
1033            almost_equal(
1034                final_lnd_outgoing_gateway_balance - initial_lnd_gateway_balance,
1035                2_000_000,
1036                1_000
1037            )
1038            .is_ok(),
1039            "LND Gateway balance changed by {} on LND outgoing IROH payment, expected 2_000_000",
1040            (final_lnd_outgoing_gateway_balance - initial_lnd_gateway_balance)
1041        );
1042
1043        // Send the funds back over iroh
1044        let recv = ln_invoice(
1045            &client,
1046            Amount::from_msats(2_000_000),
1047            "iroh receive payment".to_string(),
1048            iroh_gw_id.clone(),
1049        )
1050        .await?;
1051        gw_ldk
1052            .pay_invoice(Bolt11Invoice::from_str(&recv.invoice).expect("Could not parse invoice"))
1053            .await?;
1054        let operation_id = recv.operation_id;
1055        cmd!(client, "await-invoice", operation_id.fmt_full())
1056            .run()
1057            .await?;
1058    }
1059
1060    info!("Testing outgoing payment from client to LDK via LND gateway");
1061    let initial_lnd_gateway_balance = gw_lnd.ecash_balance(fed_id.clone()).await?;
1062    let invoice = gw_ldk.create_invoice(2_000_000).await?;
1063    ln_pay(&client, invoice.to_string(), lnd_gw_id.clone()).await?;
1064    let fed_id = fed.calculate_federation_id();
1065    gw_ldk
1066        .wait_bolt11_invoice(invoice.payment_hash().consensus_encode_to_vec())
1067        .await?;
1068
1069    // Assert balances changed by 2_000_000 msat (amount sent) + 0 msat (fee)
1070    let final_lnd_outgoing_gateway_balance = gw_lnd.ecash_balance(fed_id.clone()).await?;
1071    anyhow::ensure!(
1072        almost_equal(
1073            final_lnd_outgoing_gateway_balance - initial_lnd_gateway_balance,
1074            2_000_000,
1075            3_000
1076        )
1077        .is_ok(),
1078        "LND Gateway balance changed by {} on LND outgoing payment, expected 2_000_000",
1079        (final_lnd_outgoing_gateway_balance - initial_lnd_gateway_balance)
1080    );
1081
1082    // INCOMING: fedimint-cli receives from LDK via LND gateway
1083    info!("Testing incoming payment from LDK to client via LND gateway");
1084    let initial_lnd_incoming_client_balance = client.balance().await?;
1085    let recv = ln_invoice(
1086        &client,
1087        Amount::from_msats(1_300_000),
1088        "incoming-over-lnd-gw".to_string(),
1089        lnd_gw_id,
1090    )
1091    .await?;
1092    let invoice = recv.invoice;
1093    gw_ldk
1094        .pay_invoice(Bolt11Invoice::from_str(&invoice).expect("Could not parse invoice"))
1095        .await?;
1096
1097    // Receive the ecash notes
1098    info!("Testing receiving ecash notes");
1099    let operation_id = recv.operation_id;
1100    cmd!(client, "await-invoice", operation_id.fmt_full())
1101        .run()
1102        .await?;
1103
1104    // Only validate balance changes on v0.11 and above because there is a race
1105    // condition in older versions that causes the balances to not match.
1106    let fedimint_cli_version = crate::util::FedimintCli::version_or_default().await;
1107    if fedimint_cli_version >= *VERSION_0_11_0_ALPHA {
1108        // Assert balances changed by 1_300_000 msat
1109        let final_lnd_incoming_client_balance = client.balance().await?;
1110        let final_lnd_incoming_gateway_balance = gw_lnd.ecash_balance(fed_id.clone()).await?;
1111        anyhow::ensure!(
1112            almost_equal(
1113                final_lnd_incoming_client_balance - initial_lnd_incoming_client_balance,
1114                1_300_000,
1115                2_000
1116            )
1117            .is_ok(),
1118            "Client balance changed by {} on LND incoming payment, expected 1_300_000",
1119            (final_lnd_incoming_client_balance - initial_lnd_incoming_client_balance)
1120        );
1121        anyhow::ensure!(
1122            almost_equal(
1123                final_lnd_outgoing_gateway_balance - final_lnd_incoming_gateway_balance,
1124                1_300_000,
1125                2_000
1126            )
1127            .is_ok(),
1128            "LND Gateway balance changed by {} on LND incoming payment, expected 1_300_000",
1129            (final_lnd_outgoing_gateway_balance - final_lnd_incoming_gateway_balance)
1130        );
1131    }
1132
1133    // # Wallet tests
1134    // ## Deposit
1135    info!("Testing client deposit");
1136    let initial_walletng_balance = client.balance().await?;
1137
1138    fed.pegin_client(100_000, &client).await?; // deposit in sats
1139
1140    let post_deposit_walletng_balance = client.balance().await?;
1141
1142    almost_equal(
1143        post_deposit_walletng_balance,
1144        initial_walletng_balance + 100_000_000, // deposit in msats
1145        2_000,
1146    )
1147    .unwrap();
1148
1149    // ## Withdraw
1150    info!("Testing client withdraw");
1151
1152    let initial_walletng_balance = client.balance().await?;
1153
1154    let address = bitcoind.get_new_address().await?;
1155    let withdraw_res = cmd!(
1156        client,
1157        "withdraw",
1158        "--address",
1159        &address,
1160        "--amount",
1161        "50000 sat"
1162    )
1163    .out_json()
1164    .await?;
1165
1166    let txid: Txid = withdraw_res["txid"].as_str().unwrap().parse().unwrap();
1167    let fees_sat = withdraw_res["fees_sat"].as_u64().unwrap();
1168
1169    let tx_hex = bitcoind.poll_get_transaction(txid).await?;
1170
1171    let tx = bitcoin::Transaction::consensus_decode_hex(&tx_hex, &ModuleRegistry::default())?;
1172    assert!(
1173        tx.output
1174            .iter()
1175            .any(|o| o.script_pubkey == address.script_pubkey() && o.value.to_sat() == 50000)
1176    );
1177
1178    let post_withdraw_walletng_balance = client.balance().await?;
1179    let expected_wallet_balance = initial_walletng_balance - 50_000_000 - (fees_sat * 1000);
1180
1181    almost_equal(
1182        post_withdraw_walletng_balance,
1183        expected_wallet_balance,
1184        4_000,
1185    )
1186    .unwrap();
1187
1188    // # peer-version command
1189    let peer_0_fedimintd_version = cmd!(client, "dev", "peer-version", "--peer-id", "0")
1190        .out_json()
1191        .await?
1192        .get("version")
1193        .expect("Output didn't contain version")
1194        .as_str()
1195        .unwrap()
1196        .to_owned();
1197
1198    assert_eq!(
1199        semver::Version::parse(&peer_0_fedimintd_version)?,
1200        fedimintd_version
1201    );
1202
1203    info!("Checking initial announcements...");
1204
1205    retry(
1206        "Check initial announcements",
1207        aggressive_backoff(),
1208        || async {
1209            // Give the client some time to fetch updates
1210            cmd!(client, "dev", "wait", "1").run().await?;
1211
1212            // # API URL announcements
1213            let initial_announcements =
1214                serde_json::from_value::<BTreeMap<PeerId, SignedApiAnnouncement>>(
1215                    cmd!(client, "dev", "api-announcements",).out_json().await?,
1216                )
1217                .expect("failed to parse API announcements");
1218
1219            if initial_announcements.len() < fed.members.len() {
1220                bail!(
1221                    "Not all announcements ready; got: {}, expected: {}",
1222                    initial_announcements.len(),
1223                    fed.members.len()
1224                )
1225            }
1226
1227            if !initial_announcements
1228                .values()
1229                .all(|announcement| announcement.api_announcement.nonce == 0)
1230            {
1231                bail!("Not all announcements have their initial value");
1232            }
1233            Ok(())
1234        },
1235    )
1236    .await?;
1237
1238    const NEW_API_URL: &str = "ws://127.0.0.1:4242";
1239    let new_announcement = serde_json::from_value::<SignedApiAnnouncement>(
1240        cmd!(
1241            client,
1242            "--our-id",
1243            "0",
1244            "--password",
1245            "pass",
1246            "admin",
1247            "sign-api-announcement",
1248            NEW_API_URL
1249        )
1250        .out_json()
1251        .await?,
1252    )
1253    .expect("Couldn't parse signed announcement");
1254
1255    assert_eq!(
1256        new_announcement.api_announcement.nonce, 1,
1257        "Nonce did not increment correctly"
1258    );
1259
1260    info!("Testing if the client syncs the announcement");
1261    let announcement = poll("Waiting for the announcement to propagate", || async {
1262        cmd!(client, "dev", "wait", "1")
1263            .run()
1264            .await
1265            .map_err(ControlFlow::Break)?;
1266
1267        let new_announcements_peer2 =
1268            serde_json::from_value::<BTreeMap<PeerId, SignedApiAnnouncement>>(
1269                cmd!(client, "dev", "api-announcements",)
1270                    .out_json()
1271                    .await
1272                    .map_err(ControlFlow::Break)?,
1273            )
1274            .expect("failed to parse API announcements");
1275
1276        let announcement = new_announcements_peer2[&PeerId::from(0)]
1277            .api_announcement
1278            .clone();
1279        if announcement.nonce == 1 {
1280            Ok(announcement)
1281        } else {
1282            Err(ControlFlow::Continue(anyhow!(
1283                "Haven't received updated announcement yet; nonce: {}",
1284                announcement.nonce
1285            )))
1286        }
1287    })
1288    .await?;
1289
1290    assert_eq!(
1291        announcement.api_url,
1292        NEW_API_URL.parse().expect("valid URL")
1293    );
1294
1295    Ok(())
1296}
1297
1298pub async fn guardian_metadata_tests(dev_fed: DevFed) -> Result<()> {
1299    use fedimint_api_client::api::{DynGlobalApi, FederationApiExt};
1300    use fedimint_connectors::ConnectorRegistry;
1301    use fedimint_core::PeerId;
1302    use fedimint_core::endpoint_constants::INVITE_CODE_ENDPOINT;
1303    use fedimint_core::invite_code::InviteCode;
1304    use fedimint_core::module::ApiRequestErased;
1305    use fedimint_core::net::guardian_metadata::SignedGuardianMetadata;
1306    use fedimint_core::util::SafeUrl;
1307
1308    log_binary_versions().await?;
1309
1310    let fedimintd_version = crate::util::FedimintdCmd::version_or_default().await;
1311    let fedimint_cli_version = crate::util::FedimintCli::version_or_default().await;
1312
1313    if fedimintd_version < *VERSION_0_11_0_ALPHA || fedimint_cli_version < *VERSION_0_11_0_ALPHA {
1314        info!("Skipping test for too old versions");
1315        return Ok(());
1316    }
1317
1318    let DevFed { fed, .. } = dev_fed;
1319
1320    let client = fed.internal_client().await?;
1321
1322    info!("Checking initial guardian metadata...");
1323
1324    retry(
1325        "Check initial guardian metadata",
1326        aggressive_backoff(),
1327        || async {
1328            // Give the client some time to fetch updates
1329            cmd!(client, "dev", "wait", "1").run().await?;
1330
1331            let initial_metadata =
1332                serde_json::from_value::<BTreeMap<PeerId, SignedGuardianMetadata>>(
1333                    cmd!(client, "dev", "guardian-metadata",).out_json().await?,
1334                )
1335                .expect("failed to parse guardian metadata");
1336
1337            if initial_metadata.len() < fed.members.len() {
1338                bail!(
1339                    "Not all guardian metadata ready; got: {}, expected: {}",
1340                    initial_metadata.len(),
1341                    fed.members.len()
1342                )
1343            }
1344
1345            Ok(())
1346        },
1347    )
1348    .await?;
1349
1350    const TEST_API_URL: &str = "ws://127.0.0.1:5000/";
1351    const TEST_PKARR_ID: &str = "test_pkarr_id_z32";
1352
1353    let new_metadata = serde_json::from_value::<SignedGuardianMetadata>(
1354        cmd!(
1355            client,
1356            "--our-id",
1357            "0",
1358            "--password",
1359            "pass",
1360            "admin",
1361            "sign-guardian-metadata",
1362            "--api-urls",
1363            TEST_API_URL,
1364            "--pkarr-id",
1365            TEST_PKARR_ID
1366        )
1367        .out_json()
1368        .await?,
1369    )
1370    .expect("Couldn't parse signed guardian metadata");
1371
1372    let parsed_metadata = new_metadata.guardian_metadata();
1373
1374    assert_eq!(
1375        parsed_metadata.api_urls.first().unwrap().to_string(),
1376        TEST_API_URL,
1377        "API URL did not match"
1378    );
1379
1380    assert_eq!(
1381        parsed_metadata.pkarr_id_z32, TEST_PKARR_ID,
1382        "Pkarr ID did not match"
1383    );
1384
1385    info!("Testing if the client syncs the guardian metadata");
1386    let metadata = poll("Waiting for the guardian metadata to propagate", || async {
1387        cmd!(client, "dev", "wait", "1")
1388            .run()
1389            .await
1390            .map_err(ControlFlow::Break)?;
1391
1392        let new_metadata_peer0 =
1393            serde_json::from_value::<BTreeMap<PeerId, SignedGuardianMetadata>>(
1394                cmd!(client, "dev", "guardian-metadata",)
1395                    .out_json()
1396                    .await
1397                    .map_err(ControlFlow::Break)?,
1398            )
1399            .expect("failed to parse guardian metadata");
1400
1401        let metadata = new_metadata_peer0[&PeerId::from(0)].guardian_metadata();
1402
1403        if metadata.api_urls.first().unwrap().to_string() == TEST_API_URL {
1404            Ok(metadata.clone())
1405        } else {
1406            Err(ControlFlow::Continue(anyhow!(
1407                "Haven't received updated guardian metadata yet"
1408            )))
1409        }
1410    })
1411    .await?;
1412
1413    assert_eq!(
1414        metadata.pkarr_id_z32, TEST_PKARR_ID,
1415        "Pkarr ID did not propagate correctly"
1416    );
1417
1418    // `invite_code` only honors overridden API URLs since 0.12.0-alpha; skip
1419    // against older fedimintd in backwards-compatibility tests.
1420    if fedimintd_version < *VERSION_0_12_0_ALPHA {
1421        info!("Skipping invite_code endpoint assertion: fedimintd < 0.12.0-alpha");
1422        return Ok(());
1423    }
1424
1425    info!("Checking invite_code endpoint reflects overridden guardian metadata URL...");
1426    // Query peer 0 directly: the client's peer-URL map now points at the fake
1427    // override URL, so `dev api --peer-id 0` would fail to connect.
1428    let peer_id = PeerId::from(0);
1429    let peer0_real_url: SafeUrl = fed
1430        .vars
1431        .get(&0)
1432        .expect("peer 0 vars must exist")
1433        .FM_API_URL
1434        .parse()
1435        .expect("FM_API_URL must be a valid SafeUrl");
1436    let connectors = ConnectorRegistry::build_from_testing_env()?.bind().await?;
1437    let admin_api = DynGlobalApi::new_admin(connectors, peer_id, peer0_real_url, None)?;
1438    let invite: InviteCode = admin_api
1439        .request_single_peer(
1440            INVITE_CODE_ENDPOINT.to_string(),
1441            ApiRequestErased::default(),
1442            peer_id,
1443        )
1444        .await
1445        .expect("invite_code RPC request should succeed");
1446    assert_eq!(
1447        invite.url().to_string(),
1448        TEST_API_URL,
1449        "invite_code endpoint did not reflect overridden guardian metadata URL"
1450    );
1451
1452    Ok(())
1453}
1454
1455pub async fn cli_load_test_tool_test(dev_fed: DevFed) -> Result<()> {
1456    log_binary_versions().await?;
1457    let data_dir = env::var(FM_DATA_DIR_ENV)?;
1458    let load_test_temp = PathBuf::from(data_dir).join("load-test-temp");
1459    dev_fed
1460        .fed
1461        .pegin_client(10_000, dev_fed.fed.internal_client().await?)
1462        .await?;
1463    let invite_code = dev_fed.fed.invite_code()?;
1464    dev_fed
1465        .gw_lnd
1466        .client()
1467        .set_federation_routing_fee(dev_fed.fed.calculate_federation_id(), 0, 0)
1468        .await?;
1469    run_standard_load_test(&load_test_temp, &invite_code).await?;
1470    run_ln_circular_load_test(&load_test_temp, &invite_code).await?;
1471    Ok(())
1472}
1473
1474pub async fn run_standard_load_test(
1475    load_test_temp: &Path,
1476    invite_code: &str,
1477) -> anyhow::Result<()> {
1478    let output = cmd!(
1479        LoadTestTool,
1480        "--archive-dir",
1481        load_test_temp.display(),
1482        "--users",
1483        "1",
1484        "load-test",
1485        "--notes-per-user",
1486        "1",
1487        "--generate-invoice-with",
1488        "ldk-lightning-cli",
1489        "--invite-code",
1490        invite_code
1491    )
1492    .out_string()
1493    .await?;
1494    println!("{output}");
1495    anyhow::ensure!(
1496        output.contains("2 reissue_notes"),
1497        "reissued different number notes than expected"
1498    );
1499    anyhow::ensure!(
1500        output.contains("1 gateway_pay_invoice"),
1501        "paid different number of invoices than expected"
1502    );
1503    Ok(())
1504}
1505
1506pub async fn run_ln_circular_load_test(
1507    load_test_temp: &Path,
1508    invite_code: &str,
1509) -> anyhow::Result<()> {
1510    info!("Testing ln-circular-load-test with 'two-gateways' strategy");
1511    let output = cmd!(
1512        LoadTestTool,
1513        "--archive-dir",
1514        load_test_temp.display(),
1515        "--users",
1516        "1",
1517        "ln-circular-load-test",
1518        "--strategy",
1519        "two-gateways",
1520        "--test-duration-secs",
1521        "2",
1522        "--invite-code",
1523        invite_code
1524    )
1525    .out_string()
1526    .await?;
1527    println!("{output}");
1528    anyhow::ensure!(
1529        output.contains("gateway_create_invoice"),
1530        "missing invoice creation"
1531    );
1532    anyhow::ensure!(
1533        output.contains("gateway_pay_invoice_success"),
1534        "missing invoice payment"
1535    );
1536    anyhow::ensure!(
1537        output.contains("gateway_payment_received_success"),
1538        "missing received payment"
1539    );
1540
1541    info!("Testing ln-circular-load-test with 'partner-ping-pong' strategy");
1542    // Note: invite code isn't required because we already have an archive dir
1543    // Note: test-duration-secs needs to be greater than the timeout for
1544    // discover_api_version_set to work with degraded federations
1545    let output = cmd!(
1546        LoadTestTool,
1547        "--archive-dir",
1548        load_test_temp.display(),
1549        "--users",
1550        "1",
1551        "ln-circular-load-test",
1552        "--strategy",
1553        "partner-ping-pong",
1554        "--test-duration-secs",
1555        "6",
1556        "--invite-code",
1557        invite_code
1558    )
1559    .out_string()
1560    .await?;
1561    println!("{output}");
1562    anyhow::ensure!(
1563        output.contains("gateway_create_invoice"),
1564        "missing invoice creation"
1565    );
1566    anyhow::ensure!(
1567        output.contains("gateway_payment_received_success"),
1568        "missing received payment"
1569    );
1570
1571    info!("Testing ln-circular-load-test with 'self-payment' strategy");
1572    // Note invite code isn't required because we already have an archive dir
1573    let output = cmd!(
1574        LoadTestTool,
1575        "--archive-dir",
1576        load_test_temp.display(),
1577        "--users",
1578        "1",
1579        "ln-circular-load-test",
1580        "--strategy",
1581        "self-payment",
1582        "--test-duration-secs",
1583        "2",
1584        "--invite-code",
1585        invite_code
1586    )
1587    .out_string()
1588    .await?;
1589    println!("{output}");
1590    anyhow::ensure!(
1591        output.contains("gateway_create_invoice"),
1592        "missing invoice creation"
1593    );
1594    anyhow::ensure!(
1595        output.contains("gateway_payment_received_success"),
1596        "missing received payment"
1597    );
1598    Ok(())
1599}
1600
1601pub async fn lightning_gw_reconnect_test(
1602    dev_fed: DevFed,
1603    process_mgr: &ProcessManager,
1604) -> Result<()> {
1605    log_binary_versions().await?;
1606
1607    let DevFed {
1608        bitcoind,
1609        lnd,
1610        fed,
1611        mut gw_lnd,
1612        gw_ldk,
1613        ..
1614    } = dev_fed;
1615
1616    let client = fed
1617        .new_joined_client("lightning-gw-reconnect-test-client")
1618        .await?;
1619
1620    info!("Pegging-in both gateways");
1621    fed.pegin_gateways(99_999, vec![&gw_lnd]).await?;
1622
1623    // Drop other references to LND so that the test can kill it
1624    drop(lnd);
1625
1626    tracing::info!("Stopping LND");
1627    // Verify that the gateway can query the lightning node for the pubkey and alias
1628    assert!(gw_lnd.client().get_info().await.is_ok());
1629
1630    // Verify that after stopping the lightning node, info no longer returns the
1631    // node public key since the lightning node is unreachable.
1632    let ln_type = gw_lnd.ln.ln_type().to_string();
1633    gw_lnd.stop_lightning_node().await?;
1634    let lightning_info = gw_lnd.client().get_info().await?;
1635    if gw_lnd.gatewayd_version < *VERSION_0_10_0_ALPHA {
1636        let lightning_pub_key: Option<String> =
1637            serde_json::from_value(lightning_info["lightning_pub_key"].clone())?;
1638
1639        assert!(lightning_pub_key.is_none());
1640    } else {
1641        let not_connected = lightning_info["lightning_info"].clone();
1642        assert!(not_connected.as_str().expect("ln info is not a string") == "not_connected");
1643    }
1644
1645    // Restart LND
1646    tracing::info!("Restarting LND...");
1647    let new_lnd = Lnd::new(process_mgr, bitcoind.clone()).await?;
1648    gw_lnd.set_lightning_node(LightningNode::Lnd(new_lnd.clone()));
1649
1650    tracing::info!("Retrying info...");
1651    const MAX_RETRIES: usize = 30;
1652    const RETRY_INTERVAL: Duration = Duration::from_secs(1);
1653
1654    for i in 0..MAX_RETRIES {
1655        match do_try_create_and_pay_invoice(&gw_lnd, &client, &gw_ldk).await {
1656            Ok(()) => break,
1657            Err(e) => {
1658                if i == MAX_RETRIES - 1 {
1659                    return Err(e);
1660                }
1661                tracing::debug!(
1662                    "Pay invoice for gateway {} failed with {e:?}, retrying in {} seconds (try {}/{MAX_RETRIES})",
1663                    ln_type,
1664                    RETRY_INTERVAL.as_secs(),
1665                    i + 1,
1666                );
1667                fedimint_core::task::sleep_in_test(
1668                    "paying invoice for gateway failed",
1669                    RETRY_INTERVAL,
1670                )
1671                .await;
1672            }
1673        }
1674    }
1675
1676    info!(target: LOG_DEVIMINT, "lightning_reconnect_test: success");
1677    Ok(())
1678}
1679
1680pub async fn gw_reboot_test(dev_fed: DevFed, process_mgr: &ProcessManager) -> Result<()> {
1681    log_binary_versions().await?;
1682
1683    let DevFed {
1684        bitcoind,
1685        lnd,
1686        fed,
1687        gw_lnd,
1688        gw_ldk,
1689        gw_ldk_second,
1690        ..
1691    } = dev_fed;
1692
1693    let client = fed.new_joined_client("gw-reboot-test-client").await?;
1694    fed.pegin_client(10_000, &client).await?;
1695
1696    // Wait for gateways to sync to chain
1697    let block_height = bitcoind.get_block_count().await? - 1;
1698    try_join!(
1699        async { gw_lnd.client().wait_for_block_height(block_height).await },
1700        async { gw_ldk.client().wait_for_block_height(block_height).await },
1701    )?;
1702
1703    // Drop references to gateways so the test can kill them
1704    let lnd_gateway_id = gw_lnd.gateway_id.clone();
1705    let ldk_gateway_id = gw_ldk.gateway_id.clone();
1706    let gw_ldk_name = gw_ldk.gw_name.clone();
1707    let gw_ldk_port = gw_ldk.gw_port;
1708    let gw_lightning_port = gw_ldk.ldk_port;
1709    let gw_ldk_metrics_port = gw_ldk.metrics_port;
1710    drop(gw_lnd);
1711    drop(gw_ldk);
1712
1713    // Verify that making a payment while the gateways are down does not result in
1714    // funds being stuck
1715    info!("Making payment while gateway is down");
1716    let initial_client_balance = client.balance().await?;
1717    let invoice = gw_ldk_second.client().create_invoice(3000).await?;
1718    ln_pay(&client, invoice.to_string(), lnd_gateway_id.clone())
1719        .await
1720        .expect_err("Expected ln-pay to return error because the gateway is not online");
1721    let new_client_balance = client.balance().await?;
1722    anyhow::ensure!(initial_client_balance == new_client_balance);
1723
1724    // Reboot gateways with the same Lightning node instances
1725    info!("Rebooting gateways...");
1726    let (new_gw_lnd, new_gw_ldk) = try_join!(
1727        Gatewayd::new(process_mgr, LightningNode::Lnd(lnd.clone()), 0),
1728        Gatewayd::new(
1729            process_mgr,
1730            LightningNode::Ldk {
1731                name: gw_ldk_name,
1732                gw_port: gw_ldk_port,
1733                ldk_port: gw_lightning_port,
1734                metrics_port: gw_ldk_metrics_port,
1735            },
1736            1,
1737        )
1738    )?;
1739
1740    let lnd_gateway_id = fedimint_core::secp256k1::PublicKey::from_str(&lnd_gateway_id)?;
1741
1742    poll(
1743        "Waiting for LND Gateway Running state after reboot",
1744        || async {
1745            let lnd_value = new_gw_lnd.client().get_info().await.map_err(ControlFlow::Continue)?;
1746            let reboot_gateway_state: String = serde_json::from_value(lnd_value["gateway_state"].clone()).context("invalid gateway state").map_err(ControlFlow::Break)?;
1747            let reboot_gateway_id = fedimint_core::secp256k1::PublicKey::from_str(&new_gw_lnd.gateway_id).expect("Could not convert public key");
1748
1749            if reboot_gateway_state == "Running" {
1750                info!(target: LOG_DEVIMINT, "LND Gateway restarted, with auto-rejoin to federation");
1751                // Assert that the gateway info is the same as before the reboot
1752                assert_eq!(lnd_gateway_id, reboot_gateway_id);
1753                return Ok(());
1754            }
1755            Err(ControlFlow::Continue(anyhow!("gateway not running")))
1756        },
1757    )
1758    .await?;
1759
1760    let ldk_gateway_id = fedimint_core::secp256k1::PublicKey::from_str(&ldk_gateway_id)?;
1761    poll(
1762        "Waiting for LDK Gateway Running state after reboot",
1763        || async {
1764            let ldk_value = new_gw_ldk.client().get_info().await.map_err(ControlFlow::Continue)?;
1765            let reboot_gateway_state: String = serde_json::from_value(ldk_value["gateway_state"].clone()).context("invalid gateway state").map_err(ControlFlow::Break)?;
1766            let reboot_gateway_id = fedimint_core::secp256k1::PublicKey::from_str(&new_gw_ldk.gateway_id).expect("Could not convert public key");
1767
1768            if reboot_gateway_state == "Running" {
1769                info!(target: LOG_DEVIMINT, "LDK Gateway restarted, with auto-rejoin to federation");
1770                // Assert that the gateway info is the same as before the reboot
1771                assert_eq!(ldk_gateway_id, reboot_gateway_id);
1772                return Ok(());
1773            }
1774            Err(ControlFlow::Continue(anyhow!("gateway not running")))
1775        },
1776    )
1777    .await?;
1778
1779    info!(LOG_DEVIMINT, "gateway_reboot_test: success");
1780    Ok(())
1781}
1782
1783pub async fn do_try_create_and_pay_invoice(
1784    gw_lnd: &Gatewayd,
1785    client: &Client,
1786    gw_ldk: &Gatewayd,
1787) -> anyhow::Result<()> {
1788    // Verify that after the lightning node has restarted, the gateway
1789    // automatically reconnects and can query the lightning node
1790    // info again.
1791    poll("Waiting for info to succeed after restart", || async {
1792        gw_lnd
1793            .client()
1794            .lightning_pubkey()
1795            .await
1796            .map_err(ControlFlow::Continue)?;
1797        Ok(())
1798    })
1799    .await?;
1800
1801    tracing::info!("Creating invoice....");
1802    let invoice = ln_invoice(
1803        client,
1804        Amount::from_msats(1000),
1805        "incoming-over-lnd-gw".to_string(),
1806        gw_lnd.gateway_id.clone(),
1807    )
1808    .await?
1809    .invoice;
1810
1811    match &gw_lnd.ln.ln_type() {
1812        LightningNodeType::Lnd => {
1813            // Pay the invoice using LDK
1814            gw_ldk
1815                .client()
1816                .pay_invoice(Bolt11Invoice::from_str(&invoice).expect("Could not parse invoice"))
1817                .await?;
1818        }
1819        LightningNodeType::Ldk => {
1820            unimplemented!("do_try_create_and_pay_invoice not implemented for LDK yet");
1821        }
1822    }
1823    Ok(())
1824}
1825
1826async fn ln_pay(client: &Client, invoice: String, gw_id: String) -> anyhow::Result<String> {
1827    let value = cmd!(client, "ln-pay", invoice, "--gateway-id", gw_id,)
1828        .out_json()
1829        .await?;
1830    let outcome = serde_json::from_value::<LightningPaymentOutcome>(value)
1831        .expect("Could not deserialize Lightning payment outcome");
1832    match outcome {
1833        LightningPaymentOutcome::Success { preimage } => Ok(preimage),
1834        LightningPaymentOutcome::Failure { error_message } => {
1835            Err(anyhow!("Failed to pay lightning invoice: {error_message}"))
1836        }
1837    }
1838}
1839
1840async fn ln_invoice(
1841    client: &Client,
1842    amount: Amount,
1843    description: String,
1844    gw_id: String,
1845) -> anyhow::Result<LnInvoiceResponse> {
1846    let ln_response_val = cmd!(
1847        client,
1848        "ln-invoice",
1849        "--amount",
1850        amount.msats,
1851        format!("--description='{description}'"),
1852        "--gateway-id",
1853        gw_id,
1854    )
1855    .out_json()
1856    .await?;
1857
1858    let ln_invoice_response: LnInvoiceResponse = serde_json::from_value(ln_response_val)?;
1859
1860    Ok(ln_invoice_response)
1861}
1862
1863async fn lnv2_receive(
1864    client: &Client,
1865    gateway: &str,
1866    amount: u64,
1867) -> anyhow::Result<(Bolt11Invoice, OperationId)> {
1868    Ok(serde_json::from_value::<(Bolt11Invoice, OperationId)>(
1869        cmd!(
1870            client,
1871            "module",
1872            "lnv2",
1873            "receive",
1874            amount,
1875            "--gateway",
1876            gateway
1877        )
1878        .out_json()
1879        .await?,
1880    )?)
1881}
1882
1883async fn lnv2_send(client: &Client, gateway: &String, invoice: &String) -> anyhow::Result<()> {
1884    let send_op = serde_json::from_value::<OperationId>(
1885        cmd!(
1886            client,
1887            "module",
1888            "lnv2",
1889            "send",
1890            invoice,
1891            "--gateway",
1892            gateway
1893        )
1894        .out_json()
1895        .await?,
1896    )?;
1897
1898    let send_state = lnv2_await_send(client, send_op).await?;
1899    assert!(
1900        matches!(send_state, FinalSendOperationState::Success(_)),
1901        "unexpected send state: {send_state:?}"
1902    );
1903
1904    Ok(())
1905}
1906
1907/// Run `await-send` and parse the JSON, tolerating the pre-0.12 CLI output
1908/// shape so this works across the backwards-compatibility test matrix.
1909///
1910/// Pre-0.12 binaries serialize `FinalSendOperationState::Success` as the bare
1911/// string `"Success"` (unit variant); 0.12+ serializes it as `{"Success":
1912/// "<hex preimage>"}` (tuple variant carrying the preimage). Coerce the old
1913/// shape to a synthetic `Success(zero-preimage)` so callers can match
1914/// uniformly regardless of which CLI produced the output.
1915async fn lnv2_await_send(
1916    client: &Client,
1917    send_op: OperationId,
1918) -> anyhow::Result<FinalSendOperationState> {
1919    let raw = cmd!(
1920        client,
1921        "module",
1922        "lnv2",
1923        "await-send",
1924        serde_json::to_string(&send_op)?.substring(1, 65)
1925    )
1926    .out_json()
1927    .await?;
1928
1929    Ok(if raw.as_str() == Some("Success") {
1930        FinalSendOperationState::Success([0; 32])
1931    } else {
1932        serde_json::from_value(raw)?
1933    })
1934}
1935
1936pub async fn reconnect_test(dev_fed: DevFed, process_mgr: &ProcessManager) -> Result<()> {
1937    log_binary_versions().await?;
1938
1939    let DevFed {
1940        bitcoind, mut fed, ..
1941    } = dev_fed;
1942
1943    bitcoind.mine_blocks(110).await?;
1944    fed.await_block_sync().await?;
1945    fed.await_all_peers().await?;
1946
1947    // test a peer missing out on epochs and needing to rejoin
1948    fed.terminate_server(0).await?;
1949    fed.mine_then_wait_blocks_sync(100).await?;
1950
1951    fed.start_server(process_mgr, 0).await?;
1952    fed.mine_then_wait_blocks_sync(100).await?;
1953    fed.await_all_peers().await?;
1954    info!(target: LOG_DEVIMINT, "Server 0 successfully rejoined!");
1955    fed.mine_then_wait_blocks_sync(100).await?;
1956
1957    // now test what happens if consensus needs to be restarted
1958    fed.terminate_server(1).await?;
1959    fed.mine_then_wait_blocks_sync(100).await?;
1960    fed.terminate_server(2).await?;
1961    fed.terminate_server(3).await?;
1962
1963    fed.start_server(process_mgr, 1).await?;
1964    fed.start_server(process_mgr, 2).await?;
1965    fed.start_server(process_mgr, 3).await?;
1966
1967    fed.await_all_peers().await?;
1968
1969    info!(target: LOG_DEVIMINT, "fm success: reconnect-test");
1970    Ok(())
1971}
1972
1973pub async fn recoverytool_test(dev_fed: DevFed) -> Result<()> {
1974    log_binary_versions().await?;
1975
1976    let DevFed { bitcoind, fed, .. } = dev_fed;
1977
1978    let data_dir = env::var(FM_DATA_DIR_ENV)?;
1979    let client = fed.new_joined_client("recoverytool-test-client").await?;
1980
1981    let mut fed_utxos_sats = HashSet::from([12_345_000, 23_456_000, 34_567_000]);
1982    let deposit_fees = fed.deposit_fees()?.msats / 1000;
1983    for sats in &fed_utxos_sats {
1984        // pegin_client automatically adds fees, so we need to counteract that
1985        fed.pegin_client(*sats - deposit_fees, &client).await?;
1986    }
1987
1988    async fn withdraw(
1989        client: &Client,
1990        bitcoind: &crate::external::Bitcoind,
1991        fed_utxos_sats: &mut HashSet<u64>,
1992    ) -> Result<()> {
1993        let withdrawal_address = bitcoind.get_new_address().await?;
1994        let withdraw_res = cmd!(
1995            client,
1996            "withdraw",
1997            "--address",
1998            &withdrawal_address,
1999            "--amount",
2000            "5000 sat"
2001        )
2002        .out_json()
2003        .await?;
2004
2005        let fees_sat = withdraw_res["fees_sat"]
2006            .as_u64()
2007            .expect("withdrawal should contain fees");
2008        let txid: Txid = withdraw_res["txid"]
2009            .as_str()
2010            .expect("withdrawal should contain txid string")
2011            .parse()
2012            .expect("txid should be parsable");
2013        let tx_hex = bitcoind.poll_get_transaction(txid).await?;
2014
2015        let tx = bitcoin::Transaction::consensus_decode_hex(&tx_hex, &ModuleRegistry::default())?;
2016        assert_eq!(tx.input.len(), 1);
2017        assert_eq!(tx.output.len(), 2);
2018
2019        let change_output = tx
2020            .output
2021            .iter()
2022            .find(|o| o.to_owned().script_pubkey != withdrawal_address.script_pubkey())
2023            .expect("withdrawal must have change output");
2024        assert!(fed_utxos_sats.insert(change_output.value.to_sat()));
2025
2026        // Remove the utxo consumed from the withdrawal tx
2027        let total_output_sats = tx.output.iter().map(|o| o.value.to_sat()).sum::<u64>();
2028        let input_sats = total_output_sats + fees_sat;
2029        assert!(fed_utxos_sats.remove(&input_sats));
2030
2031        Ok(())
2032    }
2033
2034    // Initiate multiple withdrawals in a session to verify the recoverytool
2035    // recognizes change outputs
2036    for _ in 0..2 {
2037        withdraw(&client, &bitcoind, &mut fed_utxos_sats).await?;
2038    }
2039
2040    let total_fed_sats = fed_utxos_sats.iter().sum::<u64>();
2041    fed.finalize_mempool_tx().await?;
2042
2043    // We are done transacting and save the current session id so we can wait for
2044    // the next session later on. We already save it here so that if in the meantime
2045    // a session is generated we don't wait for another.
2046    let last_tx_session = client.get_session_count().await?;
2047
2048    info!("Recovering using utxos method");
2049    let output = cmd!(
2050        crate::util::Recoverytool,
2051        "--cfg",
2052        "{data_dir}/fedimintd-default-0",
2053        "utxos",
2054        "--db",
2055        "{data_dir}/fedimintd-default-0/database"
2056    )
2057    .out_json()
2058    .await?;
2059    let outputs = output.as_array().context("expected an array")?;
2060    assert_eq!(outputs.len(), fed_utxos_sats.len());
2061
2062    assert_eq!(
2063        outputs
2064            .iter()
2065            .map(|o| o["amount_sat"].as_u64().unwrap())
2066            .collect::<HashSet<_>>(),
2067        fed_utxos_sats
2068    );
2069    let utxos_descriptors = outputs
2070        .iter()
2071        .map(|o| o["descriptor"].as_str().unwrap())
2072        .collect::<HashSet<_>>();
2073
2074    debug!(target: LOG_DEVIMINT, ?utxos_descriptors, "recoverytool descriptors using UTXOs method");
2075
2076    let descriptors_json = serde_json::value::to_raw_value(&serde_json::Value::Array(vec![
2077        serde_json::Value::Array(
2078            utxos_descriptors
2079                .iter()
2080                .map(|d| {
2081                    json!({
2082                        "desc": d,
2083                        "timestamp": 0,
2084                    })
2085                })
2086                .collect(),
2087        ),
2088    ]))?;
2089    info!("Getting wallet balances before import");
2090    let bitcoin_client = bitcoind.wallet_client().await?;
2091    let balances_before = bitcoin_client.get_balances().await?;
2092    info!("Importing descriptors into bitcoin wallet");
2093    let request = bitcoin_client
2094        .get_jsonrpc_client()
2095        .build_request("importdescriptors", Some(&descriptors_json));
2096    let response = block_in_place(|| bitcoin_client.get_jsonrpc_client().send_request(request))?;
2097    response.check_error()?;
2098    info!("Getting wallet balances after import");
2099    let balances_after = bitcoin_client.get_balances().await?;
2100    let diff = balances_after.mine.immature + balances_after.mine.trusted
2101        - balances_before.mine.immature
2102        - balances_before.mine.trusted;
2103
2104    // We need to wait for a session to be generated to make sure we have the signed
2105    // session outcome in our DB. If there ever is another problem here: wait for
2106    // fedimintd-0 specifically to acknowledge the session switch. In practice this
2107    // should be sufficiently synchronous though.
2108    client.wait_session_outcome(last_tx_session).await?;
2109
2110    // Funds from descriptors should match the fed's utxos
2111    assert_eq!(diff.to_sat(), total_fed_sats);
2112    info!("Recovering using epochs method");
2113
2114    let outputs = cmd!(
2115        crate::util::Recoverytool,
2116        "--cfg",
2117        "{data_dir}/fedimintd-default-0",
2118        "epochs",
2119        "--db",
2120        "{data_dir}/fedimintd-default-0/database"
2121    )
2122    .out_json()
2123    .await?
2124    .as_array()
2125    .context("expected an array")?
2126    .clone();
2127
2128    let epochs_descriptors = outputs
2129        .iter()
2130        .map(|o| o["descriptor"].as_str().unwrap())
2131        .collect::<HashSet<_>>();
2132
2133    // nosemgrep: use-err-formatting
2134    debug!(target: LOG_DEVIMINT, ?epochs_descriptors, "recoverytool descriptors using epochs method");
2135
2136    // Epochs method includes descriptors from spent outputs, so we only need to
2137    // verify the epochs method includes all available utxos
2138    for utxo_descriptor in utxos_descriptors {
2139        assert!(epochs_descriptors.contains(utxo_descriptor));
2140    }
2141    Ok(())
2142}
2143
2144pub async fn guardian_backup_test(dev_fed: DevFed, process_mgr: &ProcessManager) -> Result<()> {
2145    const PEER_TO_TEST: u16 = 0;
2146
2147    log_binary_versions().await?;
2148
2149    let DevFed { mut fed, .. } = dev_fed;
2150
2151    fed.await_all_peers()
2152        .await
2153        .expect("Awaiting federation coming online failed");
2154
2155    let client = fed.new_joined_client("guardian-client").await?;
2156    let old_block_count = cmd!(
2157        client,
2158        "dev",
2159        "api",
2160        "--peer-id",
2161        PEER_TO_TEST.to_string(),
2162        "--module",
2163        "wallet",
2164        "block_count",
2165    )
2166    .out_json()
2167    .await?["value"]
2168        .as_u64()
2169        .expect("No block height returned");
2170
2171    let backup_res = cmd!(
2172        client,
2173        "--our-id",
2174        PEER_TO_TEST.to_string(),
2175        "--password",
2176        "pass",
2177        "admin",
2178        "guardian-config-backup"
2179    )
2180    .out_json()
2181    .await?;
2182    let backup_hex = backup_res["tar_archive_bytes"]
2183        .as_str()
2184        .expect("expected hex string");
2185    let backup_tar = hex::decode(backup_hex).expect("invalid hex");
2186
2187    let data_dir = fed
2188        .vars
2189        .get(&PEER_TO_TEST.into())
2190        .expect("peer not found")
2191        .FM_DATA_DIR
2192        .clone();
2193
2194    fed.terminate_server(PEER_TO_TEST.into())
2195        .await
2196        .expect("could not terminate fedimintd");
2197
2198    std::fs::remove_dir_all(&data_dir).expect("error deleting old datadir");
2199    std::fs::create_dir(&data_dir).expect("error creating new datadir");
2200
2201    let write_file = |name: &str, data: &[u8]| {
2202        let mut file = std::fs::File::options()
2203            .write(true)
2204            .create(true)
2205            .truncate(true)
2206            .open(data_dir.join(name))
2207            .expect("could not open file");
2208        file.write_all(data).expect("could not write file");
2209        file.flush().expect("could not flush file");
2210    };
2211
2212    write_file("backup.tar", &backup_tar);
2213
2214    assert_eq!(
2215        std::process::Command::new("tar")
2216            .arg("-xf")
2217            .arg("backup.tar")
2218            .current_dir(&data_dir)
2219            .spawn()
2220            .expect("error spawning tar")
2221            .wait()
2222            .expect("error extracting archive")
2223            .code(),
2224        Some(0),
2225        "tar failed"
2226    );
2227
2228    // If the backup contains encrypted config (from old fedimintd), write the
2229    // password file so the current fedimintd can read it
2230    if data_dir.join("private.encrypt").exists() {
2231        write_file("password.private", "pass".as_bytes());
2232    }
2233
2234    fed.start_server(process_mgr, PEER_TO_TEST.into())
2235        .await
2236        .expect("could not restart fedimintd");
2237
2238    poll("Peer catches up again", || async {
2239        let block_counts = all_peer_block_count(&client, fed.member_ids())
2240            .await
2241            .map_err(ControlFlow::Continue)?;
2242        let block_count = block_counts[&PeerId::from(PEER_TO_TEST)];
2243
2244        info!("Caught up to block {block_count} of at least {old_block_count} (counts={block_counts:?})");
2245
2246        if block_count < old_block_count {
2247            return Err(ControlFlow::Continue(anyhow!("Block count still behind")));
2248        }
2249
2250        Ok(())
2251    })
2252    .await
2253    .expect("Peer didn't rejoin federation");
2254
2255    Ok(())
2256}
2257
2258async fn peer_block_count(client: &Client, peer: PeerId) -> Result<u64> {
2259    cmd!(
2260        client,
2261        "dev",
2262        "api",
2263        "--peer-id",
2264        peer.to_string(),
2265        "--module",
2266        "wallet",
2267        "block_count",
2268    )
2269    .out_json()
2270    .await?["value"]
2271        .as_u64()
2272        .context("No block height returned")
2273}
2274
2275async fn all_peer_block_count(
2276    client: &Client,
2277    peers: impl Iterator<Item = PeerId>,
2278) -> Result<BTreeMap<PeerId, u64>> {
2279    let mut peer_heights = BTreeMap::new();
2280    for peer in peers {
2281        peer_heights.insert(peer, peer_block_count(client, peer).await?);
2282    }
2283    Ok(peer_heights)
2284}
2285
2286pub async fn cannot_replay_tx_test(dev_fed: DevFed) -> Result<()> {
2287    log_binary_versions().await?;
2288
2289    let DevFed { fed, .. } = dev_fed;
2290
2291    let client = fed.new_joined_client("cannot-replay-client").await?;
2292
2293    const CLIENT_START_AMOUNT: u64 = 10_000_000_000;
2294    const CLIENT_SPEND_AMOUNT: u64 = 5_000_000_000;
2295
2296    let initial_client_balance = client.balance().await?;
2297    assert_eq!(initial_client_balance, 0);
2298
2299    fed.pegin_client(CLIENT_START_AMOUNT / 1000, &client)
2300        .await?;
2301
2302    // Fork client before spending ecash so we can later attempt a double spend
2303    let double_spend_client = client.new_forked("double-spender").await?;
2304
2305    // Spend and reissue all ecash from the client
2306    let notes = cmd!(client, "spend", CLIENT_SPEND_AMOUNT)
2307        .out_json()
2308        .await?
2309        .get("notes")
2310        .expect("Output didn't contain e-cash notes")
2311        .as_str()
2312        .unwrap()
2313        .to_owned();
2314
2315    let client_post_spend_balance = client.balance().await?;
2316    crate::util::almost_equal(
2317        client_post_spend_balance,
2318        CLIENT_START_AMOUNT - CLIENT_SPEND_AMOUNT,
2319        10_000,
2320    )
2321    .unwrap();
2322
2323    cmd!(client, "reissue", notes).out_json().await?;
2324    let client_post_reissue_balance = client.balance().await?;
2325    crate::util::almost_equal(client_post_reissue_balance, CLIENT_START_AMOUNT, 20_000).unwrap();
2326
2327    // Attempt to spend the same ecash from the forked client
2328    let double_spend_notes = cmd!(double_spend_client, "spend", CLIENT_SPEND_AMOUNT)
2329        .out_json()
2330        .await?
2331        .get("notes")
2332        .expect("Output didn't contain e-cash notes")
2333        .as_str()
2334        .unwrap()
2335        .to_owned();
2336
2337    let double_spend_client_post_spend_balance = double_spend_client.balance().await?;
2338    crate::util::almost_equal(
2339        double_spend_client_post_spend_balance,
2340        CLIENT_START_AMOUNT - CLIENT_SPEND_AMOUNT,
2341        10_000,
2342    )
2343    .unwrap();
2344
2345    cmd!(double_spend_client, "reissue", double_spend_notes)
2346        .assert_error_contains("The transaction had an invalid input")
2347        .await?;
2348
2349    let double_spend_client_post_spend_balance = double_spend_client.balance().await?;
2350    crate::util::almost_equal(
2351        double_spend_client_post_spend_balance,
2352        CLIENT_START_AMOUNT - CLIENT_SPEND_AMOUNT,
2353        10_000,
2354    )
2355    .unwrap();
2356
2357    Ok(())
2358}
2359
2360/// Test that client can init even when the federation is down
2361///
2362/// See <https://github.com/fedimint/fedimint/issues/6939>
2363pub async fn test_offline_client_initialization(
2364    dev_fed: DevFed,
2365    _process_mgr: &ProcessManager,
2366) -> Result<()> {
2367    log_binary_versions().await?;
2368
2369    let DevFed { mut fed, .. } = dev_fed;
2370
2371    // Ensure federation is properly initialized and all peers are online
2372    fed.await_all_peers().await?;
2373
2374    // Create and join a client while all servers are online
2375    let client = fed.new_joined_client("offline-test-client").await?;
2376
2377    // Verify client can get info while federation is online
2378    const INFO_COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
2379    let online_info =
2380        fedimint_core::runtime::timeout(INFO_COMMAND_TIMEOUT, cmd!(client, "info").out_json())
2381            .await
2382            .context("Client info command timed out while federation was online")?
2383            .context("Client info command failed while federation was online")?;
2384    info!(target: LOG_DEVIMINT, "Client info while federation online: {:?}", online_info);
2385
2386    // Shutdown all federation servers
2387    info!(target: LOG_DEVIMINT, "Shutting down all federation servers...");
2388    fed.terminate_all_servers().await?;
2389
2390    // Wait a moment to ensure servers are fully shutdown
2391    fedimint_core::task::sleep_in_test("wait for federation shutdown", Duration::from_secs(2))
2392        .await;
2393
2394    // Test that client info command still works with all servers offline
2395    // This should work because client info doesn't require server communication
2396    // for basic federation metadata and local state
2397    info!(target: LOG_DEVIMINT, "Testing client info command with all servers offline...");
2398    let offline_info =
2399        fedimint_core::runtime::timeout(INFO_COMMAND_TIMEOUT, cmd!(client, "info").out_json())
2400            .await
2401            .context("Client info command timed out while federation was offline")?
2402            .context("Client info command failed while federation was offline")?;
2403
2404    info!(target: LOG_DEVIMINT, "Client info while federation offline: {:?}", offline_info);
2405
2406    Ok(())
2407}
2408
2409/// Test that client can detect federation config changes when servers restart
2410/// with new module configurations
2411///
2412/// This test starts a fresh federation, dumps the client config, then stops all
2413/// servers and modifies their configs by adding a new meta module instance. The
2414/// client should detect this configuration change after the servers restart.
2415pub async fn test_client_config_change_detection(
2416    dev_fed: DevFed,
2417    process_mgr: &ProcessManager,
2418) -> Result<()> {
2419    log_binary_versions().await?;
2420
2421    // This test edits the guardians' on-disk config out-of-band and restarts
2422    // them. Since v0.12.0-alpha the private config is stored as plaintext
2423    // `private.json`; older fedimintd only reads the encrypted `private.encrypt`
2424    // format, so the rewritten config would be invisible to it on restart.
2425    let fedimintd_version = crate::util::FedimintdCmd::version_or_default().await;
2426    if fedimintd_version < *VERSION_0_12_0_ALPHA {
2427        info!(
2428            "Skipping test_client_config_change_detection - requires fedimintd v0.12.0-alpha or later"
2429        );
2430        return Ok(());
2431    }
2432
2433    let DevFed { mut fed, .. } = dev_fed;
2434    let peer_ids: Vec<_> = fed.member_ids().collect();
2435
2436    fed.await_all_peers().await?;
2437
2438    let client = fed.new_joined_client("config-change-test-client").await?;
2439
2440    info!(target: LOG_DEVIMINT, "Getting initial client configuration...");
2441    let initial_config = cmd!(client, "config")
2442        .out_json()
2443        .await
2444        .context("Failed to get initial client config")?;
2445
2446    info!(target: LOG_DEVIMINT, "Initial config modules: {:?}", initial_config["modules"].as_object().unwrap().keys().collect::<Vec<_>>());
2447
2448    let data_dir = env::var(FM_DATA_DIR_ENV)?;
2449    let config_dir = PathBuf::from(&data_dir);
2450
2451    // Shutdown all federation servers
2452    //
2453    // In prod. one would probably use a coordinated shutdown, just to be
2454    // careful, but since the change is only adding a new module that does
2455    // not submit CIs without user/admin interaction, there is
2456    // no way for the consensus to diverge.
2457    info!(target: LOG_DEVIMINT, "Shutting down all federation servers...");
2458    fed.terminate_all_servers().await?;
2459
2460    // Wait for servers to fully shutdown
2461    fedimint_core::task::sleep_in_test("wait for federation shutdown", Duration::from_secs(2))
2462        .await;
2463
2464    info!(target: LOG_DEVIMINT, "Modifying server configurations to add new meta module...");
2465    modify_server_configs(&config_dir, &peer_ids).await?;
2466
2467    // Restart all servers with modified configs
2468    info!(target: LOG_DEVIMINT, "Restarting all servers with modified configurations...");
2469    for peer_id in peer_ids {
2470        fed.start_server(process_mgr, peer_id.to_usize()).await?;
2471    }
2472
2473    // Wait for federation to stabilize
2474    info!(target: LOG_DEVIMINT, "Wait for peers to get back up");
2475    fed.await_all_peers().await?;
2476
2477    // Use fedimint-cli dev wait to let the client read the new config in background
2478    info!(target: LOG_DEVIMINT, "Waiting for client to fetch updated configuration...");
2479    cmd!(client, "dev", "wait", "3")
2480        .run()
2481        .await
2482        .context("Failed to wait for client config update")?;
2483
2484    // Test that client switched to the new config
2485    info!(target: LOG_DEVIMINT, "Testing client detection of configuration changes...");
2486    let updated_config = cmd!(client, "config")
2487        .out_json()
2488        .await
2489        .context("Failed to get updated client config")?;
2490
2491    info!(target: LOG_DEVIMINT, "Updated config modules: {:?}", updated_config["modules"].as_object().unwrap().keys().collect::<Vec<_>>());
2492
2493    // Verify that the configuration has changed (new meta module should be present)
2494    let initial_modules = initial_config["modules"].as_object().unwrap();
2495    let updated_modules = updated_config["modules"].as_object().unwrap();
2496
2497    anyhow::ensure!(
2498        updated_modules.len() > initial_modules.len(),
2499        "Expected more modules in updated config. Initial: {}, Updated: {}",
2500        initial_modules.len(),
2501        updated_modules.len()
2502    );
2503
2504    // Check if a new meta module was added
2505    let new_meta_module = updated_modules.iter().find(|(module_id, module_config)| {
2506        module_config["kind"].as_str() == Some("meta") && !initial_modules.contains_key(*module_id)
2507    });
2508
2509    let new_meta_module_id = new_meta_module
2510        .map(|(id, _)| id)
2511        .with_context(|| "Expected to find new meta module in updated configuration")?;
2512
2513    info!(target: LOG_DEVIMINT, "Found new meta module with id: {}", new_meta_module_id);
2514
2515    // Verify client operations still work with the new configuration
2516    info!(target: LOG_DEVIMINT, "Verifying client operations work with new configuration...");
2517    let final_info = cmd!(client, "info")
2518        .out_json()
2519        .await
2520        .context("Client info command failed with updated configuration")?;
2521
2522    info!(target: LOG_DEVIMINT, "Client successfully adapted to configuration changes: {:?}", final_info["federation_id"]);
2523
2524    Ok(())
2525}
2526
2527/// Modify server configuration files to add a new meta module instance
2528async fn modify_server_configs(config_dir: &Path, peer_ids: &[PeerId]) -> Result<()> {
2529    for &peer_id in peer_ids {
2530        modify_single_peer_config(config_dir, peer_id).await?;
2531    }
2532    Ok(())
2533}
2534
2535/// Modify configuration files for a single peer to add a new meta module
2536/// instance
2537async fn modify_single_peer_config(config_dir: &Path, peer_id: PeerId) -> Result<()> {
2538    use fedimint_core::core::ModuleInstanceId;
2539    use fedimint_server::config::io::read_server_config;
2540    use serde_json::Value;
2541
2542    info!(target: LOG_DEVIMINT, %peer_id, "Modifying config for peer");
2543    let peer_dir = config_dir.join(format!("fedimintd-default-{}", peer_id.to_usize()));
2544
2545    // Read consensus config
2546    let consensus_config_path = peer_dir.join("consensus.json");
2547    let consensus_config_content = fs::read_to_string(&consensus_config_path)
2548        .await
2549        .with_context(|| format!("Failed to read consensus config for peer {peer_id}"))?;
2550
2551    let mut consensus_config: Value = serde_json::from_str(&consensus_config_content)
2552        .with_context(|| format!("Failed to parse consensus config for peer {peer_id}"))?;
2553
2554    // Read the private config using the server config reader
2555    let server_config = read_server_config(&peer_dir)
2556        .with_context(|| format!("Failed to read server config for peer {peer_id}"))?;
2557
2558    // Find existing meta module in configs to use as template
2559    let consensus_config_modules = consensus_config["modules"]
2560        .as_object()
2561        .with_context(|| format!("No modules found in consensus config for peer {peer_id}"))?;
2562
2563    // Look for existing meta module to copy its configuration
2564    let existing_meta_consensus = consensus_config_modules
2565        .values()
2566        .find(|module_config| module_config["kind"].as_str() == Some("meta"));
2567
2568    let existing_meta_consensus = existing_meta_consensus
2569        .with_context(|| {
2570            format!("No existing meta module found in consensus config for peer {peer_id}")
2571        })?
2572        .clone();
2573
2574    // Find existing meta module in private config
2575    let existing_meta_instance_id = server_config
2576        .consensus
2577        .modules
2578        .iter()
2579        .find(|(_, config)| config.kind.as_str() == "meta")
2580        .map(|(id, _)| *id)
2581        .with_context(|| {
2582            format!("No existing meta module found in private config for peer {peer_id}")
2583        })?;
2584
2585    let existing_meta_private = server_config
2586        .private
2587        .modules
2588        .get(&existing_meta_instance_id)
2589        .with_context(|| format!("Failed to get existing meta private config for peer {peer_id}"))?
2590        .clone();
2591
2592    // Find the highest existing module ID for the new module
2593    let last_existing_module_id = consensus_config_modules
2594        .keys()
2595        .filter_map(|id| id.parse::<u32>().ok())
2596        .max()
2597        .unwrap_or(0);
2598
2599    let new_module_id = (last_existing_module_id + 1).to_string();
2600    let new_module_instance_id = ModuleInstanceId::from((last_existing_module_id + 1) as u16);
2601
2602    info!(
2603        "Adding new meta module with id {} for peer {} (copying existing meta module config)",
2604        new_module_id, peer_id
2605    );
2606
2607    // Add new meta module to consensus config by copying existing meta module
2608    if let Some(modules) = consensus_config["modules"].as_object_mut() {
2609        modules.insert(new_module_id.clone(), existing_meta_consensus);
2610    }
2611
2612    // Add new meta module to private config
2613    let mut updated_private_config = server_config.private.clone();
2614    updated_private_config
2615        .modules
2616        .insert(new_module_instance_id, existing_meta_private);
2617
2618    // Write back the modified consensus and client configs
2619    let updated_consensus_content = serde_json::to_string_pretty(&consensus_config)
2620        .with_context(|| format!("Failed to serialize consensus config for peer {peer_id}"))?;
2621
2622    write_overwrite_async(&consensus_config_path, updated_consensus_content)
2623        .await
2624        .with_context(|| format!("Failed to write consensus config for peer {peer_id}"))?;
2625
2626    // Write back the modified private config as plaintext JSON
2627    let private_json_path = peer_dir.join("private.json");
2628    let private_config_content = serde_json::to_string_pretty(&updated_private_config)
2629        .with_context(|| format!("Failed to serialize private config for peer {peer_id}"))?;
2630
2631    write_overwrite_async(&private_json_path, private_config_content)
2632        .await
2633        .with_context(|| format!("Failed to write private config for peer {peer_id}"))?;
2634
2635    info!("Successfully modified configs for peer {}", peer_id);
2636    Ok(())
2637}
2638
2639/// Tests the `admin auth` command that stores admin credentials in the client
2640/// database, allowing subsequent admin commands to run without --our-id and
2641/// --password.
2642pub async fn admin_auth_tests(dev_fed: DevFed) -> Result<()> {
2643    log_binary_versions().await?;
2644
2645    let DevFed { fed, .. } = dev_fed;
2646
2647    // Ensure all federation peers are ready before starting the test
2648    // This prevents flaky failures due to Iroh connection timeouts
2649    fed.await_all_peers().await?;
2650
2651    let client = fed.new_joined_client("admin-auth-test-client").await?;
2652
2653    let peer_id = 0;
2654
2655    info!(target: LOG_DEVIMINT, "Testing admin auth command stores credentials");
2656
2657    // First, store the admin credentials using the auth command
2658    // Use --no-verify to skip interactive verification in tests
2659    let auth_result = cmd!(
2660        client,
2661        "--our-id",
2662        &peer_id.to_string(),
2663        "--password",
2664        "pass",
2665        "admin",
2666        "auth",
2667        "--peer-id",
2668        &peer_id.to_string(),
2669        "--password",
2670        "pass",
2671        "--no-verify",
2672        "--force"
2673    )
2674    .out_json()
2675    .await
2676    .context("Admin auth command failed")?;
2677
2678    info!(target: LOG_DEVIMINT, ?auth_result, "Admin auth command completed");
2679
2680    // Verify the response contains expected fields
2681    assert_eq!(
2682        auth_result
2683            .get("peer_id")
2684            .and_then(serde_json::Value::as_u64),
2685        Some(peer_id as u64),
2686        "peer_id in response should match"
2687    );
2688    assert_eq!(
2689        auth_result
2690            .get("status")
2691            .and_then(serde_json::Value::as_str),
2692        Some("saved"),
2693        "status should be 'saved'"
2694    );
2695
2696    info!(target: LOG_DEVIMINT, "Testing that stored credentials are used automatically");
2697
2698    // Now run an admin command WITHOUT --our-id and --password
2699    // It should use the stored credentials automatically
2700    let status_result = cmd!(client, "admin", "status")
2701        .out_json()
2702        .await
2703        .context("Admin status command should succeed with stored credentials")?;
2704
2705    info!(target: LOG_DEVIMINT, ?status_result, "Admin status with stored credentials succeeded");
2706
2707    info!(target: LOG_DEVIMINT, "Testing that --force overwrites existing credentials");
2708
2709    // Test that --force allows overwriting
2710    let auth_result_force = cmd!(
2711        client,
2712        "--our-id",
2713        &peer_id.to_string(),
2714        "--password",
2715        "pass",
2716        "admin",
2717        "auth",
2718        "--peer-id",
2719        &peer_id.to_string(),
2720        "--password",
2721        "pass",
2722        "--no-verify",
2723        "--force"
2724    )
2725    .out_json()
2726    .await
2727    .context("Admin auth force overwrite failed")?;
2728
2729    assert_eq!(
2730        auth_result_force.get("status").and_then(|v| v.as_str()),
2731        Some("saved"),
2732        "Force overwrite should succeed"
2733    );
2734
2735    info!(target: LOG_DEVIMINT, "admin_auth_tests completed successfully");
2736
2737    Ok(())
2738}
2739
2740#[derive(Subcommand)]
2741pub enum LatencyTest {
2742    Reissue,
2743    LnSend,
2744    LnReceive,
2745    FmPay,
2746    Restore,
2747}
2748
2749#[derive(Subcommand)]
2750pub enum UpgradeTest {
2751    Fedimintd {
2752        #[arg(long, trailing_var_arg = true, num_args=1..)]
2753        paths: Vec<PathBuf>,
2754    },
2755    FedimintCli {
2756        #[arg(long, trailing_var_arg = true, num_args=1..)]
2757        paths: Vec<PathBuf>,
2758    },
2759    Gatewayd {
2760        #[arg(long, trailing_var_arg = true, num_args=1..)]
2761        gatewayd_paths: Vec<PathBuf>,
2762        #[arg(long, trailing_var_arg = true, num_args=1..)]
2763        gateway_cli_paths: Vec<PathBuf>,
2764    },
2765}
2766
2767#[derive(Subcommand)]
2768pub enum TestCmd {
2769    /// `devfed` then checks the average latency of reissuing ecash, LN receive,
2770    /// and LN send
2771    LatencyTests {
2772        #[clap(subcommand)]
2773        r#type: LatencyTest,
2774
2775        #[arg(long, default_value = "10")]
2776        iterations: usize,
2777    },
2778    /// `devfed` then kills and restarts most of the Guardian nodes in a 4 node
2779    /// fedimint
2780    ReconnectTest,
2781    /// `devfed` then tests a bunch of the fedimint-cli commands
2782    CliTests,
2783    /// `devfed` then tests guardian metadata functionality
2784    GuardianMetadataTests,
2785    /// `devfed` then calls binary `fedimint-load-test-tool`. See
2786    /// `LoadTestArgs`.
2787    LoadTestToolTest,
2788    /// `devfed` then pegin LND Gateway. Kill the LN node,
2789    /// restart it, rejjoin fedimint and test payments still work
2790    LightningReconnectTest,
2791    /// `devfed` then reboot gateway daemon for both LDK and LND. Test
2792    /// afterward.
2793    GatewayRebootTest,
2794    /// `devfed` then tests if the recovery tool is able to do a basic recovery
2795    RecoverytoolTests,
2796    /// `devfed` then tests LNv1 LNURL receive behavior after client recovery
2797    LnurlRecoveryTest,
2798    /// `devfed` then spawns faucet for wasm tests
2799    WasmTestSetup {
2800        #[arg(long, trailing_var_arg = true, allow_hyphen_values = true, num_args=1..)]
2801        exec: Option<Vec<ffi::OsString>>,
2802    },
2803    /// Restore guardian from downloaded backup
2804    GuardianBackup,
2805    /// `devfed` then tests that spent ecash cannot be double spent
2806    CannotReplayTransaction,
2807    /// Tests that client info commands work when all federation servers are
2808    /// offline
2809    TestOfflineClientInitialization,
2810    /// Tests that client can detect federation config changes when servers
2811    /// restart with new module configurations
2812    TestClientConfigChangeDetection,
2813    /// `devfed` then tests admin auth credential storage
2814    TestAdminAuth,
2815    /// Test upgrade paths for a given binary
2816    UpgradeTests {
2817        #[clap(subcommand)]
2818        binary: UpgradeTest,
2819        #[arg(long)]
2820        lnv2: String,
2821    },
2822}
2823
2824pub async fn handle_command(cmd: TestCmd, common_args: CommonArgs) -> Result<()> {
2825    match cmd {
2826        TestCmd::WasmTestSetup { exec } => {
2827            let (process_mgr, task_group) = setup(common_args).await?;
2828            let main = {
2829                let task_group = task_group.clone();
2830                async move {
2831                    let dev_fed = dev_fed(&process_mgr).await?;
2832                    let gw_lnd = dev_fed.gw_lnd.clone();
2833                    let fed = dev_fed.fed.clone();
2834                    gw_lnd
2835                        .client()
2836                        .set_federation_routing_fee(dev_fed.fed.calculate_federation_id(), 0, 0)
2837                        .await?;
2838                    task_group.spawn_cancellable("faucet", async move {
2839                        if let Err(err) = crate::faucet::run(
2840                            &dev_fed,
2841                            format!("0.0.0.0:{}", process_mgr.globals.FM_PORT_FAUCET),
2842                            process_mgr.globals.FM_PORT_GW_LND,
2843                        )
2844                        .await
2845                        {
2846                            error!("Error spawning faucet: {err}");
2847                        }
2848                    });
2849                    try_join!(fed.pegin_gateways(30_000, vec![&gw_lnd]), async {
2850                        poll("waiting for faucet startup", || async {
2851                            TcpStream::connect(format!(
2852                                "127.0.0.1:{}",
2853                                process_mgr.globals.FM_PORT_FAUCET
2854                            ))
2855                            .await
2856                            .context("connect to faucet")
2857                            .map_err(ControlFlow::Continue)
2858                        })
2859                        .await?;
2860                        Ok(())
2861                    },)?;
2862                    if let Some(exec) = exec {
2863                        exec_user_command(exec).await?;
2864                        task_group.shutdown();
2865                    }
2866                    Ok::<_, anyhow::Error>(())
2867                }
2868            };
2869            cleanup_on_exit(main, task_group).await?;
2870        }
2871        TestCmd::LatencyTests { r#type, iterations } => {
2872            let (process_mgr, _) = setup(common_args).await?;
2873            let dev_fed = dev_fed(&process_mgr).await?;
2874            latency_tests(dev_fed, r#type, None, iterations, true).await?;
2875        }
2876        TestCmd::ReconnectTest => {
2877            let (process_mgr, _) = setup(common_args).await?;
2878            let dev_fed = dev_fed(&process_mgr).await?;
2879            reconnect_test(dev_fed, &process_mgr).await?;
2880        }
2881        TestCmd::CliTests => {
2882            let (process_mgr, _) = setup(common_args).await?;
2883            let dev_fed = dev_fed(&process_mgr).await?;
2884            cli_tests(dev_fed).await?;
2885        }
2886        TestCmd::GuardianMetadataTests => {
2887            let (process_mgr, _) = setup(common_args).await?;
2888            let dev_fed = dev_fed(&process_mgr).await?;
2889            guardian_metadata_tests(dev_fed).await?;
2890        }
2891        TestCmd::LoadTestToolTest => {
2892            // For the load test tool test, explicitly disable mint base fees
2893            unsafe { std::env::set_var(FM_DISABLE_BASE_FEES_ENV, "1") };
2894
2895            let (process_mgr, _) = setup(common_args).await?;
2896            let dev_fed = dev_fed(&process_mgr).await?;
2897            cli_load_test_tool_test(dev_fed).await?;
2898        }
2899        TestCmd::LightningReconnectTest => {
2900            let (process_mgr, _) = setup(common_args).await?;
2901            let dev_fed = dev_fed(&process_mgr).await?;
2902            lightning_gw_reconnect_test(dev_fed, &process_mgr).await?;
2903        }
2904        TestCmd::GatewayRebootTest => {
2905            let (process_mgr, _) = setup(common_args).await?;
2906            let dev_fed = dev_fed(&process_mgr).await?;
2907            gw_reboot_test(dev_fed, &process_mgr).await?;
2908        }
2909        TestCmd::RecoverytoolTests => {
2910            let (process_mgr, _) = setup(common_args).await?;
2911            let dev_fed = dev_fed(&process_mgr).await?;
2912            recoverytool_test(dev_fed).await?;
2913        }
2914        TestCmd::LnurlRecoveryTest => {
2915            let (process_mgr, _) = setup(common_args).await?;
2916            let dev_fed = dev_fed(&process_mgr).await?;
2917            lnurl_recovery_test(dev_fed).await?;
2918        }
2919        TestCmd::GuardianBackup => {
2920            let (process_mgr, _) = setup(common_args).await?;
2921            let dev_fed = dev_fed(&process_mgr).await?;
2922            guardian_backup_test(dev_fed, &process_mgr).await?;
2923        }
2924        TestCmd::CannotReplayTransaction => {
2925            let (process_mgr, _) = setup(common_args).await?;
2926            let dev_fed = dev_fed(&process_mgr).await?;
2927            cannot_replay_tx_test(dev_fed).await?;
2928        }
2929        TestCmd::TestOfflineClientInitialization => {
2930            let (process_mgr, _) = setup(common_args).await?;
2931            let dev_fed = dev_fed(&process_mgr).await?;
2932            test_offline_client_initialization(dev_fed, &process_mgr).await?;
2933        }
2934        TestCmd::TestClientConfigChangeDetection => {
2935            let (process_mgr, _) = setup(common_args).await?;
2936            let dev_fed = dev_fed(&process_mgr).await?;
2937            test_client_config_change_detection(dev_fed, &process_mgr).await?;
2938        }
2939        TestCmd::TestAdminAuth => {
2940            // Check versions early before starting infrastructure
2941            let fedimint_cli_version = crate::util::FedimintCli::version_or_default().await;
2942            let fedimintd_version = crate::util::FedimintdCmd::version_or_default().await;
2943
2944            if fedimint_cli_version < *VERSION_0_11_0_ALPHA
2945                || fedimintd_version < *VERSION_0_11_0_ALPHA
2946            {
2947                info!(target: LOG_DEVIMINT, "Skipping admin_auth_tests - requires v0.11.0-alpha or later");
2948                return Ok(());
2949            }
2950
2951            let (process_mgr, _) = setup(common_args).await?;
2952            let dev_fed = dev_fed(&process_mgr).await?;
2953            admin_auth_tests(dev_fed).await?;
2954        }
2955        TestCmd::UpgradeTests { binary, lnv2 } => {
2956            // TODO: Audit that the environment access only happens in single-threaded code.
2957            unsafe { std::env::set_var(FM_ENABLE_MODULE_LNV2_ENV, lnv2) };
2958            let (process_mgr, _) = setup(common_args).await?;
2959            Box::pin(upgrade_tests(&process_mgr, binary)).await?;
2960        }
2961    }
2962    Ok(())
2963}