Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
fcf2ddd
feat(anvil): support multiple fork URLs with fallback
decofe Apr 13, 2026
e163da9
fix: fmt
mablr Apr 13, 2026
ea007f3
Merge branch 'master' into zerosnacks/multi-fork-url
zerosnacks Apr 13, 2026
8618726
feat(config): support multi-endpoint `endpoints` array in foundry.toml
decofe Apr 13, 2026
b3752c8
test: add guardrail tests and handle curl_mode in build_fallback
decofe Apr 13, 2026
912e804
refactor(anvil): consolidate eth_rpc_url into fork_urls
decofe Apr 14, 2026
8fc2f16
refactor(anvil): simplify update_url to replace all fork_urls
decofe Apr 14, 2026
5096a0d
refactor(anvil): rename update_url to update_urls taking Vec<String>
decofe Apr 14, 2026
f014f86
refactor(anvil): update ClientFork::reset to accept Vec<String>
decofe Apr 14, 2026
1bf3caa
refactor: use Vec<String> throughout reset path
decofe Apr 14, 2026
b630f5b
Merge remote-tracking branch 'origin/master' into zerosnacks/multi-fo…
decofe Apr 14, 2026
be43484
fix: fmt
decofe Apr 14, 2026
83a36b5
Merge branch 'master' into zerosnacks/multi-fork-url
zerosnacks Apr 15, 2026
abb1b3d
round-robin for load-balance
stevencartavia Apr 16, 2026
421e1dd
comments
stevencartavia Apr 16, 2026
a12f7d4
Merge branch 'master' into zerosnacks/multi-fork-url
stevencartavia Apr 16, 2026
5519974
Merge branch 'master' into zerosnacks/multi-fork-url
zerosnacks Apr 21, 2026
8cd02a5
fix(anvil): sync fork_urls after anvil_setRpcUrl and anvil_reset
Apr 21, 2026
a95da1c
chore: fmt
Apr 21, 2026
0c7a967
fix(anvil): fix multi-fork-url bootstrap race, stale docs, and RoundR…
zerosnacks Apr 21, 2026
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
115 changes: 107 additions & 8 deletions crates/anvil/src/cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,18 @@ impl NodeArgs {
let compute_units_per_second =
if self.evm.no_rate_limit { Some(u64::MAX) } else { self.evm.compute_units_per_second };

// Validate that secondary fork URLs don't have conflicting block number suffixes
if self.evm.fork_url.len() > 1 {
for fork in &self.evm.fork_url[1..] {
if fork.block.is_some() {
eyre::bail!(
"Block number suffixes (@block) on secondary --fork-url values are not supported. \
Use --fork-block-number to set the fork block for all endpoints."
);
}
}
}

let hardfork = match &self.hardfork {
Some(hf) => {
if self.evm.networks.is_optimism() {
Expand Down Expand Up @@ -260,7 +272,7 @@ impl NodeArgs {
_ => self
.evm
.fork_url
.as_ref()
.first()
.and_then(|f| f.block)
.map(|num| ForkChoice::Block(num as i128)),
})
Expand All @@ -270,7 +282,7 @@ impl NodeArgs {
.fork_request_retries(self.evm.fork_request_retries)
.fork_retry_backoff(self.evm.fork_retry_backoff.map(Duration::from_millis))
.fork_compute_units_per_second(compute_units_per_second)
.with_eth_rpc_url(self.evm.fork_url.map(|fork| fork.url))
.with_fork_urls(self.evm.fork_url.into_iter().map(|f| f.url).collect())
.with_base_fee(self.evm.block_base_fee_per_gas)
.disable_min_priority_fee(self.evm.disable_min_priority_fee)
.with_no_storage_caching(self.evm.no_storage_caching)
Expand Down Expand Up @@ -426,14 +438,18 @@ pub struct AnvilEvmArgs {
/// Fetch state over a remote endpoint instead of starting from an empty state.
///
/// If you want to fetch state from a specific block number, add a block number like `http://localhost:8545@1400000` or use the `--fork-block-number` argument.
///
/// Multiple `--fork-url` flags can be provided to distribute requests across endpoints
/// with automatic failover. Requests are routed to the best-performing endpoint based on
/// latency and success rate.
#[arg(
long,
short,
visible_alias = "rpc-url",
value_name = "URL",
help_heading = "Fork config"
)]
pub fork_url: Option<ForkUrl>,
pub fork_url: Vec<ForkUrl>,

/// Headers to use for the rpc client, e.g. "User-Agent: test-agent"
///
Expand Down Expand Up @@ -630,13 +646,35 @@ pub struct AnvilEvmArgs {
/// Resolves an alias passed as fork-url to the matching url defined in the rpc_endpoints section
/// of the project configuration file.
/// Does nothing if the fork-url is not a configured alias.
///
/// When an alias maps to an `RpcEndpoint` with multiple `endpoints`, all URLs are expanded
/// into additional `--fork-url` entries for multi-endpoint load balancing.
impl AnvilEvmArgs {
pub fn resolve_rpc_alias(&mut self) {
if let Some(fork_url) = &self.fork_url
&& let Ok(config) = Config::load_with_providers(FigmentProviders::Anvil)
&& let Some(Ok(url)) = config.get_rpc_url_with_alias(&fork_url.url)
{
self.fork_url = Some(ForkUrl { url: url.to_string(), block: fork_url.block });
if let Ok(config) = Config::load_with_providers(FigmentProviders::Anvil) {
let mut resolved_urls = Vec::new();
for fork_url in &self.fork_url {
let mut endpoints = config.rpc_endpoints.clone().resolved();
if let Some(endpoint) = endpoints.remove(&fork_url.url) {
// Alias matched — expand all URLs from the endpoint config
if let Ok(urls) = endpoint.all_urls() {
for (i, url) in urls.into_iter().enumerate() {
resolved_urls.push(ForkUrl {
url,
// Only the first URL inherits the block suffix
block: if i == 0 { fork_url.block } else { None },
});
Comment thread
stevencartavia marked this conversation as resolved.
Outdated
}
}
} else if let Some(Ok(url)) = config.get_rpc_url_with_alias(&fork_url.url) {
// Try mesc or other resolution
resolved_urls.push(ForkUrl { url: url.to_string(), block: fork_url.block });
} else {
// Not an alias — keep as-is
resolved_urls.push(fork_url.clone());
}
}
self.fork_url = resolved_urls;
}
}
}
Expand Down Expand Up @@ -965,4 +1003,65 @@ mod tests {
["::1", "1.1.1.1", "2.2.2.2"].map(|ip| ip.parse::<IpAddr>().unwrap()).to_vec()
);
}

#[test]
fn can_parse_multiple_fork_urls() {
let args: NodeArgs = NodeArgs::parse_from([
"anvil",
"--fork-url",
"http://localhost:8545",
"--fork-url",
"http://localhost:8546",
"--fork-url",
"http://localhost:8547",
]);
assert_eq!(args.evm.fork_url.len(), 3);
assert_eq!(args.evm.fork_url[0].url, "http://localhost:8545");
assert_eq!(args.evm.fork_url[1].url, "http://localhost:8546");
assert_eq!(args.evm.fork_url[2].url, "http://localhost:8547");

// Block suffix on first URL should work
let args: NodeArgs = NodeArgs::parse_from([
"anvil",
"--fork-url",
"http://localhost:8545@1000000",
"--fork-url",
"http://localhost:8546",
]);
assert_eq!(args.evm.fork_url[0].block, Some(1000000));
assert_eq!(args.evm.fork_url[1].block, None);
}

#[test]
fn rejects_block_suffix_on_secondary_fork_urls() {
let args: NodeArgs = NodeArgs::parse_from([
"anvil",
"--fork-url",
"http://localhost:8545@1000000",
"--fork-url",
"http://localhost:8546@2000000",
]);
let result = args.into_node_config();
assert!(result.is_err());
assert!(
result.unwrap_err().to_string().contains("Block number suffixes"),
"should reject block suffix on secondary fork URL"
);
}

#[test]
fn fork_dependent_args_require_fork_url() {
// All these args have `requires = "fork_url"` — they should fail without --fork-url
let cases = [
vec!["anvil", "--fork-header", "X-Api-Key: test"],
vec!["anvil", "--timeout", "5000"],
vec!["anvil", "--retries", "3"],
vec!["anvil", "--fork-block-number", "100"],
vec!["anvil", "--fork-retry-backoff", "500"],
];
for args in &cases {
let result = NodeArgs::try_parse_from(args);
assert!(result.is_err(), "expected error when using {:?} without --fork-url", args[1]);
}
}
}
56 changes: 42 additions & 14 deletions crates/anvil/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,11 +134,13 @@ pub struct NodeConfig {
pub port: u16,
/// maximum number of transactions in a block
pub max_transactions: usize,
/// url of the rpc server that should be used for any rpc calls
pub eth_rpc_url: Option<String>,
/// Fork URLs for RPC calls. The first entry is the primary endpoint.
/// When multiple URLs are provided, requests are distributed using Alloy's
/// `FallbackService` with automatic failover based on endpoint health.
pub fork_urls: Vec<String>,
/// pins the block number or transaction hash for the state fork
pub fork_choice: Option<ForkChoice>,
/// headers to use with `eth_rpc_url`
/// headers to use with fork RPC endpoints
pub fork_headers: Vec<String>,
/// specifies chain id for cache to skip fetching from remote in offline-start mode
pub fork_chain_id: Option<U256>,
Expand Down Expand Up @@ -274,6 +276,13 @@ Chain ID: {}
fork.chain_id()
);

if self.fork_urls.len() > 1 {
let _ = writeln!(s, "Endpoints: {}", self.fork_urls.len());
for (i, url) in self.fork_urls.iter().enumerate() {
let _ = writeln!(s, " ({i}) {url}");
}
}

if let Some(tx_hash) = fork.transaction_hash() {
let _ = writeln!(s, "Transaction hash: {tx_hash}");
}
Expand Down Expand Up @@ -466,7 +475,7 @@ impl Default for NodeConfig {
mixed_mining: false,
port: NODE_PORT,
max_transactions: 1_000,
eth_rpc_url: None,
fork_urls: vec![],
fork_choice: None,
account_generator: None,
base_fee: None,
Expand Down Expand Up @@ -855,10 +864,19 @@ impl NodeConfig {
self
}

/// Sets the `eth_rpc_url` to use when forking
/// Sets the `eth_rpc_url` to use when forking (single endpoint convenience).
#[must_use]
pub fn with_eth_rpc_url<U: Into<String>>(mut self, eth_rpc_url: Option<U>) -> Self {
self.eth_rpc_url = eth_rpc_url.map(Into::into);
if let Some(url) = eth_rpc_url {
self.fork_urls = vec![url.into()];
}
self
}

/// Sets the fork URLs for load-balanced multi-endpoint forking.
#[must_use]
pub fn with_fork_urls(mut self, fork_urls: Vec<String>) -> Self {
self.fork_urls = fork_urls;
self
}

Expand Down Expand Up @@ -891,7 +909,7 @@ impl NodeConfig {
self
}

/// Sets the `fork_headers` to use with `eth_rpc_url`
/// Sets the `fork_headers` to use with fork RPC endpoints
#[must_use]
pub fn with_fork_headers(mut self, headers: Vec<String>) -> Self {
self.fork_headers = headers;
Expand Down Expand Up @@ -1017,7 +1035,7 @@ impl NodeConfig {
///
/// See also [ Config::foundry_block_cache_file()]
pub fn block_cache_path(&self, block: u64) -> Option<PathBuf> {
if self.no_storage_caching || self.eth_rpc_url.is_none() {
if self.no_storage_caching || self.fork_urls.is_empty() {
return None;
}
let chain_id = self.get_chain_id();
Expand Down Expand Up @@ -1145,7 +1163,7 @@ impl NodeConfig {
);

let (db, fork): (Arc<TokioRwLock<Box<dyn Db>>>, Option<ClientFork>) =
if let Some(eth_rpc_url) = self.eth_rpc_url.clone() {
if let Some(eth_rpc_url) = self.fork_urls.first().cloned() {
self.setup_fork_db(eth_rpc_url, &mut evm_env, &fees).await?
} else {
(Arc::new(TokioRwLock::new(Box::<MemDb>::default())), None)
Expand Down Expand Up @@ -1208,7 +1226,7 @@ impl NodeConfig {

// Writes the default create2 deployer to the backend,
// if the option is not disabled and we are not forking.
if !self.disable_default_create2_deployer && self.eth_rpc_url.is_none() {
if !self.disable_default_create2_deployer && self.fork_urls.is_empty() {
backend
.set_create2_deployer(DEFAULT_CREATE2_DEPLOYER)
.await
Expand Down Expand Up @@ -1248,16 +1266,26 @@ impl NodeConfig {
fees: &FeeManager,
) -> Result<(ForkedDatabase<AnyNetwork>, ClientForkConfig)> {
debug!(target: "node", ?eth_rpc_url, "setting up fork db");
let provider = Arc::new(
let provider = Arc::new(if self.fork_urls.len() > 1 {
debug!(target: "node", urls=?self.fork_urls, "using multi-endpoint fallback provider");
ProviderBuilder::new(&eth_rpc_url)
.timeout(self.fork_request_timeout)
.initial_backoff(self.fork_retry_backoff.as_millis() as u64)
.compute_units_per_second(self.compute_units_per_second)
.max_retry(self.fork_request_retries)
.headers(self.fork_headers.clone())
.build_fallback(self.fork_urls.clone())
.wrap_err("failed to establish fallback provider to fork urls")?
} else {
ProviderBuilder::new(&eth_rpc_url)
.timeout(self.fork_request_timeout)
.initial_backoff(self.fork_retry_backoff.as_millis() as u64)
.compute_units_per_second(self.compute_units_per_second)
.max_retry(self.fork_request_retries)
.headers(self.fork_headers.clone())
Comment thread
stevencartavia marked this conversation as resolved.
Outdated
.build()
.wrap_err("failed to establish provider to fork url")?,
);
.wrap_err("failed to establish provider to fork url")?
});

let (fork_block_number, fork_chain_id, force_transactions) = if let Some(fork_choice) =
&self.fork_choice
Expand Down Expand Up @@ -1419,7 +1447,7 @@ latest block number: {latest_block}"
.await;

let config = ClientForkConfig {
eth_rpc_url,
fork_urls: self.fork_urls.clone(),
block_number: fork_block_number,
block_hash,
transaction_hash: self.fork_choice.and_then(|fc| fc.transaction_hash()),
Expand Down
6 changes: 3 additions & 3 deletions crates/anvil/src/eth/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,7 @@ impl<N: Network> EthApi<N> {
let config = fork.config.read();

NodeForkConfig {
fork_url: Some(config.eth_rpc_url.clone()),
fork_url: Some(config.eth_rpc_url().to_string()),
fork_block_number: Some(config.block_number),
fork_retry_backoff: Some(config.backoff.as_millis()),
}
Expand Down Expand Up @@ -543,8 +543,8 @@ impl<N: Network> EthApi<N> {
)?, // .interval(interval),
);
config.provider = new_provider;
trace!(target: "backend", "Updated fork rpc from \"{}\" to \"{}\"", config.eth_rpc_url, url);
config.eth_rpc_url = url;
trace!(target: "backend", "Updated fork rpc from \"{}\" to \"{}\"", config.eth_rpc_url(), url);
config.fork_urls = vec![url];
}
Ok(())
}
Expand Down
4 changes: 2 additions & 2 deletions crates/anvil/src/eth/backend/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ where

/// Helper trait to reset the DB if it's forked
pub trait MaybeForkedDatabase {
fn maybe_reset(&mut self, _url: Option<String>, block_number: BlockId) -> Result<(), String>;
fn maybe_reset(&mut self, _urls: Vec<String>, block_number: BlockId) -> Result<(), String>;

fn maybe_flush_cache(&self) -> Result<(), String>;

Expand Down Expand Up @@ -375,7 +375,7 @@ impl<T: DatabaseRef<Error = DatabaseError> + Debug> MaybeFullDatabase for CacheD
}

impl<T: DatabaseRef<Error = DatabaseError>> MaybeForkedDatabase for CacheDB<T> {
fn maybe_reset(&mut self, _url: Option<String>, _block_number: BlockId) -> Result<(), String> {
fn maybe_reset(&mut self, _urls: Vec<String>, _block_number: BlockId) -> Result<(), String> {
Err("not supported".to_string())
}

Expand Down
Loading
Loading