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    // Gateways before 0.12 forward *every* pending HOLD invoice on their
274    // Lightning node to the gateway's payment handler and cancel the ones
275    // they cannot match to a registered LNv2 incoming contract. That includes
276    // the HOLD invoice this test creates directly on the LND node the LND
277    // gateway shares, so the gateway's cancel races this test's settle and
278    // fails it with `invoice already canceled` whenever the gateway wins.
279    // Only exercise HOLD invoices against gateways that filter out HOLD
280    // invoices they did not create themselves.
281    let test_hold_invoice = gw_lnd.gatewayd_version >= *VERSION_0_12_0_ALPHA;
282
283    let hold_invoice = if test_hold_invoice {
284        Some(lnd.create_hold_invoice(60000).await?)
285    } else {
286        info!(
287            gatewayd_version = %gw_lnd.gatewayd_version,
288            "Skipping HOLD invoice payment: gateway cancels HOLD invoices it did not create"
289        );
290
291        None
292    };
293
294    let gateway_pairs = [(gw_lnd, gw_ldk), (gw_ldk, gw_lnd)];
295
296    let gateway_matrix = [
297        (gw_lnd, gw_lnd),
298        (gw_lnd, gw_ldk),
299        (gw_ldk, gw_lnd),
300        (gw_ldk, gw_ldk),
301    ];
302
303    info!("Testing refund of circular payments...");
304
305    for (gw_send, gw_receive) in gateway_matrix {
306        info!(
307            "Testing refund of payment: client -> {} -> {} -> client",
308            gw_send.ln.ln_type(),
309            gw_receive.ln.ln_type()
310        );
311
312        let invoice = common::receive(&client, &gw_receive.addr, 1_000_000)
313            .await?
314            .0;
315
316        let state = common::send(&client, &gw_send.addr, &invoice.to_string()).await?;
317        assert!(matches!(state, FinalSendOperationState::Refunded));
318    }
319
320    pegin_gateways(dev_fed).await?;
321
322    info!("Testing circular payments...");
323
324    for (gw_send, gw_receive) in gateway_matrix {
325        info!(
326            "Testing payment: client -> {} -> {} -> client",
327            gw_send.ln.ln_type(),
328            gw_receive.ln.ln_type()
329        );
330
331        let (invoice, receive_op) = common::receive(&client, &gw_receive.addr, 1_000_000).await?;
332
333        let state = common::send(&client, &gw_send.addr, &invoice.to_string()).await?;
334        assert!(matches!(state, FinalSendOperationState::Success(_)));
335
336        common::await_receive_claimed(&client, receive_op).await?;
337    }
338
339    info!("Testing payments from client to gateways...");
340
341    for (gw_send, gw_receive) in gateway_pairs {
342        info!(
343            "Testing payment: client -> {} -> {}",
344            gw_send.ln.ln_type(),
345            gw_receive.ln.ln_type()
346        );
347
348        let invoice = gw_receive.client().create_invoice(1_000_000).await?;
349
350        let state = common::send(&client, &gw_send.addr, &invoice.to_string()).await?;
351        assert!(matches!(state, FinalSendOperationState::Success(_)));
352    }
353
354    info!("Testing payments from gateways to client...");
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, receive_op) = common::receive(&client, &gw_receive.addr, 1_000_000).await?;
364
365        gw_send.client().pay_invoice(invoice).await?;
366
367        common::await_receive_claimed(&client, receive_op).await?;
368    }
369
370    retry(
371        "Waiting for the full balance to become available to the client".to_string(),
372        backoff_util::background_backoff(),
373        || async {
374            ensure!(client.balance().await? >= 9000 * 1000);
375
376            Ok(())
377        },
378    )
379    .await?;
380
381    if let Some((hold_preimage, hold_invoice, hold_payment_hash)) = hold_invoice {
382        info!("Testing Client can pay LND HOLD invoice via LDK Gateway...");
383
384        let (state, _) = try_join!(
385            common::send(&client, &gw_ldk.addr, &hold_invoice),
386            lnd.settle_hold_invoice(hold_preimage, hold_payment_hash),
387        )?;
388        assert!(matches!(state, FinalSendOperationState::Success(_)));
389    }
390
391    info!("Testing LNv2 lightning fees...");
392
393    let fed_id = federation.calculate_federation_id();
394
395    gw_lnd
396        .client()
397        .set_federation_routing_fee(fed_id.clone(), 0, 0)
398        .await?;
399
400    gw_lnd
401        .client()
402        .set_federation_transaction_fee(fed_id.clone(), 0, 0)
403        .await?;
404
405    // Gateway pays: 1_000 msat LNv2 federation base fee. Gateway receives:
406    // 1_000_000 payment.
407    test_fees(fed_id, &client, gw_lnd, gw_ldk, 1_000_000 - 1_000).await?;
408
409    let online_peers: Vec<usize> = federation.members.keys().copied().collect();
410
411    test_iroh_payment(&client, gw_lnd, gw_ldk, &online_peers).await?;
412
413    info!("Testing payment summary...");
414
415    let lnd_payment_summary = gw_lnd.client().payment_summary().await?;
416
417    assert_eq!(lnd_payment_summary.outgoing.total_success, 5);
418    assert_eq!(lnd_payment_summary.outgoing.total_failure, 2);
419    assert_eq!(lnd_payment_summary.incoming.total_success, 4);
420    assert_eq!(lnd_payment_summary.incoming.total_failure, 0);
421
422    assert!(lnd_payment_summary.outgoing.median_latency.is_some());
423    assert!(lnd_payment_summary.outgoing.average_latency.is_some());
424    assert!(lnd_payment_summary.incoming.median_latency.is_some());
425    assert!(lnd_payment_summary.incoming.average_latency.is_some());
426
427    let ldk_payment_summary = gw_ldk.client().payment_summary().await?;
428
429    assert_eq!(
430        ldk_payment_summary.outgoing.total_success,
431        if test_hold_invoice { 4 } else { 3 }
432    );
433    assert_eq!(ldk_payment_summary.outgoing.total_failure, 2);
434    assert_eq!(ldk_payment_summary.incoming.total_success, 4);
435    assert_eq!(ldk_payment_summary.incoming.total_failure, 0);
436
437    assert!(ldk_payment_summary.outgoing.median_latency.is_some());
438    assert!(ldk_payment_summary.outgoing.average_latency.is_some());
439    assert!(ldk_payment_summary.incoming.median_latency.is_some());
440    assert!(ldk_payment_summary.incoming.average_latency.is_some());
441
442    Ok(())
443}
444
445/// Creates a fresh client and joins it to the federation identified by
446/// `invite_code`, unlike `Federation::new_joined_client` which always joins
447/// whichever federation last wrote the shared invite-code file.
448async fn join_client(name: &str, invite_code: &str) -> anyhow::Result<Client> {
449    let client = Client::create(name).await?;
450    client.join_federation(invite_code.to_string()).await?;
451    Ok(client)
452}
453
454/// Three independent clients — two in the primary federation and one in a
455/// second federation, all served by the same gateway — race to pay the *same*
456/// invoice. The gateway pays the invoice only once, so it may claim only one of
457/// the three outgoing contracts regardless of which federation funded them; the
458/// other two must be forfeited and refunded. Exactly one payment settles.
459async fn test_duplicate_payment(
460    dev_fed: &DevJitFed,
461    process_mgr: &ProcessManager,
462) -> anyhow::Result<()> {
463    info!(
464        "Testing three clients across two federations paying the same invoice settle exactly once..."
465    );
466
467    // The LDK gateway is used to send: its `pay` is idempotent per payment hash,
468    // so all contracts share a single Lightning payment and preimage.
469    let gw_send = dev_fed.gw_ldk().await?;
470    let gw_receive = dev_fed.gw_lnd().await?;
471
472    // The cross-federation dedup lives in the gateway's global database, added in
473    // 0.12. Older gateways would claim both contracts, so skip against them.
474    if gw_send.gatewayd_version < *VERSION_0_12_0_ALPHA {
475        info!(
476            gatewayd_version = %gw_send.gatewayd_version,
477            "Skipping: gateway predates cross-federation outgoing payment dedup"
478        );
479        return Ok(());
480    }
481
482    let first_federation = dev_fed.fed().await?;
483
484    // `Federation::invite_code` reads a single shared invite-code file, so
485    // capture the primary federation's invite before spawning the second one
486    // overwrites it. Clients must be joined with the right invite explicitly;
487    // `new_joined_client` would join whichever federation wrote that file last.
488    let first_invite = first_federation.invite_code()?;
489
490    // A second federation, also served by the sending gateway, covers the
491    // cross-federation case: a contract funded in one federation must still
492    // block claiming a contract for the same invoice funded in another.
493    let second_federation = Federation::new(
494        process_mgr,
495        dev_fed.bitcoind().await?.clone(),
496        false,
497        false,
498        false,
499        1,
500        "lnv2-duplicate-payment".to_string(),
501    )
502    .await?;
503    let second_invite = second_federation.invite_code()?;
504
505    assert_ne!(
506        first_federation.calculate_federation_id(),
507        second_federation.calculate_federation_id(),
508        "the second federation must be distinct from the first"
509    );
510
511    // Spawning the second federation overwrote the shared invite-code file
512    // with its own invite. Restore the primary federation's invite now that
513    // both invites are captured: `Federation::invite_code` re-reads that file,
514    // so a later `new_joined_client` on the primary federation would otherwise
515    // join the second one, which is torn down when this test returns.
516    let client_dir: PathBuf = std::env::var(FM_CLIENT_DIR_ENV)?.parse()?;
517    write_overwrite_async(client_dir.join("invite-code"), &first_invite).await?;
518
519    gw_send.client().connect_fed(second_invite.clone()).await?;
520    second_federation
521        .pegin_gateways(1_000_000, vec![gw_send])
522        .await?;
523
524    let client_a = join_client("lnv2-duplicate-payment-a", &first_invite).await?;
525    let client_b = join_client("lnv2-duplicate-payment-b", &first_invite).await?;
526    let client_c = join_client("lnv2-duplicate-payment-c", &second_invite).await?;
527
528    first_federation.pegin_client(10_000, &client_a).await?;
529    first_federation.pegin_client(10_000, &client_b).await?;
530    second_federation.pegin_client(10_000, &client_c).await?;
531
532    // Control: the second-federation client can pay a *different* invoice through
533    // the gateway on its own. This proves the gateway serves the second
534    // federation, so any refund of the shared invoice below is caused by the
535    // duplicate payment rather than a second-federation setup problem.
536    let control_invoice = gw_receive
537        .client()
538        .create_invoice(1_000_000)
539        .await?
540        .to_string();
541    let control = common::send(&client_c, &gw_send.addr, &control_invoice).await?;
542    assert!(
543        matches!(control, FinalSendOperationState::Success(_)),
544        "second-federation control payment must succeed, got {control:?}"
545    );
546
547    let invoice = gw_receive
548        .client()
549        .create_invoice(1_000_000)
550        .await?
551        .to_string();
552
553    // The two clients in the first federation race for the invoice; the
554    // per-federation dedup settles exactly one of them and refunds the other.
555    let (state_a, state_b) = try_join!(
556        common::send(&client_a, &gw_send.addr, &invoice),
557        common::send(&client_b, &gw_send.addr, &invoice),
558    )?;
559
560    // The second-federation client then pays the same, now-settled invoice. The
561    // gateway already holds the preimage, so without cross-federation dedup it
562    // claims this contract too, being reimbursed twice for one Lightning payment.
563    let state_c = common::send(&client_c, &gw_send.addr, &invoice).await?;
564
565    let states = [state_a, state_b, state_c];
566
567    info!("shared-invoice send states: {states:?}");
568
569    let successes = states
570        .iter()
571        .filter(|state| matches!(state, FinalSendOperationState::Success(_)))
572        .count();
573
574    let refunds = states
575        .iter()
576        .filter(|state| matches!(state, FinalSendOperationState::Refunded))
577        .count();
578
579    assert_eq!(
580        (successes, refunds),
581        (1, 2),
582        "exactly one payment must settle and the other two be refunded, got {states:?}"
583    );
584
585    Ok(())
586}
587
588async fn test_fees(
589    fed_id: String,
590    client: &Client,
591    gw_lnd: &Gatewayd,
592    gw_ldk: &Gatewayd,
593    expected_addition: u64,
594) -> anyhow::Result<()> {
595    let gw_lnd_ecash_prev = gw_lnd.client().ecash_balance(fed_id.clone()).await?;
596
597    let (invoice, receive_op) = common::receive(client, &gw_ldk.addr, 1_000_000).await?;
598
599    let state = common::send(client, &gw_lnd.addr, &invoice.to_string()).await?;
600    assert!(matches!(state, FinalSendOperationState::Success(_)));
601
602    common::await_receive_claimed(client, receive_op).await?;
603
604    // The sending gateway claims its outgoing contract in the background after
605    // returning the preimage to the sender, so its ecash balance is credited
606    // asynchronously. Wait for the claim to settle before asserting the fee.
607    while almost_equal(
608        gw_lnd_ecash_prev + expected_addition,
609        gw_lnd.client().ecash_balance(fed_id.clone()).await?,
610        5000,
611    )
612    .is_err()
613    {
614        info!("Waiting for the sending gateway's outgoing claim to settle...");
615        cmd!(client, "dev", "wait", "1").out_json().await?;
616    }
617
618    Ok(())
619}
620
621async fn add_gateway(client: &Client, peer: usize, gateway: &String) -> anyhow::Result<bool> {
622    cmd!(
623        client,
624        "--our-id",
625        peer.to_string(),
626        "--password",
627        "pass",
628        "module",
629        "lnv2",
630        "gateways",
631        "add",
632        gateway
633    )
634    .out_json()
635    .await?
636    .as_bool()
637    .ok_or(anyhow::anyhow!("JSON Value is not a boolean"))
638}
639
640async fn remove_gateway(client: &Client, peer: usize, gateway: &String) -> anyhow::Result<bool> {
641    cmd!(
642        client,
643        "--our-id",
644        peer.to_string(),
645        "--password",
646        "pass",
647        "module",
648        "lnv2",
649        "gateways",
650        "remove",
651        gateway
652    )
653    .out_json()
654    .await?
655    .as_bool()
656    .ok_or(anyhow::anyhow!("JSON Value is not a boolean"))
657}
658
659/// Remove all currently registered gateways, including the ones devimint
660/// adds on startup, from the given peers.
661async fn remove_all_gateways(client: &Client, peers: &[usize]) -> anyhow::Result<()> {
662    let gateways = cmd!(client, "module", "lnv2", "gateways", "list")
663        .out_json()
664        .await?
665        .as_array()
666        .expect("JSON Value is not an array")
667        .iter()
668        .map(|gateway| {
669            gateway
670                .as_str()
671                .expect("JSON Value is not a string")
672                .to_string()
673        })
674        .collect::<Vec<String>>();
675
676    for gateway in &gateways {
677        for &peer in peers {
678            assert!(remove_gateway(client, peer, gateway).await?);
679        }
680    }
681
682    Ok(())
683}
684
685// Keep this below the 310s `fm-run-test` timeout so LNURL flakes fail with
686// useful balance diagnostics instead of a generic test timeout.
687const LNURL_BALANCE_WAIT_ATTEMPTS: u64 = 120;
688
689async fn wait_for_lnurl_balance(
690    client: &Client,
691    client_name: &str,
692    expected_msats: u64,
693) -> anyhow::Result<()> {
694    let mut last_balance_msats = client.balance().await?;
695
696    for attempt in 1..=LNURL_BALANCE_WAIT_ATTEMPTS {
697        if last_balance_msats < expected_msats {
698            info!(
699                client_name,
700                balance_msats = last_balance_msats,
701                expected_msats,
702                attempt,
703                "Waiting for {client_name} to receive funds via LNURL"
704            );
705            cmd!(client, "dev", "wait", "1").out_json().await?;
706            last_balance_msats = client.balance().await?;
707        } else {
708            info!(
709                client_name,
710                balance_msats = last_balance_msats,
711                expected_msats,
712                attempt,
713                "{client_name} successfully received funds via LNURL"
714            );
715            return Ok(());
716        }
717    }
718
719    if last_balance_msats < expected_msats {
720        anyhow::bail!(
721            "timed out waiting for {client_name} to receive funds via LNURL after {} attempts; last balance: {last_balance_msats} msats; expected balance: {expected_msats} msats",
722            LNURL_BALANCE_WAIT_ATTEMPTS
723        );
724    }
725
726    info!(
727        client_name,
728        balance_msats = last_balance_msats,
729        expected_msats,
730        attempt = LNURL_BALANCE_WAIT_ATTEMPTS,
731        "{client_name} successfully received funds via LNURL"
732    );
733
734    Ok(())
735}
736
737async fn test_lnurl_pay(dev_fed: &DevJitFed) -> anyhow::Result<()> {
738    if util::FedimintCli::version_or_default().await < *VERSION_0_11_0_ALPHA {
739        return Ok(());
740    }
741
742    if util::FedimintdCmd::version_or_default().await < *VERSION_0_11_0_ALPHA {
743        return Ok(());
744    }
745
746    if util::Gatewayd::version_or_default().await < *VERSION_0_11_0_ALPHA {
747        return Ok(());
748    }
749
750    let federation = dev_fed.fed().await?;
751
752    let gw_lnd = dev_fed.gw_lnd().await?;
753    let gw_ldk = dev_fed.gw_ldk().await?;
754
755    let gateway_pairs = [(gw_lnd, gw_ldk), (gw_ldk, gw_lnd)];
756
757    let recurringd = dev_fed.recurringdv2().await?.api_url().to_string();
758
759    let client_a = federation
760        .new_joined_client("lnv2-lnurl-test-client-a")
761        .await?;
762
763    assert_module_sanity(&client_a).await?;
764
765    let client_b = federation
766        .new_joined_client("lnv2-lnurl-test-client-b")
767        .await?;
768
769    assert_module_sanity(&client_b).await?;
770
771    for (gw_send, gw_receive) in gateway_pairs {
772        info!(
773            "Testing lnurl payments: {} -> {} -> client",
774            gw_send.ln.ln_type(),
775            gw_receive.ln.ln_type()
776        );
777
778        let lnurl_a = generate_lnurl(&client_a, &recurringd, &gw_receive.addr).await?;
779        let lnurl_b = generate_lnurl(&client_b, &recurringd, &gw_receive.addr).await?;
780
781        let (invoice_a, verify_url_a) = fetch_invoice(lnurl_a.clone(), 500_000).await?;
782        let (invoice_b, verify_url_b) = fetch_invoice(lnurl_b.clone(), 500_000).await?;
783
784        let verify_task_a = task::spawn("verify_task_a", verify_payment_wait(verify_url_a.clone()));
785        let verify_task_b = task::spawn("verify_task_b", verify_payment_wait(verify_url_b.clone()));
786
787        let response_a = verify_payment(&verify_url_a).await?;
788        let response_b = verify_payment(&verify_url_b).await?;
789
790        assert!(!response_a.settled);
791        assert!(!response_b.settled);
792
793        assert!(response_a.preimage.is_none());
794        assert!(response_b.preimage.is_none());
795
796        gw_send.client().pay_invoice(invoice_a.clone()).await?;
797        gw_send.client().pay_invoice(invoice_b.clone()).await?;
798
799        let response_a = verify_payment(&verify_url_a).await?;
800        let response_b = verify_payment(&verify_url_b).await?;
801
802        assert!(response_a.settled);
803        assert!(response_b.settled);
804
805        verify_preimage(&response_a, &invoice_a);
806        verify_preimage(&response_b, &invoice_b);
807
808        assert_eq!(verify_task_a.await??, response_a);
809        assert_eq!(verify_task_b.await??, response_b);
810    }
811
812    wait_for_lnurl_balance(&client_a, "client A", 950 * 1000).await?;
813    wait_for_lnurl_balance(&client_b, "client B", 950 * 1000).await?;
814
815    Ok(())
816}
817
818/// Tests LNURL receives after recovery from seed.
819///
820/// LNv2 uses `NoModuleBackup`, so recovery restores funds via the mint module
821/// but not the operation log or LNv2 state. Verifies:
822/// 1. Balance is fully recovered.
823/// 2. Payments to a pre-recovery LNURL are still claimed by the restored
824///    client.
825async fn test_lnurl_recovery(dev_fed: &DevJitFed) -> anyhow::Result<()> {
826    // Before v0.10.0 the CLI registered LNURLs via a POST to the recurringd
827    // server.  That endpoint no longer exists, so old CLIs cannot generate
828    // LNURLs against the current recurringdv2.
829    if util::FedimintCli::version_or_default().await < *VERSION_0_10_0_ALPHA {
830        return Ok(());
831    }
832
833    let federation = dev_fed.fed().await?;
834    let gw_lnd = dev_fed.gw_lnd().await?;
835    let gw_ldk = dev_fed.gw_ldk().await?;
836    let recurringd = dev_fed.recurringdv2().await?.api_url().to_string();
837
838    const LNURL_AMOUNT_MSAT: u64 = 500_000;
839    const LNURL_BALANCE_TOLERANCE_MSAT: u64 = 100_000;
840
841    // ── Phase 1: Pre-recovery LNURL receives ──────────────────────────
842
843    info!("Phase 1: Creating client and receiving via LNURL before recovery");
844
845    let client = federation
846        .new_joined_client("lnv2-lnurl-recovery-original")
847        .await?;
848
849    let lnurl = generate_lnurl(&client, &recurringd, &gw_ldk.addr).await?;
850
851    for i in 0..3 {
852        info!("Paying LNURL invoice {}/3", i + 1);
853        let (invoice, _verify_url) = fetch_invoice(lnurl.clone(), LNURL_AMOUNT_MSAT).await?;
854        gw_lnd.client().pay_invoice(invoice).await?;
855    }
856
857    while almost_equal(
858        client.balance().await?,
859        3 * LNURL_AMOUNT_MSAT,
860        LNURL_BALANCE_TOLERANCE_MSAT,
861    )
862    .is_err()
863    {
864        info!("Waiting for pre-recovery LNURL payments to settle...");
865        cmd!(client, "dev", "wait", "1").out_json().await?;
866    }
867
868    let pre_recovery_balance = client.balance().await?;
869    info!("Pre-recovery balance: {pre_recovery_balance} msats");
870
871    let mnemonic = cmd!(client, "print-secret").out_json().await?["secret"]
872        .as_str()
873        .expect("secret is a string")
874        .to_owned();
875
876    // ── Phase 2: Recovery ─────────────────────────────────────────────
877
878    info!("Phase 2: Recovering client from seed");
879
880    let restored = Client::create("lnv2-lnurl-recovery-restored").await?;
881    cmd!(
882        restored,
883        "restore",
884        "--invite-code",
885        federation.invite_code()?,
886        "--mnemonic",
887        &mnemonic
888    )
889    .run()
890    .await?;
891
892    while restored.balance().await? < pre_recovery_balance {
893        info!("Waiting for recovery to complete...");
894        cmd!(restored, "dev", "wait", "1").out_json().await?;
895    }
896
897    let post_recovery_balance = restored.balance().await?;
898    info!("Post-recovery balance: {post_recovery_balance} msats");
899
900    // ── Phase 3: Post-recovery LNURL receives ─────────────────────────
901
902    info!("Phase 3: Paying to the original LNURL; restored client must claim");
903
904    // Reuse the Phase 1 `lnurl` on purpose: this is what a third party would
905    // have saved pre-recovery, and it must still pay the restored client.
906    for i in 0..2 {
907        info!("Paying pre-recovery LNURL invoice {}/2", i + 1);
908        let (invoice, _verify_url) = fetch_invoice(lnurl.clone(), LNURL_AMOUNT_MSAT).await?;
909        gw_lnd.client().pay_invoice(invoice).await?;
910    }
911
912    while almost_equal(
913        restored.balance().await?,
914        post_recovery_balance + 2 * LNURL_AMOUNT_MSAT,
915        LNURL_BALANCE_TOLERANCE_MSAT,
916    )
917    .is_err()
918    {
919        info!("Waiting for post-recovery LNURL payments to settle...");
920        cmd!(restored, "dev", "wait", "1").out_json().await?;
921    }
922
923    let final_balance = restored.balance().await?;
924    info!("Final balance: {final_balance} msats");
925
926    let operations = cmd!(restored, "list-operations", "--limit", "100")
927        .out_json()
928        .await?;
929    let lnv2_ops: Vec<_> = operations["operations"]
930        .as_array()
931        .expect("operations is an array")
932        .iter()
933        .filter(|op| op["operation_kind"].as_str() == Some("lnv2"))
934        .collect();
935    assert!(
936        lnv2_ops.len() >= 2,
937        "Expected at least 2 LNv2 operations after post-recovery receives, found {}",
938        lnv2_ops.len()
939    );
940
941    info!(
942        "LNURL recovery test passed: {} new operations, balance {final_balance} msats",
943        lnv2_ops.len()
944    );
945
946    Ok(())
947}
948
949async fn generate_lnurl(
950    client: &Client,
951    recurringd_base_url: &str,
952    gw_ldk_addr: &str,
953) -> anyhow::Result<String> {
954    cmd!(
955        client,
956        "module",
957        "lnv2",
958        "lnurl",
959        "generate",
960        recurringd_base_url,
961        "--gateway",
962        gw_ldk_addr,
963    )
964    .out_json()
965    .await
966    .map(|s| s.as_str().unwrap().to_owned())
967}
968
969fn verify_preimage(response: &VerifyResponse, invoice: &Bolt11Invoice) {
970    let preimage = response.preimage.expect("Payment should be settled");
971
972    let payment_hash = preimage.consensus_hash::<sha256::Hash>();
973
974    assert_eq!(payment_hash, *invoice.payment_hash());
975}
976
977async fn verify_payment(verify_url: &str) -> anyhow::Result<VerifyResponse> {
978    reqwest::get(verify_url)
979        .await?
980        .json::<LnurlResponse<VerifyResponse>>()
981        .await?
982        .into_result()
983        .map_err(anyhow::Error::msg)
984}
985
986async fn verify_payment_wait(verify_url: String) -> anyhow::Result<VerifyResponse> {
987    reqwest::get(format!("{verify_url}?wait"))
988        .await?
989        .json::<LnurlResponse<VerifyResponse>>()
990        .await?
991        .into_result()
992        .map_err(anyhow::Error::msg)
993}
994
995#[derive(Deserialize, Clone)]
996struct LnUrlPayResponse {
997    callback: String,
998}
999
1000#[derive(Deserialize, Clone)]
1001struct LnUrlPayInvoiceResponse {
1002    pr: Bolt11Invoice,
1003    verify: String,
1004}
1005
1006async fn fetch_invoice(lnurl: String, amount_msat: u64) -> anyhow::Result<(Bolt11Invoice, String)> {
1007    let url = parse_lnurl(&lnurl).ok_or_else(|| anyhow::anyhow!("Invalid LNURL"))?;
1008
1009    let response = reqwest::get(url).await?.json::<LnUrlPayResponse>().await?;
1010
1011    let callback_url = format!("{}?amount={}", response.callback, amount_msat);
1012
1013    let response = reqwest::get(callback_url)
1014        .await?
1015        .json::<LnUrlPayInvoiceResponse>()
1016        .await?;
1017
1018    ensure!(
1019        response.pr.amount_milli_satoshis() == Some(amount_msat),
1020        "Invoice amount is not set"
1021    );
1022
1023    Ok((response.pr, response.verify))
1024}
1025
1026async fn test_iroh_payment(
1027    client: &Client,
1028    gw_lnd: &Gatewayd,
1029    gw_ldk: &Gatewayd,
1030    online_peers: &[usize],
1031) -> anyhow::Result<()> {
1032    info!("Testing iroh payment...");
1033    // The send below relies on automatic gateway selection picking the iroh
1034    // gateway, so the gateways devimint added on startup have to go first.
1035    remove_all_gateways(client, online_peers).await?;
1036    for &peer in online_peers {
1037        add_gateway(client, peer, &format!("iroh://{}", gw_lnd.node_id)).await?;
1038    }
1039
1040    // If the client is below v0.10.0, also add the HTTP address so that the client
1041    // can fallback to using that, since the iroh gateway will fail.
1042    if util::FedimintCli::version_or_default().await < *VERSION_0_10_0_ALPHA
1043        || gw_lnd.gatewayd_version < *VERSION_0_10_0_ALPHA
1044    {
1045        for &peer in online_peers {
1046            add_gateway(client, peer, &gw_lnd.addr).await?;
1047        }
1048    }
1049
1050    let invoice = gw_ldk.client().create_invoice(5_000_000).await?;
1051
1052    let send_op = serde_json::from_value::<OperationId>(
1053        cmd!(client, "module", "lnv2", "send", invoice,)
1054            .out_json()
1055            .await?,
1056    )?;
1057
1058    let send_state = common::await_send(client, send_op).await?;
1059    assert!(
1060        matches!(send_state, FinalSendOperationState::Success(_)),
1061        "unexpected send state: {send_state:?}"
1062    );
1063
1064    let (invoice, receive_op) = serde_json::from_value::<(Bolt11Invoice, OperationId)>(
1065        cmd!(client, "module", "lnv2", "receive", "5000000",)
1066            .out_json()
1067            .await?,
1068    )?;
1069
1070    gw_ldk.client().pay_invoice(invoice).await?;
1071    common::await_receive_claimed(client, receive_op).await?;
1072
1073    if util::FedimintCli::version_or_default().await < *VERSION_0_10_0_ALPHA
1074        || gw_lnd.gatewayd_version < *VERSION_0_10_0_ALPHA
1075    {
1076        for &peer in online_peers {
1077            remove_gateway(client, peer, &gw_lnd.addr).await?;
1078        }
1079    }
1080
1081    for &peer in online_peers {
1082        remove_gateway(client, peer, &format!("iroh://{}", gw_lnd.node_id)).await?;
1083    }
1084
1085    Ok(())
1086}