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