Skip to main content

espresso_node/
run.rs

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