Skip to main content

lnv2_module_tests/
tests.rs

1use std::path::PathBuf;
2
3use anyhow::ensure;
4use bitcoin::hashes::sha256;
5use clap::{Parser, Subcommand};
6use devimint::devfed::DevJitFed;
7use devimint::envs::FM_CLIENT_DIR_ENV;
8use devimint::federation::{Client, Federation};
9use devimint::util::{ProcessManager, almost_equal};
10use devimint::version_constants::{
11    VERSION_0_10_0_ALPHA, VERSION_0_11_0_ALPHA, VERSION_0_12_0_ALPHA,
12};
13use devimint::{Gatewayd, cmd, util};
14use fedimint_core::core::OperationId;
15use fedimint_core::encoding::Encodable;
16use fedimint_core::task::{self};
17use fedimint_core::util::{backoff_util, retry, write_overwrite_async};
18use fedimint_lnurl::{LnurlResponse, VerifyResponse, parse_lnurl};
19use fedimint_lnv2_client::FinalSendOperationState;
20use lightning_invoice::Bolt11Invoice;
21use serde::Deserialize;
22use tokio::try_join;
23use tracing::info;
24
25#[path = "common.rs"]
26mod common;
27
28async fn module_is_present(client: &Client, kind: &str) -> anyhow::Result<bool> {
29    let modules = cmd!(client, "module").out_json().await?;
30
31    let modules = modules["list"].as_array().expect("module list is an array");
32
33    Ok(modules.iter().any(|m| m["kind"].as_str() == Some(kind)))
34}
35
36async fn assert_module_sanity(client: &Client) -> anyhow::Result<()> {
37    if !devimint::util::is_backwards_compatibility_test() {
38        ensure!(
39            !module_is_present(client, "ln").await?,
40            "ln module should not be present"
41        );
42    }
43
44    Ok(())
45}
46
47#[derive(Parser)]
48#[command(name = "lnv2-module-tests")]
49#[command(about = "LNv2 module integration tests", long_about = None)]
50struct Cli {
51    #[command(subcommand)]
52    command: Option<Commands>,
53}
54
55#[derive(Subcommand)]
56enum Commands {
57    /// Run gateway registration tests
58    GatewayRegistration,
59    /// Run payment tests
60    Payments,
61    /// Run LNURL pay tests
62    LnurlPay,
63    /// Test LNURL receives after recovery from seed
64    LnurlRecovery,
65    /// Two clients racing to pay the same invoice settle exactly once
66    DuplicatePayment,
67}
68
69#[tokio::main]
70async fn main() -> anyhow::Result<()> {
71    let cli = Cli::parse();
72
73    // The duplicate-payment test needs a second federation; every other test
74    // uses a single one.
75    let num_feds = if matches!(cli.command, None | Some(Commands::DuplicatePayment)) {
76        2
77    } else {
78        1
79    };
80
81    devimint::run_devfed_test()
82        .num_feds(num_feds)
83        .call(|dev_fed, process_mgr| async move {
84            if !devimint::util::supports_lnv2() {
85                info!("lnv2 is disabled, skipping");
86                return Ok(());
87            }
88
89            match &cli.command {
90                Some(Commands::GatewayRegistration) => {
91                    test_gateway_registration(&dev_fed).await?;
92                }
93                Some(Commands::Payments) => {
94                    test_payments(&dev_fed).await?;
95                }
96                Some(Commands::LnurlPay) => {
97                    pegin_gateways(&dev_fed).await?;
98                    test_lnurl_pay(&dev_fed).await?;
99                }
100                Some(Commands::LnurlRecovery) => {
101                    pegin_gateways(&dev_fed).await?;
102                    test_lnurl_recovery(&dev_fed).await?;
103                }
104                Some(Commands::DuplicatePayment) => {
105                    pegin_gateways(&dev_fed).await?;
106                    test_duplicate_payment(&dev_fed, &process_mgr).await?;
107                }
108                None => {
109                    // Run all tests if no subcommand is specified
110                    test_gateway_registration(&dev_fed).await?;
111                    test_payments(&dev_fed).await?;
112                    test_duplicate_payment(&dev_fed, &process_mgr).await?;
113                    test_lnurl_pay(&dev_fed).await?;
114                    test_lnurl_recovery(&dev_fed).await?;
115                }
116            }
117
118            info!("Testing LNV2 is complete!");
119
120            Ok(())
121        })
122        .await
123}
124
125async fn pegin_gateways(dev_fed: &DevJitFed) -> anyhow::Result<()> {
126    info!("Pegging-in gateways...");
127
128    let federation = dev_fed.fed().await?;
129
130    let gw_lnd = dev_fed.gw_lnd().await?;
131    let gw_ldk = dev_fed.gw_ldk().await?;
132
133    federation
134        .pegin_gateways(1_000_000, vec![gw_lnd, gw_ldk])
135        .await?;
136
137    Ok(())
138}
139
140async fn test_gateway_registration(dev_fed: &DevJitFed) -> anyhow::Result<()> {
141    let client = dev_fed
142        .fed()
143        .await?
144        .new_joined_client("lnv2-test-gateway-registration-client")
145        .await?;
146
147    assert_module_sanity(&client).await?;
148
149    let gw_lnd = dev_fed.gw_lnd().await?;
150    let gw_ldk = dev_fed.gw_ldk_connected().await?;
151
152    let gateways = [gw_lnd.addr.clone(), gw_ldk.addr.clone()];
153
154    info!("Removing the gateways devimint added on startup...");
155
156    let peers = (0..dev_fed.fed().await?.members.len()).collect::<Vec<usize>>();
157    remove_all_gateways(&client, &peers).await?;
158
159    info!("Testing registration of gateways...");
160
161    for gateway in &gateways {
162        for peer in 0..dev_fed.fed().await?.members.len() {
163            assert!(add_gateway(&client, peer, gateway).await?);
164        }
165    }
166
167    assert_eq!(
168        cmd!(client, "module", "lnv2", "gateways", "list")
169            .out_json()
170            .await?
171            .as_array()
172            .expect("JSON Value is not an array")
173            .len(),
174        2
175    );
176
177    assert_eq!(
178        cmd!(client, "module", "lnv2", "gateways", "list", "--peer", "0")
179            .out_json()
180            .await?
181            .as_array()
182            .expect("JSON Value is not an array")
183            .len(),
184        2
185    );
186
187    info!("Testing selection of gateways...");
188
189    assert!(
190        gateways.contains(
191            &cmd!(client, "module", "lnv2", "gateways", "select")
192                .out_json()
193                .await?
194                .as_str()
195                .expect("JSON Value is not a string")
196                .to_string()
197        )
198    );
199
200    cmd!(client, "module", "lnv2", "gateways", "map")
201        .out_json()
202        .await?;
203
204    for _ in 0..10 {
205        for gateway in &gateways {
206            let invoice = common::receive(&client, gateway, 1_000_000).await?.0;
207
208            assert_eq!(
209                cmd!(
210                    client,
211                    "module",
212                    "lnv2",
213                    "gateways",
214                    "select",
215                    "--invoice",
216                    invoice.to_string()
217                )
218                .out_json()
219                .await?
220                .as_str()
221                .expect("JSON Value is not a string"),
222                gateway
223            )
224        }
225    }
226
227    info!("Testing deregistration of gateways...");
228
229    for gateway in &gateways {
230        for peer in 0..dev_fed.fed().await?.members.len() {
231            assert!(remove_gateway(&client, peer, gateway).await?);
232        }
233    }
234
235    assert!(
236        cmd!(client, "module", "lnv2", "gateways", "list")
237            .out_json()
238            .await?
239            .as_array()
240            .expect("JSON Value is not an array")
241            .is_empty(),
242    );
243
244    assert!(
245        cmd!(client, "module", "lnv2", "gateways", "list", "--peer", "0")
246            .out_json()
247            .await?
248            .as_array()
249            .expect("JSON Value is not an array")
250            .is_empty()
251    );
252
253    Ok(())
254}
255
256async fn test_payments(dev_fed: &DevJitFed) -> anyhow::Result<()> {
257    let federation = dev_fed.fed().await?;
258
259    let client = federation
260        .new_joined_client("lnv2-test-payments-client")
261        .await?;
262
263    assert_module_sanity(&client).await?;
264
265    federation.pegin_client(10_000, &client).await?;
266
267    almost_equal(client.balance().await?, 10_000 * 1000, 500_000).unwrap();
268
269    let gw_lnd = dev_fed.gw_lnd().await?;
270    let gw_ldk = dev_fed.gw_ldk().await?;
271    let lnd = dev_fed.lnd().await?;
272
273    let (hold_preimage, hold_invoice, hold_payment_hash) = lnd.create_hold_invoice(60000).await?;
274
275    let gateway_pairs = [(gw_lnd, gw_ldk), (gw_ldk, gw_lnd)];
276
277    let gateway_matrix = [
278        (gw_lnd, gw_lnd),
279        (gw_lnd, gw_ldk),
280        (gw_ldk, gw_lnd),
281        (gw_ldk, gw_ldk),
282    ];
283
284    info!("Testing refund of circular payments...");
285
286    for (gw_send, gw_receive) in gateway_matrix {
287        info!(
288            "Testing refund of payment: client -> {} -> {} -> client",
289            gw_send.ln.ln_type(),
290            gw_receive.ln.ln_type()
291        );
292
293        let invoice = common::receive(&client, &gw_receive.addr, 1_000_000)
294            .await?
295            .0;
296
297        let state = common::send(&client, &gw_send.addr, &invoice.to_string()).await?;
298        assert!(matches!(state, FinalSendOperationState::Refunded));
299    }
300
301    pegin_gateways(dev_fed).await?;
302
303    info!("Testing circular payments...");
304
305    for (gw_send, gw_receive) in gateway_matrix {
306        info!(
307            "Testing payment: client -> {} -> {} -> client",
308            gw_send.ln.ln_type(),
309            gw_receive.ln.ln_type()
310        );
311
312        let (invoice, receive_op) = common::receive(&client, &gw_receive.addr, 1_000_000).await?;
313
314        let state = common::send(&client, &gw_send.addr, &invoice.to_string()).await?;
315        assert!(matches!(state, FinalSendOperationState::Success(_)));
316
317        common::await_receive_claimed(&client, receive_op).await?;
318    }
319
320    info!("Testing payments from client to gateways...");
321
322    for (gw_send, gw_receive) in gateway_pairs {
323        info!(
324            "Testing payment: client -> {} -> {}",
325            gw_send.ln.ln_type(),
326            gw_receive.ln.ln_type()
327        );
328
329        let invoice = gw_receive.client().create_invoice(1_000_000).await?;
330
331        let state = common::send(&client, &gw_send.addr, &invoice.to_string()).await?;
332        assert!(matches!(state, FinalSendOperationState::Success(_)));
333    }
334
335    info!("Testing payments from gateways to client...");
336
337    for (gw_send, gw_receive) in gateway_pairs {
338        info!(
339            "Testing payment: {} -> {} -> client",
340            gw_send.ln.ln_type(),
341            gw_receive.ln.ln_type()
342        );
343
344        let (invoice, receive_op) = common::receive(&client, &gw_receive.addr, 1_000_000).await?;
345
346        gw_send.client().pay_invoice(invoice).await?;
347
348        common::await_receive_claimed(&client, receive_op).await?;
349    }
350
351    retry(
352        "Waiting for the full balance to become available to the client".to_string(),
353        backoff_util::background_backoff(),
354        || async {
355            ensure!(client.balance().await? >= 9000 * 1000);
356
357            Ok(())
358        },
359    )
360    .await?;
361
362    info!("Testing Client can pay LND HOLD invoice via LDK Gateway...");
363
364    let (state, _) = try_join!(
365        common::send(&client, &gw_ldk.addr, &hold_invoice),
366        lnd.settle_hold_invoice(hold_preimage, hold_payment_hash),
367    )?;
368    assert!(matches!(state, FinalSendOperationState::Success(_)));
369
370    info!("Testing LNv2 lightning fees...");
371
372    let fed_id = federation.calculate_federation_id();
373
374    gw_lnd
375        .client()
376        .set_federation_routing_fee(fed_id.clone(), 0, 0)
377        .await?;
378
379    gw_lnd
380        .client()
381        .set_federation_transaction_fee(fed_id.clone(), 0, 0)
382        .await?;
383
384    // Gateway pays: 1_000 msat LNv2 federation base fee. Gateway receives:
385    // 1_000_000 payment.
386    test_fees(fed_id, &client, gw_lnd, gw_ldk, 1_000_000 - 1_000).await?;
387
388    let online_peers: Vec<usize> = federation.members.keys().copied().collect();
389
390    test_iroh_payment(&client, gw_lnd, gw_ldk, &online_peers).await?;
391
392    info!("Testing payment summary...");
393
394    let lnd_payment_summary = gw_lnd.client().payment_summary().await?;
395
396    assert_eq!(lnd_payment_summary.outgoing.total_success, 5);
397    assert_eq!(lnd_payment_summary.outgoing.total_failure, 2);
398    assert_eq!(lnd_payment_summary.incoming.total_success, 4);
399    assert_eq!(lnd_payment_summary.incoming.total_failure, 0);
400
401    assert!(lnd_payment_summary.outgoing.median_latency.is_some());
402    assert!(lnd_payment_summary.outgoing.average_latency.is_some());
403    assert!(lnd_payment_summary.incoming.median_latency.is_some());
404    assert!(lnd_payment_summary.incoming.average_latency.is_some());
405
406    let ldk_payment_summary = gw_ldk.client().payment_summary().await?;
407
408    assert_eq!(ldk_payment_summary.outgoing.total_success, 4);
409    assert_eq!(ldk_payment_summary.outgoing.total_failure, 2);
410    assert_eq!(ldk_payment_summary.incoming.total_success, 4);
411    assert_eq!(ldk_payment_summary.incoming.total_failure, 0);
412
413    assert!(ldk_payment_summary.outgoing.median_latency.is_some());
414    assert!(ldk_payment_summary.outgoing.average_latency.is_some());
415    assert!(ldk_payment_summary.incoming.median_latency.is_some());
416    assert!(ldk_payment_summary.incoming.average_latency.is_some());
417
418    Ok(())
419}
420
421/// Creates a fresh client and joins it to the federation identified by
422/// `invite_code`, unlike `Federation::new_joined_client` which always joins
423/// whichever federation last wrote the shared invite-code file.
424async fn join_client(name: &str, invite_code: &str) -> anyhow::Result<Client> {
425    let client = Client::create(name).await?;
426    client.join_federation(invite_code.to_string()).await?;
427    Ok(client)
428}
429
430/// Three independent clients — two in the primary federation and one in a
431/// second federation, all served by the same gateway — race to pay the *same*
432/// invoice. The gateway pays the invoice only once, so it may claim only one of
433/// the three outgoing contracts regardless of which federation funded them; the
434/// other two must be forfeited and refunded. Exactly one payment settles.
435async fn test_duplicate_payment(
436    dev_fed: &DevJitFed,
437    process_mgr: &ProcessManager,
438) -> anyhow::Result<()> {
439    info!(
440        "Testing three clients across two federations paying the same invoice settle exactly once..."
441    );
442
443    // The LDK gateway is used to send: its `pay` is idempotent per payment hash,
444    // so all contracts share a single Lightning payment and preimage.
445    let gw_send = dev_fed.gw_ldk().await?;
446    let gw_receive = dev_fed.gw_lnd().await?;
447
448    // The cross-federation dedup lives in the gateway's global database, added in
449    // 0.12. Older gateways would claim both contracts, so skip against them.
450    if gw_send.gatewayd_version < *VERSION_0_12_0_ALPHA {
451        info!(
452            gatewayd_version = %gw_send.gatewayd_version,
453            "Skipping: gateway predates cross-federation outgoing payment dedup"
454        );
455        return Ok(());
456    }
457
458    let first_federation = dev_fed.fed().await?;
459
460    // `Federation::invite_code` reads a single shared invite-code file, so
461    // capture the primary federation's invite before spawning the second one
462    // overwrites it. Clients must be joined with the right invite explicitly;
463    // `new_joined_client` would join whichever federation wrote that file last.
464    let first_invite = first_federation.invite_code()?;
465
466    // A second federation, also served by the sending gateway, covers the
467    // cross-federation case: a contract funded in one federation must still
468    // block claiming a contract for the same invoice funded in another.
469    let second_federation = Federation::new(
470        process_mgr,
471        dev_fed.bitcoind().await?.clone(),
472        false,
473        false,
474        false,
475        1,
476        "lnv2-duplicate-payment".to_string(),
477    )
478    .await?;
479    let second_invite = second_federation.invite_code()?;
480
481    assert_ne!(
482        first_federation.calculate_federation_id(),
483        second_federation.calculate_federation_id(),
484        "the second federation must be distinct from the first"
485    );
486
487    // Spawning the second federation overwrote the shared invite-code file
488    // with its own invite. Restore the primary federation's invite now that
489    // both invites are captured: `Federation::invite_code` re-reads that file,
490    // so a later `new_joined_client` on the primary federation would otherwise
491    // join the second one, which is torn down when this test returns.
492    let client_dir: PathBuf = std::env::var(FM_CLIENT_DIR_ENV)?.parse()?;
493    write_overwrite_async(client_dir.join("invite-code"), &first_invite).await?;
494
495    gw_send.client().connect_fed(second_invite.clone()).await?;
496    second_federation
497        .pegin_gateways(1_000_000, vec![gw_send])
498        .await?;
499
500    let client_a = join_client("lnv2-duplicate-payment-a", &first_invite).await?;
501    let client_b = join_client("lnv2-duplicate-payment-b", &first_invite).await?;
502    let client_c = join_client("lnv2-duplicate-payment-c", &second_invite).await?;
503
504    first_federation.pegin_client(10_000, &client_a).await?;
505    first_federation.pegin_client(10_000, &client_b).await?;
506    second_federation.pegin_client(10_000, &client_c).await?;
507
508    // Control: the second-federation client can pay a *different* invoice through
509    // the gateway on its own. This proves the gateway serves the second
510    // federation, so any refund of the shared invoice below is caused by the
511    // duplicate payment rather than a second-federation setup problem.
512    let control_invoice = gw_receive
513        .client()
514        .create_invoice(1_000_000)
515        .await?
516        .to_string();
517    let control = common::send(&client_c, &gw_send.addr, &control_invoice).await?;
518    assert!(
519        matches!(control, FinalSendOperationState::Success(_)),
520        "second-federation control payment must succeed, got {control:?}"
521    );
522
523    let invoice = gw_receive
524        .client()
525        .create_invoice(1_000_000)
526        .await?
527        .to_string();
528
529    // The two clients in the first federation race for the invoice; the
530    // per-federation dedup settles exactly one of them and refunds the other.
531    let (state_a, state_b) = try_join!(
532        common::send(&client_a, &gw_send.addr, &invoice),
533        common::send(&client_b, &gw_send.addr, &invoice),
534    )?;
535
536    // The second-federation client then pays the same, now-settled invoice. The
537    // gateway already holds the preimage, so without cross-federation dedup it
538    // claims this contract too, being reimbursed twice for one Lightning payment.
539    let state_c = common::send(&client_c, &gw_send.addr, &invoice).await?;
540
541    let states = [state_a, state_b, state_c];
542
543    info!("shared-invoice send states: {states:?}");
544
545    let successes = states
546        .iter()
547        .filter(|state| matches!(state, FinalSendOperationState::Success(_)))
548        .count();
549
550    let refunds = states
551        .iter()
552        .filter(|state| matches!(state, FinalSendOperationState::Refunded))
553        .count();
554
555    assert_eq!(
556        (successes, refunds),
557        (1, 2),
558        "exactly one payment must settle and the other two be refunded, got {states:?}"
559    );
560
561    Ok(())
562}
563
564async fn test_fees(
565    fed_id: String,
566    client: &Client,
567    gw_lnd: &Gatewayd,
568    gw_ldk: &Gatewayd,
569    expected_addition: u64,
570) -> anyhow::Result<()> {
571    let gw_lnd_ecash_prev = gw_lnd.client().ecash_balance(fed_id.clone()).await?;
572
573    let (invoice, receive_op) = common::receive(client, &gw_ldk.addr, 1_000_000).await?;
574
575    let state = common::send(client, &gw_lnd.addr, &invoice.to_string()).await?;
576    assert!(matches!(state, FinalSendOperationState::Success(_)));
577
578    common::await_receive_claimed(client, receive_op).await?;
579
580    // The sending gateway claims its outgoing contract in the background after
581    // returning the preimage to the sender, so its ecash balance is credited
582    // asynchronously. Wait for the claim to settle before asserting the fee.
583    while almost_equal(
584        gw_lnd_ecash_prev + expected_addition,
585        gw_lnd.client().ecash_balance(fed_id.clone()).await?,
586        5000,
587    )
588    .is_err()
589    {
590        info!("Waiting for the sending gateway's outgoing claim to settle...");
591        cmd!(client, "dev", "wait", "1").out_json().await?;
592    }
593
594    Ok(())
595}
596
597async fn add_gateway(client: &Client, peer: usize, gateway: &String) -> anyhow::Result<bool> {
598    cmd!(
599        client,
600        "--our-id",
601        peer.to_string(),
602        "--password",
603        "pass",
604        "module",
605        "lnv2",
606        "gateways",
607        "add",
608        gateway
609    )
610    .out_json()
611    .await?
612    .as_bool()
613    .ok_or(anyhow::anyhow!("JSON Value is not a boolean"))
614}
615
616async fn remove_gateway(client: &Client, peer: usize, gateway: &String) -> anyhow::Result<bool> {
617    cmd!(
618        client,
619        "--our-id",
620        peer.to_string(),
621        "--password",
622        "pass",
623        "module",
624        "lnv2",
625        "gateways",
626        "remove",
627        gateway
628    )
629    .out_json()
630    .await?
631    .as_bool()
632    .ok_or(anyhow::anyhow!("JSON Value is not a boolean"))
633}
634
635/// Remove all currently registered gateways, including the ones devimint
636/// adds on startup, from the given peers.
637async fn remove_all_gateways(client: &Client, peers: &[usize]) -> anyhow::Result<()> {
638    let gateways = cmd!(client, "module", "lnv2", "gateways", "list")
639        .out_json()
640        .await?
641        .as_array()
642        .expect("JSON Value is not an array")
643        .iter()
644        .map(|gateway| {
645            gateway
646                .as_str()
647                .expect("JSON Value is not a string")
648                .to_string()
649        })
650        .collect::<Vec<String>>();
651
652    for gateway in &gateways {
653        for &peer in peers {
654            assert!(remove_gateway(client, peer, gateway).await?);
655        }
656    }
657
658    Ok(())
659}
660
661// Keep this below the 310s `fm-run-test` timeout so LNURL flakes fail with
662// useful balance diagnostics instead of a generic test timeout.
663const LNURL_BALANCE_WAIT_ATTEMPTS: u64 = 120;
664
665async fn wait_for_lnurl_balance(
666    client: &Client,
667    client_name: &str,
668    expected_msats: u64,
669) -> anyhow::Result<()> {
670    let mut last_balance_msats = client.balance().await?;
671
672    for attempt in 1..=LNURL_BALANCE_WAIT_ATTEMPTS {
673        if last_balance_msats < expected_msats {
674            info!(
675                client_name,
676                balance_msats = last_balance_msats,
677                expected_msats,
678                attempt,
679                "Waiting for {client_name} to receive funds via LNURL"
680            );
681            cmd!(client, "dev", "wait", "1").out_json().await?;
682            last_balance_msats = client.balance().await?;
683        } else {
684            info!(
685                client_name,
686                balance_msats = last_balance_msats,
687                expected_msats,
688                attempt,
689                "{client_name} successfully received funds via LNURL"
690            );
691            return Ok(());
692        }
693    }
694
695    if last_balance_msats < expected_msats {
696        anyhow::bail!(
697            "timed out waiting for {client_name} to receive funds via LNURL after {} attempts; last balance: {last_balance_msats} msats; expected balance: {expected_msats} msats",
698            LNURL_BALANCE_WAIT_ATTEMPTS
699        );
700    }
701
702    info!(
703        client_name,
704        balance_msats = last_balance_msats,
705        expected_msats,
706        attempt = LNURL_BALANCE_WAIT_ATTEMPTS,
707        "{client_name} successfully received funds via LNURL"
708    );
709
710    Ok(())
711}
712
713async fn test_lnurl_pay(dev_fed: &DevJitFed) -> anyhow::Result<()> {
714    if util::FedimintCli::version_or_default().await < *VERSION_0_11_0_ALPHA {
715        return Ok(());
716    }
717
718    if util::FedimintdCmd::version_or_default().await < *VERSION_0_11_0_ALPHA {
719        return Ok(());
720    }
721
722    if util::Gatewayd::version_or_default().await < *VERSION_0_11_0_ALPHA {
723        return Ok(());
724    }
725
726    let federation = dev_fed.fed().await?;
727
728    let gw_lnd = dev_fed.gw_lnd().await?;
729    let gw_ldk = dev_fed.gw_ldk().await?;
730
731    let gateway_pairs = [(gw_lnd, gw_ldk), (gw_ldk, gw_lnd)];
732
733    let recurringd = dev_fed.recurringdv2().await?.api_url().to_string();
734
735    let client_a = federation
736        .new_joined_client("lnv2-lnurl-test-client-a")
737        .await?;
738
739    assert_module_sanity(&client_a).await?;
740
741    let client_b = federation
742        .new_joined_client("lnv2-lnurl-test-client-b")
743        .await?;
744
745    assert_module_sanity(&client_b).await?;
746
747    for (gw_send, gw_receive) in gateway_pairs {
748        info!(
749            "Testing lnurl payments: {} -> {} -> client",
750            gw_send.ln.ln_type(),
751            gw_receive.ln.ln_type()
752        );
753
754        let lnurl_a = generate_lnurl(&client_a, &recurringd, &gw_receive.addr).await?;
755        let lnurl_b = generate_lnurl(&client_b, &recurringd, &gw_receive.addr).await?;
756
757        let (invoice_a, verify_url_a) = fetch_invoice(lnurl_a.clone(), 500_000).await?;
758        let (invoice_b, verify_url_b) = fetch_invoice(lnurl_b.clone(), 500_000).await?;
759
760        let verify_task_a = task::spawn("verify_task_a", verify_payment_wait(verify_url_a.clone()));
761        let verify_task_b = task::spawn("verify_task_b", verify_payment_wait(verify_url_b.clone()));
762
763        let response_a = verify_payment(&verify_url_a).await?;
764        let response_b = verify_payment(&verify_url_b).await?;
765
766        assert!(!response_a.settled);
767        assert!(!response_b.settled);
768
769        assert!(response_a.preimage.is_none());
770        assert!(response_b.preimage.is_none());
771
772        gw_send.client().pay_invoice(invoice_a.clone()).await?;
773        gw_send.client().pay_invoice(invoice_b.clone()).await?;
774
775        let response_a = verify_payment(&verify_url_a).await?;
776        let response_b = verify_payment(&verify_url_b).await?;
777
778        assert!(response_a.settled);
779        assert!(response_b.settled);
780
781        verify_preimage(&response_a, &invoice_a);
782        verify_preimage(&response_b, &invoice_b);
783
784        assert_eq!(verify_task_a.await??, response_a);
785        assert_eq!(verify_task_b.await??, response_b);
786    }
787
788    wait_for_lnurl_balance(&client_a, "client A", 950 * 1000).await?;
789    wait_for_lnurl_balance(&client_b, "client B", 950 * 1000).await?;
790
791    Ok(())
792}
793
794/// Tests LNURL receives after recovery from seed.
795///
796/// LNv2 uses `NoModuleBackup`, so recovery restores funds via the mint module
797/// but not the operation log or LNv2 state. Verifies:
798/// 1. Balance is fully recovered.
799/// 2. Payments to a pre-recovery LNURL are still claimed by the restored
800///    client.
801async fn test_lnurl_recovery(dev_fed: &DevJitFed) -> anyhow::Result<()> {
802    // Before v0.10.0 the CLI registered LNURLs via a POST to the recurringd
803    // server.  That endpoint no longer exists, so old CLIs cannot generate
804    // LNURLs against the current recurringdv2.
805    if util::FedimintCli::version_or_default().await < *VERSION_0_10_0_ALPHA {
806        return Ok(());
807    }
808
809    let federation = dev_fed.fed().await?;
810    let gw_lnd = dev_fed.gw_lnd().await?;
811    let gw_ldk = dev_fed.gw_ldk().await?;
812    let recurringd = dev_fed.recurringdv2().await?.api_url().to_string();
813
814    const LNURL_AMOUNT_MSAT: u64 = 500_000;
815    const LNURL_BALANCE_TOLERANCE_MSAT: u64 = 100_000;
816
817    // ── Phase 1: Pre-recovery LNURL receives ──────────────────────────
818
819    info!("Phase 1: Creating client and receiving via LNURL before recovery");
820
821    let client = federation
822        .new_joined_client("lnv2-lnurl-recovery-original")
823        .await?;
824
825    let lnurl = generate_lnurl(&client, &recurringd, &gw_ldk.addr).await?;
826
827    for i in 0..3 {
828        info!("Paying LNURL invoice {}/3", i + 1);
829        let (invoice, _verify_url) = fetch_invoice(lnurl.clone(), LNURL_AMOUNT_MSAT).await?;
830        gw_lnd.client().pay_invoice(invoice).await?;
831    }
832
833    while almost_equal(
834        client.balance().await?,
835        3 * LNURL_AMOUNT_MSAT,
836        LNURL_BALANCE_TOLERANCE_MSAT,
837    )
838    .is_err()
839    {
840        info!("Waiting for pre-recovery LNURL payments to settle...");
841        cmd!(client, "dev", "wait", "1").out_json().await?;
842    }
843
844    let pre_recovery_balance = client.balance().await?;
845    info!("Pre-recovery balance: {pre_recovery_balance} msats");
846
847    let mnemonic = cmd!(client, "print-secret").out_json().await?["secret"]
848        .as_str()
849        .expect("secret is a string")
850        .to_owned();
851
852    // ── Phase 2: Recovery ─────────────────────────────────────────────
853
854    info!("Phase 2: Recovering client from seed");
855
856    let restored = Client::create("lnv2-lnurl-recovery-restored").await?;
857    cmd!(
858        restored,
859        "restore",
860        "--invite-code",
861        federation.invite_code()?,
862        "--mnemonic",
863        &mnemonic
864    )
865    .run()
866    .await?;
867
868    while restored.balance().await? < pre_recovery_balance {
869        info!("Waiting for recovery to complete...");
870        cmd!(restored, "dev", "wait", "1").out_json().await?;
871    }
872
873    let post_recovery_balance = restored.balance().await?;
874    info!("Post-recovery balance: {post_recovery_balance} msats");
875
876    // ── Phase 3: Post-recovery LNURL receives ─────────────────────────
877
878    info!("Phase 3: Paying to the original LNURL; restored client must claim");
879
880    // Reuse the Phase 1 `lnurl` on purpose: this is what a third party would
881    // have saved pre-recovery, and it must still pay the restored client.
882    for i in 0..2 {
883        info!("Paying pre-recovery LNURL invoice {}/2", i + 1);
884        let (invoice, _verify_url) = fetch_invoice(lnurl.clone(), LNURL_AMOUNT_MSAT).await?;
885        gw_lnd.client().pay_invoice(invoice).await?;
886    }
887
888    while almost_equal(
889        restored.balance().await?,
890        post_recovery_balance + 2 * LNURL_AMOUNT_MSAT,
891        LNURL_BALANCE_TOLERANCE_MSAT,
892    )
893    .is_err()
894    {
895        info!("Waiting for post-recovery LNURL payments to settle...");
896        cmd!(restored, "dev", "wait", "1").out_json().await?;
897    }
898
899    let final_balance = restored.balance().await?;
900    info!("Final balance: {final_balance} msats");
901
902    let operations = cmd!(restored, "list-operations", "--limit", "100")
903        .out_json()
904        .await?;
905    let lnv2_ops: Vec<_> = operations["operations"]
906        .as_array()
907        .expect("operations is an array")
908        .iter()
909        .filter(|op| op["operation_kind"].as_str() == Some("lnv2"))
910        .collect();
911    assert!(
912        lnv2_ops.len() >= 2,
913        "Expected at least 2 LNv2 operations after post-recovery receives, found {}",
914        lnv2_ops.len()
915    );
916
917    info!(
918        "LNURL recovery test passed: {} new operations, balance {final_balance} msats",
919        lnv2_ops.len()
920    );
921
922    Ok(())
923}
924
925async fn generate_lnurl(
926    client: &Client,
927    recurringd_base_url: &str,
928    gw_ldk_addr: &str,
929) -> anyhow::Result<String> {
930    cmd!(
931        client,
932        "module",
933        "lnv2",
934        "lnurl",
935        "generate",
936        recurringd_base_url,
937        "--gateway",
938        gw_ldk_addr,
939    )
940    .out_json()
941    .await
942    .map(|s| s.as_str().unwrap().to_owned())
943}
944
945fn verify_preimage(response: &VerifyResponse, invoice: &Bolt11Invoice) {
946    let preimage = response.preimage.expect("Payment should be settled");
947
948    let payment_hash = preimage.consensus_hash::<sha256::Hash>();
949
950    assert_eq!(payment_hash, *invoice.payment_hash());
951}
952
953async fn verify_payment(verify_url: &str) -> anyhow::Result<VerifyResponse> {
954    reqwest::get(verify_url)
955        .await?
956        .json::<LnurlResponse<VerifyResponse>>()
957        .await?
958        .into_result()
959        .map_err(anyhow::Error::msg)
960}
961
962async fn verify_payment_wait(verify_url: String) -> anyhow::Result<VerifyResponse> {
963    reqwest::get(format!("{verify_url}?wait"))
964        .await?
965        .json::<LnurlResponse<VerifyResponse>>()
966        .await?
967        .into_result()
968        .map_err(anyhow::Error::msg)
969}
970
971#[derive(Deserialize, Clone)]
972struct LnUrlPayResponse {
973    callback: String,
974}
975
976#[derive(Deserialize, Clone)]
977struct LnUrlPayInvoiceResponse {
978    pr: Bolt11Invoice,
979    verify: String,
980}
981
982async fn fetch_invoice(lnurl: String, amount_msat: u64) -> anyhow::Result<(Bolt11Invoice, String)> {
983    let url = parse_lnurl(&lnurl).ok_or_else(|| anyhow::anyhow!("Invalid LNURL"))?;
984
985    let response = reqwest::get(url).await?.json::<LnUrlPayResponse>().await?;
986
987    let callback_url = format!("{}?amount={}", response.callback, amount_msat);
988
989    let response = reqwest::get(callback_url)
990        .await?
991        .json::<LnUrlPayInvoiceResponse>()
992        .await?;
993
994    ensure!(
995        response.pr.amount_milli_satoshis() == Some(amount_msat),
996        "Invoice amount is not set"
997    );
998
999    Ok((response.pr, response.verify))
1000}
1001
1002async fn test_iroh_payment(
1003    client: &Client,
1004    gw_lnd: &Gatewayd,
1005    gw_ldk: &Gatewayd,
1006    online_peers: &[usize],
1007) -> anyhow::Result<()> {
1008    info!("Testing iroh payment...");
1009    // The send below relies on automatic gateway selection picking the iroh
1010    // gateway, so the gateways devimint added on startup have to go first.
1011    remove_all_gateways(client, online_peers).await?;
1012    for &peer in online_peers {
1013        add_gateway(client, peer, &format!("iroh://{}", gw_lnd.node_id)).await?;
1014    }
1015
1016    // If the client is below v0.10.0, also add the HTTP address so that the client
1017    // can fallback to using that, since the iroh gateway will fail.
1018    if util::FedimintCli::version_or_default().await < *VERSION_0_10_0_ALPHA
1019        || gw_lnd.gatewayd_version < *VERSION_0_10_0_ALPHA
1020    {
1021        for &peer in online_peers {
1022            add_gateway(client, peer, &gw_lnd.addr).await?;
1023        }
1024    }
1025
1026    let invoice = gw_ldk.client().create_invoice(5_000_000).await?;
1027
1028    let send_op = serde_json::from_value::<OperationId>(
1029        cmd!(client, "module", "lnv2", "send", invoice,)
1030            .out_json()
1031            .await?,
1032    )?;
1033
1034    let send_state = common::await_send(client, send_op).await?;
1035    assert!(
1036        matches!(send_state, FinalSendOperationState::Success(_)),
1037        "unexpected send state: {send_state:?}"
1038    );
1039
1040    let (invoice, receive_op) = serde_json::from_value::<(Bolt11Invoice, OperationId)>(
1041        cmd!(client, "module", "lnv2", "receive", "5000000",)
1042            .out_json()
1043            .await?,
1044    )?;
1045
1046    gw_ldk.client().pay_invoice(invoice).await?;
1047    common::await_receive_claimed(client, receive_op).await?;
1048
1049    if util::FedimintCli::version_or_default().await < *VERSION_0_10_0_ALPHA
1050        || gw_lnd.gatewayd_version < *VERSION_0_10_0_ALPHA
1051    {
1052        for &peer in online_peers {
1053            remove_gateway(client, peer, &gw_lnd.addr).await?;
1054        }
1055    }
1056
1057    for &peer in online_peers {
1058        remove_gateway(client, peer, &format!("iroh://{}", gw_lnd.node_id)).await?;
1059    }
1060
1061    Ok(())
1062}