Skip to main content

fedimintd/
lib.rs

1#![deny(clippy::pedantic)]
2#![allow(clippy::cast_possible_wrap)]
3#![allow(clippy::missing_errors_doc)]
4#![allow(clippy::missing_panics_doc)]
5#![allow(clippy::must_use_candidate)]
6#![allow(clippy::return_self_not_must_use)]
7#![allow(clippy::large_futures)]
8
9mod metrics;
10
11use std::convert::Infallible;
12use std::env;
13use std::fmt::Write as _;
14use std::net::SocketAddr;
15use std::path::PathBuf;
16use std::time::Duration;
17
18use anyhow::Context as _;
19use bitcoin::Network;
20use clap::builder::BoolishValueParser;
21use clap::{ArgGroup, CommandFactory, FromArgMatches, Parser};
22use fedimint_core::db::Database;
23use fedimint_core::envs::{
24    FM_IROH_DNS_ENV, FM_IROH_RELAY_ENV, FM_USE_UNKNOWN_MODULE_ENV, is_env_var_set,
25    is_running_in_test_env,
26};
27use fedimint_core::module::registry::ModuleRegistry;
28use fedimint_core::module::{ApiAuth, CORE_CONSENSUS_VERSION};
29use fedimint_core::rustls::install_crypto_provider;
30use fedimint_core::task::TaskGroup;
31use fedimint_core::timing;
32use fedimint_core::util::{
33    FmtCompact as _, FmtCompactAnyhow as _, SafeUrl, handle_version_hash_command,
34};
35use fedimint_ln_server::LightningInit;
36use fedimint_logging::{LOG_CORE, LOG_SERVER, TracingSetup};
37use fedimint_meta_server::MetaInit;
38use fedimint_mint_server::MintInit;
39use fedimint_rocksdb::RocksDb;
40use fedimint_server::IrohNextApiSettings;
41use fedimint_server::config::ConfigGenSettings;
42use fedimint_server::config::io::{DB_FILE, PLAINTEXT_PASSWORD};
43use fedimint_server::core::ServerModuleInitRegistry;
44use fedimint_server::net::api::ApiSecrets;
45use fedimint_server_bitcoin_rpc::BitcoindClientWithFallback;
46use fedimint_server_bitcoin_rpc::bitcoind::BitcoindClient;
47use fedimint_server_bitcoin_rpc::esplora::EsploraClient;
48use fedimint_server_bitcoin_rpc::tracked::ServerBitcoinRpcTracked;
49use fedimint_server_core::ServerModuleInitRegistryExt;
50use fedimint_server_core::bitcoin_rpc::IServerBitcoinRpc;
51use fedimint_unknown_server::UnknownInit;
52use fedimint_wallet_server::WalletInit;
53use fedimintd_envs::{
54    FM_API_URL_ENV, FM_BIND_API_ENV, FM_BIND_API_NEXT_ENV, FM_BIND_METRICS_ENV, FM_BIND_P2P_ENV,
55    FM_BIND_TOKIO_CONSOLE_ENV, FM_BIND_UI_ENV, FM_BITCOIN_NETWORK_ENV, FM_BITCOIND_PASSWORD_ENV,
56    FM_BITCOIND_URL_ENV, FM_BITCOIND_URL_PASSWORD_FILE_ENV, FM_BITCOIND_USERNAME_ENV,
57    FM_DATA_DIR_ENV, FM_DB_CHECKPOINT_RETENTION_ENV, FM_DISABLE_META_MODULE_ENV,
58    FM_ENABLE_IROH_ENV, FM_ESPLORA_URL_ENV, FM_FORCE_API_SECRETS_ENV,
59    FM_IROH_API_MAX_CONNECTIONS_ENV, FM_IROH_API_MAX_REQUESTS_PER_CONNECTION_ENV,
60    FM_IROH_NEXT_ENABLE_ENV, FM_IROH_P2P_RELAY_ENV, FM_P2P_MAX_CONNECTION_AGE_SECS_ENV,
61    FM_P2P_URL_ENV, FM_PASSWORD_API_ENV, FM_PASSWORD_UI_ENV, FM_SESSION_TIMEOUT_SECS_ENV,
62};
63use futures::FutureExt as _;
64#[cfg(all(
65    not(feature = "jemalloc"),
66    not(any(target_env = "msvc", target_os = "ios", target_os = "android"))
67))]
68use tracing::warn;
69use tracing::{debug, error, info};
70
71use crate::metrics::APP_START_TS;
72
73/// Time we will wait before forcefully shutting down tasks
74const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
75
76#[derive(Parser)]
77#[command(version)]
78#[command(
79    group(
80        ArgGroup::new("bitcoind_password_auth")
81           .args(["bitcoind_password", "bitcoind_url_password_file"])
82           .multiple(false)
83    ),
84    group(
85        ArgGroup::new("bitcoind_auth")
86            .args(["bitcoind_url"])
87            .requires("bitcoind_password_auth")
88            .requires_all(["bitcoind_username", "bitcoind_url"])
89    ),
90    group(
91        ArgGroup::new("bitcoin_rpc")
92            .required(true)
93            .multiple(true)
94            .args(["bitcoind_url", "esplora_url"])
95    )
96)]
97struct ServerOpts {
98    /// Path to folder containing federation config files
99    #[arg(long = "data-dir", env = FM_DATA_DIR_ENV)]
100    data_dir: PathBuf,
101
102    /// Password gating the guardian admin UI on bind-ui. Optional: falls
103    /// back to reading password.private from data-dir, and if neither is
104    /// present the UI is served without a login form. Only safe when
105    /// bind-ui stays on a trusted interface (the default 127.0.0.1).
106    #[arg(long, env = FM_PASSWORD_UI_ENV)]
107    password_ui: Option<String>,
108
109    /// Password gating admin RPCs on the public API (WebSocket on bind-api
110    /// and iroh). Optional and never falls back to disk: when unset, admin
111    /// RPCs return 401 unconditionally, since the public API is always
112    /// network-reachable and must be enabled explicitly.
113    #[arg(long, env = FM_PASSWORD_API_ENV)]
114    password_api: Option<String>,
115
116    /// The bitcoin network of the federation
117    #[arg(long, env = FM_BITCOIN_NETWORK_ENV, default_value = "regtest")]
118    bitcoin_network: Network,
119
120    /// The username to use when connecting to bitcoind
121    #[arg(long, env = FM_BITCOIND_USERNAME_ENV)]
122    bitcoind_username: Option<String>,
123
124    /// The password to use when connecting to bitcoind
125    #[arg(long, env = FM_BITCOIND_PASSWORD_ENV)]
126    bitcoind_password: Option<String>,
127
128    /// Bitcoind RPC URL, e.g. <http://127.0.0.1:8332>
129    /// This should not include authentication parameters, they should be
130    /// included in `FM_BITCOIND_USERNAME` and `FM_BITCOIND_PASSWORD`
131    #[arg(long, env = FM_BITCOIND_URL_ENV)]
132    bitcoind_url: Option<SafeUrl>,
133
134    /// If set, the password part of `--bitcoind-url` will be set/replaced with
135    /// the content of this file.
136    ///
137    /// This is useful for setups that provide secret material via ramdisk
138    /// e.g. SOPS, age, etc.
139    ///
140    /// Note this is not meant to handle bitcoind's cookie file.
141    #[arg(long, env = FM_BITCOIND_URL_PASSWORD_FILE_ENV)]
142    bitcoind_url_password_file: Option<PathBuf>,
143
144    /// Esplora HTTP base URL, e.g. <https://mempool.space/api>
145    #[arg(long, env = FM_ESPLORA_URL_ENV)]
146    esplora_url: Option<SafeUrl>,
147
148    /// Address we bind to for p2p consensus communication
149    ///
150    /// Should be `0.0.0.0:8173` most of the time, as p2p connectivity is public
151    /// and direct, and the port should be open it in the firewall.
152    #[arg(long, env = FM_BIND_P2P_ENV, default_value = "0.0.0.0:8173")]
153    bind_p2p: SocketAddr,
154
155    /// Address we bind to for the API
156    ///
157    /// Should be `0.0.0.0:8174` most of the time, as api connectivity is public
158    /// and direct, and the port should be open it in the firewall.
159    #[arg(long, env = FM_BIND_API_ENV, default_value = "0.0.0.0:8174")]
160    bind_api: SocketAddr,
161
162    /// Address we bind to for exposing the Web UI
163    ///
164    /// Built-in web UI is exposed as an HTTP port, and typically should
165    /// have TLS terminated by Nginx/Traefik/etc. and forwarded to the locally
166    /// bind port.
167    #[arg(long, env = FM_BIND_UI_ENV, default_value = "127.0.0.1:8175")]
168    bind_ui: SocketAddr,
169
170    /// Our external address for communicating with our peers
171    ///
172    /// `fedimint://<fqdn>:8173` for TCP/TLS p2p connectivity (legacy/standard).
173    ///
174    /// Ignored when Iroh stack is used. (newer/experimental)
175    #[arg(long, env = FM_P2P_URL_ENV)]
176    p2p_url: Option<SafeUrl>,
177
178    /// Our API address for clients to connect to us
179    ///
180    /// Typically `wss://<fqdn>/ws/` for TCP/TLS connectivity (legacy/standard)
181    ///
182    /// Ignored when Iroh stack is used. (newer/experimental)
183    #[arg(long, env = FM_API_URL_ENV)]
184    api_url: Option<SafeUrl>,
185
186    /// Whether to use the Iroh networking stack instead of the legacy
187    /// TCP/TLS/websocket stack. Defaults to Iroh in production and to the
188    /// legacy stack in test environments (`cargo test` / `devimint`). Only
189    /// consulted at DKG / config generation time; existing federations keep the
190    /// stack baked into their config. Pass `--enable-iroh true/false` (or
191    /// `FM_ENABLE_IROH=true/false`) to override.
192    #[arg(long, env = FM_ENABLE_IROH_ENV, value_parser = BoolishValueParser::new())]
193    enable_iroh: Option<bool>,
194
195    /// Optional URL of the Iroh DNS server
196    #[arg(long, env = FM_IROH_DNS_ENV, requires = "enable_iroh")]
197    iroh_dns: Option<SafeUrl>,
198
199    /// Optional URLs of the Iroh relays to use for registering
200    #[arg(long, env = FM_IROH_RELAY_ENV, requires = "enable_iroh", value_delimiter = ',')]
201    iroh_relays: Vec<SafeUrl>,
202
203    /// Optional Iroh 1.0 relay URLs to use for guardian P2P
204    #[arg(long, env = FM_IROH_P2P_RELAY_ENV, value_delimiter = ',')]
205    iroh_p2p_relays: Vec<SafeUrl>,
206
207    /// Number of checkpoints from the current session to retain on disk
208    #[arg(long, env = FM_DB_CHECKPOINT_RETENTION_ENV, default_value = "1")]
209    db_checkpoint_retention: u64,
210
211    /// Exit the process if a consensus session is not completed within this
212    /// many seconds, relying on the process supervisor to restart fedimintd.
213    /// Restarting guardians has been observed to resolve stuck sessions.
214    #[arg(long, env = FM_SESSION_TIMEOUT_SECS_ENV, default_value = "3600")]
215    session_timeout_secs: u64,
216
217    /// Drop a p2p connection to a peer once it exceeds this age in seconds,
218    /// relying on the reconnection logic to re-establish it. Unset by
219    /// default, in which case connections are never dropped due to their age.
220    #[arg(long, env = FM_P2P_MAX_CONNECTION_AGE_SECS_ENV)]
221    p2p_max_connection_age_secs: Option<u64>,
222
223    /// Enable tokio console logging
224    #[arg(long, env = FM_BIND_TOKIO_CONSOLE_ENV)]
225    bind_tokio_console: Option<SocketAddr>,
226
227    /// Enable jaeger for tokio console logging
228    #[arg(long, default_value = "false")]
229    with_jaeger: bool,
230
231    /// Enable prometheus metrics
232    #[arg(long, env = FM_BIND_METRICS_ENV, default_value = "127.0.0.1:8176")]
233    bind_metrics: Option<SocketAddr>,
234
235    /// Comma separated list of API secrets.
236    ///
237    /// Setting it will enforce API authentication and make the Federation
238    /// "private".
239    ///
240    /// The first secret in the list is the "active" one that the peer will use
241    /// itself to connect to other peers. Any further one is accepted by
242    /// this peer, e.g. for the purposes of smooth rotation of secret
243    /// between users.
244    ///
245    /// Note that the value provided here will override any other settings
246    /// that the user might want to set via UI at runtime, etc.
247    /// In the future, managing secrets might be possible via Admin UI
248    /// and defaults will be provided via `FM_DEFAULT_API_SECRETS`.
249    #[arg(long, env = FM_FORCE_API_SECRETS_ENV, default_value = "")]
250    force_api_secrets: ApiSecrets,
251
252    /// Maximum number of concurrent Iroh API connections
253    #[arg(long = "iroh-api-max-connections", env = FM_IROH_API_MAX_CONNECTIONS_ENV, default_value = "1000")]
254    iroh_api_max_connections: usize,
255
256    /// Maximum number of parallel requests per Iroh API connection
257    #[arg(long = "iroh-api-max-requests-per-connection", env = FM_IROH_API_MAX_REQUESTS_PER_CONNECTION_ENV, default_value = "50")]
258    iroh_api_max_requests_per_connection: usize,
259
260    /// Enable the transitional Iroh 1.0 API endpoint alongside Iroh 0.35.
261    ///
262    /// For a federation configured with the legacy Iroh API, this is a runtime
263    /// setting independent of the DKG-only `--enable-iroh` option. It is
264    /// enabled by default. Once the endpoint is advertised, it must remain
265    /// enabled.
266    #[arg(
267        long,
268        env = FM_IROH_NEXT_ENABLE_ENV,
269        default_value_t = true,
270        action = clap::ArgAction::Set,
271        num_args = 0..=1,
272        default_missing_value = "true",
273    )]
274    enable_iroh_next: bool,
275
276    /// Bind address for the transitional Iroh 1.0 API endpoint
277    #[arg(long, env = FM_BIND_API_NEXT_ENV)]
278    bind_api_next: Option<SocketAddr>,
279}
280
281impl ServerOpts {
282    pub async fn get_bitcoind_url_and_password(&self) -> anyhow::Result<(SafeUrl, String)> {
283        let url = self
284            .bitcoind_url
285            .clone()
286            .ok_or_else(|| anyhow::anyhow!("No bitcoind url set"))?;
287        if let Some(password_file) = self.bitcoind_url_password_file.as_ref() {
288            let password = tokio::fs::read_to_string(password_file)
289                .await
290                .context("Failed to read the password")?
291                .trim()
292                .to_owned();
293            Ok((url, password))
294        } else {
295            let password = self
296                .bitcoind_password
297                .clone()
298                .expect("FM_BITCOIND_URL is set but FM_BITCOIND_PASSWORD is not");
299            Ok((url, password))
300        }
301    }
302}
303
304/// Block the thread and run a Fedimintd server
305///
306/// # Arguments
307///
308/// * `module_init_registry` - The registry of available modules.
309///
310/// * `code_version_hash` - The git hash of the code that the `fedimintd` binary
311///   is being built from. This is used mostly for information purposes
312///   (`fedimintd version-hash`). See `fedimint-build` crate for easy way to
313///   obtain it.
314///
315/// * `code_version_vendor_suffix` - An optional suffix that will be appended to
316///   the internal fedimint release version, to distinguish binaries built by
317///   different vendors, usually with a different set of modules. The suffix is
318///   informational in setup/DKG: compatibility and consensus config generation
319///   use the normalized `x.y.z` release version.
320#[allow(clippy::too_many_lines)]
321pub async fn run(
322    module_init_registry: ServerModuleInitRegistry,
323    code_version_hash: &str,
324    code_version_vendor_suffix: Option<&str>,
325) -> anyhow::Result<Infallible> {
326    assert_eq!(
327        env!("FEDIMINT_BUILD_CODE_VERSION").len(),
328        code_version_hash.len(),
329        "version_hash must have an expected length"
330    );
331
332    handle_version_hash_command(code_version_hash);
333
334    let fedimint_version = env!("CARGO_PKG_VERSION");
335
336    APP_START_TS
337        .with_label_values(&[fedimint_version, code_version_hash])
338        .set(fedimint_core::time::duration_since_epoch().as_secs() as i64);
339
340    let server_opts = {
341        // Collect env vars from all registered modules and append them to the
342        // long-help text so operators can discover them via `fedimintd --help`.
343        let mut module_env_help = String::from("\nModule environment variables:\n");
344        for (_kind, module_init) in module_init_registry.iter() {
345            for doc in module_init.get_documented_env_vars() {
346                let _ = writeln!(module_env_help, "  {:40}  {}", doc.name, doc.description);
347            }
348        }
349        let matches = ServerOpts::command()
350            .after_long_help(module_env_help)
351            .get_matches();
352        ServerOpts::from_arg_matches(&matches)
353            .expect("clap arg matches must be valid after parsing")
354    };
355
356    let mut tracing_builder = TracingSetup::default();
357
358    tracing_builder
359        .tokio_console_bind(server_opts.bind_tokio_console)
360        .with_jaeger(server_opts.with_jaeger);
361
362    tracing_builder.init().unwrap();
363
364    info!("Starting fedimintd (version: {fedimint_version} version_hash: {code_version_hash})");
365
366    #[cfg(all(
367        not(feature = "jemalloc"),
368        not(any(target_env = "msvc", target_os = "ios", target_os = "android"))
369    ))]
370    warn!(
371        target: LOG_SERVER,
372        "fedimintd was built without the `jemalloc` feature. rocksdb is prone to memory \
373         fragmentation with the default allocator; consider rebuilding with `--features jemalloc`."
374    );
375
376    debug!(
377        target: LOG_SERVER,
378        core_consensus = %CORE_CONSENSUS_VERSION,
379        "Supported core consensus version",
380    );
381
382    let code_version_str = code_version_vendor_suffix.map_or_else(
383        || fedimint_version.to_string(),
384        |suffix| format!("{fedimint_version}+{suffix}"),
385    );
386
387    let timing_total_runtime = timing::TimeReporter::new("total-runtime").info();
388
389    let root_task_group = TaskGroup::new();
390
391    if let Some(bind_metrics) = server_opts.bind_metrics.as_ref() {
392        info!(
393            target: LOG_SERVER,
394            url = %format!("http://{}/metrics", bind_metrics),
395            "Initializing metrics server",
396        );
397        fedimint_metrics::spawn_api_server(*bind_metrics, root_task_group.clone()).await?;
398    }
399
400    let enable_iroh = server_opts.enable_iroh.unwrap_or(!is_running_in_test_env());
401    let iroh_next_api_settings = if server_opts.enable_iroh_next {
402        Some(IrohNextApiSettings::new(server_opts.bind_api_next))
403    } else {
404        None
405    };
406
407    let settings = ConfigGenSettings {
408        p2p_bind: server_opts.bind_p2p,
409        api_bind: server_opts.bind_api,
410        ui_bind: server_opts.bind_ui,
411        p2p_url: server_opts.p2p_url.clone(),
412        api_url: server_opts.api_url.clone(),
413        enable_iroh,
414        iroh_dns: server_opts.iroh_dns.clone(),
415        iroh_relays: server_opts.iroh_relays.clone(),
416        network: server_opts.bitcoin_network,
417        available_modules: module_init_registry.kinds(),
418        default_modules: module_init_registry.default_modules(),
419    };
420
421    let db = Database::new(
422        RocksDb::build(server_opts.data_dir.join(DB_FILE))
423            .open()
424            .await
425            .unwrap(),
426        ModuleRegistry::default(),
427    );
428
429    let dyn_server_bitcoin_rpc = match (
430        server_opts.bitcoind_url.as_ref(),
431        server_opts.esplora_url.as_ref(),
432    ) {
433        (Some(_), None) => {
434            let bitcoind_username = server_opts
435                .bitcoind_username
436                .clone()
437                .expect("FM_BITCOIND_URL is set but FM_BITCOIND_USERNAME is not");
438            let (bitcoind_url, bitcoind_password) = server_opts
439                .get_bitcoind_url_and_password()
440                .await
441                .expect("Failed to get bitcoind url");
442            BitcoindClient::new(bitcoind_username, bitcoind_password, &bitcoind_url)
443                .unwrap()
444                .into_dyn()
445        }
446        (None, Some(url)) => EsploraClient::new(url).unwrap().into_dyn(),
447        (Some(_), Some(esplora_url)) => {
448            let bitcoind_username = server_opts
449                .bitcoind_username
450                .clone()
451                .expect("FM_BITCOIND_URL is set but FM_BITCOIND_USERNAME is not");
452            let (bitcoind_url, bitcoind_password) = server_opts
453                .get_bitcoind_url_and_password()
454                .await
455                .expect("Failed to get bitcoind url");
456            BitcoindClientWithFallback::new(
457                bitcoind_username,
458                bitcoind_password,
459                &bitcoind_url,
460                esplora_url,
461            )
462            .unwrap()
463            .into_dyn()
464        }
465        _ => unreachable!("ArgGroup already enforced XOR relation"),
466    };
467    let dyn_server_bitcoin_rpc =
468        ServerBitcoinRpcTracked::new(dyn_server_bitcoin_rpc, "server").into_dyn();
469
470    root_task_group.install_kill_handler();
471
472    install_crypto_provider().await;
473
474    // The UI password falls back to the legacy password.private file on disk
475    // if its env var is not set, since the UI defaults to a loopback bind.
476    // The API password never falls back: the public admin API is always
477    // network-reachable, so it must be enabled explicitly via its env var or
478    // else admin RPCs return 401. Absence of a password for a given plane opts
479    // that plane into passwordless mode (UI) or disabled admin RPCs (API).
480    let password_file = std::fs::read_to_string(server_opts.data_dir.join(PLAINTEXT_PASSWORD))
481        .ok()
482        .map(|s| s.trim().to_owned());
483
484    let auth_ui = server_opts.password_ui.or(password_file).map(ApiAuth::new);
485    let auth_api = server_opts.password_api.map(ApiAuth::new);
486
487    let task_group = root_task_group.clone();
488    let code_version_hash = code_version_hash.to_string();
489    root_task_group.spawn_cancellable("main", async move {
490        fedimint_server::run_with_iroh_p2p_relays_and_next_api(
491            server_opts.data_dir,
492            auth_ui,
493            auth_api,
494            server_opts.force_api_secrets,
495            settings,
496            db,
497            code_version_str,
498            code_version_hash,
499            module_init_registry,
500            task_group,
501            dyn_server_bitcoin_rpc,
502            Box::new(fedimint_server_ui::setup::router),
503            Box::new(fedimint_server_ui::dashboard::router),
504            server_opts.db_checkpoint_retention,
505            Duration::from_secs(server_opts.session_timeout_secs),
506            server_opts
507                .p2p_max_connection_age_secs
508                .map(Duration::from_secs),
509            fedimint_server::ConnectionLimits::new(
510                server_opts.iroh_api_max_connections,
511                server_opts.iroh_api_max_requests_per_connection,
512            ),
513            server_opts.iroh_p2p_relays,
514            iroh_next_api_settings,
515        )
516        .await
517        .unwrap_or_else(|err| panic!("Main task returned error: {}", err.fmt_compact_anyhow()));
518    });
519
520    let shutdown_future = root_task_group
521        .make_handle()
522        .make_shutdown_rx()
523        .then(|()| async {
524            info!(target: LOG_CORE, "Shutdown called");
525        });
526
527    shutdown_future.await;
528
529    debug!(target: LOG_CORE, "Terminating main task");
530
531    if let Err(err) = root_task_group.join_all(Some(SHUTDOWN_TIMEOUT)).await {
532        error!(target: LOG_CORE, err = %err.fmt_compact(), "Error while shutting down task group");
533    }
534
535    debug!(target: LOG_CORE, "Shutdown complete");
536
537    fedimint_logging::shutdown();
538
539    drop(timing_total_runtime);
540
541    std::process::exit(-1);
542}
543
544pub fn default_modules() -> ServerModuleInitRegistry {
545    let mut server_gens = ServerModuleInitRegistry::new();
546
547    server_gens.attach(MintInit);
548    server_gens.attach(fedimint_mintv2_server::MintInit);
549
550    server_gens.attach(WalletInit);
551    server_gens.attach(fedimint_walletv2_server::WalletInit);
552
553    server_gens.attach(LightningInit);
554    server_gens.attach(fedimint_lnv2_server::LightningInit);
555
556    if !is_env_var_set(FM_DISABLE_META_MODULE_ENV) {
557        server_gens.attach(MetaInit);
558    }
559
560    if is_env_var_set(FM_USE_UNKNOWN_MODULE_ENV) {
561        server_gens.attach(UnknownInit);
562    }
563
564    server_gens
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570
571    fn server_opts_args() -> Vec<&'static str> {
572        vec![
573            "fedimintd",
574            "--data-dir",
575            "/tmp/fedimintd-test",
576            "--bitcoind-url",
577            "http://127.0.0.1:18443",
578            "--bitcoind-username",
579            "user",
580            "--bitcoind-password",
581            "pass",
582        ]
583    }
584
585    fn parse_server_opts() -> ServerOpts {
586        ServerOpts::try_parse_from(server_opts_args()).expect("server opts should parse")
587    }
588
589    fn parse_server_opts_with_enable_iroh_env(value: &str) -> ServerOpts {
590        let previous = std::env::var_os(FM_ENABLE_IROH_ENV);
591        // This test does not spawn threads while mutating the process
592        // environment.
593        unsafe {
594            std::env::set_var(FM_ENABLE_IROH_ENV, value);
595        }
596
597        let opts = parse_server_opts();
598
599        // This test does not spawn threads while mutating the process
600        // environment.
601        unsafe {
602            if let Some(previous) = previous {
603                std::env::set_var(FM_ENABLE_IROH_ENV, previous);
604            } else {
605                std::env::remove_var(FM_ENABLE_IROH_ENV);
606            }
607        }
608
609        opts
610    }
611
612    #[test]
613    fn enable_iroh_env_accepts_numeric_booleans() {
614        assert_eq!(
615            parse_server_opts_with_enable_iroh_env("1").enable_iroh,
616            Some(true)
617        );
618        assert_eq!(
619            parse_server_opts_with_enable_iroh_env("0").enable_iroh,
620            Some(false)
621        );
622    }
623
624    #[test]
625    fn iroh_next_api_defaults_to_enabled_and_accepts_false() {
626        let command = ServerOpts::command();
627        let enable_iroh_next = command
628            .get_arguments()
629            .find(|arg| arg.get_id() == "enable_iroh_next")
630            .expect("enable-iroh-next argument exists");
631        assert_eq!(enable_iroh_next.get_default_values(), ["true"]);
632
633        let mut args = server_opts_args();
634        args.push("--enable-iroh-next=false");
635        let opts = ServerOpts::try_parse_from(args).expect("explicit false should parse");
636        assert!(!opts.enable_iroh_next);
637    }
638
639    #[test]
640    fn p2p_relay_does_not_require_enable_iroh_or_change_api_relays() {
641        let opts = ServerOpts::try_parse_from([
642            "fedimintd",
643            "--data-dir",
644            "/tmp/fedimintd-test",
645            "--bitcoind-url",
646            "http://127.0.0.1:18443",
647            "--bitcoind-username",
648            "user",
649            "--bitcoind-password",
650            "pass",
651            "--iroh-p2p-relays",
652            "https://relay.example.com/",
653        ])
654        .expect("P2P relay should parse independently");
655
656        assert!(opts.iroh_relays.is_empty());
657        assert_eq!(opts.iroh_p2p_relays.len(), 1);
658    }
659}