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