Skip to main content

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