Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
125 changes: 117 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
/// using round-robin load balancing. On failure, the retry layer rotates to the next
/// endpoint.
#[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,45 @@ 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
match endpoint.all_urls() {
Ok(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 },
});
}
}
Err(e) => {
warn!(target: "node", alias=%fork_url.url, %e, "could not resolve all endpoints, using primary endpoint only");
if let Ok(url) = endpoint.url() {
resolved_urls.push(ForkUrl { url, block: fork_url.block });
} else {
resolved_urls.push(fork_url.clone());
}
}
}
} 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 +1013,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]);
}
}
}
68 changes: 55 additions & 13 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
/// round-robin load balancing with retry-based failover.
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 @@ -268,12 +270,19 @@ Block number: {}
Block hash: {:?}
Chain ID: {}
"#,
fork.eth_rpc_url(),
fork.eth_rpc_url().as_deref().unwrap_or("none"),
fork.block_number(),
fork.block_hash(),
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 @@ -393,7 +402,7 @@ Genesis Number
json!({
"available_accounts": available_accounts,
"private_keys": private_keys,
"endpoint": fork.eth_rpc_url(),
"endpoint": fork.eth_rpc_url().unwrap_or_default(),
"block_number": fork.block_number(),
"block_hash": fork.block_hash(),
"chain_id": fork.chain_id(),
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,6 +1266,10 @@ impl NodeConfig {
fees: &FeeManager,
) -> Result<(ForkedDatabase<AnyNetwork>, ClientForkConfig)> {
debug!(target: "node", ?eth_rpc_url, "setting up fork db");

// Always bootstrap with the primary URL only to avoid race conditions
// where discovery calls (get_chain_id, find_latest_fork_block, get_block)
// hit different endpoints that may be at different chain tips.
let provider = Arc::new(
ProviderBuilder::new(&eth_rpc_url)
.timeout(self.fork_request_timeout)
Expand Down Expand Up @@ -1409,6 +1431,25 @@ latest block number: {latest_block}"
BlockchainDb::new(meta, self.block_cache_path(fork_block_number))
};

// After bootstrap, rebuild the provider with round-robin if multiple URLs are
// configured. This ensures bootstrap used only the primary endpoint for consistency,
// while ongoing requests are distributed across all endpoints.
let provider = if self.fork_urls.len() > 1 {
debug!(target: "node", urls=?self.fork_urls, "using multi-endpoint round-robin provider");
Arc::new(
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 round-robin provider to fork urls")?,
)
} else {
provider
};

// This will spawn the background thread that will use the provider to fetch
// blockchain data from the other client
let backend = SharedBackend::spawn_backend(
Expand All @@ -1419,7 +1460,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 All @@ -1432,6 +1473,7 @@ latest block number: {latest_block}"
retries: self.fork_request_retries,
backoff: self.fork_retry_backoff,
compute_units_per_second: self.compute_units_per_second,
headers: self.fork_headers.clone(),
total_difficulty: block.header.total_difficulty.unwrap_or_default(),
blob_gas_used: block.header.blob_gas_used().map(|g| g as u128),
blob_excess_gas_and_price: evm_env.block_env.blob_excess_gas_and_price,
Expand Down
12 changes: 7 additions & 5 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: config.eth_rpc_url().map(|s| s.to_string()),
fork_block_number: Some(config.block_number),
fork_retry_backoff: Some(config.backoff.as_millis()),
}
Expand Down Expand Up @@ -527,7 +527,7 @@ impl<N: Network> EthApi<N> {
/// Sets the backend rpc url
///
/// Handler for ETH RPC call: `anvil_setRpcUrl`
pub fn anvil_set_rpc_url(&self, url: String) -> Result<()> {
pub async fn anvil_set_rpc_url(&self, url: String) -> Result<()> {
node_info!("anvil_setRpcUrl");
if let Some(fork) = self.backend.get_fork() {
let mut config = fork.config.write();
Expand All @@ -543,9 +543,11 @@ 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().unwrap_or("none"), url);
config.fork_urls = vec![url.clone()];
}
// Keep node_config in sync so anvil_reset(None) uses the updated URL
self.backend.node_config.write().await.fork_urls = vec![url];
Ok(())
}

Expand Down Expand Up @@ -1791,7 +1793,7 @@ impl EthApi<FoundryNetwork> {
EthRequest::EvmMineDetailed(mine) => {
self.evm_mine_detailed(mine.and_then(|p| p.params)).await.to_rpc_result()
}
EthRequest::SetRpcUrl(url) => self.anvil_set_rpc_url(url).to_rpc_result(),
EthRequest::SetRpcUrl(url) => self.anvil_set_rpc_url(url).await.to_rpc_result(),
EthRequest::EthSendUnsignedTransaction(tx) => {
self.eth_send_unsigned_transaction(*tx).await.to_rpc_result()
}
Expand Down
Loading
Loading