Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
116 changes: 108 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,8 @@ 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_eth_rpc_url(self.evm.fork_url.first().map(|fork| fork.url.clone()))
.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 +439,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 +647,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 +1004,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]);
}
}
}
36 changes: 33 additions & 3 deletions crates/anvil/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ pub struct NodeConfig {
pub max_transactions: usize,
/// url of the rpc server that should be used for any rpc calls
pub eth_rpc_url: Option<String>,
/// Additional fork URLs for load balancing across multiple RPC endpoints.
/// 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`
Expand Down Expand Up @@ -274,6 +278,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 @@ -467,6 +478,7 @@ impl Default for NodeConfig {
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 @@ -862,6 +874,13 @@ impl NodeConfig {
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
}

/// Sets the `fork_choice` to use to fork off from based on a block number
#[must_use]
pub fn with_fork_block_number<U: Into<u64>>(self, fork_block_number: Option<U>) -> Self {
Expand Down Expand Up @@ -1245,16 +1264,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 @@ -1417,6 +1446,7 @@ latest block number: {latest_block}"

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
23 changes: 18 additions & 5 deletions crates/anvil/src/eth/backend/fork.rs
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,8 @@ impl ClientFork {
#[derive(Clone, Debug)]
pub struct ClientForkConfig<N: Network = AnyNetwork> {
pub eth_rpc_url: String,
/// All fork URLs when using multi-endpoint load balancing.
pub fork_urls: Vec<String>,
Comment thread
figtracer marked this conversation as resolved.
Outdated
/// The block number of the forked block
pub block_number: u64,
/// The hash of the forked block
Expand Down Expand Up @@ -665,17 +667,28 @@ impl<N: Network> ClientForkConfig<N> {
///
/// This will fail if no new provider could be established (erroneous URL)
fn update_url(&mut self, url: String) -> Result<(), BlockchainError> {
// let interval = self.provider.get_interval();
self.provider = Arc::new(
// Update fork_urls to reflect the new primary URL
if self.fork_urls.len() > 1 {
self.fork_urls[0] = url.clone();
}

self.provider = Arc::new(if self.fork_urls.len() > 1 {
ProviderBuilder::<N>::new(url.as_str())
.timeout(self.timeout)
.max_retry(self.retries)
.initial_backoff(self.backoff.as_millis() as u64)
.compute_units_per_second(self.compute_units_per_second)
.build_fallback(self.fork_urls.clone())
.map_err(|e| BlockchainError::InvalidUrl(format!("{url}: {e}")))?
} else {
ProviderBuilder::<N>::new(url.as_str())
.timeout(self.timeout)
// .timeout_retry(self.retries)
.max_retry(self.retries)
.initial_backoff(self.backoff.as_millis() as u64)
.compute_units_per_second(self.compute_units_per_second)
.build()
.map_err(|e| BlockchainError::InvalidUrl(format!("{url}: {e}")))?, /* .interval(interval), */
);
.map_err(|e| BlockchainError::InvalidUrl(format!("{url}: {e}")))?
});
trace!(target: "fork", "Updated rpc url {}", url);
self.eth_rpc_url = url;
Ok(())
Expand Down
80 changes: 79 additions & 1 deletion crates/common/src/provider/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,22 @@ use alloy_provider::{
network::{AnyNetwork, EthereumWallet},
};
use alloy_rpc_client::ClientBuilder;
use alloy_transport::{layers::RetryBackoffLayer, utils::guess_local_url};
use alloy_transport::{
layers::{FallbackLayer, RetryBackoffLayer},
utils::guess_local_url,
};
use eyre::{Result, WrapErr};
use foundry_config::Config;
use reqwest::Url;
use std::{
marker::PhantomData,
net::SocketAddr,
num::NonZeroUsize,
path::{Path, PathBuf},
str::FromStr,
time::Duration,
};
use tower::Layer;
use url::ParseError;

/// The assumed block time for unknown chains.
Expand Down Expand Up @@ -364,6 +369,79 @@ impl<N: Network> ProviderBuilder<N> {
}

impl<N: Network> ProviderBuilder<N> {
/// Constructs a `RetryProvider` backed by multiple URLs using Alloy's `FallbackService`.
///
/// Requests are distributed across all provided endpoints using a scored strategy:
/// the top `active_transport_count` endpoints (by latency + success rate) are queried,
/// and the first successful response wins. Endpoints that return errors or time out
/// are automatically deprioritized.
///
/// Set `active_transport_count` to 1 for sequential (round-robin-like) behavior where
/// only the best-scored endpoint handles each request.
pub fn build_fallback(self, urls: Vec<String>) -> Result<RetryProvider<N>> {
let Self {
chain,
max_retry,
initial_backoff,
timeout,
compute_units_per_second,
jwt,
headers,
accept_invalid_certs,
no_proxy,
curl_mode,
..
} = self;

eyre::ensure!(!urls.is_empty(), "at least one fork URL is required");
eyre::ensure!(!curl_mode, "curl mode is not supported with multiple fork URLs");

// Build a RuntimeTransport for each URL, using the same URL normalization
// as ProviderBuilder::new() (handles localhost:port, raw socket addrs, IPC paths)
let transports: Vec<_> = urls
.iter()
.map(|url_str| {
let builder = Self::new(url_str);
let url = builder.url?;
Ok(RuntimeTransportBuilder::new(url)
.with_timeout(timeout)
.with_headers(headers.clone())
.with_jwt(jwt.clone())
.accept_invalid_certs(accept_invalid_certs)
.no_proxy(no_proxy)
.build())
})
.collect::<Result<Vec<_>>>()?;

// Wrap in FallbackService: use active_transport_count=1 for sequential failover
// (only the best-scored endpoint is tried per request, closest to round-robin)
let fallback_layer =
FallbackLayer::default().with_active_transport_count(NonZeroUsize::new(1).unwrap());
let fallback_service = fallback_layer.layer(transports);

// Apply retry layer on top of the fallback service
let retry_layer =
RetryBackoffLayer::new(max_retry, initial_backoff, compute_units_per_second);
let is_local = urls.iter().all(guess_local_url);
let client =
ClientBuilder::default().layer(retry_layer).transport(fallback_service, is_local);

if !is_local {
client.set_poll_interval(
chain
.average_blocktime_hint()
.map(|hint| hint.min(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME))
.unwrap_or(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME)
.mul_f32(POLL_INTERVAL_BLOCK_TIME_SCALE_FACTOR),
);
}

let provider =
AlloyProviderBuilder::<_, _, N>::default().connect_provider(RootProvider::new(client));

Ok(provider)
}

/// Constructs the `RetryProvider` with a wallet.
pub fn build_with_wallet<W: NetworkWallet<N> + Clone>(
self,
Expand Down
Loading
Loading