Skip to main content

aptos_sdk/transaction/
batch.rs

1//! Transaction batching for efficient multi-transaction submission.
2//!
3//! This module provides utilities for building, signing, and submitting
4//! multiple transactions efficiently with automatic sequence number management.
5//!
6//! # Overview
7//!
8//! Transaction batching is useful when you need to:
9//! - Submit multiple transfers at once
10//! - Execute a series of contract calls
11//! - Perform bulk operations efficiently
12//!
13//! # Example
14//!
15//! ```rust,ignore
16//! use aptos_sdk::transaction::batch::TransactionBatchBuilder;
17//!
18//! // Build and sign a batch of transactions.
19//! let batch = TransactionBatchBuilder::new()
20//!     .sender(account.address())
21//!     .starting_sequence_number(seq_num)
22//!     .chain_id(ChainId::testnet())
23//!     .add_payload(payload1)
24//!     .add_payload(payload2)
25//!     .add_payload(payload3)
26//!     .build_and_sign(&account)?;
27//!
28//! // Submit all transactions in parallel (returns immediately).
29//! let results = batch.submit_all(&fullnode).await;
30//!
31//! // Or submit and wait for all to complete.
32//! let results = batch.submit_and_wait_all(&fullnode, None).await;
33//! ```
34
35use crate::account::Account;
36use crate::api::FullnodeClient;
37
38use crate::error::{AptosError, AptosResult};
39use crate::transaction::{
40    RawTransaction, SignedTransaction, TransactionBuilder, TransactionPayload,
41    builder::sign_transaction,
42};
43use crate::types::{AccountAddress, ChainId};
44use futures::future::join_all;
45use std::time::Duration;
46
47/// Result of a single transaction in a batch.
48#[derive(Debug)]
49pub struct BatchTransactionResult {
50    /// Index of the transaction in the batch.
51    pub index: usize,
52    /// The signed transaction that was submitted.
53    pub transaction: SignedTransaction,
54    /// Result of the submission/execution.
55    pub result: Result<BatchTransactionStatus, AptosError>,
56}
57
58/// Status of a batch transaction after submission.
59#[derive(Debug, Clone)]
60pub enum BatchTransactionStatus {
61    /// Transaction was submitted and is pending.
62    Pending {
63        /// The transaction hash.
64        hash: String,
65    },
66    /// Transaction was submitted and confirmed.
67    Confirmed {
68        /// The transaction hash.
69        hash: String,
70        /// Whether the transaction succeeded on-chain.
71        success: bool,
72        /// The transaction version.
73        version: u64,
74        /// Gas used by the transaction.
75        gas_used: u64,
76    },
77    /// Transaction failed to submit.
78    Failed {
79        /// Error message.
80        error: String,
81    },
82}
83
84impl BatchTransactionStatus {
85    /// Returns the transaction hash if available.
86    pub fn hash(&self) -> Option<&str> {
87        match self {
88            BatchTransactionStatus::Pending { hash }
89            | BatchTransactionStatus::Confirmed { hash, .. } => Some(hash),
90            BatchTransactionStatus::Failed { .. } => None,
91        }
92    }
93
94    /// Returns true if the transaction is confirmed and successful.
95    pub fn is_success(&self) -> bool {
96        matches!(
97            self,
98            BatchTransactionStatus::Confirmed { success: true, .. }
99        )
100    }
101
102    /// Returns true if the transaction failed.
103    pub fn is_failed(&self) -> bool {
104        matches!(self, BatchTransactionStatus::Failed { .. })
105            || matches!(
106                self,
107                BatchTransactionStatus::Confirmed { success: false, .. }
108            )
109    }
110}
111
112/// Builder for creating a batch of transactions.
113///
114/// This builder handles:
115/// - Automatic sequence number management (incrementing from a starting value)
116/// - Transaction signing
117///
118/// Gas parameters use fixed defaults (a `gas_unit_price` of 100 octas and a
119/// `max_gas_amount` of 2,000,000) unless overridden via
120/// [`gas_unit_price`](Self::gas_unit_price) / [`max_gas_amount`](Self::max_gas_amount).
121/// This builder does **not** query the network for a recommended gas price;
122/// live gas-price estimation happens one level up in [`BatchOperations::build`],
123/// which fetches the estimate and passes it to this builder.
124///
125/// # Example
126///
127/// ```rust,ignore
128/// let batch = TransactionBatchBuilder::new()
129///     .sender(account.address())
130///     .starting_sequence_number(10)
131///     .chain_id(ChainId::testnet())
132///     .gas_unit_price(100)
133///     .add_payload(payload1)
134///     .add_payload(payload2)
135///     .build_and_sign(&account)?;
136/// ```
137#[derive(Debug, Clone)]
138pub struct TransactionBatchBuilder {
139    sender: Option<AccountAddress>,
140    starting_sequence_number: Option<u64>,
141    chain_id: Option<ChainId>,
142    gas_unit_price: u64,
143    max_gas_amount: u64,
144    expiration_secs: u64,
145    payloads: Vec<TransactionPayload>,
146}
147
148impl Default for TransactionBatchBuilder {
149    fn default() -> Self {
150        Self::new()
151    }
152}
153
154impl TransactionBatchBuilder {
155    /// Creates a new batch builder.
156    #[must_use]
157    pub fn new() -> Self {
158        Self {
159            sender: None,
160            starting_sequence_number: None,
161            chain_id: None,
162            gas_unit_price: 100,
163            max_gas_amount: 2_000_000,
164            expiration_secs: 600,
165            payloads: Vec::new(),
166        }
167    }
168
169    /// Sets the sender address.
170    #[must_use]
171    pub fn sender(mut self, sender: AccountAddress) -> Self {
172        self.sender = Some(sender);
173        self
174    }
175
176    /// Sets the starting sequence number.
177    ///
178    /// Each transaction in the batch will use an incrementing sequence number
179    /// starting from this value.
180    #[must_use]
181    pub fn starting_sequence_number(mut self, seq: u64) -> Self {
182        self.starting_sequence_number = Some(seq);
183        self
184    }
185
186    /// Sets the chain ID.
187    #[must_use]
188    pub fn chain_id(mut self, chain_id: ChainId) -> Self {
189        self.chain_id = Some(chain_id);
190        self
191    }
192
193    /// Sets the gas unit price for all transactions.
194    #[must_use]
195    pub fn gas_unit_price(mut self, price: u64) -> Self {
196        self.gas_unit_price = price;
197        self
198    }
199
200    /// Sets the maximum gas amount for all transactions.
201    #[must_use]
202    pub fn max_gas_amount(mut self, amount: u64) -> Self {
203        self.max_gas_amount = amount;
204        self
205    }
206
207    /// Sets the expiration time in seconds from now.
208    #[must_use]
209    pub fn expiration_secs(mut self, secs: u64) -> Self {
210        self.expiration_secs = secs;
211        self
212    }
213
214    /// Adds a transaction payload to the batch.
215    #[must_use]
216    pub fn add_payload(mut self, payload: TransactionPayload) -> Self {
217        self.payloads.push(payload);
218        self
219    }
220
221    /// Adds multiple transaction payloads to the batch.
222    #[must_use]
223    pub fn add_payloads(mut self, payloads: impl IntoIterator<Item = TransactionPayload>) -> Self {
224        self.payloads.extend(payloads);
225        self
226    }
227
228    /// Returns the number of transactions in the batch.
229    pub fn len(&self) -> usize {
230        self.payloads.len()
231    }
232
233    /// Returns true if the batch is empty.
234    pub fn is_empty(&self) -> bool {
235        self.payloads.is_empty()
236    }
237
238    /// Builds raw transactions without signing.
239    ///
240    /// # Errors
241    ///
242    /// Returns an error if `sender`, `starting_sequence_number`, or `chain_id` is not set, or if building any transaction fails.
243    pub fn build(self) -> AptosResult<Vec<RawTransaction>> {
244        let sender = self
245            .sender
246            .ok_or_else(|| AptosError::Transaction("sender is required".into()))?;
247        let starting_seq = self.starting_sequence_number.ok_or_else(|| {
248            AptosError::Transaction("starting_sequence_number is required".into())
249        })?;
250        let chain_id = self
251            .chain_id
252            .ok_or_else(|| AptosError::Transaction("chain_id is required".into()))?;
253
254        let mut transactions = Vec::with_capacity(self.payloads.len());
255
256        for (i, payload) in self.payloads.into_iter().enumerate() {
257            // SECURITY: Use checked arithmetic to prevent sequence number overflow
258            let sequence_number = starting_seq
259                .checked_add(i as u64)
260                .ok_or_else(|| AptosError::Transaction("sequence number overflow".into()))?;
261
262            let txn = TransactionBuilder::new()
263                .sender(sender)
264                .sequence_number(sequence_number)
265                .payload(payload)
266                .gas_unit_price(self.gas_unit_price)
267                .max_gas_amount(self.max_gas_amount)
268                .chain_id(chain_id)
269                .expiration_from_now(self.expiration_secs)
270                .build()?;
271            transactions.push(txn);
272        }
273
274        Ok(transactions)
275    }
276
277    /// Builds and signs all transactions in the batch.
278    ///
279    /// # Errors
280    ///
281    /// Returns an error if building the transactions fails or if signing any transaction fails.
282    pub fn build_and_sign<A: Account>(self, account: &A) -> AptosResult<SignedTransactionBatch> {
283        let raw_transactions = self.build()?;
284        let mut signed = Vec::with_capacity(raw_transactions.len());
285
286        for raw_txn in raw_transactions {
287            let signed_txn = sign_transaction(&raw_txn, account)?;
288            signed.push(signed_txn);
289        }
290
291        Ok(SignedTransactionBatch {
292            transactions: signed,
293        })
294    }
295}
296
297/// A batch of signed transactions ready for submission.
298#[derive(Debug, Clone)]
299pub struct SignedTransactionBatch {
300    transactions: Vec<SignedTransaction>,
301}
302
303impl SignedTransactionBatch {
304    /// Creates a new batch from signed transactions.
305    pub fn new(transactions: Vec<SignedTransaction>) -> Self {
306        Self { transactions }
307    }
308
309    /// Returns the transactions in the batch.
310    pub fn transactions(&self) -> &[SignedTransaction] {
311        &self.transactions
312    }
313
314    /// Consumes the batch and returns the transactions.
315    pub fn into_transactions(self) -> Vec<SignedTransaction> {
316        self.transactions
317    }
318
319    /// Returns the number of transactions in the batch.
320    pub fn len(&self) -> usize {
321        self.transactions.len()
322    }
323
324    /// Returns true if the batch is empty.
325    pub fn is_empty(&self) -> bool {
326        self.transactions.is_empty()
327    }
328
329    /// Submits all transactions in parallel.
330    ///
331    /// Returns immediately after submission without waiting for confirmation.
332    pub async fn submit_all(self, client: &FullnodeClient) -> Vec<BatchTransactionResult> {
333        let futures: Vec<_> = self
334            .transactions
335            .into_iter()
336            .enumerate()
337            .map(|(index, txn)| {
338                let client = client.clone();
339                async move {
340                    let result = client.submit_transaction(&txn).await;
341                    BatchTransactionResult {
342                        index,
343                        transaction: txn,
344                        result: result.map(|resp| BatchTransactionStatus::Pending {
345                            hash: resp.data.hash.to_string(),
346                        }),
347                    }
348                }
349            })
350            .collect();
351
352        join_all(futures).await
353    }
354
355    /// Submits all transactions in parallel and waits for confirmation.
356    ///
357    /// Each transaction is submitted and then waited on independently.
358    pub async fn submit_and_wait_all(
359        self,
360        client: &FullnodeClient,
361        timeout: Option<Duration>,
362    ) -> Vec<BatchTransactionResult> {
363        let futures: Vec<_> = self
364            .transactions
365            .into_iter()
366            .enumerate()
367            .map(|(index, txn)| {
368                let client = client.clone();
369                async move {
370                    let result = submit_and_wait_single(&client, &txn, timeout).await;
371                    BatchTransactionResult {
372                        index,
373                        transaction: txn,
374                        result,
375                    }
376                }
377            })
378            .collect();
379
380        join_all(futures).await
381    }
382
383    /// Submits transactions sequentially (one at a time).
384    ///
385    /// This is slower but may be needed if transactions depend on each other.
386    pub async fn submit_sequential(self, client: &FullnodeClient) -> Vec<BatchTransactionResult> {
387        let mut results = Vec::with_capacity(self.transactions.len());
388
389        for (index, txn) in self.transactions.into_iter().enumerate() {
390            let result = client.submit_transaction(&txn).await;
391            results.push(BatchTransactionResult {
392                index,
393                transaction: txn,
394                result: result.map(|resp| BatchTransactionStatus::Pending {
395                    hash: resp.data.hash.to_string(),
396                }),
397            });
398        }
399
400        results
401    }
402
403    /// Submits transactions sequentially and waits for each to complete.
404    ///
405    /// This ensures each transaction is confirmed before submitting the next.
406    pub async fn submit_and_wait_sequential(
407        self,
408        client: &FullnodeClient,
409        timeout: Option<Duration>,
410    ) -> Vec<BatchTransactionResult> {
411        let mut results = Vec::with_capacity(self.transactions.len());
412
413        for (index, txn) in self.transactions.into_iter().enumerate() {
414            let result = submit_and_wait_single(client, &txn, timeout).await;
415            results.push(BatchTransactionResult {
416                index,
417                transaction: txn.clone(),
418                result,
419            });
420
421            // Stop on first failure if sequential
422            if results.last().is_some_and(|r| r.result.is_err()) {
423                break;
424            }
425        }
426
427        results
428    }
429}
430
431/// Helper to submit and wait for a single transaction.
432async fn submit_and_wait_single(
433    client: &FullnodeClient,
434    txn: &SignedTransaction,
435    timeout: Option<Duration>,
436) -> Result<BatchTransactionStatus, AptosError> {
437    let response = client.submit_and_wait(txn, timeout).await?;
438    let data = response.into_inner();
439
440    let hash = data
441        .get("hash")
442        .and_then(|v| v.as_str())
443        .unwrap_or("")
444        .to_string();
445    let success = data
446        .get("success")
447        .and_then(serde_json::Value::as_bool)
448        .unwrap_or(false);
449    let version = data
450        .get("version")
451        .and_then(serde_json::Value::as_str)
452        .and_then(|s| s.parse().ok())
453        .unwrap_or(0);
454    let gas_used = data
455        .get("gas_used")
456        .and_then(|v| v.as_str())
457        .and_then(|s| s.parse().ok())
458        .unwrap_or(0);
459
460    Ok(BatchTransactionStatus::Confirmed {
461        hash,
462        success,
463        version,
464        gas_used,
465    })
466}
467
468/// Summary of batch execution results.
469#[derive(Debug, Clone)]
470pub struct BatchSummary {
471    /// Total number of transactions.
472    pub total: usize,
473    /// Number of successful transactions.
474    pub succeeded: usize,
475    /// Number of failed transactions.
476    pub failed: usize,
477    /// Number of pending transactions.
478    pub pending: usize,
479    /// Total gas used across all confirmed transactions.
480    pub total_gas_used: u64,
481}
482
483impl BatchSummary {
484    /// Creates a summary from batch results.
485    pub fn from_results(results: &[BatchTransactionResult]) -> Self {
486        let mut succeeded = 0;
487        let mut failed = 0;
488        let mut pending = 0;
489        let mut total_gas_used = 0u64;
490
491        for result in results {
492            match &result.result {
493                Ok(status) => match status {
494                    BatchTransactionStatus::Confirmed {
495                        success, gas_used, ..
496                    } => {
497                        if *success {
498                            succeeded += 1;
499                        } else {
500                            failed += 1;
501                        }
502                        total_gas_used = total_gas_used.saturating_add(*gas_used);
503                    }
504                    BatchTransactionStatus::Pending { .. } => {
505                        pending += 1;
506                    }
507                    BatchTransactionStatus::Failed { .. } => {
508                        failed += 1;
509                    }
510                },
511                Err(_) => {
512                    failed += 1;
513                }
514            }
515        }
516
517        Self {
518            total: results.len(),
519            succeeded,
520            failed,
521            pending,
522            total_gas_used,
523        }
524    }
525
526    /// Returns true if all transactions succeeded.
527    pub fn all_succeeded(&self) -> bool {
528        self.succeeded == self.total
529    }
530
531    /// Returns true if any transaction failed.
532    pub fn has_failures(&self) -> bool {
533        self.failed > 0
534    }
535}
536
537/// High-level batch operations for the Aptos client.
538#[allow(missing_debug_implementations)] // Contains references that may not implement Debug
539pub struct BatchOperations<'a> {
540    client: &'a FullnodeClient,
541    chain_id: &'a std::sync::atomic::AtomicU8,
542}
543
544impl<'a> BatchOperations<'a> {
545    /// Creates a new batch operations helper.
546    pub fn new(client: &'a FullnodeClient, chain_id: &'a std::sync::atomic::AtomicU8) -> Self {
547        Self { client, chain_id }
548    }
549
550    /// Resolves the chain ID, fetching from the node if unknown.
551    async fn resolve_chain_id(&self) -> AptosResult<ChainId> {
552        let id = self.chain_id.load(std::sync::atomic::Ordering::Relaxed);
553        if id > 0 {
554            return Ok(ChainId::new(id));
555        }
556        // Chain ID is unknown; fetch from node
557        let response = self.client.get_ledger_info().await?;
558        let info = response.into_inner();
559        self.chain_id
560            .store(info.chain_id, std::sync::atomic::Ordering::Relaxed);
561        Ok(ChainId::new(info.chain_id))
562    }
563
564    /// Builds a batch of transactions for an account.
565    ///
566    /// This automatically fetches the current sequence number, gas price,
567    /// and chain ID (if unknown).
568    ///
569    /// # Errors
570    ///
571    /// Returns an error if fetching the sequence number fails, fetching gas price fails, or building/signing the batch fails.
572    pub async fn build<A: Account>(
573        &self,
574        account: &A,
575        payloads: Vec<TransactionPayload>,
576    ) -> AptosResult<SignedTransactionBatch> {
577        // Fetch sequence number, gas price, and chain ID in parallel
578        let (sequence_number, gas_estimation, chain_id) = tokio::join!(
579            self.client.get_sequence_number(account.address()),
580            self.client.estimate_gas_price(),
581            self.resolve_chain_id()
582        );
583        let sequence_number = sequence_number?;
584        let gas_estimation = gas_estimation?;
585        let chain_id = chain_id?;
586
587        let batch = TransactionBatchBuilder::new()
588            .sender(account.address())
589            .starting_sequence_number(sequence_number)
590            .chain_id(chain_id)
591            .gas_unit_price(gas_estimation.data.recommended())
592            .add_payloads(payloads)
593            .build_and_sign(account)?;
594
595        Ok(batch)
596    }
597
598    /// Builds and submits a batch of transactions in parallel.
599    ///
600    /// # Errors
601    ///
602    /// Returns an error if building the batch fails.
603    pub async fn submit<A: Account>(
604        &self,
605        account: &A,
606        payloads: Vec<TransactionPayload>,
607    ) -> AptosResult<Vec<BatchTransactionResult>> {
608        let batch = self.build(account, payloads).await?;
609        Ok(batch.submit_all(self.client).await)
610    }
611
612    /// Builds, submits, and waits for a batch of transactions.
613    ///
614    /// # Errors
615    ///
616    /// Returns an error if building the batch fails (e.g., fetching sequence number or gas price),
617    /// signing the batch fails, or any transaction submission/waiting fails.
618    pub async fn submit_and_wait<A: Account>(
619        &self,
620        account: &A,
621        payloads: Vec<TransactionPayload>,
622        timeout: Option<Duration>,
623    ) -> AptosResult<Vec<BatchTransactionResult>> {
624        let batch = self.build(account, payloads).await?;
625        Ok(batch.submit_and_wait_all(self.client, timeout).await)
626    }
627
628    /// Creates multiple APT transfers as a batch.
629    ///
630    /// # Errors
631    ///
632    /// Returns an error if any transfer payload creation fails (e.g., invalid recipient address),
633    /// building the batch fails, or submitting/waiting for transactions fails.
634    pub async fn transfer_apt<A: Account>(
635        &self,
636        sender: &A,
637        transfers: Vec<(AccountAddress, u64)>,
638    ) -> AptosResult<Vec<BatchTransactionResult>> {
639        use crate::transaction::EntryFunction;
640
641        let payloads: Vec<_> = transfers
642            .into_iter()
643            .map(|(recipient, amount)| {
644                EntryFunction::apt_transfer(recipient, amount).map(TransactionPayload::from)
645            })
646            .collect::<AptosResult<Vec<_>>>()?;
647
648        self.submit_and_wait(sender, payloads, None).await
649    }
650}
651
652#[cfg(test)]
653mod tests {
654    use super::*;
655    use crate::config::AptosConfig;
656    use wiremock::{
657        Mock, MockServer, ResponseTemplate,
658        matchers::{method, path, path_regex},
659    };
660
661    /// Builds a `FullnodeClient` pointed at a wiremock server, with retries
662    /// disabled so error responses surface immediately.
663    fn create_mock_client(server: &MockServer) -> FullnodeClient {
664        let url = format!("{}/v1", server.uri());
665        let config = AptosConfig::custom(&url).unwrap().without_retry();
666        FullnodeClient::new(config).unwrap()
667    }
668
669    /// JSON body for a `POST /v1/transactions` (submit) response.
670    fn pending_txn_response() -> serde_json::Value {
671        serde_json::json!({
672            "hash": "0x0000000000000000000000000000000000000000000000000000000000000001",
673            "sender": "0x1",
674            "sequence_number": "0",
675            "max_gas_amount": "200000",
676            "gas_unit_price": "100",
677            "expiration_timestamp_secs": "1000000"
678        })
679    }
680
681    /// JSON body for a committed transaction fetched by hash while waiting.
682    fn committed_txn_response() -> serde_json::Value {
683        serde_json::json!({
684            "type": "user_transaction",
685            "version": "12345",
686            "hash": "0x0000000000000000000000000000000000000000000000000000000000000001",
687            "success": true,
688            "vm_status": "Executed successfully",
689            "gas_used": "500"
690        })
691    }
692
693    #[test]
694    fn test_batch_builder_missing_fields() {
695        let builder = TransactionBatchBuilder::new().add_payload(TransactionPayload::Script(
696            crate::transaction::Script {
697                code: vec![],
698                type_args: vec![],
699                args: vec![],
700            },
701        ));
702
703        let result = builder.build();
704        assert!(result.is_err());
705    }
706
707    #[test]
708    fn test_batch_builder_complete() {
709        let builder = TransactionBatchBuilder::new()
710            .sender(AccountAddress::ONE)
711            .starting_sequence_number(0)
712            .chain_id(ChainId::testnet())
713            .gas_unit_price(100)
714            .add_payload(TransactionPayload::Script(crate::transaction::Script {
715                code: vec![],
716                type_args: vec![],
717                args: vec![],
718            }))
719            .add_payload(TransactionPayload::Script(crate::transaction::Script {
720                code: vec![],
721                type_args: vec![],
722                args: vec![],
723            }));
724
725        let transactions = builder.build().unwrap();
726        assert_eq!(transactions.len(), 2);
727        assert_eq!(transactions[0].sequence_number, 0);
728        assert_eq!(transactions[1].sequence_number, 1);
729    }
730
731    #[test]
732    fn test_batch_builder_sequence_numbers() {
733        let builder = TransactionBatchBuilder::new()
734            .sender(AccountAddress::ONE)
735            .starting_sequence_number(10)
736            .chain_id(ChainId::testnet())
737            .add_payload(TransactionPayload::Script(crate::transaction::Script {
738                code: vec![],
739                type_args: vec![],
740                args: vec![],
741            }))
742            .add_payload(TransactionPayload::Script(crate::transaction::Script {
743                code: vec![],
744                type_args: vec![],
745                args: vec![],
746            }))
747            .add_payload(TransactionPayload::Script(crate::transaction::Script {
748                code: vec![],
749                type_args: vec![],
750                args: vec![],
751            }));
752
753        let transactions = builder.build().unwrap();
754        assert_eq!(transactions.len(), 3);
755        assert_eq!(transactions[0].sequence_number, 10);
756        assert_eq!(transactions[1].sequence_number, 11);
757        assert_eq!(transactions[2].sequence_number, 12);
758    }
759
760    #[test]
761    fn test_batch_summary() {
762        let results = vec![
763            BatchTransactionResult {
764                index: 0,
765                transaction: create_dummy_signed_txn(),
766                result: Ok(BatchTransactionStatus::Confirmed {
767                    hash: "0x1".to_string(),
768                    success: true,
769                    version: 100,
770                    gas_used: 500,
771                }),
772            },
773            BatchTransactionResult {
774                index: 1,
775                transaction: create_dummy_signed_txn(),
776                result: Ok(BatchTransactionStatus::Confirmed {
777                    hash: "0x2".to_string(),
778                    success: true,
779                    version: 101,
780                    gas_used: 600,
781                }),
782            },
783            BatchTransactionResult {
784                index: 2,
785                transaction: create_dummy_signed_txn(),
786                result: Ok(BatchTransactionStatus::Confirmed {
787                    hash: "0x3".to_string(),
788                    success: false,
789                    version: 102,
790                    gas_used: 100,
791                }),
792            },
793        ];
794
795        let summary = BatchSummary::from_results(&results);
796        assert_eq!(summary.total, 3);
797        assert_eq!(summary.succeeded, 2);
798        assert_eq!(summary.failed, 1);
799        assert_eq!(summary.pending, 0);
800        assert_eq!(summary.total_gas_used, 1200);
801        assert!(!summary.all_succeeded());
802        assert!(summary.has_failures());
803    }
804
805    #[test]
806    fn test_batch_status_methods() {
807        let pending = BatchTransactionStatus::Pending {
808            hash: "0x123".to_string(),
809        };
810        assert_eq!(pending.hash(), Some("0x123"));
811        assert!(!pending.is_success());
812        assert!(!pending.is_failed());
813
814        let confirmed_success = BatchTransactionStatus::Confirmed {
815            hash: "0x456".to_string(),
816            success: true,
817            version: 100,
818            gas_used: 500,
819        };
820        assert_eq!(confirmed_success.hash(), Some("0x456"));
821        assert!(confirmed_success.is_success());
822        assert!(!confirmed_success.is_failed());
823
824        let confirmed_failed = BatchTransactionStatus::Confirmed {
825            hash: "0x789".to_string(),
826            success: false,
827            version: 101,
828            gas_used: 100,
829        };
830        assert!(!confirmed_failed.is_success());
831        assert!(confirmed_failed.is_failed());
832
833        let failed = BatchTransactionStatus::Failed {
834            error: "timeout".to_string(),
835        };
836        assert!(failed.hash().is_none());
837        assert!(!failed.is_success());
838        assert!(failed.is_failed());
839    }
840
841    #[cfg(feature = "ed25519")]
842    #[test]
843    fn test_batch_build_and_sign() {
844        use crate::account::Ed25519Account;
845
846        let account = Ed25519Account::generate();
847        let batch = TransactionBatchBuilder::new()
848            .sender(account.address())
849            .starting_sequence_number(0)
850            .chain_id(ChainId::testnet())
851            .add_payload(TransactionPayload::Script(crate::transaction::Script {
852                code: vec![],
853                type_args: vec![],
854                args: vec![],
855            }))
856            .add_payload(TransactionPayload::Script(crate::transaction::Script {
857                code: vec![],
858                type_args: vec![],
859                args: vec![],
860            }))
861            .build_and_sign(&account)
862            .unwrap();
863
864        assert_eq!(batch.len(), 2);
865    }
866
867    fn create_dummy_signed_txn() -> SignedTransaction {
868        use crate::transaction::TransactionAuthenticator;
869
870        let raw_txn = RawTransaction {
871            sender: AccountAddress::ONE,
872            sequence_number: 0,
873            payload: TransactionPayload::Script(crate::transaction::Script {
874                code: vec![],
875                type_args: vec![],
876                args: vec![],
877            }),
878            max_gas_amount: 200_000,
879            gas_unit_price: 100,
880            expiration_timestamp_secs: 0,
881            chain_id: ChainId::testnet(),
882        };
883
884        SignedTransaction {
885            raw_txn,
886            authenticator: TransactionAuthenticator::ed25519(vec![0u8; 32], vec![0u8; 64]),
887        }
888    }
889
890    #[test]
891    fn test_batch_summary_all_succeeded() {
892        let results = vec![
893            BatchTransactionResult {
894                index: 0,
895                transaction: create_dummy_signed_txn(),
896                result: Ok(BatchTransactionStatus::Confirmed {
897                    hash: "0x1".to_string(),
898                    success: true,
899                    version: 100,
900                    gas_used: 500,
901                }),
902            },
903            BatchTransactionResult {
904                index: 1,
905                transaction: create_dummy_signed_txn(),
906                result: Ok(BatchTransactionStatus::Confirmed {
907                    hash: "0x2".to_string(),
908                    success: true,
909                    version: 101,
910                    gas_used: 600,
911                }),
912            },
913        ];
914
915        let summary = BatchSummary::from_results(&results);
916        assert_eq!(summary.total, 2);
917        assert_eq!(summary.succeeded, 2);
918        assert_eq!(summary.failed, 0);
919        assert!(summary.all_succeeded());
920        assert!(!summary.has_failures());
921    }
922
923    #[test]
924    fn test_batch_summary_with_pending() {
925        let results = vec![
926            BatchTransactionResult {
927                index: 0,
928                transaction: create_dummy_signed_txn(),
929                result: Ok(BatchTransactionStatus::Pending {
930                    hash: "0x1".to_string(),
931                }),
932            },
933            BatchTransactionResult {
934                index: 1,
935                transaction: create_dummy_signed_txn(),
936                result: Ok(BatchTransactionStatus::Confirmed {
937                    hash: "0x2".to_string(),
938                    success: true,
939                    version: 101,
940                    gas_used: 600,
941                }),
942            },
943        ];
944
945        let summary = BatchSummary::from_results(&results);
946        assert_eq!(summary.total, 2);
947        assert_eq!(summary.succeeded, 1);
948        assert_eq!(summary.pending, 1);
949        assert!(!summary.all_succeeded());
950    }
951
952    #[test]
953    fn test_batch_summary_with_errors() {
954        let results = vec![BatchTransactionResult {
955            index: 0,
956            transaction: create_dummy_signed_txn(),
957            result: Err(AptosError::Transaction("failed".to_string())),
958        }];
959
960        let summary = BatchSummary::from_results(&results);
961        assert_eq!(summary.total, 1);
962        assert_eq!(summary.failed, 1);
963        assert!(summary.has_failures());
964    }
965
966    #[test]
967    fn test_batch_builder_with_max_gas() {
968        let builder = TransactionBatchBuilder::new()
969            .sender(AccountAddress::ONE)
970            .starting_sequence_number(0)
971            .chain_id(ChainId::testnet())
972            .max_gas_amount(500_000)
973            .add_payload(TransactionPayload::Script(crate::transaction::Script {
974                code: vec![],
975                type_args: vec![],
976                args: vec![],
977            }));
978
979        let transactions = builder.build().unwrap();
980        assert_eq!(transactions.len(), 1);
981        assert_eq!(transactions[0].max_gas_amount, 500_000);
982    }
983
984    #[test]
985    fn test_batch_builder_with_expiration() {
986        let builder = TransactionBatchBuilder::new()
987            .sender(AccountAddress::ONE)
988            .starting_sequence_number(0)
989            .chain_id(ChainId::testnet())
990            .expiration_secs(3600) // 1 hour from now
991            .add_payload(TransactionPayload::Script(crate::transaction::Script {
992                code: vec![],
993                type_args: vec![],
994                args: vec![],
995            }));
996
997        let transactions = builder.build().unwrap();
998        // Expiration should be set to some future timestamp (> current time)
999        assert!(transactions[0].expiration_timestamp_secs > 0);
1000    }
1001
1002    #[test]
1003    fn test_batch_builder_empty_payloads() {
1004        let builder = TransactionBatchBuilder::new()
1005            .sender(AccountAddress::ONE)
1006            .starting_sequence_number(0)
1007            .chain_id(ChainId::testnet());
1008
1009        // Empty payloads returns empty vec, not error
1010        let result = builder.build();
1011        assert!(result.is_ok());
1012        assert_eq!(result.unwrap().len(), 0);
1013    }
1014
1015    #[test]
1016    fn test_batch_result_transaction_accessor() {
1017        let signed_txn = create_dummy_signed_txn();
1018        let result = BatchTransactionResult {
1019            index: 0,
1020            transaction: signed_txn.clone(),
1021            result: Ok(BatchTransactionStatus::Pending {
1022                hash: "0x123".to_string(),
1023            }),
1024        };
1025
1026        assert_eq!(result.index, 0);
1027        assert_eq!(result.transaction.raw_txn.sender, AccountAddress::ONE);
1028    }
1029
1030    #[test]
1031    fn test_batch_builder_default() {
1032        let builder = TransactionBatchBuilder::default();
1033        assert!(builder.is_empty());
1034        assert_eq!(builder.len(), 0);
1035    }
1036
1037    #[test]
1038    fn test_batch_builder_len_and_is_empty() {
1039        let builder = TransactionBatchBuilder::new();
1040        assert!(builder.is_empty());
1041        assert_eq!(builder.len(), 0);
1042
1043        let builder = builder.add_payload(TransactionPayload::Script(crate::transaction::Script {
1044            code: vec![],
1045            type_args: vec![],
1046            args: vec![],
1047        }));
1048        assert!(!builder.is_empty());
1049        assert_eq!(builder.len(), 1);
1050    }
1051
1052    #[test]
1053    fn test_batch_builder_add_payloads() {
1054        let payloads = vec![
1055            TransactionPayload::Script(crate::transaction::Script {
1056                code: vec![1],
1057                type_args: vec![],
1058                args: vec![],
1059            }),
1060            TransactionPayload::Script(crate::transaction::Script {
1061                code: vec![2],
1062                type_args: vec![],
1063                args: vec![],
1064            }),
1065            TransactionPayload::Script(crate::transaction::Script {
1066                code: vec![3],
1067                type_args: vec![],
1068                args: vec![],
1069            }),
1070        ];
1071
1072        let builder = TransactionBatchBuilder::new()
1073            .sender(AccountAddress::ONE)
1074            .starting_sequence_number(0)
1075            .chain_id(ChainId::testnet())
1076            .add_payloads(payloads);
1077
1078        assert_eq!(builder.len(), 3);
1079
1080        let transactions = builder.build().unwrap();
1081        assert_eq!(transactions.len(), 3);
1082    }
1083
1084    #[test]
1085    fn test_batch_builder_missing_sequence_number() {
1086        let builder = TransactionBatchBuilder::new()
1087            .sender(AccountAddress::ONE)
1088            .chain_id(ChainId::testnet())
1089            .add_payload(TransactionPayload::Script(crate::transaction::Script {
1090                code: vec![],
1091                type_args: vec![],
1092                args: vec![],
1093            }));
1094
1095        let result = builder.build();
1096        assert!(result.is_err());
1097        assert!(result.unwrap_err().to_string().contains("sequence_number"));
1098    }
1099
1100    #[test]
1101    fn test_batch_builder_missing_chain_id() {
1102        let builder = TransactionBatchBuilder::new()
1103            .sender(AccountAddress::ONE)
1104            .starting_sequence_number(0)
1105            .add_payload(TransactionPayload::Script(crate::transaction::Script {
1106                code: vec![],
1107                type_args: vec![],
1108                args: vec![],
1109            }));
1110
1111        let result = builder.build();
1112        assert!(result.is_err());
1113        assert!(result.unwrap_err().to_string().contains("chain_id"));
1114    }
1115
1116    #[test]
1117    fn test_batch_summary_empty() {
1118        let results: Vec<BatchTransactionResult> = vec![];
1119        let summary = BatchSummary::from_results(&results);
1120        assert_eq!(summary.total, 0);
1121        assert_eq!(summary.succeeded, 0);
1122        assert_eq!(summary.failed, 0);
1123        assert_eq!(summary.pending, 0);
1124        assert_eq!(summary.total_gas_used, 0);
1125        assert!(summary.all_succeeded());
1126        assert!(!summary.has_failures());
1127    }
1128
1129    #[test]
1130    fn test_batch_status_failed_variant() {
1131        let failed = BatchTransactionStatus::Failed {
1132            error: "connection timeout".to_string(),
1133        };
1134        assert!(failed.is_failed());
1135        assert!(!failed.is_success());
1136        assert!(failed.hash().is_none());
1137    }
1138
1139    #[test]
1140    fn test_signed_transaction_batch_len() {
1141        let batch = SignedTransactionBatch {
1142            transactions: vec![create_dummy_signed_txn(), create_dummy_signed_txn()],
1143        };
1144        assert_eq!(batch.len(), 2);
1145        assert!(!batch.is_empty());
1146    }
1147
1148    #[test]
1149    fn test_signed_transaction_batch_iter() {
1150        let txn1 = create_dummy_signed_txn();
1151        let txn2 = create_dummy_signed_txn();
1152        let batch = SignedTransactionBatch {
1153            transactions: vec![txn1, txn2],
1154        };
1155
1156        let collected: Vec<_> = batch.transactions.iter().collect();
1157        assert_eq!(collected.len(), 2);
1158    }
1159
1160    #[test]
1161    fn test_batch_builder_gas_settings() {
1162        let builder = TransactionBatchBuilder::new()
1163            .max_gas_amount(50000)
1164            .gas_unit_price(200)
1165            .expiration_secs(120);
1166
1167        assert_eq!(builder.max_gas_amount, 50000);
1168        assert_eq!(builder.gas_unit_price, 200);
1169        assert_eq!(builder.expiration_secs, 120);
1170    }
1171
1172    #[test]
1173    fn test_batch_builder_missing_sender() {
1174        let builder = TransactionBatchBuilder::new()
1175            .starting_sequence_number(0)
1176            .chain_id(ChainId::testnet())
1177            .add_payload(TransactionPayload::Script(crate::transaction::Script {
1178                code: vec![],
1179                type_args: vec![],
1180                args: vec![],
1181            }));
1182
1183        let result = builder.build();
1184        assert!(result.is_err());
1185        assert!(result.unwrap_err().to_string().contains("sender"));
1186    }
1187
1188    #[test]
1189    fn test_batch_summary_with_failures() {
1190        let txn = create_dummy_signed_txn();
1191        let results = vec![
1192            BatchTransactionResult {
1193                index: 0,
1194                transaction: txn.clone(),
1195                result: Ok(BatchTransactionStatus::Failed {
1196                    error: "error".to_string(),
1197                }),
1198            },
1199            BatchTransactionResult {
1200                index: 1,
1201                transaction: txn,
1202                result: Err(AptosError::Transaction("test".to_string())),
1203            },
1204        ];
1205
1206        let summary = BatchSummary::from_results(&results);
1207        assert_eq!(summary.total, 2);
1208        assert_eq!(summary.failed, 2);
1209        assert!(summary.has_failures());
1210    }
1211
1212    #[test]
1213    fn test_batch_status_confirmed_variant() {
1214        let status = BatchTransactionStatus::Confirmed {
1215            hash: "0xabc".to_string(),
1216            success: true,
1217            version: 1,
1218            gas_used: 150,
1219        };
1220        assert!(status.is_success());
1221        assert!(!status.is_failed());
1222        assert_eq!(status.hash(), Some("0xabc"));
1223    }
1224
1225    #[test]
1226    fn test_batch_status_pending_variant() {
1227        let status = BatchTransactionStatus::Pending {
1228            hash: "0xdef".to_string(),
1229        };
1230        assert!(!status.is_success());
1231        assert!(!status.is_failed());
1232        assert_eq!(status.hash(), Some("0xdef"));
1233    }
1234
1235    #[test]
1236    fn test_signed_transaction_batch_new() {
1237        let txn1 = create_dummy_signed_txn();
1238        let txn2 = create_dummy_signed_txn();
1239        let batch = SignedTransactionBatch::new(vec![txn1, txn2]);
1240        assert_eq!(batch.len(), 2);
1241    }
1242
1243    #[test]
1244    fn test_signed_transaction_batch_transactions() {
1245        let txn1 = create_dummy_signed_txn();
1246        let txn2 = create_dummy_signed_txn();
1247        let batch = SignedTransactionBatch::new(vec![txn1, txn2]);
1248
1249        let txns = batch.transactions();
1250        assert_eq!(txns.len(), 2);
1251    }
1252
1253    #[test]
1254    fn test_signed_transaction_batch_into_transactions() {
1255        let txn1 = create_dummy_signed_txn();
1256        let txn2 = create_dummy_signed_txn();
1257        let batch = SignedTransactionBatch::new(vec![txn1, txn2]);
1258
1259        let txns = batch.into_transactions();
1260        assert_eq!(txns.len(), 2);
1261    }
1262
1263    #[test]
1264    fn test_signed_transaction_batch_empty() {
1265        let batch = SignedTransactionBatch::new(vec![]);
1266        assert!(batch.is_empty());
1267        assert_eq!(batch.len(), 0);
1268    }
1269
1270    #[test]
1271    fn test_batch_transaction_result_accessors() {
1272        let txn = create_dummy_signed_txn();
1273        let result = BatchTransactionResult {
1274            index: 5,
1275            transaction: txn.clone(),
1276            result: Ok(BatchTransactionStatus::Confirmed {
1277                hash: "0x123".to_string(),
1278                success: true,
1279                version: 1,
1280                gas_used: 100,
1281            }),
1282        };
1283
1284        assert_eq!(result.index, 5);
1285        assert!(result.result.is_ok());
1286    }
1287
1288    #[test]
1289    fn test_batch_builder_debug() {
1290        let builder = TransactionBatchBuilder::new().sender(AccountAddress::ONE);
1291        let debug = format!("{builder:?}");
1292        assert!(debug.contains("TransactionBatchBuilder"));
1293    }
1294
1295    #[test]
1296    fn test_signed_transaction_batch_debug() {
1297        let batch = SignedTransactionBatch::new(vec![create_dummy_signed_txn()]);
1298        let debug = format!("{batch:?}");
1299        assert!(debug.contains("SignedTransactionBatch"));
1300    }
1301
1302    #[test]
1303    fn test_batch_summary_debug() {
1304        let summary = BatchSummary {
1305            total: 5,
1306            succeeded: 3,
1307            failed: 1,
1308            pending: 1,
1309            total_gas_used: 500,
1310        };
1311        let debug = format!("{summary:?}");
1312        assert!(debug.contains("BatchSummary"));
1313    }
1314
1315    #[test]
1316    fn test_batch_transaction_status_debug() {
1317        let status = BatchTransactionStatus::Confirmed {
1318            hash: "0x123".to_string(),
1319            success: true,
1320            version: 1,
1321            gas_used: 100,
1322        };
1323        let debug = format!("{status:?}");
1324        assert!(debug.contains("Confirmed"));
1325    }
1326
1327    #[tokio::test]
1328    async fn test_submit_all_returns_pending() {
1329        let server = MockServer::start().await;
1330        Mock::given(method("POST"))
1331            .and(path("/v1/transactions"))
1332            .respond_with(ResponseTemplate::new(202).set_body_json(pending_txn_response()))
1333            .mount(&server)
1334            .await;
1335
1336        let client = create_mock_client(&server);
1337        let batch =
1338            SignedTransactionBatch::new(vec![create_dummy_signed_txn(), create_dummy_signed_txn()]);
1339        let results = batch.submit_all(&client).await;
1340
1341        assert_eq!(results.len(), 2);
1342        for (i, r) in results.iter().enumerate() {
1343            assert_eq!(r.index, i);
1344            let status = r.result.as_ref().unwrap();
1345            assert!(matches!(status, BatchTransactionStatus::Pending { .. }));
1346            assert_eq!(
1347                status.hash(),
1348                Some("0x0000000000000000000000000000000000000000000000000000000000000001")
1349            );
1350        }
1351    }
1352
1353    #[tokio::test]
1354    async fn test_submit_all_surfaces_submission_error() {
1355        let server = MockServer::start().await;
1356        Mock::given(method("POST"))
1357            .and(path("/v1/transactions"))
1358            .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
1359                "message": "Invalid transaction",
1360                "error_code": "invalid_transaction_update"
1361            })))
1362            .mount(&server)
1363            .await;
1364
1365        let client = create_mock_client(&server);
1366        let batch = SignedTransactionBatch::new(vec![create_dummy_signed_txn()]);
1367        let results = batch.submit_all(&client).await;
1368
1369        assert_eq!(results.len(), 1);
1370        assert!(results[0].result.is_err());
1371    }
1372
1373    #[tokio::test]
1374    async fn test_submit_and_wait_all_confirmed() {
1375        let server = MockServer::start().await;
1376        Mock::given(method("POST"))
1377            .and(path("/v1/transactions"))
1378            .respond_with(ResponseTemplate::new(202).set_body_json(pending_txn_response()))
1379            .mount(&server)
1380            .await;
1381        Mock::given(method("GET"))
1382            .and(path_regex(r"^/v1/transactions/by_hash/0x[0-9a-f]+$"))
1383            .respond_with(ResponseTemplate::new(200).set_body_json(committed_txn_response()))
1384            .mount(&server)
1385            .await;
1386
1387        let client = create_mock_client(&server);
1388        let batch =
1389            SignedTransactionBatch::new(vec![create_dummy_signed_txn(), create_dummy_signed_txn()]);
1390        let results = batch
1391            .submit_and_wait_all(&client, Some(Duration::from_secs(5)))
1392            .await;
1393
1394        assert_eq!(results.len(), 2);
1395        for r in &results {
1396            match r.result.as_ref().unwrap() {
1397                BatchTransactionStatus::Confirmed {
1398                    success,
1399                    version,
1400                    gas_used,
1401                    hash,
1402                } => {
1403                    assert!(*success);
1404                    assert_eq!(*version, 12345);
1405                    assert_eq!(*gas_used, 500);
1406                    assert_eq!(
1407                        hash,
1408                        "0x0000000000000000000000000000000000000000000000000000000000000001"
1409                    );
1410                }
1411                other => panic!("expected Confirmed, got {other:?}"),
1412            }
1413        }
1414    }
1415
1416    #[tokio::test]
1417    async fn test_submit_sequential_returns_pending() {
1418        let server = MockServer::start().await;
1419        Mock::given(method("POST"))
1420            .and(path("/v1/transactions"))
1421            .respond_with(ResponseTemplate::new(202).set_body_json(pending_txn_response()))
1422            .mount(&server)
1423            .await;
1424
1425        let client = create_mock_client(&server);
1426        let batch =
1427            SignedTransactionBatch::new(vec![create_dummy_signed_txn(), create_dummy_signed_txn()]);
1428        let results = batch.submit_sequential(&client).await;
1429
1430        assert_eq!(results.len(), 2);
1431        assert_eq!(results[0].index, 0);
1432        assert_eq!(results[1].index, 1);
1433        assert!(results.iter().all(|r| r.result.is_ok()));
1434    }
1435
1436    #[tokio::test]
1437    async fn test_submit_and_wait_sequential_confirmed() {
1438        let server = MockServer::start().await;
1439        Mock::given(method("POST"))
1440            .and(path("/v1/transactions"))
1441            .respond_with(ResponseTemplate::new(202).set_body_json(pending_txn_response()))
1442            .mount(&server)
1443            .await;
1444        Mock::given(method("GET"))
1445            .and(path_regex(r"^/v1/transactions/by_hash/0x[0-9a-f]+$"))
1446            .respond_with(ResponseTemplate::new(200).set_body_json(committed_txn_response()))
1447            .mount(&server)
1448            .await;
1449
1450        let client = create_mock_client(&server);
1451        let batch =
1452            SignedTransactionBatch::new(vec![create_dummy_signed_txn(), create_dummy_signed_txn()]);
1453        let results = batch.submit_and_wait_sequential(&client, None).await;
1454
1455        // Both succeed, so neither triggers the early break.
1456        assert_eq!(results.len(), 2);
1457        assert!(results.iter().all(|r| r.result.is_ok()));
1458    }
1459
1460    #[tokio::test]
1461    async fn test_submit_and_wait_sequential_stops_on_failure() {
1462        let server = MockServer::start().await;
1463        // Submission itself fails, so the sequential loop must break after the
1464        // first transaction and never submit the second.
1465        Mock::given(method("POST"))
1466            .and(path("/v1/transactions"))
1467            .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
1468                "message": "Invalid transaction",
1469                "error_code": "invalid_transaction_update"
1470            })))
1471            .mount(&server)
1472            .await;
1473
1474        let client = create_mock_client(&server);
1475        let batch =
1476            SignedTransactionBatch::new(vec![create_dummy_signed_txn(), create_dummy_signed_txn()]);
1477        let results = batch.submit_and_wait_sequential(&client, None).await;
1478
1479        assert_eq!(results.len(), 1, "should stop after first failure");
1480        assert!(results[0].result.is_err());
1481    }
1482
1483    #[tokio::test]
1484    async fn test_resolve_chain_id_uses_cached_value() {
1485        // A non-zero cached chain id short-circuits without any network call.
1486        let server = MockServer::start().await;
1487        let client = create_mock_client(&server);
1488        let chain_id = std::sync::atomic::AtomicU8::new(4);
1489        let ops = BatchOperations::new(&client, &chain_id);
1490
1491        let resolved = ops.resolve_chain_id().await.unwrap();
1492        assert_eq!(resolved, ChainId::new(4));
1493    }
1494
1495    #[tokio::test]
1496    async fn test_resolve_chain_id_fetches_from_node() {
1497        let server = MockServer::start().await;
1498        Mock::given(method("GET"))
1499            .and(path("/v1"))
1500            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1501                "chain_id": 2,
1502                "epoch": "100",
1503                "ledger_version": "12345",
1504                "oldest_ledger_version": "0",
1505                "ledger_timestamp": "1000000",
1506                "node_role": "full_node",
1507                "oldest_block_height": "0",
1508                "block_height": "5000"
1509            })))
1510            .mount(&server)
1511            .await;
1512
1513        let client = create_mock_client(&server);
1514        let chain_id = std::sync::atomic::AtomicU8::new(0);
1515        let ops = BatchOperations::new(&client, &chain_id);
1516
1517        let resolved = ops.resolve_chain_id().await.unwrap();
1518        assert_eq!(resolved, ChainId::new(2));
1519        // The fetched id must be cached back into the atomic for reuse.
1520        assert_eq!(chain_id.load(std::sync::atomic::Ordering::Relaxed), 2);
1521    }
1522
1523    #[cfg(feature = "ed25519")]
1524    #[tokio::test]
1525    async fn test_batch_operations_build() {
1526        use crate::account::Ed25519Account;
1527
1528        let server = MockServer::start().await;
1529        Mock::given(method("GET"))
1530            .and(path_regex(r"^/v1/accounts/0x[0-9a-f]+$"))
1531            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1532                "sequence_number": "7",
1533                "authentication_key": "0x0000000000000000000000000000000000000000000000000000000000000001"
1534            })))
1535            .mount(&server)
1536            .await;
1537        Mock::given(method("GET"))
1538            .and(path("/v1/estimate_gas_price"))
1539            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1540                "gas_estimate": 123
1541            })))
1542            .mount(&server)
1543            .await;
1544
1545        let client = create_mock_client(&server);
1546        // Pre-seed the chain id so resolve_chain_id short-circuits.
1547        let chain_id = std::sync::atomic::AtomicU8::new(4);
1548        let ops = BatchOperations::new(&client, &chain_id);
1549        let account = Ed25519Account::generate();
1550
1551        let payloads = vec![
1552            TransactionPayload::Script(crate::transaction::Script {
1553                code: vec![],
1554                type_args: vec![],
1555                args: vec![],
1556            }),
1557            TransactionPayload::Script(crate::transaction::Script {
1558                code: vec![],
1559                type_args: vec![],
1560                args: vec![],
1561            }),
1562        ];
1563        let batch = ops.build(&account, payloads).await.unwrap();
1564
1565        // Two payloads -> two signed transactions with incrementing seq numbers
1566        // starting at the fetched sequence number (7).
1567        assert_eq!(batch.len(), 2);
1568        let txns = batch.transactions();
1569        assert_eq!(txns[0].raw_txn.sequence_number, 7);
1570        assert_eq!(txns[1].raw_txn.sequence_number, 8);
1571        // The gas price came from the estimate response.
1572        assert_eq!(txns[0].raw_txn.gas_unit_price, 123);
1573        assert_eq!(txns[0].raw_txn.chain_id, ChainId::new(4));
1574    }
1575
1576    #[cfg(feature = "ed25519")]
1577    #[tokio::test]
1578    async fn test_batch_operations_submit() {
1579        use crate::account::Ed25519Account;
1580
1581        let server = MockServer::start().await;
1582        Mock::given(method("GET"))
1583            .and(path_regex(r"^/v1/accounts/0x[0-9a-f]+$"))
1584            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1585                "sequence_number": "0",
1586                "authentication_key": "0x0000000000000000000000000000000000000000000000000000000000000001"
1587            })))
1588            .mount(&server)
1589            .await;
1590        Mock::given(method("GET"))
1591            .and(path("/v1/estimate_gas_price"))
1592            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1593                "gas_estimate": 100
1594            })))
1595            .mount(&server)
1596            .await;
1597        Mock::given(method("POST"))
1598            .and(path("/v1/transactions"))
1599            .respond_with(ResponseTemplate::new(202).set_body_json(pending_txn_response()))
1600            .mount(&server)
1601            .await;
1602
1603        let client = create_mock_client(&server);
1604        let chain_id = std::sync::atomic::AtomicU8::new(4);
1605        let ops = BatchOperations::new(&client, &chain_id);
1606        let account = Ed25519Account::generate();
1607
1608        let payloads = vec![TransactionPayload::Script(crate::transaction::Script {
1609            code: vec![],
1610            type_args: vec![],
1611            args: vec![],
1612        })];
1613        let results = ops.submit(&account, payloads).await.unwrap();
1614
1615        assert_eq!(results.len(), 1);
1616        assert!(matches!(
1617            results[0].result.as_ref().unwrap(),
1618            BatchTransactionStatus::Pending { .. }
1619        ));
1620    }
1621
1622    #[cfg(feature = "ed25519")]
1623    #[tokio::test]
1624    async fn test_batch_operations_submit_and_wait() {
1625        use crate::account::Ed25519Account;
1626
1627        let server = MockServer::start().await;
1628        Mock::given(method("GET"))
1629            .and(path_regex(r"^/v1/accounts/0x[0-9a-f]+$"))
1630            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1631                "sequence_number": "0",
1632                "authentication_key": "0x0000000000000000000000000000000000000000000000000000000000000001"
1633            })))
1634            .mount(&server)
1635            .await;
1636        Mock::given(method("GET"))
1637            .and(path("/v1/estimate_gas_price"))
1638            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1639                "gas_estimate": 100
1640            })))
1641            .mount(&server)
1642            .await;
1643        Mock::given(method("POST"))
1644            .and(path("/v1/transactions"))
1645            .respond_with(ResponseTemplate::new(202).set_body_json(pending_txn_response()))
1646            .mount(&server)
1647            .await;
1648        Mock::given(method("GET"))
1649            .and(path_regex(r"^/v1/transactions/by_hash/0x[0-9a-f]+$"))
1650            .respond_with(ResponseTemplate::new(200).set_body_json(committed_txn_response()))
1651            .mount(&server)
1652            .await;
1653
1654        let client = create_mock_client(&server);
1655        let chain_id = std::sync::atomic::AtomicU8::new(4);
1656        let ops = BatchOperations::new(&client, &chain_id);
1657        let account = Ed25519Account::generate();
1658
1659        let payloads = vec![TransactionPayload::Script(crate::transaction::Script {
1660            code: vec![],
1661            type_args: vec![],
1662            args: vec![],
1663        })];
1664        let results = ops
1665            .submit_and_wait(&account, payloads, Some(Duration::from_secs(5)))
1666            .await
1667            .unwrap();
1668
1669        assert_eq!(results.len(), 1);
1670        assert!(matches!(
1671            results[0].result.as_ref().unwrap(),
1672            BatchTransactionStatus::Confirmed { success: true, .. }
1673        ));
1674    }
1675
1676    #[cfg(feature = "ed25519")]
1677    #[tokio::test]
1678    async fn test_batch_operations_transfer_apt() {
1679        use crate::account::Ed25519Account;
1680
1681        let server = MockServer::start().await;
1682        Mock::given(method("GET"))
1683            .and(path_regex(r"^/v1/accounts/0x[0-9a-f]+$"))
1684            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1685                "sequence_number": "0",
1686                "authentication_key": "0x0000000000000000000000000000000000000000000000000000000000000001"
1687            })))
1688            .mount(&server)
1689            .await;
1690        Mock::given(method("GET"))
1691            .and(path("/v1/estimate_gas_price"))
1692            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1693                "gas_estimate": 100
1694            })))
1695            .mount(&server)
1696            .await;
1697        Mock::given(method("POST"))
1698            .and(path("/v1/transactions"))
1699            .respond_with(ResponseTemplate::new(202).set_body_json(pending_txn_response()))
1700            .mount(&server)
1701            .await;
1702        Mock::given(method("GET"))
1703            .and(path_regex(r"^/v1/transactions/by_hash/0x[0-9a-f]+$"))
1704            .respond_with(ResponseTemplate::new(200).set_body_json(committed_txn_response()))
1705            .mount(&server)
1706            .await;
1707
1708        let client = create_mock_client(&server);
1709        let chain_id = std::sync::atomic::AtomicU8::new(4);
1710        let ops = BatchOperations::new(&client, &chain_id);
1711        let account = Ed25519Account::generate();
1712
1713        let transfers = vec![
1714            (AccountAddress::ONE, 100u64),
1715            (AccountAddress::from_hex("0x2").unwrap(), 200u64),
1716        ];
1717        let results = ops.transfer_apt(&account, transfers).await.unwrap();
1718
1719        assert_eq!(results.len(), 2);
1720        assert!(results.iter().all(|r| matches!(
1721            r.result.as_ref().unwrap(),
1722            BatchTransactionStatus::Confirmed { success: true, .. }
1723        )));
1724    }
1725}