Skip to content

feat(anvil): support multiple fork URLs with round-robin load balancing#14280

Merged
zerosnacks merged 20 commits intomasterfrom
zerosnacks/multi-fork-url
Apr 21, 2026
Merged

feat(anvil): support multiple fork URLs with round-robin load balancing#14280
zerosnacks merged 20 commits intomasterfrom
zerosnacks/multi-fork-url

Conversation

@decofe
Copy link
Copy Markdown
Contributor

@decofe decofe commented Apr 13, 2026

Summary

Allow --fork-url to accept multiple RPC endpoints. Requests are distributed across endpoints using a custom round-robin tower::Service — each request goes to the next transport in rotation. On failure, the retry layer above handles retries (which will hit the next transport in the ring).

Closes #14265

Usage

CLI: multiple --fork-url flags

anvil --fork-url https://rpc1.example.com --fork-url https://rpc2.example.com --fork-url https://rpc3.example.com

foundry.toml: endpoints array (backwards compatible)

[rpc_endpoints]
mainnet = { endpoints = ["https://rpc1.example.com", "https://rpc2.example.com"], retries = 5, retry_backoff = 1000 }

Then use the alias:

anvil --fork-url mainnet

Existing single-endpoint configs continue to work unchanged:

[rpc_endpoints]
mainnet = "https://rpc1.example.com"
mainnet_alt = { endpoint = "https://rpc2.example.com", retries = 5 }

Changes

  • crates/config/src/endpoints.rs: Added extra_endpoints to RpcEndpoint, support endpoints array in deser (backwards-compatible with existing endpoint key), ResolvedRpcEndpoint::all_urls() helper
  • crates/anvil/src/cmd.rs: Changed fork_url from Option<ForkUrl> to Vec<ForkUrl>, alias resolution expands multi-endpoint configs, validates no block suffixes on secondary URLs
  • crates/anvil/src/config.rs: Added fork_urls: Vec<String> to NodeConfig, branched setup_fork_db_config to build a round-robin provider when multiple URLs are present
  • crates/common/src/provider/mod.rs: Added RoundRobinService (a tower::Service that rotates requests across multiple RuntimeTransports) and ProviderBuilder::build_fallback() which wraps it with a retry layer
  • crates/anvil/src/eth/backend/fork.rs: Added fork_urls to ClientForkConfig, updated update_url to rebuild the provider and keep URLs in sync

Design

Uses a custom RoundRobinService that distributes requests sequentially across transports using AtomicUsize rotation. Each request goes to exactly one transport. On error, failover is handled by the RetryBackoffLayer above, which will naturally hit the next transport on retry since the index advances.

Note: There is no health scoring, endpoint quarantine, or active deprioritization of unhealthy endpoints. A flaky endpoint will continue to receive its share (~1/N) of requests. True health-based fallback (e.g. via Alloy's FallbackService) could be added in a follow-up.

Transparent to all upstream consumers — ClientFork, SharedBackend, BackendHandler, ForkedDatabase see the same Arc<RetryProvider> / dyn Provider<N>.

Backwards Compatibility

  • Single --fork-url works exactly the same
  • foundry.toml single endpoint key unchanged
  • endpoints (array) is a new optional key — cannot be combined with endpoint

Known Limitations

  • No health scoring or endpoint deprioritization — all endpoints receive equal traffic regardless of error rate or latency
  • No startup validation that all endpoints return the same eth_chainId or can serve the fork block
  • anvil_setRpcUrl resets to a single endpoint; multi-endpoint state is startup-only
  • RoundRobinService::poll_ready() always returns Ready without polling inner transports

Testing

cargo test -p foundry-config -- endpoints
cargo test -p anvil --lib cmd::tests

Prompted by: zerosnacks

Allow `--fork-url` to be specified multiple times to distribute RPC
requests across endpoints using Alloy's FallbackService. Endpoints are
scored by latency and success rate; unhealthy endpoints (429s, timeouts)
are automatically deprioritized.

Uses active_transport_count=1 for sequential best-endpoint routing.

Co-authored-by: zerosnacks <95942363+zerosnacks@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d85e6-b08f-748c-8f05-17ebee8e17f8
@zerosnacks zerosnacks marked this pull request as draft April 13, 2026 09:24
zerosnacks and others added 3 commits April 13, 2026 11:32
Add `endpoints` key to `[rpc_endpoints]` config as a backwards-compatible
alternative to `endpoint`. When an alias with multiple endpoints is used
as `--fork-url`, all URLs are expanded for multi-endpoint forking.

Example:
  [rpc_endpoints]
  mainnet = { endpoints = ["https://rpc1.example.com", "https://rpc2.example.com"], retries = 5 }

Co-authored-by: zerosnacks <95942363+zerosnacks@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d85e6-b08f-748c-8f05-17ebee8e17f8
- Add test confirming `requires = "fork_url"` guards still fire with Vec
- Bail on curl_mode in build_fallback (incompatible with multi-endpoint)
- Clean up redundant extra_endpoints default in From impl

Co-authored-by: zerosnacks <95942363+zerosnacks@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d85e6-b08f-748c-8f05-17ebee8e17f8
@zerosnacks zerosnacks marked this pull request as ready for review April 13, 2026 10:09
Comment thread crates/anvil/src/eth/backend/fork.rs Outdated
@zerosnacks zerosnacks marked this pull request as draft April 14, 2026 10:04
decofe and others added 8 commits April 14, 2026 10:07
Remove duplicate `eth_rpc_url` field from `NodeConfig` and
`ClientForkConfig`. The primary URL is now always `fork_urls[0]`,
eliminating the invariant that `eth_rpc_url == fork_urls[0]`.

- `ClientForkConfig::eth_rpc_url()` is now an accessor returning `&str`
- `NodeConfig::with_eth_rpc_url()` kept as convenience, wraps into `fork_urls`
- Checks for forking enabled use `fork_urls.is_empty()` instead of `eth_rpc_url.is_none()`

Co-Authored-By: zerosnacks <95942363+zerosnacks@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d8b67-c7cc-764f-bb5e-74b6e8fa6858
When resetting the fork via anvil_reset or anvil_setRpcUrl, the intent
is to switch the fork target entirely — not swap one endpoint in a
load-balanced pool. Replace the whole fork_urls vec with the single
new URL instead of mutating fork_urls[0] in-place.

Co-Authored-By: zerosnacks <95942363+zerosnacks@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d8b67-c7cc-764f-bb5e-74b6e8fa6858
Accepts a Vec so the reset/update path can reconstruct a fallback
provider when given multiple URLs, instead of always collapsing to
a single endpoint.

Co-Authored-By: zerosnacks <95942363+zerosnacks@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d8b67-c7cc-764f-bb5e-74b6e8fa6858
Change reset(url: Option<String>) to reset(urls: Vec<String>) so the
reset path can preserve multi-endpoint fallback. The underlying
MaybeForkedDatabase::maybe_reset still takes Option<String> since
it ignores the url anyway (marked TODO upstream).

Co-Authored-By: zerosnacks <95942363+zerosnacks@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d8b67-c7cc-764f-bb5e-74b6e8fa6858
Commit to Vec<String> in MaybeForkedDatabase::maybe_reset,
ForkedDatabase::reset, and ClientFork::reset. The Option<String> →
Vec<String> conversion now happens only at the RPC boundary
(Forking.json_rpc_url in reset_fork).

Co-Authored-By: zerosnacks <95942363+zerosnacks@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d8b67-c7cc-764f-bb5e-74b6e8fa6858
Co-Authored-By: zerosnacks <95942363+zerosnacks@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d8b67-c7cc-764f-bb5e-74b6e8fa6858
// we want to force the correct base fee for the next block during
// `setup_fork_db_config`
node_config.base_fee.take();
node_config.fork_urls = vec![eth_rpc_url.clone()];
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setup_fork_db_config clones self.fork_urls into ClientForkConfig, but in the non-fork-to-fork reset path node_config.fork_urls is still the default empty vec. This panics on fork_urls[0] in eth_rpc_url().

Copy link
Copy Markdown
Collaborator

@mablr mablr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some comments, looks good overall.

Comment thread crates/anvil/src/eth/backend/fork.rs
Comment thread crates/anvil/src/cmd.rs Outdated
Comment thread crates/anvil/src/eth/backend/fork.rs
Comment thread crates/anvil/src/config.rs Outdated
@stevencartavia stevencartavia marked this pull request as ready for review April 16, 2026 16:40
mablr
mablr previously approved these changes Apr 17, 2026
Copy link
Copy Markdown
Collaborator

@mablr mablr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sgtm

@zerosnacks zerosnacks changed the title feat(anvil): support multiple fork URLs with fallback feat(anvil): support multiple fork URLs with round-robin load balancing Apr 21, 2026
Fix two bugs where node_config.fork_urls could get out of sync:

1. reset_block_number() now updates node_config.fork_urls before calling
   setup_fork_db_config(), preventing stale multi-URL lists from persisting
   after anvil_reset with a new URL.

2. anvil_setRpcUrl now also updates node_config.fork_urls, so subsequent
   anvil_reset(None) uses the correct URL instead of reverting to the
   original startup URL.

Added regression tests for both scenarios.

Amp-Thread-ID: https://ampcode.com/threads/T-019db0c0-6038-7428-8483-365402ff31a3
Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019db0c0-6038-7428-8483-365402ff31a3
Co-authored-by: Amp <amp@ampcode.com>
@zerosnacks zerosnacks enabled auto-merge (squash) April 21, 2026 16:39
zerosnacks
zerosnacks previously approved these changes Apr 21, 2026
Copy link
Copy Markdown
Member

@zerosnacks zerosnacks left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made some small bug fix changes here: 8cd02a5 and here: #14393

PR lgtm

@zerosnacks zerosnacks requested a review from mablr April 21, 2026 16:51
…obinService guard (#14393)

fix(anvil): fix multi-fork-url docs, bootstrap race, and RoundRobinService guard

- Fix bootstrap race condition: use primary URL only for discovery calls
  (get_chain_id, find_latest_fork_block, get_block), then rebuild with
  round-robin provider after the fork block is pinned.

- Fix is_local detection in build_fallback() to use normalized/parsed URLs,
  consistent with build().

- Add panic guard for RoundRobinService::new() with empty transports.

- Fix stale docs/comments that incorrectly referenced Alloy FallbackService
  and health-scored routing in CLI help, NodeConfig, ClientForkConfig, and
  build_fallback() docstring.

Amp-Thread-ID: https://ampcode.com/threads/T-019db0c0-6038-7428-8483-365402ff31a3

Co-authored-by: Amp <amp@ampcode.com>
@zerosnacks zerosnacks merged commit f15108c into master Apr 21, 2026
16 checks passed
@zerosnacks zerosnacks deleted the zerosnacks/multi-fork-url branch April 21, 2026 17:02
@github-project-automation github-project-automation Bot moved this to Done in Foundry Apr 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

feat(anvil): support multiple fork URLs with load balancing

5 participants