Skip to content

Commit

Permalink
feat: add alloy-node-bindings (#111)
Browse files Browse the repository at this point in the history
* feat: fork from ethers

* feat: migrate

* chore: lints

* chore: lints

* chore: clippy

* chore: rename to node-bindings
  • Loading branch information
DaniPopes committed Jan 14, 2024
1 parent 44ddd61 commit 5ed60f8
Show file tree
Hide file tree
Showing 13 changed files with 2,187 additions and 11 deletions.
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,12 @@ jobs:
- name: cargo hack
run: |
cargo hack check --workspace --target wasm32-unknown-unknown \
--exclude alloy-transport-ipc \
--exclude alloy-signer \
--exclude alloy-signer-aws \
--exclude alloy-signer-ledger \
--exclude alloy-signer-trezor
--exclude alloy-signer-trezor \
--exclude alloy-node-bindings \
--exclude alloy-transport-ipc
feature-checks:
runs-on: ubuntu-latest
Expand Down
3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@ alloy-consensus = { version = "0.1.0", path = "crates/consensus" }
alloy-eips = { version = "0.1.0", path = "crates/eips" }
alloy-json-rpc = { version = "0.1.0", path = "crates/json-rpc" }
alloy-network = { version = "0.1.0", path = "crates/network" }
alloy-node-bindings = { version = "0.1.0", path = "crates/node-bindings" }
alloy-pubsub = { version = "0.1.0", path = "crates/pubsub" }
alloy-rpc-client = { version = "0.1.0", path = "crates/rpc-client" }
alloy-rpc-types = { version = "0.1.0", path = "crates/rpc-types" }
alloy-rpc-trace-types = { version = "0.1.0", path = "crates/rpc-trace-types" }
alloy-rpc-types = { version = "0.1.0", path = "crates/rpc-types" }
alloy-signer = { version = "0.1.0", path = "crates/signer" }
alloy-signer-aws = { version = "0.1.0", path = "crates/signer-aws" }
alloy-signer-ledger = { version = "0.1.0", path = "crates/signer-ledger" }
Expand Down
22 changes: 22 additions & 0 deletions crates/node-bindings/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
[package]
name = "alloy-node-bindings"
description = "Ethereum execution-layer client bindings"

version.workspace = true
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
exclude.workspace = true

[dependencies]
alloy-primitives = { workspace = true, features = ["k256", "serde"] }
k256.workspace = true
serde_json.workspace = true
serde.workspace = true
tempfile.workspace = true

[dev-dependencies]
rand.workspace = true
3 changes: 3 additions & 0 deletions crates/node-bindings/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# alloy-node-bindings

Ethereum execution-layer client bindings.
332 changes: 332 additions & 0 deletions crates/node-bindings/src/anvil.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,332 @@
//! Utilities for launching an Anvil instance.

use crate::unused_port;
use alloy_primitives::{hex, Address};
use k256::{ecdsa::SigningKey, SecretKey as K256SecretKey};
use std::{
io::{BufRead, BufReader},
path::PathBuf,
process::{Child, Command},
time::{Duration, Instant},
};

/// How long we will wait for anvil to indicate that it is ready.
const ANVIL_STARTUP_TIMEOUT_MILLIS: u64 = 10_000;

/// An anvil CLI instance. Will close the instance when dropped.
///
/// Construct this using [`Anvil`].
#[derive(Debug)]
pub struct AnvilInstance {
pid: Child,
private_keys: Vec<K256SecretKey>,
addresses: Vec<Address>,
port: u16,
chain_id: Option<u64>,
}

impl AnvilInstance {
/// Returns the private keys used to instantiate this instance
pub fn keys(&self) -> &[K256SecretKey] {
&self.private_keys
}

/// Returns the addresses used to instantiate this instance
pub fn addresses(&self) -> &[Address] {
&self.addresses
}

/// Returns the port of this instance
pub fn port(&self) -> u16 {
self.port
}

/// Returns the chain of the anvil instance
pub fn chain_id(&self) -> u64 {
const ANVIL_HARDHAT_CHAIN_ID: u64 = 31_337;
self.chain_id.unwrap_or(ANVIL_HARDHAT_CHAIN_ID)
}

/// Returns the HTTP endpoint of this instance
pub fn endpoint(&self) -> String {
format!("http://localhost:{}", self.port)
}

/// Returns the Websocket endpoint of this instance
pub fn ws_endpoint(&self) -> String {
format!("ws://localhost:{}", self.port)
}
}

impl Drop for AnvilInstance {
fn drop(&mut self) {
self.pid.kill().expect("could not kill anvil");
}
}

/// Builder for launching `anvil`.
///
/// # Panics
///
/// If `spawn` is called without `anvil` being available in the user's $PATH
///
/// # Example
///
/// ```no_run
/// use alloy_node_bindings::Anvil;
///
/// let port = 8545u16;
/// let url = format!("http://localhost:{}", port).to_string();
///
/// let anvil = Anvil::new()
/// .port(port)
/// .mnemonic("abstract vacuum mammal awkward pudding scene penalty purchase dinner depart evoke puzzle")
/// .spawn();
///
/// drop(anvil); // this will kill the instance
/// ```
#[derive(Debug, Clone, Default)]
#[must_use = "This Builder struct does nothing unless it is `spawn`ed"]
pub struct Anvil {
program: Option<PathBuf>,
port: Option<u16>,
block_time: Option<u64>,
chain_id: Option<u64>,
mnemonic: Option<String>,
fork: Option<String>,
fork_block_number: Option<u64>,
args: Vec<String>,
timeout: Option<u64>,
}

impl Anvil {
/// Creates an empty Anvil builder.
/// The default port is 8545. The mnemonic is chosen randomly.
///
/// # Example
///
/// ```
/// # use alloy_node_bindings::Anvil;
/// fn a() {
/// let anvil = Anvil::default().spawn();
///
/// println!("Anvil running at `{}`", anvil.endpoint());
/// # }
/// ```
pub fn new() -> Self {
Self::default()
}

/// Creates an Anvil builder which will execute `anvil` at the given path.
///
/// # Example
///
/// ```
/// # use alloy_node_bindings::Anvil;
/// fn a() {
/// let anvil = Anvil::at("~/.foundry/bin/anvil").spawn();
///
/// println!("Anvil running at `{}`", anvil.endpoint());
/// # }
/// ```
pub fn at(path: impl Into<PathBuf>) -> Self {
Self::new().path(path)
}

/// Sets the `path` to the `anvil` cli
///
/// By default, it's expected that `anvil` is in `$PATH`, see also
/// [`std::process::Command::new()`]
pub fn path<T: Into<PathBuf>>(mut self, path: T) -> Self {
self.program = Some(path.into());
self
}

/// Sets the port which will be used when the `anvil` instance is launched.
pub fn port<T: Into<u16>>(mut self, port: T) -> Self {
self.port = Some(port.into());
self
}

/// Sets the chain_id the `anvil` instance will use.
pub fn chain_id<T: Into<u64>>(mut self, chain_id: T) -> Self {
self.chain_id = Some(chain_id.into());
self
}

/// Sets the mnemonic which will be used when the `anvil` instance is launched.
pub fn mnemonic<T: Into<String>>(mut self, mnemonic: T) -> Self {
self.mnemonic = Some(mnemonic.into());
self
}

/// Sets the block-time in seconds which will be used when the `anvil` instance is launched.
pub fn block_time<T: Into<u64>>(mut self, block_time: T) -> Self {
self.block_time = Some(block_time.into());
self
}

/// Sets the `fork-block-number` which will be used in addition to [`Self::fork`].
///
/// **Note:** if set, then this requires `fork` to be set as well
pub fn fork_block_number<T: Into<u64>>(mut self, fork_block_number: T) -> Self {
self.fork_block_number = Some(fork_block_number.into());
self
}

/// Sets the `fork` argument to fork from another currently running Ethereum client
/// at a given block. Input should be the HTTP location and port of the other client,
/// e.g. `http://localhost:8545`. You can optionally specify the block to fork from
/// using an @ sign: `http://localhost:8545@1599200`
pub fn fork<T: Into<String>>(mut self, fork: T) -> Self {
self.fork = Some(fork.into());
self
}

/// Adds an argument to pass to the `anvil`.
pub fn arg<T: Into<String>>(mut self, arg: T) -> Self {
self.args.push(arg.into());
self
}

/// Adds multiple arguments to pass to the `anvil`.
pub fn args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
for arg in args {
self = self.arg(arg);
}
self
}

/// Sets the timeout which will be used when the `anvil` instance is launched.
pub fn timeout<T: Into<u64>>(mut self, timeout: T) -> Self {
self.timeout = Some(timeout.into());
self
}

/// Consumes the builder and spawns `anvil`.
///
/// # Panics
///
/// If spawning the instance fails at any point.
#[track_caller]
pub fn spawn(self) -> AnvilInstance {
let mut cmd = if let Some(ref prg) = self.program {
Command::new(prg)
} else {
Command::new("anvil")
};
cmd.stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::inherit());
let port = if let Some(port) = self.port { port } else { unused_port() };
cmd.arg("-p").arg(port.to_string());

if let Some(mnemonic) = self.mnemonic {
cmd.arg("-m").arg(mnemonic);
}

if let Some(chain_id) = self.chain_id {
cmd.arg("--chain-id").arg(chain_id.to_string());
}

if let Some(block_time) = self.block_time {
cmd.arg("-b").arg(block_time.to_string());
}

if let Some(fork) = self.fork {
cmd.arg("-f").arg(fork);
}

if let Some(fork_block_number) = self.fork_block_number {
cmd.arg("--fork-block-number").arg(fork_block_number.to_string());
}

cmd.args(self.args);

let mut child = cmd.spawn().expect("couldnt start anvil");

let stdout = child.stdout.take().expect("Unable to get stdout for anvil child process");

let start = Instant::now();
let mut reader = BufReader::new(stdout);

let mut private_keys = Vec::new();
let mut addresses = Vec::new();
let mut is_private_key = false;
let mut chain_id = None;
loop {
if start + Duration::from_millis(self.timeout.unwrap_or(ANVIL_STARTUP_TIMEOUT_MILLIS))
<= Instant::now()
{
panic!("Timed out waiting for anvil to start. Is anvil installed?")
}

let mut line = String::new();
reader.read_line(&mut line).expect("Failed to read line from anvil process");
if line.contains("Listening on") {
break;
}

if line.starts_with("Private Keys") {
is_private_key = true;
}

if is_private_key && line.starts_with('(') {
let key_str = line
.split("0x")
.last()
.unwrap_or_else(|| panic!("could not parse private key: {}", line))
.trim();
let key_hex = hex::decode(key_str).expect("could not parse as hex");
let key = K256SecretKey::from_bytes((&key_hex[..]).into())
.expect("did not get private key");
addresses.push(Address::from_public_key(SigningKey::from(&key).verifying_key()));
private_keys.push(key);
}

if let Some(start_chain_id) = line.find("Chain ID:") {
let rest = &line[start_chain_id + "Chain ID:".len()..];
if let Ok(chain) = rest.split_whitespace().next().unwrap_or("").parse::<u64>() {
chain_id = Some(chain);
};
}
}

AnvilInstance {
pid: child,
private_keys,
addresses,
port,
chain_id: self.chain_id.or(chain_id),
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn can_launch_anvil() {
let _ = Anvil::new().spawn();
}

#[test]
fn can_launch_anvil_with_more_accounts() {
let _ = Anvil::new().arg("--accounts").arg("20").spawn();
}

#[test]
fn assert_chain_id() {
let anvil = Anvil::new().fork("https://rpc.ankr.com/eth").spawn();
assert_eq!(anvil.chain_id(), 1);
}

#[test]
fn assert_chain_id_without_rpc() {
let anvil = Anvil::new().spawn();
assert_eq!(anvil.chain_id(), 31337);
}
}
Loading

0 comments on commit 5ed60f8

Please sign in to comment.