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        let resp = client
1493            .get_account_resources_paginated(AccountAddress::ONE, Some(opaque), None)
1494            .await
1495            .unwrap();
1496        assert!(
1497            resp.data.is_empty(),
1498            "mock returned an empty page, got {} resources",
1499            resp.data.len()
1500        );
1501    }
1502
1503    #[tokio::test]
1504    async fn test_get_account_resources_no_pagination_omits_query() {
1505        let server = MockServer::start().await;
1506
1507        // When both args are None, no `start`/`limit` query params should
1508        // be appended -- the fullnode default page applies.
1509        Mock::given(method("GET"))
1510            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+/resources$"))
1511            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
1512            .expect(1)
1513            .mount(&server)
1514            .await;
1515
1516        let client = create_mock_client(&server);
1517        let resp = client
1518            .get_account_resources(AccountAddress::ONE)
1519            .await
1520            .unwrap();
1521        assert!(
1522            resp.data.is_empty(),
1523            "mock returned an empty page, got {} resources",
1524            resp.data.len()
1525        );
1526    }
1527
1528    #[tokio::test]
1529    async fn test_get_account_resources_paginated_sends_start_only() {
1530        // Start without limit: caller is paging from a saved cursor and is
1531        // happy with the fullnode default page size.
1532        let server = MockServer::start().await;
1533        Mock::given(method("GET"))
1534            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+/resources"))
1535            .and(query_param("start", "1234"))
1536            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
1537            .expect(1)
1538            .mount(&server)
1539            .await;
1540
1541        let client = create_mock_client(&server);
1542        let resp = client
1543            .get_account_resources_paginated(AccountAddress::ONE, Some("1234"), None)
1544            .await
1545            .unwrap();
1546        assert!(
1547            resp.data.is_empty(),
1548            "mock returned an empty page, got {} resources",
1549            resp.data.len()
1550        );
1551    }
1552
1553    #[tokio::test]
1554    async fn test_get_account_modules_paginated_sends_start_and_limit() {
1555        let server = MockServer::start().await;
1556        Mock::given(method("GET"))
1557            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+/modules"))
1558            .and(query_param("start", "7"))
1559            .and(query_param("limit", "100"))
1560            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
1561            .expect(1)
1562            .mount(&server)
1563            .await;
1564
1565        let client = create_mock_client(&server);
1566        let resp = client
1567            .get_account_modules_paginated(AccountAddress::ONE, Some("7"), Some(100))
1568            .await
1569            .unwrap();
1570        assert!(
1571            resp.data.is_empty(),
1572            "mock returned an empty page, got {} modules",
1573            resp.data.len()
1574        );
1575    }
1576
1577    #[tokio::test]
1578    async fn test_get_account_modules_no_pagination_omits_query() {
1579        // Symmetric with the resources variant: no `start` / `limit` query
1580        // params should be appended when both are None.
1581        let server = MockServer::start().await;
1582        Mock::given(method("GET"))
1583            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+/modules$"))
1584            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
1585            .expect(1)
1586            .mount(&server)
1587            .await;
1588
1589        let client = create_mock_client(&server);
1590        let resp = client
1591            .get_account_modules(AccountAddress::ONE)
1592            .await
1593            .unwrap();
1594        assert!(
1595            resp.data.is_empty(),
1596            "mock returned an empty page, got {} modules",
1597            resp.data.len()
1598        );
1599    }
1600
1601    #[tokio::test]
1602    async fn test_get_account_modules_paginated_sends_limit_only() {
1603        let server = MockServer::start().await;
1604
1605        // Only `limit` is sent when `start` is omitted -- caller is fetching
1606        // the first page with a custom page size.
1607        Mock::given(method("GET"))
1608            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+/modules"))
1609            .and(query_param("limit", "25"))
1610            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
1611            .expect(1)
1612            .mount(&server)
1613            .await;
1614
1615        let client = create_mock_client(&server);
1616        let resp = client
1617            .get_account_modules_paginated(AccountAddress::ONE, None, Some(25))
1618            .await
1619            .unwrap();
1620        assert!(
1621            resp.data.is_empty(),
1622            "mock returned an empty page, got {} modules",
1623            resp.data.len()
1624        );
1625    }
1626
1627    #[tokio::test]
1628    async fn test_estimate_gas_price() {
1629        let server = MockServer::start().await;
1630
1631        Mock::given(method("GET"))
1632            .and(path("/v1/estimate_gas_price"))
1633            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1634                "deprioritized_gas_estimate": 50,
1635                "gas_estimate": 100,
1636                "prioritized_gas_estimate": 150
1637            })))
1638            .expect(1)
1639            .mount(&server)
1640            .await;
1641
1642        let client = create_mock_client(&server);
1643        let result = client.estimate_gas_price().await.unwrap();
1644
1645        assert_eq!(result.data.gas_estimate, 100);
1646        assert_eq!(result.data.low(), 50);
1647        assert_eq!(result.data.high(), 150);
1648    }
1649
1650    #[tokio::test]
1651    async fn test_get_transaction_by_hash() {
1652        let server = MockServer::start().await;
1653
1654        Mock::given(method("GET"))
1655            .and(path_regex(r"/v1/transactions/by_hash/0x[0-9a-f]+"))
1656            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1657                "version": "12345",
1658                "hash": "0x0000000000000000000000000000000000000000000000000000000000000001",
1659                "success": true,
1660                "vm_status": "Executed successfully"
1661            })))
1662            .expect(1)
1663            .mount(&server)
1664            .await;
1665
1666        let client = create_mock_client(&server);
1667        let hash = HashValue::from_hex(
1668            "0x0000000000000000000000000000000000000000000000000000000000000001",
1669        )
1670        .unwrap();
1671        let result = client.get_transaction_by_hash(&hash).await.unwrap();
1672
1673        assert!(
1674            result
1675                .data
1676                .get("success")
1677                .and_then(serde_json::Value::as_bool)
1678                .unwrap()
1679        );
1680    }
1681
1682    #[tokio::test]
1683    async fn test_wait_for_transaction_success() {
1684        let server = MockServer::start().await;
1685
1686        Mock::given(method("GET"))
1687            .and(path_regex(r"/v1/transactions/by_hash/0x[0-9a-f]+"))
1688            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1689                "type": "user_transaction",
1690                "version": "12345",
1691                "hash": "0x0000000000000000000000000000000000000000000000000000000000000001",
1692                "success": true,
1693                "vm_status": "Executed successfully"
1694            })))
1695            .expect(1..)
1696            .mount(&server)
1697            .await;
1698
1699        let client = create_mock_client(&server);
1700        let hash = HashValue::from_hex(
1701            "0x0000000000000000000000000000000000000000000000000000000000000001",
1702        )
1703        .unwrap();
1704        let result = client
1705            .wait_for_transaction(&hash, Some(Duration::from_secs(5)))
1706            .await
1707            .unwrap();
1708
1709        assert!(
1710            result
1711                .data
1712                .get("success")
1713                .and_then(serde_json::Value::as_bool)
1714                .unwrap()
1715        );
1716    }
1717
1718    #[tokio::test]
1719    async fn test_server_error_retryable() {
1720        let server = MockServer::start().await;
1721
1722        Mock::given(method("GET"))
1723            .and(path("/v1"))
1724            .respond_with(ResponseTemplate::new(503).set_body_json(serde_json::json!({
1725                "message": "Service temporarily unavailable"
1726            })))
1727            .expect(1)
1728            .mount(&server)
1729            .await;
1730
1731        let url = format!("{}/v1", server.uri());
1732        let config = AptosConfig::custom(&url).unwrap().without_retry();
1733        let client = FullnodeClient::new(config).unwrap();
1734        let result = client.get_ledger_info().await;
1735
1736        assert!(result.is_err());
1737        assert!(result.unwrap_err().is_retryable());
1738    }
1739
1740    #[tokio::test]
1741    async fn test_rate_limited() {
1742        let server = MockServer::start().await;
1743
1744        Mock::given(method("GET"))
1745            .and(path("/v1"))
1746            .respond_with(
1747                ResponseTemplate::new(429)
1748                    .set_body_json(serde_json::json!({
1749                        "message": "Rate limited"
1750                    }))
1751                    .insert_header("retry-after", "30"),
1752            )
1753            .expect(1)
1754            .mount(&server)
1755            .await;
1756
1757        let url = format!("{}/v1", server.uri());
1758        let config = AptosConfig::custom(&url).unwrap().without_retry();
1759        let client = FullnodeClient::new(config).unwrap();
1760        let result = client.get_ledger_info().await;
1761
1762        assert!(result.is_err());
1763        assert!(result.unwrap_err().is_retryable());
1764    }
1765
1766    #[tokio::test]
1767    async fn test_get_block_by_height() {
1768        let server = MockServer::start().await;
1769
1770        Mock::given(method("GET"))
1771            .and(path_regex(r"/v1/blocks/by_height/\d+"))
1772            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1773                "block_height": "1000",
1774                "block_hash": "0xabc",
1775                "block_timestamp": "1234567890",
1776                "first_version": "100",
1777                "last_version": "200"
1778            })))
1779            .expect(1)
1780            .mount(&server)
1781            .await;
1782
1783        let client = create_mock_client(&server);
1784        let result = client.get_block_by_height(1000, false).await.unwrap();
1785
1786        assert!(result.data.get("block_height").is_some());
1787    }
1788
1789    #[tokio::test]
1790    async fn test_view() {
1791        let server = MockServer::start().await;
1792
1793        Mock::given(method("POST"))
1794            .and(path("/v1/view"))
1795            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!(["1000000"])))
1796            .expect(1)
1797            .mount(&server)
1798            .await;
1799
1800        let client = create_mock_client(&server);
1801        let result: AptosResponse<Vec<serde_json::Value>> = client
1802            .view(
1803                "0x1::coin::balance",
1804                vec!["0x1::aptos_coin::AptosCoin".to_string()],
1805                vec![serde_json::json!("0x1")],
1806            )
1807            .await
1808            .unwrap();
1809
1810        assert_eq!(result.data.len(), 1);
1811    }
1812
1813    #[tokio::test]
1814    async fn test_view_bcs_args_posts_bcs_request() {
1815        let server = MockServer::start().await;
1816
1817        // Arguments are real BCS bytes; the request must be sent as a BCS
1818        // `ViewRequest` body (Content-Type application/x.aptos.view_function+bcs),
1819        // not JSON. Pin the exact body bytes to guard the wire format: it must
1820        // equal the BCS of an `EntryFunction`-shaped `ViewRequest`.
1821        let args = vec![aptos_bcs::to_bytes(&AccountAddress::ONE).unwrap()];
1822        let expected_body = aptos_bcs::to_bytes(
1823            &crate::transaction::EntryFunction::from_function_id(
1824                "0x1::coin::balance",
1825                vec![],
1826                args.clone(),
1827            )
1828            .unwrap(),
1829        )
1830        .unwrap();
1831
1832        Mock::given(method("POST"))
1833            .and(path("/v1/view"))
1834            .and(wiremock::matchers::header(
1835                "content-type",
1836                "application/x.aptos.view_function+bcs",
1837            ))
1838            .and(wiremock::matchers::body_bytes(expected_body))
1839            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!(["1000000"])))
1840            .expect(1)
1841            .mount(&server)
1842            .await;
1843
1844        let client = create_mock_client(&server);
1845        let result = client
1846            .view_bcs_args("0x1::coin::balance", vec![], args)
1847            .await
1848            .unwrap()
1849            .into_inner();
1850
1851        assert_eq!(result.len(), 1);
1852        assert_eq!(result[0].as_str().unwrap(), "1000000");
1853    }
1854
1855    #[tokio::test]
1856    async fn test_simulate_transaction_with_estimate_gas_unit_price() {
1857        let server = MockServer::start().await;
1858
1859        Mock::given(method("POST"))
1860            .and(path("/v1/transactions/simulate"))
1861            .and(|req: &wiremock::Request| {
1862                req.url
1863                    .query()
1864                    .is_some_and(|q| q.contains("estimate_gas_unit_price=true"))
1865            })
1866            .respond_with(ResponseTemplate::new(200).set_body_json(simulate_response_json()))
1867            .expect(1)
1868            .mount(&server)
1869            .await;
1870
1871        let client = create_mock_client(&server);
1872        let signed = create_minimal_signed_transaction();
1873        let opts = SimulateQueryOptions::new().estimate_gas_unit_price(true);
1874        let result = client
1875            .simulate_transaction_with_options(&signed, opts)
1876            .await
1877            .unwrap();
1878        assert!(!result.data.is_empty());
1879    }
1880
1881    #[tokio::test]
1882    async fn test_simulate_transaction_with_estimate_max_gas_amount() {
1883        let server = MockServer::start().await;
1884
1885        Mock::given(method("POST"))
1886            .and(path("/v1/transactions/simulate"))
1887            .and(|req: &wiremock::Request| {
1888                req.url
1889                    .query()
1890                    .is_some_and(|q| q.contains("estimate_max_gas_amount=true"))
1891            })
1892            .respond_with(ResponseTemplate::new(200).set_body_json(simulate_response_json()))
1893            .expect(1)
1894            .mount(&server)
1895            .await;
1896
1897        let client = create_mock_client(&server);
1898        let signed = create_minimal_signed_transaction();
1899        let opts = SimulateQueryOptions::new().estimate_max_gas_amount(true);
1900        let result = client
1901            .simulate_transaction_with_options(&signed, opts)
1902            .await
1903            .unwrap();
1904        assert!(!result.data.is_empty());
1905    }
1906
1907    #[tokio::test]
1908    async fn test_simulate_transaction_with_estimate_prioritized_gas_unit_price() {
1909        let server = MockServer::start().await;
1910
1911        Mock::given(method("POST"))
1912            .and(path("/v1/transactions/simulate"))
1913            .and(|req: &wiremock::Request| {
1914                req.url
1915                    .query()
1916                    .is_some_and(|q| q.contains("estimate_prioritized_gas_unit_price=true"))
1917            })
1918            .respond_with(ResponseTemplate::new(200).set_body_json(simulate_response_json()))
1919            .expect(1)
1920            .mount(&server)
1921            .await;
1922
1923        let client = create_mock_client(&server);
1924        let signed = create_minimal_signed_transaction();
1925        let opts = SimulateQueryOptions::new().estimate_prioritized_gas_unit_price(true);
1926        let result = client
1927            .simulate_transaction_with_options(&signed, opts)
1928            .await
1929            .unwrap();
1930        assert!(!result.data.is_empty());
1931    }
1932
1933    #[tokio::test]
1934    async fn test_simulate_transaction_with_all_options() {
1935        let server = MockServer::start().await;
1936
1937        Mock::given(method("POST"))
1938            .and(path("/v1/transactions/simulate"))
1939            .and(|req: &wiremock::Request| {
1940                req.url.query().is_some_and(|q| {
1941                    q.contains("estimate_gas_unit_price=true")
1942                        && q.contains("estimate_max_gas_amount=true")
1943                        && q.contains("estimate_prioritized_gas_unit_price=true")
1944                })
1945            })
1946            .respond_with(ResponseTemplate::new(200).set_body_json(simulate_response_json()))
1947            .expect(1)
1948            .mount(&server)
1949            .await;
1950
1951        let client = create_mock_client(&server);
1952        let signed = create_minimal_signed_transaction();
1953        let opts = SimulateQueryOptions::new()
1954            .estimate_gas_unit_price(true)
1955            .estimate_max_gas_amount(true)
1956            .estimate_prioritized_gas_unit_price(true);
1957        let result = client
1958            .simulate_transaction_with_options(&signed, opts)
1959            .await
1960            .unwrap();
1961        assert!(!result.data.is_empty());
1962    }
1963
1964    #[tokio::test]
1965    async fn test_simulate_transaction_without_options() {
1966        let server = MockServer::start().await;
1967
1968        // Mock must NOT match if query contains any of the simulate options (so we use path only and expect no query param)
1969        Mock::given(method("POST"))
1970            .and(path("/v1/transactions/simulate"))
1971            .and(|req: &wiremock::Request| {
1972                // URL must not contain the simulate query params when options is None
1973                req.url.query().is_none_or(|q| {
1974                    !q.contains("estimate_gas_unit_price=")
1975                        && !q.contains("estimate_max_gas_amount=")
1976                        && !q.contains("estimate_prioritized_gas_unit_price=")
1977                })
1978            })
1979            .respond_with(ResponseTemplate::new(200).set_body_json(simulate_response_json()))
1980            .expect(1)
1981            .mount(&server)
1982            .await;
1983
1984        let client = create_mock_client(&server);
1985        let signed = create_minimal_signed_transaction();
1986        let result = client.simulate_transaction(&signed).await.unwrap();
1987        assert!(!result.data.is_empty());
1988    }
1989
1990    #[tokio::test]
1991    async fn test_get_table_item() {
1992        let server = MockServer::start().await;
1993
1994        // The endpoint is POST /tables/{handle}/item with a JSON body carrying
1995        // the key/value Move types and the key. Pin the body so the wire format
1996        // is guarded.
1997        Mock::given(method("POST"))
1998            .and(path_regex(r"^/v1/tables/0x[0-9a-f]+/item$"))
1999            .and(wiremock::matchers::body_json(serde_json::json!({
2000                "key_type": "address",
2001                "value_type": "u64",
2002                "key": "0x1",
2003            })))
2004            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!("42")))
2005            .expect(1)
2006            .mount(&server)
2007            .await;
2008
2009        let client = create_mock_client(&server);
2010        let result = client
2011            .get_table_item(
2012                "0x0000000000000000000000000000000000000000000000000000000000000abc",
2013                "address",
2014                "u64",
2015                serde_json::json!("0x1"),
2016            )
2017            .await
2018            .unwrap();
2019
2020        assert_eq!(result.data, serde_json::json!("42"));
2021    }
2022
2023    #[tokio::test]
2024    async fn test_get_table_item_missing_key_is_404() {
2025        let server = MockServer::start().await;
2026
2027        Mock::given(method("POST"))
2028            .and(path_regex(r"^/v1/tables/0x[0-9a-f]+/item$"))
2029            .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
2030                "message": "Table item not found",
2031                "error_code": "table_item_not_found"
2032            })))
2033            .expect(1)
2034            .mount(&server)
2035            .await;
2036
2037        let client = create_mock_client(&server);
2038        let err = client
2039            .get_table_item("0xabc", "address", "u64", serde_json::json!("0x2"))
2040            .await
2041            .unwrap_err();
2042
2043        assert!(matches!(
2044            err,
2045            AptosError::Api {
2046                status_code: 404,
2047                ..
2048            }
2049        ));
2050    }
2051
2052    #[tokio::test]
2053    async fn test_get_transaction_by_version() {
2054        let server = MockServer::start().await;
2055
2056        Mock::given(method("GET"))
2057            .and(path("/v1/transactions/by_version/100"))
2058            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2059                "version": "100",
2060                "hash": "0xdead",
2061                "success": true
2062            })))
2063            .expect(1)
2064            .mount(&server)
2065            .await;
2066
2067        let client = create_mock_client(&server);
2068        let result = client.get_transaction_by_version(100).await.unwrap();
2069
2070        assert_eq!(result.data.get("version").unwrap(), "100");
2071    }
2072
2073    #[tokio::test]
2074    async fn test_get_transactions_with_pagination() {
2075        let server = MockServer::start().await;
2076
2077        Mock::given(method("GET"))
2078            .and(path("/v1/transactions"))
2079            .and(query_param("start", "10"))
2080            .and(query_param("limit", "2"))
2081            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
2082                {"version": "10"},
2083                {"version": "11"}
2084            ])))
2085            .expect(1)
2086            .mount(&server)
2087            .await;
2088
2089        let client = create_mock_client(&server);
2090        let result = client.get_transactions(Some(10), Some(2)).await.unwrap();
2091
2092        assert_eq!(result.data.len(), 2);
2093    }
2094
2095    #[tokio::test]
2096    async fn test_get_account_transactions() {
2097        let server = MockServer::start().await;
2098
2099        Mock::given(method("GET"))
2100            .and(path_regex(r"^/v1/accounts/0x[0-9a-f]+/transactions$"))
2101            .and(query_param("start", "0"))
2102            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
2103                {"version": "5", "sender": "0x1"}
2104            ])))
2105            .expect(1)
2106            .mount(&server)
2107            .await;
2108
2109        let client = create_mock_client(&server);
2110        let result = client
2111            .get_account_transactions(AccountAddress::ONE, Some(0), None)
2112            .await
2113            .unwrap();
2114
2115        assert_eq!(result.data.len(), 1);
2116    }
2117
2118    #[tokio::test]
2119    async fn test_get_events_by_creation_number() {
2120        let server = MockServer::start().await;
2121
2122        Mock::given(method("GET"))
2123            .and(path_regex(r"^/v1/accounts/0x[0-9a-f]+/events/7$"))
2124            .and(query_param("limit", "25"))
2125            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
2126                {"sequence_number": "0", "type": "0x1::coin::DepositEvent"}
2127            ])))
2128            .expect(1)
2129            .mount(&server)
2130            .await;
2131
2132        let client = create_mock_client(&server);
2133        let result = client
2134            .get_events_by_creation_number(AccountAddress::ONE, 7, None, Some(25))
2135            .await
2136            .unwrap();
2137
2138        assert_eq!(result.data.len(), 1);
2139    }
2140
2141    // === Simple accessors ===
2142
2143    #[test]
2144    fn test_base_url_and_config_accessors() {
2145        let config = AptosConfig::testnet();
2146        let expected = config.fullnode_url().clone();
2147        let client = FullnodeClient::new(config).unwrap();
2148
2149        // `base_url` returns the configured fullnode URL.
2150        assert_eq!(client.base_url(), &expected);
2151        // `config` exposes the same URL through the backing config.
2152        assert_eq!(client.config().fullnode_url(), &expected);
2153    }
2154
2155    #[test]
2156    fn test_retry_config_accessor() {
2157        // A client built with a custom max_retries should surface it verbatim.
2158        let config = AptosConfig::testnet().with_max_retries(7);
2159        let client = FullnodeClient::new(config).unwrap();
2160        assert_eq!(client.retry_config().max_retries, 7);
2161
2162        // `without_retry` disables retries entirely.
2163        let client = FullnodeClient::new(AptosConfig::testnet().without_retry()).unwrap();
2164        assert_eq!(client.retry_config().max_retries, 0);
2165    }
2166
2167    // === get_sequence_number ===
2168
2169    #[tokio::test]
2170    async fn test_get_sequence_number() {
2171        let server = MockServer::start().await;
2172
2173        Mock::given(method("GET"))
2174            .and(path_regex(r"^/v1/accounts/0x[0-9a-f]+$"))
2175            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2176                "sequence_number": "99",
2177                "authentication_key": "0x0000000000000000000000000000000000000000000000000000000000000001"
2178            })))
2179            .expect(1)
2180            .mount(&server)
2181            .await;
2182
2183        let client = create_mock_client(&server);
2184        let seq = client
2185            .get_sequence_number(AccountAddress::ONE)
2186            .await
2187            .unwrap();
2188        assert_eq!(seq, 99);
2189    }
2190
2191    #[tokio::test]
2192    async fn test_get_sequence_number_unparseable_is_internal_error() {
2193        let server = MockServer::start().await;
2194
2195        // A non-numeric sequence number must surface as an Internal error
2196        // rather than panicking.
2197        Mock::given(method("GET"))
2198            .and(path_regex(r"^/v1/accounts/0x[0-9a-f]+$"))
2199            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2200                "sequence_number": "not-a-number",
2201                "authentication_key": "0x0000000000000000000000000000000000000000000000000000000000000001"
2202            })))
2203            .expect(1)
2204            .mount(&server)
2205            .await;
2206
2207        let client = create_mock_client(&server);
2208        let err = client
2209            .get_sequence_number(AccountAddress::ONE)
2210            .await
2211            .unwrap_err();
2212        assert!(matches!(err, AptosError::Internal(_)));
2213    }
2214
2215    // === get_account_module ===
2216
2217    #[tokio::test]
2218    async fn test_get_account_module() {
2219        let server = MockServer::start().await;
2220
2221        Mock::given(method("GET"))
2222            .and(path_regex(r"^/v1/accounts/0x[0-9a-f]+/module/coin$"))
2223            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2224                "bytecode": "0xdeadbeef",
2225                "abi": {
2226                    "address": "0x1",
2227                    "name": "coin",
2228                    "exposed_functions": [],
2229                    "structs": []
2230                }
2231            })))
2232            .expect(1)
2233            .mount(&server)
2234            .await;
2235
2236        let client = create_mock_client(&server);
2237        let result = client
2238            .get_account_module(AccountAddress::ONE, "coin")
2239            .await
2240            .unwrap();
2241
2242        assert_eq!(result.data.bytecode, "0xdeadbeef");
2243        assert!(result.data.abi.is_some());
2244    }
2245
2246    #[tokio::test]
2247    async fn test_get_account_module_not_found() {
2248        let server = MockServer::start().await;
2249
2250        Mock::given(method("GET"))
2251            .and(path_regex(r"^/v1/accounts/0x[0-9a-f]+/module/.*$"))
2252            .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
2253                "message": "Module not found",
2254                "error_code": "module_not_found"
2255            })))
2256            .expect(1)
2257            .mount(&server)
2258            .await;
2259
2260        let client = create_mock_client(&server);
2261        let err = client
2262            .get_account_module(AccountAddress::ONE, "missing")
2263            .await
2264            .unwrap_err();
2265        assert!(err.is_not_found());
2266    }
2267
2268    // === get_account_balance ===
2269
2270    #[tokio::test]
2271    async fn test_get_account_balance() {
2272        let server = MockServer::start().await;
2273
2274        // Balance is fetched via the 0x1::coin::balance view function, which
2275        // returns a single-element array holding a stringified u64.
2276        Mock::given(method("POST"))
2277            .and(path("/v1/view"))
2278            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!(["1234567"])))
2279            .expect(1)
2280            .mount(&server)
2281            .await;
2282
2283        let client = create_mock_client(&server);
2284        let balance = client
2285            .get_account_balance(AccountAddress::ONE)
2286            .await
2287            .unwrap();
2288        assert_eq!(balance, 1_234_567);
2289    }
2290
2291    #[tokio::test]
2292    async fn test_get_account_balance_unparseable_is_internal_error() {
2293        let server = MockServer::start().await;
2294
2295        // An empty view result cannot be parsed into a balance.
2296        Mock::given(method("POST"))
2297            .and(path("/v1/view"))
2298            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
2299            .expect(1)
2300            .mount(&server)
2301            .await;
2302
2303        let client = create_mock_client(&server);
2304        let err = client
2305            .get_account_balance(AccountAddress::ONE)
2306            .await
2307            .unwrap_err();
2308        assert!(matches!(err, AptosError::Internal(_)));
2309    }
2310
2311    // === submit_transaction ===
2312
2313    fn pending_transaction_json() -> serde_json::Value {
2314        serde_json::json!({
2315            "hash": "0x0000000000000000000000000000000000000000000000000000000000000001",
2316            "sender": "0x1",
2317            "sequence_number": "0",
2318            "max_gas_amount": "100000",
2319            "gas_unit_price": "100",
2320            "expiration_timestamp_secs": "1000000"
2321        })
2322    }
2323
2324    #[tokio::test]
2325    async fn test_submit_transaction_success() {
2326        let server = MockServer::start().await;
2327
2328        Mock::given(method("POST"))
2329            .and(path("/v1/transactions"))
2330            .and(wiremock::matchers::header(
2331                "content-type",
2332                "application/x.aptos.signed_transaction+bcs",
2333            ))
2334            .respond_with(ResponseTemplate::new(202).set_body_json(pending_transaction_json()))
2335            .expect(1)
2336            .mount(&server)
2337            .await;
2338
2339        let client = create_mock_client(&server);
2340        let signed = create_minimal_signed_transaction();
2341        let result = client.submit_transaction(&signed).await.unwrap();
2342
2343        assert_eq!(result.data.sequence_number, "0");
2344        assert_eq!(result.data.sender(), "0x1");
2345    }
2346
2347    #[tokio::test]
2348    async fn test_submit_transaction_rejected_is_api_error() {
2349        let server = MockServer::start().await;
2350
2351        // A rejected submission (e.g. invalid signature) returns HTTP 400 with
2352        // an API error body; the SDK must surface it as a non-retryable Api error.
2353        Mock::given(method("POST"))
2354            .and(path("/v1/transactions"))
2355            .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
2356                "message": "Invalid transaction",
2357                "error_code": "invalid_transaction_update"
2358            })))
2359            .expect(1)
2360            .mount(&server)
2361            .await;
2362
2363        let client = create_mock_client(&server);
2364        let signed = create_minimal_signed_transaction();
2365        let err = client.submit_transaction(&signed).await.unwrap_err();
2366
2367        match err {
2368            AptosError::Api {
2369                status_code,
2370                error_code,
2371                ..
2372            } => {
2373                assert_eq!(status_code, 400);
2374                assert_eq!(error_code.as_deref(), Some("invalid_transaction_update"));
2375            }
2376            other => panic!("expected Api error, got {other:?}"),
2377        }
2378        // 400 must not be treated as retryable.
2379        assert!(
2380            !AptosError::api(400, "x").is_retryable(),
2381            "sanity: 4xx not retryable"
2382        );
2383    }
2384
2385    // === submit_and_wait ===
2386
2387    #[tokio::test]
2388    async fn test_submit_and_wait_success() {
2389        let server = MockServer::start().await;
2390
2391        // First the submit (POST), then wait_for_transaction polls by hash (GET).
2392        Mock::given(method("POST"))
2393            .and(path("/v1/transactions"))
2394            .respond_with(ResponseTemplate::new(202).set_body_json(pending_transaction_json()))
2395            .expect(1)
2396            .mount(&server)
2397            .await;
2398
2399        Mock::given(method("GET"))
2400            .and(path_regex(r"^/v1/transactions/by_hash/0x[0-9a-f]+$"))
2401            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2402                "type": "user_transaction",
2403                "version": "555",
2404                "hash": "0x0000000000000000000000000000000000000000000000000000000000000001",
2405                "success": true,
2406                "vm_status": "Executed successfully"
2407            })))
2408            .expect(1..)
2409            .mount(&server)
2410            .await;
2411
2412        let client = create_mock_client(&server);
2413        let signed = create_minimal_signed_transaction();
2414        let result = client
2415            .submit_and_wait(&signed, Some(Duration::from_secs(5)))
2416            .await
2417            .unwrap();
2418
2419        assert_eq!(result.data.get("version").unwrap(), "555");
2420    }
2421
2422    // === wait_for_transaction error paths ===
2423
2424    #[tokio::test]
2425    async fn test_wait_for_transaction_execution_failed() {
2426        let server = MockServer::start().await;
2427
2428        // A committed-but-failed transaction (success == false) must produce an
2429        // ExecutionFailed error carrying the vm_status.
2430        Mock::given(method("GET"))
2431            .and(path_regex(r"^/v1/transactions/by_hash/0x[0-9a-f]+$"))
2432            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2433                "type": "user_transaction",
2434                "version": "12345",
2435                "hash": "0x0000000000000000000000000000000000000000000000000000000000000001",
2436                "success": false,
2437                "vm_status": "Move abort: 0x1"
2438            })))
2439            .expect(1..)
2440            .mount(&server)
2441            .await;
2442
2443        let client = create_mock_client(&server);
2444        let hash = HashValue::from_hex(
2445            "0x0000000000000000000000000000000000000000000000000000000000000001",
2446        )
2447        .unwrap();
2448        let err = client
2449            .wait_for_transaction(&hash, Some(Duration::from_secs(5)))
2450            .await
2451            .unwrap_err();
2452
2453        match err {
2454            AptosError::ExecutionFailed { vm_status } => {
2455                assert_eq!(vm_status, "Move abort: 0x1");
2456            }
2457            other => panic!("expected ExecutionFailed, got {other:?}"),
2458        }
2459    }
2460
2461    #[tokio::test]
2462    async fn test_wait_for_transaction_times_out() {
2463        let server = MockServer::start().await;
2464
2465        // The transaction never commits (perpetual 404); with a tiny timeout the
2466        // poll loop must give up and return a TransactionTimeout error.
2467        Mock::given(method("GET"))
2468            .and(path_regex(r"^/v1/transactions/by_hash/0x[0-9a-f]+$"))
2469            .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
2470                "message": "Transaction not found",
2471                "error_code": "transaction_not_found"
2472            })))
2473            .expect(1..)
2474            .mount(&server)
2475            .await;
2476
2477        let client = create_mock_client(&server);
2478        let hash = HashValue::from_hex(
2479            "0x0000000000000000000000000000000000000000000000000000000000000002",
2480        )
2481        .unwrap();
2482        let err = client
2483            .wait_for_transaction(&hash, Some(Duration::from_millis(1)))
2484            .await
2485            .unwrap_err();
2486
2487        assert!(err.is_timeout());
2488        match err {
2489            AptosError::TransactionTimeout { timeout_secs, .. } => {
2490                assert_eq!(timeout_secs, 0);
2491            }
2492            other => panic!("expected TransactionTimeout, got {other:?}"),
2493        }
2494    }
2495
2496    #[tokio::test]
2497    async fn test_wait_for_transaction_propagates_non_404_error() {
2498        let server = MockServer::start().await;
2499
2500        // A non-404 polling error (e.g. 500) must be propagated immediately
2501        // rather than being swallowed as "not committed yet".
2502        Mock::given(method("GET"))
2503            .and(path_regex(r"^/v1/transactions/by_hash/0x[0-9a-f]+$"))
2504            .respond_with(ResponseTemplate::new(500).set_body_json(serde_json::json!({
2505                "message": "boom"
2506            })))
2507            .expect(1)
2508            .mount(&server)
2509            .await;
2510
2511        let client = create_mock_client(&server);
2512        let hash = HashValue::from_hex(
2513            "0x0000000000000000000000000000000000000000000000000000000000000003",
2514        )
2515        .unwrap();
2516        let err = client
2517            .wait_for_transaction(&hash, Some(Duration::from_secs(5)))
2518            .await
2519            .unwrap_err();
2520
2521        assert!(matches!(
2522            err,
2523            AptosError::Api {
2524                status_code: 500,
2525                ..
2526            }
2527        ));
2528    }
2529
2530    // === view_bcs ===
2531
2532    #[tokio::test]
2533    async fn test_view_bcs_returns_raw_bytes() {
2534        let server = MockServer::start().await;
2535
2536        // The response is opaque BCS bytes; view_bcs must return them verbatim
2537        // without JSON decoding.
2538        let raw = vec![1u8, 2, 3, 4, 5];
2539        Mock::given(method("POST"))
2540            .and(path("/v1/view"))
2541            .and(wiremock::matchers::header("accept", "application/x-bcs"))
2542            .respond_with(ResponseTemplate::new(200).set_body_bytes(raw.clone()))
2543            .expect(1)
2544            .mount(&server)
2545            .await;
2546
2547        let client = create_mock_client(&server);
2548        let result = client
2549            .view_bcs(
2550                "0x1::coin::balance",
2551                vec!["0x1::aptos_coin::AptosCoin".to_string()],
2552                vec![aptos_bcs::to_bytes(&AccountAddress::ONE).unwrap()],
2553            )
2554            .await
2555            .unwrap();
2556
2557        assert_eq!(result.data, raw);
2558    }
2559
2560    #[tokio::test]
2561    async fn test_view_bcs_error_status_is_api_error() {
2562        let server = MockServer::start().await;
2563
2564        // view_bcs has its own error-handling branch (it reads raw bytes on
2565        // success); a non-2xx status must still map to an Api error.
2566        Mock::given(method("POST"))
2567            .and(path("/v1/view"))
2568            .respond_with(ResponseTemplate::new(400).set_body_string("bad view request"))
2569            .expect(1)
2570            .mount(&server)
2571            .await;
2572
2573        let client = create_mock_client(&server);
2574        let err = client
2575            .view_bcs("0x1::coin::balance", vec![], vec![])
2576            .await
2577            .unwrap_err();
2578
2579        match err {
2580            AptosError::Api {
2581                status_code,
2582                message,
2583                ..
2584            } => {
2585                assert_eq!(status_code, 400);
2586                assert!(message.contains("bad view request"));
2587            }
2588            other => panic!("expected Api error, got {other:?}"),
2589        }
2590    }
2591
2592    // === get_events_by_event_handle ===
2593
2594    #[tokio::test]
2595    async fn test_get_events_by_event_handle() {
2596        let server = MockServer::start().await;
2597
2598        Mock::given(method("GET"))
2599            .and(path_regex(
2600                r"^/v1/accounts/0x[0-9a-f]+/events/.+/withdraw_events$",
2601            ))
2602            .and(query_param("start", "0"))
2603            .and(query_param("limit", "10"))
2604            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
2605                {"sequence_number": "0", "type": "0x1::coin::WithdrawEvent"}
2606            ])))
2607            .expect(1)
2608            .mount(&server)
2609            .await;
2610
2611        let client = create_mock_client(&server);
2612        let result = client
2613            .get_events_by_event_handle(
2614                AccountAddress::ONE,
2615                "0x1::coin::CoinStore<0x1::aptos_coin::AptosCoin>",
2616                "withdraw_events",
2617                Some(0),
2618                Some(10),
2619            )
2620            .await
2621            .unwrap();
2622
2623        assert_eq!(result.data.len(), 1);
2624    }
2625
2626    // === get_block_by_version ===
2627
2628    #[tokio::test]
2629    async fn test_get_block_by_version() {
2630        let server = MockServer::start().await;
2631
2632        Mock::given(method("GET"))
2633            .and(path("/v1/blocks/by_version/200"))
2634            .and(query_param("with_transactions", "true"))
2635            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2636                "block_height": "50",
2637                "block_hash": "0xfeed",
2638                "block_timestamp": "1234567890",
2639                "first_version": "180",
2640                "last_version": "220"
2641            })))
2642            .expect(1)
2643            .mount(&server)
2644            .await;
2645
2646        let client = create_mock_client(&server);
2647        let result = client.get_block_by_version(200, true).await.unwrap();
2648
2649        assert_eq!(result.data.get("block_height").unwrap(), "50");
2650    }
2651
2652    // === Error-body parsing in handle_response ===
2653
2654    #[tokio::test]
2655    async fn test_api_error_parses_error_code_and_vm_error_code() {
2656        let server = MockServer::start().await;
2657
2658        // Exercise the error branch of handle_response_static: a 400 body that
2659        // carries message, error_code, and vm_error_code must all be surfaced.
2660        Mock::given(method("GET"))
2661            .and(path("/v1"))
2662            .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
2663                "message": "VM execution error",
2664                "error_code": "vm_error",
2665                "vm_error_code": 4004
2666            })))
2667            .expect(1)
2668            .mount(&server)
2669            .await;
2670
2671        let client = create_mock_client(&server);
2672        let err = client.get_ledger_info().await.unwrap_err();
2673
2674        match err {
2675            AptosError::Api {
2676                status_code,
2677                message,
2678                error_code,
2679                vm_error_code,
2680            } => {
2681                assert_eq!(status_code, 400);
2682                assert_eq!(message, "VM execution error");
2683                assert_eq!(error_code.as_deref(), Some("vm_error"));
2684                assert_eq!(vm_error_code, Some(4004));
2685            }
2686            other => panic!("expected Api error, got {other:?}"),
2687        }
2688    }
2689
2690    #[tokio::test]
2691    async fn test_api_error_non_json_body_uses_default_message() {
2692        let server = MockServer::start().await;
2693
2694        // When the error body is not valid JSON, the SDK falls back to the
2695        // "Unknown error" message and leaves the optional fields empty.
2696        Mock::given(method("GET"))
2697            .and(path("/v1"))
2698            .respond_with(ResponseTemplate::new(403).set_body_string("Forbidden (plain text)"))
2699            .expect(1)
2700            .mount(&server)
2701            .await;
2702
2703        let client = create_mock_client(&server);
2704        let err = client.get_ledger_info().await.unwrap_err();
2705
2706        match err {
2707            AptosError::Api {
2708                status_code,
2709                message,
2710                error_code,
2711                vm_error_code,
2712            } => {
2713                assert_eq!(status_code, 403);
2714                assert_eq!(message, "Unknown error");
2715                assert!(error_code.is_none());
2716                assert!(vm_error_code.is_none());
2717            }
2718            other => panic!("expected Api error, got {other:?}"),
2719        }
2720    }
2721
2722    #[tokio::test]
2723    async fn test_success_with_invalid_json_body_is_json_error() {
2724        let server = MockServer::start().await;
2725
2726        // A 200 response whose body cannot be deserialized into the expected
2727        // type must surface as a Json (deserialization) error, not a panic.
2728        Mock::given(method("GET"))
2729            .and(path("/v1"))
2730            .respond_with(ResponseTemplate::new(200).set_body_string("this is not json"))
2731            .expect(1)
2732            .mount(&server)
2733            .await;
2734
2735        let client = create_mock_client(&server);
2736        let err = client.get_ledger_info().await.unwrap_err();
2737        assert!(matches!(err, AptosError::Json(_)));
2738    }
2739}