Skip to main content

foundry_common/provider/
mod.rs

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