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 fn is_rpc_method_not_found(error: &TransportError) -> bool {
58 if error.as_error_resp().is_some_and(|response| response.code == -32601) {
59 return true;
60 }
61 let TransportError::Transport(error) = error else { return false };
62 error
63 .as_http_error()
64 .and_then(|error| rpc_error_code(&error.body))
65 .is_some_and(|code| code == -32601)
66}
67
68pub fn redact_url(raw: &str) -> String {
70 let Ok(mut redacted) = Url::parse(raw) else {
71 return "<redacted>".to_owned();
72 };
73 let _ = redacted.set_username("");
74 let _ = redacted.set_password(None);
75 redacted.set_path("");
76 redacted.set_query(None);
77 redacted.set_fragment(None);
78 redacted.to_string()
79}
80
81fn rpc_error_code(body: &str) -> Option<i64> {
82 let value =
85 serde_json::Deserializer::from_str(body).into_iter::<serde_json::Value>().next()?.ok()?;
86 value.get("error").unwrap_or(&value).get("code")?.as_i64()
87}
88
89pub type RetryProviderWithSigner<N = AnyNetwork, W = EthereumWallet> = FillProvider<
91 JoinFill<JoinFill<Identity, <N as RecommendedFillers>::RecommendedFillers>, WalletFiller<W>>,
92 RootProvider<N>,
93 N,
94>;
95
96#[inline]
113#[track_caller]
114pub fn get_http_provider(builder: impl AsRef<str>) -> RetryProvider {
115 try_get_http_provider(builder).unwrap()
116}
117
118#[inline]
121pub fn try_get_http_provider(builder: impl AsRef<str>) -> Result<RetryProvider> {
122 ProviderBuilder::new(builder.as_ref()).build()
123}
124
125#[derive(Clone)]
130pub struct RoundRobinService<S> {
131 transports: Arc<Vec<S>>,
132 next: Arc<AtomicUsize>,
133}
134
135impl<S> RoundRobinService<S> {
136 pub fn new(transports: Vec<S>) -> Self {
142 assert!(!transports.is_empty(), "RoundRobinService requires at least one transport");
143 Self { transports: Arc::new(transports), next: Arc::new(AtomicUsize::new(0)) }
144 }
145}
146
147impl<S> Service<RequestPacket> for RoundRobinService<S>
148where
149 S: Service<
150 RequestPacket,
151 Response = ResponsePacket,
152 Error = TransportError,
153 Future = TransportFut<'static>,
154 > + Clone
155 + Send
156 + Sync
157 + 'static,
158{
159 type Response = ResponsePacket;
160 type Error = TransportError;
161 type Future = TransportFut<'static>;
162
163 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
164 Poll::Ready(Ok(()))
165 }
166
167 fn call(&mut self, req: RequestPacket) -> Self::Future {
168 let transports = self.transports.clone();
169 let idx = self.next.fetch_add(1, Ordering::Relaxed) % transports.len();
170 let mut transport = transports[idx].clone();
171 transport.call(req)
172 }
173}
174
175#[derive(Debug)]
179pub struct ProviderBuilder<N: Network = AnyNetwork> {
180 url: Result<Url>,
182 chain: NamedChain,
183 max_retry: u32,
184 initial_backoff: u64,
185 timeout: Duration,
186 compute_units_per_second: u64,
188 jwt: Option<String>,
190 headers: Vec<String>,
191 is_local: bool,
192 accept_invalid_certs: bool,
194 no_proxy: bool,
196 curl_mode: bool,
198 _network: PhantomData<N>,
200}
201
202impl<N: Network> ProviderBuilder<N> {
203 pub fn new(url_str: &str) -> Self {
205 let mut url_str = url_str;
207
208 let storage;
211 if url_str.starts_with("localhost:") {
212 storage = format!("http://{url_str}");
213 url_str = storage.as_str();
214 }
215
216 let url = Url::parse(url_str)
217 .or_else(|err| match err {
218 ParseError::RelativeUrlWithoutBase => {
219 if SocketAddr::from_str(url_str).is_ok() {
220 Url::parse(&format!("http://{url_str}"))
221 } else {
222 let path = Path::new(url_str);
223
224 if let Ok(path) = resolve_path(path) {
225 Url::parse(&format!("file://{}", path.display()))
226 } else {
227 Err(err)
228 }
229 }
230 }
231 _ => Err(err),
232 })
233 .wrap_err_with(|| format!("invalid provider URL: {:?}", redact_url(url_str)));
234
235 let is_local = url.as_ref().is_ok_and(|url| guess_local_url(url.as_str()));
237
238 Self {
239 url,
240 chain: NamedChain::Mainnet,
241 max_retry: 8,
242 initial_backoff: 800,
243 timeout: REQUEST_TIMEOUT,
244 compute_units_per_second: ALCHEMY_FREE_TIER_CUPS,
246 jwt: None,
247 headers: vec![],
248 is_local,
249 accept_invalid_certs: false,
250 no_proxy: false,
251 curl_mode: false,
252 _network: PhantomData,
253 }
254 }
255
256 pub fn from_config(config: &Config) -> Result<Self> {
260 let url = config.get_rpc_url_or_localhost_http()?;
261 let mut builder = Self::new(url.as_ref())
262 .accept_invalid_certs(config.eth_rpc_accept_invalid_certs)
263 .no_proxy(config.eth_rpc_no_proxy)
264 .curl_mode(config.eth_rpc_curl);
265
266 if let Ok(chain) = config.chain.unwrap_or_default().try_into() {
267 builder = builder.chain(chain);
268 }
269
270 if let Some(jwt) = config.get_rpc_jwt_secret()? {
271 builder = builder.jwt(jwt.as_ref());
272 }
273
274 if let Some(rpc_timeout) = config.eth_rpc_timeout {
275 builder = builder.timeout(Duration::from_secs(rpc_timeout));
276 }
277
278 if let Some(rpc_headers) = config.eth_rpc_headers.clone() {
279 builder = builder.headers(rpc_headers);
280 }
281
282 Ok(builder)
283 }
284
285 pub const fn timeout(mut self, timeout: Duration) -> Self {
292 self.timeout = timeout;
293 self
294 }
295
296 pub const fn chain(mut self, chain: NamedChain) -> Self {
298 self.chain = chain;
299 self
300 }
301
302 pub const fn max_retry(mut self, max_retry: u32) -> Self {
304 self.max_retry = max_retry;
305 self
306 }
307
308 pub fn maybe_max_retry(mut self, max_retry: Option<u32>) -> Self {
310 self.max_retry = max_retry.unwrap_or(self.max_retry);
311 self
312 }
313
314 pub fn maybe_initial_backoff(mut self, initial_backoff: Option<u64>) -> Self {
317 self.initial_backoff = initial_backoff.unwrap_or(self.initial_backoff);
318 self
319 }
320
321 pub const fn initial_backoff(mut self, initial_backoff: u64) -> Self {
323 self.initial_backoff = initial_backoff;
324 self
325 }
326
327 pub const fn compute_units_per_second(mut self, compute_units_per_second: u64) -> Self {
331 self.compute_units_per_second = compute_units_per_second;
332 self
333 }
334
335 pub const fn compute_units_per_second_opt(
339 mut self,
340 compute_units_per_second: Option<u64>,
341 ) -> Self {
342 if let Some(cups) = compute_units_per_second {
343 self.compute_units_per_second = cups;
344 }
345 self
346 }
347
348 pub const fn local(mut self, is_local: bool) -> Self {
352 self.is_local = is_local;
353 self
354 }
355
356 pub const fn aggressive(self) -> Self {
360 self.max_retry(100).initial_backoff(100).local(true)
361 }
362
363 pub fn jwt(mut self, jwt: impl Into<String>) -> Self {
365 self.jwt = Some(jwt.into());
366 self
367 }
368
369 pub fn headers(mut self, headers: Vec<String>) -> Self {
371 self.headers = headers;
372
373 self
374 }
375
376 pub fn maybe_headers(mut self, headers: Option<Vec<String>>) -> Self {
378 self.headers = headers.unwrap_or(self.headers);
379 self
380 }
381
382 pub const fn accept_invalid_certs(mut self, accept_invalid_certs: bool) -> Self {
384 self.accept_invalid_certs = accept_invalid_certs;
385 self
386 }
387
388 pub const fn no_proxy(mut self, no_proxy: bool) -> Self {
393 self.no_proxy = no_proxy;
394 self
395 }
396
397 pub const fn curl_mode(mut self, curl_mode: bool) -> Self {
402 self.curl_mode = curl_mode;
403 self
404 }
405
406 pub fn build(self) -> Result<RetryProvider<N>> {
408 let Self {
409 url,
410 chain,
411 max_retry,
412 initial_backoff,
413 timeout,
414 compute_units_per_second,
415 jwt,
416 headers,
417 is_local,
418 accept_invalid_certs,
419 no_proxy,
420 curl_mode,
421 ..
422 } = self;
423 let url = url?;
424 let no_proxy = no_proxy || is_local;
425
426 let retry_layer =
427 RetryBackoffLayer::new(max_retry, initial_backoff, compute_units_per_second);
428
429 if curl_mode {
431 let transport = CurlTransport::new(url).with_headers(headers).with_jwt(jwt);
432 let client = ClientBuilder::default().layer(retry_layer).transport(transport, is_local);
433
434 let provider = AlloyProviderBuilder::<_, _, N>::default()
435 .connect_provider(RootProvider::new(client));
436
437 return Ok(provider);
438 }
439
440 let transport = RuntimeTransportBuilder::new(url)
441 .with_timeout(timeout)
442 .with_headers(headers)
443 .with_jwt(jwt)
444 .accept_invalid_certs(accept_invalid_certs)
445 .no_proxy(no_proxy)
446 .build();
447 let client = ClientBuilder::default().layer(retry_layer).transport(transport, is_local);
448
449 if !is_local {
450 client.set_poll_interval(
451 chain
452 .average_blocktime_hint()
453 .map(|hint| hint.min(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME))
456 .unwrap_or(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME)
457 .mul_f32(POLL_INTERVAL_BLOCK_TIME_SCALE_FACTOR),
458 );
459 }
460
461 let provider =
462 AlloyProviderBuilder::<_, _, N>::default().connect_provider(RootProvider::new(client));
463
464 Ok(provider)
465 }
466}
467
468impl<N: Network> ProviderBuilder<N> {
469 pub fn build_fallback(self, urls: Vec<String>) -> Result<RetryProvider<N>> {
476 let Self {
477 chain,
478 max_retry,
479 initial_backoff,
480 timeout,
481 compute_units_per_second,
482 jwt,
483 headers,
484 accept_invalid_certs,
485 no_proxy,
486 curl_mode,
487 ..
488 } = self;
489
490 eyre::ensure!(!urls.is_empty(), "at least one fork URL is required");
491 eyre::ensure!(!curl_mode, "curl mode is not supported with multiple fork URLs");
492
493 let mut parsed_urls = Vec::with_capacity(urls.len());
496 let transports: Vec<_> = urls
497 .iter()
498 .map(|url_str| {
499 let builder = Self::new(url_str);
500 let url = builder.url?;
501 let transport_no_proxy = no_proxy || builder.is_local;
502 parsed_urls.push(url.clone());
503 Ok(RuntimeTransportBuilder::new(url)
504 .with_timeout(timeout)
505 .with_headers(headers.clone())
506 .with_jwt(jwt.clone())
507 .accept_invalid_certs(accept_invalid_certs)
508 .no_proxy(transport_no_proxy)
509 .build())
510 })
511 .collect::<Result<Vec<_>>>()?;
512
513 let round_robin = RoundRobinService::new(transports);
514
515 let retry_layer =
516 RetryBackoffLayer::new(max_retry, initial_backoff, compute_units_per_second);
517 let is_local = parsed_urls.iter().all(|url| guess_local_url(url.as_str()));
519 let client = ClientBuilder::default().layer(retry_layer).transport(round_robin, is_local);
520
521 if !is_local {
522 client.set_poll_interval(
523 chain
524 .average_blocktime_hint()
525 .map(|hint| hint.min(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME))
526 .unwrap_or(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME)
527 .mul_f32(POLL_INTERVAL_BLOCK_TIME_SCALE_FACTOR),
528 );
529 }
530
531 let provider =
532 AlloyProviderBuilder::<_, _, N>::default().connect_provider(RootProvider::new(client));
533
534 Ok(provider)
535 }
536
537 pub fn build_with_wallet<W: NetworkWallet<N> + Clone>(
539 self,
540 wallet: W,
541 ) -> Result<RetryProviderWithSigner<N, W>>
542 where
543 N: RecommendedFillers,
544 {
545 let Self {
546 url,
547 chain,
548 max_retry,
549 initial_backoff,
550 timeout,
551 compute_units_per_second,
552 jwt,
553 headers,
554 is_local,
555 accept_invalid_certs,
556 no_proxy,
557 curl_mode,
558 ..
559 } = self;
560 let url = url?;
561 let no_proxy = no_proxy || is_local;
562
563 let retry_layer =
564 RetryBackoffLayer::new(max_retry, initial_backoff, compute_units_per_second);
565
566 if curl_mode {
568 let transport = CurlTransport::new(url).with_headers(headers).with_jwt(jwt);
569 let client = ClientBuilder::default().layer(retry_layer).transport(transport, is_local);
570
571 let provider = AlloyProviderBuilder::<_, _, N>::default()
572 .with_recommended_fillers()
573 .wallet(wallet)
574 .connect_provider(RootProvider::new(client));
575
576 return Ok(provider);
577 }
578
579 let transport = RuntimeTransportBuilder::new(url)
580 .with_timeout(timeout)
581 .with_headers(headers)
582 .with_jwt(jwt)
583 .accept_invalid_certs(accept_invalid_certs)
584 .no_proxy(no_proxy)
585 .build();
586
587 let client = ClientBuilder::default().layer(retry_layer).transport(transport, is_local);
588
589 if !is_local {
590 client.set_poll_interval(
591 chain
592 .average_blocktime_hint()
593 .map(|hint| hint.min(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME))
596 .unwrap_or(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME)
597 .mul_f32(POLL_INTERVAL_BLOCK_TIME_SCALE_FACTOR),
598 );
599 }
600
601 let provider = AlloyProviderBuilder::<_, _, N>::default()
602 .with_recommended_fillers()
603 .wallet(wallet)
604 .connect_provider(RootProvider::new(client));
605
606 Ok(provider)
607 }
608}
609
610#[cfg(not(windows))]
611fn resolve_path(path: &Path) -> Result<PathBuf, ()> {
612 if path.is_absolute() {
613 Ok(path.to_path_buf())
614 } else {
615 std::env::current_dir().map(|d| d.join(path)).map_err(drop)
616 }
617}
618
619#[cfg(windows)]
620fn resolve_path(path: &Path) -> Result<PathBuf, ()> {
621 if let Some(s) = path.to_str()
622 && s.starts_with(r"\\.\pipe\")
623 {
624 return Ok(path.to_path_buf());
625 }
626 if path.is_absolute() {
627 Ok(path.to_path_buf())
628 } else {
629 std::env::current_dir().map(|d| d.join(path)).map_err(drop)
630 }
631}
632
633#[cfg(test)]
634mod tests {
635 use alloy_json_rpc::ErrorPayload;
636
637 use super::*;
638
639 #[test]
640 fn redacts_url_credentials_and_resource() {
641 let url = "https://user:password@example.com:8545/private-key?token=secret#fragment";
642
643 assert_eq!(redact_url(url), "https://example.com:8545/");
644 assert_eq!(redact_url("not a URL with secret"), "<redacted>");
645 }
646
647 #[test]
648 fn invalid_provider_url_error_is_redacted() {
649 let builder = ProviderBuilder::<AnyNetwork>::new(
650 "https://example.com:bad/private-api-key?token=secret",
651 );
652
653 let error = builder.url.unwrap_err().to_string();
654 assert!(error.contains("<redacted>"));
655 assert!(!error.contains("private-api-key"));
656 assert!(!error.contains("secret"));
657 }
658
659 #[test]
660 fn method_not_found_classification_is_exact() {
661 let method_not_found = TransportError::ErrorResp(ErrorPayload::method_not_found());
662 let internal_error = TransportError::ErrorResp(ErrorPayload::internal_error());
663 let http_method_not_found = alloy_transport::TransportErrorKind::http_error(
664 403,
665 r#"{"jsonrpc":"2.0","error":{"code":-32601,"message":"method not allowed"}}"#
666 .to_string(),
667 );
668 let http_internal_error = alloy_transport::TransportErrorKind::http_error(
669 500,
670 r#"{"jsonrpc":"2.0","error":{"code":-32603,"message":"internal error"}}"#.to_string(),
671 );
672 let http_method_not_found_with_diagnostics =
673 alloy_transport::TransportErrorKind::http_error(
674 403,
675 concat!(
676 r#"{"jsonrpc":"2.0","error":{"code":-32601,"message":"method not allowed"}}"#,
677 "\n\nHTTP diagnostics:\nstatus: 403 Forbidden"
678 )
679 .to_string(),
680 );
681 let transport_error = alloy_transport::TransportErrorKind::backend_gone();
682
683 assert!(is_rpc_method_not_found(&method_not_found));
684 assert!(is_rpc_method_not_found(&http_method_not_found));
685 assert!(is_rpc_method_not_found(&http_method_not_found_with_diagnostics));
686 assert!(!is_rpc_method_not_found(&internal_error));
687 assert!(!is_rpc_method_not_found(&http_internal_error));
688 assert!(!is_rpc_method_not_found(&transport_error));
689 }
690
691 #[test]
692 fn can_auto_correct_missing_prefix() {
693 let builder = ProviderBuilder::<AnyNetwork>::new("localhost:8545");
694 assert!(builder.url.is_ok());
695
696 let url = builder.url.unwrap();
697 assert_eq!(url, Url::parse("http://localhost:8545").unwrap());
698 }
699
700 #[test]
701 fn from_config_applies_rpc_transport_options() {
702 let config = Config {
703 eth_rpc_url: Some("http://example.com".to_string()),
704 eth_rpc_accept_invalid_certs: true,
705 eth_rpc_no_proxy: true,
706 eth_rpc_timeout: Some(7),
707 ..Default::default()
708 };
709
710 let builder = ProviderBuilder::<AnyNetwork>::from_config(&config).unwrap();
711
712 assert!(builder.accept_invalid_certs);
713 assert!(builder.no_proxy);
714 assert_eq!(builder.timeout, Duration::from_secs(7));
715 }
716}