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}