openmina/commands/node/mod.rs
1use std::{fs::File, path::PathBuf, sync::Arc};
2
3use anyhow::Context;
4use ledger::proofs::provers::BlockProver;
5use node::{
6 account::AccountSecretKey,
7 snark::{BlockVerifier, TransactionVerifier},
8 transition_frontier::genesis::GenesisConfig,
9};
10
11use openmina_node_account::AccountPublicKey;
12use reqwest::Url;
13
14use node::{
15 core::log::inner::Level,
16 p2p::{connection::outgoing::P2pConnectionOutgoingInitOpts, identity::SecretKey},
17 service::Recorder,
18 SnarkerStrategy,
19};
20
21use openmina_node_native::{archive::config::ArchiveStorageOptions, tracing, NodeBuilder};
22
23/// OpenMina node configuration and runtime options
24///
25/// This struct defines all available command-line parameters for running an OpenMina node.
26/// The node can operate in different modes (basic node, block producer, archive node)
27/// depending on the parameters provided.
28///
29/// # Basic Usage
30///
31/// ```bash
32/// # Run a basic node on devnet
33/// openmina node --network devnet
34///
35/// # Run with custom ports and logging
36/// openmina node --network devnet --port 3001 --libp2p-port 8303 --verbosity debug
37/// ```
38///
39/// # Block Producer Mode
40///
41/// ```bash
42/// # Run as block producer
43/// openmina node --network devnet --producer-key /path/to/key --coinbase-receiver B62q...
44/// ```
45///
46/// # Archive Node Mode
47///
48/// ```bash
49/// # Run as archive node with local storage
50/// openmina node --network devnet --archive-local-storage
51/// ```
52#[derive(Debug, clap::Args)]
53pub struct Node {
54 /// Working directory for node data, logs, and configuration files
55 ///
56 /// Can be set via OPENMINA_HOME environment variable.
57 /// Defaults to ~/.openmina
58 #[arg(
59 long,
60 short = 'd',
61 default_value = "~/.openmina",
62 env = "OPENMINA_HOME"
63 )]
64 pub work_dir: String,
65
66 /// P2P networking secret key for node identity
67 ///
68 /// If not provided, a new key will be generated automatically.
69 /// Can be set via OPENMINA_P2P_SEC_KEY environment variable.
70 #[arg(long, short = 's', env = "OPENMINA_P2P_SEC_KEY")]
71 pub p2p_secret_key: Option<SecretKey>,
72
73 // warning, this overrides `OPENMINA_P2P_SEC_KEY`
74 /// Compatibility with OCaml Mina node
75 #[arg(long)]
76 pub libp2p_keypair: Option<String>,
77
78 // warning, this overrides `OPENMINA_P2P_SEC_KEY`
79 /// Compatibility with OCaml Mina node
80 #[arg(env = "MINA_LIBP2P_PASS")]
81 pub libp2p_password: Option<String>,
82
83 /// List of external addresses at which this node is accessible
84 #[arg(long)]
85 pub libp2p_external_ip: Vec<String>,
86
87 /// HTTP server port for RPC API and web interface
88 ///
89 /// The node will serve its HTTP API and dashboard on this port.
90 /// Default: 3000
91 #[arg(long, short, env, default_value = "3000")]
92 pub port: u16,
93
94 /// LibP2P networking port for peer-to-peer communication
95 ///
96 /// This port is used for connecting to other nodes in the network.
97 /// Default: 8302
98 #[arg(long, env, default_value = "8302")]
99 pub libp2p_port: u16,
100
101 /// Logging verbosity level
102 ///
103 /// Controls the amount of logging output. Options in order of verbosity:
104 /// - error: Only show errors
105 /// - warn: Show warnings and errors
106 /// - info: Show informational messages, warnings, and errors (default)
107 /// - debug: Show debug information and all above
108 /// - trace: Show all possible logging output
109 #[arg(long, short, env, default_value = "info")]
110 pub verbosity: Level,
111
112 /// Disable filesystem logging
113 #[arg(
114 long,
115 env = "OPENMINA_DISABLE_FILESYSTEM_LOGGING",
116 default_value_t = false
117 )]
118 pub disable_filesystem_logging: bool,
119
120 /// Specify custom path for log files
121 #[arg(long, env = "OPENMINA_LOG_PATH", default_value = "$OPENMINA_HOME")]
122 pub log_path: String,
123
124 /// Initial peers to connect to on startup
125 ///
126 /// Specify peer multiaddresses to connect to when the node starts.
127 /// Can be used multiple times to add multiple peers.
128 ///
129 /// # Multiaddr Format
130 ///
131 /// Multiaddresses follow the format: `/protocol/address/protocol/port/protocol/peer_id`
132 ///
133 /// **IPv4 Example:**
134 /// ```
135 /// /ip4/192.168.1.100/tcp/8302/p2p/12D3KooWABCDEF1234567890abcdef...
136 /// ```
137 ///
138 /// **IPv6 Example:**
139 /// ```
140 /// /ip6/2001:db8::1/tcp/8302/p2p/12D3KooWABCDEF1234567890abcdef...
141 /// ```
142 ///
143 /// **DNS Example:**
144 /// ```
145 /// /dns4/node.example.com/tcp/8302/p2p/12D3KooWABCDEF1234567890abcdef...
146 /// ```
147 ///
148 /// Where:
149 /// - `ip4/ip6/dns4` specifies the address type
150 /// - IP address or hostname
151 /// - `tcp` protocol with port number (typically 8302 for OpenMina)
152 /// - `p2p` protocol with the peer's public key identifier
153 #[arg(long, short = 'P', alias = "peer")]
154 pub peers: Vec<P2pConnectionOutgoingInitOpts>,
155
156 /// File containing initial peers to connect to
157 ///
158 /// Each line should contain a peer's multiaddr following the format described above.
159 ///
160 /// **Example file content:**
161 /// ```
162 /// /ip4/192.168.1.100/tcp/8302/p2p/12D3KooWABCDEF1234567890abcdef...
163 /// /ip4/10.0.0.50/tcp/8302/p2p/12D3KooWXYZ9876543210fedcba...
164 /// /dns4/bootstrap.example.com/tcp/8302/p2p/12D3KooW123ABC...
165 /// ```
166 ///
167 /// Empty lines and lines starting with `#` are ignored.
168 #[arg(long, env)]
169 pub peer_list_file: Option<PathBuf>,
170
171 /// URL to fetch initial peers list from
172 ///
173 /// The URL should return a text file with one peer multiaddr per line,
174 /// using the same format as described in `peer_list_file`.
175 /// Useful for dynamic peer discovery from a central bootstrap service.
176 ///
177 /// **Example URL response:**
178 /// ```
179 /// /ip4/bootstrap1.example.com/tcp/8302/p2p/12D3KooW...
180 /// /ip4/bootstrap2.example.com/tcp/8302/p2p/12D3KooX...
181 /// ```
182 #[arg(long, env)]
183 pub peer_list_url: Option<Url>,
184
185 /// Maximum number of peer connections to maintain
186 ///
187 /// The node will attempt to maintain up to this many connections
188 /// to other peers in the network. Default: 100
189 #[arg(long, default_value = "100")]
190 pub max_peers: usize,
191
192 /// Run the node in seed mode. No default peers will be added.
193 #[arg(long, env)]
194 pub seed: bool,
195
196 /// Run Snark Worker.
197 ///
198 /// Pass snarker private key as an argument.
199 #[arg(long, env, group = "snarker")]
200 pub run_snarker: Option<AccountSecretKey>,
201
202 /// Snark fee, in Mina
203 #[arg(long, env, default_value_t = 1_000_000, requires = "snarker")]
204 pub snarker_fee: u64,
205
206 #[arg(long, env, default_value = "seq", requires = "snarker")]
207 pub snarker_strategy: SnarkerStrategy,
208
209 /// Enable block producer with this key file
210 ///
211 /// MINA_PRIVKEY_PASS must be set to decrypt the keyfile if it is password-protected
212 #[arg(long, env, group = "producer")]
213 pub producer_key: Option<PathBuf>,
214
215 /// Password used to decrypt the producer key file.
216 #[arg(env = "MINA_PRIVKEY_PASS", default_value = "")]
217 pub producer_key_password: String,
218
219 /// Address to send coinbase rewards to (if this node is producing blocks).
220 /// If not provided, coinbase rewards will be sent to the producer
221 /// of a block.
222 ///
223 /// Warning: If the key is from a zkApp account, the account's
224 /// receive permission must be None.
225 #[arg(long, requires = "producer")]
226 pub coinbase_receiver: Option<AccountPublicKey>,
227
228 #[arg(long, default_value = "none", env)]
229 pub record: String,
230
231 /// Do not use peers discovery.
232 #[arg(long)]
233 pub no_peers_discovery: bool,
234
235 /// Config JSON file to load at startup.
236 // TODO: make this argument required.
237 #[arg(short = 'c', long, env)]
238 pub config: Option<PathBuf>,
239
240 /// Enable local precomputed storage.
241 ///
242 /// This option requires the following environment variables to be set:
243 /// - OPENMINA_ARCHIVE_LOCAL_STORAGE_PATH (otherwise the path to the working directory will be used)
244 #[arg(long, env)]
245 pub archive_local_storage: bool,
246
247 /// Enable archiver process.
248 ///
249 /// This requires the following environment variables to be set:
250 /// - OPENMINA_ARCHIVE_ADDRESS
251 #[arg(long, env)]
252 pub archive_archiver_process: bool,
253
254 /// Enable GCP precomputed storage.
255 ///
256 /// This requires the following environment variables to be set:
257 /// - GCP_CREDENTIALS_JSON
258 /// - GCP_BUCKET_NAME
259 ///
260 #[arg(long, env)]
261 pub archive_gcp_storage: bool,
262
263 /// Enable AWS precomputed storage.
264 ///
265 /// This requires the following environment variables to be set:
266 /// - AWS_ACCESS_KEY_ID
267 /// - AWS_SECRET_ACCESS_KEY
268 /// - AWS_SESSION_TOKEN
269 /// - AWS_DEFAULT_REGION
270 /// - OPENMINA_AWS_BUCKET_NAME
271 #[arg(long, env)]
272 pub archive_aws_storage: bool,
273
274 #[arg(long, env)]
275 pub rng_seed: Option<String>,
276}
277
278impl Node {
279 pub fn run(self) -> anyhow::Result<()> {
280 let work_dir = shellexpand::full(&self.work_dir).unwrap().into_owned();
281
282 let _guard = if !self.disable_filesystem_logging {
283 let log_output_dir = if self.log_path == "$OPENMINA_HOME" {
284 work_dir.clone()
285 } else {
286 self.log_path.clone()
287 };
288 Some(tracing::initialize_with_filesystem_output(
289 self.verbosity,
290 log_output_dir.into(),
291 ))
292 } else {
293 tracing::initialize(self.verbosity);
294 None
295 };
296
297 rayon::ThreadPoolBuilder::new()
298 .num_threads(num_cpus::get().max(2) - 1)
299 .thread_name(|i| format!("openmina_rayon_{i}"))
300 .build_global()
301 .context("failed to initialize threadpool")?;
302
303 let (daemon_conf, genesis_conf) = match self.config {
304 Some(config) => {
305 let reader = File::open(config).context("config file {config:?}")?;
306 let config: node::daemon_json::DaemonJson =
307 serde_json::from_reader(reader).context("config file {config:?}")?;
308 (
309 config
310 .daemon
311 .clone()
312 .unwrap_or(node::daemon_json::Daemon::DEFAULT),
313 Arc::new(GenesisConfig::DaemonJson(Box::new(config))),
314 )
315 }
316 None => (
317 node::daemon_json::Daemon::DEFAULT,
318 node::config::DEVNET_CONFIG.clone(),
319 ),
320 };
321
322 let custom_rng_seed = match self.rng_seed {
323 None => None,
324 Some(v) => match hex::decode(v)
325 .map_err(anyhow::Error::from)
326 .and_then(|bytes| {
327 <[u8; 32]>::try_from(bytes.as_slice()).map_err(anyhow::Error::from)
328 }) {
329 Ok(v) => Some(v),
330 Err(err) => {
331 node::core::error!(
332 node::core::log::system_time();
333 summary = "bad rng seed",
334 err = err.to_string(),
335 );
336 return Err(err);
337 }
338 },
339 };
340 let mut node_builder: NodeBuilder =
341 NodeBuilder::new(custom_rng_seed, daemon_conf, genesis_conf);
342
343 // let genesis_config = match self.config {
344 // Some(config_path) => GenesisConfig::DaemonJsonFile(config_path).into(),
345 // None => node::config::DEVNET_CONFIG.clone(),
346 // };
347 // let mut node_builder: NodeBuilder = NodeBuilder::new(None, genesis_config);
348
349 if let Some(sec_key) = self.p2p_secret_key {
350 node_builder.p2p_sec_key(sec_key);
351 }
352
353 // warning, this overrides `OPENMINA_P2P_SEC_KEY`
354 if let (Some(key_file), Some(password)) = (&self.libp2p_keypair, &self.libp2p_password) {
355 match SecretKey::from_encrypted_file(key_file, password) {
356 Ok(sk) => {
357 node_builder.p2p_sec_key(sk.clone());
358 node::core::info!(
359 node::core::log::system_time();
360 summary = "read sercret key from file",
361 file_name = key_file,
362 pk = sk.public_key().to_string(),
363 )
364 }
365 Err(err) => {
366 node::core::error!(
367 node::core::log::system_time();
368 summary = "failed to read secret key",
369 file_name = key_file,
370 err = err.to_string(),
371 );
372 return Err(err.into());
373 }
374 }
375 } else if self.libp2p_keypair.is_some() && self.libp2p_password.is_none() {
376 let error = "keyfile is specified, but `MINA_LIBP2P_PASS` is not set";
377 node::core::error!(
378 node::core::log::system_time();
379 summary = error,
380 );
381 return Err(anyhow::anyhow!(error));
382 }
383
384 node_builder.p2p_libp2p_port(self.libp2p_port);
385
386 node_builder.external_addrs(
387 self.libp2p_external_ip
388 .into_iter()
389 .filter_map(|s| s.parse().ok()),
390 );
391
392 node_builder.p2p_max_peers(self.max_peers);
393 self.seed.then(|| node_builder.p2p_seed_node());
394 self.no_peers_discovery
395 .then(|| node_builder.p2p_no_discovery());
396
397 node_builder.initial_peers(self.peers);
398 if let Some(path) = self.peer_list_file {
399 node_builder.initial_peers_from_file(path)?;
400 }
401 if let Some(url) = self.peer_list_url {
402 node_builder.initial_peers_from_url(url)?;
403 }
404
405 let block_verifier_index = BlockVerifier::make();
406 let work_verifier_index = TransactionVerifier::make();
407 node_builder
408 .block_verifier_index(block_verifier_index.clone())
409 .work_verifier_index(work_verifier_index.clone());
410
411 if let Some(producer_key_path) = self.producer_key {
412 let password = &self.producer_key_password;
413 openmina_core::thread::spawn(|| {
414 node::core::info!(node::core::log::system_time(); summary = "loading provers index");
415 BlockProver::make(Some(block_verifier_index), Some(work_verifier_index));
416 node::core::info!(node::core::log::system_time(); summary = "loaded provers index");
417 });
418 node_builder.block_producer_from_file(producer_key_path, password, None)?;
419
420 if let Some(pub_key) = self.coinbase_receiver {
421 node_builder
422 .custom_coinbase_receiver(pub_key.into())
423 .unwrap();
424 }
425 }
426
427 let archive_storage_options = ArchiveStorageOptions::from_iter(
428 [
429 (
430 self.archive_local_storage,
431 ArchiveStorageOptions::LOCAL_PRECOMPUTED_STORAGE,
432 ),
433 (
434 self.archive_archiver_process,
435 ArchiveStorageOptions::ARCHIVER_PROCESS,
436 ),
437 (
438 self.archive_gcp_storage,
439 ArchiveStorageOptions::GCP_PRECOMPUTED_STORAGE,
440 ),
441 (
442 self.archive_aws_storage,
443 ArchiveStorageOptions::AWS_PRECOMPUTED_STORAGE,
444 ),
445 ]
446 .iter()
447 .filter(|(enabled, _)| *enabled)
448 .map(|(_, option)| option.clone()),
449 );
450
451 if archive_storage_options.is_enabled() {
452 node::core::info!(
453 summary = "Archive mode enabled",
454 local_storage = archive_storage_options.uses_local_precomputed_storage(),
455 archiver_process = archive_storage_options.uses_archiver_process(),
456 gcp_storage = archive_storage_options.uses_gcp_precomputed_storage(),
457 aws_storage = archive_storage_options.uses_aws_precomputed_storage(),
458 );
459
460 archive_storage_options
461 .validate_env_vars()
462 .map_err(|e| anyhow::anyhow!(e))?;
463
464 node_builder.archive(archive_storage_options, work_dir.clone());
465 }
466
467 if let Some(sec_key) = self.run_snarker {
468 node_builder.snarker(sec_key, self.snarker_fee, self.snarker_strategy);
469 }
470
471 openmina_core::set_work_dir(work_dir.clone().into());
472
473 node_builder
474 .http_server(self.port)
475 .gather_stats()
476 .record(match self.record.trim() {
477 "none" => Recorder::None,
478 "state-with-input-actions" => Recorder::only_input_actions(work_dir),
479 _ => panic!("unknown --record strategy"),
480 });
481
482 let mut node = node_builder.build().context("node build failed!")?;
483
484 let runtime = tokio::runtime::Builder::new_current_thread()
485 .enable_all()
486 .thread_stack_size(64 * 1024 * 1024)
487 .build()
488 .unwrap();
489
490 runtime.block_on(node.run_forever());
491
492 Ok(())
493 }
494}