Skip to main content

aptos_sdk/
config.rs

1//! Network configuration for the Aptos SDK.
2//!
3//! This module provides configuration options for connecting to different
4//! Aptos networks (mainnet, testnet, devnet) or custom endpoints.
5
6use crate::error::{AptosError, AptosResult};
7use crate::retry::RetryConfig;
8use crate::types::ChainId;
9use std::time::Duration;
10use url::Url;
11
12/// Validates that a URL uses a safe scheme (http or https).
13///
14/// # Security
15///
16/// This prevents SSRF attacks via dangerous URL schemes like `file://`, `gopher://`, etc.
17/// For production use, HTTPS is strongly recommended. HTTP is permitted (e.g., for local
18/// development) but no host restrictions are enforced by this function.
19///
20/// # Errors
21///
22/// Returns [`AptosError::Config`] if the URL scheme is not `http` or `https`.
23pub fn validate_url_scheme(url: &Url) -> AptosResult<()> {
24    match url.scheme() {
25        "https" => Ok(()),
26        "http" => {
27            // HTTP is allowed for local development and testing
28            Ok(())
29        }
30        scheme => Err(AptosError::Config(format!(
31            "unsupported URL scheme '{scheme}': only 'http' and 'https' are allowed"
32        ))),
33    }
34}
35
36/// Reads a response body with an enforced size limit, aborting early if exceeded.
37///
38/// Unlike `response.bytes().await?` which buffers the entire response in memory
39/// before any size check, this function:
40/// 1. Pre-checks the `Content-Length` header (if present) to reject obviously
41///    oversized responses before reading any body data.
42/// 2. Reads the body incrementally via chunked streaming, aborting as soon as
43///    the accumulated size exceeds `max_size`.
44///
45/// This prevents memory exhaustion from malicious servers that send huge
46/// responses (including chunked transfer-encoding without `Content-Length`).
47///
48/// # Errors
49///
50/// Returns [`AptosError::Api`] with error code `RESPONSE_TOO_LARGE` if the
51/// response body exceeds `max_size` bytes.
52pub async fn read_response_bounded(
53    response: reqwest::Response,
54    max_size: usize,
55) -> AptosResult<Vec<u8>> {
56    // Pre-check Content-Length header for early rejection (avoids reading any body)
57    if let Some(content_length) = response.content_length()
58        && content_length > max_size as u64
59    {
60        return Err(AptosError::Api {
61            status_code: response.status().as_u16(),
62            message: format!(
63                "response too large: Content-Length {content_length} bytes exceeds limit of {max_size} bytes"
64            ),
65            error_code: Some("RESPONSE_TOO_LARGE".into()),
66            vm_error_code: None,
67        });
68    }
69
70    // Read body incrementally, aborting if accumulated size exceeds the limit.
71    // This protects against chunked transfer-encoding that bypasses Content-Length.
72    #[cfg(not(target_arch = "wasm32"))]
73    {
74        let mut body = Vec::with_capacity(std::cmp::min(max_size, 1024 * 1024));
75        let mut response = response;
76        while let Some(chunk) = response.chunk().await? {
77            if body.len().saturating_add(chunk.len()) > max_size {
78                return Err(AptosError::Api {
79                    status_code: response.status().as_u16(),
80                    message: format!(
81                        "response too large: exceeded limit of {max_size} bytes during streaming"
82                    ),
83                    error_code: Some("RESPONSE_TOO_LARGE".into()),
84                    vm_error_code: None,
85                });
86            }
87            body.extend_from_slice(&chunk);
88        }
89        Ok(body)
90    }
91
92    // WASM `reqwest` does not expose chunked streaming, so we buffer the body
93    // and enforce the size limit afterwards. The Content-Length pre-check above
94    // still catches the common case of oversized responses.
95    #[cfg(target_arch = "wasm32")]
96    {
97        let status_code = response.status().as_u16();
98        let body = response.bytes().await?.to_vec();
99        if body.len() > max_size {
100            return Err(AptosError::Api {
101                status_code,
102                message: format!(
103                    "response too large: exceeded limit of {max_size} bytes during streaming"
104                ),
105                error_code: Some("RESPONSE_TOO_LARGE".into()),
106                vm_error_code: None,
107            });
108        }
109        Ok(body)
110    }
111}
112
113/// Configuration for HTTP connection pooling.
114///
115/// Controls how connections are reused across requests for better performance.
116#[derive(Debug, Clone)]
117pub struct PoolConfig {
118    /// Maximum number of idle connections per host.
119    /// Default: unlimited (no limit)
120    pub max_idle_per_host: Option<usize>,
121    /// Maximum total idle connections in the pool.
122    /// Default: 100
123    pub max_idle_total: usize,
124    /// How long to keep idle connections alive.
125    /// Default: 90 seconds
126    pub idle_timeout: Duration,
127    /// Whether to enable TCP keepalive.
128    /// Default: true
129    pub tcp_keepalive: Option<Duration>,
130    /// Whether to enable TCP nodelay (disable Nagle's algorithm).
131    /// Default: true
132    pub tcp_nodelay: bool,
133    /// Maximum response body size in bytes.
134    /// Default: 10 MB (`10_485_760` bytes)
135    ///
136    /// # Security
137    ///
138    /// This limit helps prevent memory exhaustion from extremely large responses.
139    /// The Aptos API responses are typically much smaller than this limit.
140    pub max_response_size: usize,
141}
142
143/// Default maximum response size: 10 MB
144///
145/// # Security
146///
147/// This limit helps prevent memory exhaustion from malicious or compromised
148/// servers sending extremely large responses. The default of 10 MB is generous
149/// for normal Aptos API responses (typically under 1 MB). If you need to
150/// handle larger responses (e.g., bulk data exports), increase this via
151/// [`PoolConfigBuilder::max_response_size`].
152const DEFAULT_MAX_RESPONSE_SIZE: usize = 10 * 1024 * 1024;
153
154impl Default for PoolConfig {
155    fn default() -> Self {
156        Self {
157            max_idle_per_host: None, // unlimited
158            max_idle_total: 100,
159            idle_timeout: Duration::from_secs(90),
160            tcp_keepalive: Some(Duration::from_mins(1)),
161            tcp_nodelay: true,
162            max_response_size: DEFAULT_MAX_RESPONSE_SIZE,
163        }
164    }
165}
166
167impl PoolConfig {
168    /// Creates a new pool configuration builder.
169    pub fn builder() -> PoolConfigBuilder {
170        PoolConfigBuilder::default()
171    }
172
173    /// Creates a configuration optimized for high-throughput scenarios.
174    ///
175    /// - More idle connections
176    /// - Longer idle timeout
177    /// - TCP keepalive enabled
178    pub fn high_throughput() -> Self {
179        Self {
180            max_idle_per_host: Some(32),
181            max_idle_total: 256,
182            idle_timeout: Duration::from_mins(5),
183            tcp_keepalive: Some(Duration::from_secs(30)),
184            tcp_nodelay: true,
185            max_response_size: DEFAULT_MAX_RESPONSE_SIZE,
186        }
187    }
188
189    /// Creates a configuration optimized for low-latency scenarios.
190    ///
191    /// - Fewer idle connections (fresher connections)
192    /// - Shorter idle timeout
193    /// - TCP nodelay enabled
194    pub fn low_latency() -> Self {
195        Self {
196            max_idle_per_host: Some(8),
197            max_idle_total: 32,
198            idle_timeout: Duration::from_secs(30),
199            tcp_keepalive: Some(Duration::from_secs(15)),
200            tcp_nodelay: true,
201            max_response_size: DEFAULT_MAX_RESPONSE_SIZE,
202        }
203    }
204
205    /// Creates a minimal configuration for constrained environments.
206    ///
207    /// - Minimal idle connections
208    /// - Short idle timeout
209    pub fn minimal() -> Self {
210        Self {
211            max_idle_per_host: Some(2),
212            max_idle_total: 8,
213            idle_timeout: Duration::from_secs(10),
214            tcp_keepalive: None,
215            tcp_nodelay: true,
216            max_response_size: DEFAULT_MAX_RESPONSE_SIZE,
217        }
218    }
219}
220
221/// Builder for `PoolConfig`.
222#[derive(Debug, Clone, Default)]
223#[allow(clippy::option_option)] // Intentional: distinguishes "not set" from "explicitly set to None"
224pub struct PoolConfigBuilder {
225    max_idle_per_host: Option<usize>,
226    max_idle_total: Option<usize>,
227    idle_timeout: Option<Duration>,
228    /// None = not set (use default), Some(None) = explicitly disabled, Some(Some(d)) = explicitly set
229    tcp_keepalive: Option<Option<Duration>>,
230    tcp_nodelay: Option<bool>,
231    max_response_size: Option<usize>,
232}
233
234impl PoolConfigBuilder {
235    /// Sets the maximum idle connections per host.
236    #[must_use]
237    pub fn max_idle_per_host(mut self, max: usize) -> Self {
238        self.max_idle_per_host = Some(max);
239        self
240    }
241
242    /// Removes the limit on idle connections per host.
243    #[must_use]
244    pub fn unlimited_idle_per_host(mut self) -> Self {
245        self.max_idle_per_host = None;
246        self
247    }
248
249    /// Sets the maximum total idle connections.
250    #[must_use]
251    pub fn max_idle_total(mut self, max: usize) -> Self {
252        self.max_idle_total = Some(max);
253        self
254    }
255
256    /// Sets the idle connection timeout.
257    #[must_use]
258    pub fn idle_timeout(mut self, timeout: Duration) -> Self {
259        self.idle_timeout = Some(timeout);
260        self
261    }
262
263    /// Sets the TCP keepalive interval.
264    #[must_use]
265    pub fn tcp_keepalive(mut self, interval: Duration) -> Self {
266        self.tcp_keepalive = Some(Some(interval));
267        self
268    }
269
270    /// Disables TCP keepalive.
271    #[must_use]
272    pub fn no_tcp_keepalive(mut self) -> Self {
273        self.tcp_keepalive = Some(None);
274        self
275    }
276
277    /// Sets whether to enable TCP nodelay.
278    #[must_use]
279    pub fn tcp_nodelay(mut self, enabled: bool) -> Self {
280        self.tcp_nodelay = Some(enabled);
281        self
282    }
283
284    /// Sets the maximum response body size in bytes.
285    ///
286    /// # Security
287    ///
288    /// This helps prevent memory exhaustion from extremely large responses.
289    #[must_use]
290    pub fn max_response_size(mut self, size: usize) -> Self {
291        self.max_response_size = Some(size);
292        self
293    }
294
295    /// Builds the pool configuration.
296    pub fn build(self) -> PoolConfig {
297        let default = PoolConfig::default();
298        PoolConfig {
299            max_idle_per_host: self.max_idle_per_host.or(default.max_idle_per_host),
300            max_idle_total: self.max_idle_total.unwrap_or(default.max_idle_total),
301            idle_timeout: self.idle_timeout.unwrap_or(default.idle_timeout),
302            tcp_keepalive: self.tcp_keepalive.unwrap_or(default.tcp_keepalive),
303            tcp_nodelay: self.tcp_nodelay.unwrap_or(default.tcp_nodelay),
304            max_response_size: self.max_response_size.unwrap_or(default.max_response_size),
305        }
306    }
307}
308
309/// Configuration for the Aptos client.
310///
311/// Use the builder methods to customize the configuration, or use one of the
312/// preset configurations like [`AptosConfig::mainnet()`], [`AptosConfig::testnet()`],
313/// or [`AptosConfig::devnet()`].
314///
315/// # Example
316///
317/// ```rust
318/// use aptos_sdk::AptosConfig;
319/// use aptos_sdk::retry::RetryConfig;
320/// use aptos_sdk::config::PoolConfig;
321///
322/// // Use testnet with default settings
323/// let config = AptosConfig::testnet();
324///
325/// // Custom configuration with retry and connection pooling
326/// let config = AptosConfig::testnet()
327///     .with_timeout(std::time::Duration::from_secs(30))
328///     .with_retry(RetryConfig::aggressive())
329///     .with_pool(PoolConfig::high_throughput());
330/// ```
331#[derive(Debug, Clone)]
332pub struct AptosConfig {
333    /// The network to connect to
334    pub(crate) network: Network,
335    /// REST API URL (fullnode)
336    pub(crate) fullnode_url: Url,
337    /// Indexer GraphQL URL (optional)
338    pub(crate) indexer_url: Option<Url>,
339    /// Faucet URL (optional, for testnets)
340    pub(crate) faucet_url: Option<Url>,
341    /// Request timeout
342    pub(crate) timeout: Duration,
343    /// Retry configuration for transient failures
344    pub(crate) retry_config: RetryConfig,
345    /// Connection pool configuration
346    pub(crate) pool_config: PoolConfig,
347    /// Optional API key for authenticated access
348    pub(crate) api_key: Option<String>,
349}
350
351/// Known Aptos networks.
352#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
353pub enum Network {
354    /// Aptos mainnet
355    Mainnet,
356    /// Aptos testnet
357    Testnet,
358    /// Aptos devnet
359    Devnet,
360    /// Local development network
361    Local,
362    /// Custom network
363    Custom,
364}
365
366impl Network {
367    /// Returns the chain ID for this network.
368    ///
369    /// Devnet's chain ID is intentionally returned as `0` (unknown) because
370    /// it is reset on a regular cadence and any hardcoded value rapidly
371    /// goes stale. Returning `0` causes [`crate::Aptos::ensure_chain_id`] to
372    /// fetch the live chain ID from the configured fullnode and cache it.
373    pub fn chain_id(&self) -> ChainId {
374        match self {
375            Network::Mainnet => ChainId::mainnet(),
376            Network::Testnet => ChainId::testnet(),
377            Network::Devnet => ChainId::new(0),
378            Network::Local => ChainId::new(4),
379            Network::Custom => ChainId::new(0),
380        }
381    }
382
383    /// Returns the network name as a string.
384    pub fn as_str(&self) -> &'static str {
385        match self {
386            Network::Mainnet => "mainnet",
387            Network::Testnet => "testnet",
388            Network::Devnet => "devnet",
389            Network::Local => "local",
390            Network::Custom => "custom",
391        }
392    }
393}
394
395impl Default for AptosConfig {
396    fn default() -> Self {
397        Self::devnet()
398    }
399}
400
401impl AptosConfig {
402    /// Creates a configuration for Aptos mainnet.
403    ///
404    /// # Example
405    ///
406    /// ```rust
407    /// use aptos_sdk::AptosConfig;
408    ///
409    /// let config = AptosConfig::mainnet();
410    /// ```
411    #[allow(clippy::missing_panics_doc)]
412    #[must_use]
413    pub fn mainnet() -> Self {
414        Self {
415            network: Network::Mainnet,
416            fullnode_url: Url::parse("https://fullnode.mainnet.aptoslabs.com/v1")
417                .expect("valid mainnet URL"),
418            indexer_url: Some(
419                Url::parse("https://indexer.mainnet.aptoslabs.com/v1/graphql")
420                    .expect("valid indexer URL"),
421            ),
422            faucet_url: None, // No faucet on mainnet
423            timeout: Duration::from_secs(30),
424            retry_config: RetryConfig::conservative(), // More conservative for mainnet
425            pool_config: PoolConfig::default(),
426            api_key: None,
427        }
428    }
429
430    /// Creates a configuration for Aptos testnet.
431    ///
432    /// # Example
433    ///
434    /// ```rust
435    /// use aptos_sdk::AptosConfig;
436    ///
437    /// let config = AptosConfig::testnet();
438    /// ```
439    #[allow(clippy::missing_panics_doc)]
440    #[must_use]
441    pub fn testnet() -> Self {
442        Self {
443            network: Network::Testnet,
444            fullnode_url: Url::parse("https://fullnode.testnet.aptoslabs.com/v1")
445                .expect("valid testnet URL"),
446            indexer_url: Some(
447                Url::parse("https://indexer.testnet.aptoslabs.com/v1/graphql")
448                    .expect("valid indexer URL"),
449            ),
450            faucet_url: Some(
451                Url::parse("https://faucet.testnet.aptoslabs.com").expect("valid faucet URL"),
452            ),
453            timeout: Duration::from_secs(30),
454            retry_config: RetryConfig::default(),
455            pool_config: PoolConfig::default(),
456            api_key: None,
457        }
458    }
459
460    /// Creates a configuration for Aptos devnet.
461    ///
462    /// # Example
463    ///
464    /// ```rust
465    /// use aptos_sdk::AptosConfig;
466    ///
467    /// let config = AptosConfig::devnet();
468    /// ```
469    #[allow(clippy::missing_panics_doc)]
470    #[must_use]
471    pub fn devnet() -> Self {
472        Self {
473            network: Network::Devnet,
474            fullnode_url: Url::parse("https://fullnode.devnet.aptoslabs.com/v1")
475                .expect("valid devnet URL"),
476            indexer_url: Some(
477                Url::parse("https://indexer.devnet.aptoslabs.com/v1/graphql")
478                    .expect("valid indexer URL"),
479            ),
480            faucet_url: Some(
481                Url::parse("https://faucet.devnet.aptoslabs.com").expect("valid faucet URL"),
482            ),
483            timeout: Duration::from_secs(30),
484            retry_config: RetryConfig::default(),
485            pool_config: PoolConfig::default(),
486            api_key: None,
487        }
488    }
489
490    /// Creates a configuration for a local development network.
491    ///
492    /// This assumes the local network is running on the default ports
493    /// (REST API on 8080, faucet on 8081).
494    ///
495    /// # Example
496    ///
497    /// ```rust
498    /// use aptos_sdk::AptosConfig;
499    ///
500    /// let config = AptosConfig::local();
501    /// ```
502    #[allow(clippy::missing_panics_doc)]
503    #[must_use]
504    pub fn local() -> Self {
505        Self {
506            network: Network::Local,
507            fullnode_url: Url::parse("http://127.0.0.1:8080/v1").expect("valid local URL"),
508            indexer_url: None,
509            faucet_url: Some(Url::parse("http://127.0.0.1:8081").expect("valid local faucet URL")),
510            timeout: Duration::from_secs(10),
511            retry_config: RetryConfig::aggressive(), // Fast retries for local dev
512            pool_config: PoolConfig::low_latency(),  // Low latency for local dev
513            api_key: None,
514        }
515    }
516
517    /// Creates a custom configuration with the specified fullnode URL.
518    ///
519    /// # Security
520    ///
521    /// Only `http://` and `https://` URL schemes are allowed. Using `https://` is
522    /// strongly recommended for production. HTTP is acceptable only for localhost
523    /// development environments.
524    ///
525    /// # Errors
526    ///
527    /// Returns an error if the `fullnode_url` cannot be parsed as a valid URL
528    /// or uses an unsupported scheme (e.g., `file://`, `ftp://`).
529    ///
530    /// # Example
531    ///
532    /// ```rust
533    /// use aptos_sdk::AptosConfig;
534    ///
535    /// let config = AptosConfig::custom("https://my-node.example.com/v1").unwrap();
536    /// ```
537    pub fn custom(fullnode_url: &str) -> AptosResult<Self> {
538        let url = Url::parse(fullnode_url)?;
539        validate_url_scheme(&url)?;
540        Ok(Self {
541            network: Network::Custom,
542            fullnode_url: url,
543            indexer_url: None,
544            faucet_url: None,
545            timeout: Duration::from_secs(30),
546            retry_config: RetryConfig::default(),
547            pool_config: PoolConfig::default(),
548            api_key: None,
549        })
550    }
551
552    /// Sets the request timeout.
553    #[must_use]
554    pub fn with_timeout(mut self, timeout: Duration) -> Self {
555        self.timeout = timeout;
556        self
557    }
558
559    /// Sets the retry configuration for transient failures.
560    ///
561    /// # Example
562    ///
563    /// ```rust
564    /// use aptos_sdk::AptosConfig;
565    /// use aptos_sdk::retry::RetryConfig;
566    ///
567    /// let config = AptosConfig::testnet()
568    ///     .with_retry(RetryConfig::aggressive());
569    /// ```
570    #[must_use]
571    pub fn with_retry(mut self, retry_config: RetryConfig) -> Self {
572        self.retry_config = retry_config;
573        self
574    }
575
576    /// Disables automatic retry for API calls.
577    ///
578    /// This is equivalent to `with_retry(RetryConfig::no_retry())`.
579    #[must_use]
580    pub fn without_retry(mut self) -> Self {
581        self.retry_config = RetryConfig::no_retry();
582        self
583    }
584
585    /// Sets the maximum number of retries for transient failures.
586    ///
587    /// This is a convenience method that modifies *only* the `max_retries`
588    /// field of the current retry config, preserving every other field
589    /// (delays, exponential base, jitter settings, and any customized
590    /// `retryable_status_codes`).
591    #[must_use]
592    pub fn with_max_retries(mut self, max_retries: u32) -> Self {
593        self.retry_config.max_retries = max_retries;
594        self
595    }
596
597    /// Sets the connection pool configuration.
598    ///
599    /// # Example
600    ///
601    /// ```rust
602    /// use aptos_sdk::AptosConfig;
603    /// use aptos_sdk::config::PoolConfig;
604    ///
605    /// let config = AptosConfig::testnet()
606    ///     .with_pool(PoolConfig::high_throughput());
607    /// ```
608    #[must_use]
609    pub fn with_pool(mut self, pool_config: PoolConfig) -> Self {
610        self.pool_config = pool_config;
611        self
612    }
613
614    /// Sets an API key for authenticated access.
615    ///
616    /// This is useful when using Aptos Build or other services that
617    /// provide higher rate limits with API keys.
618    #[must_use]
619    pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
620        self.api_key = Some(api_key.into());
621        self
622    }
623
624    /// Sets a custom indexer URL.
625    ///
626    /// # Security
627    ///
628    /// Only `http://` and `https://` URL schemes are allowed.
629    ///
630    /// # Errors
631    ///
632    /// Returns an error if the `url` cannot be parsed as a valid URL
633    /// or uses an unsupported scheme.
634    pub fn with_indexer_url(mut self, url: &str) -> AptosResult<Self> {
635        let parsed = Url::parse(url)?;
636        validate_url_scheme(&parsed)?;
637        self.indexer_url = Some(parsed);
638        Ok(self)
639    }
640
641    /// Sets a custom faucet URL.
642    ///
643    /// # Security
644    ///
645    /// Only `http://` and `https://` URL schemes are allowed.
646    ///
647    /// # Errors
648    ///
649    /// Returns an error if the `url` cannot be parsed as a valid URL
650    /// or uses an unsupported scheme.
651    pub fn with_faucet_url(mut self, url: &str) -> AptosResult<Self> {
652        let parsed = Url::parse(url)?;
653        validate_url_scheme(&parsed)?;
654        self.faucet_url = Some(parsed);
655        Ok(self)
656    }
657
658    /// Returns the network this config is for.
659    pub fn network(&self) -> Network {
660        self.network
661    }
662
663    /// Returns the fullnode URL.
664    pub fn fullnode_url(&self) -> &Url {
665        &self.fullnode_url
666    }
667
668    /// Returns the indexer URL, if configured.
669    pub fn indexer_url(&self) -> Option<&Url> {
670        self.indexer_url.as_ref()
671    }
672
673    /// Returns the faucet URL, if configured.
674    pub fn faucet_url(&self) -> Option<&Url> {
675        self.faucet_url.as_ref()
676    }
677
678    /// Returns the chain ID for this configuration.
679    pub fn chain_id(&self) -> ChainId {
680        self.network.chain_id()
681    }
682
683    /// Returns the retry configuration.
684    pub fn retry_config(&self) -> &RetryConfig {
685        &self.retry_config
686    }
687
688    /// Returns the request timeout.
689    pub fn timeout(&self) -> Duration {
690        self.timeout
691    }
692
693    /// Returns the connection pool configuration.
694    pub fn pool_config(&self) -> &PoolConfig {
695        &self.pool_config
696    }
697}
698
699#[cfg(test)]
700mod tests {
701    use super::*;
702
703    #[test]
704    fn test_mainnet_config() {
705        let config = AptosConfig::mainnet();
706        assert_eq!(config.network(), Network::Mainnet);
707        assert!(config.fullnode_url().as_str().contains("mainnet"));
708        assert!(config.faucet_url().is_none());
709    }
710
711    #[test]
712    fn test_testnet_config() {
713        let config = AptosConfig::testnet();
714        assert_eq!(config.network(), Network::Testnet);
715        assert!(config.fullnode_url().as_str().contains("testnet"));
716        assert!(config.faucet_url().is_some());
717    }
718
719    #[test]
720    fn test_devnet_config() {
721        let config = AptosConfig::devnet();
722        assert_eq!(config.network(), Network::Devnet);
723        assert!(config.fullnode_url().as_str().contains("devnet"));
724        assert!(config.faucet_url().is_some());
725        assert!(config.indexer_url().is_some());
726    }
727
728    #[test]
729    fn test_local_config() {
730        let config = AptosConfig::local();
731        assert_eq!(config.network(), Network::Local);
732        assert!(config.fullnode_url().as_str().contains("127.0.0.1"));
733        assert!(config.faucet_url().is_some());
734        assert!(config.indexer_url().is_none());
735    }
736
737    #[test]
738    fn test_custom_config() {
739        let config = AptosConfig::custom("https://custom.example.com/v1").unwrap();
740        assert_eq!(config.network(), Network::Custom);
741        assert_eq!(
742            config.fullnode_url().as_str(),
743            "https://custom.example.com/v1"
744        );
745    }
746
747    #[test]
748    fn test_custom_config_invalid_url() {
749        let result = AptosConfig::custom("not a valid url");
750        assert!(result.is_err());
751    }
752
753    #[test]
754    fn test_builder_methods() {
755        let config = AptosConfig::testnet()
756            .with_timeout(Duration::from_mins(1))
757            .with_max_retries(5)
758            .with_api_key("test-key");
759
760        assert_eq!(config.timeout, Duration::from_mins(1));
761        assert_eq!(config.retry_config.max_retries, 5);
762        assert_eq!(config.api_key, Some("test-key".to_string()));
763    }
764
765    #[test]
766    fn test_with_max_retries_preserves_other_fields() {
767        // Start from a retry config with fully customized fields.
768        let custom_retry = RetryConfig::builder()
769            .max_retries(2)
770            .initial_delay_ms(321)
771            .max_delay_ms(54_321)
772            .exponential_base(1.75)
773            .jitter(false)
774            .jitter_factor(0.125)
775            .retryable_status_codes([418, 599])
776            .build();
777
778        let config = AptosConfig::testnet()
779            .with_retry(custom_retry)
780            .with_max_retries(9);
781
782        let retry = config.retry_config();
783        // max_retries changed as requested.
784        assert_eq!(retry.max_retries, 9);
785        // Every other field survives untouched.
786        assert_eq!(retry.initial_delay_ms, 321);
787        assert_eq!(retry.max_delay_ms, 54_321);
788        assert!((retry.exponential_base - 1.75).abs() < f64::EPSILON);
789        assert!(!retry.jitter);
790        assert!((retry.jitter_factor - 0.125).abs() < f64::EPSILON);
791        assert!(retry.is_retryable_status(418));
792        assert!(retry.is_retryable_status(599));
793        // The custom set replaced the defaults, so 500 is no longer retryable.
794        assert!(!retry.is_retryable_status(500));
795    }
796
797    #[test]
798    fn test_retry_config() {
799        let config = AptosConfig::testnet().with_retry(RetryConfig::aggressive());
800
801        assert_eq!(config.retry_config.max_retries, 5);
802        assert_eq!(config.retry_config.initial_delay_ms, 50);
803
804        let config = AptosConfig::testnet().without_retry();
805        assert_eq!(config.retry_config.max_retries, 0);
806    }
807
808    #[test]
809    fn test_network_retry_defaults() {
810        // Mainnet should be conservative
811        let mainnet = AptosConfig::mainnet();
812        assert_eq!(mainnet.retry_config.max_retries, 3);
813
814        // Local should be aggressive
815        let local = AptosConfig::local();
816        assert_eq!(local.retry_config.max_retries, 5);
817    }
818
819    #[test]
820    fn test_pool_config_default() {
821        let config = PoolConfig::default();
822        assert_eq!(config.max_idle_total, 100);
823        assert_eq!(config.idle_timeout, Duration::from_secs(90));
824        assert!(config.tcp_nodelay);
825    }
826
827    #[test]
828    fn test_pool_config_presets() {
829        let high = PoolConfig::high_throughput();
830        assert_eq!(high.max_idle_per_host, Some(32));
831        assert_eq!(high.max_idle_total, 256);
832
833        let low = PoolConfig::low_latency();
834        assert_eq!(low.max_idle_per_host, Some(8));
835        assert_eq!(low.idle_timeout, Duration::from_secs(30));
836
837        let minimal = PoolConfig::minimal();
838        assert_eq!(minimal.max_idle_per_host, Some(2));
839        assert_eq!(minimal.max_idle_total, 8);
840    }
841
842    #[test]
843    fn test_pool_config_builder() {
844        let config = PoolConfig::builder()
845            .max_idle_per_host(16)
846            .max_idle_total(64)
847            .idle_timeout(Duration::from_mins(1))
848            .tcp_nodelay(false)
849            .build();
850
851        assert_eq!(config.max_idle_per_host, Some(16));
852        assert_eq!(config.max_idle_total, 64);
853        assert_eq!(config.idle_timeout, Duration::from_mins(1));
854        assert!(!config.tcp_nodelay);
855    }
856
857    #[test]
858    fn test_pool_config_builder_tcp_keepalive() {
859        let config = PoolConfig::builder()
860            .tcp_keepalive(Duration::from_secs(30))
861            .build();
862        assert_eq!(config.tcp_keepalive, Some(Duration::from_secs(30)));
863
864        let config = PoolConfig::builder().no_tcp_keepalive().build();
865        assert_eq!(config.tcp_keepalive, None);
866    }
867
868    #[test]
869    fn test_pool_config_builder_unlimited_idle() {
870        let config = PoolConfig::builder().unlimited_idle_per_host().build();
871        assert_eq!(config.max_idle_per_host, None);
872    }
873
874    #[test]
875    fn test_aptos_config_with_pool() {
876        let config = AptosConfig::testnet().with_pool(PoolConfig::high_throughput());
877
878        assert_eq!(config.pool_config.max_idle_total, 256);
879    }
880
881    #[test]
882    fn test_aptos_config_with_indexer_url() {
883        let config = AptosConfig::testnet()
884            .with_indexer_url("https://custom-indexer.example.com/graphql")
885            .unwrap();
886        assert_eq!(
887            config.indexer_url().unwrap().as_str(),
888            "https://custom-indexer.example.com/graphql"
889        );
890    }
891
892    #[test]
893    fn test_aptos_config_with_faucet_url() {
894        let config = AptosConfig::mainnet()
895            .with_faucet_url("https://custom-faucet.example.com")
896            .unwrap();
897        assert_eq!(
898            config.faucet_url().unwrap().as_str(),
899            "https://custom-faucet.example.com/"
900        );
901    }
902
903    #[test]
904    fn test_aptos_config_default() {
905        let config = AptosConfig::default();
906        assert_eq!(config.network(), Network::Devnet);
907    }
908
909    #[test]
910    fn test_network_chain_id() {
911        assert_eq!(Network::Mainnet.chain_id().id(), 1);
912        assert_eq!(Network::Testnet.chain_id().id(), 2);
913        // Devnet chain ID is reported as 0 (unknown); see Network::chain_id
914        // doc comment. The SDK queries the fullnode to resolve the live ID.
915        assert_eq!(Network::Devnet.chain_id().id(), 0);
916        assert_eq!(Network::Local.chain_id().id(), 4);
917        assert_eq!(Network::Custom.chain_id().id(), 0);
918    }
919
920    #[test]
921    fn test_network_as_str() {
922        assert_eq!(Network::Mainnet.as_str(), "mainnet");
923        assert_eq!(Network::Testnet.as_str(), "testnet");
924        assert_eq!(Network::Devnet.as_str(), "devnet");
925        assert_eq!(Network::Local.as_str(), "local");
926        assert_eq!(Network::Custom.as_str(), "custom");
927    }
928
929    #[test]
930    fn test_aptos_config_getters() {
931        let config = AptosConfig::testnet();
932
933        assert_eq!(config.timeout(), Duration::from_secs(30));
934        assert!(config.retry_config().max_retries > 0);
935        assert!(config.pool_config().max_idle_total > 0);
936        assert_eq!(config.chain_id().id(), 2);
937    }
938
939    #[tokio::test]
940    async fn test_read_response_bounded_normal() {
941        use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
942        let server = MockServer::start().await;
943        Mock::given(method("GET"))
944            .respond_with(ResponseTemplate::new(200).set_body_string("hello world"))
945            .mount(&server)
946            .await;
947
948        let response = reqwest::get(server.uri()).await.unwrap();
949        let body = read_response_bounded(response, 1024).await.unwrap();
950        assert_eq!(body, b"hello world");
951    }
952
953    #[tokio::test]
954    async fn test_read_response_bounded_rejects_oversized_content_length() {
955        use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
956        let server = MockServer::start().await;
957        // Send a body whose accurate Content-Length exceeds the limit.
958        // The function should reject based on Content-Length pre-check
959        // before streaming the full body.
960        let body = "x".repeat(200);
961        Mock::given(method("GET"))
962            .respond_with(ResponseTemplate::new(200).set_body_string(body))
963            .mount(&server)
964            .await;
965
966        let response = reqwest::get(server.uri()).await.unwrap();
967        // Limit is 100 but body is 200 -- should be rejected via Content-Length pre-check
968        let result = read_response_bounded(response, 100).await;
969        assert!(result.is_err());
970        let err = result.unwrap_err().to_string();
971        assert!(err.contains("response too large"));
972    }
973
974    #[tokio::test]
975    async fn test_read_response_bounded_rejects_oversized_body() {
976        use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
977        let server = MockServer::start().await;
978        let large_body = "x".repeat(500);
979        Mock::given(method("GET"))
980            .respond_with(ResponseTemplate::new(200).set_body_string(large_body))
981            .mount(&server)
982            .await;
983
984        let response = reqwest::get(server.uri()).await.unwrap();
985        let result = read_response_bounded(response, 100).await;
986        assert!(result.is_err());
987    }
988
989    #[tokio::test]
990    async fn test_read_response_bounded_exact_limit() {
991        use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
992        let server = MockServer::start().await;
993        let body = "x".repeat(100);
994        Mock::given(method("GET"))
995            .respond_with(ResponseTemplate::new(200).set_body_string(body.clone()))
996            .mount(&server)
997            .await;
998
999        let response = reqwest::get(server.uri()).await.unwrap();
1000        let result = read_response_bounded(response, 100).await.unwrap();
1001        assert_eq!(result.len(), 100);
1002    }
1003
1004    #[tokio::test]
1005    async fn test_read_response_bounded_empty() {
1006        use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
1007        let server = MockServer::start().await;
1008        Mock::given(method("GET"))
1009            .respond_with(ResponseTemplate::new(200))
1010            .mount(&server)
1011            .await;
1012
1013        let response = reqwest::get(server.uri()).await.unwrap();
1014        let result = read_response_bounded(response, 1024).await.unwrap();
1015        assert!(result.is_empty());
1016    }
1017}