1#![deny(clippy::pedantic)]
2#![allow(clippy::cast_possible_truncation)]
3#![allow(clippy::cast_precision_loss)]
4#![allow(clippy::missing_errors_doc)]
5#![allow(clippy::ref_option)]
6#![allow(clippy::too_many_lines)]
7#![allow(clippy::large_futures)]
8
9use std::collections::{BTreeMap, HashMap};
10use std::path::PathBuf;
11use std::str::FromStr;
12use std::time::Duration;
13use std::vec;
14
15use anyhow::{Context, anyhow, bail};
16use clap::{Args, Parser, Subcommand, ValueEnum};
17use common::{
18 gateway_pay_invoice, get_note_summary, ldk_create_invoice, ldk_pay_invoice,
19 ldk_wait_invoice_payment, parse_gateway_id, reissue_notes,
20};
21use devimint::cmd;
22use devimint::util::GatewayLndCli;
23use devimint::version_constants::VERSION_0_10_0_ALPHA;
24use fedimint_api_client::download_from_invite_code;
25use fedimint_client::ClientHandleArc;
26use fedimint_connectors::ConnectorRegistry;
27use fedimint_core::Amount;
28use fedimint_core::endpoint_constants::SESSION_COUNT_ENDPOINT;
29use fedimint_core::invite_code::InviteCode;
30use fedimint_core::module::ApiRequestErased;
31use fedimint_core::runtime::spawn;
32use fedimint_core::util::{BoxFuture, SafeUrl};
33use fedimint_ln_client::{LightningClientModule, LnReceiveState};
34use fedimint_ln_common::LightningGateway;
35use fedimint_mint_client::OOBNotes;
36use futures::StreamExt;
37use lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription, Description};
38use serde::{Deserialize, Serialize};
39use tokio::fs::OpenOptions;
40use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufWriter};
41use tokio::sync::mpsc;
42use tracing::{debug, info, warn};
43
44use crate::common::{
45 build_client, do_spend_notes, get_invite_code_cli, remint_denomination, try_get_notes_cli,
46};
47pub mod common;
48#[cfg(test)]
49mod tests;
50
51#[derive(Parser, Clone)]
52#[command(version)]
53struct Opts {
54 #[arg(
55 long,
56 default_value = "10",
57 help = "Number of users. Each user will work in parallel"
58 )]
59 users: u16,
60
61 #[arg(long, help = "Output with the metrics results in JSON format")]
62 metrics_json_output: Option<PathBuf>,
63
64 #[arg(
65 long,
66 help = "If given, will be used to store and retrieve past metrics for comparison purposes"
67 )]
68 archive_dir: Option<PathBuf>,
69
70 #[clap(subcommand)]
71 command: Command,
72}
73
74#[derive(Debug, Clone, Copy, ValueEnum)]
75enum LnInvoiceGeneration {
76 LdkLightningCli,
77}
78
79#[derive(Subcommand, Clone)]
80enum Command {
81 #[command(about = "Keep many websocket connections to a federation for a duration of time")]
82 TestConnect {
83 #[arg(long, help = "Federation invite code")]
84 invite_code: String,
85 #[arg(
86 long,
87 default_value = "60",
88 help = "How much time to keep the connections open, in seconds"
89 )]
90 duration_secs: u64,
91 #[arg(
92 long,
93 default_value = "120",
94 help = "Timeout for connection attempt and for each request, in secnods"
95 )]
96 timeout_secs: u64,
97 #[arg(
98 long,
99 help = "If given, will limit the number of endpoints (guardians) to connect to"
100 )]
101 limit_endpoints: Option<usize>,
102 },
103 #[command(about = "Try to download the client config many times.")]
104 TestDownload {
105 #[arg(long, help = "Federation invite code")]
106 invite_code: String,
107 },
108 #[command(
109 about = "Run a load test where many users in parallel will try to reissue notes and pay invoices through the gateway"
110 )]
111 LoadTest(LoadTestArgs),
112 #[command()]
117 LnCircularLoadTest(LnCircularLoadTestArgs),
118}
119
120#[derive(Args, Clone)]
121struct LoadTestArgs {
122 #[arg(
123 long,
124 help = "Federation invite code. If none given, we assume the client already has a config downloaded in DB"
125 )]
126 invite_code: Option<InviteCode>,
127
128 #[arg(
129 long,
130 help = "Notes for the test. If none and no funds on archive, will call fedimint-cli spend"
131 )]
132 initial_notes: Option<OOBNotes>,
133
134 #[arg(
135 long,
136 help = "Gateway Id. If none, retrieve one according to --generate-invoice-with"
137 )]
138 gateway_id: Option<String>,
139
140 #[arg(
141 long,
142 help = "The method used to generate invoices to be paid through the gateway. If none and no --invoices-file provided, no gateway/LN tests will be run. Note that you can't generate an invoice using the same lightning node used by the gateway (i.e self payment is forbidden)"
143 )]
144 generate_invoice_with: Option<LnInvoiceGeneration>,
145
146 #[arg(
147 long,
148 default_value = "1",
149 help = "How many invoices will be created for each user. Only applicable if --generate-invoice-with is provided"
150 )]
151 invoices_per_user: u16,
152
153 #[arg(
154 long,
155 default_value = "0",
156 help = "How many seconds to sleep between LN payments"
157 )]
158 ln_payment_sleep_secs: u64,
159
160 #[arg(
161 long,
162 help = "A text file with one invoice per line. If --generate-invoice-with is provided, these will be additional invoices to be paid"
163 )]
164 invoices_file: Option<PathBuf>,
165
166 #[arg(
167 long,
168 help = "How many notes to distribute to each user",
169 default_value = "2"
170 )]
171 notes_per_user: u16,
172
173 #[arg(
174 long,
175 help = "Note denomination to use for the test",
176 default_value = "1024"
177 )]
178 note_denomination: Amount,
179
180 #[arg(
181 long,
182 help = "Invoice amount when generating one",
183 default_value = "1000"
184 )]
185 invoice_amount: Amount,
186}
187
188#[derive(Args, Clone)]
189struct LnCircularLoadTestArgs {
190 #[arg(
191 long,
192 help = "Federation invite code. If none given, we assume the client already has a config downloaded in DB"
193 )]
194 invite_code: Option<InviteCode>,
195
196 #[arg(
197 long,
198 help = "Notes for the test. If none and no funds on archive, will call fedimint-cli spend"
199 )]
200 initial_notes: Option<OOBNotes>,
201
202 #[arg(
203 long,
204 default_value = "60",
205 help = "For how many seconds to run the test"
206 )]
207 test_duration_secs: u64,
208
209 #[arg(
210 long,
211 default_value = "0",
212 help = "How many seconds to sleep between LN payments"
213 )]
214 ln_payment_sleep_secs: u64,
215
216 #[arg(
217 long,
218 help = "How many notes to distribute to each user",
219 default_value = "1"
220 )]
221 notes_per_user: u16,
222
223 #[arg(
224 long,
225 help = "Note denomination to use for the test",
226 default_value = "1024"
227 )]
228 note_denomination: Amount,
229
230 #[arg(
231 long,
232 help = "Invoice amount when generating one",
233 default_value = "1000"
234 )]
235 invoice_amount: Amount,
236
237 #[arg(long)]
238 strategy: LnCircularStrategy,
239}
240
241#[derive(Debug, Clone, Copy, ValueEnum)]
242enum LnCircularStrategy {
243 SelfPayment,
245 TwoGateways,
248 PartnerPingPong,
250}
251
252#[derive(Debug, Clone)]
253pub struct MetricEvent {
254 name: String,
255 duration: Duration,
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize)]
259struct EventMetricSummary {
260 name: String,
261 users: u64,
262 n: u64,
263 avg_ms: u128,
264 median_ms: u128,
265 max_ms: u128,
266 min_ms: u128,
267 timestamp_seconds: u64,
268}
269
270#[derive(Debug, Serialize, Deserialize)]
271struct EventMetricComparison {
272 avg_ms_gain: f64,
273 median_ms_gain: f64,
274 max_ms_gain: f64,
275 min_ms_gain: f64,
276 current: EventMetricSummary,
277 previous: EventMetricSummary,
278}
279
280impl std::fmt::Display for EventMetricComparison {
281 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
282 fn to_percent(gain: f64) -> String {
283 if gain >= 1.0 {
284 format!("+{:.2}%", (gain - 1.0) * 100.0)
285 } else {
286 format!("-{:.2}%", (1.0 - gain) * 100.0)
287 }
288 }
289 f.write_str(&format!(
290 "avg: {}, median: {}, max: {}, min: {}",
291 to_percent(self.avg_ms_gain),
292 to_percent(self.median_ms_gain),
293 to_percent(self.max_ms_gain),
294 to_percent(self.min_ms_gain),
295 ))
296 }
297}
298
299#[tokio::main]
300async fn main() -> anyhow::Result<()> {
301 fedimint_logging::TracingSetup::default().init()?;
302 let opts = Opts::parse();
303 validate_total_notes(&opts)?;
304 let (event_sender, event_receiver) = tokio::sync::mpsc::unbounded_channel();
305 let summary_handle = spawn("handle metrics summary", {
306 let opts = opts.clone();
307 async { handle_metrics_summary(opts, event_receiver).await }
308 });
309 let futures = match opts.command.clone() {
310 Command::TestConnect {
311 invite_code,
312 duration_secs,
313 timeout_secs,
314 limit_endpoints,
315 } => {
316 let connectors = ConnectorRegistry::build_from_client_env().bind().await;
317 let invite_code = InviteCode::from_str(&invite_code).context("invalid invite code")?;
318 test_connect_raw_client(
319 &connectors,
320 invite_code,
321 opts.users,
322 Duration::from_secs(duration_secs),
323 Duration::from_secs(timeout_secs),
324 limit_endpoints,
325 event_sender.clone(),
326 )
327 .await?
328 }
329 Command::TestDownload { invite_code } => {
330 let connectors = ConnectorRegistry::build_from_client_env().bind().await;
331 let invite_code = InviteCode::from_str(&invite_code).context("invalid invite code")?;
332 test_download_config(&connectors, &invite_code, opts.users, &event_sender.clone())
333 }
334 Command::LoadTest(args) => {
335 let invite_code = invite_code_or_fallback(args.invite_code).await;
336
337 let gateway_id = if let Some(gateway_id) = args.gateway_id {
338 Some(gateway_id)
339 } else if let Some(generate_invoice_with) = args.generate_invoice_with {
340 Some(get_gateway_id(generate_invoice_with).await?)
341 } else {
342 None
343 };
344 let invoices = if let Some(invoices_file) = args.invoices_file {
345 let display_path = invoices_file.display();
346 let invoices_file = tokio::fs::File::open(&invoices_file)
347 .await
348 .with_context(|| format!("Failed to open {display_path}"))?;
349 let mut lines = tokio::io::BufReader::new(invoices_file).lines();
350 let mut invoices = vec![];
351 while let Some(line) = lines.next_line().await? {
352 let invoice = Bolt11Invoice::from_str(&line)?;
353 invoices.push(invoice);
354 }
355 invoices
356 } else {
357 vec![]
358 };
359 if args.generate_invoice_with.is_none() && invoices.is_empty() {
360 info!(
361 "No --generate-invoice-with given no invoices on --invoices-file, not LN/gateway tests will be run"
362 );
363 }
364 run_load_test(
365 opts.archive_dir,
366 opts.users,
367 invite_code,
368 args.initial_notes,
369 args.generate_invoice_with,
370 args.invoices_per_user,
371 Duration::from_secs(args.ln_payment_sleep_secs),
372 invoices,
373 gateway_id,
374 args.notes_per_user,
375 args.note_denomination,
376 args.invoice_amount,
377 event_sender.clone(),
378 )
379 .await?
380 }
381 Command::LnCircularLoadTest(args) => {
382 let invite_code = invite_code_or_fallback(args.invite_code).await;
383 run_ln_circular_load_test(
384 opts.archive_dir,
385 opts.users,
386 invite_code,
387 args.initial_notes,
388 Duration::from_secs(args.test_duration_secs),
389 Duration::from_secs(args.ln_payment_sleep_secs),
390 args.notes_per_user,
391 args.note_denomination,
392 args.invoice_amount,
393 args.strategy,
394 event_sender.clone(),
395 )
396 .await?
397 }
398 };
399
400 let result = futures::future::join_all(futures).await;
401 drop(event_sender);
402 summary_handle.await??;
403 let len_failures = result.iter().filter(|r| r.is_err()).count();
404 eprintln!("{} results, {len_failures} failures", result.len());
405 for r in result {
406 if let Err(e) = r {
407 warn!("Task failed: {:?}", e);
408 }
409 }
410 if len_failures > 0 {
411 bail!("Finished with failures");
412 }
413 info!("Finished successfully");
414 Ok(())
415}
416
417fn validate_total_notes(opts: &Opts) -> anyhow::Result<()> {
418 match &opts.command {
419 Command::LoadTest(args) => {
420 checked_total(opts.users, args.notes_per_user)?;
421 }
422 Command::LnCircularLoadTest(args) => {
423 checked_total(opts.users, args.notes_per_user)?;
424 }
425 Command::TestConnect { .. } | Command::TestDownload { .. } => {}
426 }
427 Ok(())
428}
429
430fn checked_total(users: u16, notes_per_user: u16) -> anyhow::Result<u16> {
431 users.checked_mul(notes_per_user).ok_or_else(|| {
432 anyhow!(
433 "--users {users} multiplied by --notes-per-user {notes_per_user} exceeds the maximum supported combined note count of {}",
434 u16::MAX
435 )
436 })
437}
438
439async fn invite_code_or_fallback(invite_code: Option<InviteCode>) -> Option<InviteCode> {
440 if let Some(invite_code) = invite_code {
441 Some(invite_code)
442 } else {
443 match get_invite_code_cli(0.into()).await {
445 Ok(invite_code) => Some(invite_code),
446 Err(e) => {
447 info!(
448 "No invite code provided and failed to get one with '{e}' error, will try to proceed without one..."
449 );
450 None
451 }
452 }
453 }
454}
455
456#[allow(clippy::too_many_arguments)]
457async fn run_load_test(
458 archive_dir: Option<PathBuf>,
459 users: u16,
460 invite_code: Option<InviteCode>,
461 initial_notes: Option<OOBNotes>,
462 generate_invoice_with: Option<LnInvoiceGeneration>,
463 generated_invoices_per_user: u16,
464 ln_payment_sleep: Duration,
465 invoices_from_file: Vec<Bolt11Invoice>,
466 gateway_id: Option<String>,
467 notes_per_user: u16,
468 note_denomination: Amount,
469 invoice_amount: Amount,
470 event_sender: mpsc::UnboundedSender<MetricEvent>,
471) -> anyhow::Result<Vec<BoxFuture<'static, anyhow::Result<()>>>> {
472 let minimum_notes = checked_total(users, notes_per_user)?;
473 let db_path = get_db_path(&archive_dir);
474 let (coordinator, invite_code) = get_coordinator_client(&db_path, &invite_code).await?;
475 let minimum_amount_required = note_denomination * u64::from(minimum_notes);
476
477 reissue_initial_notes(initial_notes, &coordinator, &event_sender).await?;
478 get_required_notes(&coordinator, minimum_amount_required, &event_sender).await?;
479 print_coordinator_notes(&coordinator).await?;
480 info!(
481 "Reminting {minimum_notes} notes of denomination {note_denomination} for {users} users, {notes_per_user} notes per user (this may take a while if the number of users/notes is high)"
482 );
483 remint_denomination(&coordinator, note_denomination, minimum_notes).await?;
484 print_coordinator_notes(&coordinator).await?;
485
486 let users_clients = get_users_clients(users, db_path, invite_code).await?;
487
488 let mut users_notes =
489 get_notes_for_users(users, notes_per_user, coordinator, note_denomination).await?;
490 let mut users_invoices = HashMap::new();
491 let mut user = 0;
492 for invoice in invoices_from_file {
494 users_invoices
495 .entry(user)
496 .or_insert_with(Vec::new)
497 .push(invoice);
498 user = (user + 1) % users;
499 }
500
501 info!("Starting user tasks");
502 let futures = users_clients
503 .into_iter()
504 .enumerate()
505 .map(|(u, client)| {
506 let u = u as u16;
507 let oob_notes = users_notes.remove(&u).unwrap();
508 let invoices = users_invoices.remove(&u).unwrap_or_default();
509 let event_sender = event_sender.clone();
510 let f: BoxFuture<_> = Box::pin(do_load_test_user_task(
511 format!("User {u}:"),
512 client,
513 oob_notes,
514 generated_invoices_per_user,
515 ln_payment_sleep,
516 invoice_amount,
517 invoices,
518 generate_invoice_with,
519 event_sender,
520 gateway_id.clone(),
521 ));
522 f
523 })
524 .collect::<Vec<_>>();
525
526 Ok(futures)
527}
528
529async fn get_notes_for_users(
530 users: u16,
531 notes_per_user: u16,
532 coordinator: ClientHandleArc,
533 note_denomination: Amount,
534) -> anyhow::Result<HashMap<u16, Vec<OOBNotes>>> {
535 let mut users_notes = HashMap::new();
536 for u in 0..users {
537 users_notes.insert(u, Vec::with_capacity(notes_per_user.into()));
538 for _ in 0..notes_per_user {
539 let (_, oob_notes) = do_spend_notes(&coordinator, note_denomination).await?;
540 let user_amount = oob_notes.total_amount();
541 info!("Giving {user_amount} to user {u}");
542 users_notes.get_mut(&u).unwrap().push(oob_notes);
543 }
544 }
545 Ok(users_notes)
546}
547
548async fn get_users_clients(
549 n: u16,
550 db_path: Option<PathBuf>,
551 invite_code: Option<InviteCode>,
552) -> anyhow::Result<Vec<ClientHandleArc>> {
553 let mut users_clients = Vec::with_capacity(n.into());
554 for u in 0..n {
555 let (client, _) = get_user_client(u, &db_path, &invite_code).await?;
556 users_clients.push(client);
557 }
558 Ok(users_clients)
559}
560
561async fn get_user_client(
562 user_index: u16,
563 db_path: &Option<PathBuf>,
564 invite_code: &Option<InviteCode>,
565) -> anyhow::Result<(ClientHandleArc, Option<InviteCode>)> {
566 let user_db = db_path
567 .as_ref()
568 .map(|db_path| db_path.join(format!("user_{user_index}.db")));
569 let user_invite_code = if user_db.as_ref().is_some_and(|db| db.exists()) {
570 None
571 } else {
572 invite_code.clone()
573 };
574 let (client, invite_code) = build_client(user_invite_code, user_db.as_ref()).await?;
575 if let Ok(ln_client) = client.get_first_module::<LightningClientModule>() {
577 let _ = ln_client.update_gateway_cache().await;
578 }
579 Ok((client, invite_code))
580}
581
582async fn print_coordinator_notes(coordinator: &ClientHandleArc) -> anyhow::Result<()> {
583 info!("Note summary:");
584 let summary = get_note_summary(coordinator).await?;
585 for (k, v) in summary.iter() {
586 info!("{k}: {v}");
587 }
588 Ok(())
589}
590
591async fn get_required_notes(
592 coordinator: &ClientHandleArc,
593 minimum_amount_required: Amount,
594 event_sender: &mpsc::UnboundedSender<MetricEvent>,
595) -> anyhow::Result<()> {
596 let current_balance = coordinator.get_balance_for_btc().await?;
597
598 if current_balance < minimum_amount_required {
599 let diff = minimum_amount_required.saturating_sub(current_balance);
600 info!(
601 "Current balance {current_balance} on coordinator not enough, trying to get {diff} more through fedimint-cli"
602 );
603 match try_get_notes_cli(&diff, 5).await {
604 Ok(notes) => {
605 info!("Got {} more notes, reissuing them", notes.total_amount());
606 reissue_notes(coordinator, notes, event_sender).await?;
607 }
608 Err(e) => {
609 info!("Unable to get more notes: '{e}', will try to proceed without them");
610 }
611 }
612 } else {
613 info!(
614 "Current balance of {current_balance} already covers the minimum required of {minimum_amount_required}"
615 );
616 }
617 Ok(())
618}
619
620async fn reissue_initial_notes(
621 initial_notes: Option<OOBNotes>,
622 coordinator: &ClientHandleArc,
623 event_sender: &mpsc::UnboundedSender<MetricEvent>,
624) -> anyhow::Result<()> {
625 if let Some(notes) = initial_notes {
626 let amount = notes.total_amount();
627 info!("Reissuing initial notes, got {amount}");
628 reissue_notes(coordinator, notes, event_sender).await?;
629 }
630 Ok(())
631}
632
633async fn get_coordinator_client(
634 db_path: &Option<PathBuf>,
635 invite_code: &Option<InviteCode>,
636) -> anyhow::Result<(ClientHandleArc, Option<InviteCode>)> {
637 let (client, invite_code) = if let Some(db_path) = db_path {
638 let coordinator_db = db_path.join("coordinator.db");
639 if coordinator_db.exists() {
640 build_client(invite_code.clone(), Some(&coordinator_db)).await?
641 } else {
642 tokio::fs::create_dir_all(db_path).await?;
643 build_client(
644 Some(invite_code.clone().context(
645 "Running on this archive dir for the first time, an invite code is required",
646 )?),
647 Some(&coordinator_db),
648 )
649 .await?
650 }
651 } else {
652 build_client(
653 Some(
654 invite_code
655 .clone()
656 .context("No archive dir given, an invite code is strictly required")?,
657 ),
658 None,
659 )
660 .await?
661 };
662 Ok((client, invite_code))
663}
664
665fn get_db_path(archive_dir: &Option<PathBuf>) -> Option<PathBuf> {
666 archive_dir.as_ref().map(|p| p.join("db"))
667}
668
669async fn get_lightning_gateway(
670 client: &ClientHandleArc,
671 gateway_id: Option<String>,
672) -> Option<LightningGateway> {
673 let gateway_id = parse_gateway_id(gateway_id.or(None)?.as_str()).expect("Invalid gateway id");
674 let ln_module = client
675 .get_first_module::<LightningClientModule>()
676 .expect("Must have ln client module");
677 ln_module.select_gateway(&gateway_id).await
678}
679
680#[allow(clippy::too_many_arguments)]
681async fn do_load_test_user_task(
682 prefix: String,
683 client: ClientHandleArc,
684 oob_notes: Vec<OOBNotes>,
685 generated_invoices_per_user: u16,
686 ln_payment_sleep: Duration,
687 invoice_amount: Amount,
688 additional_invoices: Vec<Bolt11Invoice>,
689 generate_invoice_with: Option<LnInvoiceGeneration>,
690 event_sender: mpsc::UnboundedSender<MetricEvent>,
691 gateway_id: Option<String>,
692) -> anyhow::Result<()> {
693 let ln_gateway = get_lightning_gateway(&client, gateway_id).await;
694 for oob_note in oob_notes {
695 let amount = oob_note.total_amount();
696 reissue_notes(&client, oob_note, &event_sender)
697 .await
698 .map_err(|e| anyhow!("while reissuing initial {amount}: {e}"))?;
699 }
700 let mut generated_invoices_per_user_iterator = (0..generated_invoices_per_user).peekable();
701 while let Some(_) = generated_invoices_per_user_iterator.next() {
702 let total_amount = get_note_summary(&client).await?.total_amount();
703 if invoice_amount > total_amount {
704 warn!("Can't pay invoice, not enough funds: {invoice_amount} > {total_amount}");
705 } else {
706 match generate_invoice_with {
707 Some(LnInvoiceGeneration::LdkLightningCli) => {
708 let invoice = ldk_create_invoice(invoice_amount).await?;
709 gateway_pay_invoice(
710 &prefix,
711 "LND",
712 &client,
713 invoice.clone(),
714 &event_sender,
715 ln_gateway.clone(),
716 )
717 .await?;
718 ldk_wait_invoice_payment(&invoice).await?;
719 }
720 None if additional_invoices.is_empty() => {
721 debug!(
722 "No method given to generate an invoice and no invoices on file, will not test the gateway"
723 );
724 break;
725 }
726 None => {
727 break;
728 }
729 }
730 if generated_invoices_per_user_iterator.peek().is_some() {
731 fedimint_core::task::sleep(ln_payment_sleep).await;
733 }
734 }
735 }
736 let mut additional_invoices = additional_invoices.into_iter().peekable();
737 while let Some(invoice) = additional_invoices.next() {
738 let total_amount = get_note_summary(&client).await?.total_amount();
739 let invoice_amount =
740 Amount::from_msats(invoice.amount_milli_satoshis().unwrap_or_default());
741 if invoice_amount > total_amount {
742 warn!("Can't pay invoice, not enough funds: {invoice_amount} > {total_amount}");
743 } else if invoice_amount == Amount::ZERO {
744 warn!("Can't pay invoice {invoice}, amount is zero");
745 } else {
746 gateway_pay_invoice(
747 &prefix,
748 "unknown",
749 &client,
750 invoice,
751 &event_sender,
752 ln_gateway.clone(),
753 )
754 .await?;
755 if additional_invoices.peek().is_some() {
756 fedimint_core::task::sleep(ln_payment_sleep).await;
758 }
759 }
760 }
761 Ok(())
762}
763
764#[allow(clippy::too_many_arguments)]
765async fn run_ln_circular_load_test(
766 archive_dir: Option<PathBuf>,
767 users: u16,
768 invite_code: Option<InviteCode>,
769 initial_notes: Option<OOBNotes>,
770 test_duration: Duration,
771 ln_payment_sleep: Duration,
772 notes_per_user: u16,
773 note_denomination: Amount,
774 invoice_amount: Amount,
775 strategy: LnCircularStrategy,
776 event_sender: mpsc::UnboundedSender<MetricEvent>,
777) -> anyhow::Result<Vec<BoxFuture<'static, anyhow::Result<()>>>> {
778 let minimum_notes = checked_total(users, notes_per_user)?;
779 let db_path = get_db_path(&archive_dir);
780 let (coordinator, invite_code) = get_coordinator_client(&db_path, &invite_code).await?;
781 let minimum_amount_required = note_denomination * u64::from(minimum_notes);
782
783 reissue_initial_notes(initial_notes, &coordinator, &event_sender).await?;
784 get_required_notes(&coordinator, minimum_amount_required, &event_sender).await?;
785
786 info!(
787 "Reminting {minimum_notes} notes of denomination {note_denomination} for {users} users, {notes_per_user} notes per user (this may take a while if the number of users/notes is high)"
788 );
789 remint_denomination(&coordinator, note_denomination, minimum_notes).await?;
790
791 print_coordinator_notes(&coordinator).await?;
792
793 let users_clients = get_users_clients(users, db_path, invite_code.clone()).await?;
794
795 let mut users_notes =
796 get_notes_for_users(users, notes_per_user, coordinator, note_denomination).await?;
797
798 info!("Starting user tasks");
799 let futures = users_clients
800 .into_iter()
801 .enumerate()
802 .map(|(u, client)| {
803 let u = u as u16;
804 let oob_notes = users_notes.remove(&u).unwrap();
805 let event_sender = event_sender.clone();
806 let f: BoxFuture<_> = Box::pin(do_ln_circular_test_user_task(
807 format!("User {u}:"),
808 client,
809 invite_code.clone(),
810 oob_notes,
811 test_duration,
812 ln_payment_sleep,
813 invoice_amount,
814 strategy,
815 event_sender,
816 ));
817 f
818 })
819 .collect::<Vec<_>>();
820
821 Ok(futures)
822}
823
824#[allow(clippy::too_many_arguments)]
825async fn do_ln_circular_test_user_task(
826 prefix: String,
827 client: ClientHandleArc,
828 invite_code: Option<InviteCode>,
829 oob_notes: Vec<OOBNotes>,
830 test_duration: Duration,
831 ln_payment_sleep: Duration,
832 invoice_amount: Amount,
833 strategy: LnCircularStrategy,
834 event_sender: mpsc::UnboundedSender<MetricEvent>,
835) -> anyhow::Result<()> {
836 for oob_note in oob_notes {
837 let amount = oob_note.total_amount();
838 reissue_notes(&client, oob_note, &event_sender)
839 .await
840 .map_err(|e| anyhow!("while reissuing initial {amount}: {e}"))?;
841 }
842 let initial_time = fedimint_core::time::now();
843 let still_ontime = || async {
844 fedimint_core::time::now()
845 .duration_since(initial_time)
846 .expect("time to work")
847 <= test_duration
848 };
849 let sleep_a_bit = || async {
850 if still_ontime().await {
851 fedimint_core::task::sleep(ln_payment_sleep).await;
852 }
853 };
854 match strategy {
855 LnCircularStrategy::TwoGateways => {
856 let invoice_generation = LnInvoiceGeneration::LdkLightningCli;
857 while still_ontime().await {
858 let gateway_id = get_gateway_id(invoice_generation).await?;
859 let ln_gateway = get_lightning_gateway(&client, Some(gateway_id)).await;
860 run_two_gateways_strategy(
861 &prefix,
862 &invoice_generation,
863 &invoice_amount,
864 &event_sender,
865 &client,
866 ln_gateway,
867 )
868 .await?;
869 sleep_a_bit().await;
870 }
871 }
872 LnCircularStrategy::SelfPayment => {
873 while still_ontime().await {
874 do_self_payment(&prefix, &client, invoice_amount, &event_sender).await?;
875 sleep_a_bit().await;
876 }
877 }
878 LnCircularStrategy::PartnerPingPong => {
879 let (partner, _) = build_client(invite_code, None).await?;
880 while still_ontime().await {
881 do_partner_ping_pong(&prefix, &client, &partner, invoice_amount, &event_sender)
882 .await?;
883 sleep_a_bit().await;
884 }
885 }
886 }
887 Ok(())
888}
889
890const GATEWAY_CREATE_INVOICE: &str = "gateway_create_invoice";
891
892async fn run_two_gateways_strategy(
893 prefix: &str,
894 invoice_generation: &LnInvoiceGeneration,
895 invoice_amount: &Amount,
896 event_sender: &mpsc::UnboundedSender<MetricEvent>,
897 client: &ClientHandleArc,
898 ln_gateway: Option<LightningGateway>,
899) -> Result<(), anyhow::Error> {
900 let create_invoice_time = fedimint_core::time::now();
901 match *invoice_generation {
902 LnInvoiceGeneration::LdkLightningCli => {
903 let invoice = ldk_create_invoice(*invoice_amount).await?;
904 let elapsed = create_invoice_time.elapsed()?;
905 info!("Created invoice using LDK in {elapsed:?}");
906 event_sender.send(MetricEvent {
907 name: GATEWAY_CREATE_INVOICE.into(),
908 duration: elapsed,
909 })?;
910 gateway_pay_invoice(
911 prefix,
912 "LND",
913 client,
914 invoice.clone(),
915 event_sender,
916 ln_gateway.clone(),
917 )
918 .await?;
919 ldk_wait_invoice_payment(&invoice).await?;
920 let (operation_id, invoice) =
921 client_create_invoice(client, *invoice_amount, event_sender, ln_gateway).await?;
922 let pay_invoice_time = fedimint_core::time::now();
923 ldk_pay_invoice(invoice).await?;
924 wait_invoice_payment(
925 prefix,
926 "LND",
927 client,
928 operation_id,
929 event_sender,
930 pay_invoice_time,
931 )
932 .await?;
933 }
934 }
935 Ok(())
936}
937
938async fn do_self_payment(
939 prefix: &str,
940 client: &ClientHandleArc,
941 invoice_amount: Amount,
942 event_sender: &mpsc::UnboundedSender<MetricEvent>,
943) -> anyhow::Result<()> {
944 let (operation_id, invoice) =
945 client_create_invoice(client, invoice_amount, event_sender, None).await?;
946 let pay_invoice_time = fedimint_core::time::now();
947 let lightning_module = client.get_first_module::<LightningClientModule>()?;
948 lightning_module
950 .pay_bolt11_invoice(None, invoice, ())
951 .await?;
952 wait_invoice_payment(
953 prefix,
954 "gateway",
955 client,
956 operation_id,
957 event_sender,
958 pay_invoice_time,
959 )
960 .await?;
961 Ok(())
962}
963
964async fn do_partner_ping_pong(
965 prefix: &str,
966 client: &ClientHandleArc,
967 partner: &ClientHandleArc,
968 invoice_amount: Amount,
969 event_sender: &mpsc::UnboundedSender<MetricEvent>,
970) -> anyhow::Result<()> {
971 let (operation_id, invoice) =
973 client_create_invoice(partner, invoice_amount, event_sender, None).await?;
974 let pay_invoice_time = fedimint_core::time::now();
975 let lightning_module = client.get_first_module::<LightningClientModule>()?;
976 lightning_module
979 .pay_bolt11_invoice(None, invoice, ())
980 .await?;
981 wait_invoice_payment(
982 prefix,
983 "gateway",
984 partner,
985 operation_id,
986 event_sender,
987 pay_invoice_time,
988 )
989 .await?;
990 let (operation_id, invoice) =
992 client_create_invoice(client, invoice_amount, event_sender, None).await?;
993 let pay_invoice_time = fedimint_core::time::now();
994 let partner_lightning_module = partner.get_first_module::<LightningClientModule>()?;
995 partner_lightning_module
998 .pay_bolt11_invoice(None, invoice, ())
999 .await?;
1000 wait_invoice_payment(
1001 prefix,
1002 "gateway",
1003 client,
1004 operation_id,
1005 event_sender,
1006 pay_invoice_time,
1007 )
1008 .await?;
1009 Ok(())
1010}
1011
1012async fn wait_invoice_payment(
1013 prefix: &str,
1014 gateway_name: &str,
1015 client: &ClientHandleArc,
1016 operation_id: fedimint_core::core::OperationId,
1017 event_sender: &mpsc::UnboundedSender<MetricEvent>,
1018 pay_invoice_time: std::time::SystemTime,
1019) -> anyhow::Result<()> {
1020 let elapsed = pay_invoice_time.elapsed()?;
1021 info!("{prefix} Invoice payment receive started using {gateway_name} in {elapsed:?}");
1022 event_sender.send(MetricEvent {
1023 name: format!("gateway_{gateway_name}_payment_received_started"),
1024 duration: elapsed,
1025 })?;
1026 let lightning_module = client.get_first_module::<LightningClientModule>()?;
1027 let mut updates = lightning_module
1028 .subscribe_ln_receive(operation_id)
1029 .await?
1030 .into_stream();
1031 wait_invoice_payment_updates(
1032 prefix,
1033 gateway_name,
1034 operation_id,
1035 event_sender,
1036 pay_invoice_time,
1037 &mut updates,
1038 )
1039 .await
1040}
1041
1042async fn wait_invoice_payment_updates(
1043 prefix: &str,
1044 gateway_name: &str,
1045 operation_id: fedimint_core::core::OperationId,
1046 event_sender: &mpsc::UnboundedSender<MetricEvent>,
1047 pay_invoice_time: std::time::SystemTime,
1048 updates: &mut (impl futures::Stream<Item = LnReceiveState> + Unpin),
1049) -> anyhow::Result<()> {
1050 while let Some(update) = updates.next().await {
1051 debug!(%prefix, ?update, "Invoice payment update");
1052 match update {
1053 LnReceiveState::Claimed => {
1054 let elapsed: Duration = pay_invoice_time.elapsed()?;
1055 info!("{prefix} Invoice payment received on {gateway_name} in {elapsed:?}");
1056 event_sender.send(MetricEvent {
1057 name: "gateway_payment_received_success".into(),
1058 duration: elapsed,
1059 })?;
1060 event_sender.send(MetricEvent {
1061 name: format!("gateway_{gateway_name}_payment_received_success"),
1062 duration: elapsed,
1063 })?;
1064 return Ok(());
1065 }
1066 LnReceiveState::Canceled { reason } => {
1067 let elapsed: Duration = pay_invoice_time.elapsed()?;
1068 info!(
1069 "{prefix} Invoice payment receive was canceled on {gateway_name}: {reason} in {elapsed:?}"
1070 );
1071 event_sender.send(MetricEvent {
1072 name: "gateway_payment_received_canceled".into(),
1073 duration: elapsed,
1074 })?;
1075 bail!(
1076 "Invoice payment receive for operation {operation_id:?} was canceled on \
1077 {gateway_name}: {reason}"
1078 );
1079 }
1080 _ => {}
1081 }
1082 }
1083 bail!(
1084 "Invoice payment receive stream ended before operation {operation_id:?} was claimed on \
1085 {gateway_name}"
1086 )
1087}
1088
1089async fn client_create_invoice(
1090 client: &ClientHandleArc,
1091 invoice_amount: Amount,
1092 event_sender: &mpsc::UnboundedSender<MetricEvent>,
1093 ln_gateway: Option<LightningGateway>,
1094) -> anyhow::Result<(fedimint_core::core::OperationId, Bolt11Invoice)> {
1095 let create_invoice_time = fedimint_core::time::now();
1096 let lightning_module = client.get_first_module::<LightningClientModule>()?;
1097 let desc = Description::new("test".to_string())?;
1098 let (operation_id, invoice, _) = lightning_module
1099 .create_bolt11_invoice(
1100 invoice_amount,
1101 Bolt11InvoiceDescription::Direct(desc),
1102 None,
1103 (),
1104 ln_gateway,
1105 )
1106 .await?;
1107 let elapsed = create_invoice_time.elapsed()?;
1108 info!("Created invoice using gateway in {elapsed:?}");
1109 event_sender.send(MetricEvent {
1110 name: GATEWAY_CREATE_INVOICE.into(),
1111 duration: elapsed,
1112 })?;
1113 Ok((operation_id, invoice))
1114}
1115
1116fn test_download_config(
1117 endpoints: &ConnectorRegistry,
1118 invite_code: &InviteCode,
1119 users: u16,
1120 event_sender: &mpsc::UnboundedSender<MetricEvent>,
1121) -> Vec<BoxFuture<'static, anyhow::Result<()>>> {
1122 (0..users)
1123 .map(move |_| {
1124 let invite_code = invite_code.clone();
1125 let event_sender = event_sender.clone();
1126 let endpoints = endpoints.clone();
1127 let f: BoxFuture<_> = Box::pin(async move {
1128 let m = fedimint_core::time::now();
1129 let _ = download_from_invite_code(&endpoints, &invite_code).await?;
1130 event_sender.send(MetricEvent {
1131 name: "download_client_config".into(),
1132 duration: m.elapsed()?,
1133 })?;
1134 Ok(())
1135 });
1136 f
1137 })
1138 .collect()
1139}
1140
1141async fn test_connect_raw_client(
1142 endpoints: &ConnectorRegistry,
1143 invite_code: InviteCode,
1144 users: u16,
1145 duration: Duration,
1146 timeout: Duration,
1147 limit_endpoints: Option<usize>,
1148 event_sender: mpsc::UnboundedSender<MetricEvent>,
1149) -> anyhow::Result<Vec<BoxFuture<'static, anyhow::Result<()>>>> {
1150 use jsonrpsee_core::client::ClientT;
1151 use jsonrpsee_ws_client::WsClientBuilder;
1152
1153 let (mut cfg, _) = download_from_invite_code(endpoints, &invite_code).await?;
1154
1155 if let Some(limit_endpoints) = limit_endpoints {
1156 cfg.global.api_endpoints = cfg
1157 .global
1158 .api_endpoints
1159 .into_iter()
1160 .take(limit_endpoints)
1161 .collect();
1162 info!("Limiting endpoints to {:?}", cfg.global.api_endpoints);
1163 }
1164
1165 info!("Connecting to {users} clients");
1166 let clients = (0..users)
1167 .flat_map(|_| {
1168 cfg.global.api_endpoints.values().map(|url| async {
1169 let ws_client = WsClientBuilder::default()
1170 .request_timeout(timeout)
1171 .connection_timeout(timeout)
1172 .build(url_to_string_with_default_port(&url.url))
1173 .await?;
1174 Ok::<_, anyhow::Error>(ws_client)
1175 })
1176 })
1177 .collect::<Vec<_>>();
1178 let clients = futures::future::try_join_all(clients).await?;
1179 info!("Keeping {users} clients connected for {duration:?}");
1180 Ok(clients
1181 .into_iter()
1182 .map(|client| {
1183 let event_sender = event_sender.clone();
1184 let f: BoxFuture<_> = Box::pin(async move {
1185 let initial_time = fedimint_core::time::now();
1186 while initial_time.elapsed()? < duration {
1187 let m = fedimint_core::time::now();
1188 let _epoch: u64 = client
1189 .request::<_, _>(SESSION_COUNT_ENDPOINT, vec![ApiRequestErased::default()])
1190 .await?;
1191 event_sender.send(MetricEvent {
1192 name: SESSION_COUNT_ENDPOINT.into(),
1193 duration: m.elapsed()?,
1194 })?;
1195 fedimint_core::task::sleep(Duration::from_secs(1)).await;
1196 }
1197 Ok(())
1198 });
1199 f
1200 })
1201 .collect())
1202}
1203
1204fn url_to_string_with_default_port(url: &SafeUrl) -> String {
1205 format!(
1206 "{}://{}:{}{}",
1207 url.scheme(),
1208 url.host().expect("Asserted on construction"),
1209 url.port_or_known_default()
1210 .expect("Asserted on construction"),
1211 url.path()
1212 )
1213}
1214
1215async fn handle_metrics_summary(
1216 opts: Opts,
1217 mut event_receiver: mpsc::UnboundedReceiver<MetricEvent>,
1218) -> anyhow::Result<()> {
1219 let timestamp_seconds = fedimint_core::time::duration_since_epoch().as_secs();
1220 let mut metrics_json_output_files = vec![];
1221 let mut previous_metrics = vec![];
1222 let mut comparison_output = None;
1223 if let Some(archive_dir) = opts.archive_dir {
1224 let mut archive_metrics = archive_dir.join("metrics");
1225 archive_metrics.push(opts.users.to_string());
1226 tokio::fs::create_dir_all(&archive_metrics).await?;
1227 let mut archive_comparisons = archive_dir.join("comparisons");
1228 archive_comparisons.push(opts.users.to_string());
1229 tokio::fs::create_dir_all(&archive_comparisons).await?;
1230
1231 let latest_metrics_file = std::fs::read_dir(&archive_metrics)?
1232 .map(|entry| {
1233 let entry = entry.unwrap();
1234 let metadata = entry.metadata().unwrap();
1235 let created = metadata
1236 .created()
1237 .unwrap_or_else(|_| metadata.modified().unwrap());
1238 (entry, created)
1239 })
1240 .max_by_key(|(_entry, created)| created.to_owned())
1241 .map(|(entry, _)| entry.path());
1242 if let Some(latest_metrics_file) = latest_metrics_file {
1243 let display_path = latest_metrics_file.display();
1244 let latest_metrics_file = tokio::fs::File::open(&latest_metrics_file)
1245 .await
1246 .with_context(|| format!("Failed to open {display_path}"))?;
1247 let mut lines = tokio::io::BufReader::new(latest_metrics_file).lines();
1248 while let Some(line) = lines.next_line().await? {
1249 match serde_json::from_str::<EventMetricSummary>(&line) {
1250 Ok(metric) => {
1251 previous_metrics.push(metric);
1252 }
1253 Err(e) => {
1254 warn!("Failed to parse previous metric: {e:?}");
1255 }
1256 }
1257 }
1258 }
1259 let new_metric_output = archive_metrics.join(format!("{timestamp_seconds}.json",));
1260 let new_metric_output = BufWriter::new(
1261 OpenOptions::new()
1262 .write(true)
1263 .create(true)
1264 .truncate(true)
1265 .open(new_metric_output)
1266 .await?,
1267 );
1268 metrics_json_output_files.push(new_metric_output);
1269 if !previous_metrics.is_empty() {
1270 let new_comparison_output =
1271 archive_comparisons.join(format!("{timestamp_seconds}.json",));
1272 comparison_output = Some(BufWriter::new(
1273 OpenOptions::new()
1274 .write(true)
1275 .create(true)
1276 .truncate(true)
1277 .open(new_comparison_output)
1278 .await?,
1279 ));
1280 }
1281 }
1282 if let Some(metrics_json_output) = opts.metrics_json_output {
1283 metrics_json_output_files.push(BufWriter::new(
1284 tokio::fs::OpenOptions::new()
1285 .write(true)
1286 .create(true)
1287 .truncate(true)
1288 .open(metrics_json_output)
1289 .await?,
1290 ));
1291 }
1292 let mut results = BTreeMap::new();
1293 while let Some(event) = event_receiver.recv().await {
1294 let entry = results.entry(event.name).or_insert_with(Vec::new);
1295 entry.push(event.duration);
1296 }
1297 let mut previous_metrics = previous_metrics
1298 .into_iter()
1299 .map(|metric| (metric.name.clone(), metric))
1300 .collect::<HashMap<_, _>>();
1301 for (k, mut v) in results {
1302 v.sort();
1303 let n = v.len();
1304 let max = v.iter().last().unwrap();
1305 let min = v.first().unwrap();
1306 let median = v[n / 2];
1307 let sum: Duration = v.iter().sum();
1308 let avg = sum / n as u32;
1309 let metric_summary = EventMetricSummary {
1310 name: k.clone(),
1311 users: u64::from(opts.users),
1312 n: n as u64,
1313 avg_ms: avg.as_millis(),
1314 median_ms: median.as_millis(),
1315 max_ms: max.as_millis(),
1316 min_ms: min.as_millis(),
1317 timestamp_seconds,
1318 };
1319 let comparison = if let Some(previous_metric) = previous_metrics.remove(&k) {
1320 if previous_metric.n == metric_summary.n {
1321 fn calculate_gain(current: u128, previous: u128) -> f64 {
1322 current as f64 / previous as f64
1323 }
1324 let comparison = EventMetricComparison {
1325 avg_ms_gain: calculate_gain(metric_summary.avg_ms, previous_metric.avg_ms),
1326 median_ms_gain: calculate_gain(
1327 metric_summary.median_ms,
1328 previous_metric.median_ms,
1329 ),
1330 max_ms_gain: calculate_gain(metric_summary.max_ms, previous_metric.max_ms),
1331 min_ms_gain: calculate_gain(metric_summary.min_ms, previous_metric.min_ms),
1332 current: metric_summary.clone(),
1333 previous: previous_metric,
1334 };
1335 if let Some(comparison_output) = &mut comparison_output {
1336 let comparison_json =
1337 serde_json::to_string(&comparison).expect("to be serializable");
1338 comparison_output
1339 .write_all(format!("{comparison_json}\n").as_bytes())
1340 .await
1341 .expect("to write on file");
1342 }
1343 Some(comparison)
1344 } else {
1345 info!(
1346 "Skipping comparison for {k} because previous metric has different n ({} vs {})",
1347 previous_metric.n, metric_summary.n
1348 );
1349 None
1350 }
1351 } else {
1352 None
1353 };
1354 if let Some(comparison) = comparison {
1355 println!(
1356 "{n} {k}: avg {avg:?}, median {median:?}, max {max:?}, min {min:?} (compared to previous: {comparison})"
1357 );
1358 } else {
1359 println!("{n} {k}: avg {avg:?}, median {median:?}, max {max:?}, min {min:?}");
1360 }
1361 let metric_summary_json =
1362 serde_json::to_string(&metric_summary).expect("to be serializable");
1363 for metrics_json_output_file in &mut metrics_json_output_files {
1364 metrics_json_output_file
1365 .write_all(format!("{metric_summary_json}\n").as_bytes())
1366 .await
1367 .expect("to write on file");
1368 }
1369 }
1370 for mut output in metrics_json_output_files {
1371 output.flush().await?;
1372 }
1373 if let Some(mut output) = comparison_output {
1374 output.flush().await?;
1375 }
1376 Ok(())
1377}
1378
1379async fn get_gateway_id(generate_invoice_with: LnInvoiceGeneration) -> anyhow::Result<String> {
1380 let gateway_json = match generate_invoice_with {
1381 LnInvoiceGeneration::LdkLightningCli => {
1382 cmd!(GatewayLndCli, "info").out_json().await
1384 }
1385 }?;
1386
1387 let gatewayd_version = devimint::util::Gatewayd::version_or_default().await;
1388 let gateway_id = if gatewayd_version < *VERSION_0_10_0_ALPHA {
1389 gateway_json["gateway_id"]
1390 .as_str()
1391 .context("gateway_id must be a string")?
1392 .to_owned()
1393 } else {
1394 gateway_json["registrations"]["http"][1]
1395 .as_str()
1396 .context("gateway id must be a string")?
1397 .to_owned()
1398 };
1399
1400 Ok(gateway_id)
1401}