Skip to main content

fedimint_server/consensus/
mod.rs

1pub mod aleph_bft;
2pub mod api;
3pub mod db;
4pub mod debug;
5pub mod engine;
6mod iroh_api;
7pub mod peer_availability;
8pub mod transaction;
9
10use std::collections::BTreeMap;
11use std::net::SocketAddr;
12use std::path::PathBuf;
13use std::sync::Arc;
14use std::time::Duration;
15
16use anyhow::{Context as _, bail, ensure};
17use async_channel::Sender;
18use db::{ServerDbMigrationContext, get_global_database_migrations};
19use fedimint_api_client::api::DynGlobalApi;
20use fedimint_connectors::ConnectorRegistry;
21use fedimint_core::NumPeers;
22use fedimint_core::config::P2PMessage;
23use fedimint_core::core::{ModuleInstanceId, ModuleKind};
24use fedimint_core::db::{Database, apply_migrations_dbtx, verify_module_db_integrity_dbtx};
25use fedimint_core::envs::is_running_in_test_env;
26use fedimint_core::module::registry::ModuleRegistry;
27use fedimint_core::module::{ApiAuth, FEDIMINT_API_ALPN};
28use fedimint_core::net::DynP2PConnections;
29use fedimint_core::net::iroh::build_iroh_endpoint;
30use fedimint_core::session_outcome::ConsensusItem;
31use fedimint_core::task::{TaskGroup, sleep};
32use fedimint_core::util::SafeUrl;
33use fedimint_logging::{LOG_CONSENSUS, LOG_CORE};
34use fedimint_server_core::bitcoin_rpc::{DynServerBitcoinRpc, ServerBitcoinRpcMonitor};
35use fedimint_server_core::dashboard_ui::IDashboardApi;
36use fedimint_server_core::migration::apply_migrations_server_dbtx;
37use fedimint_server_core::{DynServerModule, ServerModuleInitRegistry};
38use jsonrpsee::RpcModule;
39use jsonrpsee::server::ServerHandle;
40use tokio::net::TcpListener;
41use tokio::sync::watch;
42use tracing::{debug, info, warn};
43
44use crate::config::{ServerConfig, ServerConfigLocal};
45use crate::connection_limits::ConnectionLimits;
46use crate::consensus::api::ConsensusApi;
47use crate::consensus::engine::ConsensusEngine;
48use crate::consensus::iroh_api::{IrohApiState, run_iroh_api, run_iroh_api_next};
49use crate::db::verify_server_db_integrity_dbtx;
50use crate::net::api::ApiSecrets;
51use crate::net::api::announcement::get_api_urls;
52use crate::net::api::guardian_metadata::{
53    prepare_guardian_metadata_service, reconcile_guardian_metadata, start_guardian_metadata_service,
54};
55use crate::net::iroh::{build_iroh_v1_endpoint, derive_iroh_v1_api_secret_key};
56use crate::net::p2p::P2PStatusReceivers;
57use crate::{DashboardUiRouter, IrohNextApiSettings, net, update_server_info_version_dbtx};
58
59/// How many txs can be stored in memory before blocking the API
60const TRANSACTION_BUFFER: usize = 1000;
61
62struct IrohApiEndpoints {
63    legacy: Option<iroh::Endpoint>,
64    next: Option<iroh_next::Endpoint>,
65}
66
67fn eligible_iroh_next_api_settings(
68    has_legacy_iroh_api: bool,
69    iroh_next_api_settings: Option<&IrohNextApiSettings>,
70) -> Option<&IrohNextApiSettings> {
71    if iroh_next_api_settings.is_some() && !has_legacy_iroh_api {
72        warn!(
73            target: LOG_CONSENSUS,
74            "Not starting the transitional Iroh 1.0 API because this federation was configured \
75             without the legacy Iroh API"
76        );
77        None
78    } else {
79        iroh_next_api_settings
80    }
81}
82
83/// Refuse to run with API secrets that we cannot actually enforce.
84///
85/// The websocket API rejects unauthenticated requests in an HTTP middleware,
86/// but the Iroh API carries no credentials at all: every request is dispatched
87/// to the endpoint handler without any check. Serving both at once therefore
88/// leaves the whole non-privileged client API open to anyone who knows a
89/// guardian's Iroh node id, which is public information contained in every
90/// invite code. Since the `invite_code` endpoint hands out the invite code, and
91/// with it the secret itself, that also defeats the websocket API.
92///
93/// Failing to start turns that silent hole into a configuration error the
94/// operator can see and act on.
95fn ensure_api_secrets_enforceable(
96    has_iroh_api: bool,
97    force_api_secrets: &ApiSecrets,
98) -> anyhow::Result<()> {
99    ensure!(
100        !has_iroh_api || force_api_secrets.is_empty(),
101        "This federation serves its API over Iroh, which cannot authenticate clients, so the \
102         configured API secrets would not be enforced. Either unset FM_FORCE_API_SECRETS or run a \
103         federation that does not use Iroh."
104    );
105
106    Ok(())
107}
108
109#[cfg(test)]
110#[test]
111fn api_secrets_are_only_accepted_without_the_iroh_api() {
112    use std::str::FromStr as _;
113
114    let secrets = ApiSecrets::from_str("secret").expect("valid api secret");
115
116    ensure_api_secrets_enforceable(false, &secrets).expect("websocket api enforces secrets");
117    ensure_api_secrets_enforceable(true, &ApiSecrets::none()).expect("no secrets to enforce");
118    ensure_api_secrets_enforceable(true, &secrets)
119        .expect_err("the iroh api cannot enforce secrets");
120}
121
122fn resolve_iroh_next_api_bind(
123    api_bind: SocketAddr,
124    iroh_next_api_settings: Option<&IrohNextApiSettings>,
125) -> anyhow::Result<Option<SocketAddr>> {
126    iroh_next_api_settings
127        .map(|settings| {
128            settings.bind_override().map_or_else(
129                || {
130                    let mut bind = api_bind;
131                    bind.set_port(
132                        bind.port()
133                            .checked_add(10)
134                            .context("Default Iroh 1.0 API bind port would overflow")?,
135                    );
136                    anyhow::Ok(bind)
137                },
138                anyhow::Ok,
139            )
140        })
141        .transpose()
142}
143
144#[cfg(test)]
145#[test]
146fn ineligible_iroh_next_api_does_not_validate_unused_default_bind() {
147    let api_bind = "127.0.0.1:65535".parse().expect("valid socket address");
148    let settings = IrohNextApiSettings::new(None);
149    let settings = eligible_iroh_next_api_settings(false, Some(&settings));
150
151    assert!(
152        resolve_iroh_next_api_bind(api_bind, settings)
153            .expect("ineligible listener should be skipped")
154            .is_none()
155    );
156}
157
158async fn prepare_iroh_api_endpoints(
159    cfg: &ServerConfig,
160    api_bind: SocketAddr,
161    iroh_dns: Option<SafeUrl>,
162    iroh_relays: Vec<SafeUrl>,
163    iroh_next_api_settings: Option<&IrohNextApiSettings>,
164) -> anyhow::Result<IrohApiEndpoints> {
165    let legacy = if let Some(iroh_api_sk) = cfg.private.iroh_api_sk.clone() {
166        Some(
167            build_iroh_endpoint(
168                iroh_api_sk,
169                api_bind,
170                iroh_dns.clone(),
171                iroh_relays,
172                FEDIMINT_API_ALPN,
173            )
174            .await?,
175        )
176    } else {
177        None
178    };
179
180    let next_bind = resolve_iroh_next_api_bind(api_bind, iroh_next_api_settings)?;
181    let next = if let Some(bind) = next_bind {
182        let next_api_sk = derive_iroh_v1_api_secret_key(&cfg.private.broadcast_secret_key);
183        Some(build_iroh_v1_endpoint(next_api_sk, bind, iroh_dns, FEDIMINT_API_ALPN).await?)
184    } else {
185        None
186    };
187
188    Ok(IrohApiEndpoints { legacy, next })
189}
190
191fn spawn_iroh_api_tasks(
192    consensus_api: ConsensusApi,
193    iroh_api_limits: ConnectionLimits,
194    endpoints: IrohApiEndpoints,
195    task_group: &TaskGroup,
196) {
197    let iroh_api = IrohApiState::new(consensus_api, iroh_api_limits);
198
199    if let Some(endpoint) = endpoints.legacy {
200        task_group.spawn_cancellable(
201            "iroh-api",
202            run_iroh_api(iroh_api.clone(), endpoint, task_group.clone()),
203        );
204    }
205
206    if let Some(endpoint) = endpoints.next {
207        task_group.spawn_cancellable(
208            "iroh-next-api",
209            run_iroh_api_next(iroh_api, endpoint, task_group.clone()),
210        );
211    }
212}
213
214#[allow(clippy::too_many_arguments)]
215pub async fn run(
216    connectors: ConnectorRegistry,
217    auth_ui: Option<ApiAuth>,
218    auth_api: Option<ApiAuth>,
219    connections: DynP2PConnections<P2PMessage>,
220    p2p_status_receivers: P2PStatusReceivers,
221    api_bind: SocketAddr,
222    iroh_dns: Option<SafeUrl>,
223    iroh_relays: Vec<SafeUrl>,
224    cfg: ServerConfig,
225    db: Database,
226    module_init_registry: ServerModuleInitRegistry,
227    task_group: &TaskGroup,
228    force_api_secrets: ApiSecrets,
229    data_dir: PathBuf,
230    code_version_str: String,
231    code_version_hash: String,
232    dyn_server_bitcoin_rpc: DynServerBitcoinRpc,
233    ui_bind: SocketAddr,
234    dashboard_ui_router: DashboardUiRouter,
235    db_checkpoint_retention: u64,
236    session_timeout: Duration,
237    iroh_api_limits: ConnectionLimits,
238    iroh_next_api_settings: Option<&IrohNextApiSettings>,
239) -> anyhow::Result<()> {
240    cfg.validate_config(&cfg.local.identity, &module_init_registry)?;
241
242    let iroh_next_api_settings =
243        eligible_iroh_next_api_settings(cfg.private.iroh_api_sk.is_some(), iroh_next_api_settings);
244
245    ensure_api_secrets_enforceable(
246        cfg.private.iroh_api_sk.is_some() || iroh_next_api_settings.is_some(),
247        &force_api_secrets,
248    )?;
249
250    let mut global_dbtx = db.begin_transaction().await;
251    apply_migrations_server_dbtx(
252        &mut global_dbtx.to_ref_nc(),
253        Arc::new(ServerDbMigrationContext),
254        "fedimint-server".to_string(),
255        get_global_database_migrations(),
256    )
257    .await?;
258
259    update_server_info_version_dbtx(&mut global_dbtx.to_ref_nc(), &code_version_str).await;
260
261    if is_running_in_test_env() {
262        verify_server_db_integrity_dbtx(&mut global_dbtx.to_ref_nc()).await;
263    }
264    global_dbtx.commit_tx_result().await?;
265
266    let mut modules = BTreeMap::new();
267
268    // TODO: make it work with all transports and federation secrets
269    let global_api = DynGlobalApi::new(
270        connectors.clone(),
271        cfg.consensus
272            .api_endpoints()
273            .iter()
274            .map(|(&peer_id, url)| (peer_id, url.url.clone()))
275            .collect(),
276        None,
277    );
278
279    let bitcoin_rpc_connection = ServerBitcoinRpcMonitor::new(
280        dyn_server_bitcoin_rpc,
281        if is_running_in_test_env() {
282            Duration::from_millis(100)
283        } else {
284            Duration::from_mins(1)
285        },
286        task_group,
287    );
288
289    for (module_id, module_cfg) in &cfg.consensus.modules {
290        match module_init_registry.get(&module_cfg.kind) {
291            Some(module_init) => {
292                info!(target: LOG_CORE, "Initialise module {module_id}...");
293
294                let mut dbtx = db.begin_transaction().await;
295                apply_migrations_dbtx(
296                    &mut dbtx.to_ref_nc(),
297                    Arc::new(ServerDbMigrationContext) as Arc<_>,
298                    module_init.module_kind().to_string(),
299                    module_init.get_database_migrations(),
300                    Some(*module_id),
301                    None,
302                )
303                .await?;
304
305                if let Some(used_db_prefixes) = module_init.used_db_prefixes()
306                    && is_running_in_test_env()
307                {
308                    verify_module_db_integrity_dbtx(
309                        &mut dbtx.to_ref_nc(),
310                        *module_id,
311                        module_init.module_kind(),
312                        &used_db_prefixes,
313                    )
314                    .await;
315                }
316                dbtx.commit_tx_result().await?;
317
318                let module = module_init
319                    .init(
320                        NumPeers::from(cfg.consensus.api_endpoints().len()),
321                        cfg.get_module_config(*module_id)?,
322                        db.with_prefix_module_id(*module_id).0,
323                        task_group,
324                        cfg.local.identity,
325                        global_api.with_module(*module_id),
326                        bitcoin_rpc_connection.clone(),
327                    )
328                    .await?;
329
330                modules.insert(*module_id, (module_cfg.kind.clone(), module));
331            }
332            None => bail!("Detected configuration for unsupported module id: {module_id}"),
333        }
334    }
335
336    let module_registry = ModuleRegistry::from(modules);
337
338    let client_cfg = cfg.consensus.to_client_config(&module_init_registry)?;
339
340    let (submission_sender, submission_receiver) = async_channel::bounded(TRANSACTION_BUFFER);
341    let (shutdown_sender, shutdown_receiver) = watch::channel(None);
342    let (ord_latency_sender, ord_latency_receiver) = watch::channel(None);
343    let (accepted_item_sender, accepted_item_receiver) = watch::channel(());
344    let (completed_session_sender, completed_session_receiver) = watch::channel(());
345
346    let mut ci_status_senders = BTreeMap::new();
347    let mut ci_status_receivers = BTreeMap::new();
348
349    for peer in cfg.consensus.broadcast_public_keys.keys().copied() {
350        let (ci_sender, ci_receiver) = watch::channel(None);
351
352        ci_status_senders.insert(peer, ci_sender);
353        ci_status_receivers.insert(peer, ci_receiver);
354    }
355
356    let supported_api_versions =
357        ServerConfig::supported_api_versions_summary(&cfg.consensus.modules, &module_registry);
358    debug!(
359        target: LOG_CONSENSUS,
360        ?supported_api_versions,
361        "Supported API versions",
362    );
363
364    let consensus_api = ConsensusApi {
365        cfg: cfg.clone(),
366        db: db.clone(),
367        modules: module_registry.clone(),
368        client_cfg: client_cfg.clone(),
369        submission_sender: submission_sender.clone(),
370        shutdown_sender,
371        shutdown_receiver: shutdown_receiver.clone(),
372        supported_api_versions,
373        auth_ui,
374        auth_api,
375        p2p_status_receivers,
376        ci_status_receivers,
377        ord_latency_receiver,
378        accepted_item_receiver,
379        completed_session_receiver,
380        bitcoin_rpc_connection: bitcoin_rpc_connection.clone(),
381        force_api_secret: force_api_secrets.get_active(),
382        code_version_str,
383        code_version_hash,
384        task_group: task_group.clone(),
385    };
386
387    let guardian_metadata_api =
388        prepare_guardian_metadata_service(&db, &cfg, force_api_secrets.get_active()).await?;
389
390    let iroh_api_endpoints = prepare_iroh_api_endpoints(
391        &cfg,
392        api_bind,
393        iroh_dns,
394        iroh_relays,
395        iroh_next_api_settings,
396    )
397    .await?;
398
399    let guardian_metadata_updated =
400        reconcile_guardian_metadata(&db, &cfg, iroh_next_api_settings).await?;
401
402    info!(target: LOG_CONSENSUS, "Starting Consensus Api...");
403
404    let api_handler = start_consensus_api(
405        &cfg.local,
406        consensus_api.clone(),
407        force_api_secrets.clone(),
408        api_bind,
409    )
410    .await;
411
412    spawn_iroh_api_tasks(
413        consensus_api.clone(),
414        iroh_api_limits,
415        iroh_api_endpoints,
416        task_group,
417    );
418
419    start_guardian_metadata_service(
420        &db,
421        task_group,
422        &cfg,
423        guardian_metadata_api,
424        guardian_metadata_updated,
425    );
426
427    info!(target: LOG_CONSENSUS, "Starting Submission of Module CI proposals...");
428
429    for (module_id, kind, module) in module_registry.iter_modules() {
430        submit_module_ci_proposals(
431            task_group,
432            db.clone(),
433            module_id,
434            kind.clone(),
435            module.clone(),
436            submission_sender.clone(),
437        );
438    }
439
440    let ui_service = dashboard_ui_router(consensus_api.clone().into_dyn()).into_make_service();
441
442    let ui_listener = TcpListener::bind(ui_bind)
443        .await
444        .expect("Failed to bind dashboard UI");
445
446    task_group.spawn("dashboard-ui", move |handle| async move {
447        axum::serve(ui_listener, ui_service)
448            .with_graceful_shutdown(handle.make_shutdown_rx())
449            .await
450            .expect("Failed to serve dashboard UI");
451    });
452
453    info!(target: LOG_CONSENSUS, "Dashboard UI running at http://{ui_bind} 🚀");
454
455    loop {
456        match bitcoin_rpc_connection.status() {
457            Some(status) => {
458                if let Some(progress) = status.sync_progress {
459                    if progress >= 0.999 {
460                        break;
461                    }
462
463                    info!(
464                        target: LOG_CONSENSUS,
465                        "Waiting for bitcoin backend to sync... {:.1}%",
466                        progress * 100.0
467                    );
468                } else {
469                    break;
470                }
471            }
472            None => {
473                info!(target: LOG_CONSENSUS, "Waiting to connect to bitcoin backend...");
474            }
475        }
476
477        sleep(Duration::from_secs(1)).await;
478    }
479
480    info!(target: LOG_CONSENSUS, "Starting Consensus Engine...");
481
482    let api_urls = get_api_urls(&db, &cfg.consensus).await;
483
484    let peer_activity = Arc::new(std::sync::Mutex::new(peer_availability::PeerActivity::new(
485        cfg.local.identity,
486        fedimint_core::NumPeersExt::to_num_peers(&cfg.consensus.broadcast_public_keys),
487    )));
488
489    task_group.spawn_cancellable(
490        "peer-availability",
491        peer_availability::persist_peer_availability(db.clone(), peer_activity.clone()),
492    );
493
494    // FIXME: (@leonardo) How should this be handled ?
495    // Using the `Connector::default()` for now!
496    ConsensusEngine {
497        db,
498        federation_api: DynGlobalApi::new(
499            connectors,
500            api_urls,
501            force_api_secrets.get_active().as_deref(),
502        ),
503        cfg: cfg.clone(),
504        connections,
505        ord_latency_sender,
506        accepted_item_sender,
507        completed_session_sender,
508        ci_status_senders,
509        submission_receiver,
510        shutdown_receiver,
511        modules: module_registry,
512        task_group: task_group.clone(),
513        data_dir,
514        db_checkpoint_retention,
515        session_timeout,
516        peer_activity,
517    }
518    .run()
519    .await?;
520
521    api_handler
522        .stop()
523        .expect("Consensus api should still be running");
524
525    api_handler.stopped().await;
526
527    Ok(())
528}
529
530async fn start_consensus_api(
531    cfg: &ServerConfigLocal,
532    api: ConsensusApi,
533    force_api_secrets: ApiSecrets,
534    api_bind: SocketAddr,
535) -> ServerHandle {
536    let mut rpc_module = RpcModule::new(api.clone());
537
538    net::api::attach_endpoints(&mut rpc_module, api::server_endpoints(), None);
539
540    for (id, _, module) in api.modules.iter_modules() {
541        net::api::attach_endpoints(&mut rpc_module, module.api_endpoints(), Some(id));
542    }
543
544    net::api::spawn(
545        "consensus",
546        api_bind,
547        rpc_module,
548        cfg.max_connections,
549        force_api_secrets,
550    )
551    .await
552}
553
554const CONSENSUS_PROPOSAL_TIMEOUT: Duration = Duration::from_secs(30);
555
556fn submit_module_ci_proposals(
557    task_group: &TaskGroup,
558    db: Database,
559    module_id: ModuleInstanceId,
560    kind: ModuleKind,
561    module: DynServerModule,
562    submission_sender: Sender<ConsensusItem>,
563) {
564    let mut interval = tokio::time::interval(if is_running_in_test_env() {
565        Duration::from_millis(100)
566    } else {
567        Duration::from_secs(1)
568    });
569
570    task_group.spawn(
571        format!("citem_proposals_{module_id}"),
572        move |task_handle| async move {
573            while !task_handle.is_shutting_down() {
574                let module_consensus_items = tokio::time::timeout(
575                    CONSENSUS_PROPOSAL_TIMEOUT,
576                    module.consensus_proposal(
577                        &mut db
578                            .begin_transaction_nc()
579                            .await
580                            .to_ref_with_prefix_module_id(module_id)
581                            .0
582                            .into_nc(),
583                        module_id,
584                    ),
585                )
586                .await;
587
588                match module_consensus_items {
589                    Ok(items) => {
590                        for item in items {
591                            if submission_sender
592                                .send(ConsensusItem::Module(item))
593                                .await
594                                .is_err()
595                            {
596                                warn!(
597                                    target: LOG_CONSENSUS,
598                                    module_id,
599                                    "Unable to submit module consensus item proposal via channel"
600                                );
601                            }
602                        }
603                    }
604                    Err(..) => {
605                        warn!(
606                            target: LOG_CONSENSUS,
607                            module_id,
608                            %kind,
609                            "Module failed to propose consensus items on time"
610                        );
611                    }
612                }
613
614                interval.tick().await;
615            }
616        },
617    );
618}