Skip to main content

fedimint_testing/
federation.rs

1use std::collections::BTreeMap;
2use std::sync::Arc;
3use std::time::Duration;
4
5use fedimint_api_client::api::{DynGlobalApi, FederationApiExt};
6use fedimint_client::module_init::ClientModuleInitRegistry;
7use fedimint_client::{Client, ClientHandleArc, RootSecret};
8use fedimint_client_module::AdminCreds;
9use fedimint_client_module::secret::{PlainRootSecretStrategy, RootSecretStrategy};
10use fedimint_connectors::ConnectorRegistry;
11use fedimint_core::PeerId;
12use fedimint_core::config::{ClientConfig, FederationId};
13use fedimint_core::core::ModuleKind;
14use fedimint_core::db::Database;
15use fedimint_core::db::mem_impl::MemDatabase;
16use fedimint_core::endpoint_constants::SESSION_COUNT_ENDPOINT;
17use fedimint_core::invite_code::InviteCode;
18use fedimint_core::module::{ApiAuth, ApiRequestErased};
19use fedimint_core::net::IP2PConnections;
20use fedimint_core::rustls::install_crypto_provider;
21use fedimint_core::task::{TaskGroup, block_in_place, sleep_in_test};
22use fedimint_gateway_common::ConnectFedPayload;
23use fedimint_gateway_server::{Gateway, IAdminGateway};
24use fedimint_logging::LOG_TEST;
25use fedimint_rocksdb::RocksDb;
26use fedimint_server::config::ServerConfig;
27use fedimint_server::core::ServerModuleInitRegistry;
28use fedimint_server::net::api::ApiSecrets;
29use fedimint_server::net::p2p::{ReconnectP2PConnections, p2p_status_channels};
30use fedimint_server::net::p2p_connector::{IP2PConnector, TlsTcpConnector};
31use fedimint_server::{ConnectionLimits, consensus};
32use fedimint_server_core::bitcoin_rpc::DynServerBitcoinRpc;
33use fedimint_testing_core::config::local_config_gen_params;
34use tracing::info;
35
36/// Test fixture for a running fedimint federation
37#[derive(Clone)]
38pub struct FederationTest {
39    configs: BTreeMap<PeerId, ServerConfig>,
40    server_init: ServerModuleInitRegistry,
41    client_init: ClientModuleInitRegistry,
42    _task: TaskGroup,
43    num_peers: u16,
44    num_offline: u16,
45    connectors: ConnectorRegistry,
46}
47
48impl FederationTest {
49    /// Create two clients, useful for send/receive tests
50    pub async fn two_clients(&self) -> (ClientHandleArc, ClientHandleArc) {
51        (self.new_client().await, self.new_client().await)
52    }
53
54    /// Create a client connected to this fed
55    pub async fn new_client(&self) -> ClientHandleArc {
56        let client_config = self.configs[&PeerId::from(0)]
57            .consensus
58            .to_client_config(&self.server_init)
59            .unwrap();
60
61        self.new_client_with(client_config, MemDatabase::new().into(), None)
62            .await
63    }
64
65    /// Create a client connected to this fed but using RocksDB instead of MemDB
66    pub async fn new_client_rocksdb(&self) -> ClientHandleArc {
67        let client_config = self.configs[&PeerId::from(0)]
68            .consensus
69            .to_client_config(&self.server_init)
70            .unwrap();
71
72        // a dropped TempDir deletes the directory under the open database
73        let db_dir = tempfile::tempdir()
74            .expect("Couldn't create temp dir")
75            .keep();
76
77        self.new_client_with(
78            client_config,
79            RocksDb::build(db_dir)
80                .open()
81                .await
82                .map_err(anyhow::Error::from)
83                .expect("Couldn't open DB")
84                .into(),
85            None,
86        )
87        .await
88    }
89
90    /// Create a new admin api for the given PeerId
91    pub async fn new_admin_api(&self, peer_id: PeerId) -> anyhow::Result<DynGlobalApi> {
92        let config = self.configs.get(&peer_id).expect("peer to have config");
93
94        Ok(DynGlobalApi::new_admin(
95            ConnectorRegistry::build_from_testing_env().bind().await,
96            peer_id,
97            config.consensus.api_endpoints()[&peer_id].url.clone(),
98            None,
99        ))
100    }
101
102    /// Create a new admin client connected to this fed
103    pub async fn new_admin_client(&self, peer_id: PeerId, auth: ApiAuth) -> ClientHandleArc {
104        let client_config = self.configs[&PeerId::from(0)]
105            .consensus
106            .to_client_config(&self.server_init)
107            .unwrap();
108
109        let admin_creds = AdminCreds { peer_id, auth };
110
111        self.new_client_with(client_config, MemDatabase::new().into(), Some(admin_creds))
112            .await
113    }
114
115    pub async fn new_client_with(
116        &self,
117        client_config: ClientConfig,
118        db: Database,
119        admin_creds: Option<AdminCreds>,
120    ) -> ClientHandleArc {
121        info!(target: LOG_TEST, "Setting new client with config");
122        let mut client_builder = Client::builder().await;
123        client_builder.with_module_inits(self.client_init.clone());
124        if let Some(admin_creds) = admin_creds {
125            client_builder.set_admin_creds(admin_creds);
126        }
127        let client_secret = Client::load_or_generate_client_secret(&db).await;
128        client_builder
129            .preview_with_existing_config(self.connectors.clone(), client_config, None)
130            .await
131            .join(
132                db,
133                RootSecret::StandardDoubleDerive(PlainRootSecretStrategy::to_root_secret(
134                    &client_secret,
135                )),
136            )
137            .await
138            .map(Arc::new)
139            .expect("Failed to build client")
140    }
141
142    /// Join a federation with an existing database and root secret
143    pub async fn join_client_with_db(
144        &self,
145        db: Database,
146        root_secret: RootSecret,
147    ) -> ClientHandleArc {
148        let client_config = self.configs[&PeerId::from(0)]
149            .consensus
150            .to_client_config(&self.server_init)
151            .unwrap();
152
153        info!(target: LOG_TEST, "Joining client with existing db");
154        let mut client_builder = Client::builder().await;
155        client_builder.with_module_inits(self.client_init.clone());
156        client_builder
157            .preview_with_existing_config(self.connectors.clone(), client_config, None)
158            .await
159            .join(db, root_secret)
160            .await
161            .map(Arc::new)
162            .expect("Failed to join client")
163    }
164
165    /// Create a recovering client with an existing database and root secret.
166    /// Returns both the client and the database so a new client can be created
167    /// with the same DB after recovery completes.
168    pub async fn recover_client_with_db(
169        &self,
170        db: Database,
171        root_secret: RootSecret,
172    ) -> ClientHandleArc {
173        let client_config = self.configs[&PeerId::from(0)]
174            .consensus
175            .to_client_config(&self.server_init)
176            .unwrap();
177
178        info!(target: LOG_TEST, "Recovering client with existing db");
179        let mut client_builder = Client::builder().await;
180        client_builder.with_module_inits(self.client_init.clone());
181        client_builder
182            .preview_with_existing_config(self.connectors.clone(), client_config, None)
183            .await
184            .recover(db, root_secret, None)
185            .await
186            .map(Arc::new)
187            .expect("Failed to recover client")
188    }
189
190    /// Open an existing client database (e.g., after recovery)
191    pub async fn open_client_with_db(
192        &self,
193        db: Database,
194        root_secret: RootSecret,
195    ) -> ClientHandleArc {
196        info!(target: LOG_TEST, "Opening client with existing db");
197        let mut client_builder = Client::builder().await;
198        client_builder.with_module_inits(self.client_init.clone());
199        client_builder
200            .open(self.connectors.clone(), db, root_secret)
201            .await
202            .map(Arc::new)
203            .expect("Failed to open client")
204    }
205
206    /// Return first invite code for gateways
207    pub fn invite_code(&self) -> InviteCode {
208        let peer_id = PeerId::from(0);
209        let cfg = &self.configs[&peer_id];
210        InviteCode::new(
211            cfg.consensus.api_endpoints()[&peer_id].url.clone(),
212            peer_id,
213            cfg.calculate_federation_id(),
214            None,
215        )
216    }
217
218    ///  Return the federation id
219    pub fn id(&self) -> FederationId {
220        self.configs[&PeerId::from(0)]
221            .consensus
222            .to_client_config(&self.server_init)
223            .unwrap()
224            .global
225            .calculate_federation_id()
226    }
227
228    /// Connects a gateway to this `FederationTest`
229    pub async fn connect_gateway(&self, gw: &Gateway) {
230        gw.handle_connect_federation(ConnectFedPayload {
231            invite_code: self.invite_code().to_string(),
232            use_tor: Some(false),
233            recover: Some(false),
234        })
235        .await
236        .expect("Failed to connect federation");
237    }
238
239    /// Return all online PeerIds
240    pub fn online_peer_ids(&self) -> impl Iterator<Item = PeerId> + use<> {
241        // we can assume this ordering since peers are started in ascending order
242        (0..(self.num_peers - self.num_offline)).map(PeerId::from)
243    }
244
245    /// Returns true if the federation is running in a degraded state
246    pub fn is_degraded(&self) -> bool {
247        self.num_offline > 0
248    }
249}
250
251/// Builder struct for creating a `FederationTest`.
252#[derive(Clone, Debug)]
253pub struct FederationTestBuilder {
254    num_peers: u16,
255    num_offline: u16,
256    base_port: u16,
257    primary_module_kind: ModuleKind,
258    version_hash: String,
259    server_init: ServerModuleInitRegistry,
260    client_init: ClientModuleInitRegistry,
261    bitcoin_rpc_connection: DynServerBitcoinRpc,
262    enable_mint_fees: bool,
263}
264
265impl FederationTestBuilder {
266    pub fn new(
267        server_init: ServerModuleInitRegistry,
268        client_init: ClientModuleInitRegistry,
269        primary_module_kind: ModuleKind,
270        num_offline: u16,
271        bitcoin_rpc_connection: DynServerBitcoinRpc,
272    ) -> FederationTestBuilder {
273        let num_peers = 4;
274        Self {
275            num_peers,
276            num_offline,
277            base_port: block_in_place(|| fedimint_portalloc::port_alloc(num_peers * 3))
278                .expect("Failed to allocate a port range"),
279            primary_module_kind,
280            version_hash: "fedimint-testing-dummy-version-hash".to_owned(),
281            server_init,
282            client_init,
283            bitcoin_rpc_connection,
284            enable_mint_fees: true,
285        }
286    }
287
288    pub fn num_peers(mut self, num_peers: u16) -> FederationTestBuilder {
289        self.num_peers = num_peers;
290        self
291    }
292
293    pub fn num_offline(mut self, num_offline: u16) -> FederationTestBuilder {
294        self.num_offline = num_offline;
295        self
296    }
297
298    pub fn base_port(mut self, base_port: u16) -> FederationTestBuilder {
299        self.base_port = base_port;
300        self
301    }
302
303    pub fn primary_module_kind(mut self, primary_module_kind: ModuleKind) -> FederationTestBuilder {
304        self.primary_module_kind = primary_module_kind;
305        self
306    }
307
308    pub fn version_hash(mut self, version_hash: String) -> FederationTestBuilder {
309        self.version_hash = version_hash;
310        self
311    }
312
313    pub fn disable_mint_fees(mut self) -> FederationTestBuilder {
314        self.enable_mint_fees = false;
315        self
316    }
317
318    #[allow(clippy::too_many_lines)]
319    pub async fn build(self) -> FederationTest {
320        install_crypto_provider().await;
321        let num_offline = self.num_offline;
322        assert!(
323            self.num_peers > 3 * self.num_offline,
324            "too many peers offline ({num_offline}) to reach consensus"
325        );
326        let peers = (0..self.num_peers).map(PeerId::from).collect::<Vec<_>>();
327        let params = local_config_gen_params(
328            &peers,
329            self.base_port,
330            self.enable_mint_fees,
331            &self.server_init,
332        )
333        .expect("Generates local config");
334
335        let configs =
336            ServerConfig::trusted_dealer_gen(&params, &self.server_init, &self.version_hash);
337
338        let task_group = TaskGroup::new();
339        for (peer_id, cfg) in configs.clone() {
340            let peer_port = self.base_port + u16::from(peer_id) * 3;
341
342            let p2p_bind = format!("127.0.0.1:{peer_port}").parse().unwrap();
343            let api_bind = format!("127.0.0.1:{}", peer_port + 1).parse().unwrap();
344            let ui_bind = format!("127.0.0.1:{}", peer_port + 2).parse().unwrap();
345
346            if u16::from(peer_id) >= self.num_peers - self.num_offline {
347                continue;
348            }
349
350            let instances = cfg.consensus.iter_module_instances();
351            let decoders = self.server_init.available_decoders(instances);
352            let db = Database::new(MemDatabase::new(), decoders);
353            let module_init_registry = self.server_init.clone();
354            let subgroup = task_group.make_subgroup();
355            let checkpoint_dir = tempfile::Builder::new().tempdir().unwrap().keep();
356            let code_version_str = env!("CARGO_PKG_VERSION");
357
358            let connector = TlsTcpConnector::new(
359                cfg.tls_config(),
360                p2p_bind,
361                cfg.local.p2p_endpoints.clone(),
362                cfg.local.identity,
363            )
364            .await
365            .into_dyn();
366
367            let (p2p_status_senders, p2p_status_receivers) = p2p_status_channels(connector.peers());
368
369            let connections = ReconnectP2PConnections::new(
370                cfg.local.identity,
371                connector,
372                &task_group,
373                p2p_status_senders,
374                None,
375            )
376            .into_dyn();
377
378            let bitcoin_rpc_connection = self.bitcoin_rpc_connection.clone();
379
380            task_group.spawn("fedimintd", move |_| async move {
381                Box::pin(consensus::run(
382                    ConnectorRegistry::build_from_testing_env().bind().await,
383                    Some(ApiAuth::new("pass".to_string())),
384                    Some(ApiAuth::new("pass".to_string())),
385                    connections,
386                    p2p_status_receivers,
387                    api_bind,
388                    None,
389                    vec![],
390                    cfg.clone(),
391                    db.clone(),
392                    module_init_registry,
393                    &subgroup,
394                    ApiSecrets::default(),
395                    checkpoint_dir,
396                    code_version_str.to_string(),
397                    String::new(),
398                    bitcoin_rpc_connection,
399                    ui_bind,
400                    Box::new(|_| axum::Router::new()),
401                    1,
402                    Duration::from_secs(3600),
403                    ConnectionLimits {
404                        max_connections: 1000,
405                        max_requests_per_connection: 100,
406                    },
407                    None,
408                ))
409                .await
410                .expect("Could not initialise consensus");
411            });
412        }
413
414        for (peer_id, config) in configs.clone() {
415            if u16::from(peer_id) >= self.num_peers - self.num_offline {
416                continue;
417            }
418
419            let connectors = ConnectorRegistry::build_from_testing_env().bind().await;
420            let api = DynGlobalApi::new_admin(
421                connectors,
422                peer_id,
423                config.consensus.api_endpoints()[&peer_id].url.clone(),
424                None,
425            );
426
427            while let Err(e) = api
428                .request_admin_no_auth::<u64>(SESSION_COUNT_ENDPOINT, ApiRequestErased::default())
429                .await
430            {
431                sleep_in_test(
432                    format!("Waiting for api of peer {peer_id} to come online: {e}"),
433                    Duration::from_millis(500),
434                )
435                .await;
436            }
437        }
438
439        FederationTest {
440            configs,
441            server_init: self.server_init,
442            client_init: self.client_init,
443            _task: task_group,
444            num_peers: self.num_peers,
445            num_offline: self.num_offline,
446            connectors: ConnectorRegistry::build_from_testing_env().bind().await,
447        }
448    }
449}