testcontainers_modules/anvil/
mod.rs

1use std::borrow::Cow;
2
3use testcontainers::{
4    core::{ContainerPort, Mount, WaitFor},
5    Image,
6};
7
8const NAME: &str = "ghcr.io/foundry-rs/foundry";
9const TAG: &str = "v1.1.0";
10
11/// Port that the [`AnvilNode`] container exposes for JSON-RPC connections.
12/// Can be rebound externally via [`testcontainers::core::ImageExt::with_mapped_port`]
13///
14/// [`AnvilNode`]: https://book.getfoundry.sh/anvil/
15pub const ANVIL_PORT: ContainerPort = ContainerPort::Tcp(8545);
16
17/// # Community Testcontainers Implementation for [Foundry Anvil](https://book.getfoundry.sh/anvil/)
18///
19/// This is a community implementation of the [Testcontainers](https://testcontainers.org/) interface for [Foundry Anvil](https://book.getfoundry.sh/anvil/).
20///
21/// Anvil is Foundry's fast local Ethereum node for development and testing. It's an ideal tool for rapid
22/// iteration on smart contract development and provides a clean, lightweight alternative to running a full node.
23///
24/// # Example
25///
26/// ```rust,no_run
27/// use testcontainers_modules::{
28///     anvil::{AnvilNode, ANVIL_PORT},
29///     testcontainers::runners::AsyncRunner,
30/// };
31///
32/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
33/// // Start an Anvil node
34/// let node = AnvilNode::default().start().await?;
35///
36/// // Get the RPC endpoint URL
37/// let host_port = node.get_host_port_ipv4(ANVIL_PORT).await?;
38/// let rpc_url = format!("http://localhost:{host_port}");
39///
40/// // Use with your favorite Ethereum library (alloy, ethers, web3, etc.)
41/// // let provider = Provider::try_from(rpc_url)?;
42/// # Ok(())
43/// # }
44/// ```
45///
46/// # Advanced Configuration
47///
48/// ```rust,ignore
49/// use testcontainers_modules::anvil::AnvilNode;
50///
51/// // Configure chain ID and forking
52/// let node = AnvilNode::default()
53///     .with_chain_id(1337)
54///     .with_fork_url("https://eth.llamarpc.com")
55///     .with_fork_block_number(18_000_000)
56///     .start().await?;
57/// ```
58///
59/// # Usage
60///
61/// The endpoint of the container is intended to be injected into your provider configuration, so that you can
62/// easily run tests against a local Anvil instance.
63///
64/// To use the latest Foundry image, you can use the `latest()` method:
65///
66/// ```rust,ignore
67/// let node = AnvilNode::latest().start().await?;
68/// ```
69///
70/// 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).
71///
72/// ```rust,ignore
73/// use testcontainers::core::ImageExt;
74/// let node = AnvilNode::default().with_tag("nightly").start().await?;
75/// ```
76#[derive(Debug, Clone, Default)]
77pub struct AnvilNode {
78    chain_id: Option<u64>,
79    fork_url: Option<String>,
80    fork_block_number: Option<u64>,
81    tag: Option<String>,
82    load_state_path: Option<String>,
83    dump_state_path: Option<String>,
84    state_interval_secs: Option<u64>,
85    state_mount: Option<Mount>,
86}
87
88impl AnvilNode {
89    /// Create a new AnvilNode with the latest Foundry image
90    pub fn latest() -> Self {
91        Self {
92            tag: Some("latest".to_string()),
93            ..Default::default()
94        }
95    }
96
97    /// Use a specific Foundry image tag
98    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
99        self.tag = Some(tag.into());
100        self
101    }
102
103    /// Specify the chain ID - this will be Ethereum Mainnet by default
104    pub fn with_chain_id(mut self, chain_id: u64) -> Self {
105        self.chain_id = Some(chain_id);
106        self
107    }
108
109    /// Specify the fork URL to fork from a live network
110    ///
111    /// # Example
112    /// ```rust,ignore
113    /// let node = AnvilNode::default()
114    ///     .with_fork_url("https://eth.llamarpc.com")
115    ///     .start().await?;
116    /// ```
117    pub fn with_fork_url(mut self, fork_url: impl Into<String>) -> Self {
118        self.fork_url = Some(fork_url.into());
119        self
120    }
121
122    /// Specify the fork block number (requires `with_fork_url` to be set)
123    ///
124    /// # Example
125    /// ```rust,ignore
126    /// let node = AnvilNode::default()
127    ///     .with_fork_url("https://eth.llamarpc.com")
128    ///     .with_fork_block_number(18_000_000)
129    ///     .start().await?;
130    /// ```
131    pub fn with_fork_block_number(mut self, block_number: u64) -> Self {
132        self.fork_block_number = Some(block_number);
133        self
134    }
135
136    /// Mount a host directory for anvil state at `/state` inside the container
137    ///
138    /// Use this method to bind mount a host directory to the container's `/state` directory.
139    /// This allows you to persist state across container restarts.
140    ///
141    /// # Arguments
142    /// * `host_dir` - Path on the host machine to mount (will be mounted to `/state` in container)
143    ///
144    /// # Note
145    /// When using this method, specify container paths (starting with `/state/`) in
146    /// `with_load_state_path` and `with_dump_state_path`.
147    ///
148    /// # Example
149    /// ```rust,ignore
150    /// let temp_dir = std::env::temp_dir().join("anvil-state");
151    /// let node = AnvilNode::default()
152    ///     .with_state_mount(&temp_dir)
153    ///     .with_dump_state_path("/state/state.json")
154    ///     .start().await?;
155    /// ```
156    pub fn with_state_mount(mut self, host_dir: impl AsRef<std::path::Path>) -> Self {
157        let Some(host_dir_str) = host_dir.as_ref().to_str() else {
158            return self;
159        };
160        self.state_mount = Some(Mount::bind_mount(host_dir_str, "/state"));
161        self
162    }
163
164    /// Configure Anvil to initialize from a previously saved state snapshot.
165    /// Equivalent to passing `--load-state <PATH>`.
166    ///
167    /// # Arguments
168    /// * `path` - Path to the state file (container-internal path, typically `/state/...` if using `with_state_mount`)
169    ///
170    /// # Example
171    /// ```rust,ignore
172    /// let node = AnvilNode::default()
173    ///     .with_state_mount("/host/path/to/state")
174    ///     .with_load_state_path("/state/state.json")
175    ///     .start().await?;
176    /// ```
177    pub fn with_load_state_path(mut self, path: impl Into<String>) -> Self {
178        self.load_state_path = Some(path.into());
179        self
180    }
181
182    /// Configure Anvil to dump the state on exit to the given file or directory.
183    /// Equivalent to passing `--dump-state <PATH>`.
184    ///
185    /// # Arguments
186    /// * `path` - Path where state should be saved (container-internal path, typically `/state/...` if using `with_state_mount`)
187    ///
188    /// # Example
189    /// ```rust,ignore
190    /// let node = AnvilNode::default()
191    ///     .with_state_mount("/host/path/to/state")
192    ///     .with_dump_state_path("/state/state.json")
193    ///     .start().await?;
194    /// ```
195    pub fn with_dump_state_path(mut self, path: impl Into<String>) -> Self {
196        self.dump_state_path = Some(path.into());
197        self
198    }
199
200    /// Configure periodic state persistence interval in seconds.
201    /// Equivalent to passing `--state-interval <SECONDS>`.
202    ///
203    /// # Example
204    /// ```rust,ignore
205    /// let node = AnvilNode::default()
206    ///     .with_state_mount("/host/path/to/state")
207    ///     .with_dump_state_path("/state/state.json")
208    ///     .with_state_interval(30) // Save every 30 seconds
209    ///     .start().await?;
210    /// ```
211    pub fn with_state_interval(mut self, seconds: u64) -> Self {
212        self.state_interval_secs = Some(seconds);
213        self
214    }
215}
216
217impl Image for AnvilNode {
218    fn cmd(&self) -> impl IntoIterator<Item = impl Into<Cow<'_, str>>> {
219        let mut cmd = vec![];
220
221        if let Some(chain_id) = self.chain_id {
222            cmd.push("--chain-id".to_string());
223            cmd.push(chain_id.to_string());
224        }
225
226        if let Some(ref fork_url) = self.fork_url {
227            cmd.push("--fork-url".to_string());
228            cmd.push(fork_url.to_string());
229        }
230
231        if let Some(fork_block_number) = self.fork_block_number {
232            cmd.push("--fork-block-number".to_string());
233            cmd.push(fork_block_number.to_string());
234        }
235
236        if let Some(ref load_path) = self.load_state_path {
237            cmd.push("--load-state".to_string());
238            cmd.push(load_path.clone());
239        }
240
241        if let Some(ref dump_path) = self.dump_state_path {
242            cmd.push("--dump-state".to_string());
243            cmd.push(dump_path.clone());
244        }
245
246        if let Some(interval) = self.state_interval_secs {
247            cmd.push("--state-interval".to_string());
248            cmd.push(interval.to_string());
249        }
250
251        cmd.into_iter().map(Cow::from)
252    }
253
254    fn entrypoint(&self) -> Option<&str> {
255        Some("anvil")
256    }
257
258    fn env_vars(
259        &self,
260    ) -> impl IntoIterator<Item = (impl Into<Cow<'_, str>>, impl Into<Cow<'_, str>>)> {
261        [("ANVIL_IP_ADDR".to_string(), "0.0.0.0".to_string())].into_iter()
262    }
263
264    fn mounts(&self) -> impl IntoIterator<Item = &Mount> {
265        self.state_mount.iter()
266    }
267
268    fn expose_ports(&self) -> &[ContainerPort] {
269        &[ANVIL_PORT]
270    }
271
272    fn name(&self) -> &str {
273        NAME
274    }
275
276    fn tag(&self) -> &str {
277        self.tag.as_deref().unwrap_or(TAG)
278    }
279
280    fn ready_conditions(&self) -> Vec<WaitFor> {
281        vec![WaitFor::message_on_stdout("Listening on 0.0.0.0:8545")]
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use alloy_network::AnyNetwork;
288    use alloy_provider::{Provider, RootProvider};
289
290    use super::*;
291
292    #[tokio::test]
293    async fn test_anvil_node_container() {
294        use testcontainers::runners::AsyncRunner;
295
296        let _ = pretty_env_logger::try_init();
297
298        let node = AnvilNode::default().start().await.unwrap();
299        let port = node.get_host_port_ipv4(ANVIL_PORT).await.unwrap();
300
301        let provider: RootProvider<AnyNetwork> =
302            RootProvider::new_http(format!("http://localhost:{port}").parse().unwrap());
303
304        let block_number = provider.get_block_number().await.unwrap();
305
306        assert_eq!(block_number, 0);
307    }
308
309    #[test]
310    fn test_anvil_node_container_sync() {
311        use testcontainers::runners::SyncRunner;
312
313        let _ = pretty_env_logger::try_init();
314
315        let node = AnvilNode::default().start().unwrap();
316        let port = node.get_host_port_ipv4(ANVIL_PORT).unwrap();
317
318        let provider: RootProvider<AnyNetwork> =
319            RootProvider::new_http(format!("http://localhost:{port}").parse().unwrap());
320
321        let block_number = tokio::runtime::Runtime::new()
322            .unwrap()
323            .block_on(provider.get_block_number())
324            .unwrap();
325
326        assert_eq!(block_number, 0);
327    }
328
329    #[tokio::test]
330    async fn test_anvil_latest() {
331        use testcontainers::runners::AsyncRunner;
332
333        let _ = pretty_env_logger::try_init();
334
335        let node = AnvilNode::latest().start().await.unwrap();
336        let port = node.get_host_port_ipv4(ANVIL_PORT).await.unwrap();
337
338        let provider: RootProvider<AnyNetwork> =
339            RootProvider::new_http(format!("http://localhost:{port}").parse().unwrap());
340
341        let block_number = provider.get_block_number().await.unwrap();
342
343        assert_eq!(block_number, 0);
344    }
345
346    #[test]
347    fn test_command_construction() {
348        let node = AnvilNode::default()
349            .with_chain_id(1337)
350            .with_fork_url("http://example.com")
351            .with_load_state_path("/state/state.json")
352            .with_dump_state_path("/state/state.json")
353            .with_state_interval(5);
354
355        let cmd: Vec<String> = node
356            .cmd()
357            .into_iter()
358            .map(|c| c.into().into_owned())
359            .collect();
360
361        assert_eq!(
362            cmd,
363            vec![
364                "--chain-id",
365                "1337",
366                "--fork-url",
367                "http://example.com",
368                "--load-state",
369                "/state/state.json",
370                "--dump-state",
371                "/state/state.json",
372                "--state-interval",
373                "5",
374            ]
375        );
376
377        assert_eq!(node.entrypoint(), Some("anvil"));
378    }
379}