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;
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 _network: PhantomData<N>,
134}
135
136impl<N: Network> ProviderBuilder<N> {
137 pub fn new(url_str: &str) -> Self {
139 let mut url_str = url_str;
141
142 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 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 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 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 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 pub const fn timeout(mut self, timeout: Duration) -> Self {
233 self.timeout = timeout;
234 self
235 }
236
237 pub const fn chain(mut self, chain: NamedChain) -> Self {
239 self.chain = chain;
240 self
241 }
242
243 pub const fn max_retry(mut self, max_retry: u32) -> Self {
245 self.max_retry = max_retry;
246 self
247 }
248
249 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 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 pub const fn initial_backoff(mut self, initial_backoff: u64) -> Self {
264 self.initial_backoff = initial_backoff;
265 self
266 }
267
268 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 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 pub const fn local(mut self, is_local: bool) -> Self {
293 self.is_local = is_local;
294 self
295 }
296
297 pub const fn aggressive(self) -> Self {
301 self.max_retry(100).initial_backoff(100).local(true)
302 }
303
304 pub fn jwt(mut self, jwt: impl Into<String>) -> Self {
306 self.jwt = Some(jwt.into());
307 self
308 }
309
310 pub fn headers(mut self, headers: Vec<String>) -> Self {
312 self.headers = headers;
313
314 self
315 }
316
317 pub fn maybe_headers(mut self, headers: Option<Vec<String>>) -> Self {
319 self.headers = headers.unwrap_or(self.headers);
320 self
321 }
322
323 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 pub const fn no_proxy(mut self, no_proxy: bool) -> Self {
334 self.no_proxy = no_proxy;
335 self
336 }
337
338 pub const fn curl_mode(mut self, curl_mode: bool) -> Self {
343 self.curl_mode = curl_mode;
344 self
345 }
346
347 pub fn build(self) -> Result<RetryProvider<N>> {
349 let Self {
350 url,
351 chain,
352 max_retry,
353 initial_backoff,
354 timeout,
355 compute_units_per_second,
356 jwt,
357 headers,
358 is_local,
359 accept_invalid_certs,
360 no_proxy,
361 curl_mode,
362 ..
363 } = self;
364 let url = url?;
365 let no_proxy = no_proxy || is_local;
366
367 let retry_layer =
368 RetryBackoffLayer::new(max_retry, initial_backoff, compute_units_per_second);
369
370 if curl_mode {
372 let transport = CurlTransport::new(url).with_headers(headers).with_jwt(jwt);
373 let client = ClientBuilder::default().layer(retry_layer).transport(transport, is_local);
374
375 let provider = AlloyProviderBuilder::<_, _, N>::default()
376 .connect_provider(RootProvider::new(client));
377
378 return Ok(provider);
379 }
380
381 let transport = RuntimeTransportBuilder::new(url)
382 .with_timeout(timeout)
383 .with_headers(headers)
384 .with_jwt(jwt)
385 .accept_invalid_certs(accept_invalid_certs)
386 .no_proxy(no_proxy)
387 .build();
388 let client = ClientBuilder::default().layer(retry_layer).transport(transport, is_local);
389
390 if !is_local {
391 client.set_poll_interval(
392 chain
393 .average_blocktime_hint()
394 .map(|hint| hint.min(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME))
397 .unwrap_or(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME)
398 .mul_f32(POLL_INTERVAL_BLOCK_TIME_SCALE_FACTOR),
399 );
400 }
401
402 let provider =
403 AlloyProviderBuilder::<_, _, N>::default().connect_provider(RootProvider::new(client));
404
405 Ok(provider)
406 }
407}
408
409impl<N: Network> ProviderBuilder<N> {
410 pub fn build_fallback(self, urls: Vec<String>) -> Result<RetryProvider<N>> {
417 let Self {
418 chain,
419 max_retry,
420 initial_backoff,
421 timeout,
422 compute_units_per_second,
423 jwt,
424 headers,
425 accept_invalid_certs,
426 no_proxy,
427 curl_mode,
428 ..
429 } = self;
430
431 eyre::ensure!(!urls.is_empty(), "at least one fork URL is required");
432 eyre::ensure!(!curl_mode, "curl mode is not supported with multiple fork URLs");
433
434 let mut parsed_urls = Vec::with_capacity(urls.len());
437 let transports: Vec<_> = urls
438 .iter()
439 .map(|url_str| {
440 let builder = Self::new(url_str);
441 let url = builder.url?;
442 let transport_no_proxy = no_proxy || builder.is_local;
443 parsed_urls.push(url.clone());
444 Ok(RuntimeTransportBuilder::new(url)
445 .with_timeout(timeout)
446 .with_headers(headers.clone())
447 .with_jwt(jwt.clone())
448 .accept_invalid_certs(accept_invalid_certs)
449 .no_proxy(transport_no_proxy)
450 .build())
451 })
452 .collect::<Result<Vec<_>>>()?;
453
454 let round_robin = RoundRobinService::new(transports);
455
456 let retry_layer =
457 RetryBackoffLayer::new(max_retry, initial_backoff, compute_units_per_second);
458 let is_local = parsed_urls.iter().all(|url| guess_local_url(url.as_str()));
460 let client = ClientBuilder::default().layer(retry_layer).transport(round_robin, is_local);
461
462 if !is_local {
463 client.set_poll_interval(
464 chain
465 .average_blocktime_hint()
466 .map(|hint| hint.min(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME))
467 .unwrap_or(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME)
468 .mul_f32(POLL_INTERVAL_BLOCK_TIME_SCALE_FACTOR),
469 );
470 }
471
472 let provider =
473 AlloyProviderBuilder::<_, _, N>::default().connect_provider(RootProvider::new(client));
474
475 Ok(provider)
476 }
477
478 pub fn build_with_wallet<W: NetworkWallet<N> + Clone>(
480 self,
481 wallet: W,
482 ) -> Result<RetryProviderWithSigner<N, W>>
483 where
484 N: RecommendedFillers,
485 {
486 let Self {
487 url,
488 chain,
489 max_retry,
490 initial_backoff,
491 timeout,
492 compute_units_per_second,
493 jwt,
494 headers,
495 is_local,
496 accept_invalid_certs,
497 no_proxy,
498 curl_mode,
499 ..
500 } = self;
501 let url = url?;
502 let no_proxy = no_proxy || is_local;
503
504 let retry_layer =
505 RetryBackoffLayer::new(max_retry, initial_backoff, compute_units_per_second);
506
507 if curl_mode {
509 let transport = CurlTransport::new(url).with_headers(headers).with_jwt(jwt);
510 let client = ClientBuilder::default().layer(retry_layer).transport(transport, is_local);
511
512 let provider = AlloyProviderBuilder::<_, _, N>::default()
513 .with_recommended_fillers()
514 .wallet(wallet)
515 .connect_provider(RootProvider::new(client));
516
517 return Ok(provider);
518 }
519
520 let transport = RuntimeTransportBuilder::new(url)
521 .with_timeout(timeout)
522 .with_headers(headers)
523 .with_jwt(jwt)
524 .accept_invalid_certs(accept_invalid_certs)
525 .no_proxy(no_proxy)
526 .build();
527
528 let client = ClientBuilder::default().layer(retry_layer).transport(transport, is_local);
529
530 if !is_local {
531 client.set_poll_interval(
532 chain
533 .average_blocktime_hint()
534 .map(|hint| hint.min(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME))
537 .unwrap_or(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME)
538 .mul_f32(POLL_INTERVAL_BLOCK_TIME_SCALE_FACTOR),
539 );
540 }
541
542 let provider = AlloyProviderBuilder::<_, _, N>::default()
543 .with_recommended_fillers()
544 .wallet(wallet)
545 .connect_provider(RootProvider::new(client));
546
547 Ok(provider)
548 }
549}
550
551pub fn is_rpc_method_not_found(error: &TransportError) -> bool {
557 rpc_error_code(error) == Some(-32601)
558}
559
560pub fn redact_url(raw: &str) -> String {
562 let Ok(mut redacted) = Url::parse(raw) else {
563 return "<redacted>".to_owned();
564 };
565 let _ = redacted.set_username("");
566 let _ = redacted.set_password(None);
567 redacted.set_path("");
568 redacted.set_query(None);
569 redacted.set_fragment(None);
570 redacted.to_string()
571}
572
573fn rpc_error_code(error: &TransportError) -> Option<i64> {
574 if let Some(response) = error.as_error_resp() {
575 return Some(response.code);
576 }
577 let TransportError::Transport(error) = error else { return None };
578 error.as_http_error().and_then(|error| rpc_error_code_from_body(&error.body))
579}
580
581fn rpc_error_code_from_body(body: &str) -> Option<i64> {
582 let value =
585 serde_json::Deserializer::from_str(body).into_iter::<serde_json::Value>().next()?.ok()?;
586 let error = value.get("error").unwrap_or(&value);
587 error.get("code")?.as_i64()
588}
589
590#[inline]
607#[track_caller]
608pub fn get_http_provider(builder: impl AsRef<str>) -> RetryProvider {
609 try_get_http_provider(builder).unwrap()
610}
611
612#[inline]
615pub fn try_get_http_provider(builder: impl AsRef<str>) -> Result<RetryProvider> {
616 ProviderBuilder::new(builder.as_ref()).build()
617}
618
619#[cfg(not(windows))]
620fn resolve_path(path: &Path) -> Result<PathBuf, ()> {
621 if path.is_absolute() {
622 Ok(path.to_path_buf())
623 } else {
624 std::env::current_dir().map(|d| d.join(path)).map_err(drop)
625 }
626}
627
628#[cfg(windows)]
629fn resolve_path(path: &Path) -> Result<PathBuf, ()> {
630 if let Some(s) = path.to_str()
631 && s.starts_with(r"\\.\pipe\")
632 {
633 return Ok(path.to_path_buf());
634 }
635 if path.is_absolute() {
636 Ok(path.to_path_buf())
637 } else {
638 std::env::current_dir().map(|d| d.join(path)).map_err(drop)
639 }
640}
641
642#[cfg(test)]
643mod tests {
644 use alloy_json_rpc::ErrorPayload;
645
646 use super::*;
647
648 #[test]
649 fn redacts_url_credentials_and_resource() {
650 let url = "https://user:password@example.com:8545/private-key?token=secret#fragment";
651
652 assert_eq!(redact_url(url), "https://example.com:8545/");
653 assert_eq!(redact_url("not a URL with secret"), "<redacted>");
654 }
655
656 #[test]
657 fn invalid_provider_url_error_is_redacted() {
658 let builder = ProviderBuilder::<AnyNetwork>::new(
659 "https://example.com:bad/private-api-key?token=secret",
660 );
661
662 let error = builder.url.unwrap_err().to_string();
663 assert!(error.contains("<redacted>"));
664 assert!(!error.contains("private-api-key"));
665 assert!(!error.contains("secret"));
666 }
667
668 #[test]
669 fn method_not_found_classification_is_exact() {
670 let method_not_found = TransportError::ErrorResp(ErrorPayload::method_not_found());
671 let internal_error = TransportError::ErrorResp(ErrorPayload::internal_error());
672 let http_method_not_found = alloy_transport::TransportErrorKind::http_error(
673 403,
674 r#"{"jsonrpc":"2.0","error":{"code":-32601,"message":"method not allowed"}}"#
675 .to_string(),
676 );
677 let http_internal_error = alloy_transport::TransportErrorKind::http_error(
678 500,
679 r#"{"jsonrpc":"2.0","error":{"code":-32603,"message":"internal error"}}"#.to_string(),
680 );
681 let http_method_not_found_with_diagnostics =
682 alloy_transport::TransportErrorKind::http_error(
683 403,
684 concat!(
685 r#"{"jsonrpc":"2.0","error":{"code":-32601,"message":"method not allowed"}}"#,
686 "\n\nHTTP diagnostics:\nstatus: 403 Forbidden"
687 )
688 .to_string(),
689 );
690 let transport_error = alloy_transport::TransportErrorKind::backend_gone();
691
692 assert!(is_rpc_method_not_found(&method_not_found));
693 assert!(is_rpc_method_not_found(&http_method_not_found));
694 assert!(is_rpc_method_not_found(&http_method_not_found_with_diagnostics));
695 assert!(!is_rpc_method_not_found(&internal_error));
696 assert!(!is_rpc_method_not_found(&http_internal_error));
697 assert!(!is_rpc_method_not_found(&transport_error));
698 }
699
700 #[test]
701 fn can_auto_correct_missing_prefix() {
702 let builder = ProviderBuilder::<AnyNetwork>::new("localhost:8545");
703 assert!(builder.url.is_ok());
704
705 let url = builder.url.unwrap();
706 assert_eq!(url, Url::parse("http://localhost:8545").unwrap());
707 }
708
709 #[test]
710 fn from_config_applies_rpc_transport_options() {
711 let config = Config {
712 eth_rpc_url: Some("http://example.com".to_string()),
713 chain: Some(NamedChain::Polygon.into()),
714 eth_rpc_accept_invalid_certs: true,
715 eth_rpc_no_proxy: true,
716 eth_rpc_timeout: Some(7),
717 ..Default::default()
718 };
719
720 let builder = ProviderBuilder::<AnyNetwork>::from_config(&config).unwrap();
721
722 assert!(builder.accept_invalid_certs);
723 assert!(builder.no_proxy);
724 assert_eq!(builder.timeout, Duration::from_secs(7));
725 assert_eq!(builder.chain, NamedChain::Polygon);
726 }
727
728 #[test]
729 fn from_config_with_url_overrides_rpc_url() {
730 let config = Config {
731 eth_rpc_url: Some("http://configured.example".to_string()),
732 chain: Some(NamedChain::Polygon.into()),
733 eth_rpc_timeout: Some(7),
734 ..Default::default()
735 };
736
737 let builder =
738 ProviderBuilder::<AnyNetwork>::from_config_with_url(&config, "http://sequence.example")
739 .unwrap();
740
741 assert_eq!(builder.url.unwrap().as_str(), "http://sequence.example/");
742 assert_eq!(builder.timeout, Duration::from_secs(7));
743 assert_eq!(builder.chain, NamedChain::Mainnet);
744 }
745}