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