gateway_tests/
main.rs

1#![deny(clippy::pedantic)]
2
3use std::collections::BTreeMap;
4use std::fs::{remove_dir_all, remove_file};
5use std::ops::ControlFlow;
6use std::path::PathBuf;
7use std::str::FromStr;
8use std::time::Duration;
9use std::{env, ffi};
10
11use clap::{Parser, Subcommand};
12use devimint::cli::cleanup_on_exit;
13use devimint::envs::FM_DATA_DIR_ENV;
14use devimint::external::{Bitcoind, Esplora};
15use devimint::federation::Federation;
16use devimint::util::{ProcessManager, almost_equal, poll, poll_with_timeout};
17use devimint::version_constants::{VERSION_0_8_2, VERSION_0_10_0_ALPHA};
18use devimint::{Gatewayd, LightningNode, cli, cmd, util};
19use fedimint_core::config::FederationId;
20use fedimint_core::time::now;
21use fedimint_core::{Amount, BitcoinAmountOrAll, bitcoin, default_esplora_server};
22use fedimint_gateway_common::{
23    FederationInfo, GatewayBalances, GatewayFedConfig, PaymentDetails, PaymentKind, PaymentStatus,
24};
25use fedimint_logging::LOG_TEST;
26use fedimint_testing_core::node_type::LightningNodeType;
27use itertools::Itertools;
28use tracing::info;
29
30#[derive(Parser)]
31struct GatewayTestOpts {
32    #[clap(subcommand)]
33    test: GatewayTest,
34}
35
36#[derive(Debug, Clone, Subcommand)]
37#[allow(clippy::enum_variant_names)]
38enum GatewayTest {
39    ConfigTest {
40        #[arg(long = "gw-type")]
41        gateway_type: LightningNodeType,
42    },
43    BackupRestoreTest,
44    LiquidityTest,
45    EsploraTest,
46}
47
48#[tokio::main]
49async fn main() -> anyhow::Result<()> {
50    let opts = GatewayTestOpts::parse();
51    match opts.test {
52        GatewayTest::ConfigTest { gateway_type } => Box::pin(config_test(gateway_type)).await,
53        GatewayTest::BackupRestoreTest => Box::pin(backup_restore_test()).await,
54        GatewayTest::LiquidityTest => Box::pin(liquidity_test()).await,
55        GatewayTest::EsploraTest => esplora_test().await,
56    }
57}
58
59async fn backup_restore_test() -> anyhow::Result<()> {
60    Box::pin(
61        devimint::run_devfed_test().call(|dev_fed, process_mgr| async move {
62            let gw = if devimint::util::supports_lnv2() {
63                dev_fed.gw_ldk_connected().await?
64            } else {
65                dev_fed.gw_lnd_registered().await?
66            };
67
68            let fed = dev_fed.fed().await?;
69            fed.pegin_gateways(10_000_000, vec![gw]).await?;
70
71            let mnemonic = gw.get_mnemonic().await?.mnemonic;
72
73            // Recover without a backup
74            info!(target: LOG_TEST, "Wiping gateway and recovering without a backup...");
75            let ln = gw.ln.clone();
76            let new_gw = stop_and_recover_gateway(
77                process_mgr.clone(),
78                mnemonic.clone(),
79                gw.to_owned(),
80                ln.clone(),
81                fed,
82            )
83            .await?;
84
85            // Recover with a backup
86            info!(target: LOG_TEST, "Wiping gateway and recovering with a backup...");
87            info!(target: LOG_TEST, "Creating backup...");
88            new_gw.backup_to_fed(fed).await?;
89            stop_and_recover_gateway(process_mgr, mnemonic, new_gw, ln, fed).await?;
90
91            info!(target: LOG_TEST, "backup_restore_test successful");
92            Ok(())
93        }),
94    )
95    .await
96}
97
98async fn stop_and_recover_gateway(
99    process_mgr: ProcessManager,
100    mnemonic: Vec<String>,
101    old_gw: Gatewayd,
102    new_ln: LightningNode,
103    fed: &Federation,
104) -> anyhow::Result<Gatewayd> {
105    let gateway_balances =
106        serde_json::from_value::<GatewayBalances>(cmd!(old_gw, "get-balances").out_json().await?)?;
107    let before_onchain_balance = gateway_balances.onchain_balance_sats;
108
109    // Stop the Gateway
110    let gw_type = old_gw.ln.ln_type();
111    let gw_name = old_gw.gw_name.clone();
112    let old_gw_index = old_gw.gateway_index;
113    old_gw.terminate().await?;
114    info!(target: LOG_TEST, "Terminated Gateway");
115
116    // Delete the gateway's database
117    let data_dir: PathBuf = env::var(FM_DATA_DIR_ENV)
118        .expect("Data dir is not set")
119        .parse()
120        .expect("Could not parse data dir");
121    let gw_db = data_dir.join(gw_name.clone()).join("gatewayd.db");
122    if gw_db.is_file() {
123        // db is single file on redb
124        remove_file(gw_db)?;
125    } else {
126        remove_dir_all(gw_db)?;
127    }
128    info!(target: LOG_TEST, "Deleted the Gateway's database");
129
130    if gw_type == LightningNodeType::Ldk {
131        // Delete LDK's database as well
132        let ldk_data_dir = data_dir.join(gw_name).join("ldk_node");
133        remove_dir_all(ldk_data_dir)?;
134        info!(target: LOG_TEST, "Deleted LDK's database");
135    }
136
137    let seed = mnemonic.join(" ");
138    // TODO: Audit that the environment access only happens in single-threaded code.
139    unsafe { std::env::set_var("FM_GATEWAY_MNEMONIC", seed) };
140    let new_gw = Gatewayd::new(&process_mgr, new_ln, old_gw_index).await?;
141    let new_mnemonic = new_gw.get_mnemonic().await?.mnemonic;
142    assert_eq!(mnemonic, new_mnemonic);
143    info!(target: LOG_TEST, "Verified mnemonic is the same after creating new Gateway");
144
145    let federations = serde_json::from_value::<Vec<FederationInfo>>(
146        new_gw.get_info().await?["federations"].clone(),
147    )?;
148    assert_eq!(0, federations.len());
149    info!(target: LOG_TEST, "Verified new Gateway has no federations");
150
151    new_gw.recover_fed(fed).await?;
152
153    let gateway_balances =
154        serde_json::from_value::<GatewayBalances>(cmd!(new_gw, "get-balances").out_json().await?)?;
155    let ecash_balance = gateway_balances
156        .ecash_balances
157        .first()
158        .expect("Should have one joined federation");
159    almost_equal(
160        ecash_balance.ecash_balance_msats.sats_round_down(),
161        10_000_000,
162        10,
163    )
164    .unwrap();
165    let after_onchain_balance = gateway_balances.onchain_balance_sats;
166    assert_eq!(before_onchain_balance, after_onchain_balance);
167    info!(target: LOG_TEST, "Verified balances after recovery");
168
169    Ok(new_gw)
170}
171
172/// Test that sets and verifies configurations within the gateway
173#[allow(clippy::too_many_lines)]
174async fn config_test(gw_type: LightningNodeType) -> anyhow::Result<()> {
175    Box::pin(
176        devimint::run_devfed_test()
177            .num_feds(2)
178            .call(|dev_fed, process_mgr| async move {
179                let gw = match gw_type {
180                    LightningNodeType::Lnd => dev_fed.gw_lnd_registered().await?,
181                    LightningNodeType::Ldk => dev_fed.gw_ldk_connected().await?,
182                };
183
184                // Try to connect to already connected federation
185                let invite_code = dev_fed.fed().await?.invite_code()?;
186                let output = cmd!(gw, "connect-fed", invite_code.clone())
187                    .out_json()
188                    .await;
189                assert!(
190                    output.is_err(),
191                    "Connecting to the same federation succeeded"
192                );
193                info!(target: LOG_TEST, "Verified that gateway couldn't connect to already connected federation");
194
195                let gatewayd_version = util::Gatewayd::version_or_default().await;
196
197                // Change the routing fees for a specific federation
198                let fed_id = dev_fed.fed().await?.calculate_federation_id();
199                gw.set_federation_routing_fee(fed_id.clone(), 20, 20000)
200                    .await?;
201
202                let lightning_fee = gw.get_lightning_fee(fed_id.clone()).await?;
203                assert_eq!(
204                    lightning_fee.base.msats, 20,
205                    "Federation base msat is not 20"
206                );
207                assert_eq!(
208                    lightning_fee.parts_per_million, 20000,
209                    "Federation proportional millionths is not 20000"
210                );
211                info!(target: LOG_TEST, "Verified per-federation routing fees changed");
212
213                let info_value = cmd!(gw, "info").out_json().await?;
214                let federations = info_value["federations"]
215                    .as_array()
216                    .expect("federations is an array");
217                assert_eq!(
218                    federations.len(),
219                    1,
220                    "Gateway did not have one connected federation"
221                );
222
223                // Get the federation's config and verify it parses correctly
224                let config_val = cmd!(gw, "cfg", "client-config", "--federation-id", fed_id)
225                    .out_json()
226                    .await?;
227
228                serde_json::from_value::<GatewayFedConfig>(config_val)?;
229
230                // Spawn new federation
231                let bitcoind = dev_fed.bitcoind().await?;
232                let new_fed = Federation::new(
233                    &process_mgr,
234                    bitcoind.clone(),
235                    false,
236                    false,
237                    1,
238                    "config-test".to_string(),
239                )
240                .await?;
241                let new_fed_id = new_fed.calculate_federation_id();
242                info!(target: LOG_TEST, "Successfully spawned new federation");
243
244                let new_invite_code = new_fed.invite_code()?;
245                cmd!(gw, "connect-fed", new_invite_code.clone())
246                    .out_json()
247                    .await?;
248
249                let (default_base, default_ppm) = if gatewayd_version >= *VERSION_0_8_2 {
250                    (0, 0)
251                } else {
252                    // v0.8.0 and v0.8.1
253                    (2000, 3000)
254                };
255
256                let lightning_fee = gw.get_lightning_fee(new_fed_id.clone()).await?;
257                assert_eq!(
258                    lightning_fee.base.msats, default_base,
259                    "Default Base msat for new federation was not correct"
260                );
261                assert_eq!(
262                    lightning_fee.parts_per_million, default_ppm,
263                    "Default Base msat for new federation was not correct"
264                );
265
266                info!(target: LOG_TEST, federation_id = %new_fed_id, "Verified new federation");
267
268                // Peg-in sats to gw for the new fed
269                let pegin_amount = Amount::from_msats(10_000_000);
270                new_fed
271                    .pegin_gateways(pegin_amount.sats_round_down(), vec![gw])
272                    .await?;
273
274                // Verify `info` returns multiple federations
275                let info_value = cmd!(gw, "info").out_json().await?;
276                let federations = info_value["federations"]
277                    .as_array()
278                    .expect("federations is an array");
279
280                assert_eq!(
281                    federations.len(),
282                    2,
283                    "Gateway did not have two connected federations"
284                );
285
286                let federation_fake_scids =
287                    serde_json::from_value::<Option<BTreeMap<u64, FederationId>>>(
288                        info_value
289                            .get("channels")
290                            .or_else(|| info_value.get("federation_fake_scids"))
291                            .expect("field  exists")
292                            .to_owned(),
293                    )
294                    .expect("cannot parse")
295                    .expect("should have scids");
296
297                assert_eq!(
298                    federation_fake_scids.keys().copied().collect::<Vec<u64>>(),
299                    vec![1, 2]
300                );
301
302                let first_fed_info = federations
303                    .iter()
304                    .find(|i| {
305                        *i["federation_id"]
306                            .as_str()
307                            .expect("should parse as str")
308                            .to_string()
309                            == fed_id
310                    })
311                    .expect("Could not find federation");
312
313                let second_fed_info = federations
314                    .iter()
315                    .find(|i| {
316                        *i["federation_id"]
317                            .as_str()
318                            .expect("should parse as str")
319                            .to_string()
320                            == new_fed_id
321                    })
322                    .expect("Could not find federation");
323
324                let first_fed_balance_msat =
325                    serde_json::from_value::<Amount>(first_fed_info["balance_msat"].clone())
326                        .expect("fed should have balance");
327
328                let second_fed_balance_msat =
329                    serde_json::from_value::<Amount>(second_fed_info["balance_msat"].clone())
330                        .expect("fed should have balance");
331
332                assert_eq!(first_fed_balance_msat, Amount::ZERO);
333                almost_equal(second_fed_balance_msat.msats, pegin_amount.msats, 10_000).unwrap();
334
335                leave_federation(gw, fed_id, 1).await?;
336                leave_federation(gw, new_fed_id, 2).await?;
337
338                // Rejoin new federation, verify that the balance is the same
339                let output = cmd!(gw, "connect-fed", new_invite_code.clone())
340                    .out_json()
341                    .await?;
342                let rejoined_federation_balance_msat =
343                    serde_json::from_value::<Amount>(output["balance_msat"].clone())
344                        .expect("fed has balance");
345
346                assert_eq!(second_fed_balance_msat, rejoined_federation_balance_msat);
347
348                if gw.gatewayd_version >= *VERSION_0_10_0_ALPHA {
349                    // Try to get the info over iroh
350                    info!(target: LOG_TEST, gatewayd_version = %gw.gatewayd_version, "Getting info over iroh");
351                    gw.get_info_iroh().await?;
352                }
353
354                info!(target: LOG_TEST, "Gateway configuration test successful");
355                Ok(())
356            }),
357    )
358    .await
359}
360
361/// Test that verifies the various liquidity tools (onchain, lightning, ecash)
362/// work correctly.
363#[allow(clippy::too_many_lines)]
364async fn liquidity_test() -> anyhow::Result<()> {
365    devimint::run_devfed_test()
366        .call(|dev_fed, _process_mgr| async move {
367            let federation = dev_fed.fed().await?;
368
369            if !devimint::util::supports_lnv2() {
370                info!(target: LOG_TEST, "LNv2 is not supported, which is necessary for LDK GW and liquidity test");
371                return Ok(());
372            }
373
374            let gw_lnd = dev_fed.gw_lnd_registered().await?;
375            let gw_ldk = dev_fed.gw_ldk_connected().await?;
376            let gw_ldk_second = dev_fed.gw_ldk_second_connected().await?;
377            let gateways = [gw_lnd, gw_ldk].to_vec();
378
379            let gateway_matrix = gateways
380                .iter()
381                .cartesian_product(gateways.iter())
382                .filter(|(a, b)| a.ln.ln_type() != b.ln.ln_type());
383
384            info!(target: LOG_TEST, "Pegging-in gateways...");
385            federation
386                .pegin_gateways(1_000_000, gateways.clone())
387                .await?;
388
389            info!(target: LOG_TEST, "Testing ecash payments between gateways...");
390            for (gw_send, gw_receive) in gateway_matrix.clone() {
391                info!(
392                    target: LOG_TEST,
393                    gw_send = %gw_send.ln.ln_type(),
394                    gw_receive = %gw_receive.ln.ln_type(),
395                    "Testing ecash payment",
396                );
397
398                let fed_id = federation.calculate_federation_id();
399                let prev_send_ecash_balance = gw_send.ecash_balance(fed_id.clone()).await?;
400                let prev_receive_ecash_balance = gw_receive.ecash_balance(fed_id.clone()).await?;
401                let ecash = gw_send.send_ecash(fed_id.clone(), 500_000).await?;
402                gw_receive.receive_ecash(ecash).await?;
403                let after_send_ecash_balance = gw_send.ecash_balance(fed_id.clone()).await?;
404                let after_receive_ecash_balance = gw_receive.ecash_balance(fed_id.clone()).await?;
405                assert_eq!(prev_send_ecash_balance - 500_000, after_send_ecash_balance);
406                almost_equal(
407                    prev_receive_ecash_balance + 500_000,
408                    after_receive_ecash_balance,
409                    2_000,
410                )
411                .unwrap();
412            }
413
414            info!(target: LOG_TEST, "Testing payments between gateways...");
415            for (gw_send, gw_receive) in gateway_matrix.clone() {
416                info!(
417                    target: LOG_TEST,
418                    gw_send = %gw_send.ln.ln_type(),
419                    gw_receive = %gw_receive.ln.ln_type(),
420                    "Testing lightning payment",
421                );
422
423                let invoice = gw_receive.create_invoice(1_000_000).await?;
424                gw_send.pay_invoice(invoice).await?;
425            }
426
427            let start = now() - Duration::from_secs(5 * 60);
428            let end = now() + Duration::from_secs(5 * 60);
429            info!(target: LOG_TEST, "Verifying list of transactions");
430            let lnd_transactions = gw_lnd.list_transactions(start, end).await?;
431            // One inbound and one outbound transaction
432            assert_eq!(lnd_transactions.len(), 2);
433
434            let ldk_transactions = gw_ldk.list_transactions(start, end).await?;
435            assert_eq!(ldk_transactions.len(), 2);
436
437            // Verify that transactions are filtered by time
438            let start = now() - Duration::from_secs(10 * 60);
439            let end = now() - Duration::from_secs(5 * 60);
440            let lnd_transactions = gw_lnd.list_transactions(start, end).await?;
441            assert_eq!(lnd_transactions.len(), 0);
442
443            info!(target: LOG_TEST, "Testing paying Bolt12 Offers...");
444            // TODO: investigate why the first BOLT12 payment attempt is expiring consistently
445            poll_with_timeout("First BOLT12 payment", Duration::from_secs(30), || async {
446                let offer_with_amount = gw_ldk_second.create_offer(Some(Amount::from_msats(10_000_000))).await.map_err(ControlFlow::Continue)?;
447                gw_ldk.pay_offer(offer_with_amount, None).await.map_err(ControlFlow::Continue)?;
448                assert!(get_transaction(gw_ldk_second, PaymentKind::Bolt12Offer, Amount::from_msats(10_000_000), PaymentStatus::Succeeded).await.is_some());
449                Ok(())
450            }).await?;
451
452            let offer_without_amount = gw_ldk.create_offer(None).await?;
453            gw_ldk_second.pay_offer(offer_without_amount.clone(), Some(Amount::from_msats(5_000_000))).await?;
454            assert!(get_transaction(gw_ldk, PaymentKind::Bolt12Offer, Amount::from_msats(5_000_000), PaymentStatus::Succeeded).await.is_some());
455
456            // Cannot pay an offer without an amount without specifying an amount
457            gw_ldk_second.pay_offer(offer_without_amount.clone(), None).await.expect_err("Cannot pay amountless offer without specifying an amount");
458
459            // Verify we can pay the offer again
460            gw_ldk_second.pay_offer(offer_without_amount, Some(Amount::from_msats(3_000_000))).await?;
461            assert!(get_transaction(gw_ldk, PaymentKind::Bolt12Offer, Amount::from_msats(3_000_000), PaymentStatus::Succeeded).await.is_some());
462
463            info!(target: LOG_TEST, "Pegging-out gateways...");
464            federation
465                .pegout_gateways(500_000_000, gateways.clone())
466                .await?;
467
468            info!(target: LOG_TEST, "Testing sending onchain...");
469            let bitcoind = dev_fed.bitcoind().await?;
470            for gw in &gateways {
471                let txid = gw
472                    .send_onchain(dev_fed.bitcoind().await?, BitcoinAmountOrAll::All, 10)
473                    .await?;
474                bitcoind.poll_get_transaction(txid).await?;
475            }
476
477            info!(target: LOG_TEST, "Testing closing all channels...");
478
479            // Gracefully close one of LND's channel's
480            let gw_ldk_pubkey = gw_ldk.lightning_pubkey().await?;
481            gw_lnd.close_channel(gw_ldk_pubkey, false).await?;
482
483            // Force close LDK's channels
484            gw_ldk_second.close_all_channels(true).await?;
485
486            // Verify none of the channels are active
487            for gw in gateways {
488                let channels = gw.list_channels().await?;
489                let active_channel = channels.into_iter().any(|chan| chan.is_active);
490                assert!(!active_channel);
491            }
492
493            Ok(())
494        })
495        .await
496}
497
498async fn esplora_test() -> anyhow::Result<()> {
499    let args = cli::CommonArgs::parse_from::<_, ffi::OsString>(vec![]);
500    let (process_mgr, task_group) = cli::setup(args).await?;
501    cleanup_on_exit(
502        async {
503            info!("Spawning bitcoind...");
504            let bitcoind = Bitcoind::new(&process_mgr, false).await?;
505            info!("Spawning esplora...");
506            let _esplora = Esplora::new(&process_mgr, bitcoind).await?;
507            let network = bitcoin::Network::from_str(&process_mgr.globals.FM_GATEWAY_NETWORK)
508                .expect("Could not parse network");
509            let esplora_port = process_mgr.globals.FM_PORT_ESPLORA.to_string();
510            let esplora = default_esplora_server(network, Some(esplora_port));
511            unsafe {
512                std::env::remove_var("FM_BITCOIND_URL");
513                std::env::set_var("FM_ESPLORA_URL", esplora.url.to_string());
514            }
515            info!("Spawning ldk gateway...");
516            let ldk = Gatewayd::new(
517                &process_mgr,
518                LightningNode::Ldk {
519                    name: "gateway-ldk-esplora".to_string(),
520                    gw_port: process_mgr.globals.FM_PORT_GW_LDK,
521                    ldk_port: process_mgr.globals.FM_PORT_LDK,
522                    metrics_port: process_mgr.globals.FM_PORT_GW_LDK_METRICS,
523                },
524                0,
525            )
526            .await?;
527
528            info!("Waiting for ldk gatewy to be ready...");
529            poll("Waiting for LDK to be ready", || async {
530                let info = ldk.get_info().await.map_err(ControlFlow::Continue)?;
531                let state: String = serde_json::from_value(info["gateway_state"].clone())
532                    .expect("Could not get gateway state");
533                if state == "Running" {
534                    Ok(())
535                } else {
536                    Err(ControlFlow::Continue(anyhow::anyhow!(
537                        "Gateway not running"
538                    )))
539                }
540            })
541            .await?;
542
543            ldk.get_ln_onchain_address().await?;
544            info!(target:LOG_TEST, "ldk gateway successfully spawned and connected to esplora");
545            Ok(())
546        },
547        task_group,
548    )
549    .await?;
550    Ok(())
551}
552
553async fn get_transaction(
554    gateway: &Gatewayd,
555    kind: PaymentKind,
556    amount: Amount,
557    status: PaymentStatus,
558) -> Option<PaymentDetails> {
559    let transactions = gateway
560        .list_transactions(
561            now() - Duration::from_secs(5 * 60),
562            now() + Duration::from_secs(5 * 60),
563        )
564        .await
565        .ok()?;
566    transactions.into_iter().find(|details| {
567        details.payment_kind == kind && details.amount == amount && details.status == status
568    })
569}
570
571/// Leaves the specified federation by issuing a `leave-fed` POST request to the
572/// gateway.
573async fn leave_federation(gw: &Gatewayd, fed_id: String, expected_scid: u64) -> anyhow::Result<()> {
574    let leave_fed = cmd!(gw, "leave-fed", "--federation-id", fed_id.clone())
575        .out_json()
576        .await
577        .expect("Leaving the federation failed");
578
579    let federation_id: FederationId = serde_json::from_value(leave_fed["federation_id"].clone())?;
580    assert_eq!(federation_id.to_string(), fed_id);
581
582    let scid = serde_json::from_value::<u64>(leave_fed["config"]["federation_index"].clone())?;
583
584    assert_eq!(scid, expected_scid);
585
586    info!(target: LOG_TEST, federation_id = %fed_id, "Verified gateway left federation");
587    Ok(())
588}