feat(anvil): support multiple fork URLs with round-robin load balancing#14280
Merged
zerosnacks merged 20 commits intomasterfrom Apr 21, 2026
Merged
feat(anvil): support multiple fork URLs with round-robin load balancing#14280zerosnacks merged 20 commits intomasterfrom
zerosnacks merged 20 commits intomasterfrom
Conversation
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
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
mablr
reviewed
Apr 14, 2026
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()]; |
Collaborator
There was a problem hiding this comment.
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().
mablr
reviewed
Apr 16, 2026
Collaborator
mablr
left a comment
There was a problem hiding this comment.
Some comments, looks good overall.
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
previously approved these changes
Apr 21, 2026
…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
approved these changes
Apr 21, 2026
stevencartavia
approved these changes
Apr 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Allow
--fork-urlto accept multiple RPC endpoints. Requests are distributed across endpoints using a custom round-robintower::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-urlflagsfoundry.toml:
endpointsarray (backwards compatible)Then use the alias:
Existing single-endpoint configs continue to work unchanged:
Changes
crates/config/src/endpoints.rs: Addedextra_endpointstoRpcEndpoint, supportendpointsarray in deser (backwards-compatible with existingendpointkey),ResolvedRpcEndpoint::all_urls()helpercrates/anvil/src/cmd.rs: Changedfork_urlfromOption<ForkUrl>toVec<ForkUrl>, alias resolution expands multi-endpoint configs, validates no block suffixes on secondary URLscrates/anvil/src/config.rs: Addedfork_urls: Vec<String>toNodeConfig, branchedsetup_fork_db_configto build a round-robin provider when multiple URLs are presentcrates/common/src/provider/mod.rs: AddedRoundRobinService(atower::Servicethat rotates requests across multipleRuntimeTransports) andProviderBuilder::build_fallback()which wraps it with a retry layercrates/anvil/src/eth/backend/fork.rs: Addedfork_urlstoClientForkConfig, updatedupdate_urlto rebuild the provider and keep URLs in syncDesign
Uses a custom
RoundRobinServicethat distributes requests sequentially across transports usingAtomicUsizerotation. Each request goes to exactly one transport. On error, failover is handled by theRetryBackoffLayerabove, 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,ForkedDatabasesee the sameArc<RetryProvider>/dyn Provider<N>.Backwards Compatibility
--fork-urlworks exactly the samefoundry.tomlsingleendpointkey unchangedendpoints(array) is a new optional key — cannot be combined withendpointKnown Limitations
eth_chainIdor can serve the fork blockanvil_setRpcUrlresets to a single endpoint; multi-endpoint state is startup-onlyRoundRobinService::poll_ready()always returnsReadywithout polling inner transportsTesting
Prompted by: zerosnacks