Skip to main content

aptos_sdk/api/
fullnode.rs

1//! Fullnode REST API client.
2
3use crate::api::response::{
4    AccountData, AptosResponse, GasEstimation, LedgerInfo, MoveModule, PendingTransaction, Resource,
5};
6use crate::config::AptosConfig;
7use crate::error::{AptosError, AptosResult};
8use crate::retry::{RetryConfig, RetryExecutor};
9use crate::transaction::types::SignedTransaction;
10use crate::types::{AccountAddress, HashValue};
11use reqwest::Client;
12use reqwest::header::{ACCEPT, CONTENT_TYPE};
13use std::sync::Arc;
14use std::time::Duration;
15use url::Url;
16
17const BCS_CONTENT_TYPE: &str = "application/x.aptos.signed_transaction+bcs";
18const BCS_VIEW_CONTENT_TYPE: &str = "application/x-bcs";
19const JSON_CONTENT_TYPE: &str = "application/json";
20/// Default timeout for waiting for a transaction to be committed.
21const DEFAULT_TRANSACTION_WAIT_TIMEOUT_SECS: u64 = 30;
22/// Maximum size for error response bodies (8 KB).
23///
24/// # Security
25///
26/// This prevents memory exhaustion from malicious servers sending extremely
27/// large error response bodies.
28const MAX_ERROR_BODY_SIZE: usize = 8 * 1024;
29
30/// Client for the Aptos fullnode REST API.
31///
32/// The client supports automatic retry with exponential backoff for transient
33/// failures. Configure retry behavior via [`AptosConfig::with_retry`].
34///
35/// # Example
36///
37/// ```rust,no_run
38/// use aptos_sdk::api::FullnodeClient;
39/// use aptos_sdk::config::AptosConfig;
40/// use aptos_sdk::retry::RetryConfig;
41///
42/// #[tokio::main]
43/// async fn main() -> anyhow::Result<()> {
44///     // Default retry configuration
45///     let client = FullnodeClient::new(AptosConfig::testnet())?;
46///     
47///     // Aggressive retry for unstable networks
48///     let client = FullnodeClient::new(
49///         AptosConfig::testnet().with_retry(RetryConfig::aggressive())
50///     )?;
51///     
52///     // Disable retry for debugging
53///     let client = FullnodeClient::new(
54///         AptosConfig::testnet().without_retry()
55///     )?;
56///     
57///     let ledger_info = client.get_ledger_info().await?;
58///     println!("Ledger version: {:?}", ledger_info.data.version());
59///     Ok(())
60/// }
61/// ```
62#[derive(Debug, Clone)]
63pub struct FullnodeClient {
64    config: AptosConfig,
65    client: Client,
66    retry_config: Arc<RetryConfig>,
67}
68
69impl FullnodeClient {
70    /// Creates a new fullnode client.
71    ///
72    /// # TLS Security
73    ///
74    /// This client uses `reqwest` with its default TLS configuration, which:
75    /// - Validates server certificates against the system's certificate store
76    /// - Requires valid TLS certificates for HTTPS connections
77    /// - Uses secure TLS versions (TLS 1.2+)
78    ///
79    /// All Aptos network endpoints (mainnet, testnet, devnet) use HTTPS with
80    /// valid certificates. The local configuration uses HTTP for development.
81    ///
82    /// For custom deployments requiring custom CA certificates, use the
83    /// `REQUESTS_CA_BUNDLE` or `SSL_CERT_FILE` environment variables, or
84    /// configure a custom `reqwest::Client` and use `from_client()`.
85    ///
86    /// # Errors
87    ///
88    /// Returns an error if the HTTP client fails to build (e.g., invalid TLS configuration).
89    pub fn new(config: AptosConfig) -> AptosResult<Self> {
90        let pool = config.pool_config();
91
92        // SECURITY: TLS certificate validation is enabled by default via reqwest.
93        // The client will reject connections to servers with invalid certificates.
94        // All production Aptos endpoints use HTTPS with valid certificates.
95        let mut builder = Client::builder()
96            .timeout(config.timeout)
97            .pool_max_idle_per_host(pool.max_idle_per_host.unwrap_or(usize::MAX))
98            .pool_idle_timeout(pool.idle_timeout)
99            .tcp_nodelay(pool.tcp_nodelay);
100
101        if let Some(keepalive) = pool.tcp_keepalive {
102            builder = builder.tcp_keepalive(keepalive);
103        }
104
105        let client = builder.build().map_err(AptosError::Http)?;
106
107        let retry_config = Arc::new(config.retry_config().clone());
108
109        Ok(Self {
110            config,
111            client,
112            retry_config,
113        })
114    }
115
116    /// Returns the base URL for the fullnode.
117    pub fn base_url(&self) -> &Url {
118        self.config.fullnode_url()
119    }
120
121    /// Returns the retry configuration.
122    pub fn retry_config(&self) -> &RetryConfig {
123        &self.retry_config
124    }
125
126    // === Ledger Info ===
127
128    /// Gets the current ledger information.
129    ///
130    /// # Errors
131    ///
132    /// Returns an error if the HTTP request fails, the API returns an error status code,
133    /// or the response cannot be parsed as JSON.
134    pub async fn get_ledger_info(&self) -> AptosResult<AptosResponse<LedgerInfo>> {
135        let url = self.build_url("");
136        self.get_json(url).await
137    }
138
139    // === Account ===
140
141    /// Gets account information.
142    ///
143    /// # Errors
144    ///
145    /// Returns an error if the HTTP request fails, the API returns an error status code,
146    /// the response cannot be parsed as JSON, or the account is not found (404).
147    pub async fn get_account(
148        &self,
149        address: AccountAddress,
150    ) -> AptosResult<AptosResponse<AccountData>> {
151        let url = self.build_url(&format!("accounts/{address}"));
152        self.get_json(url).await
153    }
154
155    /// Gets the sequence number for an account.
156    ///
157    /// # Errors
158    ///
159    /// Returns an error if fetching the account fails, the account is not found (404),
160    /// or the sequence number cannot be parsed from the account data.
161    pub async fn get_sequence_number(&self, address: AccountAddress) -> AptosResult<u64> {
162        let account = self.get_account(address).await?;
163        account
164            .data
165            .sequence_number()
166            .map_err(|e| AptosError::Internal(format!("failed to parse sequence number: {e}")))
167    }
168
169    /// Gets all resources for an account.
170    ///
171    /// # Errors
172    ///
173    /// Returns an error if the HTTP request fails, the API returns an error status code,
174    /// or the response cannot be parsed as JSON.
175    pub async fn get_account_resources(
176        &self,
177        address: AccountAddress,
178    ) -> AptosResult<AptosResponse<Vec<Resource>>> {
179        let url = self.build_url(&format!("accounts/{address}/resources"));
180        self.get_json(url).await
181    }
182
183    /// Gets a specific resource for an account.
184    ///
185    /// # Errors
186    ///
187    /// Returns an error if the HTTP request fails, the API returns an error status code,
188    /// the response cannot be parsed as JSON, or the resource is not found (404).
189    pub async fn get_account_resource(
190        &self,
191        address: AccountAddress,
192        resource_type: &str,
193    ) -> AptosResult<AptosResponse<Resource>> {
194        let url = self.build_url(&format!(
195            "accounts/{}/resource/{}",
196            address,
197            urlencoding::encode(resource_type)
198        ));
199        self.get_json(url).await
200    }
201
202    /// Gets all modules for an account.
203    ///
204    /// # Errors
205    ///
206    /// Returns an error if the HTTP request fails, the API returns an error status code,
207    /// or the response cannot be parsed as JSON.
208    pub async fn get_account_modules(
209        &self,
210        address: AccountAddress,
211    ) -> AptosResult<AptosResponse<Vec<MoveModule>>> {
212        let url = self.build_url(&format!("accounts/{address}/modules"));
213        self.get_json(url).await
214    }
215
216    /// Gets a specific module for an account.
217    ///
218    /// # Errors
219    ///
220    /// Returns an error if the HTTP request fails, the API returns an error status code,
221    /// the response cannot be parsed as JSON, or the module is not found (404).
222    pub async fn get_account_module(
223        &self,
224        address: AccountAddress,
225        module_name: &str,
226    ) -> AptosResult<AptosResponse<MoveModule>> {
227        let url = self.build_url(&format!("accounts/{address}/module/{module_name}"));
228        self.get_json(url).await
229    }
230
231    // === Balance ===
232
233    /// Gets the APT balance for an account in octas.
234    ///
235    /// # Errors
236    ///
237    /// Returns an error if the view function call fails, the response cannot be parsed,
238    /// or the balance value cannot be converted to u64.
239    pub async fn get_account_balance(&self, address: AccountAddress) -> AptosResult<u64> {
240        // Use the coin::balance view function which works with both legacy CoinStore
241        // and the newer Fungible Asset standard
242        let result = self
243            .view(
244                "0x1::coin::balance",
245                vec!["0x1::aptos_coin::AptosCoin".to_string()],
246                vec![serde_json::json!(address.to_string())],
247            )
248            .await?;
249
250        // The view function returns an array with a single string value
251        let balance_str = result
252            .data
253            .first()
254            .and_then(|v| v.as_str())
255            .ok_or_else(|| AptosError::Internal("failed to parse balance response".into()))?;
256
257        balance_str
258            .parse()
259            .map_err(|_| AptosError::Internal("failed to parse balance as u64".into()))
260    }
261
262    // === Transactions ===
263
264    /// Submits a signed transaction.
265    ///
266    /// Note: Transaction submission is automatically retried for transient errors.
267    /// Duplicate transaction submissions (same hash) are safe and idempotent.
268    ///
269    /// # Errors
270    ///
271    /// Returns an error if the transaction cannot be serialized to BCS, the HTTP request fails,
272    /// the API returns an error status code, or the response cannot be parsed as JSON.
273    pub async fn submit_transaction(
274        &self,
275        signed_txn: &SignedTransaction,
276    ) -> AptosResult<AptosResponse<PendingTransaction>> {
277        let url = self.build_url("transactions");
278        let bcs_bytes = signed_txn.to_bcs()?;
279        let client = self.client.clone();
280        let retry_config = self.retry_config.clone();
281        let max_response_size = self.config.pool_config().max_response_size;
282
283        let executor = RetryExecutor::from_shared(retry_config);
284        executor
285            .execute(|| {
286                let client = client.clone();
287                let url = url.clone();
288                let bcs_bytes = bcs_bytes.clone();
289                async move {
290                    let response = client
291                        .post(url)
292                        .header(CONTENT_TYPE, BCS_CONTENT_TYPE)
293                        .header(ACCEPT, JSON_CONTENT_TYPE)
294                        .body(bcs_bytes)
295                        .send()
296                        .await?;
297
298                    Self::handle_response_static(response, max_response_size).await
299                }
300            })
301            .await
302    }
303
304    /// Submits a transaction and waits for it to be committed.
305    ///
306    /// # Errors
307    ///
308    /// Returns an error if transaction submission fails, the transaction times out waiting
309    /// for commitment, the transaction execution fails, or any HTTP/API errors occur.
310    pub async fn submit_and_wait(
311        &self,
312        signed_txn: &SignedTransaction,
313        timeout: Option<Duration>,
314    ) -> AptosResult<AptosResponse<serde_json::Value>> {
315        let pending = self.submit_transaction(signed_txn).await?;
316        self.wait_for_transaction(&pending.data.hash, timeout).await
317    }
318
319    /// Gets a transaction by hash.
320    ///
321    /// # Errors
322    ///
323    /// Returns an error if the HTTP request fails, the API returns an error status code,
324    /// the response cannot be parsed as JSON, or the transaction is not found (404).
325    pub async fn get_transaction_by_hash(
326        &self,
327        hash: &HashValue,
328    ) -> AptosResult<AptosResponse<serde_json::Value>> {
329        let url = self.build_url(&format!("transactions/by_hash/{hash}"));
330        self.get_json(url).await
331    }
332
333    /// Waits for a transaction to be committed.
334    ///
335    /// Uses exponential backoff for polling, starting at 200ms and doubling up to 2s.
336    ///
337    /// # Errors
338    ///
339    /// Returns an error if the transaction times out waiting for commitment, the transaction
340    /// execution fails (`vm_status` indicates failure), or HTTP/API errors occur while polling.
341    pub async fn wait_for_transaction(
342        &self,
343        hash: &HashValue,
344        timeout: Option<Duration>,
345    ) -> AptosResult<AptosResponse<serde_json::Value>> {
346        let timeout = timeout.unwrap_or(Duration::from_secs(DEFAULT_TRANSACTION_WAIT_TIMEOUT_SECS));
347        let start = std::time::Instant::now();
348
349        // Exponential backoff: start at 200ms, double each time, max 2s
350        let initial_interval = Duration::from_millis(200);
351        let max_interval = Duration::from_secs(2);
352        let mut current_interval = initial_interval;
353
354        loop {
355            match self.get_transaction_by_hash(hash).await {
356                Ok(response) => {
357                    // Check if transaction is committed (has version)
358                    if response.data.get("version").is_some() {
359                        // Check success
360                        let success = response
361                            .data
362                            .get("success")
363                            .and_then(serde_json::Value::as_bool);
364                        if success == Some(false) {
365                            let vm_status = response
366                                .data
367                                .get("vm_status")
368                                .and_then(|v| v.as_str())
369                                .unwrap_or("unknown")
370                                .to_string();
371                            return Err(AptosError::ExecutionFailed { vm_status });
372                        }
373                        return Ok(response);
374                    }
375                }
376                Err(AptosError::Api {
377                    status_code: 404, ..
378                }) => {
379                    // Transaction not found yet, continue waiting
380                }
381                Err(e) => return Err(e),
382            }
383
384            if start.elapsed() >= timeout {
385                return Err(AptosError::TransactionTimeout {
386                    hash: hash.to_string(),
387                    timeout_secs: timeout.as_secs(),
388                });
389            }
390
391            tokio::time::sleep(current_interval).await;
392
393            // Exponential backoff with cap
394            current_interval = std::cmp::min(current_interval * 2, max_interval);
395        }
396    }
397
398    /// Simulates a transaction.
399    ///
400    /// # Errors
401    ///
402    /// Returns an error if the transaction cannot be serialized to BCS, the HTTP request fails,
403    /// the API returns an error status code, or the response cannot be parsed as JSON.
404    pub async fn simulate_transaction(
405        &self,
406        signed_txn: &SignedTransaction,
407    ) -> AptosResult<AptosResponse<Vec<serde_json::Value>>> {
408        let url = self.build_url("transactions/simulate");
409        let bcs_bytes = signed_txn.to_bcs()?;
410        let client = self.client.clone();
411        let retry_config = self.retry_config.clone();
412        let max_response_size = self.config.pool_config().max_response_size;
413
414        let executor = RetryExecutor::from_shared(retry_config);
415        executor
416            .execute(|| {
417                let client = client.clone();
418                let url = url.clone();
419                let bcs_bytes = bcs_bytes.clone();
420                async move {
421                    let response = client
422                        .post(url)
423                        .header(CONTENT_TYPE, BCS_CONTENT_TYPE)
424                        .header(ACCEPT, JSON_CONTENT_TYPE)
425                        .body(bcs_bytes)
426                        .send()
427                        .await?;
428
429                    Self::handle_response_static(response, max_response_size).await
430                }
431            })
432            .await
433    }
434
435    // === Gas ===
436
437    /// Gets the current gas estimation.
438    ///
439    /// # Errors
440    ///
441    /// Returns an error if the HTTP request fails, the API returns an error status code,
442    /// or the response cannot be parsed as JSON.
443    pub async fn estimate_gas_price(&self) -> AptosResult<AptosResponse<GasEstimation>> {
444        let url = self.build_url("estimate_gas_price");
445        self.get_json(url).await
446    }
447
448    // === View Functions ===
449
450    /// Calls a view function.
451    ///
452    /// # Errors
453    ///
454    /// Returns an error if the HTTP request fails, the API returns an error status code,
455    /// or the response cannot be parsed as JSON.
456    pub async fn view(
457        &self,
458        function: &str,
459        type_args: Vec<String>,
460        args: Vec<serde_json::Value>,
461    ) -> AptosResult<AptosResponse<Vec<serde_json::Value>>> {
462        let url = self.build_url("view");
463
464        let body = serde_json::json!({
465            "function": function,
466            "type_arguments": type_args,
467            "arguments": args,
468        });
469
470        let client = self.client.clone();
471        let retry_config = self.retry_config.clone();
472        let max_response_size = self.config.pool_config().max_response_size;
473
474        let executor = RetryExecutor::from_shared(retry_config);
475        executor
476            .execute(|| {
477                let client = client.clone();
478                let url = url.clone();
479                let body = body.clone();
480                async move {
481                    let response = client
482                        .post(url)
483                        .header(CONTENT_TYPE, JSON_CONTENT_TYPE)
484                        .header(ACCEPT, JSON_CONTENT_TYPE)
485                        .json(&body)
486                        .send()
487                        .await?;
488
489                    Self::handle_response_static(response, max_response_size).await
490                }
491            })
492            .await
493    }
494
495    /// Calls a view function using BCS encoding for both inputs and outputs.
496    ///
497    /// This method provides lossless serialization by using BCS (Binary Canonical Serialization)
498    /// instead of JSON, which is important for large integers (u128, u256) and other types
499    /// where JSON can lose precision.
500    ///
501    /// # Arguments
502    ///
503    /// * `function` - The fully qualified function name (e.g., `0x1::coin::balance`)
504    /// * `type_args` - Type arguments as strings (e.g., `0x1::aptos_coin::AptosCoin`)
505    /// * `args` - Pre-serialized BCS arguments as byte vectors
506    ///
507    /// # Returns
508    ///
509    /// Returns the raw BCS-encoded response bytes, which can be deserialized
510    /// into the expected return type using `aptos_bcs::from_bytes`.
511    ///
512    /// # Errors
513    ///
514    /// Returns an error if the HTTP request fails, the API returns an error status code,
515    /// or the BCS serialization fails.
516    pub async fn view_bcs(
517        &self,
518        function: &str,
519        type_args: Vec<String>,
520        args: Vec<Vec<u8>>,
521    ) -> AptosResult<AptosResponse<Vec<u8>>> {
522        let url = self.build_url("view");
523
524        // Convert BCS args to hex strings for the JSON request body.
525        // The Aptos API accepts hex-encoded BCS bytes in the arguments array.
526        let hex_args: Vec<serde_json::Value> = args
527            .iter()
528            .map(|bytes| serde_json::json!(const_hex::encode_prefixed(bytes)))
529            .collect();
530
531        let body = serde_json::json!({
532            "function": function,
533            "type_arguments": type_args,
534            "arguments": hex_args,
535        });
536
537        let client = self.client.clone();
538        let retry_config = self.retry_config.clone();
539        let max_response_size = self.config.pool_config().max_response_size;
540
541        let executor = RetryExecutor::from_shared(retry_config);
542        executor
543            .execute(|| {
544                let client = client.clone();
545                let url = url.clone();
546                let body = body.clone();
547                async move {
548                    let response = client
549                        .post(url)
550                        .header(CONTENT_TYPE, JSON_CONTENT_TYPE)
551                        .header(ACCEPT, BCS_VIEW_CONTENT_TYPE)
552                        .json(&body)
553                        .send()
554                        .await?;
555
556                    // Check for errors before reading body
557                    let status = response.status();
558                    if !status.is_success() {
559                        // SECURITY: Bound error body reads to prevent OOM from
560                        // malicious servers sending huge error responses.
561                        let error_bytes =
562                            crate::config::read_response_bounded(response, MAX_ERROR_BODY_SIZE)
563                                .await
564                                .ok();
565                        let error_text = error_bytes
566                            .and_then(|b| String::from_utf8(b).ok())
567                            .unwrap_or_default();
568                        return Err(AptosError::Api {
569                            status_code: status.as_u16(),
570                            message: Self::truncate_error_body(error_text),
571                            error_code: None,
572                            vm_error_code: None,
573                        });
574                    }
575
576                    // SECURITY: Stream body with size limit to prevent OOM
577                    // from malicious responses (including chunked encoding).
578                    let bytes =
579                        crate::config::read_response_bounded(response, max_response_size).await?;
580                    Ok(AptosResponse::new(bytes))
581                }
582            })
583            .await
584    }
585
586    // === Events ===
587
588    /// Gets events by event handle.
589    ///
590    /// # Errors
591    ///
592    /// Returns an error if the HTTP request fails, the API returns an error status code,
593    /// or the response cannot be parsed as JSON.
594    pub async fn get_events_by_event_handle(
595        &self,
596        address: AccountAddress,
597        event_handle_struct: &str,
598        field_name: &str,
599        start: Option<u64>,
600        limit: Option<u64>,
601    ) -> AptosResult<AptosResponse<Vec<serde_json::Value>>> {
602        let mut url = self.build_url(&format!(
603            "accounts/{}/events/{}/{}",
604            address,
605            urlencoding::encode(event_handle_struct),
606            field_name
607        ));
608
609        {
610            let mut query = url.query_pairs_mut();
611            if let Some(start) = start {
612                query.append_pair("start", &start.to_string());
613            }
614            if let Some(limit) = limit {
615                query.append_pair("limit", &limit.to_string());
616            }
617        }
618
619        self.get_json(url).await
620    }
621
622    // === Blocks ===
623
624    /// Gets block by height.
625    ///
626    /// # Errors
627    ///
628    /// Returns an error if the HTTP request fails, the API returns an error status code,
629    /// the response cannot be parsed as JSON, or the block is not found (404).
630    pub async fn get_block_by_height(
631        &self,
632        height: u64,
633        with_transactions: bool,
634    ) -> AptosResult<AptosResponse<serde_json::Value>> {
635        let mut url = self.build_url(&format!("blocks/by_height/{height}"));
636        url.query_pairs_mut()
637            .append_pair("with_transactions", &with_transactions.to_string());
638        self.get_json(url).await
639    }
640
641    /// Gets block by version.
642    ///
643    /// # Errors
644    ///
645    /// Returns an error if the HTTP request fails, the API returns an error status code,
646    /// the response cannot be parsed as JSON, or the block is not found (404).
647    pub async fn get_block_by_version(
648        &self,
649        version: u64,
650        with_transactions: bool,
651    ) -> AptosResult<AptosResponse<serde_json::Value>> {
652        let mut url = self.build_url(&format!("blocks/by_version/{version}"));
653        url.query_pairs_mut()
654            .append_pair("with_transactions", &with_transactions.to_string());
655        self.get_json(url).await
656    }
657
658    // === Helper Methods ===
659
660    fn build_url(&self, path: &str) -> Url {
661        let mut url = self.config.fullnode_url().clone();
662        if !path.is_empty() {
663            // Avoid format! allocations by building the path string manually
664            let base_path = url.path();
665            let needs_slash = !base_path.ends_with('/');
666            let new_len = base_path.len() + path.len() + usize::from(needs_slash);
667            let mut new_path = String::with_capacity(new_len);
668            new_path.push_str(base_path);
669            if needs_slash {
670                new_path.push('/');
671            }
672            new_path.push_str(path);
673            url.set_path(&new_path);
674        }
675        url
676    }
677
678    async fn get_json<T: for<'de> serde::Deserialize<'de>>(
679        &self,
680        url: Url,
681    ) -> AptosResult<AptosResponse<T>> {
682        let client = self.client.clone();
683        let url_clone = url.clone();
684        let retry_config = self.retry_config.clone();
685        let max_response_size = self.config.pool_config().max_response_size;
686
687        let executor = RetryExecutor::from_shared(retry_config);
688        executor
689            .execute(|| {
690                let client = client.clone();
691                let url = url_clone.clone();
692                async move {
693                    let response = client
694                        .get(url)
695                        .header(ACCEPT, JSON_CONTENT_TYPE)
696                        .send()
697                        .await?;
698
699                    Self::handle_response_static(response, max_response_size).await
700                }
701            })
702            .await
703    }
704
705    /// Truncates a string to the maximum error body size.
706    ///
707    /// # Security
708    ///
709    /// Prevents storing extremely large error messages from malicious servers.
710    fn truncate_error_body(body: String) -> String {
711        if body.len() > MAX_ERROR_BODY_SIZE {
712            // Find the last valid UTF-8 char boundary at or before the limit
713            let mut end = MAX_ERROR_BODY_SIZE;
714            while end > 0 && !body.is_char_boundary(end) {
715                end -= 1;
716            }
717            format!(
718                "{}... [truncated, total: {} bytes]",
719                &body[..end],
720                body.len()
721            )
722        } else {
723            body
724        }
725    }
726
727    /// Handles an HTTP response without retry (for internal use).
728    ///
729    /// # Security
730    ///
731    /// This method enforces `max_response_size` on the actual response body,
732    /// not just the Content-Length header, to prevent memory exhaustion even
733    /// when the server uses chunked transfer encoding.
734    async fn handle_response_static<T: for<'de> serde::Deserialize<'de>>(
735        response: reqwest::Response,
736        max_response_size: usize,
737    ) -> AptosResult<AptosResponse<T>> {
738        let status = response.status();
739
740        // Extract headers before consuming response body
741        let ledger_version = response
742            .headers()
743            .get("x-aptos-ledger-version")
744            .and_then(|v| v.to_str().ok())
745            .and_then(|v| v.parse().ok());
746        let ledger_timestamp = response
747            .headers()
748            .get("x-aptos-ledger-timestamp")
749            .and_then(|v| v.to_str().ok())
750            .and_then(|v| v.parse().ok());
751        let epoch = response
752            .headers()
753            .get("x-aptos-epoch")
754            .and_then(|v| v.to_str().ok())
755            .and_then(|v| v.parse().ok());
756        let block_height = response
757            .headers()
758            .get("x-aptos-block-height")
759            .and_then(|v| v.to_str().ok())
760            .and_then(|v| v.parse().ok());
761        let oldest_ledger_version = response
762            .headers()
763            .get("x-aptos-oldest-ledger-version")
764            .and_then(|v| v.to_str().ok())
765            .and_then(|v| v.parse().ok());
766        let cursor = response
767            .headers()
768            .get("x-aptos-cursor")
769            .and_then(|v| v.to_str().ok())
770            .map(ToString::to_string);
771
772        // Extract Retry-After header for rate limiting (before consuming body)
773        let retry_after_secs = response
774            .headers()
775            .get("retry-after")
776            .and_then(|v| v.to_str().ok())
777            .and_then(|v| v.parse().ok());
778
779        if status.is_success() {
780            // SECURITY: Stream body with size limit to prevent OOM
781            // from malicious responses (including chunked encoding).
782            let bytes = crate::config::read_response_bounded(response, max_response_size).await?;
783            let data: T = serde_json::from_slice(&bytes)?;
784            Ok(AptosResponse {
785                data,
786                ledger_version,
787                ledger_timestamp,
788                epoch,
789                block_height,
790                oldest_ledger_version,
791                cursor,
792            })
793        } else if status.as_u16() == 429 {
794            // SECURITY: Return specific RateLimited error with Retry-After info
795            // This allows callers to respect the server's rate limiting
796            Err(AptosError::RateLimited { retry_after_secs })
797        } else {
798            // SECURITY: Bound error body reads to prevent OOM from malicious
799            // servers sending huge error responses (including chunked encoding).
800            let error_bytes = crate::config::read_response_bounded(response, MAX_ERROR_BODY_SIZE)
801                .await
802                .ok();
803            let error_text = error_bytes
804                .and_then(|b| String::from_utf8(b).ok())
805                .unwrap_or_default();
806            let error_text = Self::truncate_error_body(error_text);
807            let body: serde_json::Value = serde_json::from_str(&error_text).unwrap_or_default();
808            let message = body
809                .get("message")
810                .and_then(|v| v.as_str())
811                .unwrap_or("Unknown error")
812                .to_string();
813            let error_code = body
814                .get("error_code")
815                .and_then(|v| v.as_str())
816                .map(ToString::to_string);
817            let vm_error_code = body
818                .get("vm_error_code")
819                .and_then(serde_json::Value::as_u64);
820
821            Err(AptosError::api_with_details(
822                status.as_u16(),
823                message,
824                error_code,
825                vm_error_code,
826            ))
827        }
828    }
829
830    /// Legacy `handle_response` - delegates to static version.
831    #[allow(dead_code)]
832    async fn handle_response<T: for<'de> serde::Deserialize<'de>>(
833        &self,
834        response: reqwest::Response,
835    ) -> AptosResult<AptosResponse<T>> {
836        let max_response_size = self.config.pool_config().max_response_size;
837        Self::handle_response_static(response, max_response_size).await
838    }
839}
840
841#[cfg(test)]
842mod tests {
843    use super::*;
844    use wiremock::{
845        Mock, MockServer, ResponseTemplate,
846        matchers::{method, path, path_regex},
847    };
848
849    #[test]
850    fn test_build_url() {
851        let client = FullnodeClient::new(AptosConfig::testnet()).unwrap();
852        let url = client.build_url("accounts/0x1");
853        assert!(url.as_str().contains("accounts/0x1"));
854    }
855
856    fn create_mock_client(server: &MockServer) -> FullnodeClient {
857        // The mock server URL needs to include /v1 since that's part of the base URL
858        let url = format!("{}/v1", server.uri());
859        let config = AptosConfig::custom(&url).unwrap().without_retry();
860        FullnodeClient::new(config).unwrap()
861    }
862
863    #[tokio::test]
864    async fn test_get_ledger_info() {
865        let server = MockServer::start().await;
866
867        Mock::given(method("GET"))
868            .and(path("/v1"))
869            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
870                "chain_id": 2,
871                "epoch": "100",
872                "ledger_version": "12345",
873                "oldest_ledger_version": "0",
874                "ledger_timestamp": "1000000",
875                "node_role": "full_node",
876                "oldest_block_height": "0",
877                "block_height": "5000"
878            })))
879            .expect(1)
880            .mount(&server)
881            .await;
882
883        let client = create_mock_client(&server);
884        let result = client.get_ledger_info().await.unwrap();
885
886        assert_eq!(result.data.chain_id, 2);
887        assert_eq!(result.data.version().unwrap(), 12345);
888        assert_eq!(result.data.height().unwrap(), 5000);
889    }
890
891    #[tokio::test]
892    async fn test_get_account() {
893        let server = MockServer::start().await;
894
895        Mock::given(method("GET"))
896            .and(path_regex(r"^/v1/accounts/0x[0-9a-f]+$"))
897            .respond_with(
898                ResponseTemplate::new(200)
899                    .set_body_json(serde_json::json!({
900                        "sequence_number": "42",
901                        "authentication_key": "0x0000000000000000000000000000000000000000000000000000000000000001"
902                    }))
903                    .insert_header("x-aptos-ledger-version", "12345"),
904            )
905            .expect(1)
906            .mount(&server)
907            .await;
908
909        let client = create_mock_client(&server);
910        let result = client.get_account(AccountAddress::ONE).await.unwrap();
911
912        assert_eq!(result.data.sequence_number().unwrap(), 42);
913        assert_eq!(result.ledger_version, Some(12345));
914    }
915
916    #[tokio::test]
917    async fn test_get_account_not_found() {
918        let server = MockServer::start().await;
919
920        Mock::given(method("GET"))
921            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+"))
922            .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
923                "message": "Account not found",
924                "error_code": "account_not_found"
925            })))
926            .expect(1)
927            .mount(&server)
928            .await;
929
930        let client = create_mock_client(&server);
931        let result = client.get_account(AccountAddress::ONE).await;
932
933        assert!(result.is_err());
934        let err = result.unwrap_err();
935        assert!(err.is_not_found());
936    }
937
938    #[tokio::test]
939    async fn test_get_account_resources() {
940        let server = MockServer::start().await;
941
942        Mock::given(method("GET"))
943            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+/resources"))
944            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
945                {
946                    "type": "0x1::account::Account",
947                    "data": {"sequence_number": "10"}
948                },
949                {
950                    "type": "0x1::coin::CoinStore<0x1::aptos_coin::AptosCoin>",
951                    "data": {"coin": {"value": "1000000"}}
952                }
953            ])))
954            .expect(1)
955            .mount(&server)
956            .await;
957
958        let client = create_mock_client(&server);
959        let result = client
960            .get_account_resources(AccountAddress::ONE)
961            .await
962            .unwrap();
963
964        assert_eq!(result.data.len(), 2);
965        assert!(result.data[0].typ.contains("Account"));
966    }
967
968    #[tokio::test]
969    async fn test_get_account_resource() {
970        let server = MockServer::start().await;
971
972        Mock::given(method("GET"))
973            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+/resource/.*"))
974            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
975                "type": "0x1::coin::CoinStore<0x1::aptos_coin::AptosCoin>",
976                "data": {"coin": {"value": "5000000"}}
977            })))
978            .expect(1)
979            .mount(&server)
980            .await;
981
982        let client = create_mock_client(&server);
983        let result = client
984            .get_account_resource(
985                AccountAddress::ONE,
986                "0x1::coin::CoinStore<0x1::aptos_coin::AptosCoin>",
987            )
988            .await
989            .unwrap();
990
991        assert!(result.data.typ.contains("CoinStore"));
992    }
993
994    #[tokio::test]
995    async fn test_get_account_modules() {
996        let server = MockServer::start().await;
997
998        Mock::given(method("GET"))
999            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+/modules"))
1000            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
1001                {
1002                    "bytecode": "0xabc123",
1003                    "abi": {
1004                        "address": "0x1",
1005                        "name": "coin",
1006                        "exposed_functions": [],
1007                        "structs": []
1008                    }
1009                }
1010            ])))
1011            .expect(1)
1012            .mount(&server)
1013            .await;
1014
1015        let client = create_mock_client(&server);
1016        let result = client
1017            .get_account_modules(AccountAddress::ONE)
1018            .await
1019            .unwrap();
1020
1021        assert_eq!(result.data.len(), 1);
1022        assert!(result.data[0].abi.is_some());
1023    }
1024
1025    #[tokio::test]
1026    async fn test_estimate_gas_price() {
1027        let server = MockServer::start().await;
1028
1029        Mock::given(method("GET"))
1030            .and(path("/v1/estimate_gas_price"))
1031            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1032                "deprioritized_gas_estimate": 50,
1033                "gas_estimate": 100,
1034                "prioritized_gas_estimate": 150
1035            })))
1036            .expect(1)
1037            .mount(&server)
1038            .await;
1039
1040        let client = create_mock_client(&server);
1041        let result = client.estimate_gas_price().await.unwrap();
1042
1043        assert_eq!(result.data.gas_estimate, 100);
1044        assert_eq!(result.data.low(), 50);
1045        assert_eq!(result.data.high(), 150);
1046    }
1047
1048    #[tokio::test]
1049    async fn test_get_transaction_by_hash() {
1050        let server = MockServer::start().await;
1051
1052        Mock::given(method("GET"))
1053            .and(path_regex(r"/v1/transactions/by_hash/0x[0-9a-f]+"))
1054            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1055                "version": "12345",
1056                "hash": "0x0000000000000000000000000000000000000000000000000000000000000001",
1057                "success": true,
1058                "vm_status": "Executed successfully"
1059            })))
1060            .expect(1)
1061            .mount(&server)
1062            .await;
1063
1064        let client = create_mock_client(&server);
1065        let hash = HashValue::from_hex(
1066            "0x0000000000000000000000000000000000000000000000000000000000000001",
1067        )
1068        .unwrap();
1069        let result = client.get_transaction_by_hash(&hash).await.unwrap();
1070
1071        assert!(
1072            result
1073                .data
1074                .get("success")
1075                .and_then(serde_json::Value::as_bool)
1076                .unwrap()
1077        );
1078    }
1079
1080    #[tokio::test]
1081    async fn test_wait_for_transaction_success() {
1082        let server = MockServer::start().await;
1083
1084        Mock::given(method("GET"))
1085            .and(path_regex(r"/v1/transactions/by_hash/0x[0-9a-f]+"))
1086            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1087                "type": "user_transaction",
1088                "version": "12345",
1089                "hash": "0x0000000000000000000000000000000000000000000000000000000000000001",
1090                "success": true,
1091                "vm_status": "Executed successfully"
1092            })))
1093            .expect(1..)
1094            .mount(&server)
1095            .await;
1096
1097        let client = create_mock_client(&server);
1098        let hash = HashValue::from_hex(
1099            "0x0000000000000000000000000000000000000000000000000000000000000001",
1100        )
1101        .unwrap();
1102        let result = client
1103            .wait_for_transaction(&hash, Some(Duration::from_secs(5)))
1104            .await
1105            .unwrap();
1106
1107        assert!(
1108            result
1109                .data
1110                .get("success")
1111                .and_then(serde_json::Value::as_bool)
1112                .unwrap()
1113        );
1114    }
1115
1116    #[tokio::test]
1117    async fn test_server_error_retryable() {
1118        let server = MockServer::start().await;
1119
1120        Mock::given(method("GET"))
1121            .and(path("/v1"))
1122            .respond_with(ResponseTemplate::new(503).set_body_json(serde_json::json!({
1123                "message": "Service temporarily unavailable"
1124            })))
1125            .expect(1)
1126            .mount(&server)
1127            .await;
1128
1129        let url = format!("{}/v1", server.uri());
1130        let config = AptosConfig::custom(&url).unwrap().without_retry();
1131        let client = FullnodeClient::new(config).unwrap();
1132        let result = client.get_ledger_info().await;
1133
1134        assert!(result.is_err());
1135        assert!(result.unwrap_err().is_retryable());
1136    }
1137
1138    #[tokio::test]
1139    async fn test_rate_limited() {
1140        let server = MockServer::start().await;
1141
1142        Mock::given(method("GET"))
1143            .and(path("/v1"))
1144            .respond_with(
1145                ResponseTemplate::new(429)
1146                    .set_body_json(serde_json::json!({
1147                        "message": "Rate limited"
1148                    }))
1149                    .insert_header("retry-after", "30"),
1150            )
1151            .expect(1)
1152            .mount(&server)
1153            .await;
1154
1155        let url = format!("{}/v1", server.uri());
1156        let config = AptosConfig::custom(&url).unwrap().without_retry();
1157        let client = FullnodeClient::new(config).unwrap();
1158        let result = client.get_ledger_info().await;
1159
1160        assert!(result.is_err());
1161        assert!(result.unwrap_err().is_retryable());
1162    }
1163
1164    #[tokio::test]
1165    async fn test_get_block_by_height() {
1166        let server = MockServer::start().await;
1167
1168        Mock::given(method("GET"))
1169            .and(path_regex(r"/v1/blocks/by_height/\d+"))
1170            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1171                "block_height": "1000",
1172                "block_hash": "0xabc",
1173                "block_timestamp": "1234567890",
1174                "first_version": "100",
1175                "last_version": "200"
1176            })))
1177            .expect(1)
1178            .mount(&server)
1179            .await;
1180
1181        let client = create_mock_client(&server);
1182        let result = client.get_block_by_height(1000, false).await.unwrap();
1183
1184        assert!(result.data.get("block_height").is_some());
1185    }
1186
1187    #[tokio::test]
1188    async fn test_view() {
1189        let server = MockServer::start().await;
1190
1191        Mock::given(method("POST"))
1192            .and(path("/v1/view"))
1193            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!(["1000000"])))
1194            .expect(1)
1195            .mount(&server)
1196            .await;
1197
1198        let client = create_mock_client(&server);
1199        let result: AptosResponse<Vec<serde_json::Value>> = client
1200            .view(
1201                "0x1::coin::balance",
1202                vec!["0x1::aptos_coin::AptosCoin".to_string()],
1203                vec![serde_json::json!("0x1")],
1204            )
1205            .await
1206            .unwrap();
1207
1208        assert_eq!(result.data.len(), 1);
1209    }
1210}