Skip to main content

hiero_sdk/
batch_transaction.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use hiero_sdk_proto::services;
4use hiero_sdk_proto::services::util_service_client::UtilServiceClient;
5use prost::Message;
6use tonic::transport::Channel;
7
8use crate::ledger_id::RefLedgerId;
9use crate::protobuf::FromProtobuf;
10use crate::transaction::{
11    AnyTransactionData,
12    ChunkInfo,
13    ToTransactionDataProtobuf,
14    TransactionData,
15    TransactionExecute,
16};
17use crate::{
18    AnyTransaction,
19    BoxGrpcFuture,
20    Error,
21    Hbar,
22    Transaction,
23    TransactionId,
24    ValidateChecksums,
25};
26
27/// Execute multiple transactions in a single consensus event. This allows for atomic execution of multiple
28/// transactions, where they either all succeed or all fail together.
29///
30/// # Requirements
31///
32/// - All inner transactions must be frozen before being added to the batch
33/// - All inner transactions must have a batch key set (using `set_batch_key()` or `batchify()`)
34/// - All inner transactions must be signed as required for each individual transaction
35/// - The BatchTransaction must be signed by all batch keys of the inner transactions
36/// - Certain transaction types (FreezeTransaction, BatchTransaction) are not allowed in a batch
37///
38/// # Important notes
39///
40/// - Fees are assessed for each inner transaction separately
41/// - The maximum number of inner transactions in a batch is limited to 25
42/// - Inner transactions cannot be scheduled transactions
43///
44/// # Example usage
45///
46/// ```rust,no_run
47/// use hiero_sdk::{BatchTransaction, TransferTransaction, PrivateKey, Client, Hbar, AccountId};
48///
49/// # async fn example() -> hiero_sdk::Result<()> {
50/// let client = Client::for_testnet();
51/// let batch_key = PrivateKey::generate_ed25519();
52/// let operator_key = PrivateKey::generate_ed25519();
53/// let sender = AccountId::new(0, 0, 123);
54/// let receiver = AccountId::new(0, 0, 456);
55/// let amount = Hbar::new(10);
56///
57/// // Create and prepare inner transaction
58/// let mut inner_transaction = TransferTransaction::new();
59/// inner_transaction
60///     .hbar_transfer(sender, -amount)
61///     .hbar_transfer(receiver, amount)
62///     .freeze_with(&client)?;
63/// inner_transaction.set_batch_key(batch_key.public_key().into());
64/// inner_transaction.sign(operator_key);
65///
66/// // Create and execute batch transaction
67/// let mut batch_transaction = BatchTransaction::new();
68/// batch_transaction.add_inner_transaction(inner_transaction.into())?;
69/// batch_transaction.freeze_with(&client)?;
70/// batch_transaction.sign(batch_key);
71/// let response = batch_transaction.execute(&client).await?;
72/// # Ok(())
73/// # }
74/// ```
75pub type BatchTransaction = Transaction<BatchTransactionData>;
76
77#[derive(Debug, Clone, Default)]
78pub struct BatchTransactionData {
79    inner_transactions: Vec<AnyTransaction>,
80}
81
82impl BatchTransaction {
83    /// Append a transaction to the list of transactions this BatchTransaction will execute.
84    ///
85    /// # Requirements for the inner transaction
86    ///
87    /// - Must be frozen (use `freeze()` or `freeze_with(client)`)
88    /// - Must have a batch key set (use `set_batch_key()` or `batchify()`)
89    /// - Must not be a blacklisted transaction type
90    ///
91    /// # Errors
92    ///
93    /// Returns an error if:
94    /// - The transaction is null
95    /// - This transaction is frozen
96    /// - The inner transaction is not frozen or missing a batch key
97    /// - The transaction is of a blacklisted type (FreezeTransaction, BatchTransaction)
98    pub fn add_inner_transaction(
99        &mut self,
100        transaction: AnyTransaction,
101    ) -> crate::Result<&mut Self> {
102        self.require_not_frozen();
103        self.validate_inner_transaction(&transaction)?;
104        self.data_mut().inner_transactions.push(transaction);
105        Ok(self)
106    }
107
108    /// Set the list of transactions to be executed as part of this BatchTransaction.
109    ///
110    /// # Requirements for each inner transaction
111    ///
112    /// - Must be frozen (use `freeze()` or `freeze_with(client)`)
113    /// - Must have a batch key set (use `set_batch_key()` or `batchify()`)
114    /// - Must not be a blacklisted transaction type
115    ///
116    /// Note: This method creates a defensive copy of the provided list.
117    ///
118    /// # Errors
119    ///
120    /// Returns an error if:
121    /// - Any inner transaction is not frozen or missing a batch key
122    /// - Any transaction is of a blacklisted type
123    pub fn set_inner_transactions(
124        &mut self,
125        transactions: Vec<AnyTransaction>,
126    ) -> crate::Result<&mut Self> {
127        self.require_not_frozen();
128
129        // Validate all transactions before setting
130        for transaction in &transactions {
131            self.validate_inner_transaction(transaction)?;
132        }
133
134        self.data_mut().inner_transactions = transactions;
135        Ok(self)
136    }
137
138    /// Get the list of transactions this BatchTransaction is currently configured to execute.
139    pub fn get_inner_transactions(&self) -> &[AnyTransaction] {
140        &self.data().inner_transactions
141    }
142
143    /// Get the list of transaction IDs of each inner transaction of this BatchTransaction.
144    ///
145    /// This method is particularly useful after execution to:
146    /// - Track individual transaction results
147    /// - Query receipts for specific inner transactions
148    /// - Monitor the status of each transaction in the batch
149    ///
150    /// **NOTE:** Transaction IDs will only be meaningful after the batch transaction has been
151    /// executed or the IDs have been explicitly set on the inner transactions.
152    pub fn get_inner_transaction_ids(&self) -> Vec<Option<TransactionId>> {
153        self.data().inner_transactions.iter().map(|tx| tx.get_transaction_id()).collect()
154    }
155
156    /// Validates if a transaction is allowed in a batch transaction.
157    ///
158    /// A transaction is valid if:
159    /// - It is not a blacklisted type (FreezeTransaction or BatchTransaction)
160    /// - It is frozen
161    /// - It has a batch key set
162    fn validate_inner_transaction(&self, transaction: &AnyTransaction) -> crate::Result<()> {
163        // Check if transaction type is blacklisted
164        match transaction.data() {
165            AnyTransactionData::Freeze(_) => {
166                return Err(Error::basic_parse(
167                    "Transaction type FreezeTransaction is not allowed in a batch transaction",
168                ));
169            }
170            AnyTransactionData::Batch(_) => {
171                return Err(Error::basic_parse(
172                    "Transaction type BatchTransaction is not allowed in a batch transaction",
173                ));
174            }
175            _ => {}
176        }
177
178        // Check if transaction is frozen
179        if !transaction.is_frozen() {
180            return Err(Error::basic_parse("Inner transaction should be frozen"));
181        }
182
183        // Check if batch key is set
184        if transaction.get_batch_key().is_none() {
185            return Err(Error::basic_parse("Batch key needs to be set"));
186        }
187
188        Ok(())
189    }
190}
191
192impl TransactionData for BatchTransactionData {
193    fn default_max_transaction_fee(&self) -> Hbar {
194        Hbar::new(2)
195    }
196}
197
198impl ToTransactionDataProtobuf for BatchTransactionData {
199    fn to_transaction_data_protobuf(
200        &self,
201        _chunk_info: &ChunkInfo,
202    ) -> services::transaction_body::Data {
203        let mut builder = services::AtomicBatchTransactionBody::default();
204
205        for transaction in &self.inner_transactions {
206            // Get the signed transaction bytes from each inner transaction
207            // Note: This unwrap is OK because inner transactions should be frozen
208            let signed_transaction_bytes = transaction
209                .to_signed_transaction_bytes()
210                .expect("Inner transaction should be frozen and serializable");
211
212            builder.transactions.push(signed_transaction_bytes);
213        }
214
215        services::transaction_body::Data::AtomicBatch(builder)
216    }
217}
218
219impl TransactionExecute for BatchTransactionData {
220    fn execute(
221        &self,
222        channel: Channel,
223        request: services::Transaction,
224    ) -> BoxGrpcFuture<'_, services::TransactionResponse> {
225        Box::pin(async move { UtilServiceClient::new(channel).atomic_batch(request).await })
226    }
227}
228
229impl ValidateChecksums for BatchTransactionData {
230    fn validate_checksums(&self, ledger_id: &RefLedgerId) -> Result<(), Error> {
231        for transaction in &self.inner_transactions {
232            transaction.validate_checksums(ledger_id)?;
233        }
234        Ok(())
235    }
236}
237
238impl FromProtobuf<services::AtomicBatchTransactionBody> for BatchTransactionData {
239    fn from_protobuf(pb: services::AtomicBatchTransactionBody) -> crate::Result<Self> {
240        let mut inner_transactions = Vec::new();
241
242        for signed_transaction_bytes in pb.transactions {
243            // Create a transaction from the signed transaction bytes
244            let proto_transaction =
245                services::Transaction { signed_transaction_bytes, ..Default::default() };
246
247            let transaction = AnyTransaction::from_bytes(&proto_transaction.encode_to_vec())?;
248            inner_transactions.push(transaction);
249        }
250
251        Ok(Self { inner_transactions })
252    }
253}
254
255impl From<BatchTransactionData> for AnyTransactionData {
256    fn from(value: BatchTransactionData) -> Self {
257        Self::Batch(value)
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use std::str::FromStr;
264
265    use super::*;
266    use crate::account::AccountCreateTransactionData;
267    use crate::{
268        AccountCreateTransaction,
269        AccountId,
270        Client,
271        FreezeTransaction,
272        Hbar,
273        PrivateKey,
274        Transaction,
275        TransactionId,
276        TransferTransaction,
277    };
278
279    fn create_test_client() -> Client {
280        Client::for_testnet()
281    }
282
283    fn create_test_operator_key() -> PrivateKey {
284        PrivateKey::from_str(
285            "302e020100300506032b65700422042091132178e72057a1d7528025956fe39b0b847f200ab59b2fdd367017f3087137"
286        ).unwrap()
287    }
288
289    fn create_valid_inner_transaction() -> crate::Result<Transaction<AccountCreateTransactionData>>
290    {
291        let client = create_test_client();
292        let operator_key = create_test_operator_key();
293        let operator_id = AccountId::new(0, 0, 2);
294        client.set_operator(operator_id, operator_key.clone());
295
296        let account_key = PrivateKey::generate_ed25519();
297
298        let mut transaction = AccountCreateTransaction::new();
299        transaction.set_key_without_alias(account_key.public_key()).initial_balance(Hbar::new(1));
300
301        transaction.batchify(&client, operator_key.public_key().into())?;
302        Ok(transaction)
303    }
304
305    fn create_unfrozen_transaction() -> Transaction<AccountCreateTransactionData> {
306        let account_key = PrivateKey::generate_ed25519();
307
308        let mut transaction = AccountCreateTransaction::new();
309        transaction.set_key_without_alias(account_key.public_key()).initial_balance(Hbar::new(1));
310
311        transaction
312    }
313
314    fn create_frozen_no_batch_key_transaction(
315    ) -> crate::Result<Transaction<AccountCreateTransactionData>> {
316        let client = create_test_client();
317        let operator_key = create_test_operator_key();
318        let operator_id = AccountId::new(0, 0, 2);
319        client.set_operator(operator_id, operator_key);
320
321        let account_key = PrivateKey::generate_ed25519();
322
323        let mut transaction = AccountCreateTransaction::new();
324        transaction.set_key_without_alias(account_key.public_key()).initial_balance(Hbar::new(1));
325
326        transaction.freeze_with(&client)?;
327        Ok(transaction)
328    }
329
330    fn create_blacklisted_transaction(
331    ) -> crate::Result<Transaction<crate::system::FreezeTransactionData>> {
332        let client = create_test_client();
333        let operator_key = create_test_operator_key();
334        let operator_id = AccountId::new(0, 0, 2);
335        client.set_operator(operator_id, operator_key.clone());
336
337        let mut transaction = FreezeTransaction::new();
338        transaction.freeze_type(crate::FreezeType::FreezeOnly);
339
340        transaction.batchify(&client, operator_key.public_key().into())?;
341        Ok(transaction)
342    }
343
344    #[test]
345    fn test_new_batch_transaction() {
346        let batch = BatchTransaction::new();
347        assert_eq!(batch.get_inner_transactions().len(), 0);
348        assert_eq!(batch.get_inner_transaction_ids().len(), 0);
349    }
350
351    #[tokio::test]
352    async fn test_add_valid_inner_transaction() -> crate::Result<()> {
353        let mut batch = BatchTransaction::new();
354        let inner_transaction = create_valid_inner_transaction()?;
355
356        let result = batch.add_inner_transaction(inner_transaction.into());
357        assert!(result.is_ok());
358        assert_eq!(batch.get_inner_transactions().len(), 1);
359        assert_eq!(batch.get_inner_transaction_ids().len(), 1);
360
361        Ok(())
362    }
363
364    #[tokio::test]
365    async fn test_add_multiple_inner_transactions() -> crate::Result<()> {
366        let mut batch = BatchTransaction::new();
367
368        // Add first transaction
369        let inner1 = create_valid_inner_transaction()?;
370        batch.add_inner_transaction(inner1.into())?;
371
372        // Add second transaction
373        let inner2 = create_valid_inner_transaction()?;
374        batch.add_inner_transaction(inner2.into())?;
375
376        assert_eq!(batch.get_inner_transactions().len(), 2);
377        assert_eq!(batch.get_inner_transaction_ids().len(), 2);
378
379        Ok(())
380    }
381
382    #[test]
383    fn test_add_unfrozen_transaction_fails() {
384        let mut batch = BatchTransaction::new();
385        let unfrozen_transaction = create_unfrozen_transaction();
386
387        let result = batch.add_inner_transaction(unfrozen_transaction.into());
388        assert!(result.is_err());
389        assert!(result.unwrap_err().to_string().contains("frozen"));
390    }
391
392    #[tokio::test]
393    async fn test_add_transaction_without_batch_key_fails() -> crate::Result<()> {
394        let mut batch = BatchTransaction::new();
395        let transaction = create_frozen_no_batch_key_transaction()?;
396
397        let result = batch.add_inner_transaction(transaction.into());
398        assert!(result.is_err());
399        let error_msg = result.unwrap_err().to_string();
400        assert!(error_msg.contains("batch key") || error_msg.contains("needs to be set"));
401
402        Ok(())
403    }
404
405    #[tokio::test]
406    async fn test_add_blacklisted_transaction_fails() -> crate::Result<()> {
407        let mut batch = BatchTransaction::new();
408        let blacklisted_transaction = create_blacklisted_transaction()?;
409
410        let result = batch.add_inner_transaction(blacklisted_transaction.into());
411        assert!(result.is_err());
412        assert!(result.unwrap_err().to_string().contains("FreezeTransaction"));
413
414        Ok(())
415    }
416
417    #[test]
418    fn test_add_batch_transaction_to_batch_fails() -> crate::Result<()> {
419        let mut batch = BatchTransaction::new();
420        let inner_batch = BatchTransaction::new();
421
422        let result = batch.add_inner_transaction(inner_batch.into());
423        assert!(result.is_err());
424        assert!(result.unwrap_err().to_string().contains("BatchTransaction"));
425
426        Ok(())
427    }
428
429    #[tokio::test]
430    async fn test_set_inner_transactions() -> crate::Result<()> {
431        let mut batch = BatchTransaction::new();
432
433        let inner1 = create_valid_inner_transaction()?;
434        let inner2 = create_valid_inner_transaction()?;
435
436        let transactions = vec![inner1.into(), inner2.into()];
437        let result = batch.set_inner_transactions(transactions);
438
439        assert!(result.is_ok());
440        assert_eq!(batch.get_inner_transactions().len(), 2);
441
442        Ok(())
443    }
444
445    #[tokio::test]
446    async fn test_set_inner_transactions_with_invalid_transaction_fails() -> crate::Result<()> {
447        let mut batch = BatchTransaction::new();
448
449        let valid_transaction = create_valid_inner_transaction()?;
450        let invalid_transaction = create_unfrozen_transaction();
451
452        let transactions = vec![valid_transaction.into(), invalid_transaction.into()];
453        let result = batch.set_inner_transactions(transactions);
454
455        assert!(result.is_err());
456        assert!(result.unwrap_err().to_string().contains("frozen"));
457
458        Ok(())
459    }
460
461    #[tokio::test]
462    async fn test_set_inner_transactions_replaces_existing() -> crate::Result<()> {
463        let mut batch = BatchTransaction::new();
464
465        // Add initial transaction
466        let initial_transaction = create_valid_inner_transaction()?;
467        batch.add_inner_transaction(initial_transaction.into())?;
468        assert_eq!(batch.get_inner_transactions().len(), 1);
469
470        // Replace with new transactions
471        let new1 = create_valid_inner_transaction()?;
472        let new2 = create_valid_inner_transaction()?;
473        let new_transactions = vec![new1.into(), new2.into()];
474
475        batch.set_inner_transactions(new_transactions)?;
476        assert_eq!(batch.get_inner_transactions().len(), 2);
477
478        Ok(())
479    }
480
481    #[tokio::test]
482    async fn test_get_inner_transaction_ids() -> crate::Result<()> {
483        let mut batch = BatchTransaction::new();
484
485        let inner1 = create_valid_inner_transaction()?;
486        let inner2 = create_valid_inner_transaction()?;
487
488        batch.add_inner_transaction(inner1.into())?;
489        batch.add_inner_transaction(inner2.into())?;
490
491        let transaction_ids = batch.get_inner_transaction_ids();
492        assert_eq!(transaction_ids.len(), 2);
493
494        // All transaction IDs should be valid
495        for tx_id in transaction_ids {
496            if let Some(tx_id) = tx_id {
497                // Account ID should have valid shard/realm/num or alias
498                assert!(
499                    tx_id.account_id.num > 0
500                        || tx_id.account_id.alias.is_some()
501                        || tx_id.account_id.evm_address.is_some()
502                );
503                assert!(tx_id.valid_start.unix_timestamp() > 0);
504            }
505        }
506
507        Ok(())
508    }
509
510    #[test]
511    fn test_empty_batch_has_no_transactions() {
512        let batch = BatchTransaction::new();
513        assert!(batch.get_inner_transactions().is_empty());
514        assert!(batch.get_inner_transaction_ids().is_empty());
515    }
516
517    #[test]
518    fn test_default_max_transaction_fee() {
519        let batch_data = BatchTransactionData::default();
520        let default_fee = batch_data.default_max_transaction_fee();
521        // Should have a reasonable default fee
522        assert!(default_fee > Hbar::from_tinybars(0));
523    }
524
525    #[test]
526    fn test_transaction_data_trait_implementation() {
527        let batch_data = BatchTransactionData::default();
528
529        // Test that it implements TransactionData correctly
530        assert!(batch_data.default_max_transaction_fee() > Hbar::from_tinybars(0));
531        // BatchTransaction doesn't require a single node account ID
532    }
533
534    #[tokio::test]
535    async fn test_validate_checksums() -> crate::Result<()> {
536        use crate::ledger_id::RefLedgerId;
537
538        let mut batch = BatchTransaction::new();
539        let inner_transaction = create_valid_inner_transaction()?;
540        batch.add_inner_transaction(inner_transaction.into())?;
541
542        // Should not panic or return error for valid checksums
543        let result = batch.data().validate_checksums(&RefLedgerId::TESTNET);
544        assert!(result.is_ok());
545
546        Ok(())
547    }
548
549    #[tokio::test]
550    async fn test_to_transaction_data_protobuf() -> crate::Result<()> {
551        let mut batch = BatchTransaction::new();
552        let inner_transaction = create_valid_inner_transaction()?;
553        batch.add_inner_transaction(inner_transaction.into())?;
554
555        let chunk_info = crate::transaction::ChunkInfo::single(
556            TransactionId::generate(AccountId::new(0, 0, 2)),
557            AccountId::new(0, 0, 3),
558        );
559        let protobuf_data = batch.data().to_transaction_data_protobuf(&chunk_info);
560
561        // Should return AtomicBatch variant
562        match protobuf_data {
563            hiero_sdk_proto::services::transaction_body::Data::AtomicBatch(atomic_batch) => {
564                assert_eq!(atomic_batch.transactions.len(), 1);
565                assert!(!atomic_batch.transactions[0].is_empty());
566            }
567            _ => panic!("Expected AtomicBatch variant"),
568        }
569
570        Ok(())
571    }
572
573    #[tokio::test]
574    async fn test_from_protobuf_roundtrip() -> crate::Result<()> {
575        let mut original_batch = BatchTransaction::new();
576        let inner_transaction = create_valid_inner_transaction()?;
577        original_batch.add_inner_transaction(inner_transaction.into())?;
578
579        // Convert to protobuf
580        let chunk_info = crate::transaction::ChunkInfo::single(
581            TransactionId::generate(AccountId::new(0, 0, 2)),
582            AccountId::new(0, 0, 3),
583        );
584        let protobuf_data = original_batch.data().to_transaction_data_protobuf(&chunk_info);
585
586        // Extract AtomicBatchTransactionBody
587        let atomic_batch = match protobuf_data {
588            hiero_sdk_proto::services::transaction_body::Data::AtomicBatch(atomic_batch) => {
589                atomic_batch
590            }
591            _ => panic!("Expected AtomicBatch variant"),
592        };
593
594        // Convert back from protobuf
595        let reconstructed_data = BatchTransactionData::from_protobuf(atomic_batch)?;
596
597        // Should have same number of inner transactions
598        assert_eq!(
599            reconstructed_data.inner_transactions.len(),
600            original_batch.data().inner_transactions.len()
601        );
602
603        Ok(())
604    }
605
606    #[test]
607    fn test_empty_batch_protobuf() {
608        let empty_batch = BatchTransaction::new();
609        let chunk_info = crate::transaction::ChunkInfo::single(
610            TransactionId::generate(AccountId::new(0, 0, 2)),
611            AccountId::new(0, 0, 3),
612        );
613        let protobuf_data = empty_batch.data().to_transaction_data_protobuf(&chunk_info);
614
615        match protobuf_data {
616            hiero_sdk_proto::services::transaction_body::Data::AtomicBatch(atomic_batch) => {
617                assert!(atomic_batch.transactions.is_empty());
618            }
619            _ => panic!("Expected AtomicBatch variant"),
620        }
621    }
622
623    #[tokio::test]
624    async fn test_large_number_of_transactions() -> crate::Result<()> {
625        let mut batch = BatchTransaction::new();
626
627        // Add many transactions (up to reasonable limit)
628        for _ in 0..10 {
629            let inner_transaction = create_valid_inner_transaction()?;
630            batch.add_inner_transaction(inner_transaction.into())?;
631        }
632
633        assert_eq!(batch.get_inner_transactions().len(), 10);
634        assert_eq!(batch.get_inner_transaction_ids().len(), 10);
635
636        Ok(())
637    }
638
639    // Legacy tests (kept for compatibility)
640    #[test]
641    fn test_validate_non_frozen_transaction() {
642        let mut batch = BatchTransaction::new();
643        let inner_tx = TransferTransaction::new();
644
645        let result = batch.add_inner_transaction(inner_tx.into());
646        assert!(result.is_err());
647        assert!(result.unwrap_err().to_string().contains("Inner transaction should be frozen"));
648    }
649
650    #[test]
651    fn test_validate_batch_key_required() {
652        let mut batch = BatchTransaction::new();
653        let inner_tx = TransferTransaction::new();
654        // Note: In a real scenario, you would freeze the transaction first,
655        // then set a batch key, but this test just checks the validation logic
656
657        let result = batch.add_inner_transaction(inner_tx.into());
658        assert!(result.is_err());
659        // The error will be about the transaction not being frozen first,
660        // which comes before the batch key check
661        assert!(result.unwrap_err().to_string().contains("Inner transaction should be frozen"));
662    }
663}