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