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}