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 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 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 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 (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 _ => (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 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 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 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
341async 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 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 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 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 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}