Skip to main content

devimint/
tests.rs

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