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