Skip to main content

lnv2_module_tests/
tests.rs

1use std::path::PathBuf;
2use std::str::FromStr;
3use std::time::Duration;
4
5use anyhow::{bail, ensure};
6use bitcoin::hashes::{Hash, sha256};
7use clap::{Parser, Subcommand};
8use devimint::devfed::{DevFed, DevJitFed};
9use devimint::envs::FM_CLIENT_DIR_ENV;
10use devimint::federation::{Client, Federation};
11use devimint::util::{ProcessManager, almost_equal, poll_simple};
12use devimint::version_constants::{
13    VERSION_0_10_0_ALPHA, VERSION_0_11_0_ALPHA, VERSION_0_12_0_ALPHA, VERSION_0_12_2_ALPHA,
14};
15use devimint::{Gatewayd, cmd, util};
16use fedimint_core::core::OperationId;
17use fedimint_core::encoding::Encodable;
18use fedimint_core::task::{self};
19use fedimint_core::util::{backoff_util, retry, write_overwrite_async};
20use fedimint_lnurl::{LnurlResponse, VerifyResponse, parse_lnurl};
21use fedimint_lnv2_client::FinalSendOperationState;
22use lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescriptionRef};
23use serde::Deserialize;
24use tokio::try_join;
25use tracing::info;
26
27#[path = "common.rs"]
28mod common;
29
30async fn module_is_present(client: &Client, kind: &str) -> anyhow::Result<bool> {
31    let modules = cmd!(client, "module").out_json().await?;
32
33    let modules = modules["list"].as_array().expect("module list is an array");
34
35    Ok(modules.iter().any(|m| m["kind"].as_str() == Some(kind)))
36}
37
38async fn assert_module_sanity(client: &Client) -> anyhow::Result<()> {
39    if !devimint::util::is_backwards_compatibility_test() {
40        ensure!(
41            !module_is_present(client, "ln").await?,
42            "ln module should not be present"
43        );
44    }
45
46    Ok(())
47}
48
49#[derive(Parser)]
50#[command(name = "lnv2-module-tests")]
51#[command(about = "LNv2 module integration tests", long_about = None)]
52struct Cli {
53    #[command(subcommand)]
54    command: Option<Commands>,
55}
56
57#[derive(Subcommand)]
58enum Commands {
59    /// Run gateway registration tests
60    GatewayRegistration,
61    /// Run payment tests
62    Payments,
63    /// Run LNURL pay tests
64    LnurlPay,
65    /// Test LNURL receives after recovery from seed
66    LnurlRecovery,
67    /// Two clients racing to pay the same invoice settle exactly once
68    DuplicatePayment,
69    /// The gateway refuses to fund a receive while the federation cannot
70    /// reach consensus
71    FederationOutage,
72}
73
74#[tokio::main]
75async fn main() -> anyhow::Result<()> {
76    let cli = Cli::parse();
77
78    // The duplicate-payment test needs a second federation; every other test
79    // uses a single one.
80    let num_feds = if matches!(cli.command, None | Some(Commands::DuplicatePayment)) {
81        2
82    } else {
83        1
84    };
85
86    devimint::run_devfed_test()
87        .num_feds(num_feds)
88        .call(|dev_fed, process_mgr| async move {
89            if !devimint::util::supports_lnv2() {
90                info!("lnv2 is disabled, skipping");
91                return Ok(());
92            }
93
94            match &cli.command {
95                Some(Commands::GatewayRegistration) => {
96                    test_gateway_registration(&dev_fed).await?;
97                }
98                Some(Commands::Payments) => {
99                    test_payments(&dev_fed).await?;
100                }
101                Some(Commands::LnurlPay) => {
102                    pegin_gateways(&dev_fed).await?;
103                    test_lnurl_pay(&dev_fed).await?;
104                }
105                Some(Commands::LnurlRecovery) => {
106                    pegin_gateways(&dev_fed).await?;
107                    test_lnurl_recovery(&dev_fed).await?;
108                }
109                Some(Commands::DuplicatePayment) => {
110                    pegin_gateways(&dev_fed).await?;
111                    test_duplicate_payment(&dev_fed, &process_mgr).await?;
112                }
113                Some(Commands::FederationOutage) => {
114                    pegin_gateways(&dev_fed).await?;
115                    let dev_fed = dev_fed.to_dev_fed(&process_mgr).await?;
116                    test_federation_outage(dev_fed, &process_mgr).await?;
117                }
118                None => {
119                    // Run all tests if no subcommand is specified
120                    test_gateway_registration(&dev_fed).await?;
121                    test_payments(&dev_fed).await?;
122                    test_duplicate_payment(&dev_fed, &process_mgr).await?;
123                    test_lnurl_pay(&dev_fed).await?;
124                    test_lnurl_recovery(&dev_fed).await?;
125                    // Last, since it takes ownership of the federation to
126                    // stop and restart guardians.
127                    pegin_gateways(&dev_fed).await?;
128                    let dev_fed = dev_fed.to_dev_fed(&process_mgr).await?;
129                    test_federation_outage(dev_fed, &process_mgr).await?;
130                }
131            }
132
133            info!("Testing LNV2 is complete!");
134
135            Ok(())
136        })
137        .await
138}
139
140async fn pegin_gateways(dev_fed: &DevJitFed) -> anyhow::Result<()> {
141    info!("Pegging-in gateways...");
142
143    let federation = dev_fed.fed().await?;
144
145    let gw_lnd = dev_fed.gw_lnd().await?;
146    let gw_ldk = dev_fed.gw_ldk().await?;
147
148    federation
149        .pegin_gateways(1_000_000, vec![gw_lnd, gw_ldk])
150        .await?;
151
152    Ok(())
153}
154
155async fn test_gateway_registration(dev_fed: &DevJitFed) -> anyhow::Result<()> {
156    let client = dev_fed
157        .fed()
158        .await?
159        .new_joined_client("lnv2-test-gateway-registration-client")
160        .await?;
161
162    assert_module_sanity(&client).await?;
163
164    let gw_lnd = dev_fed.gw_lnd().await?;
165    let gw_ldk = dev_fed.gw_ldk_connected().await?;
166
167    let gateways = [gw_lnd.addr.clone(), gw_ldk.addr.clone()];
168
169    info!("Removing the gateways devimint added on startup...");
170
171    let peers = (0..dev_fed.fed().await?.members.len()).collect::<Vec<usize>>();
172    remove_all_gateways(&client, &peers).await?;
173
174    info!("Testing registration of gateways...");
175
176    for gateway in &gateways {
177        for peer in 0..dev_fed.fed().await?.members.len() {
178            assert!(add_gateway(&client, peer, gateway).await?);
179        }
180    }
181
182    assert_eq!(
183        cmd!(client, "module", "lnv2", "gateways", "list")
184            .out_json()
185            .await?
186            .as_array()
187            .expect("JSON Value is not an array")
188            .len(),
189        2
190    );
191
192    assert_eq!(
193        cmd!(client, "module", "lnv2", "gateways", "list", "--peer", "0")
194            .out_json()
195            .await?
196            .as_array()
197            .expect("JSON Value is not an array")
198            .len(),
199        2
200    );
201
202    info!("Testing selection of gateways...");
203
204    assert!(
205        gateways.contains(
206            &cmd!(client, "module", "lnv2", "gateways", "select")
207                .out_json()
208                .await?
209                .as_str()
210                .expect("JSON Value is not a string")
211                .to_string()
212        )
213    );
214
215    cmd!(client, "module", "lnv2", "gateways", "map")
216        .out_json()
217        .await?;
218
219    for _ in 0..10 {
220        for gateway in &gateways {
221            let invoice = common::receive(&client, gateway, 1_000_000).await?.0;
222
223            assert_eq!(
224                cmd!(
225                    client,
226                    "module",
227                    "lnv2",
228                    "gateways",
229                    "select",
230                    "--invoice",
231                    invoice.to_string()
232                )
233                .out_json()
234                .await?
235                .as_str()
236                .expect("JSON Value is not a string"),
237                gateway
238            )
239        }
240    }
241
242    info!("Testing deregistration of gateways...");
243
244    for gateway in &gateways {
245        for peer in 0..dev_fed.fed().await?.members.len() {
246            assert!(remove_gateway(&client, peer, gateway).await?);
247        }
248    }
249
250    assert!(
251        cmd!(client, "module", "lnv2", "gateways", "list")
252            .out_json()
253            .await?
254            .as_array()
255            .expect("JSON Value is not an array")
256            .is_empty(),
257    );
258
259    assert!(
260        cmd!(client, "module", "lnv2", "gateways", "list", "--peer", "0")
261            .out_json()
262            .await?
263            .as_array()
264            .expect("JSON Value is not an array")
265            .is_empty()
266    );
267
268    Ok(())
269}
270
271async fn test_payments(dev_fed: &DevJitFed) -> anyhow::Result<()> {
272    let federation = dev_fed.fed().await?;
273
274    let client = federation
275        .new_joined_client("lnv2-test-payments-client")
276        .await?;
277
278    assert_module_sanity(&client).await?;
279
280    federation.pegin_client(10_000, &client).await?;
281
282    almost_equal(client.balance().await?, 10_000 * 1000, 500_000).unwrap();
283
284    let gw_lnd = dev_fed.gw_lnd().await?;
285    let gw_ldk = dev_fed.gw_ldk().await?;
286    let lnd = dev_fed.lnd().await?;
287
288    // Gateways before 0.12 forward *every* pending HOLD invoice on their
289    // Lightning node to the gateway's payment handler and cancel the ones
290    // they cannot match to a registered LNv2 incoming contract. That includes
291    // the HOLD invoice this test creates directly on the LND node the LND
292    // gateway shares, so the gateway's cancel races this test's settle and
293    // fails it with `invoice already canceled` whenever the gateway wins.
294    // Only exercise HOLD invoices against gateways that filter out HOLD
295    // invoices they did not create themselves.
296    let test_hold_invoice = gw_lnd.gatewayd_version >= *VERSION_0_12_0_ALPHA;
297
298    let hold_invoice = if test_hold_invoice {
299        Some(lnd.create_hold_invoice(60000).await?)
300    } else {
301        info!(
302            gatewayd_version = %gw_lnd.gatewayd_version,
303            "Skipping HOLD invoice payment: gateway cancels HOLD invoices it did not create"
304        );
305
306        None
307    };
308
309    let gateway_pairs = [(gw_lnd, gw_ldk), (gw_ldk, gw_lnd)];
310
311    let gateway_matrix = [
312        (gw_lnd, gw_lnd),
313        (gw_lnd, gw_ldk),
314        (gw_ldk, gw_lnd),
315        (gw_ldk, gw_ldk),
316    ];
317
318    info!("Testing refund of circular payments...");
319
320    for (gw_send, gw_receive) in gateway_matrix {
321        info!(
322            "Testing refund of payment: client -> {} -> {} -> client",
323            gw_send.ln.ln_type(),
324            gw_receive.ln.ln_type()
325        );
326
327        let invoice = common::receive(&client, &gw_receive.addr, 1_000_000)
328            .await?
329            .0;
330
331        let state = common::send(&client, &gw_send.addr, &invoice.to_string()).await?;
332        assert!(matches!(state, FinalSendOperationState::Refunded));
333    }
334
335    pegin_gateways(dev_fed).await?;
336
337    info!("Testing circular payments...");
338
339    for (gw_send, gw_receive) in gateway_matrix {
340        info!(
341            "Testing payment: client -> {} -> {} -> client",
342            gw_send.ln.ln_type(),
343            gw_receive.ln.ln_type()
344        );
345
346        let (invoice, receive_op) = common::receive(&client, &gw_receive.addr, 1_000_000).await?;
347
348        let state = common::send(&client, &gw_send.addr, &invoice.to_string()).await?;
349        assert!(matches!(state, FinalSendOperationState::Success(_)));
350
351        common::await_receive_claimed(&client, receive_op).await?;
352    }
353
354    info!("Testing payments from client to gateways...");
355
356    for (gw_send, gw_receive) in gateway_pairs {
357        info!(
358            "Testing payment: client -> {} -> {}",
359            gw_send.ln.ln_type(),
360            gw_receive.ln.ln_type()
361        );
362
363        let invoice = gw_receive.client().create_invoice(1_000_000).await?;
364
365        let state = common::send(&client, &gw_send.addr, &invoice.to_string()).await?;
366        assert!(matches!(state, FinalSendOperationState::Success(_)));
367    }
368
369    info!("Testing payments from gateways to client...");
370
371    for (gw_send, gw_receive) in gateway_pairs {
372        info!(
373            "Testing payment: {} -> {} -> client",
374            gw_send.ln.ln_type(),
375            gw_receive.ln.ln_type()
376        );
377
378        let (invoice, receive_op) = common::receive(&client, &gw_receive.addr, 1_000_000).await?;
379
380        gw_send.client().pay_invoice(invoice).await?;
381
382        common::await_receive_claimed(&client, receive_op).await?;
383    }
384
385    retry(
386        "Waiting for the full balance to become available to the client".to_string(),
387        backoff_util::background_backoff(),
388        || async {
389            ensure!(client.balance().await? >= 9000 * 1000);
390
391            Ok(())
392        },
393    )
394    .await?;
395
396    if let Some((hold_preimage, hold_invoice, hold_payment_hash)) = hold_invoice {
397        info!("Testing Client can pay LND HOLD invoice via LDK Gateway...");
398
399        let (state, _) = try_join!(
400            common::send(&client, &gw_ldk.addr, &hold_invoice),
401            lnd.settle_hold_invoice(hold_preimage, hold_payment_hash),
402        )?;
403        assert!(matches!(state, FinalSendOperationState::Success(_)));
404    }
405
406    info!("Testing LNv2 lightning fees...");
407
408    let fed_id = federation.calculate_federation_id();
409
410    gw_lnd
411        .client()
412        .set_federation_routing_fee(fed_id.clone(), 0, 0)
413        .await?;
414
415    gw_lnd
416        .client()
417        .set_federation_transaction_fee(fed_id.clone(), 0, 0)
418        .await?;
419
420    // Gateway pays: 1_000 msat LNv2 federation base fee. Gateway receives:
421    // 1_000_000 payment.
422    test_fees(fed_id, &client, gw_lnd, gw_ldk, 1_000_000 - 1_000).await?;
423
424    let online_peers: Vec<usize> = federation.members.keys().copied().collect();
425
426    test_iroh_payment(&client, gw_lnd, gw_ldk, &online_peers).await?;
427
428    info!("Testing payment summary...");
429
430    let lnd_payment_summary = gw_lnd.client().payment_summary().await?;
431
432    assert_eq!(lnd_payment_summary.outgoing.total_success, 5);
433    assert_eq!(lnd_payment_summary.outgoing.total_failure, 2);
434    assert_eq!(lnd_payment_summary.incoming.total_success, 4);
435    assert_eq!(lnd_payment_summary.incoming.total_failure, 0);
436
437    assert!(lnd_payment_summary.outgoing.median_latency.is_some());
438    assert!(lnd_payment_summary.outgoing.average_latency.is_some());
439    assert!(lnd_payment_summary.incoming.median_latency.is_some());
440    assert!(lnd_payment_summary.incoming.average_latency.is_some());
441
442    let ldk_payment_summary = gw_ldk.client().payment_summary().await?;
443
444    assert_eq!(
445        ldk_payment_summary.outgoing.total_success,
446        if test_hold_invoice { 4 } else { 3 }
447    );
448    assert_eq!(ldk_payment_summary.outgoing.total_failure, 2);
449    assert_eq!(ldk_payment_summary.incoming.total_success, 4);
450    assert_eq!(ldk_payment_summary.incoming.total_failure, 0);
451
452    assert!(ldk_payment_summary.outgoing.median_latency.is_some());
453    assert!(ldk_payment_summary.outgoing.average_latency.is_some());
454    assert!(ldk_payment_summary.incoming.median_latency.is_some());
455    assert!(ldk_payment_summary.incoming.average_latency.is_some());
456
457    Ok(())
458}
459
460/// Creates a fresh client and joins it to the federation identified by
461/// `invite_code`, unlike `Federation::new_joined_client` which always joins
462/// whichever federation last wrote the shared invite-code file.
463async fn join_client(name: &str, invite_code: &str) -> anyhow::Result<Client> {
464    let client = Client::create(name).await?;
465    client.join_federation(invite_code.to_string()).await?;
466    Ok(client)
467}
468
469/// Three independent clients — two in the primary federation and one in a
470/// second federation, all served by the same gateway — race to pay the *same*
471/// invoice. The gateway pays the invoice only once, so it may claim only one of
472/// the three outgoing contracts regardless of which federation funded them.
473/// Exactly one payment settles; how the other two end depends on the gateway
474/// version, see [`assert_duplicates_refunded`] and
475/// [`assert_duplicates_refused`].
476async fn test_duplicate_payment(
477    dev_fed: &DevJitFed,
478    process_mgr: &ProcessManager,
479) -> anyhow::Result<()> {
480    info!(
481        "Testing three clients across two federations paying the same invoice settle exactly once..."
482    );
483
484    // The LDK gateway is used to send: its `pay` is idempotent per payment hash,
485    // so all contracts share a single Lightning payment and preimage.
486    let gw_send = dev_fed.gw_ldk().await?;
487    let gw_receive = dev_fed.gw_lnd().await?;
488
489    // The cross-federation dedup lives in the gateway's global database, added in
490    // 0.12. Older gateways would claim both contracts, so skip against them.
491    if gw_send.gatewayd_version < *VERSION_0_12_0_ALPHA {
492        info!(
493            gatewayd_version = %gw_send.gatewayd_version,
494            "Skipping: gateway predates cross-federation outgoing payment dedup"
495        );
496        return Ok(());
497    }
498
499    let first_federation = dev_fed.fed().await?;
500
501    // `Federation::invite_code` reads a single shared invite-code file, so
502    // capture the primary federation's invite before spawning the second one
503    // overwrites it. Clients must be joined with the right invite explicitly;
504    // `new_joined_client` would join whichever federation wrote that file last.
505    let first_invite = first_federation.invite_code()?;
506
507    // A second federation, also served by the sending gateway, covers the
508    // cross-federation case: a contract funded in one federation must still
509    // block claiming a contract for the same invoice funded in another.
510    let second_federation = Federation::new(
511        process_mgr,
512        dev_fed.bitcoind().await?.clone(),
513        false,
514        false,
515        false,
516        1,
517        "lnv2-duplicate-payment".to_string(),
518    )
519    .await?;
520    let second_invite = second_federation.invite_code()?;
521
522    assert_ne!(
523        first_federation.calculate_federation_id(),
524        second_federation.calculate_federation_id(),
525        "the second federation must be distinct from the first"
526    );
527
528    // Spawning the second federation overwrote the shared invite-code file
529    // with its own invite. Restore the primary federation's invite now that
530    // both invites are captured: `Federation::invite_code` re-reads that file,
531    // so a later `new_joined_client` on the primary federation would otherwise
532    // join the second one, which is torn down when this test returns.
533    let client_dir: PathBuf = std::env::var(FM_CLIENT_DIR_ENV)?.parse()?;
534    write_overwrite_async(client_dir.join("invite-code"), &first_invite).await?;
535
536    gw_send.client().connect_fed(second_invite.clone()).await?;
537    second_federation
538        .pegin_gateways(1_000_000, vec![gw_send])
539        .await?;
540
541    let client_a = join_client("lnv2-duplicate-payment-a", &first_invite).await?;
542    let client_b = join_client("lnv2-duplicate-payment-b", &first_invite).await?;
543    let client_c = join_client("lnv2-duplicate-payment-c", &second_invite).await?;
544
545    first_federation.pegin_client(10_000, &client_a).await?;
546    first_federation.pegin_client(10_000, &client_b).await?;
547    second_federation.pegin_client(10_000, &client_c).await?;
548
549    // Control: the second-federation client can pay a *different* invoice through
550    // the gateway on its own. This proves the gateway serves the second
551    // federation, so any refund of the shared invoice below is caused by the
552    // duplicate payment rather than a second-federation setup problem.
553    let second_federation_id = second_federation.calculate_federation_id();
554    let second_balance_before_control = gw_send
555        .client()
556        .ecash_balance(second_federation_id.clone())
557        .await?;
558    let control_invoice = gw_receive
559        .client()
560        .create_invoice(1_000_000)
561        .await?
562        .to_string();
563    let control = common::send(&client_c, &gw_send.addr, &control_invoice).await?;
564    assert!(
565        matches!(control, FinalSendOperationState::Success(_)),
566        "second-federation control payment must succeed, got {control:?}"
567    );
568
569    // The sender sees the preimage once the gateway's claim is accepted, which
570    // can be before the claimed ecash reaches the gateway. Wait for it, so the
571    // gateway's balances only move below because of the shared invoice.
572    poll_simple(
573        "Waiting for the gateway to claim the control payment",
574        || async {
575            let balance = gw_send
576                .client()
577                .ecash_balance(second_federation_id.clone())
578                .await?;
579            ensure!(
580                balance > second_balance_before_control,
581                "gateway has not claimed the control payment yet"
582            );
583            Ok(())
584        },
585    )
586    .await?;
587
588    let invoice = gw_receive
589        .client()
590        .create_invoice(1_000_000)
591        .await?
592        .to_string();
593
594    // Before 0.12.2 the gateway claimed the payment image only after paying,
595    // and forfeited every later contract for it, so those were refunded at once.
596    // Since then it claims the image when it accepts a send and refuses the
597    // others outright, so their senders are only refunded at contract expiry.
598    if gw_send.gatewayd_version < *VERSION_0_12_2_ALPHA {
599        return assert_duplicates_refunded(&client_a, &client_b, &client_c, gw_send, &invoice)
600            .await;
601    }
602
603    assert_duplicates_refused(
604        [&client_a, &client_b, &client_c],
605        gw_send,
606        [
607            first_federation.calculate_federation_id(),
608            second_federation_id,
609        ],
610        &invoice,
611    )
612    .await
613}
614
615/// How long a send refused by the gateway is watched to stay unresolved.
616const REFUSED_SEND_WATCH: Duration = Duration::from_secs(30);
617
618/// The duplicate-payment assertions for gateways that claim the payment image
619/// only after paying and forfeit the duplicates: one settles, two are refunded.
620async fn assert_duplicates_refunded(
621    client_a: &Client,
622    client_b: &Client,
623    client_c: &Client,
624    gw_send: &Gatewayd,
625    invoice: &str,
626) -> anyhow::Result<()> {
627    // The two clients in the first federation race for the invoice; the
628    // per-federation dedup settles exactly one of them and refunds the other.
629    let (state_a, state_b) = try_join!(
630        common::send(client_a, &gw_send.addr, invoice),
631        common::send(client_b, &gw_send.addr, invoice),
632    )?;
633
634    // The second-federation client then pays the same, now-settled invoice. The
635    // gateway already holds the preimage, so without cross-federation dedup it
636    // claims this contract too, being reimbursed twice for one Lightning payment.
637    let state_c = common::send(client_c, &gw_send.addr, invoice).await?;
638
639    let states = [state_a, state_b, state_c];
640
641    info!("shared-invoice send states: {states:?}");
642
643    let successes = states
644        .iter()
645        .filter(|state| matches!(state, FinalSendOperationState::Success(_)))
646        .count();
647
648    let refunds = states
649        .iter()
650        .filter(|state| matches!(state, FinalSendOperationState::Refunded))
651        .count();
652
653    assert_eq!(
654        (successes, refunds),
655        (1, 2),
656        "exactly one payment must settle and the other two be refunded, got {states:?}"
657    );
658
659    Ok(())
660}
661
662/// The duplicate-payment assertions for gateways that claim the payment image
663/// at intake: one send settles, the gateway refuses the other two without
664/// paying or forfeiting anything, so they stay unresolved until their contracts
665/// expire, and the gateway is reimbursed exactly once.
666async fn assert_duplicates_refused(
667    [client_a, client_b, client_c]: [&Client; 3],
668    gw_send: &Gatewayd,
669    [first_federation_id, second_federation_id]: [String; 2],
670    invoice: &str,
671) -> anyhow::Result<()> {
672    let first_balance_before = gw_send
673        .client()
674        .ecash_balance(first_federation_id.clone())
675        .await?;
676    let second_balance_before = gw_send
677        .client()
678        .ecash_balance(second_federation_id.clone())
679        .await?;
680
681    // The two clients in the first federation race for the invoice. Whichever
682    // the gateway accepts first settles; the other is refused and stays funded.
683    let (op_a, op_b) = try_join!(
684        common::start_send(client_a, &gw_send.addr, invoice),
685        common::start_send(client_b, &gw_send.addr, invoice),
686    )?;
687
688    let (state_a, state_b) = tokio::join!(
689        await_send_within(client_a, op_a, REFUSED_SEND_WATCH),
690        await_send_within(client_b, op_b, REFUSED_SEND_WATCH),
691    );
692
693    // The second-federation client then pays the same, now-settled invoice. The
694    // claim is global to the gateway, so it is refused as well.
695    let op_c = common::start_send(client_c, &gw_send.addr, invoice).await?;
696    let state_c = await_send_within(client_c, op_c, REFUSED_SEND_WATCH).await;
697
698    let states = [state_a?, state_b?, state_c?];
699
700    info!("shared-invoice send states (None = unresolved): {states:?}");
701
702    let successes = states
703        .iter()
704        .filter(|state| matches!(state, Some(FinalSendOperationState::Success(_))))
705        .count();
706
707    let unresolved = states.iter().filter(|state| state.is_none()).count();
708
709    assert_eq!(
710        (successes, unresolved),
711        (1, 2),
712        "exactly one payment must settle and the other two stay unresolved, got {states:?}"
713    );
714
715    // A contract costs at least the invoice amount, so reimbursement for a
716    // second one would at least double the gain.
717    let invoice_msats = Bolt11Invoice::from_str(invoice)?
718        .amount_milli_satoshis()
719        .expect("the invoice has an amount");
720
721    let first_gain = gw_send
722        .client()
723        .ecash_balance(first_federation_id)
724        .await?
725        .checked_sub(first_balance_before)
726        .expect("the gateway's balance in the first federation must not shrink");
727
728    assert!(
729        (invoice_msats..2 * invoice_msats).contains(&first_gain),
730        "the gateway must be reimbursed for exactly one contract, gained {first_gain} msat"
731    );
732
733    assert_eq!(
734        gw_send.client().ecash_balance(second_federation_id).await?,
735        second_balance_before,
736        "the gateway must not claim the second federation's contract"
737    );
738
739    Ok(())
740}
741
742/// Waits up to `watch` for a send to reach its final state, returning `None`
743/// if it is still unresolved by then.
744async fn await_send_within(
745    client: &Client,
746    operation_id: OperationId,
747    watch: Duration,
748) -> anyhow::Result<Option<FinalSendOperationState>> {
749    match tokio::time::timeout(watch, common::await_send(client, operation_id)).await {
750        Ok(state) => state.map(Some),
751        Err(_) => Ok(None),
752    }
753}
754
755/// Stops enough guardians that no threshold of them can answer, checks that
756/// a receive is refused without the gateway funding anything, then checks
757/// that receives work again once the guardians are back.
758async fn test_federation_outage(
759    dev_fed: DevFed,
760    process_mgr: &ProcessManager,
761) -> anyhow::Result<()> {
762    info!("Testing that the gateway refuses to fund a receive during a federation outage...");
763
764    let DevFed {
765        mut fed,
766        gw_lnd,
767        gw_ldk,
768        ..
769    } = dev_fed;
770
771    if gw_lnd.gatewayd_version < *VERSION_0_12_2_ALPHA {
772        info!(
773            gatewayd_version = %gw_lnd.gatewayd_version,
774            "Skipping: gateway predates the federation liveness probe"
775        );
776        return Ok(());
777    }
778
779    let client = fed
780        .new_joined_client("lnv2-federation-outage-client")
781        .await?;
782    fed.await_all_peers().await?;
783
784    let federation_id = fed.calculate_federation_id();
785    let gateway = gw_lnd.client().address();
786
787    // Register the invoice while the federation is healthy so that only the
788    // funding decision is exercised below.
789    let (invoice, _) = common::receive(&client, &gateway, 100_000).await?;
790    let balance_before = gw_lnd.client().ecash_balance(federation_id.clone()).await?;
791
792    // Stop the smallest number of guardians that leaves fewer than a
793    // threshold of them running.
794    let fed_size = process_mgr.globals.FM_FED_SIZE;
795    let max_evil = (fed_size - 1) / 3;
796    let stopped: Vec<usize> = (fed_size - max_evil - 1..fed_size).collect();
797    for peer in &stopped {
798        fed.terminate_server(*peer).await?;
799    }
800
801    info!(
802        ?stopped,
803        "Paying invoice while the federation cannot reach consensus"
804    );
805    // The payment runs concurrently: a gateway that funds anyway holds the
806    // HTLC until the guardians are back, so awaiting it here would block the
807    // restart below.
808    let payment = fedimint_core::runtime::spawn("lnv2-outage-payment", {
809        let gw_ldk = gw_ldk.client();
810        async move { gw_ldk.pay_invoice(invoice).await }
811    });
812
813    // Bring the guardians back only once the gateway has acted on the HTLC:
814    // a refusal fails the payment, while funding consumes ecash at submission
815    // even though the transaction cannot land yet.
816    poll_simple("Waiting for the gateway to act on the HTLC", || async {
817        if payment.is_finished() {
818            return Ok(());
819        }
820        let balance = gw_lnd.client().ecash_balance(federation_id.clone()).await?;
821        if balance < balance_before {
822            return Ok(());
823        }
824        bail!("gateway has not acted on the HTLC yet")
825    })
826    .await?;
827
828    for peer in &stopped {
829        fed.start_server(process_mgr, *peer).await?;
830    }
831    fed.await_all_peers().await?;
832
833    // Funding queued during the outage would land now that the guardians are
834    // back, and the payment would complete against it. The probe exists so
835    // that it does not.
836    let outcome = payment.await?;
837    info!(?outcome, "Payment outcome once the guardians are back");
838    ensure!(
839        outcome.is_err(),
840        "payment must fail: the gateway funded an incoming contract during the outage"
841    );
842    ensure!(
843        gw_lnd.client().ecash_balance(federation_id.clone()).await? == balance_before,
844        "gateway must not fund an incoming contract while the federation cannot reach consensus"
845    );
846
847    // The gateway's own connections to the restarted guardians come back on
848    // their own schedule, and the probe refuses receives until they do, so
849    // retry with a fresh invoice each time; a refused invoice is cancelled.
850    info!("Paying a fresh invoice after the guardians are back");
851    poll_simple("Waiting for receives to succeed again", || async {
852        let (invoice, _) = common::receive(&client, &gateway, 100_000).await?;
853        gw_ldk.client().pay_invoice(invoice).await
854    })
855    .await?;
856    ensure!(
857        gw_lnd.client().ecash_balance(federation_id).await? < balance_before,
858        "gateway must fund the incoming contract once the federation is back"
859    );
860
861    info!("Federation outage test complete");
862    Ok(())
863}
864
865async fn test_fees(
866    fed_id: String,
867    client: &Client,
868    gw_lnd: &Gatewayd,
869    gw_ldk: &Gatewayd,
870    expected_addition: u64,
871) -> anyhow::Result<()> {
872    let gw_lnd_ecash_prev = gw_lnd.client().ecash_balance(fed_id.clone()).await?;
873
874    let (invoice, receive_op) = common::receive(client, &gw_ldk.addr, 1_000_000).await?;
875
876    let state = common::send(client, &gw_lnd.addr, &invoice.to_string()).await?;
877    assert!(matches!(state, FinalSendOperationState::Success(_)));
878
879    common::await_receive_claimed(client, receive_op).await?;
880
881    // The sending gateway claims its outgoing contract in the background after
882    // returning the preimage to the sender, so its ecash balance is credited
883    // asynchronously. Wait for the claim to settle before asserting the fee.
884    while almost_equal(
885        gw_lnd_ecash_prev + expected_addition,
886        gw_lnd.client().ecash_balance(fed_id.clone()).await?,
887        5000,
888    )
889    .is_err()
890    {
891        info!("Waiting for the sending gateway's outgoing claim to settle...");
892        cmd!(client, "dev", "wait", "1").out_json().await?;
893    }
894
895    Ok(())
896}
897
898async fn add_gateway(client: &Client, peer: usize, gateway: &String) -> anyhow::Result<bool> {
899    cmd!(
900        client,
901        "--our-id",
902        peer.to_string(),
903        "--password",
904        "pass",
905        "module",
906        "lnv2",
907        "gateways",
908        "add",
909        gateway
910    )
911    .out_json()
912    .await?
913    .as_bool()
914    .ok_or(anyhow::anyhow!("JSON Value is not a boolean"))
915}
916
917async fn remove_gateway(client: &Client, peer: usize, gateway: &String) -> anyhow::Result<bool> {
918    cmd!(
919        client,
920        "--our-id",
921        peer.to_string(),
922        "--password",
923        "pass",
924        "module",
925        "lnv2",
926        "gateways",
927        "remove",
928        gateway
929    )
930    .out_json()
931    .await?
932    .as_bool()
933    .ok_or(anyhow::anyhow!("JSON Value is not a boolean"))
934}
935
936/// Remove all currently registered gateways, including the ones devimint
937/// adds on startup, from the given peers.
938async fn remove_all_gateways(client: &Client, peers: &[usize]) -> anyhow::Result<()> {
939    let gateways = cmd!(client, "module", "lnv2", "gateways", "list")
940        .out_json()
941        .await?
942        .as_array()
943        .expect("JSON Value is not an array")
944        .iter()
945        .map(|gateway| {
946            gateway
947                .as_str()
948                .expect("JSON Value is not a string")
949                .to_string()
950        })
951        .collect::<Vec<String>>();
952
953    for gateway in &gateways {
954        for &peer in peers {
955            assert!(remove_gateway(client, peer, gateway).await?);
956        }
957    }
958
959    Ok(())
960}
961
962// Keep this below the 310s `fm-run-test` timeout so LNURL flakes fail with
963// useful balance diagnostics instead of a generic test timeout.
964const LNURL_BALANCE_WAIT_ATTEMPTS: u64 = 120;
965
966async fn wait_for_lnurl_balance(
967    client: &Client,
968    client_name: &str,
969    expected_msats: u64,
970) -> anyhow::Result<()> {
971    let mut last_balance_msats = client.balance().await?;
972
973    for attempt in 1..=LNURL_BALANCE_WAIT_ATTEMPTS {
974        if last_balance_msats < expected_msats {
975            info!(
976                client_name,
977                balance_msats = last_balance_msats,
978                expected_msats,
979                attempt,
980                "Waiting for {client_name} to receive funds via LNURL"
981            );
982            cmd!(client, "dev", "wait", "1").out_json().await?;
983            last_balance_msats = client.balance().await?;
984        } else {
985            info!(
986                client_name,
987                balance_msats = last_balance_msats,
988                expected_msats,
989                attempt,
990                "{client_name} successfully received funds via LNURL"
991            );
992            return Ok(());
993        }
994    }
995
996    if last_balance_msats < expected_msats {
997        anyhow::bail!(
998            "timed out waiting for {client_name} to receive funds via LNURL after {} attempts; last balance: {last_balance_msats} msats; expected balance: {expected_msats} msats",
999            LNURL_BALANCE_WAIT_ATTEMPTS
1000        );
1001    }
1002
1003    info!(
1004        client_name,
1005        balance_msats = last_balance_msats,
1006        expected_msats,
1007        attempt = LNURL_BALANCE_WAIT_ATTEMPTS,
1008        "{client_name} successfully received funds via LNURL"
1009    );
1010
1011    Ok(())
1012}
1013
1014async fn test_lnurl_pay(dev_fed: &DevJitFed) -> anyhow::Result<()> {
1015    if util::FedimintCli::version_or_default().await < *VERSION_0_11_0_ALPHA {
1016        return Ok(());
1017    }
1018
1019    if util::FedimintdCmd::version_or_default().await < *VERSION_0_11_0_ALPHA {
1020        return Ok(());
1021    }
1022
1023    if util::Gatewayd::version_or_default().await < *VERSION_0_11_0_ALPHA {
1024        return Ok(());
1025    }
1026
1027    let federation = dev_fed.fed().await?;
1028
1029    let gw_lnd = dev_fed.gw_lnd().await?;
1030    let gw_ldk = dev_fed.gw_ldk().await?;
1031
1032    let gateway_pairs = [(gw_lnd, gw_ldk), (gw_ldk, gw_lnd)];
1033
1034    let recurringd = dev_fed.recurringdv2().await?.api_url().to_string();
1035
1036    let client_a = federation
1037        .new_joined_client("lnv2-lnurl-test-client-a")
1038        .await?;
1039
1040    assert_module_sanity(&client_a).await?;
1041
1042    let client_b = federation
1043        .new_joined_client("lnv2-lnurl-test-client-b")
1044        .await?;
1045
1046    assert_module_sanity(&client_b).await?;
1047
1048    for (gw_send, gw_receive) in gateway_pairs {
1049        info!(
1050            "Testing lnurl payments: {} -> {} -> client",
1051            gw_send.ln.ln_type(),
1052            gw_receive.ln.ln_type()
1053        );
1054
1055        let lnurl_a = generate_lnurl(&client_a, &recurringd, &gw_receive.addr).await?;
1056        let lnurl_b = generate_lnurl(&client_b, &recurringd, &gw_receive.addr).await?;
1057
1058        let (invoice_a, verify_url_a) = fetch_invoice(lnurl_a.clone(), 500_000).await?;
1059        let (invoice_b, verify_url_b) = fetch_invoice(lnurl_b.clone(), 500_000).await?;
1060
1061        let verify_task_a = task::spawn("verify_task_a", verify_payment_wait(verify_url_a.clone()));
1062        let verify_task_b = task::spawn("verify_task_b", verify_payment_wait(verify_url_b.clone()));
1063
1064        let response_a = verify_payment(&verify_url_a).await?;
1065        let response_b = verify_payment(&verify_url_b).await?;
1066
1067        assert!(!response_a.settled);
1068        assert!(!response_b.settled);
1069
1070        assert!(response_a.preimage.is_none());
1071        assert!(response_b.preimage.is_none());
1072
1073        gw_send.client().pay_invoice(invoice_a.clone()).await?;
1074        gw_send.client().pay_invoice(invoice_b.clone()).await?;
1075
1076        let response_a = verify_payment(&verify_url_a).await?;
1077        let response_b = verify_payment(&verify_url_b).await?;
1078
1079        assert!(response_a.settled);
1080        assert!(response_b.settled);
1081
1082        verify_preimage(&response_a, &invoice_a);
1083        verify_preimage(&response_b, &invoice_b);
1084
1085        assert_eq!(verify_task_a.await??, response_a);
1086        assert_eq!(verify_task_b.await??, response_b);
1087    }
1088
1089    wait_for_lnurl_balance(&client_a, "client A", 950 * 1000).await?;
1090    wait_for_lnurl_balance(&client_b, "client B", 950 * 1000).await?;
1091
1092    Ok(())
1093}
1094
1095/// Tests LNURL receives after recovery from seed.
1096///
1097/// LNv2 uses `NoModuleBackup`, so recovery restores funds via the mint module
1098/// but not the operation log or LNv2 state. Verifies:
1099/// 1. Balance is fully recovered.
1100/// 2. Payments to a pre-recovery LNURL are still claimed by the restored
1101///    client.
1102async fn test_lnurl_recovery(dev_fed: &DevJitFed) -> anyhow::Result<()> {
1103    // Before v0.10.0 the CLI registered LNURLs via a POST to the recurringd
1104    // server.  That endpoint no longer exists, so old CLIs cannot generate
1105    // LNURLs against the current recurringdv2.
1106    if util::FedimintCli::version_or_default().await < *VERSION_0_10_0_ALPHA {
1107        return Ok(());
1108    }
1109
1110    let federation = dev_fed.fed().await?;
1111    let gw_lnd = dev_fed.gw_lnd().await?;
1112    let gw_ldk = dev_fed.gw_ldk().await?;
1113    let recurringd = dev_fed.recurringdv2().await?.api_url().to_string();
1114
1115    const LNURL_AMOUNT_MSAT: u64 = 500_000;
1116    const LNURL_BALANCE_TOLERANCE_MSAT: u64 = 100_000;
1117
1118    // ── Phase 1: Pre-recovery LNURL receives ──────────────────────────
1119
1120    info!("Phase 1: Creating client and receiving via LNURL before recovery");
1121
1122    let client = federation
1123        .new_joined_client("lnv2-lnurl-recovery-original")
1124        .await?;
1125
1126    let lnurl = generate_lnurl(&client, &recurringd, &gw_ldk.addr).await?;
1127
1128    for i in 0..3 {
1129        info!("Paying LNURL invoice {}/3", i + 1);
1130        let (invoice, _verify_url) = fetch_invoice(lnurl.clone(), LNURL_AMOUNT_MSAT).await?;
1131        gw_lnd.client().pay_invoice(invoice).await?;
1132    }
1133
1134    while almost_equal(
1135        client.balance().await?,
1136        3 * LNURL_AMOUNT_MSAT,
1137        LNURL_BALANCE_TOLERANCE_MSAT,
1138    )
1139    .is_err()
1140    {
1141        info!("Waiting for pre-recovery LNURL payments to settle...");
1142        cmd!(client, "dev", "wait", "1").out_json().await?;
1143    }
1144
1145    let pre_recovery_balance = client.balance().await?;
1146    info!("Pre-recovery balance: {pre_recovery_balance} msats");
1147
1148    let mnemonic = cmd!(client, "print-secret").out_json().await?["secret"]
1149        .as_str()
1150        .expect("secret is a string")
1151        .to_owned();
1152
1153    // ── Phase 2: Recovery ─────────────────────────────────────────────
1154
1155    info!("Phase 2: Recovering client from seed");
1156
1157    let restored = Client::create("lnv2-lnurl-recovery-restored").await?;
1158    cmd!(
1159        restored,
1160        "restore",
1161        "--invite-code",
1162        federation.invite_code()?,
1163        "--mnemonic",
1164        &mnemonic
1165    )
1166    .run()
1167    .await?;
1168
1169    while restored.balance().await? < pre_recovery_balance {
1170        info!("Waiting for recovery to complete...");
1171        cmd!(restored, "dev", "wait", "1").out_json().await?;
1172    }
1173
1174    let post_recovery_balance = restored.balance().await?;
1175    info!("Post-recovery balance: {post_recovery_balance} msats");
1176
1177    // ── Phase 3: Post-recovery LNURL receives ─────────────────────────
1178
1179    info!("Phase 3: Paying to the original LNURL; restored client must claim");
1180
1181    // Reuse the Phase 1 `lnurl` on purpose: this is what a third party would
1182    // have saved pre-recovery, and it must still pay the restored client.
1183    for i in 0..2 {
1184        info!("Paying pre-recovery LNURL invoice {}/2", i + 1);
1185        let (invoice, _verify_url) = fetch_invoice(lnurl.clone(), LNURL_AMOUNT_MSAT).await?;
1186        gw_lnd.client().pay_invoice(invoice).await?;
1187    }
1188
1189    while almost_equal(
1190        restored.balance().await?,
1191        post_recovery_balance + 2 * LNURL_AMOUNT_MSAT,
1192        LNURL_BALANCE_TOLERANCE_MSAT,
1193    )
1194    .is_err()
1195    {
1196        info!("Waiting for post-recovery LNURL payments to settle...");
1197        cmd!(restored, "dev", "wait", "1").out_json().await?;
1198    }
1199
1200    let final_balance = restored.balance().await?;
1201    info!("Final balance: {final_balance} msats");
1202
1203    let operations = cmd!(restored, "list-operations", "--limit", "100")
1204        .out_json()
1205        .await?;
1206    let lnv2_ops: Vec<_> = operations["operations"]
1207        .as_array()
1208        .expect("operations is an array")
1209        .iter()
1210        .filter(|op| op["operation_kind"].as_str() == Some("lnv2"))
1211        .collect();
1212    assert!(
1213        lnv2_ops.len() >= 2,
1214        "Expected at least 2 LNv2 operations after post-recovery receives, found {}",
1215        lnv2_ops.len()
1216    );
1217
1218    info!(
1219        "LNURL recovery test passed: {} new operations, balance {final_balance} msats",
1220        lnv2_ops.len()
1221    );
1222
1223    Ok(())
1224}
1225
1226async fn generate_lnurl(
1227    client: &Client,
1228    recurringd_base_url: &str,
1229    gw_ldk_addr: &str,
1230) -> anyhow::Result<String> {
1231    cmd!(
1232        client,
1233        "module",
1234        "lnv2",
1235        "lnurl",
1236        "generate",
1237        recurringd_base_url,
1238        "--gateway",
1239        gw_ldk_addr,
1240    )
1241    .out_json()
1242    .await
1243    .map(|s| s.as_str().unwrap().to_owned())
1244}
1245
1246fn verify_preimage(response: &VerifyResponse, invoice: &Bolt11Invoice) {
1247    let preimage = response.preimage.expect("Payment should be settled");
1248
1249    let payment_hash = preimage.consensus_hash::<sha256::Hash>();
1250
1251    assert_eq!(payment_hash, *invoice.payment_hash());
1252}
1253
1254async fn verify_payment(verify_url: &str) -> anyhow::Result<VerifyResponse> {
1255    reqwest::get(verify_url)
1256        .await?
1257        .json::<LnurlResponse<VerifyResponse>>()
1258        .await?
1259        .into_result()
1260        .map_err(anyhow::Error::msg)
1261}
1262
1263async fn verify_payment_wait(verify_url: String) -> anyhow::Result<VerifyResponse> {
1264    reqwest::get(format!("{verify_url}?wait"))
1265        .await?
1266        .json::<LnurlResponse<VerifyResponse>>()
1267        .await?
1268        .into_result()
1269        .map_err(anyhow::Error::msg)
1270}
1271
1272#[derive(Deserialize, Clone)]
1273struct LnUrlPayResponse {
1274    callback: String,
1275    metadata: String,
1276}
1277
1278#[derive(Deserialize, Clone)]
1279struct LnUrlPayInvoiceResponse {
1280    pr: Bolt11Invoice,
1281    // LUD-06 requires this field, so parsing fails if the service omits it
1282    routes: Vec<serde_json::Value>,
1283    verify: String,
1284}
1285
1286async fn fetch_invoice(lnurl: String, amount_msat: u64) -> anyhow::Result<(Bolt11Invoice, String)> {
1287    let url = parse_lnurl(&lnurl).ok_or_else(|| anyhow::anyhow!("Invalid LNURL"))?;
1288
1289    let response = reqwest::get(url).await?.json::<LnUrlPayResponse>().await?;
1290
1291    let callback_url = format!("{}?amount={}", response.callback, amount_msat);
1292
1293    let invoice_response = reqwest::get(callback_url)
1294        .await?
1295        .json::<LnUrlPayInvoiceResponse>()
1296        .await?;
1297
1298    ensure!(
1299        invoice_response.pr.amount_milli_satoshis() == Some(amount_msat),
1300        "Invoice amount is not set"
1301    );
1302
1303    ensure!(
1304        invoice_response.routes.is_empty(),
1305        "LUD-06 requires routes to be an empty array"
1306    );
1307
1308    let metadata_hash = sha256::Hash::hash(response.metadata.as_bytes());
1309
1310    ensure!(
1311        matches!(
1312            invoice_response.pr.description(),
1313            Bolt11InvoiceDescriptionRef::Hash(hash) if hash.0 == metadata_hash
1314        ),
1315        "Invoice does not commit to the LNURL metadata via its description hash (LUD-06)"
1316    );
1317
1318    Ok((invoice_response.pr, invoice_response.verify))
1319}
1320
1321async fn test_iroh_payment(
1322    client: &Client,
1323    gw_lnd: &Gatewayd,
1324    gw_ldk: &Gatewayd,
1325    online_peers: &[usize],
1326) -> anyhow::Result<()> {
1327    info!("Testing iroh payment...");
1328    // The send below relies on automatic gateway selection picking the iroh
1329    // gateway, so the gateways devimint added on startup have to go first.
1330    remove_all_gateways(client, online_peers).await?;
1331    for &peer in online_peers {
1332        add_gateway(client, peer, &format!("iroh://{}", gw_lnd.node_id)).await?;
1333    }
1334
1335    // If the client is below v0.10.0, also add the HTTP address so that the client
1336    // can fallback to using that, since the iroh gateway will fail.
1337    if util::FedimintCli::version_or_default().await < *VERSION_0_10_0_ALPHA
1338        || gw_lnd.gatewayd_version < *VERSION_0_10_0_ALPHA
1339    {
1340        for &peer in online_peers {
1341            add_gateway(client, peer, &gw_lnd.addr).await?;
1342        }
1343    }
1344
1345    let invoice = gw_ldk.client().create_invoice(5_000_000).await?;
1346
1347    let send_op = serde_json::from_value::<OperationId>(
1348        cmd!(client, "module", "lnv2", "send", invoice,)
1349            .out_json()
1350            .await?,
1351    )?;
1352
1353    let send_state = common::await_send(client, send_op).await?;
1354    assert!(
1355        matches!(send_state, FinalSendOperationState::Success(_)),
1356        "unexpected send state: {send_state:?}"
1357    );
1358
1359    let (invoice, receive_op) = serde_json::from_value::<(Bolt11Invoice, OperationId)>(
1360        cmd!(client, "module", "lnv2", "receive", "5000000",)
1361            .out_json()
1362            .await?,
1363    )?;
1364
1365    gw_ldk.client().pay_invoice(invoice).await?;
1366    common::await_receive_claimed(client, receive_op).await?;
1367
1368    if util::FedimintCli::version_or_default().await < *VERSION_0_10_0_ALPHA
1369        || gw_lnd.gatewayd_version < *VERSION_0_10_0_ALPHA
1370    {
1371        for &peer in online_peers {
1372            remove_gateway(client, peer, &gw_lnd.addr).await?;
1373        }
1374    }
1375
1376    for &peer in online_peers {
1377        remove_gateway(client, peer, &format!("iroh://{}", gw_lnd.node_id)).await?;
1378    }
1379
1380    Ok(())
1381}