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