diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a598c2f0..5de86382f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,8 +30,8 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1. - Added `.github/workflows/merge-conflict-bot.yml` to automatically detect and notify users of merge conflicts in Pull Requests. - Added `.github/workflows/bot-office-hours.yml` to automate the Weekly Office Hour Reminder. - feat: Implement account creation with EVM-style alias transaction example. -- Added validation logic in `.github/workflows/pr-checks.yml` to detect when no new chnagelog entries are added under [Unreleased]. -- Support for message chunking in `TopicSubmitMessageTransaction`. +- Added validation logic in `.github/workflows/pr-checks.yml` to detect when no new changelog entries are added under [Unreleased] +- feat: Allow `add_hbar_transfer`, `add_approved_hbar_transfer`, and internal `_add_hbar_transfer` to accept `Hbar` objects in addition to raw tinybar integers, with internal normalization to tinybars. Added tests validating the new behavior. ### Changed @@ -46,6 +46,10 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1. - fixed workflow: changelog check with improved sensitivity to deletions, additions, new releases +### Breaking Changes + +- Changed error message in `TransferTransaction._add_hbar_transfer()` and `AbstractTokenTransferTransaction._add_token_transfer()` when amount is zero from "Amount must be a non-zero integer" to "Amount must be a non-zero value." for clarity and consistency. + ## [0.1.9] - 2025-11-26 ### Added diff --git a/src/hiero_sdk_python/tokens/abstract_token_transfer_transaction.py b/src/hiero_sdk_python/tokens/abstract_token_transfer_transaction.py index cd3910217..790d3a0bd 100644 --- a/src/hiero_sdk_python/tokens/abstract_token_transfer_transaction.py +++ b/src/hiero_sdk_python/tokens/abstract_token_transfer_transaction.py @@ -149,7 +149,7 @@ def _add_token_transfer( if not isinstance(account_id, AccountId): raise TypeError("account_id must be an AccountId instance.") if not isinstance(amount, int) or amount == 0: - raise ValueError("Amount must be a non-zero integer.") + raise ValueError("Amount must be a non-zero value.") if expected_decimals is not None and not isinstance(expected_decimals, int): raise TypeError("expected_decimals must be an integer.") if not isinstance(is_approved, bool): diff --git a/src/hiero_sdk_python/transaction/transfer_transaction.py b/src/hiero_sdk_python/transaction/transfer_transaction.py index f630ed869..127ef1c0d 100644 --- a/src/hiero_sdk_python/transaction/transfer_transaction.py +++ b/src/hiero_sdk_python/transaction/transfer_transaction.py @@ -2,11 +2,12 @@ Defines TransferTransaction for transferring HBAR or tokens between accounts. """ -from typing import Dict, List, Optional, Tuple +from typing import Dict, List, Optional, Tuple, Union from hiero_sdk_python.account.account_id import AccountId from hiero_sdk_python.channels import _Channel from hiero_sdk_python.executable import _Method +from hiero_sdk_python.hbar import Hbar from hiero_sdk_python.hapi.services import basic_types_pb2, crypto_transfer_pb2, transaction_pb2 from hiero_sdk_python.hapi.services.schedulable_transaction_body_pb2 import ( SchedulableTransactionBody, @@ -59,14 +60,14 @@ def _init_hbar_transfers(self, hbar_transfers: Dict[AccountId, int]) -> None: self.add_hbar_transfer(account_id, amount) def _add_hbar_transfer( - self, account_id: AccountId, amount: int, is_approved: bool = False + self, account_id: AccountId, amount: Union[int, Hbar], is_approved: bool = False ) -> "TransferTransaction": """ Internal method to add a HBAR transfer to the transaction. Args: account_id (AccountId): The account ID of the sender or receiver. - amount (int): The amount of the HBAR to transfer. + amount (Union[int, Hbar]): The amount of the HBAR to transfer (in tinybars if int, or as Hbar object). is_approved (bool, optional): Whether the transfer is approved. Defaults to False. Returns: @@ -75,26 +76,31 @@ def _add_hbar_transfer( self._require_not_frozen() if not isinstance(account_id, AccountId): raise TypeError("account_id must be an AccountId instance.") - if not isinstance(amount, int) or amount == 0: - raise ValueError("Amount must be a non-zero integer.") + if not isinstance(amount, (int, Hbar)): + raise TypeError("Amount must be of type int or Hbar.") if not isinstance(is_approved, bool): raise TypeError("is_approved must be a boolean.") + tinybar = amount if isinstance(amount, int) else amount.to_tinybars() + + if tinybar == 0: + raise ValueError("Amount must be a non-zero value.") + for transfer in self.hbar_transfers: if transfer.account_id == account_id: - transfer.amount += amount + transfer.amount += tinybar return self - self.hbar_transfers.append(HbarTransfer(account_id, amount, is_approved)) + self.hbar_transfers.append(HbarTransfer(account_id, tinybar, is_approved)) return self - def add_hbar_transfer(self, account_id: AccountId, amount: int) -> "TransferTransaction": + def add_hbar_transfer(self, account_id: AccountId, amount: Union[int, Hbar]) -> "TransferTransaction": """ Adds a HBAR transfer to the transaction. Args: account_id (AccountId): The account ID of the sender or receiver. - amount (int): The amount of the HBAR to transfer. + amount (Union[int, Hbar]): The amount of the HBAR to transfer (in tinybars if int, or as Hbar object). Returns: TransferTransaction: The current instance of the transaction for chaining. @@ -103,14 +109,14 @@ def add_hbar_transfer(self, account_id: AccountId, amount: int) -> "TransferTran return self def add_approved_hbar_transfer( - self, account_id: AccountId, amount: int + self, account_id: AccountId, amount: Union[int, Hbar] ) -> "TransferTransaction": """ Adds a HBAR transfer with approval to the transaction. Args: account_id (AccountId): The account ID of the sender or receiver. - amount (int): The amount of the HBAR to transfer. + amount (Union[int, Hbar]): The amount of the HBAR to transfer (in tinybars if int, or as Hbar object). Returns: TransferTransaction: The current instance of the transaction for chaining. diff --git a/tests/integration/transfer_transaction_e2e_test.py b/tests/integration/transfer_transaction_e2e_test.py index 1e037031b..2aa284251 100644 --- a/tests/integration/transfer_transaction_e2e_test.py +++ b/tests/integration/transfer_transaction_e2e_test.py @@ -7,6 +7,7 @@ from hiero_sdk_python.crypto.private_key import PrivateKey from hiero_sdk_python.exceptions import PrecheckError from hiero_sdk_python.hbar import Hbar +from hiero_sdk_python.hbar_unit import HbarUnit from hiero_sdk_python.query.account_balance_query import CryptoGetAccountBalanceQuery from hiero_sdk_python.response_code import ResponseCode from hiero_sdk_python.tokens.nft_id import NftId @@ -448,3 +449,47 @@ def test_integration_transfer_transaction_approved_token_transfer(): finally: env.close() + + +@pytest.mark.integration +def test_integration_transfer_transaction_with_hbar_units(): + env = IntegrationTestEnv() + + try: + new_account_private_key = PrivateKey.generate() + new_account_public_key = new_account_private_key.public_key() + + initial_balance = Hbar(1) + + account_transaction = AccountCreateTransaction( + key=new_account_public_key, initial_balance=initial_balance, memo="Recipient Account" + ) + + receipt = account_transaction.execute(env.client) + + assert ( + receipt.status == ResponseCode.SUCCESS + ), f"Account creation failed with status: {ResponseCode(receipt.status).name}" + + account_id = receipt.account_id + assert account_id is not None + + transfer_transaction = TransferTransaction() + transfer_transaction.add_hbar_transfer(env.operator_id, Hbar(-1.5, HbarUnit.HBAR)) + transfer_transaction.add_hbar_transfer(account_id, Hbar(1.5, HbarUnit.HBAR)) + + receipt = transfer_transaction.execute(env.client) + + assert ( + receipt.status == ResponseCode.SUCCESS + ), f"Transfer failed with status: {ResponseCode(receipt.status).name}" + + query_transaction = CryptoGetAccountBalanceQuery(account_id) + balance = query_transaction.execute(env.client) + + expected_balance_tinybars = Hbar(1).to_tinybars() + Hbar(1.5, HbarUnit.HBAR).to_tinybars() + assert ( + balance and balance.hbars.to_tinybars() == expected_balance_tinybars + ), f"Expected balance: {expected_balance_tinybars}, actual balance: {balance.hbars.to_tinybars()}" + finally: + env.close() diff --git a/tests/unit/test_transfer_transaction.py b/tests/unit/test_transfer_transaction.py index 222303a75..aa4db98e9 100644 --- a/tests/unit/test_transfer_transaction.py +++ b/tests/unit/test_transfer_transaction.py @@ -7,6 +7,8 @@ from hiero_sdk_python.hapi.services.schedulable_transaction_body_pb2 import ( SchedulableTransactionBody, ) +from hiero_sdk_python.hbar import Hbar +from hiero_sdk_python.hbar_unit import HbarUnit from hiero_sdk_python.tokens.nft_id import NftId from hiero_sdk_python.transaction.transfer_transaction import TransferTransaction @@ -284,11 +286,11 @@ def test_zero_amount_validation(mock_account_ids): transfer_tx = TransferTransaction() # Test zero HBAR amount should raise ValueError - with pytest.raises(ValueError, match="Amount must be a non-zero integer"): + with pytest.raises(ValueError, match="Amount must be a non-zero value"): transfer_tx.add_hbar_transfer(account_id_1, 0) # Test zero token amount should raise ValueError - with pytest.raises(ValueError, match="Amount must be a non-zero integer"): + with pytest.raises(ValueError, match="Amount must be a non-zero value"): transfer_tx.add_token_transfer(token_id_1, account_id_1, 0) @@ -510,3 +512,150 @@ def test_approved_token_transfer_validation(mock_account_ids): # Test zero amount with pytest.raises(ValueError, match="Amount must be a non-zero integer"): transfer_tx.add_approved_token_transfer_with_decimals(token_id_1, account_id_1, 0, 6) + + +def test_add_hbar_transfer_with_hbar_object(mock_account_ids): + """Test adding HBAR transfers using Hbar objects.""" + account_id_sender, account_id_recipient, _, _, _ = mock_account_ids + transfer_tx = TransferTransaction() + + hbar_amount_sender = Hbar(-5, HbarUnit.HBAR) + hbar_amount_recipient = Hbar(5, HbarUnit.HBAR) + + transfer_tx.add_hbar_transfer(account_id_sender, hbar_amount_sender) + transfer_tx.add_hbar_transfer(account_id_recipient, hbar_amount_recipient) + + sender_transfer = next( + t for t in transfer_tx.hbar_transfers if t.account_id == account_id_sender + ) + recipient_transfer = next( + t for t in transfer_tx.hbar_transfers if t.account_id == account_id_recipient + ) + + assert sender_transfer.amount == hbar_amount_sender.to_tinybars() + assert recipient_transfer.amount == hbar_amount_recipient.to_tinybars() + + +def test_add_hbar_transfer_with_hbar_tinybars(mock_account_ids): + """Test adding HBAR transfers using Hbar objects with tinybar unit.""" + account_id_sender, account_id_recipient, _, _, _ = mock_account_ids + transfer_tx = TransferTransaction() + + tinybar_amount_sender = Hbar(-5_000_000_000, HbarUnit.TINYBAR) + tinybar_amount_recipient = Hbar(5_000_000_000, HbarUnit.TINYBAR) + + transfer_tx.add_hbar_transfer(account_id_sender, tinybar_amount_sender) + transfer_tx.add_hbar_transfer(account_id_recipient, tinybar_amount_recipient) + + sender_transfer = next( + t for t in transfer_tx.hbar_transfers if t.account_id == account_id_sender + ) + recipient_transfer = next( + t for t in transfer_tx.hbar_transfers if t.account_id == account_id_recipient + ) + + assert sender_transfer.amount == -5_000_000_000 + assert recipient_transfer.amount == 5_000_000_000 + + +def test_add_approved_hbar_transfer_with_hbar_object(mock_account_ids): + """Test adding approved HBAR transfers using Hbar objects.""" + account_id_1, account_id_2, _, _, _ = mock_account_ids + transfer_tx = TransferTransaction() + + hbar_amount_1 = Hbar(10, HbarUnit.HBAR) + hbar_amount_2 = Hbar(-10, HbarUnit.HBAR) + + transfer_tx.add_approved_hbar_transfer(account_id_1, hbar_amount_1) + transfer_tx.add_approved_hbar_transfer(account_id_2, hbar_amount_2) + + transfer_1 = next( + t for t in transfer_tx.hbar_transfers if t.account_id == account_id_1 + ) + transfer_2 = next( + t for t in transfer_tx.hbar_transfers if t.account_id == account_id_2 + ) + + assert transfer_1.amount == hbar_amount_1.to_tinybars() + assert transfer_1.is_approved is True + assert transfer_2.amount == hbar_amount_2.to_tinybars() + assert transfer_2.is_approved is True + + +def test_hbar_accumulation_with_hbar_objects(mock_account_ids): + """Test that HBAR transfers accumulate correctly with Hbar objects.""" + account_id_1, _, _, _, _ = mock_account_ids + transfer_tx = TransferTransaction() + + hbar_amount_1 = Hbar(5, HbarUnit.HBAR) + hbar_amount_2 = Hbar(3, HbarUnit.HBAR) + + transfer_tx.add_hbar_transfer(account_id_1, hbar_amount_1) + transfer_tx.add_hbar_transfer(account_id_1, hbar_amount_2) + + transfer = next(t for t in transfer_tx.hbar_transfers if t.account_id == account_id_1) + + expected_amount = (hbar_amount_1.to_tinybars() + hbar_amount_2.to_tinybars()) + assert transfer.amount == expected_amount + assert len(transfer_tx.hbar_transfers) == 1 + + +def test_hbar_and_int_accumulation_mixed(mock_account_ids): + """Test that HBAR objects and integer amounts accumulate together.""" + account_id_1, _, _, _, _ = mock_account_ids + transfer_tx = TransferTransaction() + + hbar_amount = Hbar(5, HbarUnit.HBAR) + int_amount = 3_000_000_000 + + transfer_tx.add_hbar_transfer(account_id_1, hbar_amount) + transfer_tx.add_hbar_transfer(account_id_1, int_amount) + + transfer = next(t for t in transfer_tx.hbar_transfers if t.account_id == account_id_1) + + expected_amount = hbar_amount.to_tinybars() + int_amount + assert transfer.amount == expected_amount + assert len(transfer_tx.hbar_transfers) == 1 + + +def test_zero_hbar_transfer_validation(mock_account_ids): + """Test that zero Hbar objects are rejected.""" + account_id_1, _, _, _, _ = mock_account_ids + transfer_tx = TransferTransaction() + + zero_hbar = Hbar(0, HbarUnit.HBAR) + + with pytest.raises(ValueError, match="Amount must be a non-zero value"): + transfer_tx.add_hbar_transfer(account_id_1, zero_hbar) + + +def test_invalid_hbar_type_validation(mock_account_ids): + """Test that invalid types are rejected.""" + account_id_1, _, _, _, _ = mock_account_ids + transfer_tx = TransferTransaction() + + with pytest.raises(TypeError, match="Amount must be of type int or Hbar"): + transfer_tx.add_hbar_transfer(account_id_1, "invalid") + + with pytest.raises(TypeError, match="Amount must be of type int or Hbar"): + transfer_tx.add_hbar_transfer(account_id_1, 5.5) + + +def test_hbar_transfer_conversion_accuracy(mock_account_ids): + """Test that Hbar objects are accurately converted to tinybars.""" + account_id_1, account_id_2, _, _, _ = mock_account_ids + transfer_tx = TransferTransaction() + + hbar_value = Hbar(1.5, HbarUnit.HBAR) + expected_tinybars = 150_000_000 + + transfer_tx.add_hbar_transfer(account_id_1, hbar_value) + + transfer = next(t for t in transfer_tx.hbar_transfers if t.account_id == account_id_1) + assert transfer.amount == expected_tinybars + + micro_hbar_value = Hbar(1_500_000, HbarUnit.MICROBAR) + transfer_tx.add_hbar_transfer(account_id_2, micro_hbar_value) + + transfer_2 = next(t for t in transfer_tx.hbar_transfers if t.account_id == account_id_2) + assert transfer_2.amount == expected_tinybars