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