1pub 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
42const DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME: Duration = Duration::from_secs(3);
45
46const POLL_INTERVAL_BLOCK_TIME_SCALE_FACTOR: f32 = 0.6;
48
49pub type RetryProvider<N = AnyNetwork> = RootProvider<N>;
51
52pub 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#[derive(Clone)]
64pub struct RoundRobinService<S> {
65 transports: Arc<Vec<S>>,
66 next: Arc<AtomicUsize>,
67}
68
69impl<S> RoundRobinService<S> {
70 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#[derive(Debug)]
113pub struct ProviderBuilder<N: Network = AnyNetwork> {
114 url: Result<Url>,
116 chain: NamedChain,
117 max_retry: u32,
118 initial_backoff: u64,
119 timeout: Duration,
120 compute_units_per_second: u64,
122 jwt: Option<String>,
124 headers: Vec<String>,
125 is_local: bool,
126 accept_invalid_certs: bool,
128 no_proxy: bool,
130 curl_mode: bool,
132 no_mpp: bool,
134 _network: PhantomData<N>,
136}
137
138impl<N: Network> ProviderBuilder<N> {
139 pub fn new(url_str: &str) -> Self {
141 let mut url_str = url_str;
143
144 let storage;
147 if url_str.starts_with("localhost:") {
148 storage = format!("http://{url_str}");
149 url_str = storage.as_str();
150 }
151
152 let url = Url::parse(url_str)
153 .or_else(|err| match err {
154 ParseError::RelativeUrlWithoutBase => {
155 if SocketAddr::from_str(url_str).is_ok() {
156 Url::parse(&format!("http://{url_str}"))
157 } else {
158 let path = Path::new(url_str);
159
160 if let Ok(path) = resolve_path(path) {
161 Url::parse(&format!("file://{}", path.display()))
162 } else {
163 Err(err)
164 }
165 }
166 }
167 _ => Err(err),
168 })
169 .wrap_err_with(|| format!("invalid provider URL: {:?}", redact_url(url_str)));
170 let url =
172 if url_str.trim().is_empty() { Err(eyre::eyre!("the RPC URL is empty")) } else { url };
173
174 let is_local = url.as_ref().is_ok_and(|url| guess_local_url(url.as_str()));
176
177 Self {
178 url,
179 chain: NamedChain::Mainnet,
180 max_retry: 8,
181 initial_backoff: 800,
182 timeout: REQUEST_TIMEOUT,
183 compute_units_per_second: ALCHEMY_FREE_TIER_CUPS,
185 jwt: None,
186 headers: vec![],
187 is_local,
188 accept_invalid_certs: false,
189 no_proxy: false,
190 curl_mode: false,
191 no_mpp: false,
192 _network: PhantomData,
193 }
194 }
195
196 pub fn from_config(config: &Config) -> Result<Self> {
200 let url = config.get_rpc_url_or_localhost_http()?;
201 let mut builder = Self::from_config_with_url(config, url.as_ref())?;
202
203 if let Ok(chain) = config.chain.unwrap_or_default().try_into() {
204 builder = builder.chain(chain);
205 }
206
207 Ok(builder)
208 }
209
210 pub fn from_config_with_url(config: &Config, url: &str) -> Result<Self> {
212 let mut builder = Self::new(url)
213 .accept_invalid_certs(config.eth_rpc_accept_invalid_certs)
214 .no_proxy(config.eth_rpc_no_proxy)
215 .curl_mode(config.eth_rpc_curl);
216
217 if let Some(jwt) = config.get_rpc_jwt_secret()? {
218 builder = builder.jwt(jwt.as_ref());
219 }
220
221 if let Some(rpc_timeout) = config.eth_rpc_timeout {
222 builder = builder.timeout(Duration::from_secs(rpc_timeout));
223 }
224
225 if let Some(rpc_headers) = config.eth_rpc_headers.clone() {
226 builder = builder.headers(rpc_headers);
227 }
228
229 Ok(builder)
230 }
231
232 pub const fn timeout(mut self, timeout: Duration) -> Self {
239 self.timeout = timeout;
240 self
241 }
242
243 pub const fn chain(mut self, chain: NamedChain) -> Self {
245 self.chain = chain;
246 self
247 }
248
249 pub const fn max_retry(mut self, max_retry: u32) -> Self {
251 self.max_retry = max_retry;
252 self
253 }
254
255 pub fn maybe_max_retry(mut self, max_retry: Option<u32>) -> Self {
257 self.max_retry = max_retry.unwrap_or(self.max_retry);
258 self
259 }
260
261 pub fn maybe_initial_backoff(mut self, initial_backoff: Option<u64>) -> Self {
264 self.initial_backoff = initial_backoff.unwrap_or(self.initial_backoff);
265 self
266 }
267
268 pub const fn initial_backoff(mut self, initial_backoff: u64) -> Self {
270 self.initial_backoff = initial_backoff;
271 self
272 }
273
274 pub const fn compute_units_per_second(mut self, compute_units_per_second: u64) -> Self {
278 self.compute_units_per_second = compute_units_per_second;
279 self
280 }
281
282 pub const fn compute_units_per_second_opt(
286 mut self,
287 compute_units_per_second: Option<u64>,
288 ) -> Self {
289 if let Some(cups) = compute_units_per_second {
290 self.compute_units_per_second = cups;
291 }
292 self
293 }
294
295 pub const fn local(mut self, is_local: bool) -> Self {
299 self.is_local = is_local;
300 self
301 }
302
303 pub const fn aggressive(self) -> Self {
307 self.max_retry(100).initial_backoff(100).local(true)
308 }
309
310 pub fn jwt(mut self, jwt: impl Into<String>) -> Self {
312 self.jwt = Some(jwt.into());
313 self
314 }
315
316 pub fn headers(mut self, headers: Vec<String>) -> Self {
318 self.headers = headers;
319
320 self
321 }
322
323 pub fn maybe_headers(mut self, headers: Option<Vec<String>>) -> Self {
325 self.headers = headers.unwrap_or(self.headers);
326 self
327 }
328
329 pub const fn accept_invalid_certs(mut self, accept_invalid_certs: bool) -> Self {
331 self.accept_invalid_certs = accept_invalid_certs;
332 self
333 }
334
335 pub const fn no_proxy(mut self, no_proxy: bool) -> Self {
340 self.no_proxy = no_proxy;
341 self
342 }
343
344 pub const fn curl_mode(mut self, curl_mode: bool) -> Self {
349 self.curl_mode = curl_mode;
350 self
351 }
352
353 pub const fn no_mpp(mut self, no_mpp: bool) -> Self {
355 self.no_mpp = no_mpp;
356 self
357 }
358
359 pub fn url(&self) -> Option<&Url> {
361 self.url.as_ref().ok()
362 }
363
364 pub fn build(self) -> Result<RetryProvider<N>> {
366 Ok(RootProvider::new(self.build_client()?))
367 }
368
369 pub fn build_client(self) -> Result<RpcClient> {
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 no_mpp,
385 ..
386 } = self;
387 let url = url?;
388 let no_proxy = no_proxy || is_local;
389
390 let retry_layer =
391 RetryBackoffLayer::new(max_retry, initial_backoff, compute_units_per_second);
392
393 if curl_mode {
395 let transport = CurlTransport::new(url).with_headers(headers).with_jwt(jwt);
396 let client = ClientBuilder::default().layer(retry_layer).transport(transport, is_local);
397
398 return Ok(client);
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 .no_mpp(no_mpp)
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 .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 Ok(client)
424 }
425}
426
427impl<N: Network> ProviderBuilder<N> {
428 pub fn build_fallback(self, urls: Vec<String>) -> Result<RetryProvider<N>> {
435 let Self {
436 chain,
437 max_retry,
438 initial_backoff,
439 timeout,
440 compute_units_per_second,
441 jwt,
442 headers,
443 accept_invalid_certs,
444 no_proxy,
445 curl_mode,
446 no_mpp,
447 ..
448 } = self;
449
450 eyre::ensure!(!urls.is_empty(), "at least one fork URL is required");
451 eyre::ensure!(!curl_mode, "curl mode is not supported with multiple fork URLs");
452
453 let mut parsed_urls = Vec::with_capacity(urls.len());
456 let transports: Vec<_> = urls
457 .iter()
458 .map(|url_str| {
459 let builder = Self::new(url_str);
460 let url = builder.url?;
461 let transport_no_proxy = no_proxy || builder.is_local;
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(transport_no_proxy)
469 .no_mpp(no_mpp)
470 .build())
471 })
472 .collect::<Result<Vec<_>>>()?;
473
474 let round_robin = RoundRobinService::new(transports);
475
476 let retry_layer =
477 RetryBackoffLayer::new(max_retry, initial_backoff, compute_units_per_second);
478 let is_local = parsed_urls.iter().all(|url| guess_local_url(url.as_str()));
480 let client = ClientBuilder::default().layer(retry_layer).transport(round_robin, is_local);
481
482 if !is_local {
483 client.set_poll_interval(
484 chain
485 .average_blocktime_hint()
486 .map(|hint| hint.min(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME))
487 .unwrap_or(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME)
488 .mul_f32(POLL_INTERVAL_BLOCK_TIME_SCALE_FACTOR),
489 );
490 }
491
492 let provider =
493 AlloyProviderBuilder::<_, _, N>::default().connect_provider(RootProvider::new(client));
494
495 Ok(provider)
496 }
497
498 pub fn build_with_wallet<W: NetworkWallet<N> + Clone>(
500 self,
501 wallet: W,
502 ) -> Result<RetryProviderWithSigner<N, W>>
503 where
504 N: RecommendedFillers,
505 {
506 let provider = self.build()?;
507 Ok(AlloyProviderBuilder::<_, _, N>::default()
508 .with_recommended_fillers()
509 .wallet(wallet)
510 .connect_provider(provider))
511 }
512}
513
514pub fn is_rpc_method_not_found(error: &TransportError) -> bool {
520 rpc_error_code(error) == Some(-32601)
521}
522
523pub fn redact_url(raw: &str) -> String {
525 let Ok(mut redacted) = Url::parse(raw) else {
526 return "<redacted>".to_owned();
527 };
528 let _ = redacted.set_username("");
529 let _ = redacted.set_password(None);
530 redacted.set_path("");
531 redacted.set_query(None);
532 redacted.set_fragment(None);
533 redacted.to_string()
534}
535
536fn rpc_error_code(error: &TransportError) -> Option<i64> {
537 if let Some(response) = error.as_error_resp() {
538 return Some(response.code);
539 }
540 let TransportError::Transport(error) = error else { return None };
541 error.as_http_error().and_then(|error| rpc_error_code_from_body(&error.body))
542}
543
544fn rpc_error_code_from_body(body: &str) -> Option<i64> {
545 let value =
548 serde_json::Deserializer::from_str(body).into_iter::<serde_json::Value>().next()?.ok()?;
549 let error = value.get("error").unwrap_or(&value);
550 error.get("code")?.as_i64()
551}
552
553#[inline]
567#[track_caller]
568pub fn get_http_provider(builder: impl AsRef<str>) -> RetryProvider {
569 try_get_http_provider(builder).unwrap()
570}
571
572#[inline]
578pub fn try_get_http_provider(builder: impl AsRef<str>) -> Result<RetryProvider> {
579 ProviderBuilder::new(builder.as_ref()).build()
580}
581
582#[cfg(not(windows))]
583fn resolve_path(path: &Path) -> Result<PathBuf, ()> {
584 if path.is_absolute() {
585 Ok(path.to_path_buf())
586 } else {
587 std::env::current_dir().map(|d| d.join(path)).map_err(drop)
588 }
589}
590
591#[cfg(windows)]
592fn resolve_path(path: &Path) -> Result<PathBuf, ()> {
593 if let Some(s) = path.to_str()
594 && s.starts_with(r"\\.\pipe\")
595 {
596 return Ok(path.to_path_buf());
597 }
598 if path.is_absolute() {
599 Ok(path.to_path_buf())
600 } else {
601 std::env::current_dir().map(|d| d.join(path)).map_err(drop)
602 }
603}
604
605#[cfg(test)]
606mod tests {
607 use alloy_json_rpc::ErrorPayload;
608
609 use super::*;
610
611 #[test]
612 fn redacts_url_credentials_and_resource() {
613 let url = "https://user:password@example.com:8545/private-key?token=secret#fragment";
614
615 assert_eq!(redact_url(url), "https://example.com:8545/");
616 assert_eq!(redact_url("not a URL with secret"), "<redacted>");
617 }
618
619 #[test]
620 fn invalid_provider_url_error_is_redacted() {
621 let builder = ProviderBuilder::<AnyNetwork>::new(
622 "https://example.com:bad/private-api-key?token=secret",
623 );
624
625 let error = builder.url.unwrap_err().to_string();
626 assert!(error.contains("<redacted>"));
627 assert!(!error.contains("private-api-key"));
628 assert!(!error.contains("secret"));
629 }
630
631 #[test]
632 fn empty_provider_url_is_rejected() {
633 for url in ["", " "] {
634 let builder = ProviderBuilder::<AnyNetwork>::new(url);
635 assert_eq!(builder.url.unwrap_err().to_string(), "the RPC URL is empty");
636 }
637 }
638
639 #[test]
640 fn method_not_found_classification_is_exact() {
641 let method_not_found = TransportError::ErrorResp(ErrorPayload::method_not_found());
642 let internal_error = TransportError::ErrorResp(ErrorPayload::internal_error());
643 let http_method_not_found = alloy_transport::TransportErrorKind::http_error(
644 403,
645 r#"{"jsonrpc":"2.0","error":{"code":-32601,"message":"method not allowed"}}"#
646 .to_string(),
647 );
648 let http_internal_error = alloy_transport::TransportErrorKind::http_error(
649 500,
650 r#"{"jsonrpc":"2.0","error":{"code":-32603,"message":"internal error"}}"#.to_string(),
651 );
652 let http_method_not_found_with_diagnostics =
653 alloy_transport::TransportErrorKind::http_error(
654 403,
655 concat!(
656 r#"{"jsonrpc":"2.0","error":{"code":-32601,"message":"method not allowed"}}"#,
657 "\n\nHTTP diagnostics:\nstatus: 403 Forbidden"
658 )
659 .to_string(),
660 );
661 let transport_error = alloy_transport::TransportErrorKind::backend_gone();
662
663 assert!(is_rpc_method_not_found(&method_not_found));
664 assert!(is_rpc_method_not_found(&http_method_not_found));
665 assert!(is_rpc_method_not_found(&http_method_not_found_with_diagnostics));
666 assert!(!is_rpc_method_not_found(&internal_error));
667 assert!(!is_rpc_method_not_found(&http_internal_error));
668 assert!(!is_rpc_method_not_found(&transport_error));
669 }
670
671 #[test]
672 fn can_auto_correct_missing_prefix() {
673 let builder = ProviderBuilder::<AnyNetwork>::new("localhost:8545");
674 assert!(builder.url.is_ok());
675
676 let url = builder.url.unwrap();
677 assert_eq!(url, Url::parse("http://localhost:8545").unwrap());
678 }
679
680 #[test]
681 fn from_config_applies_rpc_transport_options() {
682 let config = Config {
683 eth_rpc_url: Some("http://example.com".to_string()),
684 chain: Some(NamedChain::Polygon.into()),
685 eth_rpc_accept_invalid_certs: true,
686 eth_rpc_no_proxy: true,
687 eth_rpc_timeout: Some(7),
688 ..Default::default()
689 };
690
691 let builder = ProviderBuilder::<AnyNetwork>::from_config(&config).unwrap();
692
693 assert!(builder.accept_invalid_certs);
694 assert!(builder.no_proxy);
695 assert_eq!(builder.timeout, Duration::from_secs(7));
696 assert_eq!(builder.chain, NamedChain::Polygon);
697 }
698
699 #[test]
700 fn from_config_with_url_overrides_rpc_url() {
701 let config = Config {
702 eth_rpc_url: Some("http://configured.example".to_string()),
703 chain: Some(NamedChain::Polygon.into()),
704 eth_rpc_timeout: Some(7),
705 ..Default::default()
706 };
707
708 let builder =
709 ProviderBuilder::<AnyNetwork>::from_config_with_url(&config, "http://sequence.example")
710 .unwrap();
711
712 assert_eq!(builder.url.unwrap().as_str(), "http://sequence.example/");
713 assert_eq!(builder.timeout, Duration::from_secs(7));
714 assert_eq!(builder.chain, NamedChain::Mainnet);
715 }
716}