Skip to main content

movement_sdk/
movement.rs

1//! Main Movement client entry point.
2//!
3//! The [`Movement`] struct provides a unified interface for all SDK functionality.
4
5use crate::account::Account;
6use crate::api::{FullnodeClient, MovementResponse, PendingTransaction};
7use crate::config::MovementConfig;
8use crate::error::{MovementError, MovementResult};
9use crate::transaction::{
10    RawTransaction, SignedTransaction, TransactionBuilder, TransactionPayload,
11};
12use crate::types::{AccountAddress, ChainId};
13use std::sync::Arc;
14use std::sync::atomic::{AtomicU8, Ordering};
15use std::time::Duration;
16
17#[cfg(feature = "ed25519")]
18use crate::transaction::EntryFunction;
19#[cfg(feature = "ed25519")]
20use crate::types::TypeTag;
21
22#[cfg(feature = "faucet")]
23use crate::api::FaucetClient;
24#[cfg(feature = "faucet")]
25use crate::types::HashValue;
26
27#[cfg(feature = "indexer")]
28use crate::api::IndexerClient;
29
30/// The main entry point for the Movement SDK.
31///
32/// This struct provides a unified interface for interacting with the Movement blockchain,
33/// including account management, transaction building and submission, and queries.
34///
35/// # Example
36///
37/// ```rust,no_run
38/// use movement_sdk::{Movement, MovementConfig};
39///
40/// #[tokio::main]
41/// async fn main() -> anyhow::Result<()> {
42///     // Create client for testnet
43///     let movement = Movement::new(MovementConfig::testnet())?;
44///
45///     // Get ledger info
46///     let ledger = movement.ledger_info().await?;
47///     println!("Ledger version: {:?}", ledger.version());
48///
49///     Ok(())
50/// }
51/// ```
52#[derive(Debug)]
53pub struct Movement {
54    config: MovementConfig,
55    fullnode: Arc<FullnodeClient>,
56    /// Resolved chain ID. Initialized from config; lazily fetched from node
57    /// for custom networks where the chain ID is unknown (0).
58    /// Stored as `AtomicU8` to avoid lock overhead for this single-byte value.
59    chain_id: AtomicU8,
60    #[cfg(feature = "faucet")]
61    faucet: Option<FaucetClient>,
62    #[cfg(feature = "indexer")]
63    indexer: Option<IndexerClient>,
64}
65
66impl Movement {
67    /// Creates a new Movement client with the given configuration.
68    ///
69    /// # Errors
70    ///
71    /// Returns an error if the HTTP client fails to build (e.g., invalid TLS configuration).
72    pub fn new(config: MovementConfig) -> MovementResult<Self> {
73        let fullnode = Arc::new(FullnodeClient::new(config.clone())?);
74
75        #[cfg(feature = "faucet")]
76        let faucet = FaucetClient::new(&config).ok();
77
78        #[cfg(feature = "indexer")]
79        let indexer = IndexerClient::new(&config).ok();
80
81        let chain_id = AtomicU8::new(config.chain_id().id());
82
83        Ok(Self {
84            config,
85            fullnode,
86            chain_id,
87            #[cfg(feature = "faucet")]
88            faucet,
89            #[cfg(feature = "indexer")]
90            indexer,
91        })
92    }
93
94    /// Creates a client for testnet with default settings.
95    ///
96    /// # Errors
97    ///
98    /// Returns an error if the HTTP client fails to build (e.g., invalid TLS configuration).
99    pub fn testnet() -> MovementResult<Self> {
100        Self::new(MovementConfig::testnet())
101    }
102
103    /// Creates a client for devnet with default settings.
104    ///
105    /// # Errors
106    ///
107    /// Returns an error if the HTTP client fails to build (e.g., invalid TLS configuration).
108    pub fn devnet() -> MovementResult<Self> {
109        Self::new(MovementConfig::devnet())
110    }
111
112    /// Creates a client for mainnet with default settings.
113    ///
114    /// # Errors
115    ///
116    /// Returns an error if the HTTP client fails to build (e.g., invalid TLS configuration).
117    pub fn mainnet() -> MovementResult<Self> {
118        Self::new(MovementConfig::mainnet())
119    }
120
121    /// Creates a client for local development network.
122    ///
123    /// # Errors
124    ///
125    /// Returns an error if the HTTP client fails to build (e.g., invalid TLS configuration).
126    pub fn local() -> MovementResult<Self> {
127        Self::new(MovementConfig::local())
128    }
129
130    /// Returns the configuration.
131    pub fn config(&self) -> &MovementConfig {
132        &self.config
133    }
134
135    /// Returns the fullnode client.
136    pub fn fullnode(&self) -> &FullnodeClient {
137        &self.fullnode
138    }
139
140    /// Returns the faucet client, if available.
141    #[cfg(feature = "faucet")]
142    pub fn faucet(&self) -> Option<&FaucetClient> {
143        self.faucet.as_ref()
144    }
145
146    /// Returns the indexer client, if available.
147    #[cfg(feature = "indexer")]
148    pub fn indexer(&self) -> Option<&IndexerClient> {
149        self.indexer.as_ref()
150    }
151
152    // === Ledger Info ===
153
154    /// Gets the current ledger information.
155    ///
156    /// As a side effect, this also resolves the chain ID if it was unknown
157    /// (e.g., for custom network configurations).
158    ///
159    /// # Errors
160    ///
161    /// Returns an error if the HTTP request fails, the API returns an error status code,
162    /// or the response cannot be parsed.
163    pub async fn ledger_info(&self) -> MovementResult<crate::api::response::LedgerInfo> {
164        let response = self.fullnode.get_ledger_info().await?;
165        let info = response.into_inner();
166
167        // Update chain_id if it was unknown (custom network).
168        // NOTE: The load-then-store pattern has a benign TOCTOU race: multiple
169        // threads may concurrently see chain_id == 0 and all store the same
170        // value from the ledger info response. This is safe because they always
171        // store the identical chain_id value returned by the node.
172        if self.chain_id.load(Ordering::Relaxed) == 0 && info.chain_id > 0 {
173            self.chain_id.store(info.chain_id, Ordering::Relaxed);
174        }
175
176        Ok(info)
177    }
178
179    /// Returns the current chain ID.
180    ///
181    /// For known networks (mainnet, testnet, devnet, local), this returns the
182    /// well-known chain ID immediately. For custom networks, this returns
183    /// `ChainId(0)` until the chain ID is resolved via [`ensure_chain_id`](Self::ensure_chain_id)
184    /// or any method that makes a request to the node (e.g., [`build_transaction`](Self::build_transaction),
185    /// [`ledger_info`](Self::ledger_info)).
186    ///
187    pub fn chain_id(&self) -> ChainId {
188        ChainId::new(self.chain_id.load(Ordering::Relaxed))
189    }
190
191    /// Resolves the chain ID from the node if it is unknown.
192    ///
193    /// For known networks, this returns the chain ID immediately without
194    /// making a network request. For custom networks (chain ID 0), this
195    /// fetches the ledger info from the node to discover the actual chain ID
196    /// and caches it for future use.
197    ///
198    /// This is called automatically by [`build_transaction`](Self::build_transaction)
199    /// and other transaction methods, so you typically don't need to call it
200    /// directly unless you need the chain ID before building a transaction.
201    ///
202    /// # Errors
203    ///
204    /// Returns an error if the HTTP request to fetch ledger info fails.
205    ///
206    pub async fn ensure_chain_id(&self) -> MovementResult<ChainId> {
207        let id = self.chain_id.load(Ordering::Relaxed);
208        if id > 0 {
209            return Ok(ChainId::new(id));
210        }
211        // Chain ID is unknown; fetch from node
212        let response = self.fullnode.get_ledger_info().await?;
213        let info = response.into_inner();
214        self.chain_id.store(info.chain_id, Ordering::Relaxed);
215        Ok(ChainId::new(info.chain_id))
216    }
217
218    // === Account ===
219
220    /// Gets the sequence number for an account.
221    ///
222    /// # Errors
223    ///
224    /// Returns an error if the HTTP request fails, the API returns an error status code
225    /// (e.g., account not found 404), or the response cannot be parsed.
226    pub async fn get_sequence_number(&self, address: AccountAddress) -> MovementResult<u64> {
227        self.fullnode.get_sequence_number(address).await
228    }
229
230    /// Gets the APT balance for an account.
231    ///
232    /// # Errors
233    ///
234    /// Returns an error if the HTTP request fails, the API returns an error status code,
235    /// or the response cannot be parsed.
236    pub async fn get_balance(&self, address: AccountAddress) -> MovementResult<u64> {
237        self.fullnode.get_account_balance(address).await
238    }
239
240    /// Checks if an account exists.
241    ///
242    /// # Errors
243    ///
244    /// Returns an error if the HTTP request fails or the API returns an error status code
245    /// other than 404 (not found). A 404 error is handled gracefully and returns `Ok(false)`.
246    pub async fn account_exists(&self, address: AccountAddress) -> MovementResult<bool> {
247        match self.fullnode.get_account(address).await {
248            Ok(_) => Ok(true),
249            Err(MovementError::Api {
250                status_code: 404, ..
251            }) => Ok(false),
252            Err(e) => Err(e),
253        }
254    }
255
256    // === Transactions ===
257
258    /// Builds a transaction for the given account.
259    ///
260    /// This automatically fetches the sequence number and gas price.
261    ///
262    /// # Errors
263    ///
264    /// Returns an error if fetching the sequence number fails, fetching the gas price fails,
265    /// or if the transaction builder fails to construct a valid transaction (e.g., missing
266    /// required fields).
267    pub async fn build_transaction<A: Account>(
268        &self,
269        sender: &A,
270        payload: TransactionPayload,
271    ) -> MovementResult<RawTransaction> {
272        // Fetch sequence number, gas price, and chain ID in parallel
273        let (sequence_number, gas_estimation, chain_id) = tokio::join!(
274            self.get_sequence_number(sender.address()),
275            self.fullnode.estimate_gas_price(),
276            self.ensure_chain_id()
277        );
278        let sequence_number = sequence_number?;
279        let gas_estimation = gas_estimation?;
280        let chain_id = chain_id?;
281
282        TransactionBuilder::new()
283            .sender(sender.address())
284            .sequence_number(sequence_number)
285            .payload(payload)
286            .gas_unit_price(gas_estimation.data.recommended())
287            .chain_id(chain_id)
288            .expiration_from_now(600)
289            .build()
290    }
291
292    /// Signs and submits a transaction.
293    ///
294    /// # Errors
295    ///
296    /// Returns an error if building the transaction fails, signing fails (e.g., invalid key),
297    /// the transaction cannot be serialized to BCS, the HTTP request fails, or the API returns
298    /// an error status code.
299    #[cfg(feature = "ed25519")]
300    pub async fn sign_and_submit<A: Account>(
301        &self,
302        account: &A,
303        payload: TransactionPayload,
304    ) -> MovementResult<MovementResponse<PendingTransaction>> {
305        let raw_txn = self.build_transaction(account, payload).await?;
306        let signed = crate::transaction::builder::sign_transaction(&raw_txn, account)?;
307        self.fullnode.submit_transaction(&signed).await
308    }
309
310    /// Signs, submits, and waits for a transaction to complete.
311    ///
312    /// # Errors
313    ///
314    /// Returns an error if building the transaction fails, signing fails, submission fails,
315    /// the transaction times out waiting for commitment, the transaction execution fails,
316    /// or any HTTP/API errors occur.
317    #[cfg(feature = "ed25519")]
318    pub async fn sign_submit_and_wait<A: Account>(
319        &self,
320        account: &A,
321        payload: TransactionPayload,
322        timeout: Option<Duration>,
323    ) -> MovementResult<MovementResponse<serde_json::Value>> {
324        let raw_txn = self.build_transaction(account, payload).await?;
325        let signed = crate::transaction::builder::sign_transaction(&raw_txn, account)?;
326        self.fullnode.submit_and_wait(&signed, timeout).await
327    }
328
329    /// Submits a pre-signed transaction.
330    ///
331    /// # Errors
332    ///
333    /// Returns an error if the transaction cannot be serialized to BCS, the HTTP request fails,
334    /// or the API returns an error status code.
335    pub async fn submit_transaction(
336        &self,
337        signed_txn: &SignedTransaction,
338    ) -> MovementResult<MovementResponse<PendingTransaction>> {
339        self.fullnode.submit_transaction(signed_txn).await
340    }
341
342    /// Submits and waits for a pre-signed transaction.
343    ///
344    /// # Errors
345    ///
346    /// Returns an error if transaction submission fails, the transaction times out waiting
347    /// for commitment, the transaction execution fails (`vm_status` indicates failure),
348    /// or any HTTP/API errors occur.
349    pub async fn submit_and_wait(
350        &self,
351        signed_txn: &SignedTransaction,
352        timeout: Option<Duration>,
353    ) -> MovementResult<MovementResponse<serde_json::Value>> {
354        self.fullnode.submit_and_wait(signed_txn, timeout).await
355    }
356
357    /// Simulates a transaction.
358    ///
359    /// # Errors
360    ///
361    /// Returns an error if the transaction cannot be serialized to BCS, the HTTP request fails,
362    /// the API returns an error status code, or the response cannot be parsed as JSON.
363    pub async fn simulate_transaction(
364        &self,
365        signed_txn: &SignedTransaction,
366    ) -> MovementResult<MovementResponse<Vec<serde_json::Value>>> {
367        self.fullnode.simulate_transaction(signed_txn).await
368    }
369
370    /// Simulates a transaction and returns a parsed result.
371    ///
372    /// This method provides a more ergonomic way to simulate transactions
373    /// with detailed result parsing.
374    ///
375    /// # Example
376    ///
377    /// ```rust,ignore
378    /// let result = movement.simulate(&account, payload).await?;
379    /// if result.success() {
380    ///     println!("Gas estimate: {}", result.gas_used());
381    /// } else {
382    ///     println!("Would fail: {}", result.error_message().unwrap_or_default());
383    /// }
384    /// ```
385    ///
386    /// # Errors
387    ///
388    /// Returns an error if building the transaction fails, signing fails, simulation fails,
389    /// or the simulation response cannot be parsed.
390    #[cfg(feature = "ed25519")]
391    pub async fn simulate<A: Account>(
392        &self,
393        account: &A,
394        payload: TransactionPayload,
395    ) -> MovementResult<crate::transaction::SimulationResult> {
396        let raw_txn = self.build_transaction(account, payload).await?;
397        let signed = crate::transaction::builder::sign_transaction(&raw_txn, account)?;
398        let response = self.fullnode.simulate_transaction(&signed).await?;
399        crate::transaction::SimulationResult::from_response(response.into_inner())
400    }
401
402    /// Simulates a transaction with a pre-built signed transaction.
403    ///
404    /// # Errors
405    ///
406    /// Returns an error if simulation fails or the simulation response cannot be parsed.
407    pub async fn simulate_signed(
408        &self,
409        signed_txn: &SignedTransaction,
410    ) -> MovementResult<crate::transaction::SimulationResult> {
411        let response = self.fullnode.simulate_transaction(signed_txn).await?;
412        crate::transaction::SimulationResult::from_response(response.into_inner())
413    }
414
415    /// Estimates gas for a transaction by simulating it.
416    ///
417    /// Returns the estimated gas usage with a 20% safety margin.
418    ///
419    /// # Example
420    ///
421    /// ```rust,ignore
422    /// let gas = movement.estimate_gas(&account, payload).await?;
423    /// println!("Estimated gas: {}", gas);
424    /// ```
425    ///
426    /// # Errors
427    ///
428    /// Returns an error if simulation fails or if the simulation indicates the transaction
429    /// would fail (returns [`MovementError::SimulationFailed`]).
430    #[cfg(feature = "ed25519")]
431    pub async fn estimate_gas<A: Account>(
432        &self,
433        account: &A,
434        payload: TransactionPayload,
435    ) -> MovementResult<u64> {
436        let result = self.simulate(account, payload).await?;
437        if result.success() {
438            Ok(result.safe_gas_estimate())
439        } else {
440            Err(MovementError::SimulationFailed(
441                result
442                    .error_message()
443                    .unwrap_or_else(|| result.vm_status().to_string()),
444            ))
445        }
446    }
447
448    /// Simulates and submits a transaction if successful.
449    ///
450    /// This is a "dry run" approach that first simulates the transaction
451    /// to verify it will succeed before actually submitting it.
452    ///
453    /// # Example
454    ///
455    /// ```rust,ignore
456    /// let result = movement.simulate_and_submit(&account, payload).await?;
457    /// println!("Transaction submitted: {}", result.hash);
458    /// ```
459    ///
460    /// # Errors
461    ///
462    /// Returns an error if building the transaction fails, signing fails, simulation fails,
463    /// the simulation indicates the transaction would fail (returns [`MovementError::SimulationFailed`]),
464    /// or transaction submission fails.
465    #[cfg(feature = "ed25519")]
466    pub async fn simulate_and_submit<A: Account>(
467        &self,
468        account: &A,
469        payload: TransactionPayload,
470    ) -> MovementResult<MovementResponse<PendingTransaction>> {
471        // First simulate
472        let raw_txn = self.build_transaction(account, payload).await?;
473        let signed = crate::transaction::builder::sign_transaction(&raw_txn, account)?;
474        let sim_response = self.fullnode.simulate_transaction(&signed).await?;
475        let sim_result =
476            crate::transaction::SimulationResult::from_response(sim_response.into_inner())?;
477
478        if sim_result.failed() {
479            return Err(MovementError::SimulationFailed(
480                sim_result
481                    .error_message()
482                    .unwrap_or_else(|| sim_result.vm_status().to_string()),
483            ));
484        }
485
486        // Submit the same signed transaction
487        self.fullnode.submit_transaction(&signed).await
488    }
489
490    /// Simulates, submits, and waits for a transaction.
491    ///
492    /// Like `simulate_and_submit` but also waits for the transaction to complete.
493    ///
494    /// # Errors
495    ///
496    /// Returns an error if building the transaction fails, signing fails, simulation fails,
497    /// the simulation indicates the transaction would fail (returns [`MovementError::SimulationFailed`]),
498    /// submission fails, the transaction times out waiting for commitment, or the transaction
499    /// execution fails.
500    #[cfg(feature = "ed25519")]
501    pub async fn simulate_submit_and_wait<A: Account>(
502        &self,
503        account: &A,
504        payload: TransactionPayload,
505        timeout: Option<Duration>,
506    ) -> MovementResult<MovementResponse<serde_json::Value>> {
507        // First simulate
508        let raw_txn = self.build_transaction(account, payload).await?;
509        let signed = crate::transaction::builder::sign_transaction(&raw_txn, account)?;
510        let sim_response = self.fullnode.simulate_transaction(&signed).await?;
511        let sim_result =
512            crate::transaction::SimulationResult::from_response(sim_response.into_inner())?;
513
514        if sim_result.failed() {
515            return Err(MovementError::SimulationFailed(
516                sim_result
517                    .error_message()
518                    .unwrap_or_else(|| sim_result.vm_status().to_string()),
519            ));
520        }
521
522        // Submit and wait
523        self.fullnode.submit_and_wait(&signed, timeout).await
524    }
525
526    // === Transfers ===
527
528    /// Transfers APT from one account to another.
529    ///
530    /// # Errors
531    ///
532    /// Returns an error if building the transfer payload fails (e.g., invalid address),
533    /// signing fails, submission fails, the transaction times out, or the transaction
534    /// execution fails.
535    #[cfg(feature = "ed25519")]
536    pub async fn transfer_apt<A: Account>(
537        &self,
538        sender: &A,
539        recipient: AccountAddress,
540        amount: u64,
541    ) -> MovementResult<MovementResponse<serde_json::Value>> {
542        let payload = EntryFunction::apt_transfer(recipient, amount)?;
543        self.sign_submit_and_wait(sender, payload.into(), None)
544            .await
545    }
546
547    /// Transfers a coin from one account to another.
548    ///
549    /// # Errors
550    ///
551    /// Returns an error if building the transfer payload fails (e.g., invalid type tag or address),
552    /// signing fails, submission fails, the transaction times out, or the transaction
553    /// execution fails.
554    #[cfg(feature = "ed25519")]
555    pub async fn transfer_coin<A: Account>(
556        &self,
557        sender: &A,
558        recipient: AccountAddress,
559        coin_type: TypeTag,
560        amount: u64,
561    ) -> MovementResult<MovementResponse<serde_json::Value>> {
562        let payload = EntryFunction::coin_transfer(coin_type, recipient, amount)?;
563        self.sign_submit_and_wait(sender, payload.into(), None)
564            .await
565    }
566
567    // === View Functions ===
568
569    /// Calls a view function using JSON encoding.
570    ///
571    /// For lossless serialization of large integers, use [`view_bcs`](Self::view_bcs) instead.
572    ///
573    /// # Errors
574    ///
575    /// Returns an error if the HTTP request fails, the API returns an error status code,
576    /// or the response cannot be parsed as JSON.
577    pub async fn view(
578        &self,
579        function: &str,
580        type_args: Vec<String>,
581        args: Vec<serde_json::Value>,
582    ) -> MovementResult<Vec<serde_json::Value>> {
583        let response = self.fullnode.view(function, type_args, args).await?;
584        Ok(response.into_inner())
585    }
586
587    /// Calls a view function using BCS encoding for both inputs and outputs.
588    ///
589    /// This method provides lossless serialization by using BCS (Binary Canonical Serialization)
590    /// instead of JSON, which is important for large integers (u128, u256) and other types
591    /// where JSON can lose precision.
592    ///
593    /// # Type Parameter
594    ///
595    /// * `T` - The expected return type. Must implement `serde::de::DeserializeOwned`.
596    ///
597    /// # Arguments
598    ///
599    /// * `function` - The fully qualified function name (e.g., `0x1::coin::balance`)
600    /// * `type_args` - Type arguments as strings (e.g., `0x1::aptos_coin::AptosCoin`)
601    /// * `args` - Pre-serialized BCS arguments as byte vectors
602    ///
603    /// # Example
604    ///
605    /// ```rust,ignore
606    /// use movement_sdk::{Movement, MovementConfig, AccountAddress};
607    ///
608    /// let movement = Movement::new(MovementConfig::testnet())?;
609    /// let owner = AccountAddress::from_hex("0x1")?;
610    ///
611    /// // BCS-encode the argument
612    /// let args = vec![aptos_bcs::to_bytes(&owner)?];
613    ///
614    /// // Call view function with typed return
615    /// let balance: u64 = movement.view_bcs(
616    ///     "0x1::coin::balance",
617    ///     vec!["0x1::aptos_coin::AptosCoin".to_string()],
618    ///     args,
619    /// ).await?;
620    /// ```
621    ///
622    /// # Errors
623    ///
624    /// Returns an error if the HTTP request fails, the API returns an error status code,
625    /// or the BCS deserialization fails.
626    pub async fn view_bcs<T: serde::de::DeserializeOwned>(
627        &self,
628        function: &str,
629        type_args: Vec<String>,
630        args: Vec<Vec<u8>>,
631    ) -> MovementResult<T> {
632        // The /view endpoint returns BCS-encoded `Vec<MoveValue>` — even when the function
633        // has a single return. Decode the list and take the first element.
634        let response = self.fullnode.view_bcs(function, type_args, args).await?;
635        let bytes = response.into_inner();
636        let mut values: Vec<T> =
637            aptos_bcs::from_bytes(&bytes).map_err(|e| MovementError::Bcs(e.to_string()))?;
638        values
639            .pop()
640            .ok_or_else(|| MovementError::Bcs("view returned empty result list".into()))
641    }
642
643    /// Calls a view function with BCS inputs and returns raw BCS bytes.
644    ///
645    /// Use this when you need to manually deserialize the response or when
646    /// the return type is complex or dynamic.
647    ///
648    /// # Errors
649    ///
650    /// Returns an error if the HTTP request fails or the API returns an error status code.
651    pub async fn view_bcs_raw(
652        &self,
653        function: &str,
654        type_args: Vec<String>,
655        args: Vec<Vec<u8>>,
656    ) -> MovementResult<Vec<u8>> {
657        let response = self.fullnode.view_bcs(function, type_args, args).await?;
658        Ok(response.into_inner())
659    }
660
661    // === Faucet ===
662
663    /// Funds an account using the faucet.
664    ///
665    /// This method waits for the faucet transactions to be confirmed before returning.
666    ///
667    /// # Errors
668    ///
669    /// Returns an error if the faucet feature is not enabled, the faucet request fails
670    /// (e.g., rate limiting 429, server error 500), waiting for transaction confirmation
671    /// times out, or any HTTP/API errors occur.
672    #[cfg(feature = "faucet")]
673    pub async fn fund_account(
674        &self,
675        address: AccountAddress,
676        amount: u64,
677    ) -> MovementResult<Vec<String>> {
678        let faucet = self
679            .faucet
680            .as_ref()
681            .ok_or_else(|| MovementError::FeatureNotEnabled("faucet".into()))?;
682        let txn_hashes = faucet.fund(address, amount).await?;
683
684        // Parse hashes first to own them
685        let hashes: Vec<HashValue> = txn_hashes
686            .iter()
687            .filter_map(|hash_str| {
688                // Hash might have 0x prefix or not
689                let hash_str_clean = hash_str.strip_prefix("0x").unwrap_or(hash_str);
690                HashValue::from_hex(hash_str_clean).ok()
691            })
692            .collect();
693
694        // Wait for all faucet transactions to be confirmed in parallel
695        let wait_futures: Vec<_> = hashes
696            .iter()
697            .map(|hash| {
698                self.fullnode
699                    .wait_for_transaction(hash, Some(Duration::from_secs(60)))
700            })
701            .collect();
702
703        let results = futures::future::join_all(wait_futures).await;
704        for result in results {
705            result?;
706        }
707
708        Ok(txn_hashes)
709    }
710
711    #[cfg(all(feature = "faucet", feature = "ed25519"))]
712    /// Creates a funded account.
713    ///
714    /// # Errors
715    ///
716    /// Returns an error if funding the account fails (see [`Self::fund_account`] for details).
717    pub async fn create_funded_account(
718        &self,
719        amount: u64,
720    ) -> MovementResult<crate::account::Ed25519Account> {
721        let account = crate::account::Ed25519Account::generate();
722        self.fund_account(account.address(), amount).await?;
723        Ok(account)
724    }
725
726    // === Transaction Batching ===
727
728    /// Returns a batch operations helper for submitting multiple transactions.
729    ///
730    /// # Example
731    ///
732    /// ```rust,ignore
733    /// let movement = Movement::testnet()?;
734    ///
735    /// // Build and submit batch of transfers
736    /// let payloads = vec![
737    ///     EntryFunction::apt_transfer(addr1, 1000)?.into(),
738    ///     EntryFunction::apt_transfer(addr2, 2000)?.into(),
739    ///     EntryFunction::apt_transfer(addr3, 3000)?.into(),
740    /// ];
741    ///
742    /// let results = movement.batch().submit_and_wait(&sender, payloads, None).await?;
743    /// ```
744    pub fn batch(&self) -> crate::transaction::BatchOperations<'_> {
745        crate::transaction::BatchOperations::new(&self.fullnode, &self.chain_id)
746    }
747
748    /// Submits multiple transactions in parallel.
749    ///
750    /// This is a convenience method that builds, signs, and submits
751    /// multiple transactions at once.
752    ///
753    /// # Arguments
754    ///
755    /// * `account` - The account to sign with
756    /// * `payloads` - The transaction payloads to submit
757    ///
758    /// # Returns
759    ///
760    /// Results for each transaction in the batch.
761    ///
762    /// # Errors
763    ///
764    /// Returns an error if building any transaction fails, signing fails, or submission fails
765    /// for any transaction in the batch.
766    #[cfg(feature = "ed25519")]
767    pub async fn submit_batch<A: Account>(
768        &self,
769        account: &A,
770        payloads: Vec<TransactionPayload>,
771    ) -> MovementResult<Vec<crate::transaction::BatchTransactionResult>> {
772        self.batch().submit(account, payloads).await
773    }
774
775    /// Submits multiple transactions and waits for all to complete.
776    ///
777    /// # Arguments
778    ///
779    /// * `account` - The account to sign with
780    /// * `payloads` - The transaction payloads to submit
781    /// * `timeout` - Optional timeout for waiting
782    ///
783    /// # Returns
784    ///
785    /// Results for each transaction in the batch.
786    ///
787    /// # Errors
788    ///
789    /// Returns an error if building any transaction fails, signing fails, submission fails,
790    /// any transaction times out waiting for commitment, or any transaction execution fails.
791    #[cfg(feature = "ed25519")]
792    pub async fn submit_batch_and_wait<A: Account>(
793        &self,
794        account: &A,
795        payloads: Vec<TransactionPayload>,
796        timeout: Option<Duration>,
797    ) -> MovementResult<Vec<crate::transaction::BatchTransactionResult>> {
798        self.batch()
799            .submit_and_wait(account, payloads, timeout)
800            .await
801    }
802
803    /// Transfers APT to multiple recipients in a batch.
804    ///
805    /// # Arguments
806    ///
807    /// * `sender` - The sending account
808    /// * `transfers` - List of (recipient, amount) pairs
809    ///
810    /// # Example
811    ///
812    /// ```rust,ignore
813    /// let results = movement.batch_transfer_apt(&sender, vec![
814    ///     (addr1, 1_000_000),  // 0.01 APT
815    ///     (addr2, 2_000_000),  // 0.02 APT
816    ///     (addr3, 3_000_000),  // 0.03 APT
817    /// ]).await?;
818    /// ```
819    ///
820    /// # Errors
821    ///
822    /// Returns an error if building any transfer payload fails, signing fails, submission fails,
823    /// any transaction times out, or any transaction execution fails.
824    #[cfg(feature = "ed25519")]
825    pub async fn batch_transfer_apt<A: Account>(
826        &self,
827        sender: &A,
828        transfers: Vec<(AccountAddress, u64)>,
829    ) -> MovementResult<Vec<crate::transaction::BatchTransactionResult>> {
830        self.batch().transfer_apt(sender, transfers).await
831    }
832}
833
834#[cfg(test)]
835mod tests {
836    use super::*;
837    use wiremock::{
838        Mock, MockServer, ResponseTemplate,
839        matchers::{method, path, path_regex},
840    };
841
842    #[test]
843    fn test_movement_client_creation() {
844        let movement = Movement::testnet();
845        assert!(movement.is_ok());
846    }
847
848    #[test]
849    fn test_chain_id() {
850        let movement = Movement::testnet().unwrap();
851        assert_eq!(movement.chain_id(), ChainId::testnet());
852
853        let movement = Movement::mainnet().unwrap();
854        assert_eq!(movement.chain_id(), ChainId::mainnet());
855    }
856
857    fn create_mock_movement(server: &MockServer) -> Movement {
858        let url = format!("{}/v1", server.uri());
859        let config = MovementConfig::custom(&url).unwrap().without_retry();
860        Movement::new(config).unwrap()
861    }
862
863    #[tokio::test]
864    async fn test_get_sequence_number() {
865        let server = MockServer::start().await;
866
867        Mock::given(method("GET"))
868            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+"))
869            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
870                "sequence_number": "42",
871                "authentication_key": "0x0000000000000000000000000000000000000000000000000000000000000001"
872            })))
873            .expect(1)
874            .mount(&server)
875            .await;
876
877        let movement = create_mock_movement(&server);
878        let seq = movement
879            .get_sequence_number(AccountAddress::ONE)
880            .await
881            .unwrap();
882        assert_eq!(seq, 42);
883    }
884
885    #[tokio::test]
886    async fn test_get_balance() {
887        let server = MockServer::start().await;
888
889        // get_balance now uses view function instead of CoinStore resource
890        Mock::given(method("POST"))
891            .and(path("/v1/view"))
892            .respond_with(
893                ResponseTemplate::new(200).set_body_json(serde_json::json!(["5000000000"])),
894            )
895            .expect(1)
896            .mount(&server)
897            .await;
898
899        let movement = create_mock_movement(&server);
900        let balance = movement.get_balance(AccountAddress::ONE).await.unwrap();
901        assert_eq!(balance, 5_000_000_000);
902    }
903
904    #[tokio::test]
905    async fn test_get_resources_via_fullnode() {
906        let server = MockServer::start().await;
907
908        Mock::given(method("GET"))
909            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+/resources"))
910            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
911                {"type": "0x1::account::Account", "data": {}},
912                {"type": "0x1::coin::CoinStore<0x1::aptos_coin::AptosCoin>", "data": {}}
913            ])))
914            .expect(1)
915            .mount(&server)
916            .await;
917
918        let movement = create_mock_movement(&server);
919        let resources = movement
920            .fullnode()
921            .get_account_resources(AccountAddress::ONE)
922            .await
923            .unwrap();
924        assert_eq!(resources.data.len(), 2);
925    }
926
927    #[tokio::test]
928    async fn test_ledger_info() {
929        let server = MockServer::start().await;
930
931        Mock::given(method("GET"))
932            .and(path("/v1"))
933            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
934                "chain_id": 2,
935                "epoch": "100",
936                "ledger_version": "12345",
937                "oldest_ledger_version": "0",
938                "ledger_timestamp": "1000000",
939                "node_role": "full_node",
940                "oldest_block_height": "0",
941                "block_height": "5000"
942            })))
943            .expect(1)
944            .mount(&server)
945            .await;
946
947        let movement = create_mock_movement(&server);
948        let info = movement.ledger_info().await.unwrap();
949        assert_eq!(info.version().unwrap(), 12345);
950    }
951
952    #[tokio::test]
953    async fn test_config_builder() {
954        let config = MovementConfig::testnet().with_timeout(Duration::from_secs(60));
955
956        let movement = Movement::new(config).unwrap();
957        assert_eq!(movement.chain_id(), ChainId::testnet());
958    }
959
960    #[tokio::test]
961    async fn test_fullnode_accessor() {
962        let server = MockServer::start().await;
963        let movement = create_mock_movement(&server);
964
965        // Can access fullnode client directly
966        let fullnode = movement.fullnode();
967        assert!(fullnode.base_url().as_str().contains(&server.uri()));
968    }
969
970    #[cfg(feature = "ed25519")]
971    #[tokio::test]
972    async fn test_build_transaction() {
973        let server = MockServer::start().await;
974
975        // Mock for getting account
976        Mock::given(method("GET"))
977            .and(path_regex(r"/v1/accounts/0x[0-9a-f]+"))
978            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
979                "sequence_number": "0",
980                "authentication_key": "0x0000000000000000000000000000000000000000000000000000000000000001"
981            })))
982            .expect(1)
983            .mount(&server)
984            .await;
985
986        // Mock for gas price
987        Mock::given(method("GET"))
988            .and(path("/v1/estimate_gas_price"))
989            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
990                "gas_estimate": 100
991            })))
992            .expect(1)
993            .mount(&server)
994            .await;
995
996        // Mock for ledger info (needed for chain_id resolution on custom networks)
997        Mock::given(method("GET"))
998            .and(path("/v1"))
999            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1000                "chain_id": 4,
1001                "epoch": "1",
1002                "ledger_version": "100",
1003                "oldest_ledger_version": "0",
1004                "ledger_timestamp": "1000000",
1005                "node_role": "full_node",
1006                "oldest_block_height": "0",
1007                "block_height": "50"
1008            })))
1009            .expect(1)
1010            .mount(&server)
1011            .await;
1012
1013        let movement = create_mock_movement(&server);
1014        let account = crate::account::Ed25519Account::generate();
1015        let recipient = AccountAddress::from_hex("0x123").unwrap();
1016        let payload = crate::transaction::EntryFunction::apt_transfer(recipient, 1000).unwrap();
1017
1018        let raw_txn = movement
1019            .build_transaction(&account, payload.into())
1020            .await
1021            .unwrap();
1022        assert_eq!(raw_txn.sender, account.address());
1023        assert_eq!(raw_txn.sequence_number, 0);
1024    }
1025
1026    #[cfg(feature = "indexer")]
1027    #[tokio::test]
1028    async fn test_indexer_accessor() {
1029        // testnet preset has no indexer URL by default; opt in via with_indexer_url.
1030        let config = MovementConfig::testnet()
1031            .with_indexer_url("https://indexer.example.com/graphql")
1032            .unwrap();
1033        let movement = Movement::new(config).unwrap();
1034        assert!(movement.indexer().is_some());
1035    }
1036
1037    #[tokio::test]
1038    async fn test_account_exists_true() {
1039        let server = MockServer::start().await;
1040
1041        Mock::given(method("GET"))
1042            .and(path_regex(r"^/v1/accounts/0x[0-9a-f]+$"))
1043            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1044                "sequence_number": "10",
1045                "authentication_key": "0x0000000000000000000000000000000000000000000000000000000000000001"
1046            })))
1047            .expect(1)
1048            .mount(&server)
1049            .await;
1050
1051        let movement = create_mock_movement(&server);
1052        let exists = movement.account_exists(AccountAddress::ONE).await.unwrap();
1053        assert!(exists);
1054    }
1055
1056    #[tokio::test]
1057    async fn test_account_exists_false() {
1058        let server = MockServer::start().await;
1059
1060        Mock::given(method("GET"))
1061            .and(path_regex(r"^/v1/accounts/0x[0-9a-f]+$"))
1062            .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
1063                "message": "Account not found",
1064                "error_code": "account_not_found"
1065            })))
1066            .expect(1)
1067            .mount(&server)
1068            .await;
1069
1070        let movement = create_mock_movement(&server);
1071        let exists = movement.account_exists(AccountAddress::ONE).await.unwrap();
1072        assert!(!exists);
1073    }
1074
1075    #[tokio::test]
1076    async fn test_view_function() {
1077        let server = MockServer::start().await;
1078
1079        Mock::given(method("POST"))
1080            .and(path("/v1/view"))
1081            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!(["1000000"])))
1082            .expect(1)
1083            .mount(&server)
1084            .await;
1085
1086        let movement = create_mock_movement(&server);
1087        let result: Vec<serde_json::Value> = movement
1088            .view(
1089                "0x1::coin::balance",
1090                vec!["0x1::aptos_coin::AptosCoin".to_string()],
1091                vec![serde_json::json!("0x1")],
1092            )
1093            .await
1094            .unwrap();
1095
1096        assert_eq!(result.len(), 1);
1097        assert_eq!(result[0].as_str().unwrap(), "1000000");
1098    }
1099
1100    #[tokio::test]
1101    async fn test_chain_id_from_config() {
1102        let movement = Movement::mainnet().unwrap();
1103        assert_eq!(movement.chain_id(), ChainId::mainnet());
1104
1105        // devnet aliases to testnet for Movement.
1106        let movement = Movement::devnet().unwrap();
1107        assert_eq!(movement.chain_id(), ChainId::testnet());
1108    }
1109
1110    #[tokio::test]
1111    async fn test_custom_config() {
1112        let server = MockServer::start().await;
1113        let url = format!("{}/v1", server.uri());
1114        let config = MovementConfig::custom(&url).unwrap();
1115        let movement = Movement::new(config).unwrap();
1116
1117        // Custom config should have unknown chain ID
1118        assert_eq!(movement.chain_id(), ChainId::new(0));
1119    }
1120}