From d2c45005a44e7a419fe952f37bdb5961db92edec Mon Sep 17 00:00:00 2001 From: MontyPokemon <59332150+MonaaEid@users.noreply.github.com> Date: Wed, 26 Nov 2025 00:21:20 +0200 Subject: [PATCH] feat: Refactor TokenDissociateTransaction to use set_token_ids method (#830) Signed-off-by: MonaaEid Signed-off-by: MontyPokemon <59332150+MonaaEid@users.noreply.github.com> Signed-off-by: kubanjaelijaheldred --- CHANGELOG.md | 4 + .../training/max_attempts_error.md | 123 ++++++++ examples/errors/max_attempts_error.py | 285 ++++++++++++++++++ .../tokens/token_dissociate_transaction.py | 3 +- .../tokens/token_dissociate_transaction.py | 39 ++- .../transaction/transaction.py | 22 +- .../unit/test_token_dissociate_transaction.py | 54 +++- 7 files changed, 518 insertions(+), 12 deletions(-) create mode 100644 docs/sdk_developers/training/max_attempts_error.md create mode 100644 examples/errors/max_attempts_error.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b96c9c41b..3eb54aaa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,9 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1. - Added `examples/token_create_transaction_wipe_key.py` to demonstrate token wiping and the role of the wipe key. - Added `examples/account_allowance_approve_transaction_hbar.py` and `examples/account_allowance_delete_transaction_hbar.py`, deleted `examples/account_allowance_hbar.py`. [#775] - Added `docs\sdk_developers\training\receipts.md` as a training guide for users to understand hedera receipts. +- Add comprehensive documentation for `MaxAttemptsError` in `docs/sdk_developers/training/max_attempts_error.md` (2025-11-26) +- Add practical example `examples/errors/max_attempts_error.py` demonstrating network error handling and recovery strategies (2025-11-26) +- Document error handling patterns for network failures and node retry attempts (#877) ### Changed @@ -56,6 +59,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1. - Allow `PrivateKey` to be used for keys in `TopicCreateTransaction` for consistency. - Update github actions checkout from 5.0.0 to 5.0.1 (#814) - changed to add concurrency to workflow bot +- feat: Refactor `TokenDissociateTransaction` to use set_token_ids method and update transaction fee to Hbar, also update `transaction.py` and expand `examples/token_dissociate.py`, `tests/unit/token_dissociate.py`. ### Fixed diff --git a/docs/sdk_developers/training/max_attempts_error.md b/docs/sdk_developers/training/max_attempts_error.md new file mode 100644 index 000000000..15bb9d623 --- /dev/null +++ b/docs/sdk_developers/training/max_attempts_error.md @@ -0,0 +1,123 @@ +# MaxAttemptsError + +## Overview + +`MaxAttemptsError` is an exception that occurs when the SDK has exhausted all retry attempts to communicate with a Hedera network node. This error represents transient network issues or node unavailability rather than transaction logic errors. + +## When It Occurs + +`MaxAttemptsError` is raised when: +- A node repeatedly fails to respond to requests +- Network connectivity issues prevent communication with a node +- The node is temporarily unavailable or overloaded +- Multiple retry attempts have all failed + +## Error Attributes + +The `MaxAttemptsError` exception provides the following attributes: + +- **`message`** (str): The error message explaining why the maximum attempts were reached +- **`node_id`** (str): The ID of the node that was being contacted when the max attempts were reached +- **`last_error`** (Exception): The last error that occurred during the final attempt + +## Error Handling Context + +Understanding the different stages of failure is crucial for proper error handling: + +1. **Precheck Errors** (`PrecheckError`): Failures before transaction submission (e.g., insufficient balance, invalid signature) +2. **MaxAttemptsError**: Network/node retry failures during communication +3. **Receipt Status Errors** (`ReceiptStatusError`): Failures after consensus (e.g., smart contract revert) + +Many developers assume that if `execute()` doesn't throw, the transaction succeeded. These exceptions explicitly show different stages of failure. + +## Example Usage + +```python +from hiero_sdk_python import Client, TransferTransaction +from hiero_sdk_python.exceptions import MaxAttemptsError, PrecheckError, ReceiptStatusError + +# Create client and transaction +client = Client.forTestnet() +transaction = TransferTransaction() + .addHbarTransfer(sender_account, -1000) + .addHbarTransfer(receiver_account, 1000) + +try: + # Execute the transaction + receipt = transaction.execute(client) + print("Transaction executed successfully") + +except PrecheckError as e: + # Handle precheck failures (before submission) + print(f"Precheck failed: {e.message}") + print(f"Status: {e.status}") + +except MaxAttemptsError as e: + # Handle network/node retry failures + print(f"Max attempts reached on node {e.node_id}: {e.message}") + if e.last_error: + print(f"Last error: {e.last_error}") + + # Common recovery strategies: + # 1. Retry with a different node + # 2. Wait and retry the same node + # 3. Check network connectivity + +except ReceiptStatusError as e: + # Handle post-consensus failures + print(f"Transaction failed after consensus: {e.message}") + print(f"Status: {e.status}") +``` + +## Recovery Strategies + +When encountering a `MaxAttemptsError`, consider these approaches: + +### 1. Retry with Different Node +```python +try: + receipt = transaction.execute(client) +except MaxAttemptsError as e: + # Switch to a different node and retry + client.setNetwork([{"node2.hedera.com:50211": "0.0.2"}]) + receipt = transaction.execute(client) +``` + +### 2. Exponential Backoff +```python +import time + +def execute_with_retry(transaction, client, max_retries=3): + for attempt in range(max_retries): + try: + return transaction.execute(client) + except MaxAttemptsError as e: + if attempt == max_retries - 1: + raise + wait_time = 2 ** attempt # 1, 2, 4 seconds + time.sleep(wait_time) +``` + +### 3. Node Health Check +```python +try: + receipt = transaction.execute(client) +except MaxAttemptsError as e: + print(f"Node {e.node_id} appears unhealthy") + # Implement node health monitoring + # Consider removing the node from rotation temporarily +``` + +## Best Practices + +1. **Always catch MaxAttemptsError separately** from other exceptions to implement appropriate retry logic +2. **Log the node_id** to identify problematic nodes in your network +3. **Implement circuit breakers** to temporarily skip consistently failing nodes +4. **Use exponential backoff** when retrying to avoid overwhelming the network +5. **Monitor last_error** to understand the root cause of failures + +## Related Documentation + +- [Receipt Status Error](receipt_status_error.md) - Understanding post-consensus failures +- [Receipts](receipts.md) - Working with transaction receipts +- [Error Handling Guide](../common_issues.md) - General error handling strategies diff --git a/examples/errors/max_attempts_error.py b/examples/errors/max_attempts_error.py new file mode 100644 index 000000000..9faab6f33 --- /dev/null +++ b/examples/errors/max_attempts_error.py @@ -0,0 +1,285 @@ +""" +Example demonstrating MaxAttemptsError handling in Hedera SDK + +This example shows how to handle MaxAttemptsError exceptions that occur +when the SDK exhausts retry attempts to communicate with network nodes. +""" + +import time +from hiero_sdk_python import Client, TransferTransaction, AccountId, PrivateKey +from hiero_sdk_python.exceptions import MaxAttemptsError, PrecheckError, ReceiptStatusError + + +def basic_max_attempts_example(): + """ + Basic example of catching MaxAttemptsError + """ + print("=== Basic MaxAttemptsError Example ===") + + # Initialize client + client = Client.forTestnet() + + # Create a simple transfer transaction + sender = AccountId.fromString("0.0.12345") + receiver = AccountId.fromString("0.0.67890") + private_key = PrivateKey.fromString("302e020100300506032b657004220420db484b8284a5c6826c0e07c2d8296fda0b841d4e1dc4b7f308a2db69b043a8c5") + + transaction = TransferTransaction() + .addHbarTransfer(sender, -1000) + .addHbarTransfer(receiver, 1000) + .freezeWith(client) + .sign(private_key) + + try: + receipt = transaction.execute(client) + print("āœ… Transaction executed successfully") + + except PrecheckError as e: + print(f"āŒ Precheck failed: {e.message}") + print(f" Status: {e.status}") + + except MaxAttemptsError as e: + print(f"āŒ Max attempts reached on node {e.node_id}") + print(f" Error: {e.message}") + if e.last_error: + print(f" Last error: {type(e.last_error).__name__}: {e.last_error}") + + # Example recovery action + print(" šŸ’” Recovery: Consider retrying with a different node") + + except ReceiptStatusError as e: + print(f"āŒ Transaction failed after consensus: {e.message}") + print(f" Status: {e.status}") + + except Exception as e: + print(f"āŒ Unexpected error: {type(e).__name__}: {e}") + + +def retry_with_different_node(): + """ + Example demonstrating retry with a different node when MaxAttemptsError occurs + """ + print("\n=== Retry with Different Node Example ===") + + # Initialize client with multiple nodes + client = Client.forTestnet() + + # Define alternative nodes + alternative_nodes = [ + {"0.0.3": "35.237.200.180:50211"}, + {"0.0.4": "35.186.191.247:50211"}, + {"0.0.5": "35.192.2.44:50211"} + ] + + sender = AccountId.fromString("0.0.12345") + receiver = AccountId.fromString("0.0.67890") + private_key = PrivateKey.fromString("302e020100300506032b657004220420db484b8284a5c6826c0e07c2d8296fda0b841d4e1dc4b7f308a2db69b043a8c5") + + transaction = TransferTransaction() + .addHbarTransfer(sender, -1000) + .addHbarTransfer(receiver, 1000) + .freezeWith(client) + .sign(private_key) + + # Try with original node first + try: + receipt = transaction.execute(client) + print("āœ… Transaction executed successfully with original node") + return receipt + + except MaxAttemptsError as e: + print(f"āš ļø Original node {e.node_id} failed after max attempts") + print(f" Error: {e.message}") + + # Try alternative nodes + for i, node_map in enumerate(alternative_nodes): + try: + print(f" šŸ”„ Trying alternative node {i+1}...") + client.setNetwork([node_map]) + receipt = transaction.execute(client) + print(f"āœ… Transaction succeeded with alternative node {i+1}") + return receipt + + except MaxAttemptsError as retry_error: + print(f" āŒ Alternative node {i+1} also failed: {retry_error.message}") + continue + + print("āŒ All nodes failed. Transaction could not be completed.") + return None + + +def exponential_backoff_retry(): + """ + Example demonstrating exponential backoff retry strategy + """ + print("\n=== Exponential Backoff Retry Example ===") + + client = Client.forTestnet() + + sender = AccountId.fromString("0.0.12345") + receiver = AccountId.fromString("0.0.67890") + private_key = PrivateKey.fromString("302e020100300506032b657004220420db484b8284a5c6826c0e07c2d8296fda0b841d4e1dc4b7f308a2db69b043a8c5") + + transaction = TransferTransaction() + .addHbarTransfer(sender, -1000) + .addHbarTransfer(receiver, 1000) + .freezeWith(client) + .sign(private_key) + + max_retries = 3 + + for attempt in range(max_retries): + try: + print(f" šŸ“¤ Attempt {attempt + 1} of {max_retries}...") + receipt = transaction.execute(client) + print(f"āœ… Transaction succeeded on attempt {attempt + 1}") + return receipt + + except MaxAttemptsError as e: + if attempt == max_retries - 1: + print(f"āŒ All {max_retries} attempts failed") + print(f" Final error: {e.message}") + return None + + # Calculate wait time: 1s, 2s, 4s for attempts 0, 1, 2 + wait_time = 2 ** attempt + print(f" ā³ Waiting {wait_time} seconds before retry...") + print(f" Last error: {e.last_error if e.last_error else 'No specific error'}") + time.sleep(wait_time) + + except Exception as e: + print(f"āŒ Unexpected error on attempt {attempt + 1}: {type(e).__name__}: {e}") + break + + return None + + +def node_health_monitoring(): + """ + Example showing how to monitor node health based on MaxAttemptsError + """ + print("\n=== Node Health Monitoring Example ===") + + # Simple node health tracking + node_failures = {} + + client = Client.forTestnet() + + sender = AccountId.fromString("0.0.12345") + receiver = AccountId.fromString("0.0.67890") + private_key = PrivateKey.fromString("302e020100300506032b657004220420db484b8284a5c6826c0e07c2d8296fda0b841d4e1dc4b7f308a2db69b043a8c5") + + transaction = TransferTransaction() + .addHbarTransfer(sender, -1000) + .addHbarTransfer(receiver, 1000) + .freezeWith(client) + .sign(private_key) + + try: + receipt = transaction.execute(client) + print("āœ… Transaction executed successfully") + + except MaxAttemptsError as e: + node_id = e.node_id + error_msg = e.message + + # Track node failures + if node_id not in node_failures: + node_failures[node_id] = [] + node_failures[node_id].append({ + 'timestamp': time.time(), + 'error': error_msg, + 'last_error': str(e.last_error) if e.last_error else None + }) + + print(f"āŒ Node {node_id} failure recorded") + print(f" Error: {error_msg}") + print(f" Total failures for this node: {len(node_failures[node_id])}") + + # Simple health check logic + if len(node_failures[node_id]) >= 3: + print(f" 🚨 Node {node_id} marked as unhealthy (3+ failures)") + print(" šŸ’” Consider removing this node from rotation temporarily") + + except Exception as e: + print(f"āŒ Unexpected error: {type(e).__name__}: {e}") + + # Print node health summary + if node_failures: + print("\nšŸ“Š Node Health Summary:") + for node_id, failures in node_failures.items(): + print(f" Node {node_id}: {len(failures)} failures") + + +def comprehensive_error_handling(): + """ + Comprehensive example showing all error types and their handling + """ + print("\n=== Comprehensive Error Handling Example ===") + + client = Client.forTestnet() + + sender = AccountId.fromString("0.0.12345") + receiver = AccountId.fromString("0.0.67890") + private_key = PrivateKey.fromString("302e020100300506032b657004220420db484b8284a5c6826c0e07c2d8296fda0b841d4e1dc4b7f308a2db69b043a8c5") + + transaction = TransferTransaction() + .addHbarTransfer(sender, -1000) + .addHbarTransfer(receiver, 1000) + .freezeWith(client) + .sign(private_key) + + try: + receipt = transaction.execute(client) + print("āœ… Transaction executed successfully") + print(f" Transaction ID: {receipt.transactionId}") + + except PrecheckError as e: + print("šŸ” PRECHECK ERROR (Before submission)") + print(f" Status: {e.status}") + print(f" Message: {e.message}") + if e.transaction_id: + print(f" Transaction ID: {e.transaction_id}") + print(" šŸ’” Fix: Check account balance, fees, signatures, etc.") + + except MaxAttemptsError as e: + print("🌐 MAX ATTEMPTS ERROR (Network/Node failure)") + print(f" Node ID: {e.node_id}") + print(f" Message: {e.message}") + if e.last_error: + print(f" Last error: {type(e.last_error).__name__}: {e.last_error}") + print(" šŸ’” Fix: Retry with different node, check network, wait and retry") + + except ReceiptStatusError as e: + print("šŸ“‹ RECEIPT STATUS ERROR (After consensus)") + print(f" Status: {e.status}") + print(f" Transaction ID: {e.transaction_id}") + print(f" Message: {e.message}") + print(" šŸ’” Fix: Check smart contract logic, account state, etc.") + + except Exception as e: + print("āŒ UNEXPECTED ERROR") + print(f" Type: {type(e).__name__}") + print(f" Message: {e}") + print(" šŸ’” Fix: Check SDK version, network configuration, etc.") + + +if __name__ == "__main__": + print("MaxAttemptsError Handling Examples") + print("=" * 50) + + # Run all examples + basic_max_attempts_example() + retry_with_different_node() + exponential_backoff_retry() + node_health_monitoring() + comprehensive_error_handling() + + print("\n" + "=" * 50) + print("Examples completed!") + print("\nKey Takeaways:") + print("• MaxAttemptsError indicates network/node issues, not transaction logic errors") + print("• Always catch MaxAttemptsError separately for proper retry logic") + print("• Log node_id to identify problematic nodes") + print("• Implement exponential backoff when retrying") + print("• Consider circuit breakers for consistently failing nodes") diff --git a/examples/tokens/token_dissociate_transaction.py b/examples/tokens/token_dissociate_transaction.py index 0bb11fd85..6def716ce 100644 --- a/examples/tokens/token_dissociate_transaction.py +++ b/examples/tokens/token_dissociate_transaction.py @@ -155,8 +155,7 @@ def token_dissociate(client, nft_token_id, fungible_token_id, recipient_id, reci receipt = ( TokenDissociateTransaction() .set_account_id(recipient_id) - .add_token_id(nft_token_id) - .add_token_id(fungible_token_id) + .set_token_ids([nft_token_id, fungible_token_id]) .freeze_with(client) .sign(recipient_key) # Recipient must sign to approve .execute(client) diff --git a/src/hiero_sdk_python/tokens/token_dissociate_transaction.py b/src/hiero_sdk_python/tokens/token_dissociate_transaction.py index 83067d46a..97bded7d9 100644 --- a/src/hiero_sdk_python/tokens/token_dissociate_transaction.py +++ b/src/hiero_sdk_python/tokens/token_dissociate_transaction.py @@ -22,6 +22,7 @@ from hiero_sdk_python.channels import _Channel from hiero_sdk_python.executable import _Method from hiero_sdk_python.tokens.token_id import TokenId +from hiero_sdk_python.hbar import Hbar class TokenDissociateTransaction(Transaction): """ @@ -49,7 +50,7 @@ def __init__( self.account_id: Optional[AccountId] = account_id self.token_ids: List[TokenId] = token_ids or [] - self._default_transaction_fee: int = 500_000_000 + self._default_transaction_fee = Hbar(2) def set_account_id(self, account_id: AccountId) -> "TokenDissociateTransaction": """ Sets the account ID for the token dissociation transaction. """ @@ -63,6 +64,42 @@ def add_token_id(self, token_id: TokenId) -> "TokenDissociateTransaction": self.token_ids.append(token_id) return self + def set_token_ids(self, token_ids: List[TokenId]) -> "TokenDissociateTransaction": + """Sets the list of token IDs to dissociate from the account. + """ + self._require_not_frozen() + self.token_ids = token_ids + return self + + def _validate_check_sum(self, client) -> None: + """Validates the checksums of the account ID and token IDs against the provided client.""" + if self.account_id is not None: + self.account_id.validate_checksum(client) + for token_id in (self.token_ids or []): + if token_id is not None: + token_id.validate_checksum(client) + + + @classmethod + def _from_proto(cls, proto: token_dissociate_pb2.TokenDissociateTransactionBody) -> "TokenDissociateTransaction": + """ + Creates a TokenDissociateTransaction instance from a protobuf + TokenDissociateTransactionBody object. + + Args: + proto (TokenDissociateTransactionBody): The protobuf + representation of the token dissociate transaction. + """ + account_id = AccountId._from_proto(proto.account) + token_ids = [TokenId._from_proto(token_proto) for token_proto in proto.tokens] + + transaction = cls( + account_id=account_id, + token_ids=token_ids + ) + + return transaction + def _build_proto_body(self) -> token_dissociate_pb2.TokenDissociateTransactionBody: """ Returns the protobuf body for the token dissociate transaction. diff --git a/src/hiero_sdk_python/transaction/transaction.py b/src/hiero_sdk_python/transaction/transaction.py index 2dd7315cb..bf2bedf49 100644 --- a/src/hiero_sdk_python/transaction/transaction.py +++ b/src/hiero_sdk_python/transaction/transaction.py @@ -12,6 +12,7 @@ from hiero_sdk_python.hapi.services import (basic_types_pb2, transaction_pb2, transaction_contents_pb2, transaction_pb2) from hiero_sdk_python.hapi.services.schedulable_transaction_body_pb2 import SchedulableTransactionBody from hiero_sdk_python.hapi.services.transaction_response_pb2 import (TransactionResponse as TransactionResponseProto) +from hiero_sdk_python.hbar import Hbar from hiero_sdk_python.response_code import ResponseCode from hiero_sdk_python.transaction.transaction_id import TransactionId from hiero_sdk_python.transaction.transaction_response import TransactionResponse @@ -61,8 +62,9 @@ def __init__(self) -> None: # This allows us to maintain the signatures for each unique transaction # and ensures that the correct signatures are used when submitting transactions self._signature_map: dict[bytes, basic_types_pb2.SignatureMap] = {} - self._default_transaction_fee = 2_000_000 - self.operator_account_id = None + # changed from int: 2_000_000 to Hbar: 0.02 + self._default_transaction_fee = Hbar(0.02) + self.operator_account_id = None self.batch_key: Optional[PrivateKey] = None def _make_request(self): @@ -419,7 +421,11 @@ def build_base_transaction_body(self) -> transaction_pb2.TransactionBody: transaction_body.transactionID.CopyFrom(transaction_id_proto) transaction_body.nodeAccountID.CopyFrom(self.node_account_id._to_proto()) - transaction_body.transactionFee = self.transaction_fee or self._default_transaction_fee + fee = self.transaction_fee or self._default_transaction_fee + if hasattr(fee, "to_tinybars"): + transaction_body.transactionFee = int(fee.to_tinybars()) + else: + transaction_body.transactionFee = int(fee) transaction_body.transactionValidDuration.seconds = self.transaction_valid_duration transaction_body.generateRecord = self.generate_record @@ -441,9 +447,13 @@ def build_base_scheduled_body(self) -> SchedulableTransactionBody: The protobuf SchedulableTransactionBody message with common fields set. """ schedulable_body = SchedulableTransactionBody() - schedulable_body.transactionFee = ( - self.transaction_fee or self._default_transaction_fee - ) + + fee = self.transaction_fee or self._default_transaction_fee + if hasattr(fee, "to_tinybars"): + schedulable_body.transactionFee = int(fee.to_tinybars()) + else: + schedulable_body.transactionFee = int(fee) + schedulable_body.memo = self.memo custom_fee_limits = [custom_fee._to_proto() for custom_fee in self.custom_fee_limits] schedulable_body.max_custom_fees.extend(custom_fee_limits) diff --git a/tests/unit/test_token_dissociate_transaction.py b/tests/unit/test_token_dissociate_transaction.py index dd3b8b1e0..892bd11a6 100644 --- a/tests/unit/test_token_dissociate_transaction.py +++ b/tests/unit/test_token_dissociate_transaction.py @@ -1,11 +1,12 @@ +from unittest.mock import call, MagicMock, Mock import pytest -from unittest.mock import MagicMock from hiero_sdk_python.tokens.token_dissociate_transaction import TokenDissociateTransaction from hiero_sdk_python.hapi.services import timestamp_pb2 from hiero_sdk_python.hapi.services.schedulable_transaction_body_pb2 import ( SchedulableTransactionBody, ) from hiero_sdk_python.transaction.transaction_id import TransactionId +from hiero_sdk_python.tokens.token_id import TokenId pytestmark = pytest.mark.unit @@ -54,7 +55,7 @@ def test_transaction_body_with_multiple_tokens(mock_account_ids): dissociate_tx.set_account_id(account_id) for token_id in token_ids: dissociate_tx.add_token_id(token_id) - dissociate_tx.operator_account_id = operator_id + dissociate_tx.operator_account_id = operator_id dissociate_tx.transaction_id = generate_transaction_id(account_id) dissociate_tx.node_account_id = node_account_id @@ -68,6 +69,40 @@ def test_transaction_body_with_multiple_tokens(mock_account_ids): for i, token_id in enumerate(token_ids): assert transaction_body.tokenDissociate.tokens[i].tokenNum == token_id.num +# This test uses fixture mock_account_ids as parameter +def test_set_token_ids(mock_account_ids): + """Test setting multiple token IDs at once for dissociation.""" + account_id, _, _, token_id_1, token_id_2 = mock_account_ids + token_ids = [token_id_1, token_id_2] + + dissociate_tx = TokenDissociateTransaction() + dissociate_tx.set_account_id(account_id) + dissociate_tx.set_token_ids(token_ids) + + assert dissociate_tx.token_ids == token_ids + + +def test_validate_check_sum(mock_account_ids, mock_client, monkeypatch): + """Test that validate_check_sum method correctly validates account and token IDs.""" + account_id, _, _, token_id_1, token_id_2 = mock_account_ids + + dissociate_tx = TokenDissociateTransaction() + dissociate_tx.set_account_id(account_id) + dissociate_tx.set_token_ids([token_id_1, token_id_2]) + + # Mock the validate_checksum methods on the classes to avoid assigning + # attributes on frozen dataclass instances. + monkeypatch.setattr(type(account_id), "validate_checksum", MagicMock()) + token_cls = type(token_id_1) + monkeypatch.setattr(token_cls, "validate_checksum", MagicMock()) + + dissociate_tx._validate_check_sum(mock_client) + + type(account_id).validate_checksum.assert_called_once_with(mock_client) + token_validate = type(token_id_1).validate_checksum + assert token_validate.call_count == 2 + token_validate.assert_has_calls([call(mock_client), call(mock_client)]) + def test_missing_fields(): """Test that building the transaction without account ID or token IDs raises a ValueError.""" dissociate_tx = TokenDissociateTransaction() @@ -121,7 +156,20 @@ def test_to_proto(mock_account_ids, mock_client): assert proto.signedTransactionBytes assert len(proto.signedTransactionBytes) > 0 - + +def test_from_proto(mock_account_ids): + """Test creating a TokenDissociateTransaction from a protobuf object.""" + account_id, _, _, token_id_1, token_id_2 = mock_account_ids + dissociate_tx = TokenDissociateTransaction() + dissociate_tx.set_account_id(account_id) + dissociate_tx.set_token_ids([token_id_1, token_id_2]) + proto_body = dissociate_tx._build_proto_body() + reconstructed_tx = TokenDissociateTransaction._from_proto(proto_body) + assert reconstructed_tx.account_id == account_id + assert len(reconstructed_tx.token_ids) == 2 + assert reconstructed_tx.token_ids[0] == token_id_1 + assert reconstructed_tx.token_ids[1] == token_id_2 + def test_build_scheduled_body(mock_account_ids): """Test building a scheduled transaction body for token dissociate transaction.""" account_id, _, _, token_id_1, token_id_2 = mock_account_ids