Skip to main content

espresso_node/
options.rs

1#![allow(clippy::needless_lifetimes)]
2
3use core::fmt::Display;
4use std::{
5    cmp::Ordering,
6    collections::HashSet,
7    fmt::{self, Formatter},
8    iter::once,
9    path::PathBuf,
10    time::Duration,
11};
12
13use clap::{Args, FromArgMatches, Parser, error::ErrorKind};
14use derivative::Derivative;
15use espresso_telemetry::TelemetryOptions;
16use espresso_types::{BackoffParams, L1ClientOptions, parse_duration};
17use espresso_utils::logging;
18use hotshot_types::addr::NetAddr;
19use libp2p::Multiaddr;
20use light_client::{state::LightClientOptions, storage::LightClientSqliteOptions};
21use serde::Serialize;
22use url::Url;
23
24use crate::{
25    api,
26    genesis::{Genesis, GenesisSource},
27    keyset::KeySetOptions,
28    persistence,
29    proposal_fetcher::ProposalFetcherConfig,
30};
31
32// This options struct is a bit unconventional. The sequencer has multiple optional modules which
33// can be added, in any combination, to the service. These include, for example, the API server.
34// Each of these modules has its own options, which are all required if the module is added but can
35// be omitted otherwise. Clap doesn't have a good way to handle "grouped" arguments like this (they
36// have something called an argument group, but it's different). Sub-commands do exactly this, but
37// you can't have multiple sub-commands in a single command.
38//
39// What we do, then, is take the optional modules as if they were sub-commands, but we use a Clap
40// `raw` argument to collect all the module commands and their options into a single string. This
41// string is then parsed manually (using a secondary Clap `Parser`, the `SequencerModule` type) when
42// the user calls `modules()`.
43//
44// One slightly unfortunate consequence of this is that the auto-generated help documentation for
45// `SequencerModule` is not included in the help for this top-level type. Users can still get at the
46// help for individual modules by passing `help` as a subcommand, as in
47// `sequencer [options] -- help` or `sequencer [options] -- help <module>`. This means that IT IS
48// BEST NOT TO ADD REQUIRED ARGUMENTS TO THIS TYPE, since the required arguments will be required
49// even if the user is only asking for help on a module. Try to give every argument on this type a
50// default value, even if it is a bit arbitrary.
51#[derive(Parser, Clone, Derivative)]
52#[derivative(Debug(bound = ""))]
53#[command(version = build_version())]
54pub struct Options {
55    /// URL of the HotShot orchestrator.
56    #[clap(
57        short,
58        long,
59        env = "ESPRESSO_NODE_ORCHESTRATOR_URL",
60        default_value = "http://localhost:8080"
61    )]
62    #[derivative(Debug(format_with = "Display::fmt"))]
63    pub orchestrator_url: Url,
64
65    /// The socket address of the HotShot CDN's main entry point (the marshal)
66    /// in `IP:port` form
67    #[clap(
68        short,
69        long,
70        env = "ESPRESSO_NODE_CDN_ENDPOINT",
71        default_value = "127.0.0.1:8081"
72    )]
73    pub cdn_endpoint: String,
74
75    /// The address to bind to for cliquenet (in `host:port` | `ip:port` form)
76    #[clap(
77        long,
78        env = "ESPRESSO_NODE_CLIQUENET_BIND_ADDRESS",
79        default_value = "0.0.0.0:9977",
80        value_parser = parse_bind_addr
81    )]
82    pub cliquenet_bind_address: NetAddr,
83
84    /// The address to advertise to other nodes for cliquenet (in `host:port` | `ip:port` form).
85    ///
86    /// Only used for orchestrator-based setup (test networks). On real networks the address
87    /// must be registered in the stake table contract instead.
88    #[clap(
89        long,
90        env = "ESPRESSO_NODE_CLIQUENET_ADVERTISE_ADDRESS",
91        value_parser = parse_advertise_addr
92    )]
93    pub cliquenet_advertise_address: Option<NetAddr>,
94
95    /// The address to bind to for Libp2p (in `host:port` form)
96    #[clap(
97        long,
98        env = "ESPRESSO_NODE_LIBP2P_BIND_ADDRESS",
99        default_value = "0.0.0.0:1769"
100    )]
101    pub libp2p_bind_address: String,
102
103    /// Time between each Libp2p heartbeat
104    #[clap(long, env = "ESPRESSO_NODE_LIBP2P_HEARTBEAT_INTERVAL", default_value = "1s", value_parser = parse_duration)]
105    pub libp2p_heartbeat_interval: Duration,
106
107    /// Number of past heartbeats to gossip about on Libp2p
108    #[clap(long, env = "ESPRESSO_NODE_LIBP2P_HISTORY_GOSSIP", default_value = "3")]
109    pub libp2p_history_gossip: usize,
110
111    /// Number of heartbeats to keep in the Libp2p `memcache`
112    #[clap(long, env = "ESPRESSO_NODE_LIBP2P_HISTORY_LENGTH", default_value = "5")]
113    pub libp2p_history_length: usize,
114
115    /// Target number of peers for the Libp2p mesh network
116    #[clap(long, env = "ESPRESSO_NODE_LIBP2P_MESH_N", default_value = "8")]
117    pub libp2p_mesh_n: usize,
118
119    /// Maximum number of peers in the Libp2p mesh network before removing some
120    #[clap(long, env = "ESPRESSO_NODE_LIBP2P_MESH_N_HIGH", default_value = "12")]
121    pub libp2p_mesh_n_high: usize,
122
123    /// Minimum number of peers in the Libp2p mesh network before adding more
124    #[clap(long, env = "ESPRESSO_NODE_LIBP2P_MESH_N_LOW", default_value = "6")]
125    pub libp2p_mesh_n_low: usize,
126
127    /// Minimum number of outbound Libp2p peers in the mesh network before adding more
128    #[clap(
129        long,
130        env = "ESPRESSO_NODE_LIBP2P_MESH_OUTBOUND_MIN",
131        default_value = "2"
132    )]
133    pub libp2p_mesh_outbound_min: usize,
134
135    /// The maximum number of messages to include in a Libp2p IHAVE message
136    #[clap(
137        long,
138        env = "ESPRESSO_NODE_LIBP2P_MAX_IHAVE_LENGTH",
139        default_value = "5000"
140    )]
141    pub libp2p_max_ihave_length: usize,
142
143    /// The maximum number of IHAVE messages to accept from a Libp2p peer within a heartbeat
144    #[clap(
145        long,
146        env = "ESPRESSO_NODE_LIBP2P_MAX_IHAVE_MESSAGES",
147        default_value = "10"
148    )]
149    pub libp2p_max_ihave_messages: usize,
150
151    /// Libp2p published message ids time cache duration
152    #[clap(long, env = "ESPRESSO_NODE_LIBP2P_PUBLISHED_MESSAGE_IDS_CACHE_TIME", default_value = "10s", value_parser = parse_duration)]
153    pub libp2p_published_message_ids_cache_time: Duration,
154
155    /// Time to wait for a Libp2p message requested through IWANT following an IHAVE advertisement
156    #[clap(
157        long,
158        env = "ESPRESSO_NODE_LIBP2P_MAX_IWANT_FOLLOWUP_TIME",
159        default_value = "3s", value_parser = parse_duration
160    )]
161    pub libp2p_iwant_followup_time: Duration,
162
163    /// The maximum number of Libp2p messages we will process in a given RPC
164    #[clap(long, env = "ESPRESSO_NODE_LIBP2P_MAX_MESSAGES_PER_RPC")]
165    pub libp2p_max_messages_per_rpc: Option<usize>,
166
167    /// How many times we will allow a Libp2p peer to request the same message id through IWANT gossip before we start ignoring them
168    #[clap(
169        long,
170        env = "ESPRESSO_NODE_LIBP2P_GOSSIP_RETRANSMISSION",
171        default_value = "3"
172    )]
173    pub libp2p_gossip_retransmission: u32,
174
175    /// If enabled newly created messages will always be sent to all peers that are subscribed to the topic and have a good enough score
176    #[clap(
177        long,
178        env = "ESPRESSO_NODE_LIBP2P_FLOOD_PUBLISH",
179        default_value = "true"
180    )]
181    pub libp2p_flood_publish: bool,
182
183    /// The time period that Libp2p message hashes are stored in the cache
184    #[clap(long, env = "ESPRESSO_NODE_LIBP2P_DUPLICATE_CACHE_TIME", default_value = "20m", value_parser = parse_duration)]
185    pub libp2p_duplicate_cache_time: Duration,
186
187    /// Time to live for Libp2p fanout peers
188    #[clap(long, env = "ESPRESSO_NODE_LIBP2P_FANOUT_TTL", default_value = "60s", value_parser = parse_duration)]
189    pub libp2p_fanout_ttl: Duration,
190
191    /// Initial delay in each Libp2p heartbeat
192    #[clap(long, env = "ESPRESSO_NODE_LIBP2P_HEARTBEAT_INITIAL_DELAY", default_value = "5s", value_parser = parse_duration)]
193    pub libp2p_heartbeat_initial_delay: Duration,
194
195    /// How many Libp2p peers we will emit gossip to at each heartbeat
196    #[clap(
197        long,
198        env = "ESPRESSO_NODE_LIBP2P_GOSSIP_FACTOR",
199        default_value = "0.25"
200    )]
201    pub libp2p_gossip_factor: f64,
202
203    /// Minimum number of Libp2p peers to emit gossip to during a heartbeat
204    #[clap(long, env = "ESPRESSO_NODE_LIBP2P_GOSSIP_LAZY", default_value = "6")]
205    pub libp2p_gossip_lazy: usize,
206
207    /// The maximum number of bytes we will send in a single Libp2p gossip message
208    #[clap(
209        long,
210        env = "ESPRESSO_NODE_LIBP2P_MAX_GOSSIP_TRANSMIT_SIZE",
211        default_value = "2000000"
212    )]
213    pub libp2p_max_gossip_transmit_size: usize,
214
215    /// The maximum number of bytes we will send in a single Libp2p direct message
216    #[clap(
217        long,
218        env = "ESPRESSO_NODE_LIBP2P_MAX_DIRECT_TRANSMIT_SIZE",
219        default_value = "20000000"
220    )]
221    pub libp2p_max_direct_transmit_size: u64,
222
223    /// Only for the decaf network unmerge migration. Do not set unless specifically requested.
224    #[clap(long, env = "ESPRESSO_NODE_LIBP2P_DHT_PUT_QUORUM")]
225    pub libp2p_dht_put_quorum: Option<std::num::NonZeroUsize>,
226
227    /// The URL we advertise to other nodes as being for our public API.
228    /// Should be supplied in `http://host:port` form.
229    #[clap(long, env = "ESPRESSO_NODE_PUBLIC_API_URL")]
230    pub public_api_url: Option<Url>,
231
232    /// The address we advertise to other nodes as being a Libp2p endpoint.
233    /// Should be supplied in `host:port` form.
234    ///
235    /// Operators should set this to a publicly routable address whenever the bind address
236    /// is not directly reachable from peers (NAT, K8s NodePort, Docker bridge). It is added
237    /// to libp2p as an `external_address` so that Identify and Kademlia announce it to the
238    /// network. Non-globally-routable IP literals (loopback, RFC 1918 private, link-local,
239    /// etc.) only work for local testing (`demo-native`, `docker-compose`) and are dropped
240    /// from the libp2p announcement; hostnames are passed through unchanged.
241    ///
242    /// Also required when bootstrapping a fresh network from the orchestrator, where it is
243    /// registered into the stake table so peers can dial us.
244    #[clap(long, env = "ESPRESSO_NODE_LIBP2P_ADVERTISE_ADDRESS")]
245    pub libp2p_advertise_address: Option<String>,
246
247    /// A comma-separated list of Libp2p multiaddresses to use as bootstrap
248    /// nodes.
249    ///
250    /// Overrides those loaded from the `HotShot` config.
251    #[clap(
252        long,
253        env = "ESPRESSO_NODE_LIBP2P_BOOTSTRAP_NODES",
254        value_delimiter = ',',
255        num_args = 1..
256    )]
257    pub libp2p_bootstrap_nodes: Option<Vec<Multiaddr>>,
258
259    /// The URL of the builders to use for submitting transactions
260    #[clap(long, env = "ESPRESSO_BUILDER_URLS", value_delimiter = ',')]
261    pub builder_urls: Vec<Url>,
262
263    /// URL of the Light Client State Relay Server
264    #[clap(
265        long,
266        env = "ESPRESSO_STATE_RELAY_SERVER_URL",
267        default_value = "http://localhost:8083"
268    )]
269    #[derivative(Debug(format_with = "Display::fmt"))]
270    pub state_relay_server_url: Url,
271
272    /// Location of the TOML file containing genesis state.
273    ///
274    /// Accepts a plain filesystem path or an `http(s)://` URL.
275    #[clap(
276        long,
277        name = "GENESIS_FILE",
278        env = "ESPRESSO_NODE_GENESIS_FILE",
279        default_value = "/genesis/demo.toml"
280    )]
281    pub genesis_file: GenesisSource,
282
283    #[clap(flatten)]
284    pub key_set: KeySetOptions,
285
286    /// Add optional modules to the service.
287    ///
288    /// Modules are added by specifying the name of the module followed by it's arguments, as in
289    ///
290    /// sequencer [options] -- api --port 3000
291    ///
292    /// to run the API module with port 3000.
293    ///
294    /// To see a list of available modules and their arguments, use
295    ///
296    /// sequencer -- help
297    ///
298    /// Multiple modules can be specified, provided they are separated by --
299    #[clap(raw = true)]
300    modules: Vec<String>,
301
302    /// Url we will use for RPC communication with L1.
303    #[clap(
304        long,
305        env = "ESPRESSO_L1_PROVIDER",
306        default_value = "http://localhost:8545",
307        value_delimiter = ',',
308        num_args = 1..,
309    )]
310    #[derivative(Debug = "ignore")]
311    pub l1_provider_url: Vec<Url>,
312
313    /// Configuration for the L1 client.
314    #[clap(flatten)]
315    pub l1_options: L1ClientOptions,
316
317    /// Whether or not we are a DA node.
318    #[clap(long, env = "ESPRESSO_NODE_IS_DA", action)]
319    pub is_da: bool,
320
321    /// Peer nodes use to fetch missing state
322    #[clap(long, env = "ESPRESSO_NODE_STATE_PEERS", value_delimiter = ',')]
323    #[derivative(Debug(format_with = "fmt_urls"))]
324    pub state_peers: Vec<Url>,
325
326    /// Peer nodes use to fetch missing config
327    ///
328    /// Typically, the network-wide config is fetched from the orchestrator on startup and then
329    /// persisted and loaded from local storage each time the node restarts. However, if the
330    /// persisted config is missing when the node restarts (for example, the node is being migrated
331    /// to new persistent storage), it can instead be fetched directly from a peer.
332    #[clap(long, env = "ESPRESSO_NODE_CONFIG_PEERS", value_delimiter = ',')]
333    #[derivative(Debug(format_with = "fmt_opt_urls"))]
334    pub config_peers: Option<Vec<Url>>,
335
336    /// Exponential backoff for fetching missing state from peers.
337    #[clap(flatten)]
338    pub catchup_backoff: BackoffParams,
339
340    /// Base timeout for catchup requests to peers.
341    ///
342    /// This is the initial per peer timeout for HTTP requests during state catchup
343    #[clap(long, env = "ESPRESSO_NODE_CATCHUP_BASE_TIMEOUT", default_value = "2s", value_parser = parse_duration)]
344    pub catchup_base_timeout: Duration,
345
346    /// Timeout for local catchup provider requests.
347    ///
348    /// If a local provider (e.g. database) takes longer than this, the node falls back to
349    /// remote providers so it can still vote within the current view.
350    #[clap(long, env = "ESPRESSO_NODE_LOCAL_CATCHUP_TIMEOUT", default_value = "5s", value_parser = parse_duration)]
351    pub local_catchup_timeout: Duration,
352
353    /// Per-step timeout for the startup stake-table catchup walk.
354    ///
355    /// Bounds a single `wait_for_stake_table` call during `bootstrap_epoch_window`
356    /// (the underlying `fetch_leaf` retries forever); a step that exceeds this
357    /// terminates the walk
358    #[clap(long, env = "ESPRESSO_NODE_BOOTSTRAP_EPOCH_CATCHUP_TIMEOUT", default_value = "30s", value_parser = parse_duration)]
359    pub bootstrap_epoch_catchup_timeout: Duration,
360
361    #[clap(flatten)]
362    pub logging: logging::Config,
363
364    #[clap(flatten)]
365    pub identity: Identity,
366
367    #[clap(flatten)]
368    pub telemetry: TelemetryOptions,
369
370    #[clap(flatten)]
371    pub proposal_fetcher_config: ProposalFetcherConfig,
372}
373
374impl Options {
375    pub fn modules(&self) -> Modules {
376        ModuleArgs(self.modules.clone()).parse()
377    }
378}
379
380/// Parse an address to bind to, and check that it is well-formed, so that a mistyped
381/// host fails at startup rather than when the first peer cannot reach us.
382fn parse_bind_addr(s: &str) -> Result<NetAddr, String> {
383    let addr = s.parse::<NetAddr>().map_err(|e| e.to_string())?;
384    addr.validate().map_err(|e| e.to_string())?;
385    Ok(addr)
386}
387
388/// As [`parse_bind_addr`], and the port has to be one a peer can connect to. A listener
389/// may ask for an ephemeral port, an advertised address cannot: an address written
390/// without a port is port 0.
391fn parse_advertise_addr(s: &str) -> Result<NetAddr, String> {
392    let addr = parse_bind_addr(s)?;
393    if addr.port() == 0 {
394        return Err("port 0 is not a port a peer can connect to".to_string());
395    }
396    Ok(addr)
397}
398
399/// Identity represents identifying information concerning the sequencer node.
400/// This information is used to populate relevant information in the metrics
401/// endpoint.  This information will also potentially be scraped and displayed
402/// in a public facing dashboard.
403#[derive(Parser, Clone, Derivative, Serialize)]
404#[derivative(Debug(bound = ""))]
405pub struct Identity {
406    #[clap(long, env = "ESPRESSO_NODE_IDENTITY_COUNTRY_CODE")]
407    pub country_code: Option<String>,
408    #[clap(long, env = "ESPRESSO_NODE_IDENTITY_LATITUDE")]
409    pub latitude: Option<f64>,
410    #[clap(long, env = "ESPRESSO_NODE_IDENTITY_LONGITUDE")]
411    pub longitude: Option<f64>,
412
413    #[clap(long, env = "ESPRESSO_NODE_IDENTITY_NODE_NAME")]
414    pub node_name: Option<String>,
415    #[clap(long, env = "ESPRESSO_NODE_IDENTITY_NODE_DESCRIPTION")]
416    pub node_description: Option<String>,
417
418    #[clap(long, env = "ESPRESSO_NODE_IDENTITY_COMPANY_NAME")]
419    pub company_name: Option<String>,
420    #[clap(long, env = "ESPRESSO_NODE_IDENTITY_COMPANY_WEBSITE")]
421    pub company_website: Option<Url>,
422    #[clap(long, env = "ESPRESSO_NODE_IDENTITY_OPERATING_SYSTEM", default_value = std::env::consts::OS)]
423    pub operating_system: Option<String>,
424    #[clap(long, env = "ESPRESSO_NODE_IDENTITY_NODE_TYPE", default_value = get_default_node_type())]
425    pub node_type: Option<String>,
426    #[clap(long, env = "ESPRESSO_NODE_IDENTITY_NETWORK_TYPE")]
427    pub network_type: Option<String>,
428
429    #[clap(long, env = "ESPRESSO_NODE_IDENTITY_ICON_14x14_1x")]
430    pub icon_14x14_1x: Option<Url>,
431    #[clap(long, env = "ESPRESSO_NODE_IDENTITY_ICON_14x14_2x")]
432    pub icon_14x14_2x: Option<Url>,
433    #[clap(long, env = "ESPRESSO_NODE_IDENTITY_ICON_14x14_3x")]
434    pub icon_14x14_3x: Option<Url>,
435    #[clap(long, env = "ESPRESSO_NODE_IDENTITY_ICON_24x24_1x")]
436    pub icon_24x24_1x: Option<Url>,
437    #[clap(long, env = "ESPRESSO_NODE_IDENTITY_ICON_24x24_2x")]
438    pub icon_24x24_2x: Option<Url>,
439    #[clap(long, env = "ESPRESSO_NODE_IDENTITY_ICON_24x24_3x")]
440    pub icon_24x24_3x: Option<Url>,
441}
442
443/// get_default_node_type returns the current public facing binary name and
444/// version of this program.
445fn get_default_node_type() -> String {
446    format!("espresso-sequencer {}", env!("CARGO_PKG_VERSION"))
447}
448
449fn build_version() -> String {
450    let info = espresso_utils::build_info!();
451    format!(
452        "{}\nfeatures: {}",
453        info.clap_version(),
454        env!("VERGEN_CARGO_FEATURES"),
455    )
456}
457
458// The Debug implementation for Url is noisy, we just want to see the URL
459fn fmt_urls(v: &[Url], fmt: &mut std::fmt::Formatter) -> Result<(), std::fmt::Error> {
460    write!(
461        fmt,
462        "{:?}",
463        v.iter().map(|i| i.to_string()).collect::<Vec<_>>()
464    )
465}
466
467fn fmt_opt_urls(
468    v: &Option<Vec<Url>>,
469    fmt: &mut std::fmt::Formatter,
470) -> Result<(), std::fmt::Error> {
471    match v {
472        Some(urls) => {
473            write!(fmt, "Some(")?;
474            fmt_urls(urls, fmt)?;
475            write!(fmt, ")")?;
476        },
477        None => {
478            write!(fmt, "None")?;
479        },
480    }
481    Ok(())
482}
483
484#[derive(Clone, Copy, Debug, PartialEq, Eq)]
485pub struct Ratio {
486    pub numerator: u64,
487    pub denominator: u64,
488}
489
490impl From<Ratio> for (u64, u64) {
491    fn from(r: Ratio) -> Self {
492        (r.numerator, r.denominator)
493    }
494}
495
496impl Display for Ratio {
497    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
498        write!(f, "{}:{}", self.numerator, self.denominator)
499    }
500}
501
502impl PartialOrd for Ratio {
503    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
504        Some(self.cmp(other))
505    }
506}
507
508impl Ord for Ratio {
509    fn cmp(&self, other: &Self) -> Ordering {
510        (self.numerator * other.denominator).cmp(&(other.numerator * self.denominator))
511    }
512}
513
514#[derive(Clone, Debug)]
515struct ModuleArgs(Vec<String>);
516
517impl ModuleArgs {
518    fn parse(&self) -> Modules {
519        match self.try_parse() {
520            Ok(modules) => modules,
521            Err(err) => err.exit(),
522        }
523    }
524
525    fn try_parse(&self) -> Result<Modules, clap::Error> {
526        let mut modules = Modules::default();
527        let mut curr = self.0.clone();
528        let mut provided = Default::default();
529
530        while !curr.is_empty() {
531            // The first argument (the program name) is used only for help generation. We include a
532            // `--` so that the generated usage will look like `sequencer -- <command>` which is the
533            // way these commands must be invoked due to the use of `raw` arguments.
534            let module = SequencerModule::try_parse_from(
535                once("sequencer --").chain(curr.iter().map(|s| s.as_str())),
536            )?;
537            match module {
538                SequencerModule::Storage(m) => {
539                    curr = m.add(&mut modules.storage_fs, &mut provided)?
540                },
541                SequencerModule::StorageFs(m) => {
542                    curr = m.add(&mut modules.storage_fs, &mut provided)?
543                },
544                SequencerModule::StorageSql(m) => {
545                    curr = m.add(&mut modules.storage_sql, &mut provided)?
546                },
547                SequencerModule::Http(m) => curr = m.add(&mut modules.http, &mut provided)?,
548                SequencerModule::Query(m) => curr = m.add(&mut modules.query, &mut provided)?,
549                SequencerModule::Submit(m) => curr = m.add(&mut modules.submit, &mut provided)?,
550                SequencerModule::Status(m) => curr = m.add(&mut modules.status, &mut provided)?,
551                SequencerModule::Catchup(m) => curr = m.add(&mut modules.catchup, &mut provided)?,
552                SequencerModule::Config(m) => curr = m.add(&mut modules.config, &mut provided)?,
553                SequencerModule::HotshotEvents(m) => {
554                    curr = m.add(&mut modules.hotshot_events, &mut provided)?
555                },
556                SequencerModule::Explorer(m) => {
557                    curr = m.add(&mut modules.explorer, &mut provided)?
558                },
559                SequencerModule::LightClient(m) => {
560                    curr = m.add(&mut modules.light_client, &mut provided)?
561                },
562            }
563        }
564
565        Ok(modules)
566    }
567}
568
569trait ModuleInfo: Args + FromArgMatches {
570    const NAME: &'static str;
571    fn requires() -> Vec<&'static str>;
572}
573
574macro_rules! module {
575    ($name:expr, $opt:ty $(,requires: $($req:expr),*)?) => {
576        impl ModuleInfo for $opt {
577            const NAME: &'static str = $name;
578
579            fn requires() -> Vec<&'static str> {
580                vec![$($($req),*)?]
581            }
582        }
583    };
584}
585
586module!("storage-fs", persistence::fs::Options);
587module!("storage-sql", persistence::sql::Options);
588module!("http", api::options::Http);
589module!("query", api::options::Query, requires: "http");
590module!("submit", api::options::Submit, requires: "http");
591module!("status", api::options::Status, requires: "http");
592module!("catchup", api::options::Catchup, requires: "http");
593module!("config", api::options::Config, requires: "http");
594module!("hotshot-events", api::options::HotshotEvents, requires: "http");
595module!("explorer", api::options::Explorer, requires: "http", "storage-sql");
596module!("light-client", api::options::LightClient, requires: "http", "storage-sql");
597
598#[derive(Clone, Debug, Args)]
599struct Module<Options: ModuleInfo> {
600    #[clap(flatten)]
601    options: Box<Options>,
602
603    /// Add more optional modules.
604    #[clap(raw = true)]
605    modules: Vec<String>,
606}
607
608impl<Options: ModuleInfo> Module<Options> {
609    /// Add this as an optional module. Return the next optional module args.
610    fn add(
611        self,
612        options: &mut Option<Options>,
613        provided: &mut HashSet<&'static str>,
614    ) -> Result<Vec<String>, clap::Error> {
615        if options.is_some() {
616            return Err(clap::Error::raw(
617                ErrorKind::TooManyValues,
618                format!("optional module {} can only be started once", Options::NAME),
619            ));
620        }
621        for req in Options::requires() {
622            if !provided.contains(&req) {
623                return Err(clap::Error::raw(
624                    ErrorKind::MissingRequiredArgument,
625                    format!("module {} is missing required module {req}", Options::NAME),
626                ));
627            }
628        }
629        *options = Some(*self.options);
630        provided.insert(Options::NAME);
631        Ok(self.modules)
632    }
633}
634
635#[derive(Clone, Debug, Parser)]
636enum SequencerModule {
637    /// Run an HTTP server.
638    ///
639    /// The basic HTTP server comes with healthcheck and version endpoints. Add additional endpoints
640    /// by enabling additional modules:
641    /// * query: add query service endpoints
642    /// * submit: add transaction submission endpoints
643    Http(Module<api::options::Http>),
644    /// Alias for storage-fs.
645    Storage(Module<persistence::fs::Options>),
646    /// Use the file system for persistent storage.
647    StorageFs(Module<persistence::fs::Options>),
648    /// Use a Postgres database for persistent storage.
649    StorageSql(Module<persistence::sql::Options>),
650    /// Run the query API module.
651    ///
652    /// This module requires the http module to be started.
653    Query(Module<api::options::Query>),
654    /// Run the transaction submission API module.
655    ///
656    /// This module requires the http module to be started.
657    Submit(Module<api::options::Submit>),
658    /// Run the status API module.
659    ///
660    /// This module requires the http module to be started.
661    Status(Module<api::options::Status>),
662    /// Run the state catchup API module.
663    ///
664    /// This module requires the http module to be started.
665    Catchup(Module<api::options::Catchup>),
666    /// Run the config API module.
667    Config(Module<api::options::Config>),
668
669    /// Run the hotshot events API module.
670    ///
671    /// This module requires the http module to be started.
672    HotshotEvents(Module<api::options::HotshotEvents>),
673    /// Run the explorer API module.
674    ///
675    /// This module requires the http and storage-sql modules to be started.
676    Explorer(Module<api::options::Explorer>),
677    /// Run the light client API module.
678    ///
679    /// This module provides data and proofs necessary for an untrusting light client to retrieve
680    /// and verify Espresso data from this server.
681    ///
682    /// This module requires the http and storage-sql modules to be started.
683    LightClient(Module<api::options::LightClient>),
684}
685
686#[derive(Clone, Debug, Default)]
687pub struct Modules {
688    pub storage_fs: Option<persistence::fs::Options>,
689    pub storage_sql: Option<persistence::sql::Options>,
690    pub http: Option<api::options::Http>,
691    pub query: Option<api::options::Query>,
692    pub submit: Option<api::options::Submit>,
693    pub status: Option<api::options::Status>,
694    pub catchup: Option<api::options::Catchup>,
695    pub config: Option<api::options::Config>,
696    pub hotshot_events: Option<api::options::HotshotEvents>,
697    pub explorer: Option<api::options::Explorer>,
698    pub light_client: Option<api::options::LightClient>,
699}
700
701#[derive(Clone, Debug, Serialize)]
702pub struct PublicNodeConfig {
703    pub orchestrator_url: Url,
704    pub cdn_endpoint: String,
705    pub cliquenet_bind_address: NetAddr,
706    pub cliquenet_advertise_address: Option<NetAddr>,
707    pub libp2p_bind_address: String,
708    pub libp2p_advertise_address: Option<String>,
709    pub libp2p_bootstrap_nodes: Option<Vec<Multiaddr>>,
710    pub public_api_url: Option<Url>,
711    pub builder_urls: Vec<Url>,
712    pub state_relay_server_url: Url,
713    pub state_peers: Vec<Url>,
714    pub config_peers: Option<Vec<Url>>,
715    pub is_da: bool,
716    pub genesis_file: GenesisSource,
717    pub genesis: Genesis,
718    pub identity: Identity,
719    pub catchup_base_timeout: Duration,
720    pub local_catchup_timeout: Duration,
721    pub bootstrap_epoch_catchup_timeout: Duration,
722    pub catchup_backoff: BackoffParams,
723    pub proposal_fetcher: ProposalFetcherConfig,
724    pub libp2p: Libp2pTuning,
725    pub l1: L1Tuning,
726    pub l1_provider_count: usize,
727    pub l1_ws_provider_count: usize,
728    pub storage: StorageConfig,
729    pub modules: ApiModulesConfig,
730}
731
732#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
733#[serde(rename_all = "kebab-case")]
734pub enum StorageBackend {
735    Sql,
736    Fs,
737    FsDefault,
738}
739
740#[derive(Clone, Debug, Serialize)]
741pub struct StorageConfig {
742    /// Active backend.
743    pub backend: StorageBackend,
744    pub fs: Option<FsStorageConfig>,
745    pub sql: Option<SqlStorageConfig>,
746}
747
748#[derive(Clone, Debug, Serialize)]
749pub struct FsStorageConfig {
750    pub path: PathBuf,
751    pub consensus_view_retention: u64,
752}
753
754#[derive(Clone, Debug, Serialize)]
755pub struct SqlStorageConfig {
756    pub prune: bool,
757    pub archive: bool,
758    pub lightweight: bool,
759    pub disable_proactive_fetching: bool,
760    pub fetch_rate_limit: Option<usize>,
761    pub active_fetch_delay: Option<Duration>,
762    pub chunk_fetch_delay: Option<Duration>,
763    pub sync_status_chunk_size: Option<usize>,
764    pub sync_status_ttl: Option<Duration>,
765    pub proactive_scan_chunk_size: Option<usize>,
766    pub proactive_scan_interval: Option<Duration>,
767    pub idle_connection_timeout: Duration,
768    pub connection_timeout: Duration,
769    pub slow_statement_threshold: Duration,
770    pub statement_timeout: Duration,
771    pub min_connections: u32,
772    pub max_connections: u32,
773    pub query_min_connections: Option<u32>,
774    pub query_max_connections: Option<u32>,
775    pub pruning: PruningView,
776    pub consensus_pruning: ConsensusPruningView,
777}
778
779#[derive(Clone, Debug, Serialize)]
780pub struct PruningView {
781    pub pruning_threshold: Option<u64>,
782    pub minimum_retention: Option<Duration>,
783    pub target_retention: Option<Duration>,
784    pub batch_size: Option<u64>,
785    pub max_usage: Option<u16>,
786    pub interval: Option<Duration>,
787    pub pages: Option<u64>,
788}
789
790#[derive(Clone, Debug, Serialize)]
791pub struct ConsensusPruningView {
792    pub target_retention: u64,
793    pub minimum_retention: u64,
794    pub target_usage: u64,
795}
796
797#[derive(Clone, Debug, Serialize)]
798pub struct ApiModulesConfig {
799    pub http: Option<HttpConfig>,
800    pub query: Option<QueryConfig>,
801    pub submit: bool,
802    pub status: bool,
803    pub catchup: bool,
804    pub config: bool,
805    pub hotshot_events: bool,
806    pub explorer: bool,
807    pub light_client: bool,
808}
809
810#[derive(Clone, Debug, Serialize)]
811pub struct HttpConfig {
812    pub port: u16,
813    pub max_connections: Option<usize>,
814    pub tonic_port: Option<u16>,
815}
816
817#[derive(Clone, Debug, Serialize)]
818pub struct QueryConfig {
819    pub peers: Vec<Url>,
820    pub light_client: LightClientOptions,
821    pub light_client_db: LightClientSqliteOptions,
822}
823
824impl From<&persistence::sql::PruningOptions> for PruningView {
825    fn from(o: &persistence::sql::PruningOptions) -> Self {
826        Self {
827            pruning_threshold: o.pruning_threshold,
828            minimum_retention: o.minimum_retention,
829            target_retention: o.target_retention,
830            batch_size: o.batch_size,
831            max_usage: o.max_usage,
832            interval: o.interval,
833            pages: o.pages,
834        }
835    }
836}
837
838impl From<&persistence::sql::ConsensusPruningOptions> for ConsensusPruningView {
839    fn from(o: &persistence::sql::ConsensusPruningOptions) -> Self {
840        Self {
841            target_retention: o.target_retention,
842            minimum_retention: o.minimum_retention,
843            target_usage: o.target_usage,
844        }
845    }
846}
847
848impl From<&persistence::sql::Options> for SqlStorageConfig {
849    fn from(o: &persistence::sql::Options) -> Self {
850        Self {
851            prune: o.prune,
852            archive: o.archive,
853            lightweight: o.lightweight,
854            disable_proactive_fetching: o.disable_proactive_fetching,
855            fetch_rate_limit: o.fetch_rate_limit,
856            active_fetch_delay: o.active_fetch_delay,
857            chunk_fetch_delay: o.chunk_fetch_delay,
858            sync_status_chunk_size: o.sync_status_chunk_size,
859            sync_status_ttl: o.sync_status_ttl,
860            proactive_scan_chunk_size: o.proactive_scan_chunk_size,
861            proactive_scan_interval: o.proactive_scan_interval,
862            idle_connection_timeout: o.idle_connection_timeout,
863            connection_timeout: o.connection_timeout,
864            slow_statement_threshold: o.slow_statement_threshold,
865            statement_timeout: o.statement_timeout,
866            min_connections: o.min_connections,
867            max_connections: o.max_connections,
868            #[cfg(not(feature = "embedded-db"))]
869            query_min_connections: o.query_min_connections,
870            #[cfg(feature = "embedded-db")]
871            query_min_connections: None,
872            #[cfg(not(feature = "embedded-db"))]
873            query_max_connections: o.query_max_connections,
874            #[cfg(feature = "embedded-db")]
875            query_max_connections: None,
876            pruning: PruningView::from(&o.pruning),
877            consensus_pruning: ConsensusPruningView::from(&o.consensus_pruning),
878        }
879    }
880}
881
882impl From<&persistence::fs::Options> for FsStorageConfig {
883    fn from(o: &persistence::fs::Options) -> Self {
884        Self {
885            path: o.path.clone(),
886            consensus_view_retention: o.consensus_view_retention,
887        }
888    }
889}
890
891impl From<&api::options::Http> for HttpConfig {
892    fn from(o: &api::options::Http) -> Self {
893        Self {
894            port: o.port,
895            max_connections: o.max_connections,
896            tonic_port: o.tonic_port,
897        }
898    }
899}
900
901impl From<&api::options::Query> for QueryConfig {
902    fn from(o: &api::options::Query) -> Self {
903        Self {
904            peers: o.peers.clone(),
905            light_client: o.light_client.clone(),
906            light_client_db: o.light_client_db.clone(),
907        }
908    }
909}
910
911impl From<&Modules> for ApiModulesConfig {
912    fn from(m: &Modules) -> Self {
913        Self {
914            http: m.http.as_ref().map(HttpConfig::from),
915            query: m.query.as_ref().map(QueryConfig::from),
916            submit: m.submit.is_some(),
917            status: m.status.is_some(),
918            catchup: m.catchup.is_some(),
919            config: m.config.is_some(),
920            hotshot_events: m.hotshot_events.is_some(),
921            explorer: m.explorer.is_some(),
922            light_client: m.light_client.is_some(),
923        }
924    }
925}
926
927#[derive(Clone, Debug, Serialize)]
928pub struct Libp2pTuning {
929    pub heartbeat_interval: Duration,
930    pub heartbeat_initial_delay: Duration,
931    pub history_gossip: usize,
932    pub history_length: usize,
933    pub mesh_n: usize,
934    pub mesh_n_high: usize,
935    pub mesh_n_low: usize,
936    pub mesh_outbound_min: usize,
937    pub max_ihave_length: usize,
938    pub max_ihave_messages: usize,
939    pub published_message_ids_cache_time: Duration,
940    pub iwant_followup_time: Duration,
941    pub max_messages_per_rpc: Option<usize>,
942    pub gossip_retransmission: u32,
943    pub gossip_factor: f64,
944    pub gossip_lazy: usize,
945    pub max_gossip_transmit_size: usize,
946    pub max_direct_transmit_size: u64,
947    pub fanout_ttl: Duration,
948    pub duplicate_cache_time: Duration,
949    pub flood_publish: bool,
950}
951
952#[derive(Clone, Debug, Serialize)]
953pub struct L1Tuning {
954    pub retry_delay: Duration,
955    pub polling_interval: Duration,
956    pub blocks_cache_size: usize,
957    pub events_channel_capacity: usize,
958    pub events_max_block_range: u64,
959    pub subscription_timeout: Duration,
960    pub frequent_failure_tolerance: Duration,
961    pub consecutive_failure_tolerance: usize,
962    pub failover_revert: Duration,
963    pub rate_limit_delay: Option<Duration>,
964    pub stake_table_update_interval: Duration,
965    pub events_max_retry_duration: Duration,
966    pub finalized_safety_margin: Option<u64>,
967}
968
969impl From<&Options> for Libp2pTuning {
970    fn from(o: &Options) -> Self {
971        Self {
972            heartbeat_interval: o.libp2p_heartbeat_interval,
973            heartbeat_initial_delay: o.libp2p_heartbeat_initial_delay,
974            history_gossip: o.libp2p_history_gossip,
975            history_length: o.libp2p_history_length,
976            mesh_n: o.libp2p_mesh_n,
977            mesh_n_high: o.libp2p_mesh_n_high,
978            mesh_n_low: o.libp2p_mesh_n_low,
979            mesh_outbound_min: o.libp2p_mesh_outbound_min,
980            max_ihave_length: o.libp2p_max_ihave_length,
981            max_ihave_messages: o.libp2p_max_ihave_messages,
982            published_message_ids_cache_time: o.libp2p_published_message_ids_cache_time,
983            iwant_followup_time: o.libp2p_iwant_followup_time,
984            max_messages_per_rpc: o.libp2p_max_messages_per_rpc,
985            gossip_retransmission: o.libp2p_gossip_retransmission,
986            gossip_factor: o.libp2p_gossip_factor,
987            gossip_lazy: o.libp2p_gossip_lazy,
988            max_gossip_transmit_size: o.libp2p_max_gossip_transmit_size,
989            max_direct_transmit_size: o.libp2p_max_direct_transmit_size,
990            fanout_ttl: o.libp2p_fanout_ttl,
991            duplicate_cache_time: o.libp2p_duplicate_cache_time,
992            flood_publish: o.libp2p_flood_publish,
993        }
994    }
995}
996
997impl From<&L1ClientOptions> for L1Tuning {
998    fn from(o: &L1ClientOptions) -> Self {
999        Self {
1000            retry_delay: o.l1_retry_delay,
1001            polling_interval: o.l1_polling_interval,
1002            blocks_cache_size: o.l1_blocks_cache_size.get(),
1003            events_channel_capacity: o.l1_events_channel_capacity,
1004            events_max_block_range: o.l1_events_max_block_range,
1005            subscription_timeout: o.subscription_timeout,
1006            frequent_failure_tolerance: o.l1_frequent_failure_tolerance,
1007            consecutive_failure_tolerance: o.l1_consecutive_failure_tolerance,
1008            failover_revert: o.l1_failover_revert,
1009            rate_limit_delay: o.l1_rate_limit_delay,
1010            stake_table_update_interval: o.stake_table_update_interval,
1011            events_max_retry_duration: o.l1_events_max_retry_duration,
1012            finalized_safety_margin: o.l1_finalized_safety_margin,
1013        }
1014    }
1015}
1016
1017impl PublicNodeConfig {
1018    pub fn new(opt: &Options, modules: &Modules, genesis: &Genesis) -> Self {
1019        let storage = if let Some(sql) = modules.storage_sql.as_ref() {
1020            StorageConfig {
1021                backend: StorageBackend::Sql,
1022                fs: None,
1023                sql: Some(SqlStorageConfig::from(sql)),
1024            }
1025        } else if let Some(fs) = modules.storage_fs.as_ref() {
1026            StorageConfig {
1027                backend: StorageBackend::Fs,
1028                fs: Some(FsStorageConfig::from(fs)),
1029                sql: None,
1030            }
1031        } else {
1032            let fs = persistence::fs::Options::try_parse_from(std::iter::empty::<String>()).ok();
1033            StorageConfig {
1034                backend: StorageBackend::FsDefault,
1035                fs: fs.as_ref().map(FsStorageConfig::from),
1036                sql: None,
1037            }
1038        };
1039
1040        Self {
1041            orchestrator_url: opt.orchestrator_url.clone(),
1042            cdn_endpoint: opt.cdn_endpoint.clone(),
1043            cliquenet_bind_address: opt.cliquenet_bind_address.clone(),
1044            cliquenet_advertise_address: opt.cliquenet_advertise_address.clone(),
1045            libp2p_bind_address: opt.libp2p_bind_address.clone(),
1046            libp2p_advertise_address: opt.libp2p_advertise_address.clone(),
1047            libp2p_bootstrap_nodes: opt.libp2p_bootstrap_nodes.clone(),
1048            public_api_url: opt.public_api_url.clone(),
1049            builder_urls: opt.builder_urls.clone(),
1050            state_relay_server_url: opt.state_relay_server_url.clone(),
1051            state_peers: opt.state_peers.clone(),
1052            config_peers: opt.config_peers.clone(),
1053            is_da: opt.is_da,
1054            genesis_file: opt.genesis_file.clone(),
1055            genesis: genesis.clone(),
1056            identity: opt.identity.clone(),
1057            catchup_base_timeout: opt.catchup_base_timeout,
1058            local_catchup_timeout: opt.local_catchup_timeout,
1059            bootstrap_epoch_catchup_timeout: opt.bootstrap_epoch_catchup_timeout,
1060            catchup_backoff: opt.catchup_backoff,
1061            proposal_fetcher: opt.proposal_fetcher_config,
1062            libp2p: Libp2pTuning::from(opt),
1063            l1: L1Tuning::from(&opt.l1_options),
1064            l1_provider_count: opt.l1_provider_url.len(),
1065            l1_ws_provider_count: opt
1066                .l1_options
1067                .l1_ws_provider
1068                .as_ref()
1069                .map(|v| v.len())
1070                .unwrap_or(0),
1071            storage,
1072            modules: ApiModulesConfig::from(modules),
1073        }
1074    }
1075}
1076
1077#[cfg(test)]
1078mod tests {
1079    use alloy::primitives::{Address, B256, U256};
1080    use espresso_types::{
1081        FeeAccount, GenesisHeader, L1BlockInfo, PubKey, SeqTypes, Timestamp, Upgrade, UpgradeMode,
1082        UpgradeType, ViewBasedUpgrade, v0_3::ChainConfig,
1083    };
1084    use espresso_utils::ser::FromStringOrInteger;
1085    use hotshot_types::{
1086        PeerConfig, VersionedDaCommittee, light_client::StateKeyPair, stake_table::StakeTableEntry,
1087        traits::signature_key::SignatureKey, x25519,
1088    };
1089    use tagged_base64::TaggedBase64;
1090    use vbs::version::Version;
1091
1092    use super::*;
1093    use crate::genesis::{L1Finalized, StakeTableConfig};
1094
1095    #[test]
1096    fn test_build_version() {
1097        let version = build_version();
1098        for field in [
1099            "describe:",
1100            "rev:",
1101            "modified:",
1102            "branch:",
1103            "commit-timestamp:",
1104            "debug:",
1105            "os:",
1106            "arch:",
1107            "features:",
1108        ] {
1109            assert!(version.contains(field), "missing {field}: {version}");
1110        }
1111        assert!(
1112            version.contains("debug: true"),
1113            "expected debug build in test: {version}"
1114        );
1115        assert!(
1116            version.contains("testing"),
1117            "expected testing in features: {version}"
1118        );
1119    }
1120
1121    /// Build a minimal `Options` for tests, using freshly generated keys and the supplied extra args.
1122    pub(super) fn parse_options_with(extra: &[&str]) -> Options {
1123        let (_, priv_key) = PubKey::generated_from_seed_indexed([0; 32], 0);
1124        let state_key = StateKeyPair::generate_from_seed_indexed([0; 32], 0);
1125        let x25519_kp = x25519::Keypair::generate().unwrap();
1126
1127        let priv_staking = priv_key.to_tagged_base64().expect("valid key").to_string();
1128        let priv_state = state_key
1129            .sign_key_ref()
1130            .to_tagged_base64()
1131            .expect("valid key")
1132            .to_string();
1133        let priv_x25519 = TaggedBase64::try_from(x25519_kp.secret_key())
1134            .expect("valid key")
1135            .to_string();
1136
1137        let mut args: Vec<String> = vec![
1138            "sequencer".into(),
1139            "--private-staking-key".into(),
1140            priv_staking,
1141            "--private-state-key".into(),
1142            priv_state,
1143            "--private-x25519-key".into(),
1144            priv_x25519,
1145        ];
1146        args.extend(extra.iter().map(|s| s.to_string()));
1147
1148        Options::parse_from(args)
1149    }
1150
1151    /// A `Genesis` with every field populated (both upgrade modes, DA committee) so the
1152    /// `/config/runtime` snapshot documents the full response shape.
1153    fn test_genesis() -> Genesis {
1154        let chain_config = ChainConfig {
1155            chain_id: 999999999.into(),
1156            max_block_size: 3000.into(),
1157            base_fee: 1.into(),
1158            fee_recipient: FeeAccount::default(),
1159            fee_contract: Some(Address::from([0x11; 20])),
1160            stake_table_contract: Some(Address::from([0x22; 20])),
1161        };
1162        let peer = PeerConfig::<SeqTypes> {
1163            stake_table_entry: StakeTableEntry {
1164                stake_key: PubKey::generated_from_seed_indexed([0; 32], 0).0,
1165                stake_amount: U256::from(1),
1166            },
1167            state_ver_key: StateKeyPair::generate_from_seed_indexed([0; 32], 0).ver_key(),
1168            connect_info: None,
1169        };
1170        Genesis {
1171            base_version: Version { major: 0, minor: 4 },
1172            upgrade_version: Version { major: 0, minor: 5 },
1173            genesis_version: Version { major: 0, minor: 2 },
1174            epoch_height: Some(40000),
1175            drb_difficulty: Some(25000000000),
1176            drb_upgrade_difficulty: Some(25000000000),
1177            epoch_start_block: Some(10960201),
1178            stake_table_capacity: Some(200),
1179            chain_config,
1180            stake_table: StakeTableConfig { capacity: 200 },
1181            accounts: Default::default(),
1182            l1_finalized: L1Finalized::Block(L1BlockInfo {
1183                number: 21116303,
1184                timestamp: U256::from(1700000000),
1185                hash: B256::from([0x44; 32]),
1186            }),
1187            header: GenesisHeader {
1188                timestamp: Timestamp::from_integer(123456).unwrap(),
1189                chain_config,
1190            },
1191            upgrades: [(
1192                Version { major: 0, minor: 5 },
1193                Upgrade {
1194                    mode: UpgradeMode::View(ViewBasedUpgrade {
1195                        start_proposing_view: 100,
1196                        stop_proposing_view: 200,
1197                        start_voting_view: Some(1),
1198                        stop_voting_view: Some(300),
1199                    }),
1200                    upgrade_type: UpgradeType::EpochReward { chain_config },
1201                },
1202            )]
1203            .into_iter()
1204            .collect(),
1205            da_committees: Some(vec![VersionedDaCommittee {
1206                start_version: Version { major: 0, minor: 6 },
1207                start_epoch: 10,
1208                committee: vec![peer],
1209            }]),
1210        }
1211    }
1212
1213    #[test]
1214    fn public_node_config_no_secrets() {
1215        let opt = parse_options_with(&[
1216            "--l1-provider-url",
1217            "https://user:pass@example.invalid/v2/SECRET_API_KEY,https://example2.invalid/key2",
1218            "--cliquenet-bind-address",
1219            "127.0.0.1:9999",
1220            "--state-peers",
1221            "https://peer1.test,https://peer2.test",
1222        ]);
1223        let modules = opt.modules();
1224
1225        let cfg = PublicNodeConfig::new(&opt, &modules, &test_genesis());
1226        let json = serde_json::to_string(&cfg).unwrap();
1227        let json_lc = json.to_lowercase();
1228
1229        assert!(
1230            json.contains("127.0.0.1:9999"),
1231            "CLI override missing from JSON: {json}"
1232        );
1233        assert!(
1234            !json.contains("SECRET_API_KEY"),
1235            "L1 API key leaked into JSON: {json}"
1236        );
1237        assert!(
1238            !json.contains("user:pass"),
1239            "L1 URL userinfo leaked into JSON: {json}"
1240        );
1241        assert!(
1242            !json.contains("example.invalid"),
1243            "L1 host leaked into JSON: {json}"
1244        );
1245        assert!(
1246            !json.contains("example2.invalid"),
1247            "second L1 host leaked into JSON: {json}"
1248        );
1249        assert!(
1250            json.contains("\"l1_provider_count\":2"),
1251            "missing l1_provider_count: {json}"
1252        );
1253        assert!(
1254            !json.contains("\"uri\""),
1255            "DB URI key leaked into JSON: {json}"
1256        );
1257        assert!(
1258            !json.contains("postgres://"),
1259            "DB connection string leaked into JSON: {json}"
1260        );
1261
1262        const FORBIDDEN: &[&str] = &[
1263            "private", "mnemonic", "secret", "x25519", "key_file", "seed", "password",
1264        ];
1265        for token in FORBIDDEN {
1266            assert!(
1267                !json_lc.contains(token),
1268                "forbidden token '{token}' leaked into JSON: {json}"
1269            );
1270        }
1271
1272        assert!(
1273            json.contains("peer1.test") && json.contains("peer2.test"),
1274            "state_peers missing from JSON: {json}"
1275        );
1276    }
1277
1278    #[test]
1279    fn public_node_config_optionals() {
1280        let opt = parse_options_with(&[]);
1281        let modules = opt.modules();
1282
1283        let cfg = PublicNodeConfig::new(&opt, &modules, &test_genesis());
1284
1285        assert!(
1286            cfg.cliquenet_advertise_address.is_none(),
1287            "expected no advertise address: {:?}",
1288            cfg.cliquenet_advertise_address
1289        );
1290        assert!(
1291            cfg.libp2p_bootstrap_nodes.is_none(),
1292            "expected no bootstrap nodes: {:?}",
1293            cfg.libp2p_bootstrap_nodes
1294        );
1295        assert!(
1296            cfg.config_peers.is_none(),
1297            "expected no config peers: {:?}",
1298            cfg.config_peers
1299        );
1300        assert_eq!(cfg.l1_ws_provider_count, 0);
1301        assert_eq!(cfg.storage.backend, StorageBackend::FsDefault);
1302        assert!(cfg.storage.fs.is_none());
1303        assert!(cfg.storage.sql.is_none());
1304        assert!(!cfg.modules.submit);
1305        assert!(cfg.modules.http.is_none());
1306        assert!(cfg.modules.query.is_none());
1307
1308        let value: serde_json::Value = serde_json::to_value(&cfg).unwrap();
1309        assert_eq!(
1310            value["cliquenet_advertise_address"],
1311            serde_json::Value::Null
1312        );
1313        assert_eq!(value["libp2p_bootstrap_nodes"], serde_json::Value::Null);
1314        assert_eq!(value["config_peers"], serde_json::Value::Null);
1315        assert_eq!(value["public_api_url"], serde_json::Value::Null);
1316    }
1317
1318    // Document the JSON shape of GET /config/runtime. Runs under Postgres builds only;
1319    // the embedded-db variant produces a near-identical shape and the duplication isn't
1320    // worth the test complexity.
1321    #[cfg(not(feature = "embedded-db"))]
1322    #[test]
1323    fn config_node_response_snapshot() {
1324        let opt = parse_options_with(&[
1325            "--orchestrator-url",
1326            "http://orchestrator.example:8080",
1327            "--cdn-endpoint",
1328            "cdn.example:8081",
1329            "--cliquenet-bind-address",
1330            "0.0.0.0:9977",
1331            "--cliquenet-advertise-address",
1332            "node1.example:9977",
1333            "--libp2p-bind-address",
1334            "0.0.0.0:1769",
1335            "--libp2p-advertise-address",
1336            "node1.example:1769",
1337            "--libp2p-bootstrap-nodes",
1338            "/ip4/10.0.0.1/tcp/1769",
1339            "--public-api-url",
1340            "http://node1.example:24000",
1341            "--builder-urls",
1342            "http://builder.example:31004",
1343            "--state-relay-server-url",
1344            "http://relay.example:8083",
1345            "--state-peers",
1346            "https://peer1.example,https://peer2.example",
1347            "--config-peers",
1348            "https://peer1.example",
1349            "--is-da",
1350            "--genesis-file",
1351            "/path/to/genesis.toml",
1352            "--l1-provider-url",
1353            "https://eth.example",
1354            "--country-code",
1355            "US",
1356            "--node-name",
1357            "Snapshot Node",
1358            // Pin host-dependent identity defaults so the snapshot is portable.
1359            "--operating-system",
1360            "linux",
1361            "--node-type",
1362            "espresso-sequencer 0.0.0",
1363            "--",
1364            "storage-sql",
1365            "--prune",
1366            "--pruning-threshold",
1367            "1000000000000",
1368            "--",
1369            "http",
1370            "--port",
1371            "24000",
1372            "--",
1373            "query",
1374            "--",
1375            "config",
1376        ]);
1377        let modules = opt.modules();
1378
1379        let cfg = PublicNodeConfig::new(&opt, &modules, &test_genesis());
1380
1381        insta::assert_yaml_snapshot!("config_node_response_postgres", cfg);
1382    }
1383
1384    // Postgres only: storage-sql under embedded-db requires a --path arg that's
1385    // irrelevant to what this test asserts.
1386    #[cfg(not(feature = "embedded-db"))]
1387    #[test]
1388    fn public_node_config_includes_pruning() {
1389        let opt = parse_options_with(&[
1390            "--cliquenet-bind-address",
1391            "127.0.0.1:1",
1392            "--",
1393            "storage-sql",
1394            "--prune",
1395            "--pruning-threshold",
1396            "1000000000000",
1397        ]);
1398        let modules = opt.modules();
1399
1400        let cfg = PublicNodeConfig::new(&opt, &modules, &test_genesis());
1401        let json = serde_json::to_string(&cfg).unwrap();
1402
1403        assert_eq!(cfg.storage.backend, StorageBackend::Sql);
1404        assert!(
1405            json.contains("\"prune\":true"),
1406            "expected prune:true in JSON: {json}"
1407        );
1408        assert!(
1409            json.contains("pruning_threshold"),
1410            "expected pruning_threshold in JSON: {json}"
1411        );
1412        assert!(
1413            json.contains("1000000000000"),
1414            "expected pruning threshold value in JSON: {json}"
1415        );
1416        assert!(
1417            json.contains("\"consensus_pruning\""),
1418            "expected consensus_pruning object in JSON: {json}"
1419        );
1420        assert!(
1421            json.contains("\"pruning\""),
1422            "expected pruning object in JSON: {json}"
1423        );
1424        assert!(
1425            json.contains("\"target_retention\":302000"),
1426            "expected consensus_pruning target_retention default in JSON: {json}"
1427        );
1428        assert!(
1429            json.contains("\"minimum_retention\":130000"),
1430            "expected consensus_pruning minimum_retention default in JSON: {json}"
1431        );
1432        assert!(
1433            json.contains("\"target_usage\":1000000000"),
1434            "expected consensus_pruning target_usage default in JSON: {json}"
1435        );
1436        assert!(
1437            !json.contains("\"uri\""),
1438            "DB URI key leaked into JSON: {json}"
1439        );
1440        assert!(
1441            !json.contains("postgres://"),
1442            "DB connection string leaked into JSON: {json}"
1443        );
1444    }
1445}