Skip to main content

lnv2_module_tests/
tests.rs

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