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