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