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            .accept_invalid_certs(config.eth_rpc_accept_invalid_certs)
225            .no_proxy(config.eth_rpc_no_proxy)
226            .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        let no_proxy = no_proxy || is_local;
387
388        let retry_layer =
389            RetryBackoffLayer::new(max_retry, initial_backoff, compute_units_per_second);
390
391        // If curl_mode is enabled, use CurlTransport instead of RuntimeTransport
392        if curl_mode {
393            let transport = CurlTransport::new(url).with_headers(headers).with_jwt(jwt);
394            let client = ClientBuilder::default().layer(retry_layer).transport(transport, is_local);
395
396            let provider = AlloyProviderBuilder::<_, _, N>::default()
397                .connect_provider(RootProvider::new(client));
398
399            return Ok(provider);
400        }
401
402        let transport = RuntimeTransportBuilder::new(url)
403            .with_timeout(timeout)
404            .with_headers(headers)
405            .with_jwt(jwt)
406            .accept_invalid_certs(accept_invalid_certs)
407            .no_proxy(no_proxy)
408            .build();
409        let client = ClientBuilder::default().layer(retry_layer).transport(transport, is_local);
410
411        if !is_local {
412            client.set_poll_interval(
413                chain
414                    .average_blocktime_hint()
415                    // we cap the poll interval because if not provided, chain would default to
416                    // mainnet
417                    .map(|hint| hint.min(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME))
418                    .unwrap_or(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME)
419                    .mul_f32(POLL_INTERVAL_BLOCK_TIME_SCALE_FACTOR),
420            );
421        }
422
423        let provider =
424            AlloyProviderBuilder::<_, _, N>::default().connect_provider(RootProvider::new(client));
425
426        Ok(provider)
427    }
428}
429
430impl<N: Network> ProviderBuilder<N> {
431    /// Constructs a `RetryProvider` backed by multiple URLs using round-robin load balancing.
432    ///
433    /// Each request is sent to exactly one transport, rotating through the list via
434    /// [`RoundRobinService`]. There is no health scoring or endpoint deprioritization.
435    /// On failure, the `RetryBackoffLayer` retries the request, which naturally hits
436    /// the next transport in the rotation.
437    pub fn build_fallback(self, urls: Vec<String>) -> Result<RetryProvider<N>> {
438        let Self {
439            chain,
440            max_retry,
441            initial_backoff,
442            timeout,
443            compute_units_per_second,
444            jwt,
445            headers,
446            accept_invalid_certs,
447            no_proxy,
448            curl_mode,
449            ..
450        } = self;
451
452        eyre::ensure!(!urls.is_empty(), "at least one fork URL is required");
453        eyre::ensure!(!curl_mode, "curl mode is not supported with multiple fork URLs");
454
455        // Build a RuntimeTransport for each URL, using the same URL normalization
456        // as ProviderBuilder::new() (handles localhost:port, raw socket addrs, IPC paths)
457        let mut parsed_urls = Vec::with_capacity(urls.len());
458        let transports: Vec<_> = urls
459            .iter()
460            .map(|url_str| {
461                let builder = Self::new(url_str);
462                let url = builder.url?;
463                let transport_no_proxy = no_proxy || builder.is_local;
464                parsed_urls.push(url.clone());
465                Ok(RuntimeTransportBuilder::new(url)
466                    .with_timeout(timeout)
467                    .with_headers(headers.clone())
468                    .with_jwt(jwt.clone())
469                    .accept_invalid_certs(accept_invalid_certs)
470                    .no_proxy(transport_no_proxy)
471                    .build())
472            })
473            .collect::<Result<Vec<_>>>()?;
474
475        let round_robin = RoundRobinService::new(transports);
476
477        let retry_layer =
478            RetryBackoffLayer::new(max_retry, initial_backoff, compute_units_per_second);
479        // Use normalized/parsed URLs for local detection, consistent with build()
480        let is_local = parsed_urls.iter().all(|url| guess_local_url(url.as_str()));
481        let client = ClientBuilder::default().layer(retry_layer).transport(round_robin, is_local);
482
483        if !is_local {
484            client.set_poll_interval(
485                chain
486                    .average_blocktime_hint()
487                    .map(|hint| hint.min(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME))
488                    .unwrap_or(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME)
489                    .mul_f32(POLL_INTERVAL_BLOCK_TIME_SCALE_FACTOR),
490            );
491        }
492
493        let provider =
494            AlloyProviderBuilder::<_, _, N>::default().connect_provider(RootProvider::new(client));
495
496        Ok(provider)
497    }
498
499    /// Constructs the `RetryProvider` with a wallet.
500    pub fn build_with_wallet<W: NetworkWallet<N> + Clone>(
501        self,
502        wallet: W,
503    ) -> Result<RetryProviderWithSigner<N, W>>
504    where
505        N: RecommendedFillers,
506    {
507        let Self {
508            url,
509            chain,
510            max_retry,
511            initial_backoff,
512            timeout,
513            compute_units_per_second,
514            jwt,
515            headers,
516            is_local,
517            accept_invalid_certs,
518            no_proxy,
519            curl_mode,
520            ..
521        } = self;
522        let url = url?;
523        let no_proxy = no_proxy || is_local;
524
525        let retry_layer =
526            RetryBackoffLayer::new(max_retry, initial_backoff, compute_units_per_second);
527
528        // If curl_mode is enabled, use CurlTransport instead of RuntimeTransport
529        if curl_mode {
530            let transport = CurlTransport::new(url).with_headers(headers).with_jwt(jwt);
531            let client = ClientBuilder::default().layer(retry_layer).transport(transport, is_local);
532
533            let provider = AlloyProviderBuilder::<_, _, N>::default()
534                .with_recommended_fillers()
535                .wallet(wallet)
536                .connect_provider(RootProvider::new(client));
537
538            return Ok(provider);
539        }
540
541        let transport = RuntimeTransportBuilder::new(url)
542            .with_timeout(timeout)
543            .with_headers(headers)
544            .with_jwt(jwt)
545            .accept_invalid_certs(accept_invalid_certs)
546            .no_proxy(no_proxy)
547            .build();
548
549        let client = ClientBuilder::default().layer(retry_layer).transport(transport, is_local);
550
551        if !is_local {
552            client.set_poll_interval(
553                chain
554                    .average_blocktime_hint()
555                    // we cap the poll interval because if not provided, chain would default to
556                    // mainnet
557                    .map(|hint| hint.min(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME))
558                    .unwrap_or(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME)
559                    .mul_f32(POLL_INTERVAL_BLOCK_TIME_SCALE_FACTOR),
560            );
561        }
562
563        let provider = AlloyProviderBuilder::<_, _, N>::default()
564            .with_recommended_fillers()
565            .wallet(wallet)
566            .connect_provider(RootProvider::new(client));
567
568        Ok(provider)
569    }
570}
571
572#[cfg(not(windows))]
573fn resolve_path(path: &Path) -> Result<PathBuf, ()> {
574    if path.is_absolute() {
575        Ok(path.to_path_buf())
576    } else {
577        std::env::current_dir().map(|d| d.join(path)).map_err(drop)
578    }
579}
580
581#[cfg(windows)]
582fn resolve_path(path: &Path) -> Result<PathBuf, ()> {
583    if let Some(s) = path.to_str()
584        && s.starts_with(r"\\.\pipe\")
585    {
586        return Ok(path.to_path_buf());
587    }
588    if path.is_absolute() {
589        Ok(path.to_path_buf())
590    } else {
591        std::env::current_dir().map(|d| d.join(path)).map_err(drop)
592    }
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598
599    #[test]
600    fn can_auto_correct_missing_prefix() {
601        let builder = ProviderBuilder::<AnyNetwork>::new("localhost:8545");
602        assert!(builder.url.is_ok());
603
604        let url = builder.url.unwrap();
605        assert_eq!(url, Url::parse("http://localhost:8545").unwrap());
606    }
607
608    #[test]
609    fn from_config_applies_rpc_transport_options() {
610        let config = Config {
611            eth_rpc_url: Some("http://example.com".to_string()),
612            eth_rpc_accept_invalid_certs: true,
613            eth_rpc_no_proxy: true,
614            eth_rpc_timeout: Some(7),
615            ..Default::default()
616        };
617
618        let builder = ProviderBuilder::<AnyNetwork>::from_config(&config).unwrap();
619
620        assert!(builder.accept_invalid_certs);
621        assert!(builder.no_proxy);
622        assert_eq!(builder.timeout, Duration::from_secs(7));
623    }
624}