Skip to main content

movement_sdk/api/
fullnode.rs

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