From 413b649e7a4c4464bbcd42223d36fcd97878c2bd Mon Sep 17 00:00:00 2001 From: Joseph Livesey Date: Tue, 18 Nov 2025 11:58:35 -0500 Subject: [PATCH 1/2] feat(anvil): add public ANVIL_PORT constant Add public constant for API consistency with other modules in the repository. 27+ other modules export port constants, making this a standard pattern for ergonomic port access. --- src/anvil/mod.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/anvil/mod.rs b/src/anvil/mod.rs index 390b184..5121df6 100644 --- a/src/anvil/mod.rs +++ b/src/anvil/mod.rs @@ -7,7 +7,12 @@ 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/) /// @@ -151,7 +156,7 @@ impl Image for AnvilNode { } fn expose_ports(&self) -> &[ContainerPort] { - &[PORT] + &[ANVIL_PORT] } fn name(&self) -> &str { @@ -180,7 +185,7 @@ mod tests { 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 = RootProvider::new_http(format!("http://localhost:{port}").parse().unwrap()); From d31ecf50bd1c5bfa15cfd63f155a5963117a421f Mon Sep 17 00:00:00 2001 From: Joseph Livesey Date: Tue, 18 Nov 2025 13:34:05 -0500 Subject: [PATCH 2/2] feat(anvil): comprehensive documentation and API improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enhance the anvil module with better documentation, API consistency, and improved test coverage to match repository best practices. ## Changes - Add public ANVIL_PORT constant for API consistency with other modules - Add with_tag() convenience method for ergonomic tag selection - Create comprehensive inline documentation with usage examples - Add detailed builder method documentation clarifying path handling - Create examples/anvil.rs demonstrating practical usage - Add blocking/sync test variant - Add test for latest() method - Update lib.rs module description to be more descriptive - Update outdated testcontainers version references ## Impact - Test coverage: 2 → 4 tests (100% increase) - Documentation: Now includes comprehensive examples and usage patterns - API: Consistent with 27+ other modules that export port constants - User experience: Significantly improved with clearer docs and examples --- Cargo.toml | 4 ++ examples/anvil.rs | 26 ++++++++ src/anvil/mod.rs | 164 +++++++++++++++++++++++++++++++++++++++++++--- src/lib.rs | 2 +- 4 files changed, 187 insertions(+), 9 deletions(-) create mode 100644 examples/anvil.rs diff --git a/Cargo.toml b/Cargo.toml index dc43b3e..9b3d1a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] diff --git a/examples/anvil.rs b/examples/anvil.rs new file mode 100644 index 0000000..f496b1f --- /dev/null +++ b/examples/anvil.rs @@ -0,0 +1,26 @@ +use testcontainers::runners::SyncRunner; +use testcontainers_modules::anvil::{AnvilNode, ANVIL_PORT}; + +fn main() -> Result<(), Box> { + // 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(()) +} diff --git a/src/anvil/mod.rs b/src/anvil/mod.rs index 5121df6..978e159 100644 --- a/src/anvil/mod.rs +++ b/src/anvil/mod.rs @@ -18,10 +18,48 @@ pub const ANVIL_PORT: ContainerPort = ContainerPort::Tcp(8545); /// /// 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. /// -/// 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. +/// # Example +/// +/// ```rust,no_run +/// use testcontainers_modules::{ +/// anvil::{AnvilNode, ANVIL_PORT}, +/// testcontainers::runners::AsyncRunner, +/// }; +/// +/// # async fn example() -> Result<(), Box> { +/// // 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. /// /// To use the latest Foundry image, you can use the `latest()` method: /// @@ -29,10 +67,11 @@ pub const ANVIL_PORT: ContainerPort = ContainerPort::Tcp(8545); /// 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 { @@ -55,25 +94,65 @@ impl AnvilNode { } } + /// Use a specific Foundry image tag + pub fn with_tag(mut self, tag: impl Into) -> 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) -> 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) -> Self { let Some(host_dir_str) = host_dir.as_ref().to_str() else { return self; @@ -84,6 +163,17 @@ impl AnvilNode { /// Configure Anvil to initialize from a previously saved state snapshot. /// Equivalent to passing `--load-state `. + /// + /// # 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) -> Self { self.load_state_path = Some(path.into()); self @@ -91,6 +181,17 @@ impl AnvilNode { /// Configure Anvil to dump the state on exit to the given file or directory. /// Equivalent to passing `--dump-state `. + /// + /// # 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) -> Self { self.dump_state_path = Some(path.into()); self @@ -98,6 +199,15 @@ impl AnvilNode { /// Configure periodic state persistence interval in seconds. /// Equivalent to passing `--state-interval `. + /// + /// # 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 @@ -176,12 +286,13 @@ 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(); @@ -195,6 +306,43 @@ mod tests { 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 = + 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 = + 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_command_construction() { let node = AnvilNode::default() diff --git a/src/lib.rs b/src/lib.rs index e7bc845..f3acb75 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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")))]