Skip to main content

fedimint_testing/
fixtures.rs

1use std::env;
2use std::net::SocketAddr;
3use std::str::FromStr;
4use std::sync::Arc;
5use std::time::Duration;
6
7use fedimint_bip39::Bip39RootSecretStrategy;
8use fedimint_bitcoind::{BitcoindTracked, DynBitcoindRpc, IBitcoindRpc, create_esplora_rpc};
9use fedimint_client::Client;
10use fedimint_client::module_init::{
11    ClientModuleInitRegistry, DynClientModuleInit, IClientModuleInit,
12};
13use fedimint_client::secret::RootSecretStrategy;
14use fedimint_core::core::{ModuleInstanceId, ModuleKind};
15use fedimint_core::db::Database;
16use fedimint_core::db::mem_impl::MemDatabase;
17use fedimint_core::envs::BitcoinRpcConfig;
18use fedimint_core::task::{MaybeSend, MaybeSync};
19use fedimint_core::util::SafeUrl;
20use fedimint_gateway_common::{
21    ChainSource, LND_DEFAULT_PAYMENT_TIMEOUT_SECS, LND_DEFAULT_TIME_PREF, LightningInfo,
22    LightningMode,
23};
24use fedimint_gateway_server::Gateway;
25use fedimint_gateway_server::client::GatewayClientBuilder;
26use fedimint_gateway_server::config::DatabaseBackend;
27use fedimint_lightning::{ILnRpcClient, LightningContext};
28use fedimint_logging::TracingSetup;
29use fedimint_server::core::{DynServerModuleInit, IServerModuleInit, ServerModuleInitRegistry};
30use fedimint_server_bitcoin_rpc::bitcoind::BitcoindClient;
31use fedimint_server_bitcoin_rpc::esplora::EsploraClient;
32use fedimint_server_core::bitcoin_rpc::{DynServerBitcoinRpc, IServerBitcoinRpc};
33use fedimint_testing_core::test_dir;
34use rand::rngs::OsRng;
35
36use crate::btc::BitcoinTest;
37use crate::btc::mock::FakeBitcoinTest;
38use crate::btc::real::RealBitcoinTest;
39use crate::envs::{
40    FM_PORT_ESPLORA_ENV, FM_TEST_BACKEND_BITCOIN_RPC_KIND_ENV, FM_TEST_BACKEND_BITCOIN_RPC_URL_ENV,
41    FM_TEST_BITCOIND_RPC_ENV, FM_TEST_USE_REAL_DAEMONS_ENV,
42};
43use crate::federation::{FederationTest, FederationTestBuilder};
44use crate::ln::FakeLightningTest;
45
46/// A default timeout for things happening in tests
47pub const TIMEOUT: Duration = Duration::from_secs(10);
48
49pub const DEFAULT_GATEWAY_PASSWORD: &str = "thereisnosecondbest";
50
51/// A tool for easily writing fedimint integration tests
52pub struct Fixtures {
53    clients: ClientModuleInitRegistry,
54    servers: ServerModuleInitRegistry,
55    bitcoin_rpc: BitcoinRpcConfig,
56    bitcoin: Arc<dyn BitcoinTest>,
57    fake_bitcoin_rpc: Option<DynBitcoindRpc>,
58    server_bitcoin_rpc: DynServerBitcoinRpc,
59    primary_module_kind: ModuleKind,
60}
61
62impl Fixtures {
63    pub fn new_primary(
64        client: impl IClientModuleInit + 'static,
65        server: impl IServerModuleInit + MaybeSend + MaybeSync + 'static,
66    ) -> Self {
67        // Ensure tracing has been set once
68        let _ = TracingSetup::default().init();
69        let real_testing = Fixtures::is_real_test();
70        let (bitcoin, config, bitcoin_rpc_connection, fake_bitcoin_rpc): (
71            Arc<dyn BitcoinTest>,
72            BitcoinRpcConfig,
73            DynServerBitcoinRpc,
74            Option<DynBitcoindRpc>,
75        ) = if real_testing {
76            // `backend-test.sh` overrides which Bitcoin RPC to use for esplora
77            // backend tests
78            let override_bitcoin_rpc_kind = env::var(FM_TEST_BACKEND_BITCOIN_RPC_KIND_ENV);
79            let override_bitcoin_rpc_url = env::var(FM_TEST_BACKEND_BITCOIN_RPC_URL_ENV);
80
81            let rpc_config = match (override_bitcoin_rpc_kind, override_bitcoin_rpc_url) {
82                (Ok(kind), Ok(url)) => BitcoinRpcConfig {
83                    kind: kind.parse().expect("must provide valid kind"),
84                    url: url.parse().expect("must provide valid url"),
85                },
86                _ => BitcoinRpcConfig::get_defaults_from_env_vars()
87                    .expect("must provide valid default env vars"),
88            };
89
90            let server_bitcoin_rpc = match rpc_config.kind.as_ref() {
91                "bitcoind" => {
92                    // Directly extract the authentication details from the url.
93                    // Since this is just testing we can be careful to not use characters that need
94                    // to be URL-encoded
95                    let bitcoind_username = rpc_config.url.username();
96                    let bitcoind_password = rpc_config
97                        .url
98                        .password()
99                        .expect("bitcoind password was not set");
100                    BitcoindClient::new(
101                        bitcoind_username.to_string(),
102                        bitcoind_password.to_string(),
103                        &rpc_config.url,
104                    )
105                    .unwrap()
106                    .into_dyn()
107                }
108                "esplora" => EsploraClient::new(&rpc_config.url).unwrap().into_dyn(),
109                kind => panic!("Unknown bitcoin rpc kind {kind}"),
110            };
111
112            let bitcoincore_url = env::var(FM_TEST_BITCOIND_RPC_ENV)
113                .expect("Must have bitcoind RPC defined for real tests")
114                .parse()
115                .expect("Invalid bitcoind RPC URL");
116            let bitcoin = RealBitcoinTest::new(&bitcoincore_url, server_bitcoin_rpc.clone());
117
118            (Arc::new(bitcoin), rpc_config, server_bitcoin_rpc, None)
119        } else {
120            let bitcoin = FakeBitcoinTest::new();
121
122            let config = BitcoinRpcConfig {
123                kind: format!("test_btc-{}", rand::random::<u64>()),
124                url: "http://ignored".parse().unwrap(),
125            };
126
127            let dyn_bitcoin_rpc = IBitcoindRpc::into_dyn(bitcoin.clone());
128
129            let server_bitcoin_rpc = IServerBitcoinRpc::into_dyn(bitcoin.clone());
130
131            let bitcoin = Arc::new(bitcoin);
132
133            (
134                bitcoin.clone(),
135                config,
136                server_bitcoin_rpc,
137                Some(dyn_bitcoin_rpc),
138            )
139        };
140
141        Self {
142            clients: ClientModuleInitRegistry::default(),
143            servers: ServerModuleInitRegistry::default(),
144            bitcoin_rpc: config,
145            fake_bitcoin_rpc,
146            bitcoin,
147            server_bitcoin_rpc: bitcoin_rpc_connection,
148            primary_module_kind: IClientModuleInit::module_kind(&client),
149        }
150        .with_module(client, server)
151    }
152
153    pub fn is_real_test() -> bool {
154        env::var(FM_TEST_USE_REAL_DAEMONS_ENV) == Ok("1".to_string())
155    }
156
157    /// Add a module to the fed
158    pub fn with_module(
159        mut self,
160        client: impl IClientModuleInit + 'static,
161        server: impl IServerModuleInit + MaybeSend + MaybeSync + 'static,
162    ) -> Self {
163        self.clients.attach(DynClientModuleInit::from(client));
164        self.servers.attach(DynServerModuleInit::from(server));
165        self
166    }
167
168    pub fn with_server_only_module(
169        mut self,
170        server: impl IServerModuleInit + MaybeSend + MaybeSync + 'static,
171    ) -> Self {
172        self.servers.attach(DynServerModuleInit::from(server));
173        self
174    }
175
176    /// Starts a new federation with 3/4 peers online
177    pub async fn new_fed_degraded(&self) -> FederationTest {
178        self.new_fed_builder(1).build().await
179    }
180
181    /// Starts a new federation with 4/4 peers online
182    pub async fn new_fed_not_degraded(&self) -> FederationTest {
183        self.new_fed_builder(0).build().await
184    }
185
186    /// Creates a new `FederationTestBuilder` that can be used to build up a
187    /// `FederationTest` for module tests.
188    pub fn new_fed_builder(&self, num_offline: u16) -> FederationTestBuilder {
189        FederationTestBuilder::new(
190            self.servers.clone(),
191            self.clients.clone(),
192            self.primary_module_kind.clone(),
193            num_offline,
194            self.server_bitcoin_rpc(),
195        )
196    }
197
198    /// Creates a new Gateway that can be used for module tests.
199    pub async fn new_gateway(&self) -> Gateway {
200        // Use server_gens.iter() to match the alphabetical order used by the server
201        // when assigning module instance IDs (BTreeMap iteration order)
202        let module_kinds: Vec<_> = self
203            .servers
204            .iter()
205            .enumerate()
206            .map(|(id, (kind, _))| (id as ModuleInstanceId, kind.clone()))
207            .collect();
208        let decoders = self
209            .servers
210            .available_decoders(module_kinds.iter().map(|(id, kind)| (*id, kind)))
211            .unwrap();
212        let gateway_db = Database::new(MemDatabase::new(), decoders.clone());
213
214        let mnemonic = Bip39RootSecretStrategy::<12>::random(&mut OsRng);
215        Client::store_encodable_client_secret(&gateway_db, mnemonic.to_entropy())
216            .await
217            .expect("Could not generate root secret for gateway");
218
219        let registry = self
220            .clients
221            .iter()
222            .filter(|(kind, _)| {
223                // Remove LN module because the gateway adds one
224                **kind != ModuleKind::from_static_str("ln")
225            })
226            .filter(|(kind, _)| {
227                // Remove LN NG module because the gateway adds one
228                **kind != ModuleKind::from_static_str("lnv2")
229            })
230            .map(|(_, client)| client.clone())
231            .collect();
232
233        let (path, _config_dir) = test_dir(&format!("gateway-{}", rand::random::<u64>()));
234
235        // Create federation client builder for the gateway
236        let client_builder: GatewayClientBuilder =
237            GatewayClientBuilder::new(path.clone(), registry, DatabaseBackend::RocksDb)
238                .await
239                .expect("Failed to initialize gateway");
240
241        let ln_client: Arc<dyn ILnRpcClient> = Arc::new(FakeLightningTest::new());
242
243        let LightningInfo::Connected {
244            public_key: lightning_public_key,
245            alias: lightning_alias,
246            network: lightning_network,
247            block_height: _,
248            synced_to_chain: _,
249        } = ln_client.parsed_node_info().await
250        else {
251            panic!("Could not connect to Lightning node")
252        };
253        let lightning_context = LightningContext {
254            lnrpc: ln_client.clone(),
255            lightning_public_key,
256            lightning_alias,
257            lightning_network,
258        };
259
260        // Module tests do not use the webserver, so any port is ok
261        let listen: SocketAddr = "127.0.0.1:9000".parse().unwrap();
262        let address: SafeUrl = format!("http://{listen}").parse().unwrap();
263
264        let esplora_server_url = SafeUrl::parse(&format!(
265            "http://127.0.0.1:{}",
266            env::var(FM_PORT_ESPLORA_ENV).unwrap_or(String::from("50002"))
267        ))
268        .expect("Failed to parse default esplora server");
269
270        Gateway::builder(
271            // Fixtures does not use real lightning connection, so just fake the connection
272            // parameters
273            LightningMode::Lnd {
274                lnd_rpc_addr: "FakeRpcAddr".to_string(),
275                lnd_tls_cert: "FakeTlsCert".to_string(),
276                lnd_macaroon: "FakeMacaroon".to_string(),
277                lnd_time_pref: LND_DEFAULT_TIME_PREF,
278                lnd_payment_timeout_secs: LND_DEFAULT_PAYMENT_TIMEOUT_SECS,
279            },
280            client_builder,
281            gateway_db,
282        )
283        .listen(listen)
284        .api_addr(address)
285        .bcrypt_password_hash(
286            bcrypt::HashParts::from_str(
287                &bcrypt::hash(DEFAULT_GATEWAY_PASSWORD, bcrypt::DEFAULT_COST).unwrap(),
288            )
289            .unwrap(),
290        )
291        .network(bitcoin::Network::Regtest)
292        .num_route_hints(0)
293        // Manually set the gateway's state to `Running`. In tests, we don't run the
294        // webserver or intercept HTLCs, so this is necessary for instructing the
295        // gateway that it is connected to the mock Lightning node.
296        .gateway_state(fedimint_gateway_server::GatewayState::Running { lightning_context })
297        .chain_source(ChainSource::Esplora {
298            server_url: esplora_server_url,
299        })
300        .build()
301        .await
302        .expect("Failed to create gateway")
303    }
304
305    /// Get a server bitcoin RPC config
306    pub fn bitcoin_server(&self) -> BitcoinRpcConfig {
307        self.bitcoin_rpc.clone()
308    }
309
310    pub fn client_esplora_rpc(&self) -> DynBitcoindRpc {
311        let rpc = if Fixtures::is_real_test() {
312            create_esplora_rpc(
313                &SafeUrl::parse(&format!(
314                    "http://127.0.0.1:{}/",
315                    env::var(FM_PORT_ESPLORA_ENV).unwrap_or(String::from("50002"))
316                ))
317                .expect("Failed to parse default esplora server"),
318            )
319            .unwrap()
320        } else {
321            self.fake_bitcoin_rpc.clone().unwrap()
322        };
323        BitcoindTracked::new(rpc, "test-fixture").into_dyn()
324    }
325
326    /// Get a test bitcoin fixture
327    pub fn bitcoin(&self) -> Arc<dyn BitcoinTest> {
328        self.bitcoin.clone()
329    }
330
331    pub fn server_bitcoin_rpc(&self) -> DynServerBitcoinRpc {
332        self.server_bitcoin_rpc.clone()
333    }
334}