Skip to main content

espresso_node/
run.rs

1use anyhow::Context;
2use espresso_telemetry as telemetry;
3use espresso_types::{traits::NullEventConsumer, v0::traits::SequencerPersistence};
4use futures::future::FutureExt;
5use hotshot_types::traits::metrics::NoMetrics;
6use process_metrics::log_cpu_probe;
7use url::Url;
8
9use super::{
10    CatchupParams, Genesis, L1Params, NetworkParams,
11    api::{self, data_source::DataSourceOptions},
12    context::SequencerContext,
13    init_node,
14    options::{Modules, Options, PublicNodeConfig},
15    persistence,
16};
17use crate::{
18    default_telemetry_endpoint,
19    follower::{FollowerContext, FollowerParams, init_follower_node},
20    keyset::KeySet,
21};
22
23pub enum NodeContext<P: SequencerPersistence> {
24    Validator(Box<SequencerContext<P>>),
25    Follower(Box<FollowerContext<P>>),
26}
27
28impl<P: SequencerPersistence> NodeContext<P> {
29    pub async fn start(&mut self) -> anyhow::Result<()> {
30        match self {
31            Self::Validator(ctx) => ctx.start_consensus().await,
32            // Following began in `init_follower_node`.
33            Self::Follower(_) => {},
34        }
35        Ok(())
36    }
37
38    pub async fn join(&mut self) {
39        match self {
40            Self::Validator(ctx) => ctx.join().await,
41            Self::Follower(ctx) => ctx.join().await,
42        }
43    }
44
45    pub async fn shut_down(&mut self) {
46        match self {
47            Self::Validator(ctx) => ctx.shut_down().await,
48            Self::Follower(ctx) => ctx.shut_down().await,
49        }
50    }
51}
52
53pub async fn main(migrated_envs: Vec<(&str, &str)>) -> anyhow::Result<()> {
54    espresso_types::assert_node_feature();
55    let opt = <Options as clap::Parser>::parse();
56    let mut modules = opt.modules();
57
58    // Genesis carries the chain ID, which selects the default telemetry
59    // endpoint. Load it before telemetry init; the genesis log line is emitted
60    // later, once the subscriber is installed.
61    let genesis = Genesis::load(&opt.genesis_file).await?;
62    let telemetry_endpoint: Option<Url> = opt.telemetry.endpoint.clone().or_else(|| {
63        default_telemetry_endpoint(genesis.chain_config.chain_id).map(|s| {
64            s.parse()
65                .expect("default telemetry endpoint is a valid URL")
66        })
67    });
68    let telemetry_enabled = opt.telemetry.logs_enable || opt.telemetry.metrics_enable;
69
70    // Build telemetry before logging so its layer attaches to the global
71    // subscriber. The registry is still `None` here (the API setup runs later);
72    // the metrics push is attached then via `attach_metrics_push`.
73    let (mut telemetry_handle, deferred_warnings, telemetry_init_error) =
74        match (opt.key_set.clone().try_into(), telemetry_endpoint.as_ref()) {
75            (Ok(KeySet { staking, .. }), Some(endpoint)) => match telemetry::init(
76                &opt.telemetry,
77                &staking,
78                opt.identity.node_name.as_deref(),
79                opt.identity.company_name.as_deref(),
80                endpoint,
81                telemetry::registry(),
82            ) {
83                Ok((h, warns)) => (h, warns, None),
84                Err(e) => (None, Vec::new(), Some(e.context("telemetry init failed"))),
85            },
86            // Telemetry requested but no endpoint resolved (unknown chain, no
87            // override). Stay off and warn so the misconfig is visible.
88            (Ok(_), None) if telemetry_enabled => (
89                None,
90                vec![format!(
91                    "telemetry enabled but no endpoint resolved for chain {}; set \
92                     ESPRESSO_NODE_TELEMETRY_ENDPOINT",
93                    genesis.chain_config.chain_id
94                )],
95                None,
96            ),
97            (Err(_), _) if telemetry_enabled && modules.follower.is_some() => (
98                None,
99                vec![
100                    "telemetry enabled but a follower has no staking key to identify itself with; \
101                     continuing without telemetry"
102                        .into(),
103                ],
104                None,
105            ),
106            // Keyset error (surfaced later) or telemetry not requested.
107            _ => (None, Vec::new(), None),
108        };
109    let otel_layer = telemetry_handle.as_ref().and_then(|h| h.tracing_layer());
110    opt.logging.init_with_otel(otel_layer);
111    for w in deferred_warnings {
112        tracing::warn!("{w}");
113    }
114    if let Some(e) = telemetry_init_error {
115        tracing::error!("{e:#}; continuing without telemetry");
116    }
117    espresso_utils::env_compat::log_migrated_env_vars(&migrated_envs);
118    log_cpu_probe(genesis.drb_difficulty.max(genesis.drb_upgrade_difficulty)).await;
119
120    tracing::warn!(?modules, "sequencer starting up");
121    if modules
122        .query
123        .as_ref()
124        .is_some_and(|query| query.peers.is_empty())
125    {
126        tracing::error!(
127            "module query fetches missing data from its peers but none are set: set --peers \
128             (ESPRESSO_NODE_API_PEERS)"
129        );
130    }
131
132    let public_node_config = PublicNodeConfig::new(&opt, &modules, &genesis);
133
134    tracing::warn!(?genesis, "genesis");
135
136    let result = if let Some(storage) = modules.storage_fs.take() {
137        run_with_storage(
138            genesis,
139            modules,
140            opt,
141            storage,
142            public_node_config,
143            telemetry_handle.as_mut(),
144        )
145        .await
146    } else if let Some(storage) = modules.storage_sql.take() {
147        match storage.into_backend() {
148            persistence::sql::SqlBackendOptions::Sqlite(storage) => {
149                run_with_storage(
150                    genesis,
151                    modules,
152                    opt,
153                    storage,
154                    public_node_config,
155                    telemetry_handle.as_mut(),
156                )
157                .await
158            },
159            persistence::sql::SqlBackendOptions::Postgres(storage) => {
160                run_with_storage(
161                    genesis,
162                    modules,
163                    opt,
164                    storage,
165                    public_node_config,
166                    telemetry_handle.as_mut(),
167                )
168                .await
169            },
170        }
171    } else {
172        // Persistence is required. If none is provided, just use the local file system.
173        run_with_storage(
174            genesis,
175            modules,
176            opt,
177            persistence::fs::Options::default(),
178            public_node_config,
179            telemetry_handle.as_mut(),
180        )
181        .await
182    };
183
184    if let Some(h) = telemetry_handle {
185        h.shutdown();
186    }
187    result
188}
189
190async fn run_with_storage<S>(
191    genesis: Genesis,
192    modules: Modules,
193    opt: Options,
194    storage_opt: S,
195    public_node_config: PublicNodeConfig,
196    telemetry_handle: Option<&mut telemetry::TelemetryHandle>,
197) -> anyhow::Result<()>
198where
199    S: DataSourceOptions,
200{
201    let mut ctx = init_with_storage(genesis, modules, opt, storage_opt, public_node_config).await?;
202
203    // The API setup deposited the prometheus Registry into `telemetry::REGISTRY`
204    // (if the HTTP module was configured). Attach the metrics push task now,
205    // before consensus starts churning.
206    if let (Some(handle), Some(registry)) = (telemetry_handle, telemetry::registry()) {
207        handle.attach_metrics_push(registry);
208    }
209
210    ctx.start().await?;
211
212    tokio::select! {
213        () = ctx.join() => tracing::warn!("consensus stopped; exiting"),
214        _ = espresso_utils::shutdown::wait_for_shutdown_signal() => ctx.shut_down().await,
215    }
216
217    Ok(())
218}
219
220pub async fn init_with_storage<S>(
221    genesis: Genesis,
222    mut modules: Modules,
223    opt: Options,
224    mut storage_opt: S,
225    public_node_config: PublicNodeConfig,
226) -> anyhow::Result<NodeContext<S::Persistence>>
227where
228    S: DataSourceOptions,
229{
230    let follower_params = match (&modules.follower, &modules.query) {
231        (Some(follower), Some(query)) => {
232            Some(opt.follower_params(follower, query, storage_opt.light_client_db(query)?)?)
233        },
234        _ => None,
235    };
236    let l1_params = L1Params {
237        urls: opt.l1_provider_url,
238        options: opt.l1_options,
239    };
240
241    if let Some(params) = follower_params {
242        let ctx = init_follower_with_storage(
243            genesis,
244            modules,
245            params,
246            l1_params,
247            storage_opt,
248            public_node_config,
249        )
250        .await?;
251        return Ok(NodeContext::Follower(Box::new(ctx)));
252    }
253
254    let KeySet {
255        staking,
256        state,
257        x25519,
258    } = opt.key_set.try_into()?;
259
260    let network_params = NetworkParams {
261        cliquenet_bind_addr: opt.cliquenet_bind_address,
262        cliquenet_advertise_addr: opt.cliquenet_advertise_address,
263        x25519_secret_key: x25519,
264        orchestrator_url: opt.orchestrator_url,
265        builder_urls: opt.builder_urls,
266        state_relay_server_url: opt.state_relay_server_url,
267        public_api_url: opt.public_api_url,
268        private_staking_key: staking,
269        private_state_key: state,
270        config_peers: opt.config_peers,
271        catchup: CatchupParams {
272            state_peers: opt.state_peers,
273            backoff: opt.catchup_backoff,
274            base_timeout: opt.catchup_base_timeout,
275            local_timeout: opt.local_catchup_timeout,
276            http_delay: opt.http_catchup_delay,
277        },
278        bootstrap_epoch_catchup_timeout: opt.bootstrap_epoch_catchup_timeout,
279    };
280
281    let proposal_fetcher_config = opt.proposal_fetcher_config;
282    let empty_block_delay = opt.empty_block_delay;
283    let leader_trace_dir = opt.leader_trace_dir.clone();
284
285    if modules.query.is_none() {
286        storage_opt.set_consensus_only();
287    }
288    let persistence = storage_opt.create().await?;
289
290    // Initialize HotShot. If the user requested the HTTP module, we must initialize the handle in
291    // a special way, in order to populate the API with consensus metrics. Otherwise, we initialize
292    // the handle directly, with no metrics.
293    let ctx = match modules.http.take() {
294        Some(http_opt) => {
295            let http_opt = api_options(http_opt, modules, &storage_opt, public_node_config);
296            http_opt
297                .serve(move |metrics, consumer, storage| {
298                    async move {
299                        init_node(
300                            genesis,
301                            network_params,
302                            metrics,
303                            persistence,
304                            l1_params,
305                            storage,
306                            consumer,
307                            opt.is_da,
308                            opt.identity,
309                            proposal_fetcher_config,
310                            empty_block_delay,
311                            leader_trace_dir,
312                        )
313                        .await
314                    }
315                    .boxed()
316                })
317                .await?
318        },
319        None => {
320            init_node(
321                genesis,
322                network_params,
323                Box::new(NoMetrics),
324                persistence,
325                l1_params,
326                None,
327                NullEventConsumer,
328                opt.is_da,
329                opt.identity,
330                proposal_fetcher_config,
331                empty_block_delay,
332                leader_trace_dir,
333            )
334            .await?
335        },
336    };
337
338    Ok(NodeContext::Validator(Box::new(ctx)))
339}
340
341/// Module parsing has checked that the http, query and storage-sql modules are present.
342async fn init_follower_with_storage<S>(
343    genesis: Genesis,
344    mut modules: Modules,
345    params: FollowerParams,
346    l1_params: L1Params,
347    mut storage_opt: S,
348    public_node_config: PublicNodeConfig,
349) -> anyhow::Result<FollowerContext<S::Persistence>>
350where
351    S: DataSourceOptions,
352{
353    let http_opt = modules
354        .http
355        .take()
356        .context("a follower needs the http module")?;
357    let http_opt = api_options(http_opt, modules, &storage_opt, public_node_config);
358    let persistence = storage_opt.create().await?;
359    http_opt
360        .serve(move |metrics, sink, _storage| {
361            async move {
362                init_follower_node(genesis, params, metrics, persistence, l1_params, sink).await
363            }
364            .boxed()
365        })
366        .await
367        .map(FollowerContext::new)
368}
369
370fn api_options<S: DataSourceOptions>(
371    http_opt: api::options::Http,
372    modules: Modules,
373    storage_opt: &S,
374    public_node_config: PublicNodeConfig,
375) -> api::Options {
376    let mut http_opt = api::Options::from(http_opt);
377    if let Some(query) = modules.query {
378        http_opt = storage_opt.enable_query_module(http_opt, query);
379    }
380    if let Some(submit) = modules.submit {
381        http_opt = http_opt.submit(submit);
382    }
383    if let Some(status) = modules.status {
384        http_opt = http_opt.status(status);
385    }
386    if let Some(catchup) = modules.catchup {
387        http_opt = http_opt.catchup(catchup);
388    }
389    if modules.hotshot_events.is_some() {
390        tracing::warn!(
391            "the hotshot-events module is deprecated and ignored: the node no longer serves \
392             /hotshot-events. Remove it from the command line."
393        );
394    }
395    if let Some(explorer) = modules.explorer {
396        http_opt = http_opt.explorer(explorer);
397    }
398    if modules.light_client.is_some() {
399        tracing::warn!(
400            "the light-client module is deprecated and ignored: every node with SQL storage and \
401             the query module serves /light-client. Remove it from the command line."
402        );
403    }
404    if let Some(config) = modules.config {
405        http_opt = http_opt
406            .config(config)
407            .public_node_config(public_node_config);
408    }
409    http_opt
410}
411
412#[cfg(test)]
413mod test {
414    use std::time::Duration;
415
416    use clap::Parser;
417    use espresso_types::{
418        PubKey, Upgrade, UpgradeType,
419        v0_1::{UpgradeMode, ViewBasedUpgrade},
420    };
421    use hotshot_types::{light_client::StateKeyPair, traits::signature_key::SignatureKey, x25519};
422    use http_client::{Client, Url, error::ClientErr};
423    use tagged_base64::TaggedBase64;
424    use tempfile::TempDir;
425    use test_utils::reserve_tcp_port;
426    use tokio::spawn;
427    use versions::{LARGE_BLOCK_VERSION, NEW_PROTOCOL_VERSION};
428
429    use super::*;
430    use crate::{
431        SequencerApiVersion,
432        api::options::Http,
433        genesis::{L1Finalized, StakeTableConfig},
434        persistence::fs,
435    };
436
437    #[test_log::test(tokio::test(flavor = "multi_thread"))]
438    async fn test_startup_before_orchestrator() {
439        let (pub_key, priv_key) = PubKey::generated_from_seed_indexed([0; 32], 0);
440        let state_key = StateKeyPair::generate_from_seed_indexed([0; 32], 0);
441        let x25519_kp = x25519::Keypair::generate().unwrap();
442
443        let port1 = reserve_tcp_port().expect("OS should have ephemeral ports available");
444        let port2 = reserve_tcp_port().expect("OS should have ephemeral ports available");
445        let tmp = TempDir::new().unwrap();
446
447        let genesis_file = tmp.path().join("genesis.toml");
448        let genesis = Genesis {
449            chain_config: Default::default(),
450            stake_table: StakeTableConfig { capacity: 10 },
451            accounts: Default::default(),
452            l1_finalized: L1Finalized::Number { number: 0 },
453            header: Default::default(),
454            upgrades: [(
455                LARGE_BLOCK_VERSION,
456                Upgrade {
457                    mode: UpgradeMode::View(ViewBasedUpgrade {
458                        start_proposing_view: 100,
459                        stop_proposing_view: 200,
460                        start_voting_view: None,
461                        stop_voting_view: None,
462                    }),
463                    upgrade_type: UpgradeType::LargeBlock {
464                        chain_config: Default::default(),
465                    },
466                },
467            )]
468            .into_iter()
469            .collect(),
470            base_version: NEW_PROTOCOL_VERSION,
471            upgrade_version: LARGE_BLOCK_VERSION,
472            epoch_height: Some(10),
473            drb_difficulty: None,
474            drb_upgrade_difficulty: None,
475            epoch_start_block: None,
476            stake_table_capacity: None,
477            genesis_version: NEW_PROTOCOL_VERSION,
478            da_committees: None,
479        };
480        genesis.to_file(&genesis_file).unwrap();
481
482        let modules = Modules {
483            http: Some(Http::with_port(port1)),
484            query: Some(Default::default()),
485            storage_fs: Some(fs::Options::new(tmp.path().into())),
486            config: Some(Default::default()),
487            ..Default::default()
488        };
489        let opt = Options::parse_from([
490            "sequencer",
491            "--private-staking-key",
492            &priv_key.to_tagged_base64().expect("valid key").to_string(),
493            "--private-state-key",
494            &state_key
495                .sign_key_ref()
496                .to_tagged_base64()
497                .expect("valid key")
498                .to_string(),
499            "--private-x25519-key",
500            &TaggedBase64::try_from(x25519_kp.secret_key())
501                .expect("valid key")
502                .to_string(),
503            "--cliquenet-bind-address",
504            &format!("127.0.0.1:{port2}"),
505            "--cliquenet-advertise-address",
506            &format!("127.0.0.1:{port2}"),
507            "--genesis-file",
508            &genesis_file.display().to_string(),
509        ]);
510
511        // Start the sequencer in a background task. This process will not complete, because it will
512        // be waiting for the orchestrator, but it should at least start up the API server and
513        // populate some metrics.
514        tracing::info!(port = %port1, "starting sequencer");
515        let public_node_config = PublicNodeConfig::new(&opt, &modules, &genesis);
516        let task = spawn(async move {
517            if let Err(err) = init_with_storage(
518                genesis,
519                modules,
520                opt,
521                fs::Options::new(tmp.path().into()),
522                public_node_config,
523            )
524            .await
525            {
526                tracing::error!("failed to start sequencer: {err:#}");
527            }
528        });
529
530        // The healthcheck should eventually come up even though the node is waiting for the
531        // orchestrator.
532        tracing::info!("waiting for API to start");
533        let url: Url = format!("http://localhost:{port1}").parse().unwrap();
534        let client = Client::<ClientErr, SequencerApiVersion>::new(url.clone());
535        assert!(client.connect(Some(Duration::from_secs(60))).await);
536        client.get::<()>("healthcheck").send().await.unwrap();
537
538        // The metrics should include information about the node and software version. The
539        // client doesn't support fetching a plaintext file, so we use a raw reqwest client.
540        let res = reqwest::get(
541            url.join(&espresso_api::routes::v1::status_metrics())
542                .unwrap(),
543        )
544        .await
545        .unwrap();
546        assert!(res.status().is_success(), "{}", res.status());
547        let metrics = res.text().await.unwrap();
548        let lines = metrics.lines().collect::<Vec<_>>();
549        assert!(
550            lines.contains(&format!("consensus_node{{key=\"{pub_key}\"}} 1").as_str()),
551            "{lines:#?}"
552        );
553        assert!(
554            lines.contains(
555                &format!(
556                    "consensus_version{{desc=\"{}\",rev=\"{}\",timestamp=\"{}\"}} 1",
557                    espresso_utils::build_info::GIT_DESCRIBE,
558                    espresso_utils::build_info::GIT_SHA,
559                    espresso_utils::build_info::GIT_COMMIT_TIMESTAMP,
560                )
561                .as_str()
562            ),
563            "{lines:#?}"
564        );
565        let build_info_line = lines
566            .iter()
567            .find(|l| l.starts_with("consensus_build_info{"));
568        assert!(
569            build_info_line.is_some(),
570            "missing consensus_build_info metric: {lines:#?}"
571        );
572        let build_info_line = build_info_line.unwrap();
573        assert!(
574            build_info_line.contains("modified="),
575            "expected modified= in build_info: {lines:#?}"
576        );
577        assert!(
578            build_info_line.contains("features="),
579            "expected features= in build_info: {lines:#?}"
580        );
581        assert!(
582            build_info_line.contains("testing"),
583            "expected testing in features: {lines:#?}"
584        );
585        let genesis_line = concat!(
586            r#"consensus_genesis{base_version="0.6",genesis_version="0.6","#,
587            r#"upgrade_version="0.7"} 1"#
588        );
589        assert!(
590            lines.contains(&genesis_line),
591            "missing consensus_genesis metric: {lines:#?}"
592        );
593        assert!(
594            lines.contains(&r#"consensus_genesis_upgrade{version="0.7"} 1"#),
595            "missing consensus_genesis_upgrade metric: {lines:#?}"
596        );
597
598        // The /config/runtime endpoint should be available and reflect CLI overrides. Use a raw
599        // reqwest client to fetch JSON, since our client defaults to VBS encoding which can't
600        // round-trip arbitrary JSON via `serde_json::Value`.
601        let res = reqwest::Client::new()
602            .get(
603                url.join(&espresso_api::routes::v1::config_runtime())
604                    .unwrap(),
605            )
606            .header(reqwest::header::ACCEPT, "application/json")
607            .send()
608            .await
609            .unwrap();
610        assert!(
611            res.status().is_success(),
612            "config/runtime status: {}",
613            res.status()
614        );
615        let node_cfg: serde_json::Value = res.json().await.expect("config/runtime returns JSON");
616        assert_eq!(
617            node_cfg["cliquenet_bind_address"],
618            serde_json::Value::String(format!("127.0.0.1:{port2}")),
619            "cliquenet_bind_address mismatch: {node_cfg}"
620        );
621        assert_eq!(
622            node_cfg["is_da"],
623            serde_json::Value::Bool(false),
624            "is_da mismatch: {node_cfg}"
625        );
626        let top_level = node_cfg
627            .as_object()
628            .expect("config/runtime returns a JSON object");
629        for key in top_level.keys() {
630            assert!(
631                !key.to_lowercase().contains("private"),
632                "top-level key '{key}' contains 'private': {node_cfg}"
633            );
634        }
635        assert_eq!(
636            node_cfg["genesis"]["base_version"],
637            serde_json::Value::String("0.6".into()),
638            "genesis.base_version mismatch: {node_cfg}"
639        );
640        assert_eq!(
641            node_cfg["genesis"]["upgrade"][0]["version"],
642            serde_json::Value::String("0.7".into()),
643            "genesis.upgrade[0].version mismatch: {node_cfg}"
644        );
645        assert_eq!(
646            node_cfg["genesis"]["upgrade"][0]["start_proposing_view"],
647            serde_json::Value::Number(100.into()),
648            "genesis.upgrade[0].start_proposing_view mismatch: {node_cfg}"
649        );
650
651        task.abort();
652    }
653}