alloy_node_bindings/nodes/
geth.rs1use crate::{
4 utils::{extract_endpoint, extract_value, unused_port, GracefulShutdown},
5 NodeError, NODE_DIAL_LOOP_TIMEOUT, NODE_STARTUP_TIMEOUT,
6};
7use alloy_genesis::{CliqueConfig, Genesis};
8use alloy_primitives::Address;
9use k256::ecdsa::SigningKey;
10use std::{
11 ffi::OsString,
12 fs::{create_dir_all, File},
13 io::{BufRead, BufReader},
14 path::PathBuf,
15 process::{Child, ChildStderr, Command, Stdio},
16 time::{Duration, Instant},
17};
18use tempfile::tempdir;
19use url::Url;
20
21const API: &str = "eth,net,web3,txpool,admin,personal,miner,debug";
23
24const GETH: &str = "geth";
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum NodeMode {
30 Dev(DevOptions),
32 NonDev(PrivateNetOptions),
34}
35
36impl Default for NodeMode {
37 fn default() -> Self {
38 Self::Dev(Default::default())
39 }
40}
41
42#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
44pub struct DevOptions {
45 pub block_time: Option<u64>,
47}
48
49#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub struct PrivateNetOptions {
52 pub p2p_port: Option<u16>,
54
55 pub discovery: bool,
57}
58
59impl Default for PrivateNetOptions {
60 fn default() -> Self {
61 Self { p2p_port: None, discovery: true }
62 }
63}
64
65#[derive(Debug)]
69pub struct GethInstance {
70 pid: Child,
71 host: String,
72 port: u16,
73 p2p_port: Option<u16>,
74 auth_port: Option<u16>,
75 ipc: Option<PathBuf>,
76 data_dir: Option<PathBuf>,
77 genesis: Option<Genesis>,
78 clique_private_key: Option<SigningKey>,
79}
80
81impl GethInstance {
82 pub fn host(&self) -> &str {
84 &self.host
85 }
86
87 pub const fn port(&self) -> u16 {
89 self.port
90 }
91
92 pub const fn p2p_port(&self) -> Option<u16> {
94 self.p2p_port
95 }
96
97 pub const fn auth_port(&self) -> Option<u16> {
99 self.auth_port
100 }
101
102 #[doc(alias = "http_endpoint")]
104 pub fn endpoint(&self) -> String {
105 format!("http://{}:{}", self.host, self.port)
106 }
107
108 pub fn ws_endpoint(&self) -> String {
110 format!("ws://{}:{}", self.host, self.port)
111 }
112
113 pub fn ipc_endpoint(&self) -> String {
115 self.ipc.clone().map_or_else(|| "geth.ipc".to_string(), |ipc| ipc.display().to_string())
116 }
117
118 #[doc(alias = "http_endpoint_url")]
120 pub fn endpoint_url(&self) -> Url {
121 Url::parse(&self.endpoint()).unwrap()
122 }
123
124 pub fn ws_endpoint_url(&self) -> Url {
126 Url::parse(&self.ws_endpoint()).unwrap()
127 }
128
129 pub const fn data_dir(&self) -> Option<&PathBuf> {
131 self.data_dir.as_ref()
132 }
133
134 pub const fn genesis(&self) -> Option<&Genesis> {
136 self.genesis.as_ref()
137 }
138
139 #[deprecated = "clique support was removed in geth >=1.14"]
141 pub const fn clique_private_key(&self) -> Option<&SigningKey> {
142 self.clique_private_key.as_ref()
143 }
144
145 pub fn stderr(&mut self) -> Result<ChildStderr, NodeError> {
150 self.pid.stderr.take().ok_or(NodeError::NoStderr)
151 }
152
153 pub fn wait_to_add_peer(&mut self, id: &str) -> Result<(), NodeError> {
159 let mut stderr = self.pid.stderr.as_mut().ok_or(NodeError::NoStderr)?;
160 let mut err_reader = BufReader::new(&mut stderr);
161 let mut line = String::new();
162 let start = Instant::now();
163
164 while start.elapsed() < NODE_DIAL_LOOP_TIMEOUT {
165 line.clear();
166 err_reader.read_line(&mut line).map_err(NodeError::ReadLineError)?;
167
168 let truncated_id = if id.len() > 16 { &id[..16] } else { id };
170 if line.contains("Adding p2p peer") && line.contains(truncated_id) {
171 return Ok(());
172 }
173 }
174 Err(NodeError::Timeout)
175 }
176}
177
178impl Drop for GethInstance {
179 fn drop(&mut self) {
180 GracefulShutdown::shutdown(&mut self.pid, 10, "geth");
181 }
182}
183
184#[derive(Clone, Debug, Default)]
205#[must_use = "This Builder struct does nothing unless it is `spawn`ed"]
206pub struct Geth {
207 program: Option<PathBuf>,
208 host: Option<String>,
209 port: Option<u16>,
210 authrpc_port: Option<u16>,
211 ipc_path: Option<PathBuf>,
212 ipc_enabled: bool,
213 data_dir: Option<PathBuf>,
214 chain_id: Option<u64>,
215 insecure_unlock: bool,
216 keep_err: bool,
217 timeout: Option<u64>,
218 genesis: Option<Genesis>,
219 mode: NodeMode,
220 clique_private_key: Option<SigningKey>,
221 args: Vec<OsString>,
222}
223
224impl Geth {
225 pub fn new() -> Self {
227 Self::default()
228 }
229
230 pub fn at(path: impl Into<PathBuf>) -> Self {
243 Self::new().path(path)
244 }
245
246 pub fn path<T: Into<PathBuf>>(mut self, path: T) -> Self {
251 self.program = Some(path.into());
252 self
253 }
254
255 pub fn dev(mut self) -> Self {
257 self.mode = NodeMode::Dev(Default::default());
258 self
259 }
260
261 pub const fn is_clique(&self) -> bool {
263 self.clique_private_key.is_some()
264 }
265
266 pub fn clique_address(&self) -> Option<Address> {
268 self.clique_private_key.as_ref().map(|pk| Address::from_public_key(pk.verifying_key()))
269 }
270
271 #[deprecated = "clique support was removed in geth >=1.14"]
277 pub fn set_clique_private_key<T: Into<SigningKey>>(mut self, private_key: T) -> Self {
278 self.clique_private_key = Some(private_key.into());
279 self
280 }
281
282 pub fn port<T: Into<u16>>(mut self, port: T) -> Self {
287 self.port = Some(port.into());
288 self
289 }
290
291 pub fn host<T: Into<String>>(mut self, host: T) -> Self {
295 self.host = Some(host.into());
296 self
297 }
298
299 pub fn p2p_port(mut self, port: u16) -> Self {
304 match &mut self.mode {
305 NodeMode::Dev(_) => {
306 self.mode = NodeMode::NonDev(PrivateNetOptions {
307 p2p_port: Some(port),
308 ..Default::default()
309 })
310 }
311 NodeMode::NonDev(opts) => opts.p2p_port = Some(port),
312 }
313 self
314 }
315
316 pub const fn block_time(mut self, block_time: u64) -> Self {
321 self.mode = NodeMode::Dev(DevOptions { block_time: Some(block_time) });
322 self
323 }
324
325 pub const fn chain_id(mut self, chain_id: u64) -> Self {
327 self.chain_id = Some(chain_id);
328 self
329 }
330
331 pub const fn insecure_unlock(mut self) -> Self {
333 self.insecure_unlock = true;
334 self
335 }
336
337 pub const fn enable_ipc(mut self) -> Self {
339 self.ipc_enabled = true;
340 self
341 }
342
343 pub fn disable_discovery(mut self) -> Self {
348 self.inner_disable_discovery();
349 self
350 }
351
352 fn inner_disable_discovery(&mut self) {
353 match &mut self.mode {
354 NodeMode::Dev(_) => {
355 self.mode =
356 NodeMode::NonDev(PrivateNetOptions { discovery: false, ..Default::default() })
357 }
358 NodeMode::NonDev(opts) => opts.discovery = false,
359 }
360 }
361
362 pub fn ipc_path<T: Into<PathBuf>>(mut self, path: T) -> Self {
366 self.ipc_path = Some(path.into());
367 self.ipc_enabled = true;
368 self
369 }
370
371 pub fn data_dir<T: Into<PathBuf>>(mut self, path: T) -> Self {
373 self.data_dir = Some(path.into());
374 self
375 }
376
377 pub fn genesis(mut self, genesis: Genesis) -> Self {
384 self.genesis = Some(genesis);
385 self
386 }
387
388 pub const fn authrpc_port(mut self, port: u16) -> Self {
390 self.authrpc_port = Some(port);
391 self
392 }
393
394 pub const fn timeout(mut self, timeout: u64) -> Self {
399 self.timeout = Some(timeout);
400 self
401 }
402
403 pub const fn keep_stderr(mut self) -> Self {
408 self.keep_err = true;
409 self
410 }
411
412 pub fn push_arg<T: Into<OsString>>(&mut self, arg: T) {
414 self.args.push(arg.into());
415 }
416
417 pub fn extend_args<I, S>(&mut self, args: I)
419 where
420 I: IntoIterator<Item = S>,
421 S: Into<OsString>,
422 {
423 for arg in args {
424 self.push_arg(arg);
425 }
426 }
427
428 pub fn arg<T: Into<OsString>>(mut self, arg: T) -> Self {
432 self.args.push(arg.into());
433 self
434 }
435
436 pub fn args<I, S>(mut self, args: I) -> Self
440 where
441 I: IntoIterator<Item = S>,
442 S: Into<OsString>,
443 {
444 for arg in args {
445 self = self.arg(arg);
446 }
447 self
448 }
449
450 #[track_caller]
456 pub fn spawn(self) -> GethInstance {
457 self.try_spawn().unwrap()
458 }
459
460 pub fn try_spawn(mut self) -> Result<GethInstance, NodeError> {
469 let bin_path = self
470 .program
471 .as_ref()
472 .map_or_else(|| GETH.as_ref(), |bin| bin.as_os_str())
473 .to_os_string();
474 let mut cmd = Command::new(&bin_path);
475 cmd.stderr(Stdio::piped());
477
478 let mut port = self.port.unwrap_or(0);
480 let port_s = port.to_string();
481
482 if !self.ipc_enabled {
484 cmd.arg("--ipcdisable");
485 }
486
487 cmd.arg("--http");
489 cmd.arg("--http.port").arg(&port_s);
490 cmd.arg("--http.api").arg(API);
491
492 if let Some(ref host) = self.host {
493 cmd.arg("--http.addr").arg(host);
494 }
495
496 cmd.arg("--ws");
498 cmd.arg("--ws.port").arg(port_s);
499 cmd.arg("--ws.api").arg(API);
500
501 if let Some(ref host) = self.host {
502 cmd.arg("--ws.addr").arg(host);
503 }
504
505 let is_clique = self.is_clique();
507 if self.insecure_unlock || is_clique {
508 cmd.arg("--allow-insecure-unlock");
509 }
510
511 if is_clique {
512 self.inner_disable_discovery();
513 }
514
515 let authrpc_port = self.authrpc_port.unwrap_or_else(&mut unused_port);
517 cmd.arg("--authrpc.port").arg(authrpc_port.to_string());
518
519 if is_clique {
521 let clique_addr = self.clique_address();
522 if let Some(genesis) = &mut self.genesis {
523 let clique_config = CliqueConfig { period: Some(0), epoch: Some(8) };
525 genesis.config.clique = Some(clique_config);
526
527 let clique_addr = clique_addr.ok_or_else(|| {
528 NodeError::CliqueAddressError(
529 "could not calculates the address of the Clique consensus address."
530 .to_string(),
531 )
532 })?;
533
534 let extra_data_bytes =
536 [&[0u8; 32][..], clique_addr.as_ref(), &[0u8; 65][..]].concat();
537 genesis.extra_data = extra_data_bytes.into();
538 }
539
540 let clique_addr = self.clique_address().ok_or_else(|| {
541 NodeError::CliqueAddressError(
542 "could not calculates the address of the Clique consensus address.".to_string(),
543 )
544 })?;
545
546 self.genesis = Some(Genesis::clique_genesis(
547 self.chain_id.ok_or(NodeError::ChainIdNotSet)?,
548 clique_addr,
549 ));
550
551 cmd.arg("--miner.etherbase").arg(format!("{clique_addr:?}"));
555 }
556
557 if let Some(genesis) = &self.genesis {
558 let temp_genesis_dir = tempdir().map_err(NodeError::CreateDirError)?;
560 let temp_genesis_path = temp_genesis_dir.path().join("genesis.json");
561
562 let mut file = File::create(&temp_genesis_path).map_err(|_| {
564 NodeError::GenesisError("could not create genesis file".to_string())
565 })?;
566
567 serde_json::to_writer_pretty(&mut file, &genesis).map_err(|_| {
569 NodeError::GenesisError("could not write genesis to file".to_string())
570 })?;
571
572 let mut init_cmd = Command::new(bin_path);
573 if let Some(data_dir) = &self.data_dir {
574 init_cmd.arg("--datadir").arg(data_dir);
575 }
576
577 init_cmd.stderr(Stdio::null());
579
580 init_cmd.arg("init").arg(temp_genesis_path);
581 let res = init_cmd
582 .spawn()
583 .map_err(NodeError::SpawnError)?
584 .wait()
585 .map_err(NodeError::WaitError)?;
586 if !res.success() {
588 return Err(NodeError::InitError);
589 }
590
591 }
593
594 if let Some(data_dir) = &self.data_dir {
595 cmd.arg("--datadir").arg(data_dir);
596
597 if !data_dir.exists() {
599 create_dir_all(data_dir).map_err(NodeError::CreateDirError)?;
600 }
601 }
602
603 let mut p2p_port = match self.mode {
605 NodeMode::Dev(DevOptions { block_time }) => {
606 cmd.arg("--dev");
607 if let Some(block_time) = block_time {
608 cmd.arg("--dev.period").arg(block_time.to_string());
609 }
610 None
611 }
612 NodeMode::NonDev(PrivateNetOptions { p2p_port, discovery }) => {
613 let port = p2p_port.unwrap_or(0);
615 cmd.arg("--port").arg(port.to_string());
616
617 if !discovery {
619 cmd.arg("--nodiscover");
620 }
621 Some(port)
622 }
623 };
624
625 if let Some(chain_id) = self.chain_id {
626 cmd.arg("--networkid").arg(chain_id.to_string());
627 }
628
629 cmd.arg("--verbosity").arg("4");
631
632 if let Some(ipc) = &self.ipc_path {
633 cmd.arg("--ipcpath").arg(ipc);
634 }
635
636 cmd.args(self.args);
637
638 let mut child = cmd.spawn().map_err(NodeError::SpawnError)?;
639
640 let stderr = child.stderr.take().ok_or(NodeError::NoStderr)?;
641
642 let timeout = self.timeout.map(Duration::from_millis).unwrap_or(NODE_STARTUP_TIMEOUT);
643 let start = Instant::now();
644 let mut reader = BufReader::new(stderr);
645
646 let mut p2p_started = matches!(self.mode, NodeMode::Dev(_));
649 let mut ports_started = false;
650
651 loop {
652 if start.elapsed() >= timeout {
653 let _ = child.kill();
654 return Err(NodeError::Timeout);
655 }
656
657 let mut line = String::with_capacity(120);
658 reader.read_line(&mut line).map_err(NodeError::ReadLineError)?;
659
660 if matches!(self.mode, NodeMode::NonDev(_)) && line.contains("Started P2P networking") {
661 p2p_started = true;
662 }
663
664 if !matches!(self.mode, NodeMode::Dev(_)) {
665 if line.contains("New local node record") {
667 if let Some(port) = extract_value("tcp=", &line) {
668 p2p_port = port.parse::<u16>().ok();
669 }
670 }
671 }
672
673 if line.contains("HTTP endpoint opened")
676 || (line.contains("HTTP server started") && !line.contains("auth=true"))
677 {
678 if let Some(addr) = extract_endpoint("endpoint=", &line) {
680 port = addr.port();
682 }
683
684 ports_started = true;
685 }
686
687 if line.contains("Fatal:") {
690 let _ = child.kill();
691 return Err(NodeError::Fatal(line));
692 }
693
694 if ports_started && p2p_started {
696 break;
697 }
698 }
699
700 if self.keep_err {
701 child.stderr = Some(reader.into_inner());
703 } else {
704 std::thread::spawn(move || {
708 let mut buf = String::new();
709 loop {
710 buf.clear();
711 match reader.read_line(&mut buf) {
712 Ok(0) | Err(_) => break,
713 Ok(_) => {}
714 }
715 }
716 });
717 }
718
719 Ok(GethInstance {
720 pid: child,
721 host: self.host.unwrap_or_else(|| "localhost".to_string()),
722 port,
723 ipc: self.ipc_path,
724 data_dir: self.data_dir,
725 p2p_port,
726 auth_port: self.authrpc_port,
727 genesis: self.genesis,
728 clique_private_key: self.clique_private_key,
729 })
730 }
731}
732
733#[cfg(test)]
734mod tests {
735 use super::*;
736
737 #[cfg(unix)]
738 #[test]
739 fn respects_startup_timeout() {
740 use std::os::unix::fs::PermissionsExt;
741
742 let dir = tempdir().unwrap();
743 let program = dir.path().join("geth");
744 std::fs::write(
746 &program,
747 "#!/bin/sh\nsleep 11\necho 'Initializing dev chain' >&2\necho 'HTTP server started endpoint=127.0.0.1:8545 auth=false' >&2\n",
748 )
749 .unwrap();
750 std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap();
751
752 assert!(matches!(Geth::at(&program).timeout(0).try_spawn(), Err(NodeError::Timeout)));
753 let geth = Geth::at(&program).timeout(30_000).try_spawn().unwrap();
754 assert_eq!(geth.port(), 8545);
755 assert!(geth.p2p_port().is_none());
756 }
757
758 #[test]
759 fn can_set_host() {
760 let geth = Geth::new().host("0.0.0.0").dev().try_spawn();
761 if let Ok(geth) = geth {
762 assert_eq!(geth.host(), "0.0.0.0");
763 assert!(geth.endpoint().starts_with("http://0.0.0.0:"));
764 assert!(geth.ws_endpoint().starts_with("ws://0.0.0.0:"));
765 }
766 }
767
768 #[test]
769 fn default_host_is_localhost() {
770 let geth = Geth::new().dev().try_spawn();
771 if let Ok(geth) = geth {
772 assert_eq!(geth.host(), "localhost");
773 assert!(geth.endpoint().starts_with("http://localhost:"));
774 assert!(geth.ws_endpoint().starts_with("ws://localhost:"));
775 }
776 }
777}