diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14281f96..bcfaa4ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,7 @@ env: ETHERSCAN_API_KEY_ETHEREUM: I5BXNZYP5GEDWFINGVEZKYIVU2695NPQZB ETHERSCAN_API_KEY_CELO: B13XSMUT6Q3Q4WZ5DNQR8RXDBA2KNTMT4M GOERLI_PRIVATE_KEY: "fa4a1a79e869a96fcb42727f75e3232d6865a82ea675bb95de967a7fe6a773b2" + GETH_BUILD: 1.10.26-e5eb32ac jobs: tests: @@ -36,9 +37,9 @@ jobs: - name: Install geth run: | mkdir -p "$HOME/bin" - wget -q https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.9.23-8c2f2715.tar.gz - tar -xvf geth-linux-amd64-1.9.23-8c2f2715.tar.gz - mv geth-linux-amd64-1.9.23-8c2f2715/geth $HOME/bin/geth + wget -q https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-$GETH_BUILD.tar.gz + tar -xvf geth-linux-amd64-$GETH_BUILD.tar.gz + mv geth-linux-amd64-$GETH_BUILD/geth $HOME/bin/geth chmod u+x "$HOME/bin/geth" export PATH=$HOME/bin:$PATH geth version @@ -79,9 +80,9 @@ jobs: - name: Install geth run: | mkdir -p "$HOME/bin" - wget -q https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.9.23-8c2f2715.tar.gz - tar -xvf geth-linux-amd64-1.9.23-8c2f2715.tar.gz - mv geth-linux-amd64-1.9.23-8c2f2715/geth $HOME/bin/geth + wget -q https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-$GETH_BUILD.tar.gz + tar -xvf geth-linux-amd64-$GETH_BUILD.tar.gz + mv geth-linux-amd64-$GETH_BUILD/geth $HOME/bin/geth chmod u+x "$HOME/bin/geth" export PATH=$HOME/bin:$PATH geth version @@ -201,9 +202,9 @@ jobs: - name: Install geth (for state overrides example) run: | mkdir -p "$HOME/bin" - wget -q https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.9.23-8c2f2715.tar.gz - tar -xvf geth-linux-amd64-1.9.23-8c2f2715.tar.gz - mv geth-linux-amd64-1.9.23-8c2f2715/geth $HOME/bin/geth + wget -q https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-$GETH_BUILD.tar.gz + tar -xvf geth-linux-amd64-$GETH_BUILD.tar.gz + mv geth-linux-amd64-$GETH_BUILD/geth $HOME/bin/geth chmod u+x "$HOME/bin/geth" export PATH=$HOME/bin:$PATH geth version diff --git a/CHANGELOG.md b/CHANGELOG.md index b8c22654..a3c817c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -221,6 +221,8 @@ ### Unreleased +- Add a subset of the `admin` namespace + [1880](https://github.com/gakonst/ethers-rs/pull/1880) - Return String for net version [1376](https://github.com/gakonst/ethers-rs/pull/1376) - Stream of paginated logs that load logs in small pages diff --git a/Cargo.lock b/Cargo.lock index 6ed19fbe..66b839fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1185,6 +1185,25 @@ dependencies = [ "cfg-if 1.0.0", ] +[[package]] +name = "enr" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "492a7e5fc2504d5fdce8e124d3e263b244a68b283cac67a69eda0cd43e0aebad" +dependencies = [ + "base64 0.13.1", + "bs58", + "bytes", + "hex", + "k256", + "log", + "rand 0.8.5", + "rlp", + "serde", + "sha3", + "zeroize", +] + [[package]] name = "env_logger" version = "0.10.0" @@ -1479,6 +1498,7 @@ dependencies = [ "auto_impl 1.0.1", "base64 0.13.1", "bytes", + "enr", "ethers-core", "futures-channel", "futures-core", diff --git a/ethers-core/Cargo.toml b/ethers-core/Cargo.toml index e221c89d..68ce8c73 100644 --- a/ethers-core/Cargo.toml +++ b/ethers-core/Cargo.toml @@ -27,7 +27,7 @@ tiny-keccak = { version = "2.0.2", default-features = false } # misc chrono = { version = "0.4", default-features = false } serde = { version = "1.0.124", default-features = false, features = ["derive"] } -serde_json = { version = "1.0.64", default-features = false } +serde_json = { version = "1.0.64", default-features = false, features = ["arbitrary_precision"] } thiserror = { version = "1.0", default-features = false } bytes = { version = "1.3.0", features = ["serde"] } hex = { version = "0.4.3", default-features = false, features = ["std"] } diff --git a/ethers-core/src/types/fee.rs b/ethers-core/src/types/fee.rs index f05df7e2..c6274e66 100644 --- a/ethers-core/src/types/fee.rs +++ b/ethers-core/src/types/fee.rs @@ -1,7 +1,5 @@ -use std::str::FromStr; - -use crate::types::U256; -use serde::{de::Deserializer, Deserialize, Serialize}; +use crate::{types::U256, utils::from_int_or_hex}; +use serde::{Deserialize, Serialize}; #[derive(Deserialize, Serialize, Debug, Clone)] #[serde(rename_all = "camelCase")] @@ -19,19 +17,3 @@ pub struct FeeHistory { #[serde(default)] pub reward: Vec>, } - -fn from_int_or_hex<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - #[derive(Deserialize)] - #[serde(untagged)] - enum IntOrHex { - Int(u64), - Hex(String), - } - match IntOrHex::deserialize(deserializer)? { - IntOrHex::Int(n) => Ok(U256::from(n)), - IntOrHex::Hex(s) => U256::from_str(s.as_str()).map_err(serde::de::Error::custom), - } -} diff --git a/ethers-core/src/utils/genesis.rs b/ethers-core/src/utils/genesis.rs new file mode 100644 index 00000000..232dd7d4 --- /dev/null +++ b/ethers-core/src/utils/genesis.rs @@ -0,0 +1,170 @@ +use std::collections::HashMap; + +use crate::{ + types::{Address, Bytes, H256, U256, U64}, + utils::{from_int_or_hex, from_int_or_hex_opt}, +}; +use serde::{Deserialize, Serialize}; + +/// This represents the chain configuration, specifying the genesis block, header fields, and hard +/// fork switch blocks. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Genesis { + /// The fork configuration for this network. + pub config: ChainConfig, + + /// The genesis header nonce. + pub nonce: U64, + + /// The genesis header timestamp. + pub timestamp: U64, + + /// The genesis header extra data. + pub extra_data: Bytes, + + /// The genesis header gas limit. + pub gas_limit: U64, + + /// The genesis header difficulty. + #[serde(deserialize_with = "from_int_or_hex")] + pub difficulty: U256, + + /// The genesis header mix hash. + pub mix_hash: H256, + + /// The genesis header coinbase address. + pub coinbase: Address, + + /// The initial state of the genesis block. + pub alloc: HashMap, +} + +/// An account in the state of the genesis block. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct GenesisAccount { + #[serde(skip_serializing_if = "Option::is_none")] + pub nonce: Option, + pub balance: U256, + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, + #[serde(flatten, skip_serializing_if = "Option::is_none")] + pub storage: Option>, +} + +/// Represents a node's chain configuration. +/// +/// See [geth's `ChainConfig` +/// struct](https://github.com/ethereum/go-ethereum/blob/64dccf7aa411c5c7cd36090c3d9b9892945ae813/params/config.go#L349) +/// for the source of each field. +#[derive(Clone, Debug, Deserialize, Serialize, Default)] +#[serde(default, rename_all = "camelCase")] +pub struct ChainConfig { + /// The network's chain ID. + pub chain_id: u64, + + /// The homestead switch block (None = no fork, 0 = already homestead). + #[serde(skip_serializing_if = "Option::is_none")] + pub homestead_block: Option, + + /// The DAO fork switch block (None = no fork). + #[serde(skip_serializing_if = "Option::is_none")] + pub dao_fork_block: Option, + + /// Whether or not the node supports the DAO hard-fork. + pub dao_fork_support: bool, + + /// The EIP-150 hard fork block (None = no fork). + #[serde(skip_serializing_if = "Option::is_none")] + pub eip150_block: Option, + + /// The EIP-150 hard fork hash. + #[serde(skip_serializing_if = "Option::is_none")] + pub eip150_hash: Option, + + /// The EIP-155 hard fork block. + #[serde(skip_serializing_if = "Option::is_none")] + pub eip155_block: Option, + + /// The EIP-158 hard fork block. + #[serde(skip_serializing_if = "Option::is_none")] + pub eip158_block: Option, + + /// The Byzantium hard fork block. + #[serde(skip_serializing_if = "Option::is_none")] + pub byzantium_block: Option, + + /// The Constantinople hard fork block. + #[serde(skip_serializing_if = "Option::is_none")] + pub constantinople_block: Option, + + /// The Petersburg hard fork block. + #[serde(skip_serializing_if = "Option::is_none")] + pub petersburg_block: Option, + + /// The Istanbul hard fork block. + #[serde(skip_serializing_if = "Option::is_none")] + pub istanbul_block: Option, + + /// The Muir Glacier hard fork block. + #[serde(skip_serializing_if = "Option::is_none")] + pub muir_glacier_block: Option, + + /// The Berlin hard fork block. + #[serde(skip_serializing_if = "Option::is_none")] + pub berlin_block: Option, + + /// The London hard fork block. + #[serde(skip_serializing_if = "Option::is_none")] + pub london_block: Option, + + /// The Arrow Glacier hard fork block. + #[serde(skip_serializing_if = "Option::is_none")] + pub arrow_glacier_block: Option, + + /// The Gray Glacier hard fork block. + #[serde(skip_serializing_if = "Option::is_none")] + pub gray_glacier_block: Option, + + /// Virtual fork after the merge to use as a network splitter. + #[serde(skip_serializing_if = "Option::is_none")] + pub merge_netsplit_block: Option, + + /// The Shanghai hard fork block. + #[serde(skip_serializing_if = "Option::is_none")] + pub shanghai_block: Option, + + /// The Cancun hard fork block. + #[serde(skip_serializing_if = "Option::is_none")] + pub cancun_block: Option, + + /// Total difficulty reached that triggers the merge consensus upgrade. + #[serde(skip_serializing_if = "Option::is_none", deserialize_with = "from_int_or_hex_opt")] + pub terminal_total_difficulty: Option, + + /// A flag specifying that the network already passed the terminal total difficulty. Its + /// purpose is to disable legacy sync without having seen the TTD locally. + pub terminal_total_difficulty_passed: bool, + + /// Ethash parameters. + #[serde(skip_serializing_if = "Option::is_none")] + pub ethash: Option, + + /// Clique parameters. + #[serde(skip_serializing_if = "Option::is_none")] + pub clique: Option, +} + +/// Empty consensus configuration for proof-of-work networks. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct EthashConfig {} + +/// Consensus configuration for Clique. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct CliqueConfig { + /// Number of seconds between blocks to enforce. + pub period: u64, + + /// Epoch length to reset votes and checkpoints. + pub epoch: u64, +} diff --git a/ethers-core/src/utils/geth.rs b/ethers-core/src/utils/geth.rs index 2a65ab94..657b8da3 100644 --- a/ethers-core/src/utils/geth.rs +++ b/ethers-core/src/utils/geth.rs @@ -1,20 +1,39 @@ -use super::unused_port; +use super::{unused_port, Genesis}; +use crate::types::H256; use std::{ + env::temp_dir, + fs::{create_dir, File}, io::{BufRead, BufReader}, path::PathBuf, - process::{Child, Command}, + process::{Child, Command, Stdio}, time::{Duration, Instant}, }; /// How long we will wait for geth to indicate that it is ready. const GETH_STARTUP_TIMEOUT_MILLIS: u64 = 10_000; +/// Timeout for waiting for geth to add a peer. +const GETH_DIAL_LOOP_TIMEOUT: Duration = Duration::new(20, 0); + /// The exposed APIs -const API: &str = "eth,net,web3,txpool"; +const API: &str = "eth,net,web3,txpool,admin"; /// The geth command const GETH: &str = "geth"; +/// Errors that can occur when working with the [`GethInstance`]. +#[derive(Debug)] +pub enum GethInstanceError { + /// Timed out waiting for a message from geth's stderr. + Timeout(String), + + /// A line could not be read from the geth stderr. + ReadLineError(std::io::Error), + + /// The child geth process's stderr was not captured. + NoStderr, +} + /// A geth instance. Will close the instance when dropped. /// /// Construct this using [`Geth`](crate::utils::Geth) @@ -22,6 +41,8 @@ pub struct GethInstance { pid: Child, port: u16, ipc: Option, + data_dir: Option, + p2p_port: Option, } impl GethInstance { @@ -30,6 +51,11 @@ impl GethInstance { self.port } + /// Returns the p2p port of this instance + pub fn p2p_port(&self) -> Option { + self.p2p_port + } + /// Returns the HTTP endpoint of this instance pub fn endpoint(&self) -> String { format!("http://localhost:{}", self.port) @@ -40,9 +66,35 @@ impl GethInstance { format!("ws://localhost:{}", self.port) } + /// Returns the path to this instances' IPC socket pub fn ipc_path(&self) -> &Option { &self.ipc } + + /// Returns the path to this instances' data directory + pub fn data_dir(&self) -> &Option { + &self.data_dir + } + + /// Blocks until geth adds the specified peer, using 20s as the timeout. + 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)?; + + // geth ids are trunated + 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 { @@ -51,6 +103,44 @@ impl Drop for GethInstance { } } +/// Whether or not geth is in `dev` mode and configuration options that depend on the mode. +#[derive(Debug, Clone)] +pub enum GethMode { + /// Options that can be set in dev mode + Dev(DevOptions), + /// Options that cannot be set in dev mode + NonDev(PrivateNetOptions), +} + +impl Default for GethMode { + fn default() -> Self { + Self::Dev(Default::default()) + } +} + +/// Configuration options that can be set in dev mode. +#[derive(Debug, Clone, Default)] +pub struct DevOptions { + /// The interval at which the dev chain will mine new blocks. + pub block_time: Option, +} + +/// Configuration options that cannot be set in dev mode. +#[derive(Debug, Clone)] +pub struct PrivateNetOptions { + /// The p2p port to use. + pub p2p_port: Option, + + /// Whether or not peer discovery is enabled. + pub discovery: bool, +} + +impl Default for PrivateNetOptions { + fn default() -> Self { + Self { p2p_port: None, discovery: true } + } +} + /// Builder for launching `geth`. /// /// # Panics @@ -75,8 +165,12 @@ impl Drop for GethInstance { #[derive(Clone, Default)] pub struct Geth { port: Option, - block_time: Option, + authrpc_port: Option, ipc_path: Option, + data_dir: Option, + chain_id: Option, + genesis: Option, + mode: GethMode, } impl Geth { @@ -93,10 +187,54 @@ impl Geth { self } + /// Sets the port which will be used for incoming p2p connections. + /// + /// This will put the geth instance into non-dev mode, discarding any previously set dev-mode + /// options. + #[must_use] + 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 + } + /// Sets the block-time which will be used when the `geth-cli` instance is launched. + /// + /// This will put the geth instance in `dev` mode, discarding any previously set options that + /// cannot be used in dev mode. #[must_use] pub fn block_time>(mut self, block_time: T) -> Self { - self.block_time = Some(block_time.into()); + self.mode = GethMode::Dev(DevOptions { block_time: Some(block_time.into()) }); + self + } + + /// Sets the chain id for the geth instance. + #[must_use] + pub fn chain_id>(mut self, chain_id: T) -> Self { + self.chain_id = Some(chain_id.into()); + self + } + + /// Disable discovery for the geth instance. + /// + /// This will put the geth instance into non-dev mode, discarding any previously set dev-mode + /// options. + #[must_use] + pub fn disable_discovery(mut self) -> Self { + match self.mode { + GethMode::Dev(_) => { + self.mode = + GethMode::NonDev(PrivateNetOptions { discovery: false, ..Default::default() }) + } + GethMode::NonDev(ref mut opts) => opts.discovery = false, + } self } @@ -107,6 +245,32 @@ impl Geth { self } + /// Sets the data directory for geth. + #[must_use] + pub fn data_dir>(mut self, path: T) -> Self { + self.data_dir = Some(path.into()); + self + } + + /// Sets the `genesis.json` for the geth instance. + /// + /// If this is set, geth will be initialized with `geth init` and the `--datadir` option will be + /// set to the same value as `data_dir`. + /// + /// This is destructive and will overwrite any existing data in the data directory. + #[must_use] + pub fn genesis(mut self, genesis: Genesis) -> Self { + self.genesis = Some(genesis); + self + } + + /// Sets the port for authenticated RPC connections. + #[must_use] + pub fn authrpc_port(mut self, port: u16) -> Self { + self.authrpc_port = Some(port); + self + } + /// Consumes the builder and spawns `geth` with stdout redirected /// to /dev/null. pub fn spawn(self) -> GethInstance { @@ -114,6 +278,7 @@ impl Geth { // geth uses stderr for its logs cmd.stderr(std::process::Stdio::piped()); let port = if let Some(port) = self.port { port } else { unused_port() }; + let authrpc_port = if let Some(port) = self.authrpc_port { port } else { unused_port() }; // Open the HTTP API cmd.arg("--http"); @@ -125,22 +290,88 @@ impl Geth { cmd.arg("--ws.port").arg(port.to_string()); cmd.arg("--ws.api").arg(API); - // Dev mode with custom block time - cmd.arg("--dev"); - if let Some(block_time) = self.block_time { - cmd.arg("--dev.period").arg(block_time.to_string()); + // Set the port for authenticated APIs + cmd.arg("--authrpc.port").arg(authrpc_port.to_string()); + + // use geth init to initialize the datadir if the genesis exists + if let Some(genesis) = self.genesis { + // create a temp dir to store the genesis file + let temp_genesis_path = temp_dir().join("genesis.json"); + + // create the genesis file + let mut file = File::create(&temp_genesis_path).expect("could not create genesis file"); + + // serialize genesis and write to file + serde_json::to_writer_pretty(&mut file, &genesis) + .expect("could not write genesis to file"); + + let mut init_cmd = Command::new(GETH); + if let Some(ref data_dir) = self.data_dir { + init_cmd.arg("--datadir").arg(data_dir); + } + + // set the stderr to null so we don't pollute the test output + init_cmd.stderr(Stdio::null()); + + init_cmd.arg("init").arg(temp_genesis_path); + init_cmd + .spawn() + .expect("failed to spawn geth init") + .wait() + .expect("failed to wait for geth init to exit"); } + if let Some(ref data_dir) = self.data_dir { + cmd.arg("--datadir").arg(data_dir); + + // create the directory if it doesn't exist + if !data_dir.exists() { + create_dir(data_dir).expect("could not create data dir"); + } + } + + // Dev mode with custom block time + 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()); + } + } + GethMode::NonDev(PrivateNetOptions { p2p_port, discovery }) => { + if let Some(p2p_port) = p2p_port { + cmd.arg("--port").arg(p2p_port.to_string()); + } + + // disable discovery if the flag is set + if !discovery { + cmd.arg("--nodiscover"); + } + } + } + + if let Some(chain_id) = self.chain_id { + cmd.arg("--networkid").arg(chain_id.to_string()); + } + + // debug verbosity is needed to check when peers are added + 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 stdout = child.stderr.expect("Unable to get stderr for geth child process"); + let stderr = child.stderr.expect("Unable to get stderr for geth child process"); let start = Instant::now(); - let mut reader = BufReader::new(stdout); + let mut reader = BufReader::new(stderr); + + // we shouldn't need to wait for p2p to start if geth is in dev mode - p2p is disabled in + // dev mode + let mut p2p_started = matches!(self.mode, GethMode::Dev(_)); + let mut http_started = false; loop { if start + Duration::from_millis(GETH_STARTUP_TIMEOUT_MILLIS) <= Instant::now() { @@ -150,14 +381,30 @@ impl Geth { let mut line = String::new(); 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; + } + // geth 1.9.23 uses "server started" while 1.9.18 uses "endpoint opened" - if line.contains("HTTP endpoint opened") || line.contains("HTTP server started") { + // the unauthenticated api is used for regular non-engine API requests + if line.contains("HTTP endpoint opened") || + (line.contains("HTTP server started") && !line.contains("auth=true")) + { + http_started = true; + } + + if p2p_started && http_started { break } } child.stderr = Some(reader.into_inner()); - GethInstance { pid: child, port, ipc: self.ipc_path } + let p2p_port = match self.mode { + GethMode::Dev(_) => None, + GethMode::NonDev(PrivateNetOptions { p2p_port, .. }) => p2p_port, + }; + + GethInstance { pid: child, port, ipc: self.ipc_path, data_dir: self.data_dir, p2p_port } } } diff --git a/ethers-core/src/utils/mod.rs b/ethers-core/src/utils/mod.rs index f3e7d7bc..5a2e1315 100644 --- a/ethers-core/src/utils/mod.rs +++ b/ethers-core/src/utils/mod.rs @@ -10,6 +10,10 @@ mod geth; #[cfg(not(target_arch = "wasm32"))] pub use geth::{Geth, GethInstance}; +/// Utilities for working with a `genesis.json` and other chain config structs. +mod genesis; +pub use genesis::{ChainConfig, Genesis}; + /// Utilities for launching an anvil instance #[cfg(not(target_arch = "wasm32"))] mod anvil; @@ -23,6 +27,7 @@ mod hash; pub use hash::{hash_message, id, keccak256, serialize}; mod units; +use serde::{Deserialize, Deserializer}; pub use units::Units; /// Re-export RLP @@ -38,6 +43,7 @@ use k256::{ecdsa::SigningKey, PublicKey as K256PublicKey}; use std::{ convert::{TryFrom, TryInto}, fmt, + str::FromStr, }; use thiserror::Error; @@ -452,6 +458,34 @@ pub fn eip1559_default_estimator(base_fee_per_gas: U256, rewards: Vec> (max_fee_per_gas, max_priority_fee_per_gas) } +/// Deserializes the input into a U256, accepting both 0x-prefixed hex and decimal strings with +/// arbitrary precision, defined by serde_json's [`Number`](serde_json::Number). +pub fn from_int_or_hex<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum IntOrHex { + Int(serde_json::Number), + Hex(String), + } + + match IntOrHex::deserialize(deserializer)? { + IntOrHex::Hex(s) => U256::from_str(s.as_str()).map_err(serde::de::Error::custom), + IntOrHex::Int(n) => U256::from_dec_str(&n.to_string()).map_err(serde::de::Error::custom), + } +} + +/// Deserializes the input into an `Option`, using [`from_int_or_hex`] to deserialize the +/// inner value. +pub fn from_int_or_hex_opt<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Ok(Some(from_int_or_hex(deserializer)?)) +} + fn estimate_priority_fee(rewards: Vec>) -> U256 { let mut rewards: Vec = rewards.iter().map(|r| r[0]).filter(|r| *r > U256::zero()).collect(); diff --git a/ethers-providers/Cargo.toml b/ethers-providers/Cargo.toml index 83ca1b51..b924c882 100644 --- a/ethers-providers/Cargo.toml +++ b/ethers-providers/Cargo.toml @@ -35,6 +35,9 @@ futures-timer = { version = "3.0.2", default-features = false } futures-channel = { version = "0.3.16", default-features = false, optional = true } pin-project = { version = "1.0.11", default-features = false } +# peer-related admin namespace +enr = { version = "0.7.0", default-features = false, features = ["k256", "serde"] } + # tracing tracing = { version = "0.1.37", default-features = false } tracing-futures = { version = "0.2.5", default-features = false, features = ["std-future"] } diff --git a/ethers-providers/src/admin.rs b/ethers-providers/src/admin.rs new file mode 100644 index 00000000..3de1ed4e --- /dev/null +++ b/ethers-providers/src/admin.rs @@ -0,0 +1,402 @@ +use crate::{H256, U256}; +use enr::{k256::ecdsa::SigningKey, Enr}; +use ethers_core::utils::{from_int_or_hex, ChainConfig}; +use serde::{Deserialize, Serialize}; +use std::net::{IpAddr, SocketAddr}; + +/// This includes general information about a running node, spanning networking and protocol +/// details. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct NodeInfo { + /// The node's private key. + pub id: H256, + + /// The node's user agent, containing a client name, version, OS, and other metadata. + pub name: String, + + /// The enode URL of the connected node. + pub enode: String, + + /// The [ENR](https://eips.ethereum.org/EIPS/eip-778) of the running client. + pub enr: Enr, + + /// The IP address of the connected node. + pub ip: IpAddr, + + /// The node's listening ports. + pub ports: Ports, + + /// The node's listening address. + #[serde(rename = "listenAddr")] + pub listen_addr: String, + + /// The protocols that the node supports, with protocol metadata. + pub protocols: ProtocolInfo, +} + +/// Represents a node's discovery and listener ports. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Ports { + /// The node's discovery port. + pub discovery: u16, + + /// The node's listener port. + pub listener: u16, +} + +/// Represents protocols that the connected RPC node supports. +/// +/// This contains protocol information reported by the connected RPC node. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ProtocolInfo { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub eth: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub snap: Option, +} + +/// Represents a short summary of the `eth` sub-protocol metadata known about the host peer. +/// +/// See [geth's `NodeInfo` +/// struct](https://github.com/ethereum/go-ethereum/blob/c2e0abce2eedc1ba2a1b32c46fd07ef18a25354a/eth/protocols/eth/handler.go#L129) +/// for how these fields are determined. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct EthProtocolInfo { + /// The eth network version. + pub network: u64, + + /// The total difficulty of the host's blockchain. + #[serde(deserialize_with = "from_int_or_hex")] + pub difficulty: U256, + + /// The Keccak hash of the host's genesis block. + pub genesis: H256, + + /// The chain configuration for the host's fork rules. + pub config: ChainConfig, + + /// The hash of the host's best known block. + pub head: H256, +} + +/// Represents a short summary of the host's `snap` sub-protocol metadata. +/// +/// This is just an empty struct, because [geth's internal representation is +/// empty](https://github.com/ethereum/go-ethereum/blob/c2e0abce2eedc1ba2a1b32c46fd07ef18a25354a/eth/protocols/snap/handler.go#L571-L576). +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SnapProtocolInfo {} + +/// Represents the protocols that a peer supports. +/// +/// This differs from [`ProtocolInfo`] in that [`PeerProtocolInfo`] contains protocol information +/// gathered from the protocol handshake, and [`ProtocolInfo`] contains information reported by the +/// connected RPC node. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct PeerProtocolInfo { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub eth: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub snap: Option, +} + +/// Can contain either eth protocol info or a string "handshake", which geth uses if the peer is +/// still completing the handshake for the protocol. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(untagged)] +pub enum EthPeerInfo { + /// The `eth` sub-protocol metadata known about the host peer. + Info(Box), + + /// The string "handshake" if the peer is still completing the handshake for the protocol. + #[serde(deserialize_with = "deser_handshake", serialize_with = "ser_handshake")] + Handshake, +} + +/// Represents a short summary of the `eth` sub-protocol metadata known about a connected peer +/// +/// See [geth's `ethPeerInfo` +/// struct](https://github.com/ethereum/go-ethereum/blob/53d1ae096ac0515173e17f0f81a553e5f39027f7/eth/peer.go#L28) +/// for how these fields are determined. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct EthInfo { + /// The negotiated eth version. + pub version: u64, + + /// The total difficulty of the peer's blockchain. + #[serde(deserialize_with = "from_int_or_hex")] + pub difficulty: U256, + + /// The hash of the peer's best known block. + pub head: H256, +} + +/// Can contain either snap protocol info or a string "handshake", which geth uses if the peer is +/// still completing the handshake for the protocol. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(untagged)] +pub enum SnapPeerInfo { + /// The `snap` sub-protocol metadata known about the host peer. + Info(SnapInfo), + + /// The string "handshake" if the peer is still completing the handshake for the protocol. + #[serde(deserialize_with = "deser_handshake", serialize_with = "ser_handshake")] + Handshake, +} + +/// Represents a short summary of the `snap` sub-protocol metadata known about a connected peer. +/// +/// See [geth's `snapPeerInfo` +/// struct](https://github.com/ethereum/go-ethereum/blob/53d1ae096ac0515173e17f0f81a553e5f39027f7/eth/peer.go#L53) +/// for how these fields are determined. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct SnapInfo { + /// The negotiated snap version. + pub version: u64, +} + +/// Represents a short summary of information known about a connected peer. +/// +/// See [geth's `PeerInfo` struct](https://github.com/ethereum/go-ethereum/blob/64dccf7aa411c5c7cd36090c3d9b9892945ae813/p2p/peer.go#L484) for the source of each field. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct PeerInfo { + /// The peer's ENR. + #[serde(skip_serializing_if = "Option::is_none")] + pub enr: Option>, + + /// The peer's enode URL. + pub enode: String, + + /// The peer's enode ID. + pub id: String, + + /// The peer's name. + pub name: String, + + /// The peer's capabilities. + pub caps: Vec, + + /// Networking information about the peer. + pub network: PeerNetworkInfo, + + /// The protocols that the peer supports, with protocol metadata. + pub protocols: PeerProtocolInfo, +} + +/// Represents networking related information about the peer, including details about whether or +/// not it is inbound, trusted, or static. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PeerNetworkInfo { + /// The local endpoint of the TCP connection. + pub local_address: SocketAddr, + + /// The remote endpoint of the TCP connection. + pub remote_address: SocketAddr, + + /// Whether or not the peer is inbound. + pub inbound: bool, + + /// Whether or not the peer is trusted. + pub trusted: bool, + + /// Whether or not the peer is a static peer. + #[serde(rename = "static")] + pub static_node: bool, +} + +fn deser_handshake<'de, D>(deserializer: D) -> Result<(), D::Error> +where + D: serde::Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + if s == "handshake" { + Ok(()) + } else { + Err(serde::de::Error::custom( + "expected \"handshake\" if protocol info did not appear in the response", + )) + } +} + +fn ser_handshake(serializer: S) -> Result +where + S: serde::Serializer, +{ + serializer.serialize_str("handshake") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserialize_peer_info() { + let response = r#"{ + "enode":"enode://bb37b7302f79e47c1226d6e3ccf0ef6d51146019efdcc1f6e861fd1c1a78d5e84e486225a6a8a503b93d5c50125ee980835c92bde7f7d12f074c16f4e439a578@127.0.0.1:60872", + "id":"ca23c04b7e796da5d6a5f04a62b81c88d41b1341537db85a2b6443e838d8339b", + "name":"Geth/v1.10.19-stable/darwin-arm64/go1.18.3", + "caps":["eth/66","eth/67","snap/1"], + "network":{ + "localAddress":"127.0.0.1:30304", + "remoteAddress":"127.0.0.1:60872", + "inbound":true, + "trusted":false, + "static":false + }, + "protocols":{ + "eth":{ + "version":67, + "difficulty":0, + "head":"0xb04009ddf4b0763f42778e7d5937e49bebf1e11b2d26c9dac6cefb5f84b6f8ea" + }, + "snap":{"version":1} + } + }"#; + let peer_info: PeerInfo = serde_json::from_str(response).unwrap(); + + assert_eq!(peer_info.enode, "enode://bb37b7302f79e47c1226d6e3ccf0ef6d51146019efdcc1f6e861fd1c1a78d5e84e486225a6a8a503b93d5c50125ee980835c92bde7f7d12f074c16f4e439a578@127.0.0.1:60872"); + } + + #[test] + fn deserialize_node_info() { + // this response also has an enr + let response = r#"{ + "id":"6e2fe698f3064cd99410926ce16734e35e3cc947d4354461d2594f2d2dd9f7b6", + "name":"Geth/v1.10.19-stable/darwin-arm64/go1.18.3", + "enode":"enode://d7dfaea49c7ef37701e668652bcf1bc63d3abb2ae97593374a949e175e4ff128730a2f35199f3462a56298b981dfc395a5abebd2d6f0284ffe5bdc3d8e258b86@127.0.0.1:30304?discport=0", + "enr":"enr:-Jy4QIvS0dKBLjTTV_RojS8hjriwWsJNHRVyOh4Pk4aUXc5SZjKRVIOeYc7BqzEmbCjLdIY4Ln7x5ZPf-2SsBAc2_zqGAYSwY1zog2V0aMfGhNegsXuAgmlkgnY0gmlwhBiT_DiJc2VjcDI1NmsxoQLX366knH7zdwHmaGUrzxvGPTq7Kul1kzdKlJ4XXk_xKIRzbmFwwIN0Y3CCdmA", + "ip":"127.0.0.1", + "ports":{ + "discovery":0, + "listener":30304 + }, + "listenAddr":"[::]:30304", + "protocols":{ + "eth":{ + "network":1337, + "difficulty":0, + "genesis":"0xb04009ddf4b0763f42778e7d5937e49bebf1e11b2d26c9dac6cefb5f84b6f8ea", + "config":{ + "chainId":0, + "eip150Hash":"0x0000000000000000000000000000000000000000000000000000000000000000" + }, + "head":"0xb04009ddf4b0763f42778e7d5937e49bebf1e11b2d26c9dac6cefb5f84b6f8ea" + }, + "snap":{} + } + }"#; + + let _: NodeInfo = serde_json::from_str(response).unwrap(); + } + + #[test] + fn deserialize_node_info_post_merge() { + // this response also has an enr + let response = r#"{ + "id":"6e2fe698f3064cd99410926ce16734e35e3cc947d4354461d2594f2d2dd9f7b6", + "name":"Geth/v1.10.19-stable/darwin-arm64/go1.18.3", + "enode":"enode://d7dfaea49c7ef37701e668652bcf1bc63d3abb2ae97593374a949e175e4ff128730a2f35199f3462a56298b981dfc395a5abebd2d6f0284ffe5bdc3d8e258b86@127.0.0.1:30304?discport=0", + "enr":"enr:-Jy4QIvS0dKBLjTTV_RojS8hjriwWsJNHRVyOh4Pk4aUXc5SZjKRVIOeYc7BqzEmbCjLdIY4Ln7x5ZPf-2SsBAc2_zqGAYSwY1zog2V0aMfGhNegsXuAgmlkgnY0gmlwhBiT_DiJc2VjcDI1NmsxoQLX366knH7zdwHmaGUrzxvGPTq7Kul1kzdKlJ4XXk_xKIRzbmFwwIN0Y3CCdmA", + "ip":"127.0.0.1", + "ports":{ + "discovery":0, + "listener":30304 + }, + "listenAddr":"[::]:30304", + "protocols":{ + "eth":{ + "network":1337, + "difficulty":0, + "genesis":"0xb04009ddf4b0763f42778e7d5937e49bebf1e11b2d26c9dac6cefb5f84b6f8ea", + "config":{ + "chainId":0, + "eip150Hash":"0x0000000000000000000000000000000000000000000000000000000000000000", + "terminalTotalDifficulty":58750000000000000000000, + "terminalTotalDifficultyPassed":true, + "ethash":{} + }, + "head":"0xb04009ddf4b0763f42778e7d5937e49bebf1e11b2d26c9dac6cefb5f84b6f8ea" + }, + "snap":{} + } + }"#; + + let _: NodeInfo = serde_json::from_str(response).unwrap(); + } + + #[test] + fn deserialize_node_info_mainnet_full() { + let actual_response = r#"{ + "id": "74477ca052fcf55ee9eafb369fafdb3e91ad7b64fbd7ae15a4985bfdc43696f2", + "name": "Geth/v1.10.26-stable/darwin-arm64/go1.19.3", + "enode": "enode://962184c6f2a19e064e2ddf0d5c5a788c8c5ed3a4909b7f75fb4dad967392ff542772bcc498cd7f15e13eecbde830265f379779c6da1f71fb8fe1a4734dfc0a1e@127.0.0.1:13337?discport=0", + "enr": "enr:-J-4QFttJyL3f2-B2TQmBZNFxex99TSBv1YtB_8jqUbXWkf6LOREKQAPW2bIn8kJ8QvHbWxCQNFzTX6sehjbrz1ZkSuGAYSyQ0_rg2V0aMrJhPxk7ASDEYwwgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQKWIYTG8qGeBk4t3w1cWniMjF7TpJCbf3X7Ta2Wc5L_VIRzbmFwwIN0Y3CCNBk", + "ip": "127.0.0.1", + "ports": { + "discovery": 0, + "listener": 13337 + }, + "listenAddr": "[::]:13337", + "protocols": { + "eth": { + "network": 1337, + "difficulty": 17179869184, + "genesis": "0xd4e56740f876aef8c010b86a40d5f56745a118d0906a34e69aec8c0db1cb8fa3", + "config": { + "chainId": 1, + "homesteadBlock": 1150000, + "daoForkBlock": 1920000, + "daoForkSupport": true, + "eip150Block": 2463000, + "eip150Hash": "0x2086799aeebeae135c246c65021c82b4e15a2c451340993aacfd2751886514f0", + "eip155Block": 2675000, + "eip158Block": 2675000, + "byzantiumBlock": 4370000, + "constantinopleBlock": 7280000, + "petersburgBlock": 7280000, + "istanbulBlock": 9069000, + "muirGlacierBlock": 9200000, + "berlinBlock": 12244000, + "londonBlock": 12965000, + "arrowGlacierBlock": 13773000, + "grayGlacierBlock": 15050000, + "terminalTotalDifficulty": 58750000000000000000000, + "terminalTotalDifficultyPassed": true, + "ethash": {} + }, + "head": "0xd4e56740f876aef8c010b86a40d5f56745a118d0906a34e69aec8c0db1cb8fa3" + }, + "snap": {} + } + }"#; + + let _: NodeInfo = serde_json::from_str(actual_response).unwrap(); + } + + #[test] + fn deserialize_peer_info_handshake() { + let response = r#"{ + "enode": "enode://a997fde0023537ad01e536ebf2eeeb4b4b3d5286707586727b704f32e8e2b4959e08b6db5b27eb6b7e9f6efcbb53657f4e2bd16900aa77a89426dc3382c29ce0@[::1]:60948", + "id": "df6f8bc331005962c2ef1f5236486a753bc6b2ddb5ef04370757999d1ca832d4", + "name": "Geth/v1.10.26-stable-e5eb32ac/linux-amd64/go1.18.5", + "caps": ["eth/66","eth/67","snap/1"], + "network":{ + "localAddress":"[::1]:30304", + "remoteAddress":"[::1]:60948", + "inbound":true, + "trusted":false, + "static":false + }, + "protocols":{ + "eth":"handshake", + "snap":"handshake" + } + }"#; + + let info: PeerInfo = serde_json::from_str(response).unwrap(); + assert_eq!(info.protocols.eth, Some(EthPeerInfo::Handshake)); + assert_eq!(info.protocols.snap, Some(SnapPeerInfo::Handshake)); + } +} diff --git a/ethers-providers/src/lib.rs b/ethers-providers/src/lib.rs index 6905b14a..9c7c22a1 100644 --- a/ethers-providers/src/lib.rs +++ b/ethers-providers/src/lib.rs @@ -10,6 +10,10 @@ pub use transports::*; mod provider; pub use provider::{is_local_endpoint, FilterKind, Provider, ProviderError, ProviderExt}; +// types for the admin api +pub mod admin; +pub use admin::{NodeInfo, PeerInfo}; + // ENS support pub mod ens; @@ -488,6 +492,32 @@ pub trait Middleware: Sync + Send + Debug { self.inner().get_proof(from, locations, block).await.map_err(FromErr::from) } + // Admin namespace + + async fn add_peer(&self, enode_url: String) -> Result { + self.inner().add_peer(enode_url).await.map_err(FromErr::from) + } + + async fn add_trusted_peer(&self, enode_url: String) -> Result { + self.inner().add_trusted_peer(enode_url).await.map_err(FromErr::from) + } + + async fn node_info(&self) -> Result { + self.inner().node_info().await.map_err(FromErr::from) + } + + async fn peers(&self) -> Result, Self::Error> { + self.inner().peers().await.map_err(FromErr::from) + } + + async fn remove_peer(&self, enode_url: String) -> Result { + self.inner().remove_peer(enode_url).await.map_err(FromErr::from) + } + + async fn remove_trusted_peer(&self, enode_url: String) -> Result { + self.inner().remove_trusted_peer(enode_url).await.map_err(FromErr::from) + } + // Mempool inspection for Geth's API async fn txpool_content(&self) -> Result { diff --git a/ethers-providers/src/provider.rs b/ethers-providers/src/provider.rs index e38464da..846742d2 100644 --- a/ethers-providers/src/provider.rs +++ b/ethers-providers/src/provider.rs @@ -4,7 +4,7 @@ use crate::{ pubsub::{PubsubClient, SubscriptionStream}, stream::{FilterWatcher, DEFAULT_LOCAL_POLL_INTERVAL, DEFAULT_POLL_INTERVAL}, FromErr, Http as HttpProvider, JsonRpcClient, JsonRpcClientWrapper, LogQuery, MockProvider, - PendingTransaction, QuorumProvider, RwClient, SyncingStatus, + NodeInfo, PeerInfo, PendingTransaction, QuorumProvider, RwClient, SyncingStatus, }; #[cfg(all(not(target_arch = "wasm32"), feature = "ws"))] @@ -798,6 +798,48 @@ impl Middleware for Provider

{ self.request("eth_getProof", [from, locations, block]).await } + // Admin namespace + + /// Requests adding the given peer, returning a boolean representing whether or not the peer + /// was accepted for tracking. + async fn add_peer(&self, enode_url: String) -> Result { + let enode_url = utils::serialize(&enode_url); + self.request("admin_addPeer", [enode_url]).await + } + + /// Requests adding the given peer as a trusted peer, which the node will always connect to + /// even when its peer slots are full. + async fn add_trusted_peer(&self, enode_url: String) -> Result { + let enode_url = utils::serialize(&enode_url); + self.request("admin_addTrustedPeer", [enode_url]).await + } + + /// Returns general information about the node as well as information about the running p2p + /// protocols (e.g. `eth`, `snap`). + async fn node_info(&self) -> Result { + self.request("admin_nodeInfo", ()).await + } + + /// Returns the list of peers currently connected to the node. + async fn peers(&self) -> Result, Self::Error> { + self.request("admin_peers", ()).await + } + + /// Requests to remove the given peer, returning true if the enode was successfully parsed and + /// the peer was removed. + async fn remove_peer(&self, enode_url: String) -> Result { + let enode_url = utils::serialize(&enode_url); + self.request("admin_removePeer", [enode_url]).await + } + + /// Requests to remove the given peer, returning a boolean representing whether or not the + /// enode url passed was validated. A return value of `true` does not necessarily mean that the + /// peer was disconnected. + async fn remove_trusted_peer(&self, enode_url: String) -> Result { + let enode_url = utils::serialize(&enode_url); + self.request("admin_removeTrustedPeer", [enode_url]).await + } + ////// Ethereum Naming Service // The Ethereum Naming Service (ENS) allows easy to remember and use names to // be assigned to Ethereum addresses. Any provider operation which takes an address @@ -1753,13 +1795,15 @@ pub mod dev_rpc { #[cfg(test)] #[cfg(not(target_arch = "wasm32"))] mod tests { + use std::path::PathBuf; + use super::*; use crate::Http; use ethers_core::{ types::{ transaction::eip2930::AccessList, Eip1559TransactionRequest, TransactionRequest, H256, }, - utils::Anvil, + utils::{Anvil, Genesis, Geth, GethInstance}, }; use futures_util::StreamExt; @@ -2124,4 +2168,151 @@ mod tests { "ens name not found: `ox63616e.eth` resolver (0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2) is invalid." ); } + + #[tokio::test] + async fn geth_admin_nodeinfo() { + // we can't use the test provider because infura does not expose admin endpoints + let port = 8546u16; + let p2p_listener_port = 13337u16; + let authrpc_port = 8552u16; + let network = 1337u64; + let temp_dir = tempfile::tempdir().unwrap().into_path(); + + let (geth, provider) = spawn_geth_and_create_provider( + network, + port, + p2p_listener_port, + authrpc_port, + Some(temp_dir), + None, + ); + + let info = provider.node_info().await.unwrap(); + drop(geth); + + // check that the port we set works + assert_eq!(info.ports.listener, p2p_listener_port); + + // make sure it is running eth + assert!(info.protocols.eth.is_some()); + + // check that the network id is correct + assert_eq!(info.protocols.eth.unwrap().network, network); + } + + /// Spawn a new `GethInstance` without discovery and crate a `Provider` for it. + /// + /// These will all use the same genesis config. + fn spawn_geth_and_create_provider( + chain_id: u64, + rpc_port: u16, + p2p_port: u16, + authrpc_port: u16, + datadir: Option, + genesis: Option, + ) -> (GethInstance, Provider) { + let geth = Geth::new() + .port(rpc_port) + .p2p_port(p2p_port) + .authrpc_port(authrpc_port) + .chain_id(chain_id) + .disable_discovery(); + + let geth = match genesis { + Some(genesis) => geth.genesis(genesis), + None => geth, + }; + + let geth = match datadir { + Some(dir) => geth.data_dir(dir), + None => geth, + } + .spawn(); + + let url = format!("http://127.0.0.1:{}", rpc_port); + let provider = Provider::try_from(url).unwrap(); + (geth, provider) + } + + /// Spawn a set of [`GethInstance`]s with the list of given data directories and [`Provider`]s + /// for those [`GethInstance`]s without discovery, setting sequential ports for their p2p, rpc, + /// and authrpc ports. + fn spawn_geth_instances( + datadirs: Vec, + chain_id: u64, + genesis: Option, + ) -> Vec<(GethInstance, Provider)> { + let mut geths = Vec::new(); + let mut p2p_port = 30303; + let mut rpc_port = 8545; + let mut authrpc_port = 8551; + + for dir in datadirs { + let (geth, provider) = spawn_geth_and_create_provider( + chain_id, + rpc_port, + p2p_port, + authrpc_port, + Some(dir), + genesis.clone(), + ); + + geths.push((geth, provider)); + + p2p_port += 1; + rpc_port += 1; + authrpc_port += 1; + } + + geths + } + + #[tokio::test] + async fn add_second_geth_peer() { + // init each geth directory + let dir1 = tempfile::tempdir().unwrap().into_path(); + let dir2 = tempfile::tempdir().unwrap().into_path(); + + // use the default genesis + let genesis = utils::Genesis::default(); + + // spawn the geths + let mut geths = spawn_geth_instances(vec![dir1.clone(), dir2.clone()], 1337, Some(genesis)); + let (mut first_geth, first_peer) = geths.pop().unwrap(); + let (second_geth, second_peer) = geths.pop().unwrap(); + + // get nodeinfo for each geth instance + let first_info = first_peer.node_info().await.unwrap(); + let second_info = second_peer.node_info().await.unwrap(); + let first_port = first_info.ports.listener; + + // replace the ip in the enode by putting + let first_prefix = first_info.enode.split('@').collect::>(); + + // create enodes for each geth instance using each id and port + let first_enode = format!("{}@localhost:{}", first_prefix.first().unwrap(), first_port); + + // add the first geth as a peer for the second + let res = second_peer.add_peer(first_enode).await.unwrap(); + assert!(res); + + // wait on the listening peer for an incoming connection + first_geth.wait_to_add_peer(second_info.id).unwrap(); + + // check that second_geth exists in the first_geth peer list + let peers = first_peer.peers().await.unwrap(); + + drop(first_geth); + drop(second_geth); + + // check that the second peer is in the list (it uses an enr so the enr should be Some) + assert_eq!(peers.len(), 1); + + let peer = peers.get(0).unwrap(); + assert_eq!(H256::from_str(&peer.id).unwrap(), second_info.id); + + // remove directories + std::fs::remove_dir_all(dir1).unwrap(); + std::fs::remove_dir_all(dir2).unwrap(); + } }