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;
6pub mod transaction;
7
8use std::collections::BTreeMap;
9use std::net::SocketAddr;
10use std::path::PathBuf;
11use std::sync::Arc;
12use std::time::Duration;
13
14use anyhow::bail;
15use async_channel::Sender;
16use db::{ServerDbMigrationContext, get_global_database_migrations};
17use fedimint_api_client::api::DynGlobalApi;
18use fedimint_connectors::ConnectorRegistry;
19use fedimint_core::NumPeers;
20use fedimint_core::config::P2PMessage;
21use fedimint_core::core::{ModuleInstanceId, ModuleKind};
22use fedimint_core::db::{Database, apply_migrations_dbtx, verify_module_db_integrity_dbtx};
23use fedimint_core::envs::is_running_in_test_env;
24use fedimint_core::epoch::ConsensusItem;
25use fedimint_core::module::registry::ModuleRegistry;
26use fedimint_core::module::{
27    ApiAuth, ApiEndpoint, ApiError, ApiMethod, FEDIMINT_API_ALPN, IrohApiRequest,
28};
29use fedimint_core::net::iroh::build_iroh_endpoint;
30use fedimint_core::net::peers::DynP2PConnections;
31use fedimint_core::task::{TaskGroup, sleep};
32use fedimint_core::util::{FmtCompactAnyhow as _, SafeUrl};
33use fedimint_logging::{LOG_CONSENSUS, LOG_CORE, LOG_NET_API};
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 futures::FutureExt;
39use iroh::Endpoint;
40use iroh::endpoint::{Incoming, RecvStream, SendStream, VarInt};
41use jsonrpsee::RpcModule;
42use jsonrpsee::server::ServerHandle;
43use serde_json::Value;
44use tokio::net::TcpListener;
45use tokio::sync::{Semaphore, watch};
46use tracing::{info, warn};
47
48use crate::config::{ServerConfig, ServerConfigLocal};
49use crate::connection_limits::ConnectionLimits;
50use crate::consensus::api::{ConsensusApi, server_endpoints};
51use crate::consensus::engine::ConsensusEngine;
52use crate::db::verify_server_db_integrity_dbtx;
53use crate::metrics::{
54    IROH_API_CONNECTION_DURATION_SECONDS, IROH_API_CONNECTION_IDLE_TIMEOUT_TOTAL,
55    IROH_API_CONNECTIONS_ACTIVE, IROH_API_REQUEST_DURATION_SECONDS, IROH_API_REQUEST_RESPONSE_CODE,
56};
57use crate::net::api::announcement::get_api_urls;
58use crate::net::api::{ApiSecrets, HasApiContext};
59use crate::net::p2p::P2PStatusReceivers;
60use crate::{DashboardUiRouter, net, update_server_info_version_dbtx};
61
62/// How many txs can be stored in memory before blocking the API
63const TRANSACTION_BUFFER: usize = 1000;
64
65/// How long an iroh API connection may stay idle before the server closes it.
66const IROH_API_CONNECTION_IDLE_TIMEOUT: Duration = Duration::from_secs(5 * 60);
67
68/// Application-level QUIC error code for expected idle iroh API connection
69/// reaping.
70const IROH_API_CONNECTION_IDLE_TIMEOUT_ERROR_CODE: u32 = 0;
71
72/// Application-level QUIC close reason for idle iroh API connection reaping.
73const IROH_API_CONNECTION_IDLE_TIMEOUT_ERROR_REASON: &[u8] = b"idle timeout";
74
75#[allow(clippy::too_many_arguments)]
76pub async fn run(
77    connectors: ConnectorRegistry,
78    auth_ui: Option<ApiAuth>,
79    auth_api: Option<ApiAuth>,
80    connections: DynP2PConnections<P2PMessage>,
81    p2p_status_receivers: P2PStatusReceivers,
82    api_bind: SocketAddr,
83    iroh_dns: Option<SafeUrl>,
84    iroh_relays: Vec<SafeUrl>,
85    cfg: ServerConfig,
86    db: Database,
87    module_init_registry: ServerModuleInitRegistry,
88    task_group: &TaskGroup,
89    force_api_secrets: ApiSecrets,
90    data_dir: PathBuf,
91    code_version_str: String,
92    code_version_hash: String,
93    dyn_server_bitcoin_rpc: DynServerBitcoinRpc,
94    ui_bind: SocketAddr,
95    dashboard_ui_router: DashboardUiRouter,
96    db_checkpoint_retention: u64,
97    session_timeout: Duration,
98    iroh_api_limits: ConnectionLimits,
99) -> anyhow::Result<()> {
100    cfg.validate_config(&cfg.local.identity, &module_init_registry)?;
101
102    let mut global_dbtx = db.begin_transaction().await;
103    apply_migrations_server_dbtx(
104        &mut global_dbtx.to_ref_nc(),
105        Arc::new(ServerDbMigrationContext),
106        "fedimint-server".to_string(),
107        get_global_database_migrations(),
108    )
109    .await?;
110
111    update_server_info_version_dbtx(&mut global_dbtx.to_ref_nc(), &code_version_str).await;
112
113    if is_running_in_test_env() {
114        verify_server_db_integrity_dbtx(&mut global_dbtx.to_ref_nc()).await;
115    }
116    global_dbtx.commit_tx_result().await?;
117
118    let mut modules = BTreeMap::new();
119
120    // TODO: make it work with all transports and federation secrets
121    let global_api = DynGlobalApi::new(
122        connectors.clone(),
123        cfg.consensus
124            .api_endpoints()
125            .iter()
126            .map(|(&peer_id, url)| (peer_id, url.url.clone()))
127            .collect(),
128        None,
129    )?;
130
131    let bitcoin_rpc_connection = ServerBitcoinRpcMonitor::new(
132        dyn_server_bitcoin_rpc,
133        if is_running_in_test_env() {
134            Duration::from_millis(100)
135        } else {
136            Duration::from_mins(1)
137        },
138        task_group,
139    );
140
141    for (module_id, module_cfg) in &cfg.consensus.modules {
142        match module_init_registry.get(&module_cfg.kind) {
143            Some(module_init) => {
144                info!(target: LOG_CORE, "Initialise module {module_id}...");
145
146                let mut dbtx = db.begin_transaction().await;
147                apply_migrations_dbtx(
148                    &mut dbtx.to_ref_nc(),
149                    Arc::new(ServerDbMigrationContext) as Arc<_>,
150                    module_init.module_kind().to_string(),
151                    module_init.get_database_migrations(),
152                    Some(*module_id),
153                    None,
154                )
155                .await?;
156
157                if let Some(used_db_prefixes) = module_init.used_db_prefixes()
158                    && is_running_in_test_env()
159                {
160                    verify_module_db_integrity_dbtx(
161                        &mut dbtx.to_ref_nc(),
162                        *module_id,
163                        module_init.module_kind(),
164                        &used_db_prefixes,
165                    )
166                    .await;
167                }
168                dbtx.commit_tx_result().await?;
169
170                let module = module_init
171                    .init(
172                        NumPeers::from(cfg.consensus.api_endpoints().len()),
173                        cfg.get_module_config(*module_id)?,
174                        db.with_prefix_module_id(*module_id).0,
175                        task_group,
176                        cfg.local.identity,
177                        global_api.with_module(*module_id),
178                        bitcoin_rpc_connection.clone(),
179                    )
180                    .await?;
181
182                modules.insert(*module_id, (module_cfg.kind.clone(), module));
183            }
184            None => bail!("Detected configuration for unsupported module id: {module_id}"),
185        }
186    }
187
188    let module_registry = ModuleRegistry::from(modules);
189
190    let client_cfg = cfg.consensus.to_client_config(&module_init_registry)?;
191
192    let (submission_sender, submission_receiver) = async_channel::bounded(TRANSACTION_BUFFER);
193    let (shutdown_sender, shutdown_receiver) = watch::channel(None);
194    let (ord_latency_sender, ord_latency_receiver) = watch::channel(None);
195
196    let mut ci_status_senders = BTreeMap::new();
197    let mut ci_status_receivers = BTreeMap::new();
198
199    for peer in cfg.consensus.broadcast_public_keys.keys().copied() {
200        let (ci_sender, ci_receiver) = watch::channel(None);
201
202        ci_status_senders.insert(peer, ci_sender);
203        ci_status_receivers.insert(peer, ci_receiver);
204    }
205
206    let consensus_api = ConsensusApi {
207        cfg: cfg.clone(),
208        db: db.clone(),
209        modules: module_registry.clone(),
210        client_cfg: client_cfg.clone(),
211        submission_sender: submission_sender.clone(),
212        shutdown_sender,
213        shutdown_receiver: shutdown_receiver.clone(),
214        supported_api_versions: ServerConfig::supported_api_versions_summary(
215            &cfg.consensus.modules,
216            &module_init_registry,
217        ),
218        auth_ui,
219        auth_api,
220        p2p_status_receivers,
221        ci_status_receivers,
222        ord_latency_receiver,
223        bitcoin_rpc_connection: bitcoin_rpc_connection.clone(),
224        force_api_secret: force_api_secrets.get_active(),
225        code_version_str,
226        code_version_hash,
227        task_group: task_group.clone(),
228    };
229
230    info!(target: LOG_CONSENSUS, "Starting Consensus Api...");
231
232    let api_handler = start_consensus_api(
233        &cfg.local,
234        consensus_api.clone(),
235        force_api_secrets.clone(),
236        api_bind,
237    )
238    .await;
239
240    if let Some(iroh_api_sk) = cfg.private.iroh_api_sk.clone()
241        && let Err(e) = Box::pin(start_iroh_api(
242            iroh_api_sk,
243            api_bind,
244            iroh_dns,
245            iroh_relays,
246            consensus_api.clone(),
247            task_group,
248            iroh_api_limits,
249        ))
250        .await
251    {
252        // clean up ws api before propagating error
253        api_handler.stop().expect("Just started");
254        api_handler.stopped().await;
255        return Err(e);
256    }
257
258    info!(target: LOG_CONSENSUS, "Starting Submission of Module CI proposals...");
259
260    for (module_id, kind, module) in module_registry.iter_modules() {
261        submit_module_ci_proposals(
262            task_group,
263            db.clone(),
264            module_id,
265            kind.clone(),
266            module.clone(),
267            submission_sender.clone(),
268        );
269    }
270
271    let ui_service = dashboard_ui_router(consensus_api.clone().into_dyn()).into_make_service();
272
273    let ui_listener = TcpListener::bind(ui_bind)
274        .await
275        .expect("Failed to bind dashboard UI");
276
277    task_group.spawn("dashboard-ui", move |handle| async move {
278        axum::serve(ui_listener, ui_service)
279            .with_graceful_shutdown(handle.make_shutdown_rx())
280            .await
281            .expect("Failed to serve dashboard UI");
282    });
283
284    info!(target: LOG_CONSENSUS, "Dashboard UI running at http://{ui_bind} 🚀");
285
286    loop {
287        match bitcoin_rpc_connection.status() {
288            Some(status) => {
289                if let Some(progress) = status.sync_progress {
290                    if progress >= 0.999 {
291                        break;
292                    }
293
294                    info!(target: LOG_CONSENSUS, "Waiting for bitcoin backend to sync... {progress:.1}%");
295                } else {
296                    break;
297                }
298            }
299            None => {
300                info!(target: LOG_CONSENSUS, "Waiting to connect to bitcoin backend...");
301            }
302        }
303
304        sleep(Duration::from_secs(1)).await;
305    }
306
307    info!(target: LOG_CONSENSUS, "Starting Consensus Engine...");
308
309    let api_urls = get_api_urls(&db, &cfg.consensus).await;
310
311    // FIXME: (@leonardo) How should this be handled ?
312    // Using the `Connector::default()` for now!
313    ConsensusEngine {
314        db,
315        federation_api: DynGlobalApi::new(
316            connectors,
317            api_urls,
318            force_api_secrets.get_active().as_deref(),
319        )?,
320        cfg: cfg.clone(),
321        connections,
322        ord_latency_sender,
323        ci_status_senders,
324        submission_receiver,
325        shutdown_receiver,
326        modules: module_registry,
327        task_group: task_group.clone(),
328        data_dir,
329        db_checkpoint_retention,
330        session_timeout,
331    }
332    .run()
333    .await?;
334
335    api_handler
336        .stop()
337        .expect("Consensus api should still be running");
338
339    api_handler.stopped().await;
340
341    Ok(())
342}
343
344async fn start_consensus_api(
345    cfg: &ServerConfigLocal,
346    api: ConsensusApi,
347    force_api_secrets: ApiSecrets,
348    api_bind: SocketAddr,
349) -> ServerHandle {
350    let mut rpc_module = RpcModule::new(api.clone());
351
352    net::api::attach_endpoints(&mut rpc_module, api::server_endpoints(), None);
353
354    for (id, _, module) in api.modules.iter_modules() {
355        net::api::attach_endpoints(&mut rpc_module, module.api_endpoints(), Some(id));
356    }
357
358    net::api::spawn(
359        "consensus",
360        api_bind,
361        rpc_module,
362        cfg.max_connections,
363        force_api_secrets,
364    )
365    .await
366}
367
368const CONSENSUS_PROPOSAL_TIMEOUT: Duration = Duration::from_secs(30);
369
370fn submit_module_ci_proposals(
371    task_group: &TaskGroup,
372    db: Database,
373    module_id: ModuleInstanceId,
374    kind: ModuleKind,
375    module: DynServerModule,
376    submission_sender: Sender<ConsensusItem>,
377) {
378    let mut interval = tokio::time::interval(if is_running_in_test_env() {
379        Duration::from_millis(100)
380    } else {
381        Duration::from_secs(1)
382    });
383
384    task_group.spawn(
385        format!("citem_proposals_{module_id}"),
386        move |task_handle| async move {
387            while !task_handle.is_shutting_down() {
388                let module_consensus_items = tokio::time::timeout(
389                    CONSENSUS_PROPOSAL_TIMEOUT,
390                    module.consensus_proposal(
391                        &mut db
392                            .begin_transaction_nc()
393                            .await
394                            .to_ref_with_prefix_module_id(module_id)
395                            .0
396                            .into_nc(),
397                        module_id,
398                    ),
399                )
400                .await;
401
402                match module_consensus_items {
403                    Ok(items) => {
404                        for item in items {
405                            if submission_sender
406                                .send(ConsensusItem::Module(item))
407                                .await
408                                .is_err()
409                            {
410                                warn!(
411                                    target: LOG_CONSENSUS,
412                                    module_id,
413                                    "Unable to submit module consensus item proposal via channel"
414                                );
415                            }
416                        }
417                    }
418                    Err(..) => {
419                        warn!(
420                            target: LOG_CONSENSUS,
421                            module_id,
422                            %kind,
423                            "Module failed to propose consensus items on time"
424                        );
425                    }
426                }
427
428                interval.tick().await;
429            }
430        },
431    );
432}
433
434async fn start_iroh_api(
435    secret_key: iroh::SecretKey,
436    api_bind: SocketAddr,
437    iroh_dns: Option<SafeUrl>,
438    iroh_relays: Vec<SafeUrl>,
439    consensus_api: ConsensusApi,
440    task_group: &TaskGroup,
441    iroh_api_limits: ConnectionLimits,
442) -> anyhow::Result<()> {
443    let endpoint = build_iroh_endpoint(
444        secret_key,
445        api_bind,
446        iroh_dns,
447        iroh_relays,
448        FEDIMINT_API_ALPN,
449    )
450    .await?;
451    task_group.spawn_cancellable(
452        "iroh-api",
453        run_iroh_api(consensus_api, endpoint, task_group.clone(), iroh_api_limits),
454    );
455
456    Ok(())
457}
458
459async fn run_iroh_api(
460    consensus_api: ConsensusApi,
461    endpoint: Endpoint,
462    task_group: TaskGroup,
463    iroh_api_limits: ConnectionLimits,
464) {
465    let core_api = server_endpoints()
466        .into_iter()
467        .map(|endpoint| (endpoint.path.to_string(), endpoint))
468        .collect::<BTreeMap<String, ApiEndpoint<ConsensusApi>>>();
469
470    let module_api = consensus_api
471        .modules
472        .iter_modules()
473        .map(|(id, _, module)| {
474            let api_endpoints = module
475                .api_endpoints()
476                .into_iter()
477                .map(|endpoint| (endpoint.path.to_string(), endpoint))
478                .collect::<BTreeMap<String, ApiEndpoint<DynServerModule>>>();
479
480            (id, api_endpoints)
481        })
482        .collect::<BTreeMap<ModuleInstanceId, BTreeMap<String, ApiEndpoint<DynServerModule>>>>();
483
484    let consensus_api = Arc::new(consensus_api);
485    let core_api = Arc::new(core_api);
486    let module_api = Arc::new(module_api);
487    let parallel_connections_limit = Arc::new(Semaphore::new(iroh_api_limits.max_connections));
488
489    loop {
490        match endpoint.accept().await {
491            Some(incoming) => {
492                if parallel_connections_limit.available_permits() == 0 {
493                    warn!(
494                        target: LOG_NET_API,
495                        limit = iroh_api_limits.max_connections,
496                        "Iroh API connection limit reached, blocking new connections"
497                    );
498                }
499                let permit = parallel_connections_limit
500                    .clone()
501                    .acquire_owned()
502                    .await
503                    .expect("semaphore should not be closed");
504                task_group.spawn_cancellable_silent(
505                    "handle-iroh-connection",
506                    handle_incoming(
507                        consensus_api.clone(),
508                        core_api.clone(),
509                        module_api.clone(),
510                        task_group.clone(),
511                        incoming,
512                        permit,
513                        iroh_api_limits.max_requests_per_connection,
514                    )
515                    .then(|result| async {
516                        if let Err(err) = result {
517                            warn!(target: LOG_NET_API, err = %err.fmt_compact_anyhow(), "Failed to handle iroh connection");
518                        }
519                    }),
520                );
521            }
522            None => return,
523        }
524    }
525}
526
527async fn handle_incoming(
528    consensus_api: Arc<ConsensusApi>,
529    core_api: Arc<BTreeMap<String, ApiEndpoint<ConsensusApi>>>,
530    module_api: Arc<BTreeMap<ModuleInstanceId, BTreeMap<String, ApiEndpoint<DynServerModule>>>>,
531    task_group: TaskGroup,
532    incoming: Incoming,
533    _connection_permit: tokio::sync::OwnedSemaphorePermit,
534    iroh_api_max_requests_per_connection: usize,
535) -> anyhow::Result<()> {
536    let connection = incoming.accept()?.await?;
537    let parallel_requests_limit = Arc::new(Semaphore::new(iroh_api_max_requests_per_connection));
538
539    IROH_API_CONNECTIONS_ACTIVE.inc();
540    let connection_timer = IROH_API_CONNECTION_DURATION_SECONDS.start_timer();
541    scopeguard::defer! {
542        IROH_API_CONNECTIONS_ACTIVE.dec();
543        connection_timer.observe_duration();
544    }
545
546    loop {
547        let accept_result = fedimint_core::runtime::timeout(
548            IROH_API_CONNECTION_IDLE_TIMEOUT,
549            connection.accept_bi(),
550        )
551        .await;
552
553        let (send_stream, recv_stream) = match accept_result {
554            Ok(streams) => streams?,
555            Err(_)
556                if parallel_requests_limit.available_permits()
557                    < iroh_api_max_requests_per_connection =>
558            {
559                continue;
560            }
561            Err(_) => {
562                IROH_API_CONNECTION_IDLE_TIMEOUT_TOTAL.inc();
563                tracing::debug!(
564                    target: LOG_NET_API,
565                    idle_timeout_secs = IROH_API_CONNECTION_IDLE_TIMEOUT.as_secs(),
566                    "Closing idle iroh API connection"
567                );
568                connection.close(
569                    VarInt::from_u32(IROH_API_CONNECTION_IDLE_TIMEOUT_ERROR_CODE),
570                    IROH_API_CONNECTION_IDLE_TIMEOUT_ERROR_REASON,
571                );
572                return Ok(());
573            }
574        };
575
576        if parallel_requests_limit.available_permits() == 0 {
577            warn!(
578                target: LOG_NET_API,
579                limit = iroh_api_max_requests_per_connection,
580                "Iroh API request limit reached for connection, blocking new requests"
581            );
582        }
583        let permit = parallel_requests_limit
584            .clone()
585            .acquire_owned()
586            .await
587            .expect("semaphore should not be closed");
588        task_group.spawn_cancellable_silent(
589            "handle-iroh-request",
590            handle_request(
591                consensus_api.clone(),
592                core_api.clone(),
593                module_api.clone(),
594                send_stream,
595                recv_stream,
596                permit,
597            )
598            .then(|result| async {
599                if let Err(err) = result {
600                    warn!(target: LOG_NET_API, err = %err.fmt_compact_anyhow(), "Failed to handle iroh request");
601                }
602            }),
603        );
604    }
605}
606
607async fn handle_request(
608    consensus_api: Arc<ConsensusApi>,
609    core_api: Arc<BTreeMap<String, ApiEndpoint<ConsensusApi>>>,
610    module_api: Arc<BTreeMap<ModuleInstanceId, BTreeMap<String, ApiEndpoint<DynServerModule>>>>,
611    mut send_stream: SendStream,
612    mut recv_stream: RecvStream,
613    _request_permit: tokio::sync::OwnedSemaphorePermit,
614) -> anyhow::Result<()> {
615    let request = recv_stream.read_to_end(100_000).await?;
616
617    let request = serde_json::from_slice::<IrohApiRequest>(&request)?;
618
619    let method = request.method.to_string();
620    let timer = IROH_API_REQUEST_DURATION_SECONDS
621        .with_label_values(&[&method])
622        .start_timer();
623
624    let response = await_response(consensus_api, core_api, module_api, request).await;
625
626    timer.observe_duration();
627
628    let response_code = response
629        .as_ref()
630        .map_or_else(|err| err.code.to_string(), |_| "0".to_string());
631    IROH_API_REQUEST_RESPONSE_CODE
632        .with_label_values(&[method.as_str(), response_code.as_str(), "default"])
633        .inc();
634
635    let response = serde_json::to_vec(&response)?;
636
637    send_stream.write_all(&response).await?;
638
639    send_stream.finish()?;
640
641    Ok(())
642}
643
644async fn await_response(
645    consensus_api: Arc<ConsensusApi>,
646    core_api: Arc<BTreeMap<String, ApiEndpoint<ConsensusApi>>>,
647    module_api: Arc<BTreeMap<ModuleInstanceId, BTreeMap<String, ApiEndpoint<DynServerModule>>>>,
648    request: IrohApiRequest,
649) -> Result<Value, ApiError> {
650    match request.method {
651        ApiMethod::Core(method) => {
652            let endpoint = core_api.get(&method).ok_or(ApiError::not_found(method))?;
653
654            let (state, context) = consensus_api.context(&request.request, None).await;
655
656            (endpoint.handler)(state, context, request.request).await
657        }
658        ApiMethod::Module(module_id, method) => {
659            let endpoint = module_api
660                .get(&module_id)
661                .ok_or(ApiError::not_found(module_id.to_string()))?
662                .get(&method)
663                .ok_or(ApiError::not_found(method))?;
664
665            let (state, context) = consensus_api
666                .context(&request.request, Some(module_id))
667                .await;
668
669            (endpoint.handler)(state, context, request.request).await
670        }
671    }
672}