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 configuration backing this client.
123    pub fn config(&self) -> &AptosConfig {
124        &self.config
125    }
126
127    /// Calls a view function whose arguments are supplied as **BCS-encoded
128    /// bytes**, returning the JSON-decoded result values.
129    ///
130    /// This differs from [`view`](Self::view) (which takes JSON arguments) and
131    /// from [`view_bcs`](Self::view_bcs) (which hex-encodes BCS bytes into the
132    /// JSON body, and therefore only round-trips correctly for argument types
133    /// whose hex happens to coincide with their JSON form, e.g. addresses).
134    ///
135    /// Here the entire request is serialized as an on-wire `ViewRequest`
136    /// (identical BCS layout to [`crate::transaction::EntryFunction`]) and
137    /// posted with the `application/x.aptos.view_function+bcs` content type,
138    /// exactly like the TypeScript SDK's `view`. This is the only reliable way
139    /// to pass typed arguments such as `Option<String>`, `vector<u8>`, or
140    /// `String` to a view function. The response is requested as JSON and
141    /// returned as an array of [`serde_json::Value`], one entry per declared
142    /// return value.
143    ///
144    /// # Arguments
145    ///
146    /// * `function` - Fully qualified function id (e.g. `0x1::coin::balance`).
147    /// * `type_args` - Type arguments for generic functions.
148    /// * `args` - Each argument already BCS-encoded (e.g. via
149    ///   `aptos_bcs::to_bytes`).
150    ///
151    /// # Errors
152    ///
153    /// Returns an error if the function id is malformed, the request cannot be
154    /// BCS-serialized, the HTTP request fails, or the API returns an error
155    /// status code.
156    pub async fn view_bcs_args(
157        &self,
158        function: &str,
159        type_args: Vec<crate::types::TypeTag>,
160        args: Vec<Vec<u8>>,
161    ) -> AptosResult<AptosResponse<Vec<serde_json::Value>>> {
162        // `Content-Type` for a request whose body is a BCS-serialized
163        // `ViewRequest` (as opposed to `application/x-bcs`, which is an `Accept`
164        // value asking the node to BCS-encode the *response*).
165        const BCS_VIEW_REQUEST_CONTENT_TYPE: &str = "application/x.aptos.view_function+bcs";
166
167        // A `ViewRequest`/`ViewFunction` is BCS-identical to an `EntryFunction`:
168        // `module: ModuleId, function: Identifier, ty_args: Vec<TypeTag>,
169        // args: Vec<Vec<u8>>`. Reusing `EntryFunction` keeps the wire format in
170        // one place and gives us the function-id parsing for free.
171        let view_fn =
172            crate::transaction::EntryFunction::from_function_id(function, type_args, args)?;
173        let body = aptos_bcs::to_bytes(&view_fn).map_err(AptosError::bcs)?;
174        let url = self.build_url("view");
175
176        let client = self.client.clone();
177        let retry_config = self.retry_config.clone();
178        let max_response_size = self.config.pool_config().max_response_size;
179
180        let executor = RetryExecutor::from_shared(retry_config);
181        executor
182            .execute(|| {
183                let client = client.clone();
184                let url = url.clone();
185                let body = body.clone();
186                async move {
187                    let response = client
188                        .post(url)
189                        .header(CONTENT_TYPE, BCS_VIEW_REQUEST_CONTENT_TYPE)
190                        .header(ACCEPT, JSON_CONTENT_TYPE)
191                        .body(body)
192                        .send()
193                        .await?;
194
195                    Self::handle_response_static(response, max_response_size).await
196                }
197            })
198            .await
199    }
200
201    /// Returns the retry configuration.
202    pub fn retry_config(&self) -> &RetryConfig {
203        &self.retry_config
204    }
205
206    // === Ledger Info ===
207
208    /// Gets the current ledger information.
209    ///
210    /// # Errors
211    ///
212    /// Returns an error if the HTTP request fails, the API returns an error status code,
213    /// or the response cannot be parsed as JSON.
214    pub async fn get_ledger_info(&self) -> AptosResult<AptosResponse<LedgerInfo>> {
215        let url = self.build_url("");
216        self.get_json(url).await
217    }
218
219    // === Account ===
220
221    /// Gets account information.
222    ///
223    /// # Errors
224    ///
225    /// Returns an error if the HTTP request fails, the API returns an error status code,
226    /// the response cannot be parsed as JSON, or the account is not found (404).
227    pub async fn get_account(
228        &self,
229        address: AccountAddress,
230    ) -> AptosResult<AptosResponse<AccountData>> {
231        let url = self.build_url(&format!("accounts/{address}"));
232        self.get_json(url).await
233    }
234
235    /// Gets the sequence number for an account.
236    ///
237    /// # Errors
238    ///
239    /// Returns an error if fetching the account fails, the account is not found (404),
240    /// or the sequence number cannot be parsed from the account data.
241    pub async fn get_sequence_number(&self, address: AccountAddress) -> AptosResult<u64> {
242        let account = self.get_account(address).await?;
243        account
244            .data
245            .sequence_number()
246            .map_err(|e| AptosError::Internal(format!("failed to parse sequence number: {e}")))
247    }
248
249    /// Gets all resources for an account in a single page (uses the
250    /// fullnode's default page size; large accounts may be truncated).
251    ///
252    /// For paginated access on accounts that hold many resources, use
253    /// [`get_account_resources_paginated`](Self::get_account_resources_paginated).
254    ///
255    /// # Errors
256    ///
257    /// Returns an error if the HTTP request fails, the API returns an error status code,
258    /// or the response cannot be parsed as JSON.
259    pub async fn get_account_resources(
260        &self,
261        address: AccountAddress,
262    ) -> AptosResult<AptosResponse<Vec<Resource>>> {
263        self.get_account_resources_paginated(address, None, None)
264            .await
265    }
266
267    /// Gets resources for an account with explicit pagination cursors.
268    ///
269    /// * `start` -- opaque cursor token returned by the previous page in
270    ///   the `x-aptos-cursor` header (and surfaced as
271    ///   [`AptosResponse::cursor`](super::response::AptosResponse#field.cursor)).
272    ///   The type is `Option<&str>` rather than `Option<u64>` so opaque
273    ///   non-numeric cursors round-trip losslessly. Pass `None` for the
274    ///   first page; for subsequent pages forward
275    ///   `previous_response.cursor.as_deref()`.
276    /// * `limit` -- maximum number of resources to return on this page.
277    ///   The fullnode caps this server-side; callers should not assume
278    ///   their requested limit is honored verbatim.
279    ///
280    /// Matches the TypeScript SDK's `getAccountResources({ start, limit })`.
281    ///
282    /// # Errors
283    ///
284    /// Returns an error if the HTTP request fails, the API returns an error status code,
285    /// or the response cannot be parsed as JSON.
286    pub async fn get_account_resources_paginated(
287        &self,
288        address: AccountAddress,
289        start: Option<&str>,
290        limit: Option<u16>,
291    ) -> AptosResult<AptosResponse<Vec<Resource>>> {
292        let mut url = self.build_url(&format!("accounts/{address}/resources"));
293        append_start_limit(&mut url, start, limit);
294        self.get_json(url).await
295    }
296
297    /// Gets a specific resource for an account.
298    ///
299    /// # Errors
300    ///
301    /// Returns an error if the HTTP request fails, the API returns an error status code,
302    /// the response cannot be parsed as JSON, or the resource is not found (404).
303    pub async fn get_account_resource(
304        &self,
305        address: AccountAddress,
306        resource_type: &str,
307    ) -> AptosResult<AptosResponse<Resource>> {
308        let url = self.build_url(&format!(
309            "accounts/{}/resource/{}",
310            address,
311            urlencoding::encode(resource_type)
312        ));
313        self.get_json(url).await
314    }
315
316    /// Gets all modules for an account in a single page (uses the
317    /// fullnode's default page size; accounts that publish many modules
318    /// may be truncated).
319    ///
320    /// For paginated access, use
321    /// [`get_account_modules_paginated`](Self::get_account_modules_paginated).
322    ///
323    /// # Errors
324    ///
325    /// Returns an error if the HTTP request fails, the API returns an error status code,
326    /// or the response cannot be parsed as JSON.
327    pub async fn get_account_modules(
328        &self,
329        address: AccountAddress,
330    ) -> AptosResult<AptosResponse<Vec<MoveModule>>> {
331        self.get_account_modules_paginated(address, None, None)
332            .await
333    }
334
335    /// Gets modules for an account with explicit pagination cursors.
336    ///
337    /// See [`get_account_resources_paginated`](Self::get_account_resources_paginated)
338    /// for `start` / `limit` semantics; they are interpreted the same way
339    /// by the fullnode.
340    ///
341    /// # Errors
342    ///
343    /// Returns an error if the HTTP request fails, the API returns an error status code,
344    /// or the response cannot be parsed as JSON.
345    pub async fn get_account_modules_paginated(
346        &self,
347        address: AccountAddress,
348        start: Option<&str>,
349        limit: Option<u16>,
350    ) -> AptosResult<AptosResponse<Vec<MoveModule>>> {
351        let mut url = self.build_url(&format!("accounts/{address}/modules"));
352        append_start_limit(&mut url, start, limit);
353        self.get_json(url).await
354    }
355
356    /// Gets a specific module for an account.
357    ///
358    /// # Errors
359    ///
360    /// Returns an error if the HTTP request fails, the API returns an error status code,
361    /// the response cannot be parsed as JSON, or the module is not found (404).
362    pub async fn get_account_module(
363        &self,
364        address: AccountAddress,
365        module_name: &str,
366    ) -> AptosResult<AptosResponse<MoveModule>> {
367        let url = self.build_url(&format!("accounts/{address}/module/{module_name}"));
368        self.get_json(url).await
369    }
370
371    // === Balance ===
372
373    /// Gets the APT balance for an account in octas.
374    ///
375    /// # Errors
376    ///
377    /// Returns an error if the view function call fails, the response cannot be parsed,
378    /// or the balance value cannot be converted to u64.
379    pub async fn get_account_balance(&self, address: AccountAddress) -> AptosResult<u64> {
380        // Use the coin::balance view function which works with both legacy CoinStore
381        // and the newer Fungible Asset standard
382        let result = self
383            .view(
384                "0x1::coin::balance",
385                vec!["0x1::aptos_coin::AptosCoin".to_string()],
386                vec![serde_json::json!(address.to_string())],
387            )
388            .await?;
389
390        // The view function returns an array with a single string value
391        let balance_str = result
392            .data
393            .first()
394            .and_then(|v| v.as_str())
395            .ok_or_else(|| AptosError::Internal("failed to parse balance response".into()))?;
396
397        balance_str
398            .parse()
399            .map_err(|_| AptosError::Internal("failed to parse balance as u64".into()))
400    }
401
402    // === Transactions ===
403
404    /// Submits a signed transaction.
405    ///
406    /// Note: Transaction submission is automatically retried for transient errors.
407    /// Duplicate transaction submissions (same hash) are safe and idempotent.
408    ///
409    /// # Errors
410    ///
411    /// Returns an error if the transaction cannot be serialized to BCS, the HTTP request fails,
412    /// the API returns an error status code, or the response cannot be parsed as JSON.
413    pub async fn submit_transaction(
414        &self,
415        signed_txn: &SignedTransaction,
416    ) -> AptosResult<AptosResponse<PendingTransaction>> {
417        let url = self.build_url("transactions");
418        let bcs_bytes = signed_txn.to_bcs()?;
419        let client = self.client.clone();
420        let retry_config = self.retry_config.clone();
421        let max_response_size = self.config.pool_config().max_response_size;
422
423        let executor = RetryExecutor::from_shared(retry_config);
424        executor
425            .execute(|| {
426                let client = client.clone();
427                let url = url.clone();
428                let bcs_bytes = bcs_bytes.clone();
429                async move {
430                    let response = client
431                        .post(url)
432                        .header(CONTENT_TYPE, BCS_CONTENT_TYPE)
433                        .header(ACCEPT, JSON_CONTENT_TYPE)
434                        .body(bcs_bytes)
435                        .send()
436                        .await?;
437
438                    Self::handle_response_static(response, max_response_size).await
439                }
440            })
441            .await
442    }
443
444    /// Submits a transaction and waits for it to be committed.
445    ///
446    /// # Errors
447    ///
448    /// Returns an error if transaction submission fails, the transaction times out waiting
449    /// for commitment, the transaction execution fails, or any HTTP/API errors occur.
450    pub async fn submit_and_wait(
451        &self,
452        signed_txn: &SignedTransaction,
453        timeout: Option<Duration>,
454    ) -> AptosResult<AptosResponse<serde_json::Value>> {
455        let pending = self.submit_transaction(signed_txn).await?;
456        self.wait_for_transaction(&pending.data.hash, timeout).await
457    }
458
459    /// Gets a transaction by hash.
460    ///
461    /// # Errors
462    ///
463    /// Returns an error if the HTTP request fails, the API returns an error status code,
464    /// the response cannot be parsed as JSON, or the transaction is not found (404).
465    pub async fn get_transaction_by_hash(
466        &self,
467        hash: &HashValue,
468    ) -> AptosResult<AptosResponse<serde_json::Value>> {
469        let url = self.build_url(&format!("transactions/by_hash/{hash}"));
470        self.get_json(url).await
471    }
472
473    /// Gets a committed transaction by its ledger version.
474    ///
475    /// # Errors
476    ///
477    /// Returns an error if the HTTP request fails, the API returns an error status code,
478    /// the response cannot be parsed as JSON, or the transaction is not found (404).
479    pub async fn get_transaction_by_version(
480        &self,
481        version: u64,
482    ) -> AptosResult<AptosResponse<serde_json::Value>> {
483        let url = self.build_url(&format!("transactions/by_version/{version}"));
484        self.get_json(url).await
485    }
486
487    /// Lists committed transactions in ascending ledger-version order.
488    ///
489    /// `start` is the ledger version to begin at. When `None`, the fullnode
490    /// returns the most recent page (the last `limit` transactions) but the
491    /// transactions within that page are still ordered oldest-to-newest by
492    /// version. `limit` bounds the page size (the fullnode caps this
493    /// regardless of the requested value).
494    ///
495    /// # Errors
496    ///
497    /// Returns an error if the HTTP request fails, the API returns an error status code,
498    /// or the response cannot be parsed as JSON.
499    pub async fn get_transactions(
500        &self,
501        start: Option<u64>,
502        limit: Option<u16>,
503    ) -> AptosResult<AptosResponse<Vec<serde_json::Value>>> {
504        let mut url = self.build_url("transactions");
505        {
506            let mut query = url.query_pairs_mut();
507            if let Some(start) = start {
508                query.append_pair("start", &start.to_string());
509            }
510            if let Some(limit) = limit {
511                query.append_pair("limit", &limit.to_string());
512            }
513        }
514        self.get_json(url).await
515    }
516
517    /// Lists transactions sent by a specific account, ordered by the account's
518    /// sequence number.
519    ///
520    /// `start` is the sequence number to begin at (defaults to `0` when `None`);
521    /// `limit` bounds the page size.
522    ///
523    /// # Errors
524    ///
525    /// Returns an error if the HTTP request fails, the API returns an error status code,
526    /// or the response cannot be parsed as JSON.
527    pub async fn get_account_transactions(
528        &self,
529        address: AccountAddress,
530        start: Option<u64>,
531        limit: Option<u16>,
532    ) -> AptosResult<AptosResponse<Vec<serde_json::Value>>> {
533        let mut url = self.build_url(&format!("accounts/{address}/transactions"));
534        {
535            let mut query = url.query_pairs_mut();
536            if let Some(start) = start {
537                query.append_pair("start", &start.to_string());
538            }
539            if let Some(limit) = limit {
540                query.append_pair("limit", &limit.to_string());
541            }
542        }
543        self.get_json(url).await
544    }
545
546    /// Waits for a transaction to be committed.
547    ///
548    /// Uses exponential backoff for polling, starting at 200ms and doubling up to 2s.
549    ///
550    /// # Errors
551    ///
552    /// Returns an error if the transaction times out waiting for commitment, the transaction
553    /// execution fails (`vm_status` indicates failure), or HTTP/API errors occur while polling.
554    pub async fn wait_for_transaction(
555        &self,
556        hash: &HashValue,
557        timeout: Option<Duration>,
558    ) -> AptosResult<AptosResponse<serde_json::Value>> {
559        let timeout = timeout.unwrap_or(Duration::from_secs(DEFAULT_TRANSACTION_WAIT_TIMEOUT_SECS));
560        let start = std::time::Instant::now();
561
562        // Exponential backoff: start at 200ms, double each time, max 2s
563        let initial_interval = Duration::from_millis(200);
564        let max_interval = Duration::from_secs(2);
565        let mut current_interval = initial_interval;
566
567        loop {
568            match self.get_transaction_by_hash(hash).await {
569                Ok(response) => {
570                    // Check if transaction is committed (has version)
571                    if response.data.get("version").is_some() {
572                        // Check success
573                        let success = response
574                            .data
575                            .get("success")
576                            .and_then(serde_json::Value::as_bool);
577                        if success == Some(false) {
578                            let vm_status = response
579                                .data
580                                .get("vm_status")
581                                .and_then(|v| v.as_str())
582                                .unwrap_or("unknown")
583                                .to_string();
584                            return Err(AptosError::ExecutionFailed { vm_status });
585                        }
586                        return Ok(response);
587                    }
588                }
589                Err(AptosError::Api {
590                    status_code: 404, ..
591                }) => {
592                    // Transaction not found yet, continue waiting
593                }
594                Err(e) => return Err(e),
595            }
596
597            if start.elapsed() >= timeout {
598                return Err(AptosError::TransactionTimeout {
599                    hash: hash.to_string(),
600                    timeout_secs: timeout.as_secs(),
601                });
602            }
603
604            tokio::time::sleep(current_interval).await;
605
606            // Exponential backoff with cap
607            current_interval = std::cmp::min(current_interval * 2, max_interval);
608        }
609    }
610
611    /// Simulates a transaction.
612    ///
613    /// Delegates to [`simulate_transaction_with_options`](Self::simulate_transaction_with_options) with `None` for options.
614    ///
615    /// The request body is derived from `signed_txn` after rewriting authenticators for the
616    /// simulate endpoint (see [`SignedTransaction::for_simulate_endpoint`]).
617    ///
618    /// # Errors
619    ///
620    /// Returns an error if the transaction cannot be serialized to BCS, the HTTP request fails,
621    /// the API returns an error status code, or the response cannot be parsed as JSON.
622    pub async fn simulate_transaction(
623        &self,
624        signed_txn: &SignedTransaction,
625    ) -> AptosResult<AptosResponse<Vec<serde_json::Value>>> {
626        self.simulate_transaction_with_options(signed_txn, None as Option<SimulateQueryOptions>)
627            .await
628    }
629
630    /// Simulates a transaction with optional query parameters.
631    ///
632    /// Pass [`SimulateQueryOptions`] to request gas estimation behavior
633    /// (e.g. `estimate_gas_unit_price`, `estimate_max_gas_amount`) as query
634    /// parameters to the `/transactions/simulate` endpoint.
635    ///
636    /// Authenticators on `signed_txn` are rewritten client-side (via
637    /// [`SignedTransaction::for_simulate_endpoint`]) before BCS serialization so the
638    /// fullnode never receives a cryptographically valid signature (which it rejects with
639    /// HTTP 400).
640    ///
641    /// # Errors
642    ///
643    /// Returns an error if the transaction cannot be serialized to BCS, the HTTP request fails,
644    /// the API returns an error status code, or the response cannot be parsed as JSON.
645    pub async fn simulate_transaction_with_options(
646        &self,
647        signed_txn: &SignedTransaction,
648        options: impl Into<Option<SimulateQueryOptions>>,
649    ) -> AptosResult<AptosResponse<Vec<serde_json::Value>>> {
650        let mut url = self.build_url("transactions/simulate");
651        if let Some(opts) = options.into() {
652            let mut pairs = url.query_pairs_mut();
653            if opts.estimate_gas_unit_price {
654                pairs.append_pair("estimate_gas_unit_price", "true");
655            }
656            if opts.estimate_max_gas_amount {
657                pairs.append_pair("estimate_max_gas_amount", "true");
658            }
659            if opts.estimate_prioritized_gas_unit_price {
660                pairs.append_pair("estimate_prioritized_gas_unit_price", "true");
661            }
662        }
663        let bcs_bytes = signed_txn.for_simulate_endpoint().to_bcs()?;
664        let client = self.client.clone();
665        let retry_config = self.retry_config.clone();
666        let max_response_size = self.config.pool_config().max_response_size;
667
668        let executor = RetryExecutor::from_shared(retry_config);
669        executor
670            .execute(|| {
671                let client = client.clone();
672                let url = url.clone();
673                let bcs_bytes = bcs_bytes.clone();
674                async move {
675                    let response = client
676                        .post(url)
677                        .header(CONTENT_TYPE, BCS_CONTENT_TYPE)
678                        .header(ACCEPT, JSON_CONTENT_TYPE)
679                        .body(bcs_bytes)
680                        .send()
681                        .await?;
682
683                    Self::handle_response_static(response, max_response_size).await
684                }
685            })
686            .await
687    }
688
689    // === Gas ===
690
691    /// Gets the current gas estimation.
692    ///
693    /// # Errors
694    ///
695    /// Returns an error if the HTTP request fails, the API returns an error status code,
696    /// or the response cannot be parsed as JSON.
697    pub async fn estimate_gas_price(&self) -> AptosResult<AptosResponse<GasEstimation>> {
698        let url = self.build_url("estimate_gas_price");
699        self.get_json(url).await
700    }
701
702    // === View Functions ===
703
704    /// Calls a view function.
705    ///
706    /// # Errors
707    ///
708    /// Returns an error if the HTTP request fails, the API returns an error status code,
709    /// or the response cannot be parsed as JSON.
710    pub async fn view(
711        &self,
712        function: &str,
713        type_args: Vec<String>,
714        args: Vec<serde_json::Value>,
715    ) -> AptosResult<AptosResponse<Vec<serde_json::Value>>> {
716        let url = self.build_url("view");
717
718        let body = serde_json::json!({
719            "function": function,
720            "type_arguments": type_args,
721            "arguments": args,
722        });
723
724        let client = self.client.clone();
725        let retry_config = self.retry_config.clone();
726        let max_response_size = self.config.pool_config().max_response_size;
727
728        let executor = RetryExecutor::from_shared(retry_config);
729        executor
730            .execute(|| {
731                let client = client.clone();
732                let url = url.clone();
733                let body = body.clone();
734                async move {
735                    let response = client
736                        .post(url)
737                        .header(CONTENT_TYPE, JSON_CONTENT_TYPE)
738                        .header(ACCEPT, JSON_CONTENT_TYPE)
739                        .json(&body)
740                        .send()
741                        .await?;
742
743                    Self::handle_response_static(response, max_response_size).await
744                }
745            })
746            .await
747    }
748
749    /// Calls a view function using BCS encoding for both inputs and outputs.
750    ///
751    /// This method provides lossless serialization by using BCS (Binary Canonical Serialization)
752    /// instead of JSON, which is important for large integers (u128, u256) and other types
753    /// where JSON can lose precision.
754    ///
755    /// # Arguments
756    ///
757    /// * `function` - The fully qualified function name (e.g., `0x1::coin::balance`)
758    /// * `type_args` - Type arguments as strings (e.g., `0x1::aptos_coin::AptosCoin`)
759    /// * `args` - Pre-serialized BCS arguments as byte vectors
760    ///
761    /// # Returns
762    ///
763    /// Returns the raw BCS-encoded response bytes, which can be deserialized
764    /// into the expected return type using `aptos_bcs::from_bytes`.
765    ///
766    /// # Errors
767    ///
768    /// Returns an error if the HTTP request fails, the API returns an error status code,
769    /// or the BCS serialization fails.
770    pub async fn view_bcs(
771        &self,
772        function: &str,
773        type_args: Vec<String>,
774        args: Vec<Vec<u8>>,
775    ) -> AptosResult<AptosResponse<Vec<u8>>> {
776        let url = self.build_url("view");
777
778        // Convert BCS args to hex strings for the JSON request body.
779        // The Aptos API accepts hex-encoded BCS bytes in the arguments array.
780        let hex_args: Vec<serde_json::Value> = args
781            .iter()
782            .map(|bytes| serde_json::json!(const_hex::encode_prefixed(bytes)))
783            .collect();
784
785        let body = serde_json::json!({
786            "function": function,
787            "type_arguments": type_args,
788            "arguments": hex_args,
789        });
790
791        let client = self.client.clone();
792        let retry_config = self.retry_config.clone();
793        let max_response_size = self.config.pool_config().max_response_size;
794
795        let executor = RetryExecutor::from_shared(retry_config);
796        executor
797            .execute(|| {
798                let client = client.clone();
799                let url = url.clone();
800                let body = body.clone();
801                async move {
802                    let response = client
803                        .post(url)
804                        .header(CONTENT_TYPE, JSON_CONTENT_TYPE)
805                        .header(ACCEPT, BCS_VIEW_CONTENT_TYPE)
806                        .json(&body)
807                        .send()
808                        .await?;
809
810                    // Check for errors before reading body
811                    let status = response.status();
812                    if !status.is_success() {
813                        // SECURITY: Bound error body reads to prevent OOM from
814                        // malicious servers sending huge error responses.
815                        let error_bytes =
816                            crate::config::read_response_bounded(response, MAX_ERROR_BODY_SIZE)
817                                .await
818                                .ok();
819                        let error_text = error_bytes
820                            .and_then(|b| String::from_utf8(b).ok())
821                            .unwrap_or_default();
822                        return Err(AptosError::Api {
823                            status_code: status.as_u16(),
824                            message: Self::truncate_error_body(error_text),
825                            error_code: None,
826                            vm_error_code: None,
827                        });
828                    }
829
830                    // SECURITY: Stream body with size limit to prevent OOM
831                    // from malicious responses (including chunked encoding).
832                    let bytes =
833                        crate::config::read_response_bounded(response, max_response_size).await?;
834                    Ok(AptosResponse::new(bytes))
835                }
836            })
837            .await
838    }
839
840    // === Tables ===
841
842    /// Reads an item from a Move table by its key.
843    ///
844    /// Table state is not addressable as a normal account resource, so the
845    /// fullnode exposes a dedicated `POST /tables/{handle}/item` endpoint that
846    /// takes the table's key/value Move types and the (JSON-encoded) key, and
847    /// returns the stored value. This mirrors the TypeScript SDK's
848    /// `getTableItem`.
849    ///
850    /// # Arguments
851    ///
852    /// * `handle` - The table handle (the address-like `0x…` identifier of the
853    ///   `Table` on chain, e.g. read from a resource field).
854    /// * `key_type` - The Move type of the key (e.g. `address`, `u64`,
855    ///   `0x1::string::String`).
856    /// * `value_type` - The Move type of the stored value.
857    /// * `key` - The key to look up, encoded as the fullnode expects it in JSON
858    ///   (e.g. `serde_json::json!("0x1")` for an `address` key).
859    ///
860    /// # Errors
861    ///
862    /// Returns an error if the request cannot be serialized, the HTTP request
863    /// fails, the API returns an error status code (including 404 when the key
864    /// is absent from the table), or the response cannot be parsed as JSON.
865    pub async fn get_table_item(
866        &self,
867        handle: &str,
868        key_type: &str,
869        value_type: &str,
870        key: serde_json::Value,
871    ) -> AptosResult<AptosResponse<serde_json::Value>> {
872        let url = self.build_url(&format!("tables/{}/item", urlencoding::encode(handle)));
873        let body = serde_json::json!({
874            "key_type": key_type,
875            "value_type": value_type,
876            "key": key,
877        });
878        self.post_json(url, &body).await
879    }
880
881    // === Events ===
882
883    /// Gets events emitted from an account by their creation number.
884    ///
885    /// Each `EventHandle` an account owns has a unique creation number; this
886    /// endpoint returns the events for one such handle without needing to know
887    /// the handle's Move struct type (unlike
888    /// [`get_events_by_event_handle`](Self::get_events_by_event_handle)). Mirrors
889    /// the TypeScript SDK's `getAccountEventsByCreationNumber`.
890    ///
891    /// # Errors
892    ///
893    /// Returns an error if the HTTP request fails, the API returns an error status code,
894    /// or the response cannot be parsed as JSON.
895    pub async fn get_events_by_creation_number(
896        &self,
897        address: AccountAddress,
898        creation_number: u64,
899        start: Option<u64>,
900        limit: Option<u64>,
901    ) -> AptosResult<AptosResponse<Vec<serde_json::Value>>> {
902        let mut url = self.build_url(&format!("accounts/{address}/events/{creation_number}"));
903        {
904            let mut query = url.query_pairs_mut();
905            if let Some(start) = start {
906                query.append_pair("start", &start.to_string());
907            }
908            if let Some(limit) = limit {
909                query.append_pair("limit", &limit.to_string());
910            }
911        }
912        self.get_json(url).await
913    }
914
915    /// Gets events by event handle.
916    ///
917    /// # Errors
918    ///
919    /// Returns an error if the HTTP request fails, the API returns an error status code,
920    /// or the response cannot be parsed as JSON.
921    pub async fn get_events_by_event_handle(
922        &self,
923        address: AccountAddress,
924        event_handle_struct: &str,
925        field_name: &str,
926        start: Option<u64>,
927        limit: Option<u64>,
928    ) -> AptosResult<AptosResponse<Vec<serde_json::Value>>> {
929        let mut url = self.build_url(&format!(
930            "accounts/{}/events/{}/{}",
931            address,
932            urlencoding::encode(event_handle_struct),
933            field_name
934        ));
935
936        {
937            let mut query = url.query_pairs_mut();
938            if let Some(start) = start {
939                query.append_pair("start", &start.to_string());
940            }
941            if let Some(limit) = limit {
942                query.append_pair("limit", &limit.to_string());
943            }
944        }
945
946        self.get_json(url).await
947    }
948
949    // === Blocks ===
950
951    /// Gets block by height.
952    ///
953    /// # Errors
954    ///
955    /// Returns an error if the HTTP request fails, the API returns an error status code,
956    /// the response cannot be parsed as JSON, or the block is not found (404).
957    pub async fn get_block_by_height(
958        &self,
959        height: u64,
960        with_transactions: bool,
961    ) -> AptosResult<AptosResponse<serde_json::Value>> {
962        let mut url = self.build_url(&format!("blocks/by_height/{height}"));
963        url.query_pairs_mut()
964            .append_pair("with_transactions", &with_transactions.to_string());
965        self.get_json(url).await
966    }
967
968    /// Gets block by version.
969    ///
970    /// # Errors
971    ///
972    /// Returns an error if the HTTP request fails, the API returns an error status code,
973    /// the response cannot be parsed as JSON, or the block is not found (404).
974    pub async fn get_block_by_version(
975        &self,
976        version: u64,
977        with_transactions: bool,
978    ) -> AptosResult<AptosResponse<serde_json::Value>> {
979        let mut url = self.build_url(&format!("blocks/by_version/{version}"));
980        url.query_pairs_mut()
981            .append_pair("with_transactions", &with_transactions.to_string());
982        self.get_json(url).await
983    }
984
985    // === Helper Methods ===
986
987    fn build_url(&self, path: &str) -> Url {
988        let mut url = self.config.fullnode_url().clone();
989        if !path.is_empty() {
990            // Avoid format! allocations by building the path string manually
991            let base_path = url.path();
992            let needs_slash = !base_path.ends_with('/');
993            let new_len = base_path.len() + path.len() + usize::from(needs_slash);
994            let mut new_path = String::with_capacity(new_len);
995            new_path.push_str(base_path);
996            if needs_slash {
997                new_path.push('/');
998            }
999            new_path.push_str(path);
1000            url.set_path(&new_path);
1001        }
1002        url
1003    }
1004
1005    async fn get_json<T: for<'de> serde::Deserialize<'de>>(
1006        &self,
1007        url: Url,
1008    ) -> AptosResult<AptosResponse<T>> {
1009        let client = self.client.clone();
1010        let url_clone = url.clone();
1011        let retry_config = self.retry_config.clone();
1012        let max_response_size = self.config.pool_config().max_response_size;
1013
1014        let executor = RetryExecutor::from_shared(retry_config);
1015        executor
1016            .execute(|| {
1017                let client = client.clone();
1018                let url = url_clone.clone();
1019                async move {
1020                    let response = client
1021                        .get(url)
1022                        .header(ACCEPT, JSON_CONTENT_TYPE)
1023                        .send()
1024                        .await?;
1025
1026                    Self::handle_response_static(response, max_response_size).await
1027                }
1028            })
1029            .await
1030    }
1031
1032    /// Posts a JSON body and deserializes the JSON response.
1033    ///
1034    /// Shares the retry/backoff and bounded-response handling with
1035    /// [`get_json`](Self::get_json); used by read endpoints that require a
1036    /// request body (e.g. table-item lookups). Retries are safe because these
1037    /// endpoints are read-only and idempotent.
1038    async fn post_json<B: serde::Serialize, T: for<'de> serde::Deserialize<'de>>(
1039        &self,
1040        url: Url,
1041        body: &B,
1042    ) -> AptosResult<AptosResponse<T>> {
1043        let body = serde_json::to_vec(body)?;
1044        let client = self.client.clone();
1045        let retry_config = self.retry_config.clone();
1046        let max_response_size = self.config.pool_config().max_response_size;
1047
1048        let executor = RetryExecutor::from_shared(retry_config);
1049        executor
1050            .execute(|| {
1051                let client = client.clone();
1052                let url = url.clone();
1053                let body = body.clone();
1054                async move {
1055                    let response = client
1056                        .post(url)
1057                        .header(CONTENT_TYPE, JSON_CONTENT_TYPE)
1058                        .header(ACCEPT, JSON_CONTENT_TYPE)
1059                        .body(body)
1060                        .send()
1061                        .await?;
1062
1063                    Self::handle_response_static(response, max_response_size).await
1064                }
1065            })
1066            .await
1067    }
1068
1069    /// Truncates a string to the maximum error body size.
1070    ///
1071    /// # Security
1072    ///
1073    /// Prevents storing extremely large error messages from malicious servers.
1074    fn truncate_error_body(body: String) -> String {
1075        if body.len() > MAX_ERROR_BODY_SIZE {
1076            // Find the last valid UTF-8 char boundary at or before the limit
1077            let mut end = MAX_ERROR_BODY_SIZE;
1078            while end > 0 && !body.is_char_boundary(end) {
1079                end -= 1;
1080            }
1081            format!(
1082                "{}... [truncated, total: {} bytes]",
1083                &body[..end],
1084                body.len()
1085            )
1086        } else {
1087            body
1088        }
1089    }
1090
1091    /// Handles an HTTP response without retry (for internal use).
1092    ///
1093    /// # Security
1094    ///
1095    /// This method enforces `max_response_size` on the actual response body,
1096    /// not just the Content-Length header, to prevent memory exhaustion even
1097    /// when the server uses chunked transfer encoding.
1098    async fn handle_response_static<T: for<'de> serde::Deserialize<'de>>(
1099        response: reqwest::Response,
1100        max_response_size: usize,
1101    ) -> AptosResult<AptosResponse<T>> {
1102        let status = response.status();
1103
1104        // Extract headers before consuming response body
1105        let ledger_version = response
1106            .headers()
1107            .get("x-aptos-ledger-version")
1108            .and_then(|v| v.to_str().ok())
1109            .and_then(|v| v.parse().ok());
1110        let ledger_timestamp = response
1111            .headers()
1112            .get("x-aptos-ledger-timestamp")
1113            .and_then(|v| v.to_str().ok())
1114            .and_then(|v| v.parse().ok());
1115        let epoch = response
1116            .headers()
1117            .get("x-aptos-epoch")
1118            .and_then(|v| v.to_str().ok())
1119            .and_then(|v| v.parse().ok());
1120        let block_height = response
1121            .headers()
1122            .get("x-aptos-block-height")
1123            .and_then(|v| v.to_str().ok())
1124            .and_then(|v| v.parse().ok());
1125        let oldest_ledger_version = response
1126            .headers()
1127            .get("x-aptos-oldest-ledger-version")
1128            .and_then(|v| v.to_str().ok())
1129            .and_then(|v| v.parse().ok());
1130        let cursor = response
1131            .headers()
1132            .get("x-aptos-cursor")
1133            .and_then(|v| v.to_str().ok())
1134            .map(ToString::to_string);
1135
1136        // Extract Retry-After header for rate limiting (before consuming body)
1137        let retry_after_secs = response
1138            .headers()
1139            .get("retry-after")
1140            .and_then(|v| v.to_str().ok())
1141            .and_then(|v| v.parse().ok());
1142
1143        if status.is_success() {
1144            // SECURITY: Stream body with size limit to prevent OOM
1145            // from malicious responses (including chunked encoding).
1146            let bytes = crate::config::read_response_bounded(response, max_response_size).await?;
1147            let data: T = serde_json::from_slice(&bytes)?;
1148            Ok(AptosResponse {
1149                data,
1150                ledger_version,
1151                ledger_timestamp,
1152                epoch,
1153                block_height,
1154                oldest_ledger_version,
1155                cursor,
1156            })
1157        } else if status.as_u16() == 429 {
1158            // SECURITY: Return specific RateLimited error with Retry-After info
1159            // This allows callers to respect the server's rate limiting
1160            Err(AptosError::RateLimited { retry_after_secs })
1161        } else {
1162            // SECURITY: Bound error body reads to prevent OOM from malicious
1163            // servers sending huge error responses (including chunked encoding).
1164            let error_bytes = crate::config::read_response_bounded(response, MAX_ERROR_BODY_SIZE)
1165                .await
1166                .ok();
1167            let error_text = error_bytes
1168                .and_then(|b| String::from_utf8(b).ok())
1169                .unwrap_or_default();
1170            let error_text = Self::truncate_error_body(error_text);
1171            let body: serde_json::Value = serde_json::from_str(&error_text).unwrap_or_default();
1172            let message = body
1173                .get("message")
1174                .and_then(|v| v.as_str())
1175                .unwrap_or("Unknown error")
1176                .to_string();
1177            let error_code = body
1178                .get("error_code")
1179                .and_then(|v| v.as_str())
1180                .map(ToString::to_string);
1181            let vm_error_code = body
1182                .get("vm_error_code")
1183                .and_then(serde_json::Value::as_u64);
1184
1185            Err(AptosError::api_with_details(
1186                status.as_u16(),
1187                message,
1188                error_code,
1189                vm_error_code,
1190            ))
1191        }
1192    }
1193
1194    /// Legacy `handle_response` - delegates to static version.
1195    #[allow(dead_code)]
1196    async fn handle_response<T: for<'de> serde::Deserialize<'de>>(
1197        &self,
1198        response: reqwest::Response,
1199    ) -> AptosResult<AptosResponse<T>> {
1200        let max_response_size = self.config.pool_config().max_response_size;
1201        Self::handle_response_static(response, max_response_size).await
1202    }
1203}
1204
1205/// Appends `start` and `limit` query parameters to `url` when present.
1206///
1207/// Shared by paginated REST endpoints (`/accounts/{addr}/resources`,
1208/// `/accounts/{addr}/modules`, ...) so the formatting stays consistent.
1209/// `start` is forwarded verbatim as a string so opaque pagination cursors
1210/// returned in the `x-aptos-cursor` header round-trip losslessly (the
1211/// fullnode does not promise numeric cursors).
1212fn append_start_limit(url: &mut Url, start: Option<&str>, limit: Option<u16>) {
1213    if start.is_none() && limit.is_none() {
1214        return;
1215    }
1216    let mut query = url.query_pairs_mut();
1217    if let Some(start) = start {
1218        query.append_pair("start", start);
1219    }
1220    if let Some(limit) = limit {
1221        query.append_pair("limit", &limit.to_string());
1222    }
1223}
1224
1225#[cfg(test)]
1226mod tests {
1227    use super::*;
1228    use crate::transaction::authenticator::{
1229        Ed25519PublicKey, Ed25519Signature, TransactionAuthenticator,
1230    };
1231    use crate::transaction::simulation::SimulateQueryOptions;
1232    use crate::transaction::types::{RawTransaction, SignedTransaction};
1233    use crate::types::ChainId;
1234    use wiremock::{
1235        Mock, MockServer, ResponseTemplate,
1236        matchers::{method, path, path_regex, query_param},
1237    };
1238
1239    #[test]
1240    fn test_build_url() {
1241        let client = FullnodeClient::new(AptosConfig::testnet()).unwrap();
1242        let url = client.build_url("accounts/0x1");
1243        assert!(url.as_str().contains("accounts/0x1"));
1244    }
1245
1246    fn create_mock_client(server: &MockServer) -> FullnodeClient {
1247        // The mock server URL needs to include /v1 since that's part of the base URL
1248        let url = format!("{}/v1", server.uri());
1249        let config = AptosConfig::custom(&url).unwrap().without_retry();
1250        FullnodeClient::new(config).unwrap()
1251    }
1252
1253    /// Creates a minimal `SignedTransaction` for use in `simulate_transaction` tests.
1254    fn create_minimal_signed_transaction() -> SignedTransaction {
1255        use crate::transaction::payload::{EntryFunction, TransactionPayload};
1256
1257        let raw = RawTransaction::new(
1258            AccountAddress::ONE,
1259            0,
1260            TransactionPayload::EntryFunction(
1261                EntryFunction::apt_transfer(AccountAddress::ONE, 0).unwrap(),
1262            ),
1263            100_000,
1264            100,
1265            std::time::SystemTime::now()
1266                .duration_since(std::time::UNIX_EPOCH)
1267                .unwrap()
1268                .as_secs()
1269                .saturating_add(600),
1270            ChainId::testnet(),
1271        );
1272        let auth = TransactionAuthenticator::Ed25519 {
1273            public_key: Ed25519PublicKey([0u8; 32]),
1274            signature: Ed25519Signature([0u8; 64]),
1275        };
1276        SignedTransaction::new(raw, auth)
1277    }
1278
1279    fn simulate_response_json() -> serde_json::Value {
1280        serde_json::json!([{
1281            "success": true,
1282            "vm_status": "Executed successfully",
1283            "gas_used": "100",
1284            "max_gas_amount": "200000",
1285            "gas_unit_price": "100",
1286            "hash": "0x1",
1287            "changes": [],
1288            "events": []
1289        }])
1290    }
1291
1292    #[tokio::test]
1293    async fn test_get_ledger_info() {
1294        let server = MockServer::start().await;
1295
1296        Mock::given(method("GET"))
1297            .and(path("/v1"))
1298            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1299                "chain_id": 2,
1300                "epoch": "100",
1301                "ledger_version": "12345",
1302                "oldest_ledger_version": "0",
1303                "ledger_timestamp": "1000000",
1304                "node_role": "full_node",
1305                "oldest_block_height": "0",
1306                "block_height": "5000"
1307            })))
1308            .expect(1)
1309            .mount(&server)
1310            .await;
1311
1312        let client = create_mock_client(&server);
1313        let result = client.get_ledger_info().await.unwrap();
1314
1315        assert_eq!(result.data.chain_id, 2);
1316        assert_eq!(result.data.version().unwrap(), 12345);
1317        assert_eq!(result.data.height().unwrap(), 5000);
1318    }
1319
1320    #[tokio::test]
1321    async fn test_get_account() {
1322        let server = MockServer::start().await;
1323
1324        Mock::given(method("GET"))
1325            .and(path_regex(r"^/v1/accounts/0x[0-9a-f]+$"))
1326            .respond_with(
1327                ResponseTemplate::new(200)
1328                    .set_body_json(serde_json::json!({
1329                        "sequence_number": "42",
1330                        "authentication_key": "0x0000000000000000000000000000000000000000000000000000000000000001"
1331                    }))
1332                    .insert_header("x-aptos-ledger-version", "12345"),
1333            )
1334            .expect(1)
1335            .mount(&server)
1336            .await;
1337
1338        let client = create_mock_client(&server);
1339        let result = client.get_account(AccountAddress::ONE).await.unwrap();
1340
1341        assert_eq!(result.data.sequence_number().unwrap(), 42);
1342        assert_eq!(result.ledger_version, Some(12345));
1343    }
1344
1345    #[tokio::test]
1346    async fn test_get_account_not_found() {
1347        let server = MockServer::start().await;
1348
1349        Mock::given(method("GET"))
1350            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+"))
1351            .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
1352                "message": "Account not found",
1353                "error_code": "account_not_found"
1354            })))
1355            .expect(1)
1356            .mount(&server)
1357            .await;
1358
1359        let client = create_mock_client(&server);
1360        let result = client.get_account(AccountAddress::ONE).await;
1361
1362        assert!(result.is_err());
1363        let err = result.unwrap_err();
1364        assert!(err.is_not_found());
1365    }
1366
1367    #[tokio::test]
1368    async fn test_get_account_resources() {
1369        let server = MockServer::start().await;
1370
1371        Mock::given(method("GET"))
1372            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+/resources"))
1373            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
1374                {
1375                    "type": "0x1::account::Account",
1376                    "data": {"sequence_number": "10"}
1377                },
1378                {
1379                    "type": "0x1::coin::CoinStore<0x1::aptos_coin::AptosCoin>",
1380                    "data": {"coin": {"value": "1000000"}}
1381                }
1382            ])))
1383            .expect(1)
1384            .mount(&server)
1385            .await;
1386
1387        let client = create_mock_client(&server);
1388        let result = client
1389            .get_account_resources(AccountAddress::ONE)
1390            .await
1391            .unwrap();
1392
1393        assert_eq!(result.data.len(), 2);
1394        assert!(result.data[0].typ.contains("Account"));
1395    }
1396
1397    #[tokio::test]
1398    async fn test_get_account_resource() {
1399        let server = MockServer::start().await;
1400
1401        Mock::given(method("GET"))
1402            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+/resource/.*"))
1403            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1404                "type": "0x1::coin::CoinStore<0x1::aptos_coin::AptosCoin>",
1405                "data": {"coin": {"value": "5000000"}}
1406            })))
1407            .expect(1)
1408            .mount(&server)
1409            .await;
1410
1411        let client = create_mock_client(&server);
1412        let result = client
1413            .get_account_resource(
1414                AccountAddress::ONE,
1415                "0x1::coin::CoinStore<0x1::aptos_coin::AptosCoin>",
1416            )
1417            .await
1418            .unwrap();
1419
1420        assert!(result.data.typ.contains("CoinStore"));
1421    }
1422
1423    #[tokio::test]
1424    async fn test_get_account_modules() {
1425        let server = MockServer::start().await;
1426
1427        Mock::given(method("GET"))
1428            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+/modules"))
1429            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
1430                {
1431                    "bytecode": "0xabc123",
1432                    "abi": {
1433                        "address": "0x1",
1434                        "name": "coin",
1435                        "exposed_functions": [],
1436                        "structs": []
1437                    }
1438                }
1439            ])))
1440            .expect(1)
1441            .mount(&server)
1442            .await;
1443
1444        let client = create_mock_client(&server);
1445        let result = client
1446            .get_account_modules(AccountAddress::ONE)
1447            .await
1448            .unwrap();
1449
1450        assert_eq!(result.data.len(), 1);
1451        assert!(result.data[0].abi.is_some());
1452    }
1453
1454    #[tokio::test]
1455    async fn test_get_account_resources_paginated_sends_start_and_limit() {
1456        let server = MockServer::start().await;
1457
1458        // Verify the SDK forwards both query params verbatim.
1459        Mock::given(method("GET"))
1460            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+/resources"))
1461            .and(query_param("start", "42"))
1462            .and(query_param("limit", "9"))
1463            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
1464            .expect(1)
1465            .mount(&server)
1466            .await;
1467
1468        let client = create_mock_client(&server);
1469        let result = client
1470            .get_account_resources_paginated(AccountAddress::ONE, Some("42"), Some(9))
1471            .await
1472            .unwrap();
1473        assert_eq!(result.data.len(), 0);
1474    }
1475
1476    #[tokio::test]
1477    async fn test_get_account_resources_paginated_round_trips_opaque_cursor() {
1478        // The `x-aptos-cursor` header is opaque (`Option<String>` on
1479        // `AptosResponse`). A caller pulling page N+1 must be able to pass
1480        // page N's cursor verbatim, even when it's not a decimal integer.
1481        let server = MockServer::start().await;
1482        let opaque = "0x0a1b2c3d_state_key_token";
1483        Mock::given(method("GET"))
1484            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+/resources"))
1485            .and(query_param("start", opaque))
1486            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
1487            .expect(1)
1488            .mount(&server)
1489            .await;
1490
1491        let client = create_mock_client(&server);
1492        client
1493            .get_account_resources_paginated(AccountAddress::ONE, Some(opaque), None)
1494            .await
1495            .unwrap();
1496    }
1497
1498    #[tokio::test]
1499    async fn test_get_account_resources_no_pagination_omits_query() {
1500        let server = MockServer::start().await;
1501
1502        // When both args are None, no `start`/`limit` query params should
1503        // be appended -- the fullnode default page applies.
1504        Mock::given(method("GET"))
1505            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+/resources$"))
1506            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
1507            .expect(1)
1508            .mount(&server)
1509            .await;
1510
1511        let client = create_mock_client(&server);
1512        client
1513            .get_account_resources(AccountAddress::ONE)
1514            .await
1515            .unwrap();
1516    }
1517
1518    #[tokio::test]
1519    async fn test_get_account_resources_paginated_sends_start_only() {
1520        // Start without limit: caller is paging from a saved cursor and is
1521        // happy with the fullnode default page size.
1522        let server = MockServer::start().await;
1523        Mock::given(method("GET"))
1524            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+/resources"))
1525            .and(query_param("start", "1234"))
1526            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
1527            .expect(1)
1528            .mount(&server)
1529            .await;
1530
1531        let client = create_mock_client(&server);
1532        client
1533            .get_account_resources_paginated(AccountAddress::ONE, Some("1234"), None)
1534            .await
1535            .unwrap();
1536    }
1537
1538    #[tokio::test]
1539    async fn test_get_account_modules_paginated_sends_start_and_limit() {
1540        let server = MockServer::start().await;
1541        Mock::given(method("GET"))
1542            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+/modules"))
1543            .and(query_param("start", "7"))
1544            .and(query_param("limit", "100"))
1545            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
1546            .expect(1)
1547            .mount(&server)
1548            .await;
1549
1550        let client = create_mock_client(&server);
1551        client
1552            .get_account_modules_paginated(AccountAddress::ONE, Some("7"), Some(100))
1553            .await
1554            .unwrap();
1555    }
1556
1557    #[tokio::test]
1558    async fn test_get_account_modules_no_pagination_omits_query() {
1559        // Symmetric with the resources variant: no `start` / `limit` query
1560        // params should be appended when both are None.
1561        let server = MockServer::start().await;
1562        Mock::given(method("GET"))
1563            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+/modules$"))
1564            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
1565            .expect(1)
1566            .mount(&server)
1567            .await;
1568
1569        let client = create_mock_client(&server);
1570        client
1571            .get_account_modules(AccountAddress::ONE)
1572            .await
1573            .unwrap();
1574    }
1575
1576    #[tokio::test]
1577    async fn test_get_account_modules_paginated_sends_limit_only() {
1578        let server = MockServer::start().await;
1579
1580        // Only `limit` is sent when `start` is omitted -- caller is fetching
1581        // the first page with a custom page size.
1582        Mock::given(method("GET"))
1583            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+/modules"))
1584            .and(query_param("limit", "25"))
1585            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
1586            .expect(1)
1587            .mount(&server)
1588            .await;
1589
1590        let client = create_mock_client(&server);
1591        client
1592            .get_account_modules_paginated(AccountAddress::ONE, None, Some(25))
1593            .await
1594            .unwrap();
1595    }
1596
1597    #[tokio::test]
1598    async fn test_estimate_gas_price() {
1599        let server = MockServer::start().await;
1600
1601        Mock::given(method("GET"))
1602            .and(path("/v1/estimate_gas_price"))
1603            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1604                "deprioritized_gas_estimate": 50,
1605                "gas_estimate": 100,
1606                "prioritized_gas_estimate": 150
1607            })))
1608            .expect(1)
1609            .mount(&server)
1610            .await;
1611
1612        let client = create_mock_client(&server);
1613        let result = client.estimate_gas_price().await.unwrap();
1614
1615        assert_eq!(result.data.gas_estimate, 100);
1616        assert_eq!(result.data.low(), 50);
1617        assert_eq!(result.data.high(), 150);
1618    }
1619
1620    #[tokio::test]
1621    async fn test_get_transaction_by_hash() {
1622        let server = MockServer::start().await;
1623
1624        Mock::given(method("GET"))
1625            .and(path_regex(r"/v1/transactions/by_hash/0x[0-9a-f]+"))
1626            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1627                "version": "12345",
1628                "hash": "0x0000000000000000000000000000000000000000000000000000000000000001",
1629                "success": true,
1630                "vm_status": "Executed successfully"
1631            })))
1632            .expect(1)
1633            .mount(&server)
1634            .await;
1635
1636        let client = create_mock_client(&server);
1637        let hash = HashValue::from_hex(
1638            "0x0000000000000000000000000000000000000000000000000000000000000001",
1639        )
1640        .unwrap();
1641        let result = client.get_transaction_by_hash(&hash).await.unwrap();
1642
1643        assert!(
1644            result
1645                .data
1646                .get("success")
1647                .and_then(serde_json::Value::as_bool)
1648                .unwrap()
1649        );
1650    }
1651
1652    #[tokio::test]
1653    async fn test_wait_for_transaction_success() {
1654        let server = MockServer::start().await;
1655
1656        Mock::given(method("GET"))
1657            .and(path_regex(r"/v1/transactions/by_hash/0x[0-9a-f]+"))
1658            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1659                "type": "user_transaction",
1660                "version": "12345",
1661                "hash": "0x0000000000000000000000000000000000000000000000000000000000000001",
1662                "success": true,
1663                "vm_status": "Executed successfully"
1664            })))
1665            .expect(1..)
1666            .mount(&server)
1667            .await;
1668
1669        let client = create_mock_client(&server);
1670        let hash = HashValue::from_hex(
1671            "0x0000000000000000000000000000000000000000000000000000000000000001",
1672        )
1673        .unwrap();
1674        let result = client
1675            .wait_for_transaction(&hash, Some(Duration::from_secs(5)))
1676            .await
1677            .unwrap();
1678
1679        assert!(
1680            result
1681                .data
1682                .get("success")
1683                .and_then(serde_json::Value::as_bool)
1684                .unwrap()
1685        );
1686    }
1687
1688    #[tokio::test]
1689    async fn test_server_error_retryable() {
1690        let server = MockServer::start().await;
1691
1692        Mock::given(method("GET"))
1693            .and(path("/v1"))
1694            .respond_with(ResponseTemplate::new(503).set_body_json(serde_json::json!({
1695                "message": "Service temporarily unavailable"
1696            })))
1697            .expect(1)
1698            .mount(&server)
1699            .await;
1700
1701        let url = format!("{}/v1", server.uri());
1702        let config = AptosConfig::custom(&url).unwrap().without_retry();
1703        let client = FullnodeClient::new(config).unwrap();
1704        let result = client.get_ledger_info().await;
1705
1706        assert!(result.is_err());
1707        assert!(result.unwrap_err().is_retryable());
1708    }
1709
1710    #[tokio::test]
1711    async fn test_rate_limited() {
1712        let server = MockServer::start().await;
1713
1714        Mock::given(method("GET"))
1715            .and(path("/v1"))
1716            .respond_with(
1717                ResponseTemplate::new(429)
1718                    .set_body_json(serde_json::json!({
1719                        "message": "Rate limited"
1720                    }))
1721                    .insert_header("retry-after", "30"),
1722            )
1723            .expect(1)
1724            .mount(&server)
1725            .await;
1726
1727        let url = format!("{}/v1", server.uri());
1728        let config = AptosConfig::custom(&url).unwrap().without_retry();
1729        let client = FullnodeClient::new(config).unwrap();
1730        let result = client.get_ledger_info().await;
1731
1732        assert!(result.is_err());
1733        assert!(result.unwrap_err().is_retryable());
1734    }
1735
1736    #[tokio::test]
1737    async fn test_get_block_by_height() {
1738        let server = MockServer::start().await;
1739
1740        Mock::given(method("GET"))
1741            .and(path_regex(r"/v1/blocks/by_height/\d+"))
1742            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1743                "block_height": "1000",
1744                "block_hash": "0xabc",
1745                "block_timestamp": "1234567890",
1746                "first_version": "100",
1747                "last_version": "200"
1748            })))
1749            .expect(1)
1750            .mount(&server)
1751            .await;
1752
1753        let client = create_mock_client(&server);
1754        let result = client.get_block_by_height(1000, false).await.unwrap();
1755
1756        assert!(result.data.get("block_height").is_some());
1757    }
1758
1759    #[tokio::test]
1760    async fn test_view() {
1761        let server = MockServer::start().await;
1762
1763        Mock::given(method("POST"))
1764            .and(path("/v1/view"))
1765            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!(["1000000"])))
1766            .expect(1)
1767            .mount(&server)
1768            .await;
1769
1770        let client = create_mock_client(&server);
1771        let result: AptosResponse<Vec<serde_json::Value>> = client
1772            .view(
1773                "0x1::coin::balance",
1774                vec!["0x1::aptos_coin::AptosCoin".to_string()],
1775                vec![serde_json::json!("0x1")],
1776            )
1777            .await
1778            .unwrap();
1779
1780        assert_eq!(result.data.len(), 1);
1781    }
1782
1783    #[tokio::test]
1784    async fn test_view_bcs_args_posts_bcs_request() {
1785        let server = MockServer::start().await;
1786
1787        // Arguments are real BCS bytes; the request must be sent as a BCS
1788        // `ViewRequest` body (Content-Type application/x.aptos.view_function+bcs),
1789        // not JSON. Pin the exact body bytes to guard the wire format: it must
1790        // equal the BCS of an `EntryFunction`-shaped `ViewRequest`.
1791        let args = vec![aptos_bcs::to_bytes(&AccountAddress::ONE).unwrap()];
1792        let expected_body = aptos_bcs::to_bytes(
1793            &crate::transaction::EntryFunction::from_function_id(
1794                "0x1::coin::balance",
1795                vec![],
1796                args.clone(),
1797            )
1798            .unwrap(),
1799        )
1800        .unwrap();
1801
1802        Mock::given(method("POST"))
1803            .and(path("/v1/view"))
1804            .and(wiremock::matchers::header(
1805                "content-type",
1806                "application/x.aptos.view_function+bcs",
1807            ))
1808            .and(wiremock::matchers::body_bytes(expected_body))
1809            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!(["1000000"])))
1810            .expect(1)
1811            .mount(&server)
1812            .await;
1813
1814        let client = create_mock_client(&server);
1815        let result = client
1816            .view_bcs_args("0x1::coin::balance", vec![], args)
1817            .await
1818            .unwrap()
1819            .into_inner();
1820
1821        assert_eq!(result.len(), 1);
1822        assert_eq!(result[0].as_str().unwrap(), "1000000");
1823    }
1824
1825    #[tokio::test]
1826    async fn test_simulate_transaction_with_estimate_gas_unit_price() {
1827        let server = MockServer::start().await;
1828
1829        Mock::given(method("POST"))
1830            .and(path("/v1/transactions/simulate"))
1831            .and(|req: &wiremock::Request| {
1832                req.url
1833                    .query()
1834                    .is_some_and(|q| q.contains("estimate_gas_unit_price=true"))
1835            })
1836            .respond_with(ResponseTemplate::new(200).set_body_json(simulate_response_json()))
1837            .expect(1)
1838            .mount(&server)
1839            .await;
1840
1841        let client = create_mock_client(&server);
1842        let signed = create_minimal_signed_transaction();
1843        let opts = SimulateQueryOptions::new().estimate_gas_unit_price(true);
1844        let result = client
1845            .simulate_transaction_with_options(&signed, opts)
1846            .await
1847            .unwrap();
1848        assert!(!result.data.is_empty());
1849    }
1850
1851    #[tokio::test]
1852    async fn test_simulate_transaction_with_estimate_max_gas_amount() {
1853        let server = MockServer::start().await;
1854
1855        Mock::given(method("POST"))
1856            .and(path("/v1/transactions/simulate"))
1857            .and(|req: &wiremock::Request| {
1858                req.url
1859                    .query()
1860                    .is_some_and(|q| q.contains("estimate_max_gas_amount=true"))
1861            })
1862            .respond_with(ResponseTemplate::new(200).set_body_json(simulate_response_json()))
1863            .expect(1)
1864            .mount(&server)
1865            .await;
1866
1867        let client = create_mock_client(&server);
1868        let signed = create_minimal_signed_transaction();
1869        let opts = SimulateQueryOptions::new().estimate_max_gas_amount(true);
1870        let result = client
1871            .simulate_transaction_with_options(&signed, opts)
1872            .await
1873            .unwrap();
1874        assert!(!result.data.is_empty());
1875    }
1876
1877    #[tokio::test]
1878    async fn test_simulate_transaction_with_estimate_prioritized_gas_unit_price() {
1879        let server = MockServer::start().await;
1880
1881        Mock::given(method("POST"))
1882            .and(path("/v1/transactions/simulate"))
1883            .and(|req: &wiremock::Request| {
1884                req.url
1885                    .query()
1886                    .is_some_and(|q| q.contains("estimate_prioritized_gas_unit_price=true"))
1887            })
1888            .respond_with(ResponseTemplate::new(200).set_body_json(simulate_response_json()))
1889            .expect(1)
1890            .mount(&server)
1891            .await;
1892
1893        let client = create_mock_client(&server);
1894        let signed = create_minimal_signed_transaction();
1895        let opts = SimulateQueryOptions::new().estimate_prioritized_gas_unit_price(true);
1896        let result = client
1897            .simulate_transaction_with_options(&signed, opts)
1898            .await
1899            .unwrap();
1900        assert!(!result.data.is_empty());
1901    }
1902
1903    #[tokio::test]
1904    async fn test_simulate_transaction_with_all_options() {
1905        let server = MockServer::start().await;
1906
1907        Mock::given(method("POST"))
1908            .and(path("/v1/transactions/simulate"))
1909            .and(|req: &wiremock::Request| {
1910                req.url.query().is_some_and(|q| {
1911                    q.contains("estimate_gas_unit_price=true")
1912                        && q.contains("estimate_max_gas_amount=true")
1913                        && q.contains("estimate_prioritized_gas_unit_price=true")
1914                })
1915            })
1916            .respond_with(ResponseTemplate::new(200).set_body_json(simulate_response_json()))
1917            .expect(1)
1918            .mount(&server)
1919            .await;
1920
1921        let client = create_mock_client(&server);
1922        let signed = create_minimal_signed_transaction();
1923        let opts = SimulateQueryOptions::new()
1924            .estimate_gas_unit_price(true)
1925            .estimate_max_gas_amount(true)
1926            .estimate_prioritized_gas_unit_price(true);
1927        let result = client
1928            .simulate_transaction_with_options(&signed, opts)
1929            .await
1930            .unwrap();
1931        assert!(!result.data.is_empty());
1932    }
1933
1934    #[tokio::test]
1935    async fn test_simulate_transaction_without_options() {
1936        let server = MockServer::start().await;
1937
1938        // Mock must NOT match if query contains any of the simulate options (so we use path only and expect no query param)
1939        Mock::given(method("POST"))
1940            .and(path("/v1/transactions/simulate"))
1941            .and(|req: &wiremock::Request| {
1942                // URL must not contain the simulate query params when options is None
1943                req.url.query().is_none_or(|q| {
1944                    !q.contains("estimate_gas_unit_price=")
1945                        && !q.contains("estimate_max_gas_amount=")
1946                        && !q.contains("estimate_prioritized_gas_unit_price=")
1947                })
1948            })
1949            .respond_with(ResponseTemplate::new(200).set_body_json(simulate_response_json()))
1950            .expect(1)
1951            .mount(&server)
1952            .await;
1953
1954        let client = create_mock_client(&server);
1955        let signed = create_minimal_signed_transaction();
1956        let result = client.simulate_transaction(&signed).await.unwrap();
1957        assert!(!result.data.is_empty());
1958    }
1959
1960    #[tokio::test]
1961    async fn test_get_table_item() {
1962        let server = MockServer::start().await;
1963
1964        // The endpoint is POST /tables/{handle}/item with a JSON body carrying
1965        // the key/value Move types and the key. Pin the body so the wire format
1966        // is guarded.
1967        Mock::given(method("POST"))
1968            .and(path_regex(r"^/v1/tables/0x[0-9a-f]+/item$"))
1969            .and(wiremock::matchers::body_json(serde_json::json!({
1970                "key_type": "address",
1971                "value_type": "u64",
1972                "key": "0x1",
1973            })))
1974            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!("42")))
1975            .expect(1)
1976            .mount(&server)
1977            .await;
1978
1979        let client = create_mock_client(&server);
1980        let result = client
1981            .get_table_item(
1982                "0x0000000000000000000000000000000000000000000000000000000000000abc",
1983                "address",
1984                "u64",
1985                serde_json::json!("0x1"),
1986            )
1987            .await
1988            .unwrap();
1989
1990        assert_eq!(result.data, serde_json::json!("42"));
1991    }
1992
1993    #[tokio::test]
1994    async fn test_get_table_item_missing_key_is_404() {
1995        let server = MockServer::start().await;
1996
1997        Mock::given(method("POST"))
1998            .and(path_regex(r"^/v1/tables/0x[0-9a-f]+/item$"))
1999            .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
2000                "message": "Table item not found",
2001                "error_code": "table_item_not_found"
2002            })))
2003            .expect(1)
2004            .mount(&server)
2005            .await;
2006
2007        let client = create_mock_client(&server);
2008        let err = client
2009            .get_table_item("0xabc", "address", "u64", serde_json::json!("0x2"))
2010            .await
2011            .unwrap_err();
2012
2013        assert!(matches!(
2014            err,
2015            AptosError::Api {
2016                status_code: 404,
2017                ..
2018            }
2019        ));
2020    }
2021
2022    #[tokio::test]
2023    async fn test_get_transaction_by_version() {
2024        let server = MockServer::start().await;
2025
2026        Mock::given(method("GET"))
2027            .and(path("/v1/transactions/by_version/100"))
2028            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2029                "version": "100",
2030                "hash": "0xdead",
2031                "success": true
2032            })))
2033            .expect(1)
2034            .mount(&server)
2035            .await;
2036
2037        let client = create_mock_client(&server);
2038        let result = client.get_transaction_by_version(100).await.unwrap();
2039
2040        assert_eq!(result.data.get("version").unwrap(), "100");
2041    }
2042
2043    #[tokio::test]
2044    async fn test_get_transactions_with_pagination() {
2045        let server = MockServer::start().await;
2046
2047        Mock::given(method("GET"))
2048            .and(path("/v1/transactions"))
2049            .and(query_param("start", "10"))
2050            .and(query_param("limit", "2"))
2051            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
2052                {"version": "10"},
2053                {"version": "11"}
2054            ])))
2055            .expect(1)
2056            .mount(&server)
2057            .await;
2058
2059        let client = create_mock_client(&server);
2060        let result = client.get_transactions(Some(10), Some(2)).await.unwrap();
2061
2062        assert_eq!(result.data.len(), 2);
2063    }
2064
2065    #[tokio::test]
2066    async fn test_get_account_transactions() {
2067        let server = MockServer::start().await;
2068
2069        Mock::given(method("GET"))
2070            .and(path_regex(r"^/v1/accounts/0x[0-9a-f]+/transactions$"))
2071            .and(query_param("start", "0"))
2072            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
2073                {"version": "5", "sender": "0x1"}
2074            ])))
2075            .expect(1)
2076            .mount(&server)
2077            .await;
2078
2079        let client = create_mock_client(&server);
2080        let result = client
2081            .get_account_transactions(AccountAddress::ONE, Some(0), None)
2082            .await
2083            .unwrap();
2084
2085        assert_eq!(result.data.len(), 1);
2086    }
2087
2088    #[tokio::test]
2089    async fn test_get_events_by_creation_number() {
2090        let server = MockServer::start().await;
2091
2092        Mock::given(method("GET"))
2093            .and(path_regex(r"^/v1/accounts/0x[0-9a-f]+/events/7$"))
2094            .and(query_param("limit", "25"))
2095            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
2096                {"sequence_number": "0", "type": "0x1::coin::DepositEvent"}
2097            ])))
2098            .expect(1)
2099            .mount(&server)
2100            .await;
2101
2102        let client = create_mock_client(&server);
2103        let result = client
2104            .get_events_by_creation_number(AccountAddress::ONE, 7, None, Some(25))
2105            .await
2106            .unwrap();
2107
2108        assert_eq!(result.data.len(), 1);
2109    }
2110
2111    // === Simple accessors ===
2112
2113    #[test]
2114    fn test_base_url_and_config_accessors() {
2115        let config = AptosConfig::testnet();
2116        let expected = config.fullnode_url().clone();
2117        let client = FullnodeClient::new(config).unwrap();
2118
2119        // `base_url` returns the configured fullnode URL.
2120        assert_eq!(client.base_url(), &expected);
2121        // `config` exposes the same URL through the backing config.
2122        assert_eq!(client.config().fullnode_url(), &expected);
2123    }
2124
2125    #[test]
2126    fn test_retry_config_accessor() {
2127        // A client built with a custom max_retries should surface it verbatim.
2128        let config = AptosConfig::testnet().with_max_retries(7);
2129        let client = FullnodeClient::new(config).unwrap();
2130        assert_eq!(client.retry_config().max_retries, 7);
2131
2132        // `without_retry` disables retries entirely.
2133        let client = FullnodeClient::new(AptosConfig::testnet().without_retry()).unwrap();
2134        assert_eq!(client.retry_config().max_retries, 0);
2135    }
2136
2137    // === get_sequence_number ===
2138
2139    #[tokio::test]
2140    async fn test_get_sequence_number() {
2141        let server = MockServer::start().await;
2142
2143        Mock::given(method("GET"))
2144            .and(path_regex(r"^/v1/accounts/0x[0-9a-f]+$"))
2145            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2146                "sequence_number": "99",
2147                "authentication_key": "0x0000000000000000000000000000000000000000000000000000000000000001"
2148            })))
2149            .expect(1)
2150            .mount(&server)
2151            .await;
2152
2153        let client = create_mock_client(&server);
2154        let seq = client
2155            .get_sequence_number(AccountAddress::ONE)
2156            .await
2157            .unwrap();
2158        assert_eq!(seq, 99);
2159    }
2160
2161    #[tokio::test]
2162    async fn test_get_sequence_number_unparseable_is_internal_error() {
2163        let server = MockServer::start().await;
2164
2165        // A non-numeric sequence number must surface as an Internal error
2166        // rather than panicking.
2167        Mock::given(method("GET"))
2168            .and(path_regex(r"^/v1/accounts/0x[0-9a-f]+$"))
2169            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2170                "sequence_number": "not-a-number",
2171                "authentication_key": "0x0000000000000000000000000000000000000000000000000000000000000001"
2172            })))
2173            .expect(1)
2174            .mount(&server)
2175            .await;
2176
2177        let client = create_mock_client(&server);
2178        let err = client
2179            .get_sequence_number(AccountAddress::ONE)
2180            .await
2181            .unwrap_err();
2182        assert!(matches!(err, AptosError::Internal(_)));
2183    }
2184
2185    // === get_account_module ===
2186
2187    #[tokio::test]
2188    async fn test_get_account_module() {
2189        let server = MockServer::start().await;
2190
2191        Mock::given(method("GET"))
2192            .and(path_regex(r"^/v1/accounts/0x[0-9a-f]+/module/coin$"))
2193            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2194                "bytecode": "0xdeadbeef",
2195                "abi": {
2196                    "address": "0x1",
2197                    "name": "coin",
2198                    "exposed_functions": [],
2199                    "structs": []
2200                }
2201            })))
2202            .expect(1)
2203            .mount(&server)
2204            .await;
2205
2206        let client = create_mock_client(&server);
2207        let result = client
2208            .get_account_module(AccountAddress::ONE, "coin")
2209            .await
2210            .unwrap();
2211
2212        assert_eq!(result.data.bytecode, "0xdeadbeef");
2213        assert!(result.data.abi.is_some());
2214    }
2215
2216    #[tokio::test]
2217    async fn test_get_account_module_not_found() {
2218        let server = MockServer::start().await;
2219
2220        Mock::given(method("GET"))
2221            .and(path_regex(r"^/v1/accounts/0x[0-9a-f]+/module/.*$"))
2222            .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
2223                "message": "Module not found",
2224                "error_code": "module_not_found"
2225            })))
2226            .expect(1)
2227            .mount(&server)
2228            .await;
2229
2230        let client = create_mock_client(&server);
2231        let err = client
2232            .get_account_module(AccountAddress::ONE, "missing")
2233            .await
2234            .unwrap_err();
2235        assert!(err.is_not_found());
2236    }
2237
2238    // === get_account_balance ===
2239
2240    #[tokio::test]
2241    async fn test_get_account_balance() {
2242        let server = MockServer::start().await;
2243
2244        // Balance is fetched via the 0x1::coin::balance view function, which
2245        // returns a single-element array holding a stringified u64.
2246        Mock::given(method("POST"))
2247            .and(path("/v1/view"))
2248            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!(["1234567"])))
2249            .expect(1)
2250            .mount(&server)
2251            .await;
2252
2253        let client = create_mock_client(&server);
2254        let balance = client
2255            .get_account_balance(AccountAddress::ONE)
2256            .await
2257            .unwrap();
2258        assert_eq!(balance, 1_234_567);
2259    }
2260
2261    #[tokio::test]
2262    async fn test_get_account_balance_unparseable_is_internal_error() {
2263        let server = MockServer::start().await;
2264
2265        // An empty view result cannot be parsed into a balance.
2266        Mock::given(method("POST"))
2267            .and(path("/v1/view"))
2268            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
2269            .expect(1)
2270            .mount(&server)
2271            .await;
2272
2273        let client = create_mock_client(&server);
2274        let err = client
2275            .get_account_balance(AccountAddress::ONE)
2276            .await
2277            .unwrap_err();
2278        assert!(matches!(err, AptosError::Internal(_)));
2279    }
2280
2281    // === submit_transaction ===
2282
2283    fn pending_transaction_json() -> serde_json::Value {
2284        serde_json::json!({
2285            "hash": "0x0000000000000000000000000000000000000000000000000000000000000001",
2286            "sender": "0x1",
2287            "sequence_number": "0",
2288            "max_gas_amount": "100000",
2289            "gas_unit_price": "100",
2290            "expiration_timestamp_secs": "1000000"
2291        })
2292    }
2293
2294    #[tokio::test]
2295    async fn test_submit_transaction_success() {
2296        let server = MockServer::start().await;
2297
2298        Mock::given(method("POST"))
2299            .and(path("/v1/transactions"))
2300            .and(wiremock::matchers::header(
2301                "content-type",
2302                "application/x.aptos.signed_transaction+bcs",
2303            ))
2304            .respond_with(ResponseTemplate::new(202).set_body_json(pending_transaction_json()))
2305            .expect(1)
2306            .mount(&server)
2307            .await;
2308
2309        let client = create_mock_client(&server);
2310        let signed = create_minimal_signed_transaction();
2311        let result = client.submit_transaction(&signed).await.unwrap();
2312
2313        assert_eq!(result.data.sequence_number, "0");
2314        assert_eq!(result.data.sender(), "0x1");
2315    }
2316
2317    #[tokio::test]
2318    async fn test_submit_transaction_rejected_is_api_error() {
2319        let server = MockServer::start().await;
2320
2321        // A rejected submission (e.g. invalid signature) returns HTTP 400 with
2322        // an API error body; the SDK must surface it as a non-retryable Api error.
2323        Mock::given(method("POST"))
2324            .and(path("/v1/transactions"))
2325            .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
2326                "message": "Invalid transaction",
2327                "error_code": "invalid_transaction_update"
2328            })))
2329            .expect(1)
2330            .mount(&server)
2331            .await;
2332
2333        let client = create_mock_client(&server);
2334        let signed = create_minimal_signed_transaction();
2335        let err = client.submit_transaction(&signed).await.unwrap_err();
2336
2337        match err {
2338            AptosError::Api {
2339                status_code,
2340                error_code,
2341                ..
2342            } => {
2343                assert_eq!(status_code, 400);
2344                assert_eq!(error_code.as_deref(), Some("invalid_transaction_update"));
2345            }
2346            other => panic!("expected Api error, got {other:?}"),
2347        }
2348        // 400 must not be treated as retryable.
2349        assert!(
2350            !AptosError::api(400, "x").is_retryable(),
2351            "sanity: 4xx not retryable"
2352        );
2353    }
2354
2355    // === submit_and_wait ===
2356
2357    #[tokio::test]
2358    async fn test_submit_and_wait_success() {
2359        let server = MockServer::start().await;
2360
2361        // First the submit (POST), then wait_for_transaction polls by hash (GET).
2362        Mock::given(method("POST"))
2363            .and(path("/v1/transactions"))
2364            .respond_with(ResponseTemplate::new(202).set_body_json(pending_transaction_json()))
2365            .expect(1)
2366            .mount(&server)
2367            .await;
2368
2369        Mock::given(method("GET"))
2370            .and(path_regex(r"^/v1/transactions/by_hash/0x[0-9a-f]+$"))
2371            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2372                "type": "user_transaction",
2373                "version": "555",
2374                "hash": "0x0000000000000000000000000000000000000000000000000000000000000001",
2375                "success": true,
2376                "vm_status": "Executed successfully"
2377            })))
2378            .expect(1..)
2379            .mount(&server)
2380            .await;
2381
2382        let client = create_mock_client(&server);
2383        let signed = create_minimal_signed_transaction();
2384        let result = client
2385            .submit_and_wait(&signed, Some(Duration::from_secs(5)))
2386            .await
2387            .unwrap();
2388
2389        assert_eq!(result.data.get("version").unwrap(), "555");
2390    }
2391
2392    // === wait_for_transaction error paths ===
2393
2394    #[tokio::test]
2395    async fn test_wait_for_transaction_execution_failed() {
2396        let server = MockServer::start().await;
2397
2398        // A committed-but-failed transaction (success == false) must produce an
2399        // ExecutionFailed error carrying the vm_status.
2400        Mock::given(method("GET"))
2401            .and(path_regex(r"^/v1/transactions/by_hash/0x[0-9a-f]+$"))
2402            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2403                "type": "user_transaction",
2404                "version": "12345",
2405                "hash": "0x0000000000000000000000000000000000000000000000000000000000000001",
2406                "success": false,
2407                "vm_status": "Move abort: 0x1"
2408            })))
2409            .expect(1..)
2410            .mount(&server)
2411            .await;
2412
2413        let client = create_mock_client(&server);
2414        let hash = HashValue::from_hex(
2415            "0x0000000000000000000000000000000000000000000000000000000000000001",
2416        )
2417        .unwrap();
2418        let err = client
2419            .wait_for_transaction(&hash, Some(Duration::from_secs(5)))
2420            .await
2421            .unwrap_err();
2422
2423        match err {
2424            AptosError::ExecutionFailed { vm_status } => {
2425                assert_eq!(vm_status, "Move abort: 0x1");
2426            }
2427            other => panic!("expected ExecutionFailed, got {other:?}"),
2428        }
2429    }
2430
2431    #[tokio::test]
2432    async fn test_wait_for_transaction_times_out() {
2433        let server = MockServer::start().await;
2434
2435        // The transaction never commits (perpetual 404); with a tiny timeout the
2436        // poll loop must give up and return a TransactionTimeout error.
2437        Mock::given(method("GET"))
2438            .and(path_regex(r"^/v1/transactions/by_hash/0x[0-9a-f]+$"))
2439            .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
2440                "message": "Transaction not found",
2441                "error_code": "transaction_not_found"
2442            })))
2443            .expect(1..)
2444            .mount(&server)
2445            .await;
2446
2447        let client = create_mock_client(&server);
2448        let hash = HashValue::from_hex(
2449            "0x0000000000000000000000000000000000000000000000000000000000000002",
2450        )
2451        .unwrap();
2452        let err = client
2453            .wait_for_transaction(&hash, Some(Duration::from_millis(1)))
2454            .await
2455            .unwrap_err();
2456
2457        assert!(err.is_timeout());
2458        match err {
2459            AptosError::TransactionTimeout { timeout_secs, .. } => {
2460                assert_eq!(timeout_secs, 0);
2461            }
2462            other => panic!("expected TransactionTimeout, got {other:?}"),
2463        }
2464    }
2465
2466    #[tokio::test]
2467    async fn test_wait_for_transaction_propagates_non_404_error() {
2468        let server = MockServer::start().await;
2469
2470        // A non-404 polling error (e.g. 500) must be propagated immediately
2471        // rather than being swallowed as "not committed yet".
2472        Mock::given(method("GET"))
2473            .and(path_regex(r"^/v1/transactions/by_hash/0x[0-9a-f]+$"))
2474            .respond_with(ResponseTemplate::new(500).set_body_json(serde_json::json!({
2475                "message": "boom"
2476            })))
2477            .expect(1)
2478            .mount(&server)
2479            .await;
2480
2481        let client = create_mock_client(&server);
2482        let hash = HashValue::from_hex(
2483            "0x0000000000000000000000000000000000000000000000000000000000000003",
2484        )
2485        .unwrap();
2486        let err = client
2487            .wait_for_transaction(&hash, Some(Duration::from_secs(5)))
2488            .await
2489            .unwrap_err();
2490
2491        assert!(matches!(
2492            err,
2493            AptosError::Api {
2494                status_code: 500,
2495                ..
2496            }
2497        ));
2498    }
2499
2500    // === view_bcs ===
2501
2502    #[tokio::test]
2503    async fn test_view_bcs_returns_raw_bytes() {
2504        let server = MockServer::start().await;
2505
2506        // The response is opaque BCS bytes; view_bcs must return them verbatim
2507        // without JSON decoding.
2508        let raw = vec![1u8, 2, 3, 4, 5];
2509        Mock::given(method("POST"))
2510            .and(path("/v1/view"))
2511            .and(wiremock::matchers::header("accept", "application/x-bcs"))
2512            .respond_with(ResponseTemplate::new(200).set_body_bytes(raw.clone()))
2513            .expect(1)
2514            .mount(&server)
2515            .await;
2516
2517        let client = create_mock_client(&server);
2518        let result = client
2519            .view_bcs(
2520                "0x1::coin::balance",
2521                vec!["0x1::aptos_coin::AptosCoin".to_string()],
2522                vec![aptos_bcs::to_bytes(&AccountAddress::ONE).unwrap()],
2523            )
2524            .await
2525            .unwrap();
2526
2527        assert_eq!(result.data, raw);
2528    }
2529
2530    #[tokio::test]
2531    async fn test_view_bcs_error_status_is_api_error() {
2532        let server = MockServer::start().await;
2533
2534        // view_bcs has its own error-handling branch (it reads raw bytes on
2535        // success); a non-2xx status must still map to an Api error.
2536        Mock::given(method("POST"))
2537            .and(path("/v1/view"))
2538            .respond_with(ResponseTemplate::new(400).set_body_string("bad view request"))
2539            .expect(1)
2540            .mount(&server)
2541            .await;
2542
2543        let client = create_mock_client(&server);
2544        let err = client
2545            .view_bcs("0x1::coin::balance", vec![], vec![])
2546            .await
2547            .unwrap_err();
2548
2549        match err {
2550            AptosError::Api {
2551                status_code,
2552                message,
2553                ..
2554            } => {
2555                assert_eq!(status_code, 400);
2556                assert!(message.contains("bad view request"));
2557            }
2558            other => panic!("expected Api error, got {other:?}"),
2559        }
2560    }
2561
2562    // === get_events_by_event_handle ===
2563
2564    #[tokio::test]
2565    async fn test_get_events_by_event_handle() {
2566        let server = MockServer::start().await;
2567
2568        Mock::given(method("GET"))
2569            .and(path_regex(
2570                r"^/v1/accounts/0x[0-9a-f]+/events/.+/withdraw_events$",
2571            ))
2572            .and(query_param("start", "0"))
2573            .and(query_param("limit", "10"))
2574            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
2575                {"sequence_number": "0", "type": "0x1::coin::WithdrawEvent"}
2576            ])))
2577            .expect(1)
2578            .mount(&server)
2579            .await;
2580
2581        let client = create_mock_client(&server);
2582        let result = client
2583            .get_events_by_event_handle(
2584                AccountAddress::ONE,
2585                "0x1::coin::CoinStore<0x1::aptos_coin::AptosCoin>",
2586                "withdraw_events",
2587                Some(0),
2588                Some(10),
2589            )
2590            .await
2591            .unwrap();
2592
2593        assert_eq!(result.data.len(), 1);
2594    }
2595
2596    // === get_block_by_version ===
2597
2598    #[tokio::test]
2599    async fn test_get_block_by_version() {
2600        let server = MockServer::start().await;
2601
2602        Mock::given(method("GET"))
2603            .and(path("/v1/blocks/by_version/200"))
2604            .and(query_param("with_transactions", "true"))
2605            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2606                "block_height": "50",
2607                "block_hash": "0xfeed",
2608                "block_timestamp": "1234567890",
2609                "first_version": "180",
2610                "last_version": "220"
2611            })))
2612            .expect(1)
2613            .mount(&server)
2614            .await;
2615
2616        let client = create_mock_client(&server);
2617        let result = client.get_block_by_version(200, true).await.unwrap();
2618
2619        assert_eq!(result.data.get("block_height").unwrap(), "50");
2620    }
2621
2622    // === Error-body parsing in handle_response ===
2623
2624    #[tokio::test]
2625    async fn test_api_error_parses_error_code_and_vm_error_code() {
2626        let server = MockServer::start().await;
2627
2628        // Exercise the error branch of handle_response_static: a 400 body that
2629        // carries message, error_code, and vm_error_code must all be surfaced.
2630        Mock::given(method("GET"))
2631            .and(path("/v1"))
2632            .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
2633                "message": "VM execution error",
2634                "error_code": "vm_error",
2635                "vm_error_code": 4004
2636            })))
2637            .expect(1)
2638            .mount(&server)
2639            .await;
2640
2641        let client = create_mock_client(&server);
2642        let err = client.get_ledger_info().await.unwrap_err();
2643
2644        match err {
2645            AptosError::Api {
2646                status_code,
2647                message,
2648                error_code,
2649                vm_error_code,
2650            } => {
2651                assert_eq!(status_code, 400);
2652                assert_eq!(message, "VM execution error");
2653                assert_eq!(error_code.as_deref(), Some("vm_error"));
2654                assert_eq!(vm_error_code, Some(4004));
2655            }
2656            other => panic!("expected Api error, got {other:?}"),
2657        }
2658    }
2659
2660    #[tokio::test]
2661    async fn test_api_error_non_json_body_uses_default_message() {
2662        let server = MockServer::start().await;
2663
2664        // When the error body is not valid JSON, the SDK falls back to the
2665        // "Unknown error" message and leaves the optional fields empty.
2666        Mock::given(method("GET"))
2667            .and(path("/v1"))
2668            .respond_with(ResponseTemplate::new(403).set_body_string("Forbidden (plain text)"))
2669            .expect(1)
2670            .mount(&server)
2671            .await;
2672
2673        let client = create_mock_client(&server);
2674        let err = client.get_ledger_info().await.unwrap_err();
2675
2676        match err {
2677            AptosError::Api {
2678                status_code,
2679                message,
2680                error_code,
2681                vm_error_code,
2682            } => {
2683                assert_eq!(status_code, 403);
2684                assert_eq!(message, "Unknown error");
2685                assert!(error_code.is_none());
2686                assert!(vm_error_code.is_none());
2687            }
2688            other => panic!("expected Api error, got {other:?}"),
2689        }
2690    }
2691
2692    #[tokio::test]
2693    async fn test_success_with_invalid_json_body_is_json_error() {
2694        let server = MockServer::start().await;
2695
2696        // A 200 response whose body cannot be deserialized into the expected
2697        // type must surface as a Json (deserialization) error, not a panic.
2698        Mock::given(method("GET"))
2699            .and(path("/v1"))
2700            .respond_with(ResponseTemplate::new(200).set_body_string("this is not json"))
2701            .expect(1)
2702            .mount(&server)
2703            .await;
2704
2705        let client = create_mock_client(&server);
2706        let err = client.get_ledger_info().await.unwrap_err();
2707        assert!(matches!(err, AptosError::Json(_)));
2708    }
2709}