Skip to main content

espresso_node/
run.rs

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