Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ azure_storage_blobs = "0.21.0"
azure_storage = "0.21.0"
base64 = "0.22.1"

[[example]]
name = "anvil"
required-features = ["anvil"]

[[example]]
name = "postgres"
required-features = ["postgres"]
Expand Down
26 changes: 26 additions & 0 deletions examples/anvil.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
use testcontainers::runners::SyncRunner;
use testcontainers_modules::anvil::{AnvilNode, ANVIL_PORT};

fn main() -> Result<(), Box<dyn std::error::Error + 'static>> {
// Start the Anvil node
let node = AnvilNode::default().start()?;

// Get the mapped port for the Anvil JSON-RPC endpoint
let port = node.get_host_port_ipv4(ANVIL_PORT)?;
let rpc_url = format!("http://localhost:{port}");

println!("Anvil node started successfully!");
println!("JSON-RPC endpoint: {rpc_url}");
println!();
println!("You can now connect to this endpoint using your Ethereum tooling:");
println!(" - cast: cast block-number --rpc-url {rpc_url}");
println!(" - web3: new Web3(new Web3.providers.HttpProvider('{rpc_url}'))");
println!(" - alloy: Provider::try_from('{rpc_url}')");
println!();
println!("Press Ctrl+C to stop the container");

// Keep the container running
std::thread::park();

Ok(())
}
175 changes: 164 additions & 11 deletions src/anvil/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,71 @@ use testcontainers::{

const NAME: &str = "ghcr.io/foundry-rs/foundry";
const TAG: &str = "v1.1.0";
const PORT: ContainerPort = ContainerPort::Tcp(8545);

/// Port that the [`AnvilNode`] container exposes for JSON-RPC connections.
/// Can be rebound externally via [`testcontainers::core::ImageExt::with_mapped_port`]
///
/// [`AnvilNode`]: https://book.getfoundry.sh/anvil/
pub const ANVIL_PORT: ContainerPort = ContainerPort::Tcp(8545);

/// # Community Testcontainers Implementation for [Foundry Anvil](https://book.getfoundry.sh/anvil/)
///
/// This is a community implementation of the [Testcontainers](https://testcontainers.org/) interface for [Foundry Anvil](https://book.getfoundry.sh/anvil/).
///
/// It is not officially supported by Foundry, but it is a community effort to provide a more user-friendly interface for running Anvil inside a Docker container.
/// Anvil is Foundry's fast local Ethereum node for development and testing. It's an ideal tool for rapid
/// iteration on smart contract development and provides a clean, lightweight alternative to running a full node.
///
/// # Example
///
/// ```rust,no_run
/// use testcontainers_modules::{
/// anvil::{AnvilNode, ANVIL_PORT},
/// testcontainers::runners::AsyncRunner,
/// };
///
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
/// // Start an Anvil node
/// let node = AnvilNode::default().start().await?;
///
/// // Get the RPC endpoint URL
/// let host_port = node.get_host_port_ipv4(ANVIL_PORT).await?;
/// let rpc_url = format!("http://localhost:{host_port}");
///
/// // Use with your favorite Ethereum library (alloy, ethers, web3, etc.)
/// // let provider = Provider::try_from(rpc_url)?;
/// # Ok(())
/// # }
/// ```
///
/// # Advanced Configuration
///
/// ```rust,ignore
/// use testcontainers_modules::anvil::AnvilNode;
///
/// // Configure chain ID and forking
/// let node = AnvilNode::default()
/// .with_chain_id(1337)
/// .with_fork_url("https://eth.llamarpc.com")
/// .with_fork_block_number(18_000_000)
/// .start().await?;
/// ```
///
/// # Usage
///
/// The endpoint of the container is intended to be injected into your provider configuration, so that you can easily run tests against a local Anvil instance.
/// See the `test_anvil_node_container` test for an example of how to use this.
/// The endpoint of the container is intended to be injected into your provider configuration, so that you can
/// easily run tests against a local Anvil instance.
///
/// To use the latest Foundry image, you can use the `latest()` method:
///
/// ```rust,ignore
/// let node = AnvilNode::latest().start().await?;
/// ```
///
/// Users can use a specific Foundry image in their code with [`ImageExt::with_tag`](https://docs.rs/testcontainers/0.23.1/testcontainers/core/trait.ImageExt.html#tymethod.with_tag).
/// Users can use a specific Foundry image in their code with [`ImageExt::with_tag`](https://docs.rs/testcontainers/latest/testcontainers/core/trait.ImageExt.html#tymethod.with_tag).
///
/// ```rust,ignore
/// let node = AnvilNode::with_tag("master").start().await?;
/// use testcontainers::core::ImageExt;
/// let node = AnvilNode::default().with_tag("nightly").start().await?;
/// ```
#[derive(Debug, Clone, Default)]
pub struct AnvilNode {
Expand All @@ -50,25 +94,65 @@ impl AnvilNode {
}
}

/// Use a specific Foundry image tag
pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
self.tag = Some(tag.into());
self
}

/// Specify the chain ID - this will be Ethereum Mainnet by default
pub fn with_chain_id(mut self, chain_id: u64) -> Self {
self.chain_id = Some(chain_id);
self
}

/// Specify the fork URL
/// Specify the fork URL to fork from a live network
///
/// # Example
/// ```rust,ignore
/// let node = AnvilNode::default()
/// .with_fork_url("https://eth.llamarpc.com")
/// .start().await?;
/// ```
pub fn with_fork_url(mut self, fork_url: impl Into<String>) -> Self {
self.fork_url = Some(fork_url.into());
self
}

/// Specify the fork block number
/// Specify the fork block number (requires `with_fork_url` to be set)
///
/// # Example
/// ```rust,ignore
/// let node = AnvilNode::default()
/// .with_fork_url("https://eth.llamarpc.com")
/// .with_fork_block_number(18_000_000)
/// .start().await?;
/// ```
pub fn with_fork_block_number(mut self, block_number: u64) -> Self {
self.fork_block_number = Some(block_number);
self
}

/// Mount a host directory for anvil state at `/state` inside the container
///
/// Use this method to bind mount a host directory to the container's `/state` directory.
/// This allows you to persist state across container restarts.
///
/// # Arguments
/// * `host_dir` - Path on the host machine to mount (will be mounted to `/state` in container)
///
/// # Note
/// When using this method, specify container paths (starting with `/state/`) in
/// `with_load_state_path` and `with_dump_state_path`.
///
/// # Example
/// ```rust,ignore
/// let temp_dir = std::env::temp_dir().join("anvil-state");
/// let node = AnvilNode::default()
/// .with_state_mount(&temp_dir)
/// .with_dump_state_path("/state/state.json")
/// .start().await?;
/// ```
pub fn with_state_mount(mut self, host_dir: impl AsRef<std::path::Path>) -> Self {
let Some(host_dir_str) = host_dir.as_ref().to_str() else {
return self;
Expand All @@ -79,20 +163,51 @@ impl AnvilNode {

/// Configure Anvil to initialize from a previously saved state snapshot.
/// Equivalent to passing `--load-state <PATH>`.
///
/// # Arguments
/// * `path` - Path to the state file (container-internal path, typically `/state/...` if using `with_state_mount`)
///
/// # Example
/// ```rust,ignore
/// let node = AnvilNode::default()
/// .with_state_mount("/host/path/to/state")
/// .with_load_state_path("/state/state.json")
/// .start().await?;
/// ```
pub fn with_load_state_path(mut self, path: impl Into<String>) -> Self {
self.load_state_path = Some(path.into());
self
}

/// Configure Anvil to dump the state on exit to the given file or directory.
/// Equivalent to passing `--dump-state <PATH>`.
///
/// # Arguments
/// * `path` - Path where state should be saved (container-internal path, typically `/state/...` if using `with_state_mount`)
///
/// # Example
/// ```rust,ignore
/// let node = AnvilNode::default()
/// .with_state_mount("/host/path/to/state")
/// .with_dump_state_path("/state/state.json")
/// .start().await?;
/// ```
pub fn with_dump_state_path(mut self, path: impl Into<String>) -> Self {
self.dump_state_path = Some(path.into());
self
}

/// Configure periodic state persistence interval in seconds.
/// Equivalent to passing `--state-interval <SECONDS>`.
///
/// # Example
/// ```rust,ignore
/// let node = AnvilNode::default()
/// .with_state_mount("/host/path/to/state")
/// .with_dump_state_path("/state/state.json")
/// .with_state_interval(30) // Save every 30 seconds
/// .start().await?;
/// ```
pub fn with_state_interval(mut self, seconds: u64) -> Self {
self.state_interval_secs = Some(seconds);
self
Expand Down Expand Up @@ -151,7 +266,7 @@ impl Image for AnvilNode {
}

fn expose_ports(&self) -> &[ContainerPort] {
&[PORT]
&[ANVIL_PORT]
}

fn name(&self) -> &str {
Expand All @@ -171,16 +286,54 @@ impl Image for AnvilNode {
mod tests {
use alloy_network::AnyNetwork;
use alloy_provider::{Provider, RootProvider};
use testcontainers::runners::AsyncRunner;

use super::*;

#[tokio::test]
async fn test_anvil_node_container() {
use testcontainers::runners::AsyncRunner;

let _ = pretty_env_logger::try_init();

let node = AnvilNode::default().start().await.unwrap();
let port = node.get_host_port_ipv4(PORT).await.unwrap();
let port = node.get_host_port_ipv4(ANVIL_PORT).await.unwrap();

let provider: RootProvider<AnyNetwork> =
RootProvider::new_http(format!("http://localhost:{port}").parse().unwrap());

let block_number = provider.get_block_number().await.unwrap();

assert_eq!(block_number, 0);
}

#[test]
fn test_anvil_node_container_sync() {
use testcontainers::runners::SyncRunner;

let _ = pretty_env_logger::try_init();

let node = AnvilNode::default().start().unwrap();
let port = node.get_host_port_ipv4(ANVIL_PORT).unwrap();

let provider: RootProvider<AnyNetwork> =
RootProvider::new_http(format!("http://localhost:{port}").parse().unwrap());

let block_number = tokio::runtime::Runtime::new()
.unwrap()
.block_on(provider.get_block_number())
.unwrap();

assert_eq!(block_number, 0);
}

#[tokio::test]
async fn test_anvil_latest() {
use testcontainers::runners::AsyncRunner;

let _ = pretty_env_logger::try_init();

let node = AnvilNode::latest().start().await.unwrap();
let port = node.get_host_port_ipv4(ANVIL_PORT).await.unwrap();

let provider: RootProvider<AnyNetwork> =
RootProvider::new_http(format!("http://localhost:{port}").parse().unwrap());
Expand Down
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

#[cfg(feature = "anvil")]
#[cfg_attr(docsrs, doc(cfg(feature = "anvil")))]
/// **Anvil** (local blockchain emulator for EVM-compatible development) testcontainer
/// **Anvil** (Foundry's fast local Ethereum node for development and testing) testcontainer
pub mod anvil;
#[cfg(feature = "arrow_flightsql")]
#[cfg_attr(docsrs, doc(cfg(feature = "arrow_flightsql")))]
Expand Down
Loading