Skip to main content

foundry_config/
endpoints.rs

1//! Support for multiple RPC-endpoints
2
3use crate::resolve::{RE_PLACEHOLDER, UnresolvedEnvVarError, interpolate};
4use serde::{Deserialize, Deserializer, Serialize, Serializer, ser::SerializeMap};
5use std::{
6    collections::BTreeMap,
7    fmt,
8    ops::{Deref, DerefMut},
9};
10
11/// Container type for API endpoints, like various RPC endpoints
12#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(transparent)]
14pub struct RpcEndpoints {
15    endpoints: BTreeMap<String, RpcEndpoint>,
16}
17
18impl RpcEndpoints {
19    /// Creates a new list of endpoints
20    pub fn new(
21        endpoints: impl IntoIterator<Item = (impl Into<String>, impl Into<RpcEndpointType>)>,
22    ) -> Self {
23        Self {
24            endpoints: endpoints
25                .into_iter()
26                .map(|(name, e)| match e.into() {
27                    RpcEndpointType::String(url) => (name.into(), RpcEndpoint::new(url)),
28                    RpcEndpointType::Config(config) => (name.into(), config),
29                })
30                .collect(),
31        }
32    }
33
34    /// Returns `true` if this type doesn't contain any endpoints
35    pub fn is_empty(&self) -> bool {
36        self.endpoints.is_empty()
37    }
38
39    /// Returns all (alias -> rpc_endpoint) pairs
40    pub fn resolved(self) -> ResolvedRpcEndpoints {
41        ResolvedRpcEndpoints {
42            endpoints: self.endpoints.into_iter().map(|(name, e)| (name, e.resolve())).collect(),
43        }
44    }
45}
46
47impl Deref for RpcEndpoints {
48    type Target = BTreeMap<String, RpcEndpoint>;
49
50    fn deref(&self) -> &Self::Target {
51        &self.endpoints
52    }
53}
54
55/// RPC endpoint wrapper type
56#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
57#[serde(untagged)]
58pub enum RpcEndpointType {
59    /// Raw Endpoint url string
60    String(RpcEndpointUrl),
61    /// Config object
62    Config(RpcEndpoint),
63}
64
65impl RpcEndpointType {
66    /// Returns the url or config this type holds
67    ///
68    /// # Error
69    ///
70    /// Returns an error if the type holds a reference to an env var and the env var is not set
71    pub fn resolve(self) -> Result<String, UnresolvedEnvVarError> {
72        match self {
73            Self::String(url) => url.resolve(),
74            Self::Config(config) => config.endpoint.resolve(),
75        }
76    }
77}
78
79impl fmt::Display for RpcEndpointType {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        match self {
82            Self::String(url) => url.fmt(f),
83            Self::Config(config) => config.fmt(f),
84        }
85    }
86}
87
88impl TryFrom<RpcEndpointType> for String {
89    type Error = UnresolvedEnvVarError;
90
91    fn try_from(value: RpcEndpointType) -> Result<Self, Self::Error> {
92        match value {
93            RpcEndpointType::String(url) => url.resolve(),
94            RpcEndpointType::Config(config) => config.endpoint.resolve(),
95        }
96    }
97}
98
99/// Represents a single endpoint
100///
101/// This type preserves the value as it's stored in the config. If the value is a reference to an
102/// env var, then the `Endpoint::Env` var will hold the reference (`${MAIN_NET}`) and _not_ the
103/// value of the env var itself.
104/// In other words, this type does not resolve env vars when it's being deserialized
105#[derive(Clone, Debug, PartialEq, Eq)]
106pub enum RpcEndpointUrl {
107    /// A raw Url (ws, http)
108    Url(String),
109    /// An endpoint that contains at least one `${ENV_VAR}` placeholder
110    ///
111    /// **Note:** this contains the endpoint as is, like `https://eth-mainnet.alchemyapi.io/v2/${API_KEY}` or `${EPC_ENV_VAR}`
112    Env(String),
113}
114
115impl RpcEndpointUrl {
116    /// Returns the url variant
117    pub fn as_url(&self) -> Option<&str> {
118        match self {
119            Self::Url(url) => Some(url),
120            Self::Env(_) => None,
121        }
122    }
123
124    /// Returns the url this type holds
125    ///
126    /// # Error
127    ///
128    /// Returns an error if the type holds a reference to an env var and the env var is not set
129    pub fn resolve(self) -> Result<String, UnresolvedEnvVarError> {
130        match self {
131            Self::Url(url) => Ok(url),
132            Self::Env(val) => interpolate(&val),
133        }
134    }
135}
136
137impl fmt::Display for RpcEndpointUrl {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        match self {
140            Self::Url(url) => url.fmt(f),
141            Self::Env(var) => var.fmt(f),
142        }
143    }
144}
145
146impl TryFrom<RpcEndpointUrl> for String {
147    type Error = UnresolvedEnvVarError;
148
149    fn try_from(value: RpcEndpointUrl) -> Result<Self, Self::Error> {
150        value.resolve()
151    }
152}
153
154impl Serialize for RpcEndpointUrl {
155    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
156    where
157        S: Serializer,
158    {
159        serializer.serialize_str(&self.to_string())
160    }
161}
162
163impl<'de> Deserialize<'de> for RpcEndpointUrl {
164    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
165    where
166        D: Deserializer<'de>,
167    {
168        let val = String::deserialize(deserializer)?;
169        let endpoint = if RE_PLACEHOLDER.is_match(&val) { Self::Env(val) } else { Self::Url(val) };
170
171        Ok(endpoint)
172    }
173}
174
175impl From<RpcEndpointUrl> for RpcEndpointType {
176    fn from(endpoint: RpcEndpointUrl) -> Self {
177        Self::String(endpoint)
178    }
179}
180
181impl From<RpcEndpointUrl> for RpcEndpoint {
182    fn from(endpoint: RpcEndpointUrl) -> Self {
183        Self { endpoint, ..Default::default() }
184    }
185}
186
187/// The auth token to be used for RPC endpoints
188/// It works in the same way as the `RpcEndpoint` type, where it can be a raw string or a reference
189#[derive(Clone, Debug, PartialEq, Eq)]
190pub enum RpcAuth {
191    Raw(String),
192    Env(String),
193}
194
195impl RpcAuth {
196    /// Returns the auth token this type holds
197    ///
198    /// # Error
199    ///
200    /// Returns an error if the type holds a reference to an env var and the env var is not set
201    pub fn resolve(self) -> Result<String, UnresolvedEnvVarError> {
202        match self {
203            Self::Raw(raw_auth) => Ok(raw_auth),
204            Self::Env(var) => interpolate(&var),
205        }
206    }
207}
208
209impl fmt::Display for RpcAuth {
210    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
211        match self {
212            Self::Raw(url) => url.fmt(f),
213            Self::Env(var) => var.fmt(f),
214        }
215    }
216}
217
218impl Serialize for RpcAuth {
219    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
220    where
221        S: Serializer,
222    {
223        serializer.serialize_str(&self.to_string())
224    }
225}
226
227impl<'de> Deserialize<'de> for RpcAuth {
228    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
229    where
230        D: Deserializer<'de>,
231    {
232        let val = String::deserialize(deserializer)?;
233        let auth = if RE_PLACEHOLDER.is_match(&val) { Self::Env(val) } else { Self::Raw(val) };
234
235        Ok(auth)
236    }
237}
238
239// Rpc endpoint configuration
240#[derive(Debug, Clone, Default, PartialEq, Eq)]
241pub struct RpcEndpointConfig {
242    /// The number of retries.
243    pub retries: Option<u32>,
244
245    /// Initial retry backoff.
246    pub retry_backoff: Option<u64>,
247
248    /// The available compute units per second.
249    ///
250    /// See also <https://docs.alchemy.com/reference/compute-units#what-are-cups-compute-units-per-second>
251    pub compute_units_per_second: Option<u64>,
252}
253
254impl fmt::Display for RpcEndpointConfig {
255    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
256        let Self { retries, retry_backoff, compute_units_per_second } = self;
257
258        if let Some(retries) = retries {
259            write!(f, ", retries={retries}")?;
260        }
261
262        if let Some(retry_backoff) = retry_backoff {
263            write!(f, ", retry_backoff={retry_backoff}")?;
264        }
265
266        if let Some(compute_units_per_second) = compute_units_per_second {
267            write!(f, ", compute_units_per_second={compute_units_per_second}")?;
268        }
269
270        Ok(())
271    }
272}
273
274/// Rpc endpoint configuration variant
275#[derive(Debug, Clone, PartialEq, Eq)]
276pub struct RpcEndpoint {
277    /// endpoint url or env
278    pub endpoint: RpcEndpointUrl,
279
280    /// Additional fallback endpoints for load-balanced multi-endpoint forking.
281    /// When set, requests are distributed across all endpoints (primary + extra)
282    /// with automatic failover.
283    pub extra_endpoints: Vec<RpcEndpointUrl>,
284
285    /// Token to be used as authentication
286    pub auth: Option<RpcAuth>,
287
288    /// additional configuration
289    pub config: RpcEndpointConfig,
290}
291
292impl RpcEndpoint {
293    pub fn new(endpoint: RpcEndpointUrl) -> Self {
294        Self { endpoint, ..Default::default() }
295    }
296
297    /// Resolves environment variables in fields into their raw values
298    pub fn resolve(self) -> ResolvedRpcEndpoint {
299        ResolvedRpcEndpoint {
300            endpoint: self.endpoint.resolve(),
301            extra_endpoints: self.extra_endpoints.into_iter().map(|e| e.resolve()).collect(),
302            auth: self.auth.map(|auth| auth.resolve()),
303            config: self.config,
304        }
305    }
306}
307
308impl fmt::Display for RpcEndpoint {
309    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
310        let Self { endpoint, auth, config, .. } = self;
311        write!(f, "{endpoint}")?;
312        write!(f, "{config}")?;
313        if let Some(auth) = auth {
314            write!(f, ", auth={auth}")?;
315        }
316        Ok(())
317    }
318}
319
320impl Serialize for RpcEndpoint {
321    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
322    where
323        S: Serializer,
324    {
325        let has_config = self.config.retries.is_some()
326            || self.config.retry_backoff.is_some()
327            || self.config.compute_units_per_second.is_some()
328            || self.auth.is_some();
329
330        if !has_config && self.extra_endpoints.is_empty() {
331            // serialize as plain endpoint string if there's no additional config
332            self.endpoint.serialize(serializer)
333        } else {
334            let mut map = serializer.serialize_map(None)?;
335            if self.extra_endpoints.is_empty() {
336                map.serialize_entry("endpoint", &self.endpoint)?;
337            } else {
338                // Serialize all endpoints as an array under "endpoints"
339                let all: Vec<&RpcEndpointUrl> =
340                    std::iter::once(&self.endpoint).chain(&self.extra_endpoints).collect();
341                map.serialize_entry("endpoints", &all)?;
342            }
343            map.serialize_entry("retries", &self.config.retries)?;
344            map.serialize_entry("retry_backoff", &self.config.retry_backoff)?;
345            map.serialize_entry("compute_units_per_second", &self.config.compute_units_per_second)?;
346            map.serialize_entry("auth", &self.auth)?;
347            map.end()
348        }
349    }
350}
351
352impl<'de> Deserialize<'de> for RpcEndpoint {
353    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
354    where
355        D: Deserializer<'de>,
356    {
357        let value = serde_json::Value::deserialize(deserializer)?;
358        if value.is_string() {
359            return Ok(Self {
360                endpoint: serde_json::from_value(value).map_err(serde::de::Error::custom)?,
361                ..Default::default()
362            });
363        }
364
365        // Support both single "endpoint" and array "endpoints" for backwards compatibility
366        #[derive(Deserialize)]
367        struct RpcEndpointConfigInner {
368            #[serde(alias = "url")]
369            endpoint: Option<RpcEndpointUrl>,
370            /// Array of endpoint URLs for multi-endpoint load balancing
371            endpoints: Option<Vec<RpcEndpointUrl>>,
372            retries: Option<u32>,
373            retry_backoff: Option<u64>,
374            compute_units_per_second: Option<u64>,
375            auth: Option<RpcAuth>,
376        }
377
378        let RpcEndpointConfigInner {
379            endpoint,
380            endpoints,
381            retries,
382            retry_backoff,
383            compute_units_per_second,
384            auth,
385        } = serde_json::from_value(value).map_err(serde::de::Error::custom)?;
386
387        let (primary, extra) = match (endpoint, endpoints) {
388            // Single endpoint: endpoint = "..."
389            (Some(ep), None) => (ep, vec![]),
390            // Array of endpoints: endpoints = ["...", "..."]
391            (None, Some(mut eps)) => {
392                if eps.is_empty() {
393                    return Err(serde::de::Error::custom(
394                        "endpoints array must contain at least one URL",
395                    ));
396                }
397                let primary = eps.remove(0);
398                (primary, eps)
399            }
400            // Both provided — error
401            (Some(_), Some(_)) => {
402                return Err(serde::de::Error::custom(
403                    "cannot specify both `endpoint` and `endpoints`",
404                ));
405            }
406            // Neither provided — error
407            (None, None) => {
408                return Err(serde::de::Error::custom(
409                    "must specify either `endpoint` or `endpoints`",
410                ));
411            }
412        };
413
414        Ok(Self {
415            endpoint: primary,
416            extra_endpoints: extra,
417            auth,
418            config: RpcEndpointConfig { retries, retry_backoff, compute_units_per_second },
419        })
420    }
421}
422
423impl From<RpcEndpoint> for RpcEndpointType {
424    fn from(config: RpcEndpoint) -> Self {
425        Self::Config(config)
426    }
427}
428
429impl Default for RpcEndpoint {
430    fn default() -> Self {
431        Self {
432            endpoint: RpcEndpointUrl::Url("http://localhost:8545".to_string()),
433            extra_endpoints: vec![],
434            config: RpcEndpointConfig::default(),
435            auth: None,
436        }
437    }
438}
439
440/// Rpc endpoint with environment variables resolved to values, see [`RpcEndpoint::resolve`].
441#[derive(Clone, Debug, PartialEq, Eq)]
442pub struct ResolvedRpcEndpoint {
443    pub endpoint: Result<String, UnresolvedEnvVarError>,
444    /// Additional resolved endpoints for multi-endpoint load balancing.
445    pub extra_endpoints: Vec<Result<String, UnresolvedEnvVarError>>,
446    pub auth: Option<Result<String, UnresolvedEnvVarError>>,
447    pub config: RpcEndpointConfig,
448}
449
450impl ResolvedRpcEndpoint {
451    /// Returns the primary url this type holds, see [`RpcEndpoint::resolve`]
452    pub fn url(&self) -> Result<String, UnresolvedEnvVarError> {
453        self.endpoint.clone()
454    }
455
456    /// Returns all resolved URLs (primary + extra) for multi-endpoint configurations.
457    /// Returns an empty vec if no extra endpoints are configured.
458    pub fn all_urls(&self) -> Result<Vec<String>, UnresolvedEnvVarError> {
459        let primary = self.endpoint.clone()?;
460        if self.extra_endpoints.is_empty() {
461            return Ok(vec![primary]);
462        }
463        let mut urls = vec![primary];
464        for ep in &self.extra_endpoints {
465            urls.push(ep.clone()?);
466        }
467        Ok(urls)
468    }
469
470    // Returns true if all environment variables are resolved successfully
471    pub fn is_unresolved(&self) -> bool {
472        let endpoint_err = self.endpoint.is_err();
473        let extra_err = self.extra_endpoints.iter().any(|e| e.is_err());
474        let auth_err = self.auth.as_ref().map(|auth| auth.is_err()).unwrap_or(false);
475        endpoint_err || extra_err || auth_err
476    }
477
478    // Attempts to resolve unresolved environment variables into a new instance
479    pub fn try_resolve(mut self) -> Self {
480        if !self.is_unresolved() {
481            return self;
482        }
483        if let Err(err) = self.endpoint {
484            self.endpoint = err.try_resolve()
485        }
486        for ep in &mut self.extra_endpoints {
487            if let Err(err) = std::mem::replace(ep, Ok(String::new())) {
488                *ep = err.try_resolve();
489            }
490        }
491        if let Some(Err(err)) = self.auth {
492            self.auth = Some(err.try_resolve())
493        }
494        self
495    }
496}
497
498/// Container type for _resolved_ endpoints.
499#[derive(Clone, Debug, Default, PartialEq, Eq)]
500pub struct ResolvedRpcEndpoints {
501    endpoints: BTreeMap<String, ResolvedRpcEndpoint>,
502}
503
504impl ResolvedRpcEndpoints {
505    /// Returns true if there's an endpoint that couldn't be resolved
506    pub fn has_unresolved(&self) -> bool {
507        self.endpoints.values().any(|e| e.is_unresolved())
508    }
509}
510
511impl Deref for ResolvedRpcEndpoints {
512    type Target = BTreeMap<String, ResolvedRpcEndpoint>;
513
514    fn deref(&self) -> &Self::Target {
515        &self.endpoints
516    }
517}
518
519impl DerefMut for ResolvedRpcEndpoints {
520    fn deref_mut(&mut self) -> &mut Self::Target {
521        &mut self.endpoints
522    }
523}
524
525/// Returns the URL for a built-in RPC alias, if one exists.
526///
527/// Built-in aliases act as fallbacks: they are only used when the alias has **not** been
528/// defined by the user in `[rpc_endpoints]` or resolved via MESC.
529pub fn builtin_rpc_url(alias: &str) -> Option<&'static str> {
530    match alias {
531        "tempo" => Some("https://rpc.mpp.tempo.xyz"),
532        "moderato" => Some("https://rpc.mpp.moderato.tempo.xyz"),
533        _ => None,
534    }
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    #[test]
542    fn serde_rpc_config() {
543        let s = r#"{
544            "endpoint": "http://localhost:8545",
545            "retries": 5,
546            "retry_backoff": 250,
547            "compute_units_per_second": 100,
548            "auth": "Bearer 123"
549        }"#;
550        let config: RpcEndpoint = serde_json::from_str(s).unwrap();
551        assert_eq!(
552            config,
553            RpcEndpoint {
554                endpoint: RpcEndpointUrl::Url("http://localhost:8545".to_string()),
555                extra_endpoints: vec![],
556                config: RpcEndpointConfig {
557                    retries: Some(5),
558                    retry_backoff: Some(250),
559                    compute_units_per_second: Some(100),
560                },
561                auth: Some(RpcAuth::Raw("Bearer 123".to_string())),
562            }
563        );
564
565        let s = "\"http://localhost:8545\"";
566        let config: RpcEndpoint = serde_json::from_str(s).unwrap();
567        assert_eq!(
568            config,
569            RpcEndpoint {
570                endpoint: RpcEndpointUrl::Url("http://localhost:8545".to_string()),
571                extra_endpoints: vec![],
572                config: RpcEndpointConfig {
573                    retries: None,
574                    retry_backoff: None,
575                    compute_units_per_second: None,
576                },
577                auth: None,
578            }
579        );
580    }
581
582    #[test]
583    fn serde_rpc_config_multi_endpoints() {
584        // Array of endpoints via "endpoints" key
585        let s = r#"{
586            "endpoints": ["https://rpc1.example.com", "https://rpc2.example.com", "https://rpc3.example.com"],
587            "retries": 5,
588            "retry_backoff": 1000
589        }"#;
590        let config: RpcEndpoint = serde_json::from_str(s).unwrap();
591        assert_eq!(
592            config,
593            RpcEndpoint {
594                endpoint: RpcEndpointUrl::Url("https://rpc1.example.com".to_string()),
595                extra_endpoints: vec![
596                    RpcEndpointUrl::Url("https://rpc2.example.com".to_string()),
597                    RpcEndpointUrl::Url("https://rpc3.example.com".to_string()),
598                ],
599                config: RpcEndpointConfig {
600                    retries: Some(5),
601                    retry_backoff: Some(1000),
602                    compute_units_per_second: None,
603                },
604                auth: None,
605            }
606        );
607
608        // Resolved URLs
609        let resolved = config.resolve();
610        let all_urls = resolved.all_urls().unwrap();
611        assert_eq!(
612            all_urls,
613            vec![
614                "https://rpc1.example.com".to_string(),
615                "https://rpc2.example.com".to_string(),
616                "https://rpc3.example.com".to_string(),
617            ]
618        );
619    }
620
621    #[test]
622    fn serde_rpc_config_rejects_both_endpoint_and_endpoints() {
623        let s = r#"{
624            "endpoint": "https://rpc1.example.com",
625            "endpoints": ["https://rpc2.example.com"]
626        }"#;
627        let result: Result<RpcEndpoint, _> = serde_json::from_str(s);
628        assert!(result.is_err());
629        assert!(result.unwrap_err().to_string().contains("cannot specify both"));
630    }
631
632    #[test]
633    fn serde_rpc_config_rejects_empty_endpoints() {
634        let s = r#"{ "endpoints": [] }"#;
635        let result: Result<RpcEndpoint, _> = serde_json::from_str(s);
636        assert!(result.is_err());
637        assert!(result.unwrap_err().to_string().contains("at least one URL"));
638    }
639}