use super::{CliqueConfig, Genesis};
use crate::{
types::{Bytes, H256},
utils::{secret_key_to_address, unused_port},
};
use k256::ecdsa::SigningKey;
use std::{
borrow::Cow,
fs::{create_dir, File},
io::{BufRead, BufReader},
net::SocketAddr,
path::PathBuf,
process::{Child, ChildStderr, Command, Stdio},
time::{Duration, Instant},
};
use tempfile::tempdir;
const GETH_STARTUP_TIMEOUT: Duration = Duration::from_secs(10);
const GETH_DIAL_LOOP_TIMEOUT: Duration = Duration::from_secs(20);
const API: &str = "eth,net,web3,txpool,admin,personal,miner,debug";
const GETH: &str = "geth";
#[derive(Debug)]
pub enum GethInstanceError {
Timeout(String),
ReadLineError(std::io::Error),
NoStderr,
}
#[derive(Debug)]
pub struct GethInstance {
pid: Child,
port: u16,
ipc: Option<PathBuf>,
data_dir: Option<PathBuf>,
p2p_port: Option<u16>,
genesis: Option<Genesis>,
clique_private_key: Option<SigningKey>,
}
impl GethInstance {
pub fn port(&self) -> u16 {
self.port
}
pub fn p2p_port(&self) -> Option<u16> {
self.p2p_port
}
pub fn endpoint(&self) -> String {
format!("http://localhost:{}", self.port)
}
pub fn ws_endpoint(&self) -> String {
format!("ws://localhost:{}", self.port)
}
pub fn ipc_path(&self) -> &Option<PathBuf> {
&self.ipc
}
pub fn data_dir(&self) -> &Option<PathBuf> {
&self.data_dir
}
pub fn genesis(&self) -> &Option<Genesis> {
&self.genesis
}
pub fn clique_private_key(&self) -> &Option<SigningKey> {
&self.clique_private_key
}
pub fn stderr(&mut self) -> Result<ChildStderr, GethInstanceError> {
self.pid.stderr.take().ok_or(GethInstanceError::NoStderr)
}
pub fn wait_to_add_peer(&mut self, id: H256) -> Result<(), GethInstanceError> {
let mut stderr = self.pid.stderr.as_mut().ok_or(GethInstanceError::NoStderr)?;
let mut err_reader = BufReader::new(&mut stderr);
let mut line = String::new();
let start = Instant::now();
while start.elapsed() < GETH_DIAL_LOOP_TIMEOUT {
line.clear();
err_reader.read_line(&mut line).map_err(GethInstanceError::ReadLineError)?;
let truncated_id = hex::encode(&id.0[..8]);
if line.contains("Adding p2p peer") && line.contains(&truncated_id) {
return Ok(())
}
}
Err(GethInstanceError::Timeout("Timed out waiting for geth to add a peer".into()))
}
}
impl Drop for GethInstance {
fn drop(&mut self) {
self.pid.kill().expect("could not kill geth");
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GethMode {
Dev(DevOptions),
NonDev(PrivateNetOptions),
}
impl Default for GethMode {
fn default() -> Self {
Self::Dev(Default::default())
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct DevOptions {
pub block_time: Option<u64>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PrivateNetOptions {
pub p2p_port: Option<u16>,
pub discovery: bool,
}
impl Default for PrivateNetOptions {
fn default() -> Self {
Self { p2p_port: None, discovery: true }
}
}
#[derive(Clone, Debug, Default)]
#[must_use = "This Builder struct does nothing unless it is `spawn`ed"]
pub struct Geth {
program: Option<PathBuf>,
port: Option<u16>,
authrpc_port: Option<u16>,
ipc_path: Option<PathBuf>,
data_dir: Option<PathBuf>,
chain_id: Option<u64>,
insecure_unlock: bool,
genesis: Option<Genesis>,
mode: GethMode,
clique_private_key: Option<SigningKey>,
}
impl Geth {
pub fn new() -> Self {
Self::default()
}
pub fn at(path: impl Into<PathBuf>) -> Self {
Self::new().path(path)
}
pub fn is_clique(&self) -> bool {
self.clique_private_key.is_some()
}
pub fn path<T: Into<PathBuf>>(mut self, path: T) -> Self {
self.program = Some(path.into());
self
}
pub fn set_clique_private_key<T: Into<SigningKey>>(mut self, private_key: T) -> Self {
self.clique_private_key = Some(private_key.into());
self
}
pub fn port<T: Into<u16>>(mut self, port: T) -> Self {
self.port = Some(port.into());
self
}
pub fn p2p_port(mut self, port: u16) -> Self {
match self.mode {
GethMode::Dev(_) => {
self.mode = GethMode::NonDev(PrivateNetOptions {
p2p_port: Some(port),
..Default::default()
})
}
GethMode::NonDev(ref mut opts) => opts.p2p_port = Some(port),
}
self
}
pub fn block_time<T: Into<u64>>(mut self, block_time: T) -> Self {
self.mode = GethMode::Dev(DevOptions { block_time: Some(block_time.into()) });
self
}
pub fn chain_id<T: Into<u64>>(mut self, chain_id: T) -> Self {
self.chain_id = Some(chain_id.into());
self
}
pub fn insecure_unlock(mut self) -> Self {
self.insecure_unlock = true;
self
}
pub fn disable_discovery(mut self) -> Self {
self.inner_disable_discovery();
self
}
fn inner_disable_discovery(&mut self) {
match self.mode {
GethMode::Dev(_) => {
self.mode =
GethMode::NonDev(PrivateNetOptions { discovery: false, ..Default::default() })
}
GethMode::NonDev(ref mut opts) => opts.discovery = false,
}
}
pub fn ipc_path<T: Into<PathBuf>>(mut self, path: T) -> Self {
self.ipc_path = Some(path.into());
self
}
pub fn data_dir<T: Into<PathBuf>>(mut self, path: T) -> Self {
self.data_dir = Some(path.into());
self
}
pub fn genesis(mut self, genesis: Genesis) -> Self {
self.genesis = Some(genesis);
self
}
pub fn authrpc_port(mut self, port: u16) -> Self {
self.authrpc_port = Some(port);
self
}
#[track_caller]
pub fn spawn(mut self) -> GethInstance {
let bin_path = match self.program.as_ref() {
Some(bin) => bin.as_os_str(),
None => GETH.as_ref(),
}
.to_os_string();
let mut cmd = Command::new(&bin_path);
cmd.stderr(Stdio::piped());
let mut port = self.port.unwrap_or(0);
let port_s = port.to_string();
cmd.arg("--http");
cmd.arg("--http.port").arg(&port_s);
cmd.arg("--http.api").arg(API);
cmd.arg("--ws");
cmd.arg("--ws.port").arg(port_s);
cmd.arg("--ws.api").arg(API);
let is_clique = self.is_clique();
if self.insecure_unlock || is_clique {
cmd.arg("--allow-insecure-unlock");
}
if is_clique {
self.inner_disable_discovery();
}
let authrpc_port = self.authrpc_port.unwrap_or_else(&mut unused_port);
cmd.arg("--authrpc.port").arg(authrpc_port.to_string());
if is_clique {
if let Some(genesis) = &mut self.genesis {
let clique_config = CliqueConfig { period: Some(0), epoch: Some(8) };
genesis.config.clique = Some(clique_config);
let clique_addr = secret_key_to_address(
self.clique_private_key.as_ref().expect("is_clique == true"),
);
let extra_data_bytes =
[&[0u8; 32][..], clique_addr.as_ref(), &[0u8; 65][..]].concat();
let extra_data = Bytes::from(extra_data_bytes);
genesis.extra_data = extra_data;
cmd.arg("--miner.etherbase").arg(format!("{clique_addr:?}"));
}
let clique_addr =
secret_key_to_address(self.clique_private_key.as_ref().expect("is_clique == true"));
self.genesis = Some(Genesis::new(
self.chain_id.expect("chain id must be set in clique mode"),
clique_addr,
));
cmd.arg("--miner.etherbase").arg(format!("{clique_addr:?}"));
}
if let Some(ref genesis) = self.genesis {
let temp_genesis_dir_path =
tempdir().expect("should be able to create temp dir for genesis init").into_path();
let temp_genesis_path = temp_genesis_dir_path.join("genesis.json");
let mut file = File::create(&temp_genesis_path).expect("could not create genesis file");
serde_json::to_writer_pretty(&mut file, &genesis)
.expect("could not write genesis to file");
let mut init_cmd = Command::new(bin_path);
if let Some(ref data_dir) = self.data_dir {
init_cmd.arg("--datadir").arg(data_dir);
}
init_cmd.stderr(Stdio::null());
init_cmd.arg("init").arg(temp_genesis_path);
let res = init_cmd
.spawn()
.expect("failed to spawn geth init")
.wait()
.expect("failed to wait for geth init to exit");
if !res.success() {
panic!("geth init failed");
}
std::fs::remove_dir_all(temp_genesis_dir_path)
.expect("could not remove genesis temp dir");
}
if let Some(ref data_dir) = self.data_dir {
cmd.arg("--datadir").arg(data_dir);
if !data_dir.exists() {
create_dir(data_dir).expect("could not create data dir");
}
}
let mut p2p_port = match self.mode {
GethMode::Dev(DevOptions { block_time }) => {
cmd.arg("--dev");
if let Some(block_time) = block_time {
cmd.arg("--dev.period").arg(block_time.to_string());
}
None
}
GethMode::NonDev(PrivateNetOptions { p2p_port, discovery }) => {
let port = p2p_port.unwrap_or(0);
cmd.arg("--port").arg(port.to_string());
if !discovery {
cmd.arg("--nodiscover");
}
Some(port)
}
};
if let Some(chain_id) = self.chain_id {
cmd.arg("--networkid").arg(chain_id.to_string());
}
cmd.arg("--verbosity").arg("4");
if let Some(ref ipc) = self.ipc_path {
cmd.arg("--ipcpath").arg(ipc);
}
let mut child = cmd.spawn().expect("couldnt start geth");
let stderr = child.stderr.expect("Unable to get stderr for geth child process");
let start = Instant::now();
let mut reader = BufReader::new(stderr);
let mut p2p_started = matches!(self.mode, GethMode::Dev(_));
let mut http_started = false;
loop {
if start + GETH_STARTUP_TIMEOUT <= Instant::now() {
panic!("Timed out waiting for geth to start. Is geth installed?")
}
let mut line = String::with_capacity(120);
reader.read_line(&mut line).expect("Failed to read line from geth process");
if matches!(self.mode, GethMode::NonDev(_)) && line.contains("Started P2P networking") {
p2p_started = true;
}
if !matches!(self.mode, GethMode::Dev(_)) {
if line.contains("New local node record") {
if let Some(port) = extract_value("tcp=", &line) {
p2p_port = port.parse::<u16>().ok();
}
}
}
if line.contains("HTTP endpoint opened") ||
(line.contains("HTTP server started") && !line.contains("auth=true"))
{
if let Some(addr) = extract_endpoint(&line) {
port = addr.port();
}
http_started = true;
}
if line.contains("Fatal:") {
panic!("{line}");
}
if p2p_started && http_started {
break
}
}
child.stderr = Some(reader.into_inner());
GethInstance {
pid: child,
port,
ipc: self.ipc_path,
data_dir: self.data_dir,
p2p_port,
genesis: self.genesis,
clique_private_key: self.clique_private_key,
}
}
}
fn extract_value<'a>(key: &str, line: &'a str) -> Option<&'a str> {
let mut key = Cow::from(key);
if !key.ends_with('=') {
key = Cow::from(format!("{}=", key));
}
line.find(key.as_ref()).map(|pos| {
let start = pos + key.len();
let end = line[start..].find(' ').map(|i| start + i).unwrap_or(line.len());
line[start..end].trim()
})
}
fn extract_endpoint(line: &str) -> Option<SocketAddr> {
let val = extract_value("endpoint=", line)?;
val.parse::<SocketAddr>().ok()
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn test_extract_address() {
let line = "INFO [07-01|13:20:42.774] HTTP server started endpoint=127.0.0.1:8545 auth=false prefix= cors= vhosts=localhost";
assert_eq!(extract_endpoint(line), Some(SocketAddr::from(([127, 0, 0, 1], 8545))));
}
#[test]
fn port_0() {
run_with_tempdir(|_| {
let _geth = Geth::new().disable_discovery().port(0u16).spawn();
});
}
#[track_caller]
fn run_with_tempdir(f: impl Fn(&Path)) {
let temp_dir = tempfile::tempdir().unwrap();
let temp_dir_path = temp_dir.path();
f(temp_dir_path);
#[cfg(not(windows))]
temp_dir.close().unwrap();
}
#[test]
fn p2p_port() {
run_with_tempdir(|temp_dir_path| {
let geth = Geth::new().disable_discovery().data_dir(temp_dir_path).spawn();
let p2p_port = geth.p2p_port();
assert!(p2p_port.is_some());
});
}
#[test]
fn explicit_p2p_port() {
run_with_tempdir(|temp_dir_path| {
let geth = Geth::new().p2p_port(1234).data_dir(temp_dir_path).spawn();
let p2p_port = geth.p2p_port();
assert_eq!(p2p_port, Some(1234));
});
}
#[test]
fn dev_mode() {
run_with_tempdir(|temp_dir_path| {
let geth = Geth::new().data_dir(temp_dir_path).spawn();
let p2p_port = geth.p2p_port();
assert!(p2p_port.is_none(), "{p2p_port:?}");
})
}
#[test]
fn clique_correctly_configured() {
run_with_tempdir(|temp_dir_path| {
let private_key = SigningKey::random(&mut rand::thread_rng());
let geth = Geth::new()
.set_clique_private_key(private_key)
.chain_id(1337u64)
.data_dir(temp_dir_path)
.spawn();
assert!(geth.p2p_port.is_some());
assert!(geth.clique_private_key().is_some());
assert!(geth.genesis().is_some());
})
}
}