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, RpcClient};
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/// A round-robin transport that distributes requests across multiple transports.
60///
61/// Each request is sent to exactly one transport, rotating through the list.
62/// Failover on error is handled by the retry layer above this service.
63#[derive(Clone)]
64pub struct RoundRobinService<S> {
65    transports: Arc<Vec<S>>,
66    next: Arc<AtomicUsize>,
67}
68
69impl<S> RoundRobinService<S> {
70    /// Creates a new round-robin service from a non-empty list of transports.
71    ///
72    /// # Panics
73    ///
74    /// Panics if `transports` is empty.
75    pub fn new(transports: Vec<S>) -> Self {
76        assert!(!transports.is_empty(), "RoundRobinService requires at least one transport");
77        Self { transports: Arc::new(transports), next: Arc::new(AtomicUsize::new(0)) }
78    }
79}
80
81impl<S> Service<RequestPacket> for RoundRobinService<S>
82where
83    S: Service<
84            RequestPacket,
85            Response = ResponsePacket,
86            Error = TransportError,
87            Future = TransportFut<'static>,
88        > + Clone
89        + Send
90        + Sync
91        + 'static,
92{
93    type Response = ResponsePacket;
94    type Error = TransportError;
95    type Future = TransportFut<'static>;
96
97    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
98        Poll::Ready(Ok(()))
99    }
100
101    fn call(&mut self, req: RequestPacket) -> Self::Future {
102        let transports = self.transports.clone();
103        let idx = self.next.fetch_add(1, Ordering::Relaxed) % transports.len();
104        let mut transport = transports[idx].clone();
105        transport.call(req)
106    }
107}
108
109/// Helper type to construct a `RetryProvider`
110///
111/// This builder is generic over the network type `N`, defaulting to `AnyNetwork`.
112#[derive(Debug)]
113pub struct ProviderBuilder<N: Network = AnyNetwork> {
114    // Note: this is a result, so we can easily chain builder calls
115    url: Result<Url>,
116    chain: NamedChain,
117    max_retry: u32,
118    initial_backoff: u64,
119    timeout: Duration,
120    /// available CUPS
121    compute_units_per_second: u64,
122    /// JWT Secret
123    jwt: Option<String>,
124    headers: Vec<String>,
125    is_local: bool,
126    /// Whether to accept invalid certificates.
127    accept_invalid_certs: bool,
128    /// Whether to disable automatic proxy detection.
129    no_proxy: bool,
130    /// Whether to output curl commands instead of making requests.
131    curl_mode: bool,
132    /// Phantom data for the network type.
133    _network: PhantomData<N>,
134}
135
136impl<N: Network> ProviderBuilder<N> {
137    /// Creates a new ProviderBuilder helper instance.
138    pub fn new(url_str: &str) -> Self {
139        // a copy is needed for the next lines to work
140        let mut url_str = url_str;
141
142        // invalid url: non-prefixed URL scheme is not allowed, so we prepend the default http
143        // prefix
144        let storage;
145        if url_str.starts_with("localhost:") {
146            storage = format!("http://{url_str}");
147            url_str = storage.as_str();
148        }
149
150        let url = Url::parse(url_str)
151            .or_else(|err| match err {
152                ParseError::RelativeUrlWithoutBase => {
153                    if SocketAddr::from_str(url_str).is_ok() {
154                        Url::parse(&format!("http://{url_str}"))
155                    } else {
156                        let path = Path::new(url_str);
157
158                        if let Ok(path) = resolve_path(path) {
159                            Url::parse(&format!("file://{}", path.display()))
160                        } else {
161                            Err(err)
162                        }
163                    }
164                }
165                _ => Err(err),
166            })
167            .wrap_err_with(|| format!("invalid provider URL: {:?}", redact_url(url_str)));
168
169        // Use the final URL string to guess if it's a local URL.
170        let is_local = url.as_ref().is_ok_and(|url| guess_local_url(url.as_str()));
171
172        Self {
173            url,
174            chain: NamedChain::Mainnet,
175            max_retry: 8,
176            initial_backoff: 800,
177            timeout: REQUEST_TIMEOUT,
178            // alchemy max cpus <https://docs.alchemy.com/reference/compute-units#what-are-cups-compute-units-per-second>
179            compute_units_per_second: ALCHEMY_FREE_TIER_CUPS,
180            jwt: None,
181            headers: vec![],
182            is_local,
183            accept_invalid_certs: false,
184            no_proxy: false,
185            curl_mode: false,
186            _network: PhantomData,
187        }
188    }
189
190    /// Constructs a [ProviderBuilder] instantiated using [Config] values.
191    ///
192    /// Defaults to `http://localhost:8545` and `Mainnet`.
193    pub fn from_config(config: &Config) -> Result<Self> {
194        let url = config.get_rpc_url_or_localhost_http()?;
195        let mut builder = Self::from_config_with_url(config, url.as_ref())?;
196
197        if let Ok(chain) = config.chain.unwrap_or_default().try_into() {
198            builder = builder.chain(chain);
199        }
200
201        Ok(builder)
202    }
203
204    /// Constructs a [ProviderBuilder] for `url`, applying transport options from [Config].
205    pub fn from_config_with_url(config: &Config, url: &str) -> Result<Self> {
206        let mut builder = Self::new(url)
207            .accept_invalid_certs(config.eth_rpc_accept_invalid_certs)
208            .no_proxy(config.eth_rpc_no_proxy)
209            .curl_mode(config.eth_rpc_curl);
210
211        if let Some(jwt) = config.get_rpc_jwt_secret()? {
212            builder = builder.jwt(jwt.as_ref());
213        }
214
215        if let Some(rpc_timeout) = config.eth_rpc_timeout {
216            builder = builder.timeout(Duration::from_secs(rpc_timeout));
217        }
218
219        if let Some(rpc_headers) = config.eth_rpc_headers.clone() {
220            builder = builder.headers(rpc_headers);
221        }
222
223        Ok(builder)
224    }
225
226    /// Enables a request timeout.
227    ///
228    /// The timeout is applied from when the request starts connecting until the
229    /// response body has finished.
230    ///
231    /// Default is no timeout.
232    pub const fn timeout(mut self, timeout: Duration) -> Self {
233        self.timeout = timeout;
234        self
235    }
236
237    /// Sets the chain of the node the provider will connect to
238    pub const fn chain(mut self, chain: NamedChain) -> Self {
239        self.chain = chain;
240        self
241    }
242
243    /// How often to retry a failed request
244    pub const fn max_retry(mut self, max_retry: u32) -> Self {
245        self.max_retry = max_retry;
246        self
247    }
248
249    /// How often to retry a failed request. If `None`, defaults to the already-set value.
250    pub fn maybe_max_retry(mut self, max_retry: Option<u32>) -> Self {
251        self.max_retry = max_retry.unwrap_or(self.max_retry);
252        self
253    }
254
255    /// The starting backoff delay to use after the first failed request. If `None`, defaults to
256    /// the already-set value.
257    pub fn maybe_initial_backoff(mut self, initial_backoff: Option<u64>) -> Self {
258        self.initial_backoff = initial_backoff.unwrap_or(self.initial_backoff);
259        self
260    }
261
262    /// The starting backoff delay to use after the first failed request
263    pub const fn initial_backoff(mut self, initial_backoff: u64) -> Self {
264        self.initial_backoff = initial_backoff;
265        self
266    }
267
268    /// Sets the number of assumed available compute units per second
269    ///
270    /// See also, <https://docs.alchemy.com/reference/compute-units#what-are-cups-compute-units-per-second>
271    pub const fn compute_units_per_second(mut self, compute_units_per_second: u64) -> Self {
272        self.compute_units_per_second = compute_units_per_second;
273        self
274    }
275
276    /// Sets the number of assumed available compute units per second
277    ///
278    /// See also, <https://docs.alchemy.com/reference/compute-units#what-are-cups-compute-units-per-second>
279    pub const fn compute_units_per_second_opt(
280        mut self,
281        compute_units_per_second: Option<u64>,
282    ) -> Self {
283        if let Some(cups) = compute_units_per_second {
284            self.compute_units_per_second = cups;
285        }
286        self
287    }
288
289    /// Sets the provider to be local.
290    ///
291    /// This is useful for local dev nodes.
292    pub const fn local(mut self, is_local: bool) -> Self {
293        self.is_local = is_local;
294        self
295    }
296
297    /// Sets aggressive `max_retry` and `initial_backoff` values
298    ///
299    /// This is only recommend for local dev nodes
300    pub const fn aggressive(self) -> Self {
301        self.max_retry(100).initial_backoff(100).local(true)
302    }
303
304    /// Sets the JWT secret
305    pub fn jwt(mut self, jwt: impl Into<String>) -> Self {
306        self.jwt = Some(jwt.into());
307        self
308    }
309
310    /// Sets http headers
311    pub fn headers(mut self, headers: Vec<String>) -> Self {
312        self.headers = headers;
313
314        self
315    }
316
317    /// Sets http headers. If `None`, defaults to the already-set value.
318    pub fn maybe_headers(mut self, headers: Option<Vec<String>>) -> Self {
319        self.headers = headers.unwrap_or(self.headers);
320        self
321    }
322
323    /// Sets whether to accept invalid certificates.
324    pub const fn accept_invalid_certs(mut self, accept_invalid_certs: bool) -> Self {
325        self.accept_invalid_certs = accept_invalid_certs;
326        self
327    }
328
329    /// Sets whether to disable automatic proxy detection.
330    ///
331    /// This can help in sandboxed environments (e.g., Cursor IDE sandbox, macOS App Sandbox)
332    /// where system proxy detection via SCDynamicStore causes crashes.
333    pub const fn no_proxy(mut self, no_proxy: bool) -> Self {
334        self.no_proxy = no_proxy;
335        self
336    }
337
338    /// Sets whether to output curl commands instead of making requests.
339    ///
340    /// When enabled, the provider will print equivalent curl commands to stdout
341    /// instead of actually executing the RPC requests.
342    pub const fn curl_mode(mut self, curl_mode: bool) -> Self {
343        self.curl_mode = curl_mode;
344        self
345    }
346
347    /// Constructs the `RetryProvider` taking all configs into account.
348    pub fn build(self) -> Result<RetryProvider<N>> {
349        Ok(RootProvider::new(self.build_client()?))
350    }
351
352    /// Constructs a shared RPC client with the configured transport, authentication, and retries.
353    pub fn build_client(self) -> Result<RpcClient> {
354        let Self {
355            url,
356            chain,
357            max_retry,
358            initial_backoff,
359            timeout,
360            compute_units_per_second,
361            jwt,
362            headers,
363            is_local,
364            accept_invalid_certs,
365            no_proxy,
366            curl_mode,
367            ..
368        } = self;
369        let url = url?;
370        let no_proxy = no_proxy || is_local;
371
372        let retry_layer =
373            RetryBackoffLayer::new(max_retry, initial_backoff, compute_units_per_second);
374
375        // If curl_mode is enabled, use CurlTransport instead of RuntimeTransport
376        if curl_mode {
377            let transport = CurlTransport::new(url).with_headers(headers).with_jwt(jwt);
378            let client = ClientBuilder::default().layer(retry_layer).transport(transport, is_local);
379
380            return Ok(client);
381        }
382
383        let transport = RuntimeTransportBuilder::new(url)
384            .with_timeout(timeout)
385            .with_headers(headers)
386            .with_jwt(jwt)
387            .accept_invalid_certs(accept_invalid_certs)
388            .no_proxy(no_proxy)
389            .build();
390        let client = ClientBuilder::default().layer(retry_layer).transport(transport, is_local);
391
392        if !is_local {
393            client.set_poll_interval(
394                chain
395                    .average_blocktime_hint()
396                    // we cap the poll interval because if not provided, chain would default to
397                    // mainnet
398                    .map(|hint| hint.min(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME))
399                    .unwrap_or(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME)
400                    .mul_f32(POLL_INTERVAL_BLOCK_TIME_SCALE_FACTOR),
401            );
402        }
403
404        Ok(client)
405    }
406}
407
408impl<N: Network> ProviderBuilder<N> {
409    /// Constructs a `RetryProvider` backed by multiple URLs using round-robin load balancing.
410    ///
411    /// Each request is sent to exactly one transport, rotating through the list via
412    /// [`RoundRobinService`]. There is no health scoring or endpoint deprioritization.
413    /// On failure, the `RetryBackoffLayer` retries the request, which naturally hits
414    /// the next transport in the rotation.
415    pub fn build_fallback(self, urls: Vec<String>) -> Result<RetryProvider<N>> {
416        let Self {
417            chain,
418            max_retry,
419            initial_backoff,
420            timeout,
421            compute_units_per_second,
422            jwt,
423            headers,
424            accept_invalid_certs,
425            no_proxy,
426            curl_mode,
427            ..
428        } = self;
429
430        eyre::ensure!(!urls.is_empty(), "at least one fork URL is required");
431        eyre::ensure!(!curl_mode, "curl mode is not supported with multiple fork URLs");
432
433        // Build a RuntimeTransport for each URL, using the same URL normalization
434        // as ProviderBuilder::new() (handles localhost:port, raw socket addrs, IPC paths)
435        let mut parsed_urls = Vec::with_capacity(urls.len());
436        let transports: Vec<_> = urls
437            .iter()
438            .map(|url_str| {
439                let builder = Self::new(url_str);
440                let url = builder.url?;
441                let transport_no_proxy = no_proxy || builder.is_local;
442                parsed_urls.push(url.clone());
443                Ok(RuntimeTransportBuilder::new(url)
444                    .with_timeout(timeout)
445                    .with_headers(headers.clone())
446                    .with_jwt(jwt.clone())
447                    .accept_invalid_certs(accept_invalid_certs)
448                    .no_proxy(transport_no_proxy)
449                    .build())
450            })
451            .collect::<Result<Vec<_>>>()?;
452
453        let round_robin = RoundRobinService::new(transports);
454
455        let retry_layer =
456            RetryBackoffLayer::new(max_retry, initial_backoff, compute_units_per_second);
457        // Use normalized/parsed URLs for local detection, consistent with build()
458        let is_local = parsed_urls.iter().all(|url| guess_local_url(url.as_str()));
459        let client = ClientBuilder::default().layer(retry_layer).transport(round_robin, is_local);
460
461        if !is_local {
462            client.set_poll_interval(
463                chain
464                    .average_blocktime_hint()
465                    .map(|hint| hint.min(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME))
466                    .unwrap_or(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME)
467                    .mul_f32(POLL_INTERVAL_BLOCK_TIME_SCALE_FACTOR),
468            );
469        }
470
471        let provider =
472            AlloyProviderBuilder::<_, _, N>::default().connect_provider(RootProvider::new(client));
473
474        Ok(provider)
475    }
476
477    /// Constructs the `RetryProvider` with a wallet.
478    pub fn build_with_wallet<W: NetworkWallet<N> + Clone>(
479        self,
480        wallet: W,
481    ) -> Result<RetryProviderWithSigner<N, W>>
482    where
483        N: RecommendedFillers,
484    {
485        let provider = self.build()?;
486        Ok(AlloyProviderBuilder::<_, _, N>::default()
487            .with_recommended_fillers()
488            .wallet(wallet)
489            .connect_provider(provider))
490    }
491}
492
493/// Returns whether an RPC transport error reports JSON-RPC method-not-found.
494///
495/// Some providers encode JSON-RPC errors inside an HTTP error response instead of returning a
496/// normal JSON-RPC response. Only the exact `-32601` code is treated as method unavailability;
497/// authentication, internal, and transport errors must remain visible to callers.
498pub fn is_rpc_method_not_found(error: &TransportError) -> bool {
499    rpc_error_code(error) == Some(-32601)
500}
501
502/// Returns an RPC URL safe for display by retaining only its scheme, host, and port.
503pub fn redact_url(raw: &str) -> String {
504    let Ok(mut redacted) = Url::parse(raw) else {
505        return "<redacted>".to_owned();
506    };
507    let _ = redacted.set_username("");
508    let _ = redacted.set_password(None);
509    redacted.set_path("");
510    redacted.set_query(None);
511    redacted.set_fragment(None);
512    redacted.to_string()
513}
514
515fn rpc_error_code(error: &TransportError) -> Option<i64> {
516    if let Some(response) = error.as_error_resp() {
517        return Some(response.code);
518    }
519    let TransportError::Transport(error) = error else { return None };
520    error.as_http_error().and_then(|error| rpc_error_code_from_body(&error.body))
521}
522
523fn rpc_error_code_from_body(body: &str) -> Option<i64> {
524    // HTTP transports may append human-readable diagnostics after the JSON-RPC body. Parse the
525    // first complete JSON value instead of requiring the entire body to be JSON.
526    let value =
527        serde_json::Deserializer::from_str(body).into_iter::<serde_json::Value>().next()?.ok()?;
528    let error = value.get("error").unwrap_or(&value);
529    error.get("code")?.as_i64()
530}
531
532/// Constructs a provider with a 100 millisecond interval poll if it's a localhost URL (most likely
533/// an anvil or other dev node) and with the default, or 7 second otherwise.
534///
535/// See [`try_get_http_provider`] for more details.
536///
537/// # Panics
538///
539/// Panics if the URL is invalid.
540///
541/// # Examples
542///
543/// ```
544/// use foundry_common::provider::get_http_provider;
545///
546/// let retry_provider = get_http_provider("http://localhost:8545");
547/// ```
548#[inline]
549#[track_caller]
550pub fn get_http_provider(builder: impl AsRef<str>) -> RetryProvider {
551    try_get_http_provider(builder).unwrap()
552}
553
554/// Constructs a provider with a 100 millisecond interval poll if it's a localhost URL (most likely
555/// an anvil or other dev node) and with the default, or 7 second otherwise.
556#[inline]
557pub fn try_get_http_provider(builder: impl AsRef<str>) -> Result<RetryProvider> {
558    ProviderBuilder::new(builder.as_ref()).build()
559}
560
561#[cfg(not(windows))]
562fn resolve_path(path: &Path) -> Result<PathBuf, ()> {
563    if path.is_absolute() {
564        Ok(path.to_path_buf())
565    } else {
566        std::env::current_dir().map(|d| d.join(path)).map_err(drop)
567    }
568}
569
570#[cfg(windows)]
571fn resolve_path(path: &Path) -> Result<PathBuf, ()> {
572    if let Some(s) = path.to_str()
573        && s.starts_with(r"\\.\pipe\")
574    {
575        return Ok(path.to_path_buf());
576    }
577    if path.is_absolute() {
578        Ok(path.to_path_buf())
579    } else {
580        std::env::current_dir().map(|d| d.join(path)).map_err(drop)
581    }
582}
583
584#[cfg(test)]
585mod tests {
586    use alloy_json_rpc::ErrorPayload;
587
588    use super::*;
589
590    #[test]
591    fn redacts_url_credentials_and_resource() {
592        let url = "https://user:password@example.com:8545/private-key?token=secret#fragment";
593
594        assert_eq!(redact_url(url), "https://example.com:8545/");
595        assert_eq!(redact_url("not a URL with secret"), "<redacted>");
596    }
597
598    #[test]
599    fn invalid_provider_url_error_is_redacted() {
600        let builder = ProviderBuilder::<AnyNetwork>::new(
601            "https://example.com:bad/private-api-key?token=secret",
602        );
603
604        let error = builder.url.unwrap_err().to_string();
605        assert!(error.contains("<redacted>"));
606        assert!(!error.contains("private-api-key"));
607        assert!(!error.contains("secret"));
608    }
609
610    #[test]
611    fn method_not_found_classification_is_exact() {
612        let method_not_found = TransportError::ErrorResp(ErrorPayload::method_not_found());
613        let internal_error = TransportError::ErrorResp(ErrorPayload::internal_error());
614        let http_method_not_found = alloy_transport::TransportErrorKind::http_error(
615            403,
616            r#"{"jsonrpc":"2.0","error":{"code":-32601,"message":"method not allowed"}}"#
617                .to_string(),
618        );
619        let http_internal_error = alloy_transport::TransportErrorKind::http_error(
620            500,
621            r#"{"jsonrpc":"2.0","error":{"code":-32603,"message":"internal error"}}"#.to_string(),
622        );
623        let http_method_not_found_with_diagnostics =
624            alloy_transport::TransportErrorKind::http_error(
625                403,
626                concat!(
627                    r#"{"jsonrpc":"2.0","error":{"code":-32601,"message":"method not allowed"}}"#,
628                    "\n\nHTTP diagnostics:\nstatus: 403 Forbidden"
629                )
630                .to_string(),
631            );
632        let transport_error = alloy_transport::TransportErrorKind::backend_gone();
633
634        assert!(is_rpc_method_not_found(&method_not_found));
635        assert!(is_rpc_method_not_found(&http_method_not_found));
636        assert!(is_rpc_method_not_found(&http_method_not_found_with_diagnostics));
637        assert!(!is_rpc_method_not_found(&internal_error));
638        assert!(!is_rpc_method_not_found(&http_internal_error));
639        assert!(!is_rpc_method_not_found(&transport_error));
640    }
641
642    #[test]
643    fn can_auto_correct_missing_prefix() {
644        let builder = ProviderBuilder::<AnyNetwork>::new("localhost:8545");
645        assert!(builder.url.is_ok());
646
647        let url = builder.url.unwrap();
648        assert_eq!(url, Url::parse("http://localhost:8545").unwrap());
649    }
650
651    #[test]
652    fn from_config_applies_rpc_transport_options() {
653        let config = Config {
654            eth_rpc_url: Some("http://example.com".to_string()),
655            chain: Some(NamedChain::Polygon.into()),
656            eth_rpc_accept_invalid_certs: true,
657            eth_rpc_no_proxy: true,
658            eth_rpc_timeout: Some(7),
659            ..Default::default()
660        };
661
662        let builder = ProviderBuilder::<AnyNetwork>::from_config(&config).unwrap();
663
664        assert!(builder.accept_invalid_certs);
665        assert!(builder.no_proxy);
666        assert_eq!(builder.timeout, Duration::from_secs(7));
667        assert_eq!(builder.chain, NamedChain::Polygon);
668    }
669
670    #[test]
671    fn from_config_with_url_overrides_rpc_url() {
672        let config = Config {
673            eth_rpc_url: Some("http://configured.example".to_string()),
674            chain: Some(NamedChain::Polygon.into()),
675            eth_rpc_timeout: Some(7),
676            ..Default::default()
677        };
678
679        let builder =
680            ProviderBuilder::<AnyNetwork>::from_config_with_url(&config, "http://sequence.example")
681                .unwrap();
682
683        assert_eq!(builder.url.unwrap().as_str(), "http://sequence.example/");
684        assert_eq!(builder.timeout, Duration::from_secs(7));
685        assert_eq!(builder.chain, NamedChain::Mainnet);
686    }
687}