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