Skip to main content

foundry_common/provider/
mod.rs

1//! Provider-related instantiation and usage utilities.
2
3pub mod curl_transport;
4pub mod mpp;
5pub mod runtime_transport;
6
7use crate::{
8    ALCHEMY_FREE_TIER_CUPS, REQUEST_TIMEOUT,
9    provider::{curl_transport::CurlTransport, runtime_transport::RuntimeTransportBuilder},
10};
11use alloy_chains::NamedChain;
12use alloy_json_rpc::{RequestPacket, ResponsePacket};
13use alloy_network::{Network, NetworkWallet};
14use alloy_provider::{
15    Identity, ProviderBuilder as AlloyProviderBuilder, RootProvider,
16    fillers::{FillProvider, JoinFill, RecommendedFillers, WalletFiller},
17    network::{AnyNetwork, EthereumWallet},
18};
19use alloy_rpc_client::ClientBuilder;
20use alloy_transport::{
21    TransportError, TransportFut, layers::RetryBackoffLayer, utils::guess_local_url,
22};
23use eyre::{Result, WrapErr};
24use foundry_config::Config;
25use reqwest::Url;
26use std::{
27    marker::PhantomData,
28    net::SocketAddr,
29    path::{Path, PathBuf},
30    str::FromStr,
31    sync::{
32        Arc,
33        atomic::{AtomicUsize, Ordering},
34    },
35    task::{Context, Poll},
36    time::Duration,
37};
38use tower::Service;
39use url::ParseError;
40
41/// The assumed block time for unknown chains.
42/// We assume that these are chains have a faster block time.
43const DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME: Duration = Duration::from_secs(3);
44
45/// The factor to scale the block time by to get the poll interval.
46const POLL_INTERVAL_BLOCK_TIME_SCALE_FACTOR: f32 = 0.6;
47
48/// Helper type alias for a retry provider
49pub type RetryProvider<N = AnyNetwork> = RootProvider<N>;
50
51/// Helper type alias for a retry provider with a signer
52pub type RetryProviderWithSigner<N = AnyNetwork, W = EthereumWallet> = FillProvider<
53    JoinFill<JoinFill<Identity, <N as RecommendedFillers>::RecommendedFillers>, WalletFiller<W>>,
54    RootProvider<N>,
55    N,
56>;
57
58/// Constructs a provider with a 100 millisecond interval poll if it's a localhost URL (most likely
59/// an anvil or other dev node) and with the default, or 7 second otherwise.
60///
61/// See [`try_get_http_provider`] for more details.
62///
63/// # Panics
64///
65/// Panics if the URL is invalid.
66///
67/// # Examples
68///
69/// ```
70/// use foundry_common::provider::get_http_provider;
71///
72/// let retry_provider = get_http_provider("http://localhost:8545");
73/// ```
74#[inline]
75#[track_caller]
76pub fn get_http_provider(builder: impl AsRef<str>) -> RetryProvider {
77    try_get_http_provider(builder).unwrap()
78}
79
80/// Constructs a provider with a 100 millisecond interval poll if it's a localhost URL (most likely
81/// an anvil or other dev node) and with the default, or 7 second otherwise.
82#[inline]
83pub fn try_get_http_provider(builder: impl AsRef<str>) -> Result<RetryProvider> {
84    ProviderBuilder::new(builder.as_ref()).build()
85}
86
87/// A round-robin transport that distributes requests across multiple transports.
88///
89/// Each request is sent to exactly one transport, rotating through the list.
90/// Failover on error is handled by the retry layer above this service.
91#[derive(Clone)]
92pub struct RoundRobinService<S> {
93    transports: Arc<Vec<S>>,
94    next: Arc<AtomicUsize>,
95}
96
97impl<S> RoundRobinService<S> {
98    /// Creates a new round-robin service from a non-empty list of transports.
99    ///
100    /// # Panics
101    ///
102    /// Panics if `transports` is empty.
103    pub fn new(transports: Vec<S>) -> Self {
104        assert!(!transports.is_empty(), "RoundRobinService requires at least one transport");
105        Self { transports: Arc::new(transports), next: Arc::new(AtomicUsize::new(0)) }
106    }
107}
108
109impl<S> Service<RequestPacket> for RoundRobinService<S>
110where
111    S: Service<
112            RequestPacket,
113            Response = ResponsePacket,
114            Error = TransportError,
115            Future = TransportFut<'static>,
116        > + Clone
117        + Send
118        + Sync
119        + 'static,
120{
121    type Response = ResponsePacket;
122    type Error = TransportError;
123    type Future = TransportFut<'static>;
124
125    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
126        Poll::Ready(Ok(()))
127    }
128
129    fn call(&mut self, req: RequestPacket) -> Self::Future {
130        let transports = self.transports.clone();
131        let idx = self.next.fetch_add(1, Ordering::Relaxed) % transports.len();
132        let mut transport = transports[idx].clone();
133        transport.call(req)
134    }
135}
136
137/// Helper type to construct a `RetryProvider`
138///
139/// This builder is generic over the network type `N`, defaulting to `AnyNetwork`.
140#[derive(Debug)]
141pub struct ProviderBuilder<N: Network = AnyNetwork> {
142    // Note: this is a result, so we can easily chain builder calls
143    url: Result<Url>,
144    chain: NamedChain,
145    max_retry: u32,
146    initial_backoff: u64,
147    timeout: Duration,
148    /// available CUPS
149    compute_units_per_second: u64,
150    /// JWT Secret
151    jwt: Option<String>,
152    headers: Vec<String>,
153    is_local: bool,
154    /// Whether to accept invalid certificates.
155    accept_invalid_certs: bool,
156    /// Whether to disable automatic proxy detection.
157    no_proxy: bool,
158    /// Whether to output curl commands instead of making requests.
159    curl_mode: bool,
160    /// Phantom data for the network type.
161    _network: PhantomData<N>,
162}
163
164impl<N: Network> ProviderBuilder<N> {
165    /// Creates a new ProviderBuilder helper instance.
166    pub fn new(url_str: &str) -> Self {
167        // a copy is needed for the next lines to work
168        let mut url_str = url_str;
169
170        // invalid url: non-prefixed URL scheme is not allowed, so we prepend the default http
171        // prefix
172        let storage;
173        if url_str.starts_with("localhost:") {
174            storage = format!("http://{url_str}");
175            url_str = storage.as_str();
176        }
177
178        let url = Url::parse(url_str)
179            .or_else(|err| match err {
180                ParseError::RelativeUrlWithoutBase => {
181                    if SocketAddr::from_str(url_str).is_ok() {
182                        Url::parse(&format!("http://{url_str}"))
183                    } else {
184                        let path = Path::new(url_str);
185
186                        if let Ok(path) = resolve_path(path) {
187                            Url::parse(&format!("file://{}", path.display()))
188                        } else {
189                            Err(err)
190                        }
191                    }
192                }
193                _ => Err(err),
194            })
195            .wrap_err_with(|| format!("invalid provider URL: {url_str:?}"));
196
197        // Use the final URL string to guess if it's a local URL.
198        let is_local = url.as_ref().is_ok_and(|url| guess_local_url(url.as_str()));
199
200        Self {
201            url,
202            chain: NamedChain::Mainnet,
203            max_retry: 8,
204            initial_backoff: 800,
205            timeout: REQUEST_TIMEOUT,
206            // alchemy max cpus <https://docs.alchemy.com/reference/compute-units#what-are-cups-compute-units-per-second>
207            compute_units_per_second: ALCHEMY_FREE_TIER_CUPS,
208            jwt: None,
209            headers: vec![],
210            is_local,
211            accept_invalid_certs: false,
212            no_proxy: false,
213            curl_mode: false,
214            _network: PhantomData,
215        }
216    }
217
218    /// Constructs a [ProviderBuilder] instantiated using [Config] values.
219    ///
220    /// Defaults to `http://localhost:8545` and `Mainnet`.
221    pub fn from_config(config: &Config) -> Result<Self> {
222        let url = config.get_rpc_url_or_localhost_http()?;
223        let mut builder = Self::new(url.as_ref());
224
225        builder = builder.accept_invalid_certs(config.eth_rpc_accept_invalid_certs);
226        builder = builder.curl_mode(config.eth_rpc_curl);
227
228        if let Ok(chain) = config.chain.unwrap_or_default().try_into() {
229            builder = builder.chain(chain);
230        }
231
232        if let Some(jwt) = config.get_rpc_jwt_secret()? {
233            builder = builder.jwt(jwt.as_ref());
234        }
235
236        if let Some(rpc_timeout) = config.eth_rpc_timeout {
237            builder = builder.timeout(Duration::from_secs(rpc_timeout));
238        }
239
240        if let Some(rpc_headers) = config.eth_rpc_headers.clone() {
241            builder = builder.headers(rpc_headers);
242        }
243
244        Ok(builder)
245    }
246
247    /// Enables a request timeout.
248    ///
249    /// The timeout is applied from when the request starts connecting until the
250    /// response body has finished.
251    ///
252    /// Default is no timeout.
253    pub const fn timeout(mut self, timeout: Duration) -> Self {
254        self.timeout = timeout;
255        self
256    }
257
258    /// Sets the chain of the node the provider will connect to
259    pub const fn chain(mut self, chain: NamedChain) -> Self {
260        self.chain = chain;
261        self
262    }
263
264    /// How often to retry a failed request
265    pub const fn max_retry(mut self, max_retry: u32) -> Self {
266        self.max_retry = max_retry;
267        self
268    }
269
270    /// How often to retry a failed request. If `None`, defaults to the already-set value.
271    pub fn maybe_max_retry(mut self, max_retry: Option<u32>) -> Self {
272        self.max_retry = max_retry.unwrap_or(self.max_retry);
273        self
274    }
275
276    /// The starting backoff delay to use after the first failed request. If `None`, defaults to
277    /// the already-set value.
278    pub fn maybe_initial_backoff(mut self, initial_backoff: Option<u64>) -> Self {
279        self.initial_backoff = initial_backoff.unwrap_or(self.initial_backoff);
280        self
281    }
282
283    /// The starting backoff delay to use after the first failed request
284    pub const fn initial_backoff(mut self, initial_backoff: u64) -> Self {
285        self.initial_backoff = initial_backoff;
286        self
287    }
288
289    /// Sets the number of assumed available compute units per second
290    ///
291    /// See also, <https://docs.alchemy.com/reference/compute-units#what-are-cups-compute-units-per-second>
292    pub const fn compute_units_per_second(mut self, compute_units_per_second: u64) -> Self {
293        self.compute_units_per_second = compute_units_per_second;
294        self
295    }
296
297    /// Sets the number of assumed available compute units per second
298    ///
299    /// See also, <https://docs.alchemy.com/reference/compute-units#what-are-cups-compute-units-per-second>
300    pub const fn compute_units_per_second_opt(
301        mut self,
302        compute_units_per_second: Option<u64>,
303    ) -> Self {
304        if let Some(cups) = compute_units_per_second {
305            self.compute_units_per_second = cups;
306        }
307        self
308    }
309
310    /// Sets the provider to be local.
311    ///
312    /// This is useful for local dev nodes.
313    pub const fn local(mut self, is_local: bool) -> Self {
314        self.is_local = is_local;
315        self
316    }
317
318    /// Sets aggressive `max_retry` and `initial_backoff` values
319    ///
320    /// This is only recommend for local dev nodes
321    pub const fn aggressive(self) -> Self {
322        self.max_retry(100).initial_backoff(100).local(true)
323    }
324
325    /// Sets the JWT secret
326    pub fn jwt(mut self, jwt: impl Into<String>) -> Self {
327        self.jwt = Some(jwt.into());
328        self
329    }
330
331    /// Sets http headers
332    pub fn headers(mut self, headers: Vec<String>) -> Self {
333        self.headers = headers;
334
335        self
336    }
337
338    /// Sets http headers. If `None`, defaults to the already-set value.
339    pub fn maybe_headers(mut self, headers: Option<Vec<String>>) -> Self {
340        self.headers = headers.unwrap_or(self.headers);
341        self
342    }
343
344    /// Sets whether to accept invalid certificates.
345    pub const fn accept_invalid_certs(mut self, accept_invalid_certs: bool) -> Self {
346        self.accept_invalid_certs = accept_invalid_certs;
347        self
348    }
349
350    /// Sets whether to disable automatic proxy detection.
351    ///
352    /// This can help in sandboxed environments (e.g., Cursor IDE sandbox, macOS App Sandbox)
353    /// where system proxy detection via SCDynamicStore causes crashes.
354    pub const fn no_proxy(mut self, no_proxy: bool) -> Self {
355        self.no_proxy = no_proxy;
356        self
357    }
358
359    /// Sets whether to output curl commands instead of making requests.
360    ///
361    /// When enabled, the provider will print equivalent curl commands to stdout
362    /// instead of actually executing the RPC requests.
363    pub const fn curl_mode(mut self, curl_mode: bool) -> Self {
364        self.curl_mode = curl_mode;
365        self
366    }
367
368    /// Constructs the `RetryProvider` taking all configs into account.
369    pub fn build(self) -> Result<RetryProvider<N>> {
370        let Self {
371            url,
372            chain,
373            max_retry,
374            initial_backoff,
375            timeout,
376            compute_units_per_second,
377            jwt,
378            headers,
379            is_local,
380            accept_invalid_certs,
381            no_proxy,
382            curl_mode,
383            ..
384        } = self;
385        let url = url?;
386
387        let retry_layer =
388            RetryBackoffLayer::new(max_retry, initial_backoff, compute_units_per_second);
389
390        // If curl_mode is enabled, use CurlTransport instead of RuntimeTransport
391        if curl_mode {
392            let transport = CurlTransport::new(url).with_headers(headers).with_jwt(jwt);
393            let client = ClientBuilder::default().layer(retry_layer).transport(transport, is_local);
394
395            let provider = AlloyProviderBuilder::<_, _, N>::default()
396                .connect_provider(RootProvider::new(client));
397
398            return Ok(provider);
399        }
400
401        let transport = RuntimeTransportBuilder::new(url)
402            .with_timeout(timeout)
403            .with_headers(headers)
404            .with_jwt(jwt)
405            .accept_invalid_certs(accept_invalid_certs)
406            .no_proxy(no_proxy)
407            .build();
408        let client = ClientBuilder::default().layer(retry_layer).transport(transport, is_local);
409
410        if !is_local {
411            client.set_poll_interval(
412                chain
413                    .average_blocktime_hint()
414                    // we cap the poll interval because if not provided, chain would default to
415                    // mainnet
416                    .map(|hint| hint.min(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME))
417                    .unwrap_or(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME)
418                    .mul_f32(POLL_INTERVAL_BLOCK_TIME_SCALE_FACTOR),
419            );
420        }
421
422        let provider =
423            AlloyProviderBuilder::<_, _, N>::default().connect_provider(RootProvider::new(client));
424
425        Ok(provider)
426    }
427}
428
429impl<N: Network> ProviderBuilder<N> {
430    /// Constructs a `RetryProvider` backed by multiple URLs using round-robin load balancing.
431    ///
432    /// Each request is sent to exactly one transport, rotating through the list via
433    /// [`RoundRobinService`]. There is no health scoring or endpoint deprioritization.
434    /// On failure, the `RetryBackoffLayer` retries the request, which naturally hits
435    /// the next transport in the rotation.
436    pub fn build_fallback(self, urls: Vec<String>) -> Result<RetryProvider<N>> {
437        let Self {
438            chain,
439            max_retry,
440            initial_backoff,
441            timeout,
442            compute_units_per_second,
443            jwt,
444            headers,
445            accept_invalid_certs,
446            no_proxy,
447            curl_mode,
448            ..
449        } = self;
450
451        eyre::ensure!(!urls.is_empty(), "at least one fork URL is required");
452        eyre::ensure!(!curl_mode, "curl mode is not supported with multiple fork URLs");
453
454        // Build a RuntimeTransport for each URL, using the same URL normalization
455        // as ProviderBuilder::new() (handles localhost:port, raw socket addrs, IPC paths)
456        let mut parsed_urls = Vec::with_capacity(urls.len());
457        let transports: Vec<_> = urls
458            .iter()
459            .map(|url_str| {
460                let builder = Self::new(url_str);
461                let url = builder.url?;
462                parsed_urls.push(url.clone());
463                Ok(RuntimeTransportBuilder::new(url)
464                    .with_timeout(timeout)
465                    .with_headers(headers.clone())
466                    .with_jwt(jwt.clone())
467                    .accept_invalid_certs(accept_invalid_certs)
468                    .no_proxy(no_proxy)
469                    .build())
470            })
471            .collect::<Result<Vec<_>>>()?;
472
473        let round_robin = RoundRobinService::new(transports);
474
475        let retry_layer =
476            RetryBackoffLayer::new(max_retry, initial_backoff, compute_units_per_second);
477        // Use normalized/parsed URLs for local detection, consistent with build()
478        let is_local = parsed_urls.iter().all(|url| guess_local_url(url.as_str()));
479        let client = ClientBuilder::default().layer(retry_layer).transport(round_robin, is_local);
480
481        if !is_local {
482            client.set_poll_interval(
483                chain
484                    .average_blocktime_hint()
485                    .map(|hint| hint.min(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME))
486                    .unwrap_or(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME)
487                    .mul_f32(POLL_INTERVAL_BLOCK_TIME_SCALE_FACTOR),
488            );
489        }
490
491        let provider =
492            AlloyProviderBuilder::<_, _, N>::default().connect_provider(RootProvider::new(client));
493
494        Ok(provider)
495    }
496
497    /// Constructs the `RetryProvider` with a wallet.
498    pub fn build_with_wallet<W: NetworkWallet<N> + Clone>(
499        self,
500        wallet: W,
501    ) -> Result<RetryProviderWithSigner<N, W>>
502    where
503        N: RecommendedFillers,
504    {
505        let Self {
506            url,
507            chain,
508            max_retry,
509            initial_backoff,
510            timeout,
511            compute_units_per_second,
512            jwt,
513            headers,
514            is_local,
515            accept_invalid_certs,
516            no_proxy,
517            curl_mode,
518            ..
519        } = self;
520        let url = url?;
521
522        let retry_layer =
523            RetryBackoffLayer::new(max_retry, initial_backoff, compute_units_per_second);
524
525        // If curl_mode is enabled, use CurlTransport instead of RuntimeTransport
526        if curl_mode {
527            let transport = CurlTransport::new(url).with_headers(headers).with_jwt(jwt);
528            let client = ClientBuilder::default().layer(retry_layer).transport(transport, is_local);
529
530            let provider = AlloyProviderBuilder::<_, _, N>::default()
531                .with_recommended_fillers()
532                .wallet(wallet)
533                .connect_provider(RootProvider::new(client));
534
535            return Ok(provider);
536        }
537
538        let transport = RuntimeTransportBuilder::new(url)
539            .with_timeout(timeout)
540            .with_headers(headers)
541            .with_jwt(jwt)
542            .accept_invalid_certs(accept_invalid_certs)
543            .no_proxy(no_proxy)
544            .build();
545
546        let client = ClientBuilder::default().layer(retry_layer).transport(transport, is_local);
547
548        if !is_local {
549            client.set_poll_interval(
550                chain
551                    .average_blocktime_hint()
552                    // we cap the poll interval because if not provided, chain would default to
553                    // mainnet
554                    .map(|hint| hint.min(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME))
555                    .unwrap_or(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME)
556                    .mul_f32(POLL_INTERVAL_BLOCK_TIME_SCALE_FACTOR),
557            );
558        }
559
560        let provider = AlloyProviderBuilder::<_, _, N>::default()
561            .with_recommended_fillers()
562            .wallet(wallet)
563            .connect_provider(RootProvider::new(client));
564
565        Ok(provider)
566    }
567}
568
569#[cfg(not(windows))]
570fn resolve_path(path: &Path) -> Result<PathBuf, ()> {
571    if path.is_absolute() {
572        Ok(path.to_path_buf())
573    } else {
574        std::env::current_dir().map(|d| d.join(path)).map_err(drop)
575    }
576}
577
578#[cfg(windows)]
579fn resolve_path(path: &Path) -> Result<PathBuf, ()> {
580    if let Some(s) = path.to_str()
581        && s.starts_with(r"\\.\pipe\")
582    {
583        return Ok(path.to_path_buf());
584    }
585    if path.is_absolute() {
586        Ok(path.to_path_buf())
587    } else {
588        std::env::current_dir().map(|d| d.join(path)).map_err(drop)
589    }
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595
596    #[test]
597    fn can_auto_correct_missing_prefix() {
598        let builder = ProviderBuilder::<AnyNetwork>::new("localhost:8545");
599        assert!(builder.url.is_ok());
600
601        let url = builder.url.unwrap();
602        assert_eq!(url, Url::parse("http://localhost:8545").unwrap());
603    }
604}