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::simulation::SimulateQueryOptions;
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
18const BCS_CONTENT_TYPE: &str = "application/x.aptos.signed_transaction+bcs";
19const BCS_VIEW_CONTENT_TYPE: &str = "application/x-bcs";
20const JSON_CONTENT_TYPE: &str = "application/json";
21/// Default timeout for waiting for a transaction to be committed.
22const DEFAULT_TRANSACTION_WAIT_TIMEOUT_SECS: u64 = 30;
23/// Maximum size for error response bodies (8 KB).
24///
25/// # Security
26///
27/// This prevents memory exhaustion from malicious servers sending extremely
28/// large error response bodies.
29const MAX_ERROR_BODY_SIZE: usize = 8 * 1024;
30
31/// Client for the Aptos fullnode REST API.
32///
33/// The client supports automatic retry with exponential backoff for transient
34/// failures. Configure retry behavior via [`AptosConfig::with_retry`].
35///
36/// # Example
37///
38/// ```rust,no_run
39/// use aptos_sdk::api::FullnodeClient;
40/// use aptos_sdk::config::AptosConfig;
41/// use aptos_sdk::retry::RetryConfig;
42///
43/// #[tokio::main]
44/// async fn main() -> anyhow::Result<()> {
45///     // Default retry configuration
46///     let client = FullnodeClient::new(AptosConfig::testnet())?;
47///     
48///     // Aggressive retry for unstable networks
49///     let client = FullnodeClient::new(
50///         AptosConfig::testnet().with_retry(RetryConfig::aggressive())
51///     )?;
52///     
53///     // Disable retry for debugging
54///     let client = FullnodeClient::new(
55///         AptosConfig::testnet().without_retry()
56///     )?;
57///     
58///     let ledger_info = client.get_ledger_info().await?;
59///     println!("Ledger version: {:?}", ledger_info.data.version());
60///     Ok(())
61/// }
62/// ```
63#[derive(Debug, Clone)]
64pub struct FullnodeClient {
65    config: AptosConfig,
66    client: Client,
67    retry_config: Arc<RetryConfig>,
68}
69
70impl FullnodeClient {
71    /// Creates a new fullnode client.
72    ///
73    /// # TLS Security
74    ///
75    /// This client uses `reqwest` with its default TLS configuration, which:
76    /// - Validates server certificates against the system's certificate store
77    /// - Requires valid TLS certificates for HTTPS connections
78    /// - Uses secure TLS versions (TLS 1.2+)
79    ///
80    /// All Aptos network endpoints (mainnet, testnet, devnet) use HTTPS with
81    /// valid certificates. The local configuration uses HTTP for development.
82    ///
83    /// For custom deployments requiring custom CA certificates, use the
84    /// `REQUESTS_CA_BUNDLE` or `SSL_CERT_FILE` environment variables, or
85    /// configure a custom `reqwest::Client` and use `from_client()`.
86    ///
87    /// # Errors
88    ///
89    /// Returns an error if the HTTP client fails to build (e.g., invalid TLS configuration).
90    pub fn new(config: AptosConfig) -> AptosResult<Self> {
91        let pool = config.pool_config();
92
93        // SECURITY: TLS certificate validation is enabled by default via reqwest.
94        // The client will reject connections to servers with invalid certificates.
95        // All production Aptos endpoints use HTTPS with valid certificates.
96        let mut builder = Client::builder()
97            .timeout(config.timeout)
98            .pool_max_idle_per_host(pool.max_idle_per_host.unwrap_or(usize::MAX))
99            .pool_idle_timeout(pool.idle_timeout)
100            .tcp_nodelay(pool.tcp_nodelay);
101
102        if let Some(keepalive) = pool.tcp_keepalive {
103            builder = builder.tcp_keepalive(keepalive);
104        }
105
106        let client = builder.build().map_err(AptosError::Http)?;
107
108        let retry_config = Arc::new(config.retry_config().clone());
109
110        Ok(Self {
111            config,
112            client,
113            retry_config,
114        })
115    }
116
117    /// Returns the base URL for the fullnode.
118    pub fn base_url(&self) -> &Url {
119        self.config.fullnode_url()
120    }
121
122    /// Returns the retry configuration.
123    pub fn retry_config(&self) -> &RetryConfig {
124        &self.retry_config
125    }
126
127    // === Ledger Info ===
128
129    /// Gets the current ledger information.
130    ///
131    /// # Errors
132    ///
133    /// Returns an error if the HTTP request fails, the API returns an error status code,
134    /// or the response cannot be parsed as JSON.
135    pub async fn get_ledger_info(&self) -> AptosResult<AptosResponse<LedgerInfo>> {
136        let url = self.build_url("");
137        self.get_json(url).await
138    }
139
140    // === Account ===
141
142    /// Gets account information.
143    ///
144    /// # Errors
145    ///
146    /// Returns an error if the HTTP request fails, the API returns an error status code,
147    /// the response cannot be parsed as JSON, or the account is not found (404).
148    pub async fn get_account(
149        &self,
150        address: AccountAddress,
151    ) -> AptosResult<AptosResponse<AccountData>> {
152        let url = self.build_url(&format!("accounts/{address}"));
153        self.get_json(url).await
154    }
155
156    /// Gets the sequence number for an account.
157    ///
158    /// # Errors
159    ///
160    /// Returns an error if fetching the account fails, the account is not found (404),
161    /// or the sequence number cannot be parsed from the account data.
162    pub async fn get_sequence_number(&self, address: AccountAddress) -> AptosResult<u64> {
163        let account = self.get_account(address).await?;
164        account
165            .data
166            .sequence_number()
167            .map_err(|e| AptosError::Internal(format!("failed to parse sequence number: {e}")))
168    }
169
170    /// Gets all resources for an account in a single page (uses the
171    /// fullnode's default page size; large accounts may be truncated).
172    ///
173    /// For paginated access on accounts that hold many resources, use
174    /// [`get_account_resources_paginated`](Self::get_account_resources_paginated).
175    ///
176    /// # Errors
177    ///
178    /// Returns an error if the HTTP request fails, the API returns an error status code,
179    /// or the response cannot be parsed as JSON.
180    pub async fn get_account_resources(
181        &self,
182        address: AccountAddress,
183    ) -> AptosResult<AptosResponse<Vec<Resource>>> {
184        self.get_account_resources_paginated(address, None, None)
185            .await
186    }
187
188    /// Gets resources for an account with explicit pagination cursors.
189    ///
190    /// * `start` -- opaque cursor token returned by the previous page in
191    ///   the `x-aptos-cursor` header (and surfaced as
192    ///   [`AptosResponse::cursor`](super::response::AptosResponse#field.cursor)).
193    ///   The type is `Option<&str>` rather than `Option<u64>` so opaque
194    ///   non-numeric cursors round-trip losslessly. Pass `None` for the
195    ///   first page; for subsequent pages forward
196    ///   `previous_response.cursor.as_deref()`.
197    /// * `limit` -- maximum number of resources to return on this page.
198    ///   The fullnode caps this server-side; callers should not assume
199    ///   their requested limit is honored verbatim.
200    ///
201    /// Matches the TypeScript SDK's `getAccountResources({ start, limit })`.
202    ///
203    /// # Errors
204    ///
205    /// Returns an error if the HTTP request fails, the API returns an error status code,
206    /// or the response cannot be parsed as JSON.
207    pub async fn get_account_resources_paginated(
208        &self,
209        address: AccountAddress,
210        start: Option<&str>,
211        limit: Option<u16>,
212    ) -> AptosResult<AptosResponse<Vec<Resource>>> {
213        let mut url = self.build_url(&format!("accounts/{address}/resources"));
214        append_start_limit(&mut url, start, limit);
215        self.get_json(url).await
216    }
217
218    /// Gets a specific resource 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 resource is not found (404).
224    pub async fn get_account_resource(
225        &self,
226        address: AccountAddress,
227        resource_type: &str,
228    ) -> AptosResult<AptosResponse<Resource>> {
229        let url = self.build_url(&format!(
230            "accounts/{}/resource/{}",
231            address,
232            urlencoding::encode(resource_type)
233        ));
234        self.get_json(url).await
235    }
236
237    /// Gets all modules for an account in a single page (uses the
238    /// fullnode's default page size; accounts that publish many modules
239    /// may be truncated).
240    ///
241    /// For paginated access, use
242    /// [`get_account_modules_paginated`](Self::get_account_modules_paginated).
243    ///
244    /// # Errors
245    ///
246    /// Returns an error if the HTTP request fails, the API returns an error status code,
247    /// or the response cannot be parsed as JSON.
248    pub async fn get_account_modules(
249        &self,
250        address: AccountAddress,
251    ) -> AptosResult<AptosResponse<Vec<MoveModule>>> {
252        self.get_account_modules_paginated(address, None, None)
253            .await
254    }
255
256    /// Gets modules for an account with explicit pagination cursors.
257    ///
258    /// See [`get_account_resources_paginated`](Self::get_account_resources_paginated)
259    /// for `start` / `limit` semantics; they are interpreted the same way
260    /// by the fullnode.
261    ///
262    /// # Errors
263    ///
264    /// Returns an error if the HTTP request fails, the API returns an error status code,
265    /// or the response cannot be parsed as JSON.
266    pub async fn get_account_modules_paginated(
267        &self,
268        address: AccountAddress,
269        start: Option<&str>,
270        limit: Option<u16>,
271    ) -> AptosResult<AptosResponse<Vec<MoveModule>>> {
272        let mut url = self.build_url(&format!("accounts/{address}/modules"));
273        append_start_limit(&mut url, start, limit);
274        self.get_json(url).await
275    }
276
277    /// Gets a specific module for an account.
278    ///
279    /// # Errors
280    ///
281    /// Returns an error if the HTTP request fails, the API returns an error status code,
282    /// the response cannot be parsed as JSON, or the module is not found (404).
283    pub async fn get_account_module(
284        &self,
285        address: AccountAddress,
286        module_name: &str,
287    ) -> AptosResult<AptosResponse<MoveModule>> {
288        let url = self.build_url(&format!("accounts/{address}/module/{module_name}"));
289        self.get_json(url).await
290    }
291
292    // === Balance ===
293
294    /// Gets the APT balance for an account in octas.
295    ///
296    /// # Errors
297    ///
298    /// Returns an error if the view function call fails, the response cannot be parsed,
299    /// or the balance value cannot be converted to u64.
300    pub async fn get_account_balance(&self, address: AccountAddress) -> AptosResult<u64> {
301        // Use the coin::balance view function which works with both legacy CoinStore
302        // and the newer Fungible Asset standard
303        let result = self
304            .view(
305                "0x1::coin::balance",
306                vec!["0x1::aptos_coin::AptosCoin".to_string()],
307                vec![serde_json::json!(address.to_string())],
308            )
309            .await?;
310
311        // The view function returns an array with a single string value
312        let balance_str = result
313            .data
314            .first()
315            .and_then(|v| v.as_str())
316            .ok_or_else(|| AptosError::Internal("failed to parse balance response".into()))?;
317
318        balance_str
319            .parse()
320            .map_err(|_| AptosError::Internal("failed to parse balance as u64".into()))
321    }
322
323    // === Transactions ===
324
325    /// Submits a signed transaction.
326    ///
327    /// Note: Transaction submission is automatically retried for transient errors.
328    /// Duplicate transaction submissions (same hash) are safe and idempotent.
329    ///
330    /// # Errors
331    ///
332    /// Returns an error if the transaction cannot be serialized to BCS, the HTTP request fails,
333    /// the API returns an error status code, or the response cannot be parsed as JSON.
334    pub async fn submit_transaction(
335        &self,
336        signed_txn: &SignedTransaction,
337    ) -> AptosResult<AptosResponse<PendingTransaction>> {
338        let url = self.build_url("transactions");
339        let bcs_bytes = signed_txn.to_bcs()?;
340        let client = self.client.clone();
341        let retry_config = self.retry_config.clone();
342        let max_response_size = self.config.pool_config().max_response_size;
343
344        let executor = RetryExecutor::from_shared(retry_config);
345        executor
346            .execute(|| {
347                let client = client.clone();
348                let url = url.clone();
349                let bcs_bytes = bcs_bytes.clone();
350                async move {
351                    let response = client
352                        .post(url)
353                        .header(CONTENT_TYPE, BCS_CONTENT_TYPE)
354                        .header(ACCEPT, JSON_CONTENT_TYPE)
355                        .body(bcs_bytes)
356                        .send()
357                        .await?;
358
359                    Self::handle_response_static(response, max_response_size).await
360                }
361            })
362            .await
363    }
364
365    /// Submits a transaction and waits for it to be committed.
366    ///
367    /// # Errors
368    ///
369    /// Returns an error if transaction submission fails, the transaction times out waiting
370    /// for commitment, the transaction execution fails, or any HTTP/API errors occur.
371    pub async fn submit_and_wait(
372        &self,
373        signed_txn: &SignedTransaction,
374        timeout: Option<Duration>,
375    ) -> AptosResult<AptosResponse<serde_json::Value>> {
376        let pending = self.submit_transaction(signed_txn).await?;
377        self.wait_for_transaction(&pending.data.hash, timeout).await
378    }
379
380    /// Gets a transaction by hash.
381    ///
382    /// # Errors
383    ///
384    /// Returns an error if the HTTP request fails, the API returns an error status code,
385    /// the response cannot be parsed as JSON, or the transaction is not found (404).
386    pub async fn get_transaction_by_hash(
387        &self,
388        hash: &HashValue,
389    ) -> AptosResult<AptosResponse<serde_json::Value>> {
390        let url = self.build_url(&format!("transactions/by_hash/{hash}"));
391        self.get_json(url).await
392    }
393
394    /// Waits for a transaction to be committed.
395    ///
396    /// Uses exponential backoff for polling, starting at 200ms and doubling up to 2s.
397    ///
398    /// # Errors
399    ///
400    /// Returns an error if the transaction times out waiting for commitment, the transaction
401    /// execution fails (`vm_status` indicates failure), or HTTP/API errors occur while polling.
402    pub async fn wait_for_transaction(
403        &self,
404        hash: &HashValue,
405        timeout: Option<Duration>,
406    ) -> AptosResult<AptosResponse<serde_json::Value>> {
407        let timeout = timeout.unwrap_or(Duration::from_secs(DEFAULT_TRANSACTION_WAIT_TIMEOUT_SECS));
408        let start = std::time::Instant::now();
409
410        // Exponential backoff: start at 200ms, double each time, max 2s
411        let initial_interval = Duration::from_millis(200);
412        let max_interval = Duration::from_secs(2);
413        let mut current_interval = initial_interval;
414
415        loop {
416            match self.get_transaction_by_hash(hash).await {
417                Ok(response) => {
418                    // Check if transaction is committed (has version)
419                    if response.data.get("version").is_some() {
420                        // Check success
421                        let success = response
422                            .data
423                            .get("success")
424                            .and_then(serde_json::Value::as_bool);
425                        if success == Some(false) {
426                            let vm_status = response
427                                .data
428                                .get("vm_status")
429                                .and_then(|v| v.as_str())
430                                .unwrap_or("unknown")
431                                .to_string();
432                            return Err(AptosError::ExecutionFailed { vm_status });
433                        }
434                        return Ok(response);
435                    }
436                }
437                Err(AptosError::Api {
438                    status_code: 404, ..
439                }) => {
440                    // Transaction not found yet, continue waiting
441                }
442                Err(e) => return Err(e),
443            }
444
445            if start.elapsed() >= timeout {
446                return Err(AptosError::TransactionTimeout {
447                    hash: hash.to_string(),
448                    timeout_secs: timeout.as_secs(),
449                });
450            }
451
452            tokio::time::sleep(current_interval).await;
453
454            // Exponential backoff with cap
455            current_interval = std::cmp::min(current_interval * 2, max_interval);
456        }
457    }
458
459    /// Simulates a transaction.
460    ///
461    /// Delegates to [`simulate_transaction_with_options`](Self::simulate_transaction_with_options) with `None` for options.
462    ///
463    /// The request body is derived from `signed_txn` after rewriting authenticators for the
464    /// simulate endpoint (see [`SignedTransaction::for_simulate_endpoint`]).
465    ///
466    /// # Errors
467    ///
468    /// Returns an error if the transaction cannot be serialized to BCS, the HTTP request fails,
469    /// the API returns an error status code, or the response cannot be parsed as JSON.
470    pub async fn simulate_transaction(
471        &self,
472        signed_txn: &SignedTransaction,
473    ) -> AptosResult<AptosResponse<Vec<serde_json::Value>>> {
474        self.simulate_transaction_with_options(signed_txn, None as Option<SimulateQueryOptions>)
475            .await
476    }
477
478    /// Simulates a transaction with optional query parameters.
479    ///
480    /// Pass [`SimulateQueryOptions`] to request gas estimation behavior
481    /// (e.g. `estimate_gas_unit_price`, `estimate_max_gas_amount`) as query
482    /// parameters to the `/transactions/simulate` endpoint.
483    ///
484    /// Authenticators on `signed_txn` are rewritten client-side (via
485    /// [`SignedTransaction::for_simulate_endpoint`]) before BCS serialization so the
486    /// fullnode never receives a cryptographically valid signature (which it rejects with
487    /// HTTP 400).
488    ///
489    /// # Errors
490    ///
491    /// Returns an error if the transaction cannot be serialized to BCS, the HTTP request fails,
492    /// the API returns an error status code, or the response cannot be parsed as JSON.
493    pub async fn simulate_transaction_with_options(
494        &self,
495        signed_txn: &SignedTransaction,
496        options: impl Into<Option<SimulateQueryOptions>>,
497    ) -> AptosResult<AptosResponse<Vec<serde_json::Value>>> {
498        let mut url = self.build_url("transactions/simulate");
499        if let Some(opts) = options.into() {
500            let mut pairs = url.query_pairs_mut();
501            if opts.estimate_gas_unit_price {
502                pairs.append_pair("estimate_gas_unit_price", "true");
503            }
504            if opts.estimate_max_gas_amount {
505                pairs.append_pair("estimate_max_gas_amount", "true");
506            }
507            if opts.estimate_prioritized_gas_unit_price {
508                pairs.append_pair("estimate_prioritized_gas_unit_price", "true");
509            }
510        }
511        let bcs_bytes = signed_txn.for_simulate_endpoint().to_bcs()?;
512        let client = self.client.clone();
513        let retry_config = self.retry_config.clone();
514        let max_response_size = self.config.pool_config().max_response_size;
515
516        let executor = RetryExecutor::from_shared(retry_config);
517        executor
518            .execute(|| {
519                let client = client.clone();
520                let url = url.clone();
521                let bcs_bytes = bcs_bytes.clone();
522                async move {
523                    let response = client
524                        .post(url)
525                        .header(CONTENT_TYPE, BCS_CONTENT_TYPE)
526                        .header(ACCEPT, JSON_CONTENT_TYPE)
527                        .body(bcs_bytes)
528                        .send()
529                        .await?;
530
531                    Self::handle_response_static(response, max_response_size).await
532                }
533            })
534            .await
535    }
536
537    // === Gas ===
538
539    /// Gets the current gas estimation.
540    ///
541    /// # Errors
542    ///
543    /// Returns an error if the HTTP request fails, the API returns an error status code,
544    /// or the response cannot be parsed as JSON.
545    pub async fn estimate_gas_price(&self) -> AptosResult<AptosResponse<GasEstimation>> {
546        let url = self.build_url("estimate_gas_price");
547        self.get_json(url).await
548    }
549
550    // === View Functions ===
551
552    /// Calls a view function.
553    ///
554    /// # Errors
555    ///
556    /// Returns an error if the HTTP request fails, the API returns an error status code,
557    /// or the response cannot be parsed as JSON.
558    pub async fn view(
559        &self,
560        function: &str,
561        type_args: Vec<String>,
562        args: Vec<serde_json::Value>,
563    ) -> AptosResult<AptosResponse<Vec<serde_json::Value>>> {
564        let url = self.build_url("view");
565
566        let body = serde_json::json!({
567            "function": function,
568            "type_arguments": type_args,
569            "arguments": args,
570        });
571
572        let client = self.client.clone();
573        let retry_config = self.retry_config.clone();
574        let max_response_size = self.config.pool_config().max_response_size;
575
576        let executor = RetryExecutor::from_shared(retry_config);
577        executor
578            .execute(|| {
579                let client = client.clone();
580                let url = url.clone();
581                let body = body.clone();
582                async move {
583                    let response = client
584                        .post(url)
585                        .header(CONTENT_TYPE, JSON_CONTENT_TYPE)
586                        .header(ACCEPT, JSON_CONTENT_TYPE)
587                        .json(&body)
588                        .send()
589                        .await?;
590
591                    Self::handle_response_static(response, max_response_size).await
592                }
593            })
594            .await
595    }
596
597    /// Calls a view function using BCS encoding for both inputs and outputs.
598    ///
599    /// This method provides lossless serialization by using BCS (Binary Canonical Serialization)
600    /// instead of JSON, which is important for large integers (u128, u256) and other types
601    /// where JSON can lose precision.
602    ///
603    /// # Arguments
604    ///
605    /// * `function` - The fully qualified function name (e.g., `0x1::coin::balance`)
606    /// * `type_args` - Type arguments as strings (e.g., `0x1::aptos_coin::AptosCoin`)
607    /// * `args` - Pre-serialized BCS arguments as byte vectors
608    ///
609    /// # Returns
610    ///
611    /// Returns the raw BCS-encoded response bytes, which can be deserialized
612    /// into the expected return type using `aptos_bcs::from_bytes`.
613    ///
614    /// # Errors
615    ///
616    /// Returns an error if the HTTP request fails, the API returns an error status code,
617    /// or the BCS serialization fails.
618    pub async fn view_bcs(
619        &self,
620        function: &str,
621        type_args: Vec<String>,
622        args: Vec<Vec<u8>>,
623    ) -> AptosResult<AptosResponse<Vec<u8>>> {
624        let url = self.build_url("view");
625
626        // Convert BCS args to hex strings for the JSON request body.
627        // The Aptos API accepts hex-encoded BCS bytes in the arguments array.
628        let hex_args: Vec<serde_json::Value> = args
629            .iter()
630            .map(|bytes| serde_json::json!(const_hex::encode_prefixed(bytes)))
631            .collect();
632
633        let body = serde_json::json!({
634            "function": function,
635            "type_arguments": type_args,
636            "arguments": hex_args,
637        });
638
639        let client = self.client.clone();
640        let retry_config = self.retry_config.clone();
641        let max_response_size = self.config.pool_config().max_response_size;
642
643        let executor = RetryExecutor::from_shared(retry_config);
644        executor
645            .execute(|| {
646                let client = client.clone();
647                let url = url.clone();
648                let body = body.clone();
649                async move {
650                    let response = client
651                        .post(url)
652                        .header(CONTENT_TYPE, JSON_CONTENT_TYPE)
653                        .header(ACCEPT, BCS_VIEW_CONTENT_TYPE)
654                        .json(&body)
655                        .send()
656                        .await?;
657
658                    // Check for errors before reading body
659                    let status = response.status();
660                    if !status.is_success() {
661                        // SECURITY: Bound error body reads to prevent OOM from
662                        // malicious servers sending huge error responses.
663                        let error_bytes =
664                            crate::config::read_response_bounded(response, MAX_ERROR_BODY_SIZE)
665                                .await
666                                .ok();
667                        let error_text = error_bytes
668                            .and_then(|b| String::from_utf8(b).ok())
669                            .unwrap_or_default();
670                        return Err(AptosError::Api {
671                            status_code: status.as_u16(),
672                            message: Self::truncate_error_body(error_text),
673                            error_code: None,
674                            vm_error_code: None,
675                        });
676                    }
677
678                    // SECURITY: Stream body with size limit to prevent OOM
679                    // from malicious responses (including chunked encoding).
680                    let bytes =
681                        crate::config::read_response_bounded(response, max_response_size).await?;
682                    Ok(AptosResponse::new(bytes))
683                }
684            })
685            .await
686    }
687
688    // === Events ===
689
690    /// Gets events by event handle.
691    ///
692    /// # Errors
693    ///
694    /// Returns an error if the HTTP request fails, the API returns an error status code,
695    /// or the response cannot be parsed as JSON.
696    pub async fn get_events_by_event_handle(
697        &self,
698        address: AccountAddress,
699        event_handle_struct: &str,
700        field_name: &str,
701        start: Option<u64>,
702        limit: Option<u64>,
703    ) -> AptosResult<AptosResponse<Vec<serde_json::Value>>> {
704        let mut url = self.build_url(&format!(
705            "accounts/{}/events/{}/{}",
706            address,
707            urlencoding::encode(event_handle_struct),
708            field_name
709        ));
710
711        {
712            let mut query = url.query_pairs_mut();
713            if let Some(start) = start {
714                query.append_pair("start", &start.to_string());
715            }
716            if let Some(limit) = limit {
717                query.append_pair("limit", &limit.to_string());
718            }
719        }
720
721        self.get_json(url).await
722    }
723
724    // === Blocks ===
725
726    /// Gets block by height.
727    ///
728    /// # Errors
729    ///
730    /// Returns an error if the HTTP request fails, the API returns an error status code,
731    /// the response cannot be parsed as JSON, or the block is not found (404).
732    pub async fn get_block_by_height(
733        &self,
734        height: u64,
735        with_transactions: bool,
736    ) -> AptosResult<AptosResponse<serde_json::Value>> {
737        let mut url = self.build_url(&format!("blocks/by_height/{height}"));
738        url.query_pairs_mut()
739            .append_pair("with_transactions", &with_transactions.to_string());
740        self.get_json(url).await
741    }
742
743    /// Gets block by version.
744    ///
745    /// # Errors
746    ///
747    /// Returns an error if the HTTP request fails, the API returns an error status code,
748    /// the response cannot be parsed as JSON, or the block is not found (404).
749    pub async fn get_block_by_version(
750        &self,
751        version: u64,
752        with_transactions: bool,
753    ) -> AptosResult<AptosResponse<serde_json::Value>> {
754        let mut url = self.build_url(&format!("blocks/by_version/{version}"));
755        url.query_pairs_mut()
756            .append_pair("with_transactions", &with_transactions.to_string());
757        self.get_json(url).await
758    }
759
760    // === Helper Methods ===
761
762    fn build_url(&self, path: &str) -> Url {
763        let mut url = self.config.fullnode_url().clone();
764        if !path.is_empty() {
765            // Avoid format! allocations by building the path string manually
766            let base_path = url.path();
767            let needs_slash = !base_path.ends_with('/');
768            let new_len = base_path.len() + path.len() + usize::from(needs_slash);
769            let mut new_path = String::with_capacity(new_len);
770            new_path.push_str(base_path);
771            if needs_slash {
772                new_path.push('/');
773            }
774            new_path.push_str(path);
775            url.set_path(&new_path);
776        }
777        url
778    }
779
780    async fn get_json<T: for<'de> serde::Deserialize<'de>>(
781        &self,
782        url: Url,
783    ) -> AptosResult<AptosResponse<T>> {
784        let client = self.client.clone();
785        let url_clone = url.clone();
786        let retry_config = self.retry_config.clone();
787        let max_response_size = self.config.pool_config().max_response_size;
788
789        let executor = RetryExecutor::from_shared(retry_config);
790        executor
791            .execute(|| {
792                let client = client.clone();
793                let url = url_clone.clone();
794                async move {
795                    let response = client
796                        .get(url)
797                        .header(ACCEPT, JSON_CONTENT_TYPE)
798                        .send()
799                        .await?;
800
801                    Self::handle_response_static(response, max_response_size).await
802                }
803            })
804            .await
805    }
806
807    /// Truncates a string to the maximum error body size.
808    ///
809    /// # Security
810    ///
811    /// Prevents storing extremely large error messages from malicious servers.
812    fn truncate_error_body(body: String) -> String {
813        if body.len() > MAX_ERROR_BODY_SIZE {
814            // Find the last valid UTF-8 char boundary at or before the limit
815            let mut end = MAX_ERROR_BODY_SIZE;
816            while end > 0 && !body.is_char_boundary(end) {
817                end -= 1;
818            }
819            format!(
820                "{}... [truncated, total: {} bytes]",
821                &body[..end],
822                body.len()
823            )
824        } else {
825            body
826        }
827    }
828
829    /// Handles an HTTP response without retry (for internal use).
830    ///
831    /// # Security
832    ///
833    /// This method enforces `max_response_size` on the actual response body,
834    /// not just the Content-Length header, to prevent memory exhaustion even
835    /// when the server uses chunked transfer encoding.
836    async fn handle_response_static<T: for<'de> serde::Deserialize<'de>>(
837        response: reqwest::Response,
838        max_response_size: usize,
839    ) -> AptosResult<AptosResponse<T>> {
840        let status = response.status();
841
842        // Extract headers before consuming response body
843        let ledger_version = response
844            .headers()
845            .get("x-aptos-ledger-version")
846            .and_then(|v| v.to_str().ok())
847            .and_then(|v| v.parse().ok());
848        let ledger_timestamp = response
849            .headers()
850            .get("x-aptos-ledger-timestamp")
851            .and_then(|v| v.to_str().ok())
852            .and_then(|v| v.parse().ok());
853        let epoch = response
854            .headers()
855            .get("x-aptos-epoch")
856            .and_then(|v| v.to_str().ok())
857            .and_then(|v| v.parse().ok());
858        let block_height = response
859            .headers()
860            .get("x-aptos-block-height")
861            .and_then(|v| v.to_str().ok())
862            .and_then(|v| v.parse().ok());
863        let oldest_ledger_version = response
864            .headers()
865            .get("x-aptos-oldest-ledger-version")
866            .and_then(|v| v.to_str().ok())
867            .and_then(|v| v.parse().ok());
868        let cursor = response
869            .headers()
870            .get("x-aptos-cursor")
871            .and_then(|v| v.to_str().ok())
872            .map(ToString::to_string);
873
874        // Extract Retry-After header for rate limiting (before consuming body)
875        let retry_after_secs = response
876            .headers()
877            .get("retry-after")
878            .and_then(|v| v.to_str().ok())
879            .and_then(|v| v.parse().ok());
880
881        if status.is_success() {
882            // SECURITY: Stream body with size limit to prevent OOM
883            // from malicious responses (including chunked encoding).
884            let bytes = crate::config::read_response_bounded(response, max_response_size).await?;
885            let data: T = serde_json::from_slice(&bytes)?;
886            Ok(AptosResponse {
887                data,
888                ledger_version,
889                ledger_timestamp,
890                epoch,
891                block_height,
892                oldest_ledger_version,
893                cursor,
894            })
895        } else if status.as_u16() == 429 {
896            // SECURITY: Return specific RateLimited error with Retry-After info
897            // This allows callers to respect the server's rate limiting
898            Err(AptosError::RateLimited { retry_after_secs })
899        } else {
900            // SECURITY: Bound error body reads to prevent OOM from malicious
901            // servers sending huge error responses (including chunked encoding).
902            let error_bytes = crate::config::read_response_bounded(response, MAX_ERROR_BODY_SIZE)
903                .await
904                .ok();
905            let error_text = error_bytes
906                .and_then(|b| String::from_utf8(b).ok())
907                .unwrap_or_default();
908            let error_text = Self::truncate_error_body(error_text);
909            let body: serde_json::Value = serde_json::from_str(&error_text).unwrap_or_default();
910            let message = body
911                .get("message")
912                .and_then(|v| v.as_str())
913                .unwrap_or("Unknown error")
914                .to_string();
915            let error_code = body
916                .get("error_code")
917                .and_then(|v| v.as_str())
918                .map(ToString::to_string);
919            let vm_error_code = body
920                .get("vm_error_code")
921                .and_then(serde_json::Value::as_u64);
922
923            Err(AptosError::api_with_details(
924                status.as_u16(),
925                message,
926                error_code,
927                vm_error_code,
928            ))
929        }
930    }
931
932    /// Legacy `handle_response` - delegates to static version.
933    #[allow(dead_code)]
934    async fn handle_response<T: for<'de> serde::Deserialize<'de>>(
935        &self,
936        response: reqwest::Response,
937    ) -> AptosResult<AptosResponse<T>> {
938        let max_response_size = self.config.pool_config().max_response_size;
939        Self::handle_response_static(response, max_response_size).await
940    }
941}
942
943/// Appends `start` and `limit` query parameters to `url` when present.
944///
945/// Shared by paginated REST endpoints (`/accounts/{addr}/resources`,
946/// `/accounts/{addr}/modules`, ...) so the formatting stays consistent.
947/// `start` is forwarded verbatim as a string so opaque pagination cursors
948/// returned in the `x-aptos-cursor` header round-trip losslessly (the
949/// fullnode does not promise numeric cursors).
950fn append_start_limit(url: &mut Url, start: Option<&str>, limit: Option<u16>) {
951    if start.is_none() && limit.is_none() {
952        return;
953    }
954    let mut query = url.query_pairs_mut();
955    if let Some(start) = start {
956        query.append_pair("start", start);
957    }
958    if let Some(limit) = limit {
959        query.append_pair("limit", &limit.to_string());
960    }
961}
962
963#[cfg(test)]
964mod tests {
965    use super::*;
966    use crate::transaction::authenticator::{
967        Ed25519PublicKey, Ed25519Signature, TransactionAuthenticator,
968    };
969    use crate::transaction::simulation::SimulateQueryOptions;
970    use crate::transaction::types::{RawTransaction, SignedTransaction};
971    use crate::types::ChainId;
972    use wiremock::{
973        Mock, MockServer, ResponseTemplate,
974        matchers::{method, path, path_regex, query_param},
975    };
976
977    #[test]
978    fn test_build_url() {
979        let client = FullnodeClient::new(AptosConfig::testnet()).unwrap();
980        let url = client.build_url("accounts/0x1");
981        assert!(url.as_str().contains("accounts/0x1"));
982    }
983
984    fn create_mock_client(server: &MockServer) -> FullnodeClient {
985        // The mock server URL needs to include /v1 since that's part of the base URL
986        let url = format!("{}/v1", server.uri());
987        let config = AptosConfig::custom(&url).unwrap().without_retry();
988        FullnodeClient::new(config).unwrap()
989    }
990
991    /// Creates a minimal `SignedTransaction` for use in `simulate_transaction` tests.
992    fn create_minimal_signed_transaction() -> SignedTransaction {
993        use crate::transaction::payload::{EntryFunction, TransactionPayload};
994
995        let raw = RawTransaction::new(
996            AccountAddress::ONE,
997            0,
998            TransactionPayload::EntryFunction(
999                EntryFunction::apt_transfer(AccountAddress::ONE, 0).unwrap(),
1000            ),
1001            100_000,
1002            100,
1003            std::time::SystemTime::now()
1004                .duration_since(std::time::UNIX_EPOCH)
1005                .unwrap()
1006                .as_secs()
1007                .saturating_add(600),
1008            ChainId::testnet(),
1009        );
1010        let auth = TransactionAuthenticator::Ed25519 {
1011            public_key: Ed25519PublicKey([0u8; 32]),
1012            signature: Ed25519Signature([0u8; 64]),
1013        };
1014        SignedTransaction::new(raw, auth)
1015    }
1016
1017    fn simulate_response_json() -> serde_json::Value {
1018        serde_json::json!([{
1019            "success": true,
1020            "vm_status": "Executed successfully",
1021            "gas_used": "100",
1022            "max_gas_amount": "200000",
1023            "gas_unit_price": "100",
1024            "hash": "0x1",
1025            "changes": [],
1026            "events": []
1027        }])
1028    }
1029
1030    #[tokio::test]
1031    async fn test_get_ledger_info() {
1032        let server = MockServer::start().await;
1033
1034        Mock::given(method("GET"))
1035            .and(path("/v1"))
1036            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1037                "chain_id": 2,
1038                "epoch": "100",
1039                "ledger_version": "12345",
1040                "oldest_ledger_version": "0",
1041                "ledger_timestamp": "1000000",
1042                "node_role": "full_node",
1043                "oldest_block_height": "0",
1044                "block_height": "5000"
1045            })))
1046            .expect(1)
1047            .mount(&server)
1048            .await;
1049
1050        let client = create_mock_client(&server);
1051        let result = client.get_ledger_info().await.unwrap();
1052
1053        assert_eq!(result.data.chain_id, 2);
1054        assert_eq!(result.data.version().unwrap(), 12345);
1055        assert_eq!(result.data.height().unwrap(), 5000);
1056    }
1057
1058    #[tokio::test]
1059    async fn test_get_account() {
1060        let server = MockServer::start().await;
1061
1062        Mock::given(method("GET"))
1063            .and(path_regex(r"^/v1/accounts/0x[0-9a-f]+$"))
1064            .respond_with(
1065                ResponseTemplate::new(200)
1066                    .set_body_json(serde_json::json!({
1067                        "sequence_number": "42",
1068                        "authentication_key": "0x0000000000000000000000000000000000000000000000000000000000000001"
1069                    }))
1070                    .insert_header("x-aptos-ledger-version", "12345"),
1071            )
1072            .expect(1)
1073            .mount(&server)
1074            .await;
1075
1076        let client = create_mock_client(&server);
1077        let result = client.get_account(AccountAddress::ONE).await.unwrap();
1078
1079        assert_eq!(result.data.sequence_number().unwrap(), 42);
1080        assert_eq!(result.ledger_version, Some(12345));
1081    }
1082
1083    #[tokio::test]
1084    async fn test_get_account_not_found() {
1085        let server = MockServer::start().await;
1086
1087        Mock::given(method("GET"))
1088            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+"))
1089            .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
1090                "message": "Account not found",
1091                "error_code": "account_not_found"
1092            })))
1093            .expect(1)
1094            .mount(&server)
1095            .await;
1096
1097        let client = create_mock_client(&server);
1098        let result = client.get_account(AccountAddress::ONE).await;
1099
1100        assert!(result.is_err());
1101        let err = result.unwrap_err();
1102        assert!(err.is_not_found());
1103    }
1104
1105    #[tokio::test]
1106    async fn test_get_account_resources() {
1107        let server = MockServer::start().await;
1108
1109        Mock::given(method("GET"))
1110            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+/resources"))
1111            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
1112                {
1113                    "type": "0x1::account::Account",
1114                    "data": {"sequence_number": "10"}
1115                },
1116                {
1117                    "type": "0x1::coin::CoinStore<0x1::aptos_coin::AptosCoin>",
1118                    "data": {"coin": {"value": "1000000"}}
1119                }
1120            ])))
1121            .expect(1)
1122            .mount(&server)
1123            .await;
1124
1125        let client = create_mock_client(&server);
1126        let result = client
1127            .get_account_resources(AccountAddress::ONE)
1128            .await
1129            .unwrap();
1130
1131        assert_eq!(result.data.len(), 2);
1132        assert!(result.data[0].typ.contains("Account"));
1133    }
1134
1135    #[tokio::test]
1136    async fn test_get_account_resource() {
1137        let server = MockServer::start().await;
1138
1139        Mock::given(method("GET"))
1140            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+/resource/.*"))
1141            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1142                "type": "0x1::coin::CoinStore<0x1::aptos_coin::AptosCoin>",
1143                "data": {"coin": {"value": "5000000"}}
1144            })))
1145            .expect(1)
1146            .mount(&server)
1147            .await;
1148
1149        let client = create_mock_client(&server);
1150        let result = client
1151            .get_account_resource(
1152                AccountAddress::ONE,
1153                "0x1::coin::CoinStore<0x1::aptos_coin::AptosCoin>",
1154            )
1155            .await
1156            .unwrap();
1157
1158        assert!(result.data.typ.contains("CoinStore"));
1159    }
1160
1161    #[tokio::test]
1162    async fn test_get_account_modules() {
1163        let server = MockServer::start().await;
1164
1165        Mock::given(method("GET"))
1166            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+/modules"))
1167            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
1168                {
1169                    "bytecode": "0xabc123",
1170                    "abi": {
1171                        "address": "0x1",
1172                        "name": "coin",
1173                        "exposed_functions": [],
1174                        "structs": []
1175                    }
1176                }
1177            ])))
1178            .expect(1)
1179            .mount(&server)
1180            .await;
1181
1182        let client = create_mock_client(&server);
1183        let result = client
1184            .get_account_modules(AccountAddress::ONE)
1185            .await
1186            .unwrap();
1187
1188        assert_eq!(result.data.len(), 1);
1189        assert!(result.data[0].abi.is_some());
1190    }
1191
1192    #[tokio::test]
1193    async fn test_get_account_resources_paginated_sends_start_and_limit() {
1194        let server = MockServer::start().await;
1195
1196        // Verify the SDK forwards both query params verbatim.
1197        Mock::given(method("GET"))
1198            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+/resources"))
1199            .and(query_param("start", "42"))
1200            .and(query_param("limit", "9"))
1201            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
1202            .expect(1)
1203            .mount(&server)
1204            .await;
1205
1206        let client = create_mock_client(&server);
1207        let result = client
1208            .get_account_resources_paginated(AccountAddress::ONE, Some("42"), Some(9))
1209            .await
1210            .unwrap();
1211        assert_eq!(result.data.len(), 0);
1212    }
1213
1214    #[tokio::test]
1215    async fn test_get_account_resources_paginated_round_trips_opaque_cursor() {
1216        // The `x-aptos-cursor` header is opaque (`Option<String>` on
1217        // `AptosResponse`). A caller pulling page N+1 must be able to pass
1218        // page N's cursor verbatim, even when it's not a decimal integer.
1219        let server = MockServer::start().await;
1220        let opaque = "0x0a1b2c3d_state_key_token";
1221        Mock::given(method("GET"))
1222            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+/resources"))
1223            .and(query_param("start", opaque))
1224            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
1225            .expect(1)
1226            .mount(&server)
1227            .await;
1228
1229        let client = create_mock_client(&server);
1230        client
1231            .get_account_resources_paginated(AccountAddress::ONE, Some(opaque), None)
1232            .await
1233            .unwrap();
1234    }
1235
1236    #[tokio::test]
1237    async fn test_get_account_resources_no_pagination_omits_query() {
1238        let server = MockServer::start().await;
1239
1240        // When both args are None, no `start`/`limit` query params should
1241        // be appended -- the fullnode default page applies.
1242        Mock::given(method("GET"))
1243            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+/resources$"))
1244            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
1245            .expect(1)
1246            .mount(&server)
1247            .await;
1248
1249        let client = create_mock_client(&server);
1250        client
1251            .get_account_resources(AccountAddress::ONE)
1252            .await
1253            .unwrap();
1254    }
1255
1256    #[tokio::test]
1257    async fn test_get_account_resources_paginated_sends_start_only() {
1258        // Start without limit: caller is paging from a saved cursor and is
1259        // happy with the fullnode default page size.
1260        let server = MockServer::start().await;
1261        Mock::given(method("GET"))
1262            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+/resources"))
1263            .and(query_param("start", "1234"))
1264            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
1265            .expect(1)
1266            .mount(&server)
1267            .await;
1268
1269        let client = create_mock_client(&server);
1270        client
1271            .get_account_resources_paginated(AccountAddress::ONE, Some("1234"), None)
1272            .await
1273            .unwrap();
1274    }
1275
1276    #[tokio::test]
1277    async fn test_get_account_modules_paginated_sends_start_and_limit() {
1278        let server = MockServer::start().await;
1279        Mock::given(method("GET"))
1280            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+/modules"))
1281            .and(query_param("start", "7"))
1282            .and(query_param("limit", "100"))
1283            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
1284            .expect(1)
1285            .mount(&server)
1286            .await;
1287
1288        let client = create_mock_client(&server);
1289        client
1290            .get_account_modules_paginated(AccountAddress::ONE, Some("7"), Some(100))
1291            .await
1292            .unwrap();
1293    }
1294
1295    #[tokio::test]
1296    async fn test_get_account_modules_no_pagination_omits_query() {
1297        // Symmetric with the resources variant: no `start` / `limit` query
1298        // params should be appended when both are None.
1299        let server = MockServer::start().await;
1300        Mock::given(method("GET"))
1301            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+/modules$"))
1302            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
1303            .expect(1)
1304            .mount(&server)
1305            .await;
1306
1307        let client = create_mock_client(&server);
1308        client
1309            .get_account_modules(AccountAddress::ONE)
1310            .await
1311            .unwrap();
1312    }
1313
1314    #[tokio::test]
1315    async fn test_get_account_modules_paginated_sends_limit_only() {
1316        let server = MockServer::start().await;
1317
1318        // Only `limit` is sent when `start` is omitted -- caller is fetching
1319        // the first page with a custom page size.
1320        Mock::given(method("GET"))
1321            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+/modules"))
1322            .and(query_param("limit", "25"))
1323            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
1324            .expect(1)
1325            .mount(&server)
1326            .await;
1327
1328        let client = create_mock_client(&server);
1329        client
1330            .get_account_modules_paginated(AccountAddress::ONE, None, Some(25))
1331            .await
1332            .unwrap();
1333    }
1334
1335    #[tokio::test]
1336    async fn test_estimate_gas_price() {
1337        let server = MockServer::start().await;
1338
1339        Mock::given(method("GET"))
1340            .and(path("/v1/estimate_gas_price"))
1341            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1342                "deprioritized_gas_estimate": 50,
1343                "gas_estimate": 100,
1344                "prioritized_gas_estimate": 150
1345            })))
1346            .expect(1)
1347            .mount(&server)
1348            .await;
1349
1350        let client = create_mock_client(&server);
1351        let result = client.estimate_gas_price().await.unwrap();
1352
1353        assert_eq!(result.data.gas_estimate, 100);
1354        assert_eq!(result.data.low(), 50);
1355        assert_eq!(result.data.high(), 150);
1356    }
1357
1358    #[tokio::test]
1359    async fn test_get_transaction_by_hash() {
1360        let server = MockServer::start().await;
1361
1362        Mock::given(method("GET"))
1363            .and(path_regex(r"/v1/transactions/by_hash/0x[0-9a-f]+"))
1364            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1365                "version": "12345",
1366                "hash": "0x0000000000000000000000000000000000000000000000000000000000000001",
1367                "success": true,
1368                "vm_status": "Executed successfully"
1369            })))
1370            .expect(1)
1371            .mount(&server)
1372            .await;
1373
1374        let client = create_mock_client(&server);
1375        let hash = HashValue::from_hex(
1376            "0x0000000000000000000000000000000000000000000000000000000000000001",
1377        )
1378        .unwrap();
1379        let result = client.get_transaction_by_hash(&hash).await.unwrap();
1380
1381        assert!(
1382            result
1383                .data
1384                .get("success")
1385                .and_then(serde_json::Value::as_bool)
1386                .unwrap()
1387        );
1388    }
1389
1390    #[tokio::test]
1391    async fn test_wait_for_transaction_success() {
1392        let server = MockServer::start().await;
1393
1394        Mock::given(method("GET"))
1395            .and(path_regex(r"/v1/transactions/by_hash/0x[0-9a-f]+"))
1396            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1397                "type": "user_transaction",
1398                "version": "12345",
1399                "hash": "0x0000000000000000000000000000000000000000000000000000000000000001",
1400                "success": true,
1401                "vm_status": "Executed successfully"
1402            })))
1403            .expect(1..)
1404            .mount(&server)
1405            .await;
1406
1407        let client = create_mock_client(&server);
1408        let hash = HashValue::from_hex(
1409            "0x0000000000000000000000000000000000000000000000000000000000000001",
1410        )
1411        .unwrap();
1412        let result = client
1413            .wait_for_transaction(&hash, Some(Duration::from_secs(5)))
1414            .await
1415            .unwrap();
1416
1417        assert!(
1418            result
1419                .data
1420                .get("success")
1421                .and_then(serde_json::Value::as_bool)
1422                .unwrap()
1423        );
1424    }
1425
1426    #[tokio::test]
1427    async fn test_server_error_retryable() {
1428        let server = MockServer::start().await;
1429
1430        Mock::given(method("GET"))
1431            .and(path("/v1"))
1432            .respond_with(ResponseTemplate::new(503).set_body_json(serde_json::json!({
1433                "message": "Service temporarily unavailable"
1434            })))
1435            .expect(1)
1436            .mount(&server)
1437            .await;
1438
1439        let url = format!("{}/v1", server.uri());
1440        let config = AptosConfig::custom(&url).unwrap().without_retry();
1441        let client = FullnodeClient::new(config).unwrap();
1442        let result = client.get_ledger_info().await;
1443
1444        assert!(result.is_err());
1445        assert!(result.unwrap_err().is_retryable());
1446    }
1447
1448    #[tokio::test]
1449    async fn test_rate_limited() {
1450        let server = MockServer::start().await;
1451
1452        Mock::given(method("GET"))
1453            .and(path("/v1"))
1454            .respond_with(
1455                ResponseTemplate::new(429)
1456                    .set_body_json(serde_json::json!({
1457                        "message": "Rate limited"
1458                    }))
1459                    .insert_header("retry-after", "30"),
1460            )
1461            .expect(1)
1462            .mount(&server)
1463            .await;
1464
1465        let url = format!("{}/v1", server.uri());
1466        let config = AptosConfig::custom(&url).unwrap().without_retry();
1467        let client = FullnodeClient::new(config).unwrap();
1468        let result = client.get_ledger_info().await;
1469
1470        assert!(result.is_err());
1471        assert!(result.unwrap_err().is_retryable());
1472    }
1473
1474    #[tokio::test]
1475    async fn test_get_block_by_height() {
1476        let server = MockServer::start().await;
1477
1478        Mock::given(method("GET"))
1479            .and(path_regex(r"/v1/blocks/by_height/\d+"))
1480            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1481                "block_height": "1000",
1482                "block_hash": "0xabc",
1483                "block_timestamp": "1234567890",
1484                "first_version": "100",
1485                "last_version": "200"
1486            })))
1487            .expect(1)
1488            .mount(&server)
1489            .await;
1490
1491        let client = create_mock_client(&server);
1492        let result = client.get_block_by_height(1000, false).await.unwrap();
1493
1494        assert!(result.data.get("block_height").is_some());
1495    }
1496
1497    #[tokio::test]
1498    async fn test_view() {
1499        let server = MockServer::start().await;
1500
1501        Mock::given(method("POST"))
1502            .and(path("/v1/view"))
1503            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!(["1000000"])))
1504            .expect(1)
1505            .mount(&server)
1506            .await;
1507
1508        let client = create_mock_client(&server);
1509        let result: AptosResponse<Vec<serde_json::Value>> = client
1510            .view(
1511                "0x1::coin::balance",
1512                vec!["0x1::aptos_coin::AptosCoin".to_string()],
1513                vec![serde_json::json!("0x1")],
1514            )
1515            .await
1516            .unwrap();
1517
1518        assert_eq!(result.data.len(), 1);
1519    }
1520
1521    #[tokio::test]
1522    async fn test_simulate_transaction_with_estimate_gas_unit_price() {
1523        let server = MockServer::start().await;
1524
1525        Mock::given(method("POST"))
1526            .and(path("/v1/transactions/simulate"))
1527            .and(|req: &wiremock::Request| {
1528                req.url
1529                    .query()
1530                    .is_some_and(|q| q.contains("estimate_gas_unit_price=true"))
1531            })
1532            .respond_with(ResponseTemplate::new(200).set_body_json(simulate_response_json()))
1533            .expect(1)
1534            .mount(&server)
1535            .await;
1536
1537        let client = create_mock_client(&server);
1538        let signed = create_minimal_signed_transaction();
1539        let opts = SimulateQueryOptions::new().estimate_gas_unit_price(true);
1540        let result = client
1541            .simulate_transaction_with_options(&signed, opts)
1542            .await
1543            .unwrap();
1544        assert!(!result.data.is_empty());
1545    }
1546
1547    #[tokio::test]
1548    async fn test_simulate_transaction_with_estimate_max_gas_amount() {
1549        let server = MockServer::start().await;
1550
1551        Mock::given(method("POST"))
1552            .and(path("/v1/transactions/simulate"))
1553            .and(|req: &wiremock::Request| {
1554                req.url
1555                    .query()
1556                    .is_some_and(|q| q.contains("estimate_max_gas_amount=true"))
1557            })
1558            .respond_with(ResponseTemplate::new(200).set_body_json(simulate_response_json()))
1559            .expect(1)
1560            .mount(&server)
1561            .await;
1562
1563        let client = create_mock_client(&server);
1564        let signed = create_minimal_signed_transaction();
1565        let opts = SimulateQueryOptions::new().estimate_max_gas_amount(true);
1566        let result = client
1567            .simulate_transaction_with_options(&signed, opts)
1568            .await
1569            .unwrap();
1570        assert!(!result.data.is_empty());
1571    }
1572
1573    #[tokio::test]
1574    async fn test_simulate_transaction_with_estimate_prioritized_gas_unit_price() {
1575        let server = MockServer::start().await;
1576
1577        Mock::given(method("POST"))
1578            .and(path("/v1/transactions/simulate"))
1579            .and(|req: &wiremock::Request| {
1580                req.url
1581                    .query()
1582                    .is_some_and(|q| q.contains("estimate_prioritized_gas_unit_price=true"))
1583            })
1584            .respond_with(ResponseTemplate::new(200).set_body_json(simulate_response_json()))
1585            .expect(1)
1586            .mount(&server)
1587            .await;
1588
1589        let client = create_mock_client(&server);
1590        let signed = create_minimal_signed_transaction();
1591        let opts = SimulateQueryOptions::new().estimate_prioritized_gas_unit_price(true);
1592        let result = client
1593            .simulate_transaction_with_options(&signed, opts)
1594            .await
1595            .unwrap();
1596        assert!(!result.data.is_empty());
1597    }
1598
1599    #[tokio::test]
1600    async fn test_simulate_transaction_with_all_options() {
1601        let server = MockServer::start().await;
1602
1603        Mock::given(method("POST"))
1604            .and(path("/v1/transactions/simulate"))
1605            .and(|req: &wiremock::Request| {
1606                req.url.query().is_some_and(|q| {
1607                    q.contains("estimate_gas_unit_price=true")
1608                        && q.contains("estimate_max_gas_amount=true")
1609                        && q.contains("estimate_prioritized_gas_unit_price=true")
1610                })
1611            })
1612            .respond_with(ResponseTemplate::new(200).set_body_json(simulate_response_json()))
1613            .expect(1)
1614            .mount(&server)
1615            .await;
1616
1617        let client = create_mock_client(&server);
1618        let signed = create_minimal_signed_transaction();
1619        let opts = SimulateQueryOptions::new()
1620            .estimate_gas_unit_price(true)
1621            .estimate_max_gas_amount(true)
1622            .estimate_prioritized_gas_unit_price(true);
1623        let result = client
1624            .simulate_transaction_with_options(&signed, opts)
1625            .await
1626            .unwrap();
1627        assert!(!result.data.is_empty());
1628    }
1629
1630    #[tokio::test]
1631    async fn test_simulate_transaction_without_options() {
1632        let server = MockServer::start().await;
1633
1634        // Mock must NOT match if query contains any of the simulate options (so we use path only and expect no query param)
1635        Mock::given(method("POST"))
1636            .and(path("/v1/transactions/simulate"))
1637            .and(|req: &wiremock::Request| {
1638                // URL must not contain the simulate query params when options is None
1639                req.url.query().is_none_or(|q| {
1640                    !q.contains("estimate_gas_unit_price=")
1641                        && !q.contains("estimate_max_gas_amount=")
1642                        && !q.contains("estimate_prioritized_gas_unit_price=")
1643                })
1644            })
1645            .respond_with(ResponseTemplate::new(200).set_body_json(simulate_response_json()))
1646            .expect(1)
1647            .mount(&server)
1648            .await;
1649
1650        let client = create_mock_client(&server);
1651        let signed = create_minimal_signed_transaction();
1652        let result = client.simulate_transaction(&signed).await.unwrap();
1653        assert!(!result.data.is_empty());
1654    }
1655}