Skip to main content

lnv2_module_tests/
tests.rs

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