diff --git a/src/hiero_sdk_python/account/account_create_transaction.py b/src/hiero_sdk_python/account/account_create_transaction.py index 0aeac467c..32015be57 100644 --- a/src/hiero_sdk_python/account/account_create_transaction.py +++ b/src/hiero_sdk_python/account/account_create_transaction.py @@ -371,3 +371,35 @@ def _get_method(self, channel: _Channel) -> _Method: _Method: An instance of _Method containing the transaction and query functions. """ return _Method(transaction_func=channel.crypto.createAccount, query_func=None) + + @classmethod + def _from_protobuf(cls, transaction_body): + """Create account create transaction from protobuf message.""" + transaction = super()._from_protobuf(transaction_body) + + if transaction_body.HasField("cryptoCreateAccount"): + body = transaction_body.cryptoCreateAccount + + if body.HasField("key"): + transaction.key = Key.from_proto_key(body.key) + + transaction.initial_balance = body.initialBalance + transaction.receiver_signature_required = body.receiverSigRequired + transaction.auto_renew_period = None + + if body.HasField("autoRenewPeriod"): + transaction.auto_renew_period = Duration._from_proto(body.autoRenewPeriod) + + transaction.account_memo = body.memo if body.memo else None + transaction.max_automatic_token_associations = body.max_automatic_token_associations + transaction.alias = EvmAddress.from_bytes(body.alias) if body.alias else None + transaction.decline_staking_reward = body.decline_reward + staked_id = body.WhichOneof("staked_id") + + if staked_id == "staked_account_id": + transaction.staked_account_id = AccountId._from_proto(body.staked_account_id) + + elif staked_id == "staked_node_id": + transaction.staked_node_id = body.staked_node_id + + return transaction diff --git a/src/hiero_sdk_python/consensus/topic_message_submit_transaction.py b/src/hiero_sdk_python/consensus/topic_message_submit_transaction.py index 627c41831..18a6d6f43 100644 --- a/src/hiero_sdk_python/consensus/topic_message_submit_transaction.py +++ b/src/hiero_sdk_python/consensus/topic_message_submit_transaction.py @@ -4,7 +4,6 @@ from hiero_sdk_python.channels import _Channel from hiero_sdk_python.consensus.topic_id import TopicId -from hiero_sdk_python.crypto.private_key import PrivateKey from hiero_sdk_python.executable import _Method from hiero_sdk_python.hapi.services import consensus_submit_message_pb2, transaction_pb2 from hiero_sdk_python.hapi.services.schedulable_transaction_body_pb2 import ( @@ -107,33 +106,6 @@ def set_message(self, message: bytes | str) -> TopicMessageSubmitTransaction: self._total_chunks = self.get_required_chunks() return self - def set_chunk_size(self, chunk_size: int) -> TopicMessageSubmitTransaction: - """ - Set maximum chunk size in bytes. - - Args: - chunk_size (int): The size of each chunk in bytes. - - Returns: - TopicMessageSubmitTransaction: This transaction instance (for chaining). - """ - super().set_chunk_size(chunk_size) - self._total_chunks = self.get_required_chunks() - return self - - def set_max_chunks(self, max_chunks: int) -> TopicMessageSubmitTransaction: - """ - Set maximum allowed chunks. - - Args: - max_chunks (int): The maximum number of chunks allowed. - - Returns: - TopicMessageSubmitTransaction: This transaction instance (for chaining). - """ - super().set_max_chunks(max_chunks) - return self - def set_custom_fee_limits(self, custom_fee_limits: list[CustomFeeLimit]) -> TopicMessageSubmitTransaction: """ Sets the maximum custom fees that the user is willing to pay for the message. @@ -177,25 +149,28 @@ def _build_proto_body(self) -> consensus_submit_message_pb2.ConsensusSubmitMessa content = self._message_as_bytes() - start_index = self._current_chunk_index * self.chunk_size - end_index = min(start_index + self.chunk_size, len(content)) - chunk_content = content[start_index:end_index] + if self._current_chunk_index is not None: + chunk_info = consensus_submit_message_pb2.ConsensusMessageChunkInfo( + initialTransactionID=self._initial_transaction_id._to_proto(), + total=self._total_chunks, + number=self._current_chunk_index + 1, + ) - body = consensus_submit_message_pb2.ConsensusSubmitMessageTransactionBody( - topicID=self.topic_id._to_proto() if self.topic_id else None, message=chunk_content - ) + start_index = (self._current_chunk_index) * self.chunk_size + end_index = min(start_index + self.chunk_size, len(content)) - # Multi-chunk metadata - if self._total_chunks > 1: - body.chunkInfo.CopyFrom( - consensus_submit_message_pb2.ConsensusMessageChunkInfo( - initialTransactionID=self._initial_transaction_id._to_proto(), - total=self._total_chunks, - number=self._current_chunk_index + 1, - ) + chunk_content = content[start_index:end_index] + + return consensus_submit_message_pb2.ConsensusSubmitMessageTransactionBody( + topicID=self.topic_id._to_proto() if self.topic_id else None, + message=chunk_content, + chunkInfo=chunk_info, ) - return body + return consensus_submit_message_pb2.ConsensusSubmitMessageTransactionBody( + topicID=self.topic_id._to_proto() if self.topic_id else None, + message=content, + ) def build_transaction_body(self) -> transaction_pb2.TransactionBody: """ @@ -233,15 +208,16 @@ def _get_method(self, channel: _Channel) -> _Method: """ return _Method(transaction_func=channel.topic.submitMessage, query_func=None) - def sign(self, private_key: PrivateKey) -> TopicMessageSubmitTransaction: - """ - Signs the transaction using the provided private key. + @classmethod + def _from_protobuf(cls, transaction_body): + transaction = super()._from_protobuf(transaction_body) - Args: - private_key (PrivateKey): The private key to sign the transaction with. + if transaction_body.HasField("consensusSubmitMessage"): + body = transaction_body.consensusSubmitMessage - Returns: - TopicMessageSubmitTransaction: This transaction instance (for chaining). - """ - super().sign(private_key) - return self + if body.HasField("topicID"): + transaction.topic_id = TopicId._from_proto(body.topicID) + transaction.message = body.message.decode("utf-8") if body.message else None + transaction._total_chunks = transaction.get_required_chunks() + + return transaction diff --git a/src/hiero_sdk_python/executable.py b/src/hiero_sdk_python/executable.py index 145311d46..5d1a63fb3 100644 --- a/src/hiero_sdk_python/executable.py +++ b/src/hiero_sdk_python/executable.py @@ -86,12 +86,28 @@ def __init__(self): self._grpc_deadline: float | None = None self._request_timeout: float | None = None - self.node_account_id: AccountId | None = None + self._node_account_id: AccountId | None = None self.node_account_ids: list[AccountId] = [] self._used_node_account_id: AccountId | None = None self._node_account_ids_index: int = 0 + @property + def node_account_id(self) -> AccountId | None: + warnings.warn( + "'node_account_id' is deprecated; use 'node_account_ids' instead.", DeprecationWarning, stacklevel=2 + ) + + return self.node_account_ids[0] if len(self.node_account_ids) > 0 else None + + @node_account_id.setter + def node_account_id(self, account_id: AccountId) -> None: + warnings.warn( + "'node_account_id' is deprecated; use 'node_account_ids' instead.", DeprecationWarning, stacklevel=2 + ) + + self.node_account_ids = [account_id] if account_id is not None else [] + def set_node_account_ids(self, node_account_ids: list[AccountId]): """ Explicitly set the node account IDs to execute against. @@ -115,6 +131,12 @@ def set_node_account_id(self, node_account_id: AccountId): Returns: The current instance of the class for chaining. """ + warnings.warn( + "Method 'set_node_account_id()' is deprecated; use 'set_node_account_ids()' instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.set_node_account_ids([node_account_id]) def set_max_attempts(self, max_attempts: int): @@ -346,9 +368,8 @@ def _resolve_execution_config(self, client: Client, timeout: int | float | None) if getattr(self, attr) is None: setattr(self, attr, default) - # nodes to which the executaion must be run against, if not provided used nodes from client if not self.node_account_ids: - self.node_account_ids = [node._account_id for node in client.network._healthy_nodes] + self.node_account_ids = [node._account_id for node in client.network.nodes] if not self.node_account_ids: raise RuntimeError("No healthy nodes available for execution") @@ -429,10 +450,10 @@ def _execute(self, client: Client, timeout: int | float | None = None): node = client.network._get_node(node_id) if node is None: - raise RuntimeError(f"No node found for node_account_id: {self.node_account_id}") + raise RuntimeError(f"No node found for node_account_id: {self._node_account_id}") # Store for logging and receipts - self.node_account_id = node._account_id + self._node_account_id = node._account_id # Create a channel wrapper from the client's channel channel = node._get_channel() @@ -442,7 +463,7 @@ def _execute(self, client: Client, timeout: int | float | None = None): "requestId", self._get_request_id(), "nodeAccountID", - self.node_account_id, + self._node_account_id, "attempt", attempt + 1, "maxAttempts", @@ -483,7 +504,7 @@ def _execute(self, client: Client, timeout: int | float | None = None): logger.trace( f"{self.__class__.__name__} status received", "nodeAccountID", - self.node_account_id, + self._node_account_id, "network", client.network.network, "state", @@ -518,7 +539,7 @@ def _execute(self, client: Client, timeout: int | float | None = None): case _ExecutionState.FINISHED: # If the transaction completed successfully, map the response and return it logger.trace(f"{self.__class__.__name__} finished execution") - return self._map_response(response, self.node_account_id, proto_request) + return self._map_response(response, self._node_account_id, proto_request) logger.error( "Exceeded maximum attempts for request", @@ -529,7 +550,7 @@ def _execute(self, client: Client, timeout: int | float | None = None): ) raise MaxAttemptsError( "Exceeded maximum attempts or request timeout", - self.node_account_id, + self._node_account_id, err_persistant, ) diff --git a/src/hiero_sdk_python/file/file_append_transaction.py b/src/hiero_sdk_python/file/file_append_transaction.py index b944efb59..d5427a3d5 100644 --- a/src/hiero_sdk_python/file/file_append_transaction.py +++ b/src/hiero_sdk_python/file/file_append_transaction.py @@ -79,26 +79,6 @@ def _encode_contents(self, contents: str | bytes | None) -> bytes | None: return contents.encode("utf-8") return contents - def _calculate_total_chunks(self) -> int: - """ - Calculates the total number of chunks needed for the current contents. - - Returns: - int: The total number of chunks needed. - """ - if self.contents is None: - return 1 - return math.ceil(len(self.contents) / self.chunk_size) - - def get_required_chunks(self) -> int: - """ - Gets the number of chunks required for the current contents. - - Returns: - int: The number of chunks required. - """ - return self._calculate_total_chunks() - def set_file_id(self, file_id: FileId) -> FileAppendTransaction: """ Sets the file ID for this file append transaction. @@ -129,31 +109,25 @@ def set_contents(self, contents: str | bytes | None) -> FileAppendTransaction: self._total_chunks = self._calculate_total_chunks() return self - def set_max_chunks(self, max_chunks: int) -> FileAppendTransaction: + def _calculate_total_chunks(self) -> int: """ - Sets the maximum number of chunks allowed for this transaction. - - Args: - max_chunks (int): The maximum number of chunks allowed. + Calculates the total number of chunks needed for the current contents. Returns: - FileAppendTransaction: This transaction instance. + int: The total number of chunks needed. """ - super().set_max_chunks(max_chunks) - return self + if self.contents is None: + return 1 + return math.ceil(len(self.contents) / self.chunk_size) - def set_chunk_size(self, chunk_size: int) -> FileAppendTransaction: + def get_required_chunks(self) -> int: """ - Sets the chunk size for this transaction. - - Args: - chunk_size (int): The size of each chunk in bytes. + Gets the number of chunks required for the current contents. Returns: - FileAppendTransaction: This transaction instance. + int: The number of chunks required. """ - super().set_chunk_size(chunk_size) - return self + return self._calculate_total_chunks() def _build_proto_body(self) -> file_append_pb2.FileAppendTransactionBody: """ @@ -169,15 +143,18 @@ def _build_proto_body(self) -> file_append_pb2.FileAppendTransactionBody: if self.file_id is None: raise ValueError("Missing required FileID") - if self.contents is None: - chunk_contents = b"" - else: + contents = self.contents if self.contents is not None else b"" + + if self._current_chunk_index is not None: start_index = self._current_chunk_index * self.chunk_size - end_index = min(start_index + self.chunk_size, len(self.contents)) - chunk_contents = self.contents[start_index:end_index] + end_index = min(start_index + self.chunk_size, len(contents)) + chunk_contents = contents[start_index:end_index] + return file_append_pb2.FileAppendTransactionBody( + fileID=self.file_id._to_proto() if self.file_id else None, contents=chunk_contents + ) return file_append_pb2.FileAppendTransactionBody( - fileID=self.file_id._to_proto() if self.file_id else None, contents=chunk_contents + fileID=self.file_id._to_proto() if self.file_id else None, contents=contents ) def build_transaction_body(self) -> Any: @@ -235,3 +212,10 @@ def _from_proto(self, proto: file_append_pb2.FileAppendTransactionBody) -> FileA self.contents = proto.contents self._total_chunks = self._calculate_total_chunks() return self + + @classmethod + def _from_protobuf(cls, transaction_body): + transaction = super()._from_protobuf(transaction_body) + if transaction_body.HasField("fileAppend"): + transaction._from_proto(transaction_body.fileAppend) + return transaction diff --git a/src/hiero_sdk_python/nodes/node_create_transaction.py b/src/hiero_sdk_python/nodes/node_create_transaction.py index 472d08c25..9fbeb97dd 100644 --- a/src/hiero_sdk_python/nodes/node_create_transaction.py +++ b/src/hiero_sdk_python/nodes/node_create_transaction.py @@ -301,8 +301,8 @@ def _get_method(self, channel: _Channel) -> _Method: return _Method(transaction_func=channel.address_book.createNode, query_func=None) @classmethod - def _from_protobuf(cls, transaction_body, body_bytes: bytes, sig_map): - transaction = super()._from_protobuf(transaction_body, body_bytes, sig_map) + def _from_protobuf(cls, transaction_body): + transaction = super()._from_protobuf(transaction_body) if transaction_body.HasField("nodeCreate"): pb = transaction_body.nodeCreate diff --git a/src/hiero_sdk_python/nodes/node_update_transaction.py b/src/hiero_sdk_python/nodes/node_update_transaction.py index 92d2d81ac..eafe0bd84 100644 --- a/src/hiero_sdk_python/nodes/node_update_transaction.py +++ b/src/hiero_sdk_python/nodes/node_update_transaction.py @@ -360,8 +360,8 @@ def _get_method(self, channel: _Channel) -> _Method: return _Method(transaction_func=channel.address_book.updateNode, query_func=None) @classmethod - def _from_protobuf(cls, transaction_body, body_bytes: bytes, sig_map): - transaction = super()._from_protobuf(transaction_body, body_bytes, sig_map) + def _from_protobuf(cls, transaction_body): + transaction = super()._from_protobuf(transaction_body) if transaction_body.HasField("nodeUpdate"): pb = transaction_body.nodeUpdate diff --git a/src/hiero_sdk_python/nodes/registered_node_create_transaction.py b/src/hiero_sdk_python/nodes/registered_node_create_transaction.py index c11e329f2..b4760607d 100644 --- a/src/hiero_sdk_python/nodes/registered_node_create_transaction.py +++ b/src/hiero_sdk_python/nodes/registered_node_create_transaction.py @@ -100,8 +100,8 @@ def _get_method(self, channel: _Channel) -> _Method: return _Method(transaction_func=channel.address_book.createRegisteredNode, query_func=None) @classmethod - def _from_protobuf(cls, transaction_body, body_bytes: bytes, sig_map): - transaction = super()._from_protobuf(transaction_body, body_bytes, sig_map) + def _from_protobuf(cls, transaction_body): + transaction = super()._from_protobuf(transaction_body) if transaction_body.HasField("registeredNodeCreate"): pb = transaction_body.registeredNodeCreate diff --git a/src/hiero_sdk_python/nodes/registered_node_delete_transaction.py b/src/hiero_sdk_python/nodes/registered_node_delete_transaction.py index 3ce37b124..76c303125 100644 --- a/src/hiero_sdk_python/nodes/registered_node_delete_transaction.py +++ b/src/hiero_sdk_python/nodes/registered_node_delete_transaction.py @@ -62,8 +62,8 @@ def _get_method(self, channel: _Channel) -> _Method: return _Method(transaction_func=channel.address_book.deleteRegisteredNode, query_func=None) @classmethod - def _from_protobuf(cls, transaction_body, body_bytes: bytes, sig_map): - transaction = super()._from_protobuf(transaction_body, body_bytes, sig_map) + def _from_protobuf(cls, transaction_body): + transaction = super()._from_protobuf(transaction_body) # Extract registered node fields if the body contains a registeredNodeDelete message if transaction_body.HasField("registeredNodeDelete"): diff --git a/src/hiero_sdk_python/nodes/registered_node_update_transaction.py b/src/hiero_sdk_python/nodes/registered_node_update_transaction.py index 37520a219..b196b7705 100644 --- a/src/hiero_sdk_python/nodes/registered_node_update_transaction.py +++ b/src/hiero_sdk_python/nodes/registered_node_update_transaction.py @@ -122,8 +122,8 @@ def _get_method(self, channel: _Channel) -> _Method: return _Method(transaction_func=channel.address_book.updateRegisteredNode, query_func=None) @classmethod - def _from_protobuf(cls, transaction_body, body_bytes: bytes, sig_map): - transaction = super()._from_protobuf(transaction_body, body_bytes, sig_map) + def _from_protobuf(cls, transaction_body): + transaction = super()._from_protobuf(transaction_body) if transaction_body.HasField("registeredNodeUpdate"): pb = transaction_body.registeredNodeUpdate diff --git a/src/hiero_sdk_python/query/fee_estimate_query.py b/src/hiero_sdk_python/query/fee_estimate_query.py index 36348236d..7693f859c 100644 --- a/src/hiero_sdk_python/query/fee_estimate_query.py +++ b/src/hiero_sdk_python/query/fee_estimate_query.py @@ -137,7 +137,7 @@ def execute(self, client) -> FeeEstimateResponse: self._ensure_frozen(self._transaction, client) if self._is_chunked(): - return self._execute_chunked(client, url, mode) + return self._execute_chunked(url, mode) return self._execute_single(url, mode) @@ -191,20 +191,14 @@ def _post(self, url: str, payload: bytes) -> dict: raise RuntimeError("Unreachable") def _execute_single(self, url: str, mode: FeeEstimateMode) -> FeeEstimateResponse: - data = self._post(url, self._transaction.to_bytes()) + data = self._post(url, self._transaction._to_proto().SerializeToString()) return self._to_response(data, mode) - def _execute_chunked(self, client, url: str, mode: FeeEstimateMode) -> FeeEstimateResponse: + def _execute_chunked(self, url: str, mode: FeeEstimateMode) -> FeeEstimateResponse: """ Aggregate fees across all chunks into a single response. """ - # Save original state to restore later - original_id = self._transaction.transaction_id - original_index = getattr(self._transaction, "_current_chunk_index", 0) - original_bodies = dict(self._transaction._transaction_body_bytes) - original_signatures = dict(self._transaction._signature_map) - total_node_base = 0 total_service_base = 0 total_network_subtotal = 0 @@ -215,16 +209,11 @@ def _execute_chunked(self, client, url: str, mode: FeeEstimateMode) -> FeeEstima final_multiplier = 0 final_hvm = 0 + self._transaction._current_transaction_id_index = 0 try: - for i, chunk_tx_id in enumerate(self._transaction._transaction_ids): - self._transaction._current_chunk_index = i - self._transaction.transaction_id = chunk_tx_id - - self._transaction._transaction_body_bytes.clear() - - self._transaction.freeze_with(client) - - tx_bytes = self._transaction.to_bytes() + for i in range(len(self._transaction._transaction_ids)): + self._transaction._current_transaction_id_index = i + tx_bytes = self._transaction._to_proto().SerializeToString() data = self._post(url, tx_bytes) response = self._to_response(data, mode) @@ -242,12 +231,7 @@ def _execute_chunked(self, client, url: str, mode: FeeEstimateMode) -> FeeEstima final_hvm = response.high_volume_multiplier finally: - self._transaction.transaction_id = original_id - self._transaction._current_chunk_index = original_index - self._transaction._transaction_body_bytes.clear() - self._transaction._transaction_body_bytes.update(original_bodies) - self._transaction._signature_map.clear() - self._transaction._signature_map.update(original_signatures) + self._transaction._current_transaction_id_index = 0 return FeeEstimateResponse( mode=mode, diff --git a/src/hiero_sdk_python/query/query.py b/src/hiero_sdk_python/query/query.py index 6a9f093bc..6eb912a1d 100644 --- a/src/hiero_sdk_python/query/query.py +++ b/src/hiero_sdk_python/query/query.py @@ -179,11 +179,11 @@ def _make_request_header(self) -> query_header_pb2.QueryHeader: header.responseType = query_header_pb2.ResponseType.COST_ANSWER return header - if self.operator is not None and self.node_account_id is not None and self.payment_amount is not None: + if self.operator is not None and self._node_account_id is not None and self.payment_amount is not None: payment_tx = self._build_query_payment_transaction( payer_account_id=self.operator.account_id, payer_private_key=self.operator.private_key, - node_account_id=self.node_account_id, + node_account_id=self._node_account_id, amount=self.payment_amount, ) header.payment.CopyFrom(payment_tx) diff --git a/src/hiero_sdk_python/transaction/batch_transaction.py b/src/hiero_sdk_python/transaction/batch_transaction.py index ede88c843..2b637ea15 100644 --- a/src/hiero_sdk_python/transaction/batch_transaction.py +++ b/src/hiero_sdk_python/transaction/batch_transaction.py @@ -95,7 +95,7 @@ def _verify_inner_transaction(self, transaction: Transaction) -> None: raise ValueError("Batch key needs to be set") @classmethod - def _from_protobuf(cls, transaction_body, body_bytes: bytes, sig_map) -> BatchTransaction: + def _from_protobuf(cls, transaction_body) -> BatchTransaction: """ Creates a BatchTransaction instance from protobuf components. @@ -107,7 +107,7 @@ def _from_protobuf(cls, transaction_body, body_bytes: bytes, sig_map) -> BatchTr Returns: BatchTransaction: A new transaction instance with all fields restored """ - transaction = super()._from_protobuf(transaction_body, body_bytes, sig_map) + transaction = super()._from_protobuf(transaction_body) if transaction_body.HasField("atomic_batch"): atomic_batch = transaction_body.atomic_batch diff --git a/src/hiero_sdk_python/transaction/chunked_transaction.py b/src/hiero_sdk_python/transaction/chunked_transaction.py index b2c940bb8..ba6390f9c 100644 --- a/src/hiero_sdk_python/transaction/chunked_transaction.py +++ b/src/hiero_sdk_python/transaction/chunked_transaction.py @@ -1,17 +1,18 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import Literal, overload +from typing import Literal, TypeVar, overload from hiero_sdk_python.client.client import Client -from hiero_sdk_python.crypto.private_key import PrivateKey -from hiero_sdk_python.hapi.services import timestamp_pb2 from hiero_sdk_python.transaction.transaction import Transaction from hiero_sdk_python.transaction.transaction_id import TransactionId from hiero_sdk_python.transaction.transaction_receipt import TransactionReceipt from hiero_sdk_python.transaction.transaction_response import TransactionResponse +T = TypeVar("T", bound="ChunkedTransaction") + + class ChunkedTransaction(Transaction, ABC): """ Abstract base class for transactions that support chunking. @@ -29,18 +30,16 @@ def __init__(self) -> None: super().__init__() # Chunking state - self._current_chunk_index: int = 0 + self._current_chunk_index: int | None = None self._total_chunks: int = 1 self._initial_transaction_id: TransactionId | None = None - self._transaction_ids: list[TransactionId] = [] - self._signing_keys: list[PrivateKey] = [] # Chunk configuration (set by subclasses) self.chunk_size: int = 1024 self.max_chunks: int = 20 @abstractmethod - def _build_proto_body(self): + def _build_proto_body(self: T): """ Builds the protobuf body for the current chunk. @@ -56,7 +55,7 @@ def _build_proto_body(self): """ pass - def set_chunk_size(self, chunk_size: int) -> ChunkedTransaction: + def set_chunk_size(self: T, chunk_size: int) -> T: """ Sets the chunk size for this transaction. @@ -77,7 +76,7 @@ def set_chunk_size(self, chunk_size: int) -> ChunkedTransaction: self._total_chunks = self.get_required_chunks() return self - def set_max_chunks(self, max_chunks: int) -> ChunkedTransaction: + def set_max_chunks(self: T, max_chunks: int) -> T: """ Sets the maximum number of chunks allowed. @@ -97,26 +96,20 @@ def set_max_chunks(self, max_chunks: int) -> ChunkedTransaction: self.max_chunks = max_chunks return self - def _validate_chunking(self) -> int: + def _validate_chunking(self: T) -> None: """ - Validates that the required chunks don't exceed max_chunks. + Validates that the transaction doesn't exceed the maximum number of chunks. Raises: - ValueError: If required chunks exceed max_chunks. + ValueError: If the transaction exceeds the maximum number of chunks. """ - required = self.get_required_chunks() - if required < 1: - raise ValueError("Transaction must require at least one chunk") - self._total_chunks = required - - if self.max_chunks and required > self.max_chunks: + if self.max_chunks and self.get_required_chunks() > self.max_chunks: raise ValueError( - f"Message requires {required} chunks but max_chunks={self.max_chunks}. " - f"Increase limit with set_max_chunks()." + f"Cannot execute ChunkedTransaction with more than {self.max_chunks} chunks. " + f"Required: {self.get_required_chunks()} Increase limit with set_max_chunks()." ) - return required - def freeze_with(self, client: Client) -> ChunkedTransaction: + def freeze_with(self: T, client: Client) -> T: """ Freezes the transaction by building transaction bodies for all chunks. @@ -129,45 +122,44 @@ def freeze_with(self, client: Client) -> ChunkedTransaction: Returns: ChunkedTransaction: This transaction instance for chaining. """ + self._validate_chunking() + if self._transaction_body_bytes: return self - self._validate_chunking() self._resolve_transaction_id(client) + self._resolve_node_ids(client) + + self._initial_transaction_id = self._transaction_ids[0] + + required_chunks = self.get_required_chunks() + self._generate_transaction_ids(self._transaction_ids[0], required_chunks) - if self.transaction_id.valid_start is None: - raise ValueError("Transaction ID with valid_start must be set before freezing chunked transaction.") + for chunk in range(required_chunks): + self._current_chunk_index = chunk + self._current_transaction_id_index = chunk - # Generate transaction IDs for all chunks if not already done - if not self._transaction_ids: - base_timestamp = self.transaction_id.valid_start + node_bytes = {} - for i in range(self.get_required_chunks()): - if i == 0: - # First chunk uses the original transaction ID - if self._initial_transaction_id is None: - self._initial_transaction_id = self.transaction_id + for node_account_id in self.node_account_ids: + self._node_account_id = node_account_id - chunk_transaction_id = self.transaction_id - else: - # Subsequent chunks get incremented timestamps - # Add i nanoseconds to space out chunks - next_nanos = base_timestamp.nanos + i + transaction_body = self.build_transaction_body() + transaction_body.transactionID.CopyFrom(self._transaction_ids[chunk]._to_proto()) + transaction_body.nodeAccountID.CopyFrom(node_account_id._to_proto()) - chunk_valid_start = timestamp_pb2.Timestamp( - seconds=base_timestamp.seconds + next_nanos // 1_000_000_000, nanos=next_nanos % 1_000_000_000 - ) - chunk_transaction_id = TransactionId( - account_id=self.transaction_id.account_id, valid_start=chunk_valid_start - ) + node_bytes[node_account_id] = transaction_body.SerializeToString() - self._transaction_ids.append(chunk_transaction_id) + self._transaction_body_bytes[self._transaction_ids[chunk]] = node_bytes + + self._current_chunk_index = None + self._current_transaction_id_index = 0 return super().freeze_with(client) @overload def execute( - self, + self: T, client: Client, timeout: int | float | None = None, wait_for_receipt: Literal[True] = True, @@ -176,7 +168,7 @@ def execute( @overload def execute( - self, + self: T, client: Client, timeout: int | float | None = None, wait_for_receipt: Literal[False] = False, @@ -184,7 +176,7 @@ def execute( ) -> TransactionResponse: ... def execute( - self, + self: T, client: Client, timeout: int | float | None = None, wait_for_receipt: bool = True, @@ -211,7 +203,7 @@ def execute( @overload def execute_all( - self, + self: T, client: Client, timeout: int | float | None = None, wait_for_receipt: Literal[True] = True, @@ -220,7 +212,7 @@ def execute_all( @overload def execute_all( - self, + self: T, client: Client, timeout: int | float | None = None, wait_for_receipt: Literal[False] = False, @@ -228,7 +220,7 @@ def execute_all( ) -> list[TransactionResponse]: ... def execute_all( - self, + self: T, client: Client, timeout: int | float | None = None, wait_for_receipt: bool = True, @@ -251,69 +243,25 @@ def execute_all( """ self._validate_chunking() - # For single-chunk transactions, delegate to the standard execution flow. - if self.get_required_chunks() == 1: - return [ - super().execute( - client, - timeout=timeout, - wait_for_receipt=wait_for_receipt, - validate_status=validate_status, - ) - ] - - # For multi-chunk transactions, ensure we are frozen before proceeding. if not self._transaction_body_bytes: self.freeze_with(client) - responses = [] - - for chunk_index in range(self.get_required_chunks()): - self._current_chunk_index = chunk_index - - if chunk_index < len(self._transaction_ids): - self.transaction_id = self._transaction_ids[chunk_index] - - # Clear the frozen state to rebuild the body for this chunk. - self._transaction_body_bytes.clear() - self._signature_map.clear() - - self.freeze_with(client) + # Single chunk transaction + if len(self._transaction_ids) == 1: + return [super().execute(client, timeout, wait_for_receipt, validate_status)] - for signing_key in self._signing_keys: - super().sign(signing_key) - - response = super().execute( - client, - timeout=timeout, - wait_for_receipt=wait_for_receipt, - validate_status=validate_status, - ) + # Multi-chunk transaction - execute all chunks + responses = [] + self._current_transaction_id_index = 0 + for index in range(len(self._transaction_ids)): + self._current_transaction_id_index = index + response = super().execute(client, timeout, wait_for_receipt, validate_status) responses.append(response) return responses - def sign(self, private_key: PrivateKey) -> ChunkedTransaction: - """ - Signs the transaction using the provided private key. - - For multi-chunk transactions, stores the signing key for later use when - executing all chunks. - - Args: - private_key (PrivateKey): The private key to sign with. - - Returns: - ChunkedTransaction: This transaction instance for chaining. - """ - super().sign(private_key) - # Store the signing key for multi-chunk execution only after signing succeeds. - if private_key not in self._signing_keys: - self._signing_keys.append(private_key) - return self - @property - def body_size_all_chunks(self) -> list[int]: + def body_size_all_chunks(self: T) -> list[int]: """ Returns an array of body sizes for each chunk in the transaction. @@ -328,17 +276,16 @@ def body_size_all_chunks(self) -> list[int]: self._require_frozen() sizes = [] - original_index = self._current_chunk_index - original_transaction_id = self.transaction_id + original_chunk_index = self._current_chunk_index + original_transaction_index = self._current_transaction_id_index try: - for i, transaction_id in enumerate(self._transaction_ids): + for i, _ in enumerate(self._transaction_ids): + self._current_transaction_id_index = i self._current_chunk_index = i - self.transaction_id = transaction_id - sizes.append(self.body_size) finally: - self._current_chunk_index = original_index - self.transaction_id = original_transaction_id + self._current_chunk_index = original_chunk_index + self._current_transaction_id_index = original_transaction_index return sizes diff --git a/src/hiero_sdk_python/transaction/transaction.py b/src/hiero_sdk_python/transaction/transaction.py index 00e7b8ff3..4c28d22ce 100644 --- a/src/hiero_sdk_python/transaction/transaction.py +++ b/src/hiero_sdk_python/transaction/transaction.py @@ -8,7 +8,8 @@ from hiero_sdk_python.crypto.key import Key from hiero_sdk_python.exceptions import PrecheckError from hiero_sdk_python.executable import _Executable, _ExecutionState -from hiero_sdk_python.hapi.services import basic_types_pb2, transaction_contents_pb2, transaction_pb2 +from hiero_sdk_python.hapi.sdk.transaction_list_pb2 import TransactionList +from hiero_sdk_python.hapi.services import basic_types_pb2, timestamp_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 @@ -46,7 +47,6 @@ def __init__(self) -> None: """Initializes a new Transaction instance with default values.""" super().__init__() - self.transaction_id = None self._transaction_fee: int | None = None self.transaction_valid_duration = 120 self.generate_record = False @@ -58,7 +58,7 @@ def __init__(self) -> None: # which is necessary in case node is unhealthy and we have to switch it with other node. # Each transaction body has the AccountId of the node it's being submitted to. # If these do not match `INVALID_NODE_ACCOUNT` error will occur. - self._transaction_body_bytes: dict[AccountId, bytes] = {} + self._transaction_body_bytes: dict[TransactionId, dict[AccountId, bytes]] = {} # Maps transaction body bytes to their associated signatures # This allows us to maintain the signatures for each unique transaction @@ -68,6 +68,8 @@ def __init__(self) -> None: self._default_transaction_fee = Hbar(2) self.operator_account_id = None self.batch_key: Key | None = None + self._transaction_ids: list[TransactionId] = [] + self._current_transaction_id_index: int = 0 def _make_request(self): """ @@ -181,25 +183,28 @@ def sign(self, private_key: PrivateKey) -> Transaction: self._require_frozen() # We sign the bodies for each node in case we need to switch nodes during execution. - for body_bytes in self._transaction_body_bytes.values(): - signature = private_key.sign(body_bytes) + for node_body_bytes in self._transaction_body_bytes.values(): + for body_bytes in node_body_bytes.values(): + signature = private_key.sign(body_bytes) - public_key_bytes = private_key.public_key().to_bytes_raw() + public_key_bytes = private_key.public_key().to_bytes_raw() - if private_key.is_ed25519(): - sig_pair = basic_types_pb2.SignaturePair(pubKeyPrefix=public_key_bytes, ed25519=signature) - else: - sig_pair = basic_types_pb2.SignaturePair(pubKeyPrefix=public_key_bytes, ECDSA_secp256k1=signature) + if private_key.is_ed25519(): + sig_pair = basic_types_pb2.SignaturePair(pubKeyPrefix=public_key_bytes, ed25519=signature) + else: + sig_pair = basic_types_pb2.SignaturePair(pubKeyPrefix=public_key_bytes, ECDSA_secp256k1=signature) - # We initialize the signature map for this body_bytes if it doesn't exist yet - self._signature_map.setdefault(body_bytes, basic_types_pb2.SignatureMap()) + # We initialize the signature map for this body_bytes if it doesn't exist yet + self._signature_map.setdefault(body_bytes, basic_types_pb2.SignatureMap()) - # deduplication check - already_signed = any(sp.pubKeyPrefix == public_key_bytes for sp in self._signature_map[body_bytes].sigPair) + # deduplication check + already_signed = any( + sp.pubKeyPrefix == public_key_bytes for sp in self._signature_map[body_bytes].sigPair + ) - # append only if not already signed - if not already_signed: - self._signature_map[body_bytes].sigPair.append(sig_pair) + # append only if not already signed + if not already_signed: + self._signature_map[body_bytes].sigPair.append(sig_pair) return self @@ -216,9 +221,11 @@ def _to_proto(self): # We require the transaction to be frozen before converting to protobuf self._require_frozen() - body_bytes = self._transaction_body_bytes.get(self.node_account_id) + body_bytes = self._transaction_body_bytes[self._transaction_ids[self._current_transaction_id_index]][ + self._node_account_id + ] if body_bytes is None: - raise ValueError(f"No transaction body found for node {self.node_account_id}") + raise ValueError(f"No transaction body found for node {self._node_account_id}") # Get signature map, or create empty one if transaction is not signed sig_map = self._signature_map.get(body_bytes) @@ -236,7 +243,7 @@ def _resolve_transaction_id(self, client: Client): if client is not None: operator_account_id = client.operator_account_id if operator_account_id is not None: - self.transaction_id = client.generate_transaction_id() + self._transaction_ids = [client.generate_transaction_id()] else: raise ValueError("Client must have an operator_account or transactionId must be set.") else: @@ -245,11 +252,19 @@ def _resolve_transaction_id(self, client: Client): ) def _resolve_node_ids(self, client: Client): - if self.node_account_id is None and len(self.node_account_ids) == 0 and client is None: + if self._node_account_id is None and len(self.node_account_ids) == 0 and client is None: raise ValueError( "Node account ID must be set before freezing. Use freeze_with(client) or manually set node_account_ids." ) + if self.batch_key: + # For Inner Transaction of batch transaction node_account_id=0.0.0 + self.node_account_ids = [AccountId(0, 0, 0)] + + elif len(self.node_account_ids) == 0: + # If nodes not set by user use all nodes from client network + self.node_account_ids = [node._account_id for node in client.network.nodes] + def freeze(self): """ Freezes the transaction by building the transaction body and setting necessary IDs. @@ -286,38 +301,28 @@ def freeze_with(self, client: Client): if self._transaction_body_bytes: return self - # Check transaction_id and node id to be set when using freeze() + # Check transaction_id and node id to be set for the transaction when using freeze() self._resolve_transaction_id(client) - self._resolve_node_ids(client) - # We iterate through every node in the client's network - # For each node, set the node_account_id and build the transaction body - # This allows the transaction to be submitted to any node in the network + # Genreate all the transaction_id if chunk transaction + require_chunks = self.get_required_chunks() + self._generate_transaction_ids(self._transaction_ids[0], require_chunks) - if self.batch_key: - # For Inner Transaction of batch transaction node_account_id=0.0.0 - self.node_account_id = AccountId(0, 0, 0) - self._transaction_body_bytes[AccountId(0, 0, 0)] = self.build_transaction_body().SerializeToString() - return self + # We iterate through every node in the client's network + # For each node, set the node_account_id, transaction_id and build the transaction body + # Normal transaction has one transaction_id + node_bytes = {} + for node_account_id in self.node_account_ids: + self._node_account_id = node_account_id - # Single node - if self.node_account_id: - self.set_node_account_id(self.node_account_id) - self._transaction_body_bytes[self.node_account_id] = self.build_transaction_body().SerializeToString() - return self + transaction_body = self.build_transaction_body() + transaction_body.transactionID.CopyFrom(self.transaction_id._to_proto()) + transaction_body.nodeAccountID.CopyFrom(node_account_id._to_proto()) - # Multiple node - if len(self.node_account_ids) > 0: - for node_account_id in self.node_account_ids: - self.node_account_id = node_account_id - self._transaction_body_bytes[node_account_id] = self.build_transaction_body().SerializeToString() + node_bytes[node_account_id] = transaction_body.SerializeToString() - else: - # Use all nodes from client network - for node in client.network.nodes: - self.node_account_id = node._account_id - self._transaction_body_bytes[node._account_id] = self.build_transaction_body().SerializeToString() + self._transaction_body_bytes[self._transaction_ids[0]] = node_bytes return self @@ -393,6 +398,26 @@ def execute( return response + def _generate_transaction_ids(self, initial_transaction: TransactionId, count: int): + """Generate all transaction_id for require chunks.""" + self._transaction_ids = [] + + if count == 1: + self._transaction_ids = [initial_transaction] + return + + transaction_id = initial_transaction + for i in range(count): + self._transaction_ids.append(transaction_id) + + next_nanos = initial_transaction.valid_start.nanos + (i + 1) + next_valid_start = timestamp_pb2.Timestamp( + seconds=initial_transaction.valid_start.seconds + next_nanos // 1_000_000_000, + nanos=next_nanos % 1_000_000_000, + ) + + transaction_id = TransactionId(account_id=self.transaction_id.account_id, valid_start=next_valid_start) + def is_signed_by(self, public_key): """ Checks if the transaction has been signed by the given public key. @@ -405,7 +430,7 @@ def is_signed_by(self, public_key): """ public_key_bytes = public_key.to_bytes_raw() - sig_map = self._signature_map.get(self._transaction_body_bytes.get(self.node_account_id)) + sig_map = self._signature_map.get(self._transaction_body_bytes[self.transaction_id][self._node_account_id]) if sig_map is None: return False @@ -452,20 +477,7 @@ def build_base_transaction_body(self) -> transaction_pb2.TransactionBody: Raises: ValueError: If required IDs are not set. """ - if self.transaction_id is None: - if self.operator_account_id is None: - raise ValueError("Operator account ID is not set.") - self.transaction_id = TransactionId.generate(self.operator_account_id) - - transaction_id_proto = self.transaction_id._to_proto() - - selected_node = self.node_account_id or (self.node_account_ids[0] if self.node_account_ids else None) - if selected_node is None: - raise ValueError("Node account ID is not set.") - transaction_body = transaction_pb2.TransactionBody() - transaction_body.transactionID.CopyFrom(transaction_id_proto) - transaction_body.nodeAccountID.CopyFrom(selected_node._to_proto()) fee = self._transaction_fee or self._default_transaction_fee if hasattr(fee, "to_tinybars"): @@ -603,9 +615,22 @@ def set_transaction_id(self, transaction_id: TransactionId): Exception: If the transaction has already been frozen. """ self._require_not_frozen() - self.transaction_id = transaction_id + self._transaction_ids = [transaction_id] return self + @property + def transaction_id(self) -> TransactionId | None: + """Get the transaction ID for the transaction.""" + if len(self._transaction_ids) > 0: + return self._transaction_ids[self._current_transaction_id_index] + + return None + + @transaction_id.setter + def transaction_id(self, transaction_id: TransactionId): + """Sets the transaction ID for the transaction.""" + self.set_transaction_id(transaction_id) + # this will preserves original behavior @property def transaction_fee(self): @@ -638,7 +663,6 @@ def to_bytes(self) -> bytes: Serializes the frozen transaction into its protobuf-encoded byte representation. This method is equivalent to the TypeScript SDK's transaction.toBytes() method. - The transaction must be frozen before calling this method. The transaction can be serialized with or without signatures: - **Unsigned**: Can be sent to external signing services or HSMs @@ -664,17 +688,45 @@ def to_bytes(self) -> bytes: Returns: bytes: The serialized transaction as bytes. - - Raises: - Exception: If the transaction has not been frozen yet. """ - self._require_frozen() + transaction_list = TransactionList() - # Get the transaction protobuf - transaction_proto = self._to_proto() + if self._transaction_body_bytes: + for node_transaction_bodies in self._transaction_body_bytes.values(): + for transaction_body_bytes in node_transaction_bodies.values(): + signed_transaction = transaction_contents_pb2.SignedTransaction(bodyBytes=transaction_body_bytes) + signature_map = self._signature_map.get(transaction_body_bytes) - # Serialize to bytes - return transaction_proto.SerializeToString() + if signature_map is not None: + signed_transaction.sigMap.CopyFrom(signature_map) + else: + signed_transaction.sigMap.CopyFrom(basic_types_pb2.SignatureMap(sigPair=[])) + + transaction_list.transaction_list.append( + transaction_pb2.Transaction(signedTransactionBytes=signed_transaction.SerializeToString()) + ) + + return transaction_list.SerializeToString() + + transaction_body = self.build_transaction_body() + + if self.transaction_id is not None: + transaction_body.transactionID.CopyFrom(self.transaction_id._to_proto()) + + node_account_ids = self.node_account_ids or [None] + for node_account_id in node_account_ids: + if node_account_id is not None: + transaction_body.nodeAccountID.CopyFrom(node_account_id._to_proto()) + + signed_transaction = transaction_contents_pb2.SignedTransaction( + bodyBytes=transaction_body.SerializeToString() + ) + + transaction_list.transaction_list.append( + transaction_pb2.Transaction(signedTransactionBytes=signed_transaction.SerializeToString()) + ) + + return transaction_list.SerializeToString() @staticmethod def from_bytes(transaction_bytes: bytes): @@ -739,16 +791,21 @@ def from_bytes(transaction_bytes: bytes): """ if not isinstance(transaction_bytes, bytes): raise ValueError("transaction_bytes must be bytes") - if len(transaction_bytes) == 0: raise ValueError("transaction_bytes cannot be empty") try: - transaction_proto = transaction_pb2.Transaction() - transaction_proto.ParseFromString(transaction_bytes) - except Exception as e: - raise ValueError(f"Failed to parse transaction bytes: {e}") from e + return Transaction._process_transaction_list_bytes(transaction_bytes) + except Exception: + try: + # Backward compatibility + return Transaction._process_single_transaction_bytes(transaction_bytes) + except Exception as e: + raise ValueError(f"Failed to parse transaction_bytes {e}") from e + @staticmethod + def _process_base_transaction(transaction_proto: transaction_pb2.Transaction): + """Parses a serialized Transaction protobuf.""" try: signed_transaction = transaction_contents_pb2.SignedTransaction() signed_transaction.ParseFromString(transaction_proto.signedTransactionBytes) @@ -762,19 +819,112 @@ def from_bytes(transaction_bytes: bytes): raise ValueError(f"Failed to parse transaction body: {e}") from e transaction_type = transaction_body.WhichOneof("data") - if transaction_type is None: raise ValueError("Transaction body does not contain any transaction data") - transaction_class = Transaction._get_transaction_class(transaction_type) if transaction_class is None: raise ValueError(f"Unknown transaction type: {transaction_type}") - return transaction_class._from_protobuf( - transaction_body, signed_transaction.bodyBytes, signed_transaction.sigMap + return transaction_class, transaction_body, signed_transaction + + # Deprecated, for backward compatiblity only + @staticmethod + def _process_single_transaction_bytes(transaction_bytes: bytes): + """Deserializes a single Transaction protobuf.""" + try: + transaction_proto = transaction_pb2.Transaction() + transaction_proto.ParseFromString(transaction_bytes) + except Exception as e: + raise ValueError(f"Failed to parse transaction bytes: {e}") from e + + transaction_class, transaction_body, signed_transaction = Transaction._process_base_transaction( + transaction_proto + ) + + transaction: Transaction = transaction_class._from_protobuf(transaction_body) + + if transaction._node_account_id is not None: + transaction.set_node_account_id(transaction._node_account_id) + + Transaction._restore_signatures( + transaction, + transaction, + signed_transaction, ) + return transaction + + @staticmethod + def _process_transaction_list_bytes(transaction_bytes: bytes): + """Deserializes a TransactionList protobuf.""" + try: + transaction_list = TransactionList() + transaction_list.ParseFromString(transaction_bytes) + except Exception as e: + raise ValueError(f"Failed to parse TransactionList: {e}") from e + + if not transaction_list.transaction_list: + raise ValueError("TransactionList contains no transactions") + + restored_transaction: Transaction = None + + for transaction_proto in transaction_list.transaction_list: + transaction_class, transaction_body, signed_transaction = Transaction._process_base_transaction( + transaction_proto + ) + + transaction: Transaction = transaction_class._from_protobuf(transaction_body) + + node_account_id = transaction._node_account_id + transaction_id = transaction.transaction_id + + if restored_transaction is None: + restored_transaction = transaction + + if node_account_id is not None: + restored_transaction.node_account_ids = [node_account_id] + + if transaction_id is not None: + restored_transaction._transaction_ids = [transaction_id] + + else: + if node_account_id is not None and node_account_id not in restored_transaction.node_account_ids: + restored_transaction.node_account_ids.append(node_account_id) + + if transaction_id is not None and transaction_id not in restored_transaction._transaction_ids: + restored_transaction._transaction_ids.append(transaction_id) + + Transaction._restore_signatures( + restored_transaction, + transaction, + signed_transaction, + ) + + return restored_transaction + + @staticmethod + def _restore_signatures( + restored_transaction: Transaction, + transaction: Transaction, + signed_transaction, + ): + """Restores signature maps and body bytes.""" + + if not signed_transaction.HasField("sigMap"): + return + + if signed_transaction.sigMap.sigPair: + restored_transaction._signature_map[signed_transaction.bodyBytes] = signed_transaction.sigMap + + if transaction._transaction_ids and transaction._node_account_id is not None: + tx_id = transaction._transaction_ids[0] + + restored_transaction._transaction_body_bytes.setdefault(tx_id, {}) + restored_transaction._transaction_body_bytes[tx_id][transaction._node_account_id] = ( + signed_transaction.bodyBytes + ) + @staticmethod def _get_transaction_class(transaction_type: str): """ @@ -856,7 +1006,7 @@ def _get_transaction_class(transaction_type: str): raise ValueError(f"Failed to import transaction class for type '{transaction_type}': {e}") from e @classmethod - def _from_protobuf(cls, transaction_body, body_bytes: bytes, sig_map): + def _from_protobuf(cls, transaction_body): """ Creates a transaction instance from protobuf components. @@ -874,10 +1024,10 @@ def _from_protobuf(cls, transaction_body, body_bytes: bytes, sig_map): transaction = cls() if transaction_body.HasField("transactionID"): - transaction.transaction_id = TransactionId._from_proto(transaction_body.transactionID) + transaction._transaction_ids = [TransactionId._from_proto(transaction_body.transactionID)] if transaction_body.HasField("nodeAccountID"): - transaction.node_account_id = AccountId._from_proto(transaction_body.nodeAccountID) + transaction._node_account_id = AccountId._from_proto(transaction_body.nodeAccountID) transaction.transaction_fee = transaction_body.transactionFee transaction.transaction_valid_duration = transaction_body.transactionValidDuration.seconds @@ -892,14 +1042,6 @@ def _from_protobuf(cls, transaction_body, body_bytes: bytes, sig_map): CustomFeeLimit._from_proto(fee) for fee in transaction_body.max_custom_fees ] - if transaction.node_account_id: - # restore for the original frozen node - transaction.set_node_account_id(transaction.node_account_id) - transaction._transaction_body_bytes[transaction.node_account_id] = body_bytes - - if sig_map and sig_map.sigPair: - transaction._signature_map[body_bytes] = sig_map - return transaction def set_batch_key(self, key: Key): diff --git a/src/hiero_sdk_python/transaction/transfer_transaction.py b/src/hiero_sdk_python/transaction/transfer_transaction.py index 27d517f91..29bb4d8e2 100644 --- a/src/hiero_sdk_python/transaction/transfer_transaction.py +++ b/src/hiero_sdk_python/transaction/transfer_transaction.py @@ -162,7 +162,7 @@ def _get_method(self, channel: _Channel) -> _Method: return _Method(transaction_func=channel.crypto.cryptoTransfer, query_func=None) @classmethod - def _from_protobuf(cls, transaction_body, body_bytes: bytes, sig_map): + def _from_protobuf(cls, transaction_body): """ Creates a TransferTransaction instance from protobuf components. @@ -174,7 +174,7 @@ def _from_protobuf(cls, transaction_body, body_bytes: bytes, sig_map): Returns: TransferTransaction: A new transaction instance with all fields restored """ - transaction = super()._from_protobuf(transaction_body, body_bytes, sig_map) + transaction = super()._from_protobuf(transaction_body) if transaction_body.HasField("cryptoTransfer"): crypto_transfer = transaction_body.cryptoTransfer diff --git a/tests/integration/file_append_transaction_e2e_test.py b/tests/integration/file_append_transaction_e2e_test.py index 1238a3d7f..03ba9b7d5 100644 --- a/tests/integration/file_append_transaction_e2e_test.py +++ b/tests/integration/file_append_transaction_e2e_test.py @@ -8,6 +8,7 @@ from hiero_sdk_python.file.file_contents_query import FileContentsQuery from hiero_sdk_python.file.file_create_transaction import FileCreateTransaction from hiero_sdk_python.response_code import ResponseCode +from hiero_sdk_python.transaction.transaction import Transaction from hiero_sdk_python.transaction.transaction_id import TransactionId @@ -234,7 +235,8 @@ def test_integration_file_append_transaction_max_chunks_exceeded(env): # Should fail with max chunks exceeded with pytest.raises( - ValueError, match="Message requires 100 chunks but max_chunks=5. Increase limit with set_max_chunks()." + ValueError, + match="Cannot execute ChunkedTransaction with more than 5 chunks. Required: 100 Increase limit with set_max_chunks().", ): append_tx.execute(env.client) @@ -326,3 +328,39 @@ def test_file_append_chunk_transaction_can_execute_with_manual_freeze(env): file_contents = FileContentsQuery().set_file_id(file_id).execute(env.client) assert file_contents == bytes(content, "utf-8") + + +@pytest.mark.integration +def test_serialize_chunked_file_append_transaction_can_be_executed(env): + """Test serilaize chunk file append transaction can be executed.""" + create_receipt = ( + FileCreateTransaction() + .set_keys(env.client.operator_private_key.public_key()) + .set_contents(b"") + .execute(env.client) + ) + + assert create_receipt.status == ResponseCode.SUCCESS + file_id = create_receipt.file_id + + file_contents = FileContentsQuery().set_file_id(file_id).execute(env.client) + assert file_contents == b"" + + content = "A" * 20 # content with (20/10) bytes ie approx 2 chunks + + tx1 = FileAppendTransaction().set_file_id(file_id).set_chunk_size(10).set_contents(content).freeze_with(env.client) + + tx_bytes = tx1.to_bytes() + + tx2 = Transaction.from_bytes(tx_bytes) + + assert isinstance(tx2, FileAppendTransaction) + assert len(tx2._transaction_ids) == 2 + assert len(tx2.node_account_ids) == len(env.client.network.nodes) + + receipt = tx2.execute(env.client) + + assert receipt.status == ResponseCode.SUCCESS + + file_contents = FileContentsQuery().set_file_id(file_id).execute(env.client) + assert file_contents == bytes(content, "utf-8") diff --git a/tests/integration/topic_message_submit_transaction_e2e_test.py b/tests/integration/topic_message_submit_transaction_e2e_test.py index 4fbb52ff7..dc067e01f 100644 --- a/tests/integration/topic_message_submit_transaction_e2e_test.py +++ b/tests/integration/topic_message_submit_transaction_e2e_test.py @@ -19,6 +19,7 @@ from hiero_sdk_python.response_code import ResponseCode from hiero_sdk_python.tokens.custom_fixed_fee import CustomFixedFee from hiero_sdk_python.transaction.custom_fee_limit import CustomFeeLimit +from hiero_sdk_python.transaction.transaction import Transaction from hiero_sdk_python.transaction.transaction_id import TransactionId @@ -286,7 +287,8 @@ def test_integration_topic_message_submit_transaction_fails_if_required_chunk_gr ) message_transaction.set_max_chunks(2) with pytest.raises( - ValueError, match="Message requires 4 chunks but max_chunks=2. Increase limit with set_max_chunks()." + ValueError, + match="Cannot execute ChunkedTransaction with more than 2 chunks. Required: 4 Increase limit with set_max_chunks().", ): message_transaction.execute(env.client) @@ -321,3 +323,74 @@ def test_topic_message_submit_transaction_can_submit_a_large_message_manual_free assert info.sequence_number == 14 delete_topic(env.client, topic_id) + + +@pytest.mark.integration +def test_non_freeze_serialize_chunk_topic_message_submit_transaction_can_be_executed(env): + """Test topic message submit transaction can execute serialize chunk transaction.""" + topic_id = create_topic(client=env.client, admin_key=env.operator_key) + + info = TopicInfoQuery().set_topic_id(topic_id).execute(env.client) + assert info.sequence_number == 0 + + message = "A" * (1024 * 14) # message with (1024 * 14) bytes ie 14 chunks + + tx1 = TopicMessageSubmitTransaction().set_topic_id(topic_id).set_message(message) + + tx_bytes = tx1.to_bytes() + + tx2 = Transaction.from_bytes(tx_bytes) + + assert isinstance(tx2, TopicMessageSubmitTransaction) + assert tx2.topic_id == tx1.topic_id + assert tx2.message == tx1.message + + receipt = tx2.execute(env.client) + + assert receipt.status == ResponseCode.SUCCESS + + info = TopicInfoQuery().set_topic_id(topic_id).execute(env.client) + assert info.sequence_number == 14 + + delete_topic(env.client, topic_id) + + +@pytest.mark.integration +def test_freeze_serialize_chunk_topic_message_submit_transaction_can_be_executed(env): + """Test topic message submit transaction can execute frozen serialize chunk transaction.""" + topic_id = create_topic(client=env.client, admin_key=env.operator_key) + + info = TopicInfoQuery().set_topic_id(topic_id).execute(env.client) + assert info.sequence_number == 0 + + message = "A" * (1024 * 14) # message with (1024 * 14) bytes ie 14 chunks + + tx1 = TopicMessageSubmitTransaction().set_topic_id(topic_id).set_message(message).freeze_with(env.client) + + tx_bytes = tx1.to_bytes() + + tx2 = Transaction.from_bytes(tx_bytes) + + assert isinstance(tx2, TopicMessageSubmitTransaction) + assert tx2.topic_id == tx1.topic_id + # if transaction is frozen the serialization uses the transaction_body_bytes + # so the message will only contain the text for the first chunk + assert tx2.message != tx1.message + assert tx2._transaction_ids == tx1._transaction_ids + assert len(tx2._transaction_body_bytes) == len(tx1._transaction_body_bytes) + + for transaction_id, node_bytes in tx2._transaction_body_bytes.items(): + for node_id, _ in node_bytes.items(): + assert ( + tx1._transaction_body_bytes[transaction_id][node_id] + == tx2._transaction_body_bytes[transaction_id][node_id] + ) + + receipt = tx2.execute(env.client) + + assert receipt.status == ResponseCode.SUCCESS + + info = TopicInfoQuery().set_topic_id(topic_id).execute(env.client) + assert info.sequence_number == 14 + + delete_topic(env.client, topic_id) diff --git a/tests/integration/transaction_freeze_e2e_test.py b/tests/integration/transaction_freeze_e2e_test.py index 7c9777e82..34b6fef2a 100644 --- a/tests/integration/transaction_freeze_e2e_test.py +++ b/tests/integration/transaction_freeze_e2e_test.py @@ -22,8 +22,10 @@ def test_transaction_executes_successfully(env): receipt = tx.execute(executor_client) # Verify that the transaction_bodys are generated for all nodes present in client network - assert len(tx._transaction_body_bytes) == len(env.client.network.nodes) - assert set(tx._transaction_body_bytes.keys()) == {node._account_id for node in env.client.network.nodes} + assert len(tx._transaction_body_bytes) == len(tx._transaction_ids) + assert set(tx._transaction_body_bytes.keys()) == set(tx._transaction_ids) + + # TODO: check for node_ids assert receipt.status == ResponseCode.SUCCESS, "Transaction must execute successfully" @@ -41,8 +43,10 @@ def test_transaction_executes_successfully_with_node_account_ids(env): tx.sign(executor_key) # Verify that the transaction_bodys are generated for the provided node_account_ids only - assert len(tx._transaction_body_bytes) == 2 - assert set(tx._transaction_body_bytes.keys()) == set(node_account_ids) + assert len(tx._transaction_body_bytes) == len(tx._transaction_ids) + assert set(tx._transaction_body_bytes.keys()) == set(tx._transaction_ids) + + # TODO: check for the node_ids receipt = tx.execute(executor_client) assert receipt.status == ResponseCode.SUCCESS, "Transaction must execute successfully" @@ -61,8 +65,10 @@ def test_transaction_executes_successfully_with_single_node_account_id(env): tx.sign(executor_key) # Verify that the transaction_bodys are generated for the provided node_account_id only - assert len(tx._transaction_body_bytes) == 1 - assert set(tx._transaction_body_bytes.keys()) == {node_account_id} + assert len(tx._transaction_body_bytes) == len(tx._transaction_ids) + assert set(tx._transaction_body_bytes.keys()) == set(tx._transaction_ids) + + # TODO: check for node_ids receipt = tx.execute(executor_client) assert receipt.status == ResponseCode.SUCCESS, "Transaction must execute successfully" diff --git a/tests/unit/account_create_transaction_test.py b/tests/unit/account_create_transaction_test.py index d5c2ac0d9..c61f485ef 100644 --- a/tests/unit/account_create_transaction_test.py +++ b/tests/unit/account_create_transaction_test.py @@ -108,6 +108,7 @@ def test_account_create_transaction_build_scheduled_body(mock_account_ids): def test_account_create_transaction_sign(mock_account_ids, mock_client): """Test signing the account create transaction.""" operator_id, node_account_id = mock_account_ids + transaction_id = generate_transaction_id(operator_id) new_private_key = PrivateKey.generate() new_public_key = new_private_key.public_key() @@ -119,20 +120,20 @@ def test_account_create_transaction_sign(mock_account_ids, mock_client): .set_initial_balance(100000000) .set_account_memo("Test account") ) - account_tx.transaction_id = generate_transaction_id(operator_id) + account_tx.transaction_id = transaction_id account_tx.node_account_id = node_account_id account_tx.freeze_with(mock_client) # Add first signiture account_tx.sign(mock_client.operator_private_key) - body_bytes = account_tx._transaction_body_bytes[node_account_id] + body_bytes = account_tx._transaction_body_bytes[transaction_id][node_account_id] assert body_bytes in account_tx._signature_map, "Body bytes should be a key in the signature map dictionary" assert len(account_tx._signature_map[body_bytes].sigPair) == 1, "Transaction should have exactly one signature" # Add second signiture account_tx.sign(operator_private_key) - body_bytes = account_tx._transaction_body_bytes[node_account_id] + body_bytes = account_tx._transaction_body_bytes[transaction_id][node_account_id] assert body_bytes in account_tx._signature_map, "Body bytes should be a key in the signature map dictionary" assert len(account_tx._signature_map[body_bytes].sigPair) == 2, "Transaction should have exactly two signatures" diff --git a/tests/unit/account_delete_transaction_test.py b/tests/unit/account_delete_transaction_test.py index 5fd987764..d2d65237f 100644 --- a/tests/unit/account_delete_transaction_test.py +++ b/tests/unit/account_delete_transaction_test.py @@ -188,7 +188,7 @@ def test_sign_transaction(mock_client, delete_params): delete_tx.sign(private_key) node_id = mock_client.network.current_node._account_id - body_bytes = delete_tx._transaction_body_bytes[node_id] + body_bytes = delete_tx._transaction_body_bytes[delete_tx.transaction_id][node_id] assert len(delete_tx._signature_map[body_bytes].sigPair) == 1 sig_pair = delete_tx._signature_map[body_bytes].sigPair[0] diff --git a/tests/unit/account_update_transaction_test.py b/tests/unit/account_update_transaction_test.py index 00e7da506..03396d535 100644 --- a/tests/unit/account_update_transaction_test.py +++ b/tests/unit/account_update_transaction_test.py @@ -354,7 +354,7 @@ def test_sign_transaction(mock_client): account_tx.sign(private_key) node_id = mock_client.network.current_node._account_id - body_bytes = account_tx._transaction_body_bytes[node_id] + body_bytes = account_tx._transaction_body_bytes[account_tx.transaction_id][node_id] assert len(account_tx._signature_map[body_bytes].sigPair) == 1 sig_pair = account_tx._signature_map[body_bytes].sigPair[0] diff --git a/tests/unit/batch_transaction_test.py b/tests/unit/batch_transaction_test.py index eec1d2093..acdd7d85a 100644 --- a/tests/unit/batch_transaction_test.py +++ b/tests/unit/batch_transaction_test.py @@ -312,7 +312,7 @@ def test_sign_transaction(mock_client, mock_tx): batch_tx.sign(private_key) node_id = mock_client.network.current_node._account_id - body_bytes = batch_tx._transaction_body_bytes[node_id] + body_bytes = batch_tx._transaction_body_bytes[batch_tx.transaction_id][node_id] assert body_bytes in batch_tx._signature_map, "signature map must contain an entry for the tx body bytes" sig_pairs = batch_tx._signature_map[body_bytes].sigPair diff --git a/tests/unit/chunked_transaction_test.py b/tests/unit/chunked_transaction_test.py index aeb989024..9dc465bb9 100644 --- a/tests/unit/chunked_transaction_test.py +++ b/tests/unit/chunked_transaction_test.py @@ -15,9 +15,12 @@ class DummyChunkedTransaction(ChunkedTransaction): + """Mock ChunkTransaction class with default chunk size as 1024""" + def __init__(self, required_chunks: int = 1) -> None: super().__init__() self.required_chunks = required_chunks + self._total_chunks = self.get_required_chunks() def _get_method(self, _channel): method = type("Method", (), {})() @@ -44,10 +47,9 @@ def test_constructor_sets_default_chunk_configuration(): assert tx.chunk_size == 1024 assert tx.max_chunks == 20 - assert tx._current_chunk_index == 0 + assert tx._current_chunk_index is None assert tx._total_chunks == 1 assert tx._transaction_ids == [] - assert tx._signing_keys == [] @pytest.mark.parametrize( @@ -61,21 +63,13 @@ def test_setters_reject_non_positive_values(mock_client, setter_name, value, mes getattr(tx, setter_name)(value) -def test_validate_chunking_rejects_zero_required_chunks(): - """Test that _validate_chunking raises a ValueError when the required chunks is zero.""" - tx = DummyChunkedTransaction(required_chunks=0) - - with pytest.raises(ValueError, match="Transaction must require at least one chunk"): - tx._validate_chunking() - - def test_validate_chunking_rejects_too_many_chunks(): """Test that _validate_chunking raises a ValueError when the required chunks exceeds max_chunks.""" tx = DummyChunkedTransaction(required_chunks=4).set_max_chunks(2) with pytest.raises( ValueError, - match=r"Message requires 4 chunks but max_chunks=2\. Increase limit with set_max_chunks\(\)\.", + match="Cannot execute ChunkedTransaction with more than 2 chunks. Required: 4 Increase limit with set_max_chunks().", ): tx._validate_chunking() @@ -86,7 +80,7 @@ def test_freeze_with_rejects_too_many_chunks(mock_client): with pytest.raises( ValueError, - match=r"Message requires 5 chunks but max_chunks=3\. Increase limit with set_max_chunks\(\)\.", + match="Cannot execute ChunkedTransaction with more than 3 chunks. Required: 5 Increase limit with set_max_chunks().", ): tx.freeze_with(mock_client) @@ -100,7 +94,8 @@ def test_freeze_with_builds_chunk_transaction_ids(mock_client): assert tx._total_chunks == 3 assert len(tx._transaction_ids) == 3 - assert tx._initial_transaction_id == tx.transaction_id + assert tx._initial_transaction_id is not None + assert tx._initial_transaction_id.valid_start == tx.transaction_id.valid_start assert tx._transaction_ids[0] == tx.transaction_id assert tx._transaction_ids[1].valid_start.seconds == 123 assert tx._transaction_ids[1].valid_start.nanos == 457 @@ -108,17 +103,6 @@ def test_freeze_with_builds_chunk_transaction_ids(mock_client): assert tx._transaction_ids[2].valid_start.nanos == 458 -def test_sign_tracks_signing_keys_once(mock_client, private_key): - tx = DummyChunkedTransaction(required_chunks=1) - tx.freeze_with(mock_client) - - tx.sign(private_key) - tx.sign(private_key) - - assert tx._signing_keys == [private_key] - assert tx.is_signed_by(private_key.public_key()) is True - - def test_body_size_all_chunks_restores_state(mock_client): tx = DummyChunkedTransaction(required_chunks=3) tx.transaction_id = TransactionId( @@ -164,7 +148,8 @@ def test_execute_all_multi_chunk_replays_each_chunk(mock_client, private_key): assert responses == ["chunk-1", "chunk-2", "chunk-3"] assert mock_execute.call_count == 3 - assert tx._current_chunk_index == 2 + # ensure reset to none once transaction_body_bytes created + assert tx._current_chunk_index is None def test_validate_chunking_allows_required_equal_to_max_chunks(): @@ -173,4 +158,5 @@ def test_validate_chunking_allows_required_equal_to_max_chunks(): tx._validate_chunking() assert tx._total_chunks == 3 - assert tx._current_chunk_index == 0 + # since not transactionBytes is build + assert tx._current_chunk_index is None diff --git a/tests/unit/contract_delete_transaction_test.py b/tests/unit/contract_delete_transaction_test.py index efd56b635..5c66fc899 100644 --- a/tests/unit/contract_delete_transaction_test.py +++ b/tests/unit/contract_delete_transaction_test.py @@ -296,7 +296,7 @@ def test_sign_transaction(mock_client, delete_params): delete_tx.sign(private_key) node_id = mock_client.network.current_node._account_id - body_bytes = delete_tx._transaction_body_bytes[node_id] + body_bytes = delete_tx._transaction_body_bytes[delete_tx.transaction_id][node_id] assert len(delete_tx._signature_map[body_bytes].sigPair) == 1 sig_pair = delete_tx._signature_map[body_bytes].sigPair[0] diff --git a/tests/unit/contract_execute_transaction_test.py b/tests/unit/contract_execute_transaction_test.py index f7ce6a450..89ee7e0cd 100644 --- a/tests/unit/contract_execute_transaction_test.py +++ b/tests/unit/contract_execute_transaction_test.py @@ -325,7 +325,7 @@ def test_sign_transaction(mock_client, execute_params): execute_tx.sign(private_key) node_id = mock_client.network.current_node._account_id - body_bytes = execute_tx._transaction_body_bytes[node_id] + body_bytes = execute_tx._transaction_body_bytes[execute_tx.transaction_id][node_id] assert len(execute_tx._signature_map[body_bytes].sigPair) == 1 sig_pair = execute_tx._signature_map[body_bytes].sigPair[0] diff --git a/tests/unit/ethereum_transaction_test.py b/tests/unit/ethereum_transaction_test.py index 26f1d8cc4..e65e1d1e9 100644 --- a/tests/unit/ethereum_transaction_test.py +++ b/tests/unit/ethereum_transaction_test.py @@ -170,7 +170,7 @@ def test_sign_transaction(mock_client, ethereum_params): ethereum_tx.sign(private_key) node_id = mock_client.network.current_node._account_id - body_bytes = ethereum_tx._transaction_body_bytes[node_id] + body_bytes = ethereum_tx._transaction_body_bytes[ethereum_tx.transaction_id][node_id] assert len(ethereum_tx._signature_map[body_bytes].sigPair) == 1 sig_pair = ethereum_tx._signature_map[body_bytes].sigPair[0] diff --git a/tests/unit/executable_test.py b/tests/unit/executable_test.py index bbe7f76a2..4b2d42556 100644 --- a/tests/unit/executable_test.py +++ b/tests/unit/executable_test.py @@ -402,10 +402,12 @@ def test_transaction_node_switching_body_bytes(): ) for node in client.network.nodes: - assert transaction._transaction_body_bytes.get(node._account_id) is not None, ( + assert transaction._transaction_body_bytes[transaction.transaction_id][node._account_id] is not None, ( "Transaction body bytes should be set for all nodes" ) - sig_map = transaction._signature_map.get(transaction._transaction_body_bytes[node._account_id]) + sig_map = transaction._signature_map.get( + transaction._transaction_body_bytes[transaction.transaction_id][node._account_id] + ) assert sig_map is not None, "Signature map should be set for all nodes" assert len(sig_map.sigPair) == 1, "Signature map should have one signature" assert sig_map.sigPair[0].pubKeyPrefix == client.operator_private_key.public_key().to_bytes_raw(), ( @@ -838,11 +840,12 @@ def test_executable_overrides_client_config(mock_client): def test_no_healthy_nodes_raises(mock_client): """Test that execution fails if no healthy nodes are available.""" - mock_client.network._healthy_nodes = [] - tx = AccountCreateTransaction().set_key_without_alias(PrivateKey.generate().public_key()).set_initial_balance(1) - with pytest.raises(RuntimeError, match="No healthy nodes available"): + for node in mock_client.network.nodes: + node.is_healthy = lambda: False + + with pytest.raises(RuntimeError, match="All nodes are unhealthy"): tx.execute(mock_client) diff --git a/tests/unit/fee_estimate_query_test.py b/tests/unit/fee_estimate_query_test.py index 4d4540f18..e33d1ef79 100644 --- a/tests/unit/fee_estimate_query_test.py +++ b/tests/unit/fee_estimate_query_test.py @@ -380,7 +380,7 @@ def test_port_replacement_for_localhost_execute_multiple(): with patch.object(query, "_execute_chunked", return_value=MagicMock()) as mock_execute_chunked: query.execute(client_1) - called_url = mock_execute_chunked.call_args[0][1] + called_url = mock_execute_chunked.call_args[0][0] assert ":8084" in called_url assert ":38081" not in called_url @@ -389,6 +389,6 @@ def test_port_replacement_for_localhost_execute_multiple(): with patch.object(query, "_execute_chunked", return_value=MagicMock()) as mock_execute_chunked: query.execute(client_2) - called_url = mock_execute_chunked.call_args[0][1] + called_url = mock_execute_chunked.call_args[0][0] assert ":8084" in called_url assert ":38081" not in called_url diff --git a/tests/unit/file_append_transaction_test.py b/tests/unit/file_append_transaction_test.py index 655cc3be2..e4ae8f45f 100644 --- a/tests/unit/file_append_transaction_test.py +++ b/tests/unit/file_append_transaction_test.py @@ -120,7 +120,8 @@ def test_validate_chunking(): # Should raise error when required chunks > max_chunks with pytest.raises( - ValueError, match="Message requires 140 chunks but max_chunks=5. Increase limit with set_max_chunks()." + ValueError, + match="Cannot execute ChunkedTransaction with more than 5 chunks. Required: 140 Increase limit with set_max_chunks().", ): file_tx._validate_chunking() @@ -403,3 +404,145 @@ def test_chunk_transaction_id_nanosecond_overflow(file_id): # Second chunk seconds=base_seconds + 1, nanos=0 assert tx._transaction_ids[1].valid_start.seconds == base_seconds + 1 assert tx._transaction_ids[1].valid_start.nanos == 0 + + +def test_serialization_non_chunk_transaction_non_freeze(file_id): + """Test that FileAppendTransaction can serialize non frozen and non chunk transaction""" + content = "Hello! Hiero" + + tx1 = FileAppendTransaction().set_file_id(file_id).set_contents(content).set_chunk_size(100).set_max_chunks(10) + + assert tx1.file_id == file_id + assert tx1.contents == content.encode("utf-8") + assert tx1.chunk_size == 100 + assert tx1.max_chunks == 10 + + tx_bytes = tx1.to_bytes() + + assert tx_bytes is not None + + tx2 = Transaction.from_bytes(tx_bytes) + + assert isinstance(tx2, FileAppendTransaction) + assert not tx2._transaction_body_bytes + assert tx2.file_id == tx1.file_id + assert tx2.contents == tx1.contents + + # These values are not serialized because they are not part of FileAppendTransactionBody they should fallback to default values. + assert tx2.chunk_size != tx1.chunk_size + assert tx2.chunk_size == 4096 + assert tx2.max_chunks != tx1.max_chunks + assert tx2.max_chunks == 20 + + +def test_serialization_chunk_transaction_non_freeze(file_id): + """Test that FileAppendTransaction can serialize non frozen and chunk transaction""" + content = "A" * 8192 # create 2 chucks + + tx1 = FileAppendTransaction().set_file_id(file_id).set_contents(content) + + assert tx1.file_id == file_id + assert tx1.contents == content.encode("utf-8") + + tx_bytes = tx1.to_bytes() + + assert tx_bytes is not None + + tx2 = Transaction.from_bytes(tx_bytes) + + assert isinstance(tx2, FileAppendTransaction) + assert not tx2._transaction_body_bytes + assert tx2.file_id == tx1.file_id + assert tx2.contents == tx1.contents + + +def test_serialization_non_chunk_transaction_freeze(file_id): + """Test that FileAppendTransaction can serialize frozen and non chunk transaction""" + transaction_id = TransactionId.generate(AccountId(0, 0, 3)) + content = "Hello! Hiero" + + tx1 = ( + FileAppendTransaction() + .set_file_id(file_id) + .set_contents(content) + .set_chunk_size(100) + .set_max_chunks(10) + .set_transaction_id(transaction_id) + .set_node_account_ids([AccountId(0, 0, 4), AccountId(0, 0, 5)]) + .freeze() + ) + + assert tx1.file_id == file_id + assert tx1.contents == content.encode("utf-8") + assert tx1.chunk_size == 100 + assert tx1.max_chunks == 10 + + assert tx1._transaction_body_bytes + + assert tx1.transaction_id == transaction_id + assert len(tx1._transaction_ids) == 1 # single chunk + + tx_bytes = tx1.to_bytes() + + assert tx_bytes is not None + + tx2 = Transaction.from_bytes(tx_bytes) + + assert isinstance(tx2, FileAppendTransaction) + assert tx2.file_id == tx1.file_id + assert tx2.contents == tx1.contents + + assert tx2.chunk_size != tx1.chunk_size + assert tx2.chunk_size == 4096 + assert tx2.max_chunks != tx1.max_chunks + assert tx2.max_chunks == 20 + + assert tx2._transaction_body_bytes + + assert tx2.transaction_id == transaction_id + assert len(tx2._transaction_ids) == 1 # single chunk + assert tx2._transaction_ids == tx1._transaction_ids + assert tx2.node_account_ids == tx1.node_account_ids + + +def test_serialization_chunk_transaction_freeze(file_id): + """Test that FileAppendTransaction can serialize frozen and chunk transaction""" + transaction_id = TransactionId.generate(AccountId(0, 0, 3)) + content = "A" * 8192 # create 2 chucks + + tx1 = ( + FileAppendTransaction() + .set_file_id(file_id) + .set_contents(content) + .set_transaction_id(transaction_id) + .set_node_account_ids([AccountId(0, 0, 4), AccountId(0, 0, 5)]) + .freeze() + ) + + assert tx1.file_id == file_id + assert tx1.contents == content.encode("utf-8") + + assert tx1._transaction_body_bytes + + assert tx1.transaction_id == transaction_id + assert len(tx1._transaction_ids) == 2 # 2 chunks + + tx_bytes = tx1.to_bytes() + + assert tx_bytes is not None + + tx2 = Transaction.from_bytes(tx_bytes) + + assert isinstance(tx2, FileAppendTransaction) + assert tx2.file_id == tx1.file_id + + # On freeze, the chunked tx content will be only the first chunk content. the chunk execution is handle by the _transaction_bytes and _transaction_ids + assert tx2.contents != tx1.contents + assert tx2.contents == tx1.contents[0:4096] + + assert tx2._transaction_body_bytes + + assert tx2.transaction_id == transaction_id + assert len(tx2._transaction_ids) == 2 # 2 chunk + assert tx2._transaction_ids == tx1._transaction_ids + assert tx2.node_account_ids == tx1.node_account_ids diff --git a/tests/unit/file_delete_transaction_test.py b/tests/unit/file_delete_transaction_test.py index 2aa95840f..5fb6a3123 100644 --- a/tests/unit/file_delete_transaction_test.py +++ b/tests/unit/file_delete_transaction_test.py @@ -74,7 +74,7 @@ def test_sign_transaction(mock_client, file_id): delete_tx.sign(private_key) node_id = mock_client.network.current_node._account_id - body_bytes = delete_tx._transaction_body_bytes[node_id] + body_bytes = delete_tx._transaction_body_bytes[delete_tx.transaction_id][node_id] assert len(delete_tx._signature_map[body_bytes].sigPair) == 1 sig_pair = delete_tx._signature_map[body_bytes].sigPair[0] diff --git a/tests/unit/file_update_transaction_test.py b/tests/unit/file_update_transaction_test.py index cf0a71840..9f4a42378 100644 --- a/tests/unit/file_update_transaction_test.py +++ b/tests/unit/file_update_transaction_test.py @@ -264,7 +264,7 @@ def test_sign_transaction(mock_client, file_id): file_tx.sign(private_key) node_id = mock_client.network.current_node._account_id - body_bytes = file_tx._transaction_body_bytes[node_id] + body_bytes = file_tx._transaction_body_bytes[file_tx.transaction_id][node_id] assert len(file_tx._signature_map[body_bytes].sigPair) == 1 sig_pair = file_tx._signature_map[body_bytes].sigPair[0] diff --git a/tests/unit/freeze_transaction_test.py b/tests/unit/freeze_transaction_test.py index 026545b9f..78aa747bc 100644 --- a/tests/unit/freeze_transaction_test.py +++ b/tests/unit/freeze_transaction_test.py @@ -237,7 +237,7 @@ def test_sign_transaction(mock_client): freeze_tx.sign(private_key) node_id = mock_client.network.current_node._account_id - body_bytes = freeze_tx._transaction_body_bytes[node_id] + body_bytes = freeze_tx._transaction_body_bytes[freeze_tx.transaction_id][node_id] assert len(freeze_tx._signature_map[body_bytes].sigPair) == 1 sig_pair = freeze_tx._signature_map[body_bytes].sigPair[0] diff --git a/tests/unit/node_create_transaction_test.py b/tests/unit/node_create_transaction_test.py index 6c768d346..c35cd6583 100644 --- a/tests/unit/node_create_transaction_test.py +++ b/tests/unit/node_create_transaction_test.py @@ -307,7 +307,7 @@ def test_sign_transaction(mock_client): node_tx.sign(private_key) node_id = mock_client.network.current_node._account_id - body_bytes = node_tx._transaction_body_bytes[node_id] + body_bytes = node_tx._transaction_body_bytes[node_tx.transaction_id][node_id] assert len(node_tx._signature_map[body_bytes].sigPair) == 1 sig_pair = node_tx._signature_map[body_bytes].sigPair[0] diff --git a/tests/unit/node_delete_transaction_test.py b/tests/unit/node_delete_transaction_test.py index 621075197..61bf6c857 100644 --- a/tests/unit/node_delete_transaction_test.py +++ b/tests/unit/node_delete_transaction_test.py @@ -151,7 +151,7 @@ def test_sign_transaction(mock_client, node_id): node_tx.sign(private_key) node_id_key = mock_client.network.current_node._account_id - body_bytes = node_tx._transaction_body_bytes[node_id_key] + body_bytes = node_tx._transaction_body_bytes[node_tx.transaction_id][node_id_key] assert len(node_tx._signature_map[body_bytes].sigPair) == 1 sig_pair = node_tx._signature_map[body_bytes].sigPair[0] diff --git a/tests/unit/node_update_transaction_test.py b/tests/unit/node_update_transaction_test.py index b5bc95a43..6a5056611 100644 --- a/tests/unit/node_update_transaction_test.py +++ b/tests/unit/node_update_transaction_test.py @@ -327,7 +327,7 @@ def test_sign_transaction(mock_client): node_tx.sign(private_key) node_id = mock_client.network.current_node._account_id - body_bytes = node_tx._transaction_body_bytes[node_id] + body_bytes = node_tx._transaction_body_bytes[node_tx.transaction_id][node_id] assert len(node_tx._signature_map[body_bytes].sigPair) == 1 sig_pair = node_tx._signature_map[body_bytes].sigPair[0] diff --git a/tests/unit/prng_transaction_test.py b/tests/unit/prng_transaction_test.py index ef9f2e45c..c0622f82e 100644 --- a/tests/unit/prng_transaction_test.py +++ b/tests/unit/prng_transaction_test.py @@ -109,7 +109,7 @@ def test_sign_transaction(mock_client, prng_params): prng_tx.sign(private_key) node_id = mock_client.network.current_node._account_id - body_bytes = prng_tx._transaction_body_bytes[node_id] + body_bytes = prng_tx._transaction_body_bytes[prng_tx.transaction_id][node_id] assert len(prng_tx._signature_map[body_bytes].sigPair) == 1 sig_pair = prng_tx._signature_map[body_bytes].sigPair[0] diff --git a/tests/unit/query_test.py b/tests/unit/query_test.py index 91cfe5f62..f57104ee2 100644 --- a/tests/unit/query_test.py +++ b/tests/unit/query_test.py @@ -149,7 +149,7 @@ def test_request_header_payment_zero(query, mock_client): """Test that payment field is not present in request header when payment amount is 0""" # Set up operator and node account ID from mock client query.operator = mock_client.operator - query.node_account_id = mock_client.network.current_node._account_id + query._node_account_id = mock_client.network.current_node._account_id # Test with payment amount set to 0 Hbar query.payment_amount = Hbar(0) @@ -160,7 +160,7 @@ def test_request_header_payment_zero(query, mock_client): def test_make_request_header_with_payment(query_requires_payment, mock_client): """Test making request header with payment transaction for queries that require payment""" query_requires_payment.operator = mock_client.operator - query_requires_payment.node_account_id = mock_client.network.current_node._account_id + query_requires_payment._node_account_id = mock_client.network.current_node._account_id query_requires_payment.set_query_payment(Hbar(1)) header = query_requires_payment._make_request_header() @@ -175,7 +175,7 @@ def test_make_request_header_with_payment(query_requires_payment, mock_client): def test_request_header_excludes_payment_for_free_query(query, mock_client): """Test that payment is not included in request header for queries that don't require payment""" query.operator = mock_client.operator - query.node_account_id = mock_client.network.current_node._account_id + query._node_account_id = mock_client.network.current_node._account_id # Set query payment to 1 Hbar query.set_query_payment(Hbar(1)) diff --git a/tests/unit/schedule_create_transaction_test.py b/tests/unit/schedule_create_transaction_test.py index 2354acd49..b9f0ab51c 100644 --- a/tests/unit/schedule_create_transaction_test.py +++ b/tests/unit/schedule_create_transaction_test.py @@ -242,7 +242,7 @@ def test_sign_transaction(mock_client): schedule_tx.sign(private_key) node_id = mock_client.network.current_node._account_id - body_bytes = schedule_tx._transaction_body_bytes[node_id] + body_bytes = schedule_tx._transaction_body_bytes[schedule_tx.transaction_id][node_id] assert len(schedule_tx._signature_map[body_bytes].sigPair) == 1 sig_pair = schedule_tx._signature_map[body_bytes].sigPair[0] diff --git a/tests/unit/schedule_delete_transaction_test.py b/tests/unit/schedule_delete_transaction_test.py index 65c4e9383..d4163f7d0 100644 --- a/tests/unit/schedule_delete_transaction_test.py +++ b/tests/unit/schedule_delete_transaction_test.py @@ -117,7 +117,7 @@ def test_sign_transaction(mock_client, delete_params): delete_tx.sign(private_key) node_id = mock_client.network.current_node._account_id - body_bytes = delete_tx._transaction_body_bytes[node_id] + body_bytes = delete_tx._transaction_body_bytes[delete_tx.transaction_id][node_id] assert len(delete_tx._signature_map[body_bytes].sigPair) == 1 sig_pair = delete_tx._signature_map[body_bytes].sigPair[0] diff --git a/tests/unit/schedule_sign_transaction_test.py b/tests/unit/schedule_sign_transaction_test.py index ce3a67fd5..fd874765a 100644 --- a/tests/unit/schedule_sign_transaction_test.py +++ b/tests/unit/schedule_sign_transaction_test.py @@ -130,7 +130,7 @@ def test_sign_transaction(mock_client, schedule_id): schedule_sign_tx.sign(private_key) node_id = mock_client.network.current_node._account_id - body_bytes = schedule_sign_tx._transaction_body_bytes[node_id] + body_bytes = schedule_sign_tx._transaction_body_bytes[schedule_sign_tx.transaction_id][node_id] assert len(schedule_sign_tx._signature_map[body_bytes].sigPair) == 1 sig_pair = schedule_sign_tx._signature_map[body_bytes].sigPair[0] diff --git a/tests/unit/token_airdrop_transaction_cancel_test.py b/tests/unit/token_airdrop_transaction_cancel_test.py index ac84eb223..274f41798 100644 --- a/tests/unit/token_airdrop_transaction_cancel_test.py +++ b/tests/unit/token_airdrop_transaction_cancel_test.py @@ -217,7 +217,7 @@ def test_sign_transaction(mock_account_ids, mock_client): cancel_airdrop_tx.sign(private_key) node_id = mock_client.network.current_node._account_id - body_bytes = cancel_airdrop_tx._transaction_body_bytes[node_id] + body_bytes = cancel_airdrop_tx._transaction_body_bytes[cancel_airdrop_tx.transaction_id][node_id] assert body_bytes in cancel_airdrop_tx._signature_map, "Body bytes should be a key in the signature map dictionary" assert len(cancel_airdrop_tx._signature_map[body_bytes].sigPair) == 1 diff --git a/tests/unit/token_airdrop_transaction_test.py b/tests/unit/token_airdrop_transaction_test.py index db2ce86f4..cae3063d9 100644 --- a/tests/unit/token_airdrop_transaction_test.py +++ b/tests/unit/token_airdrop_transaction_test.py @@ -161,24 +161,6 @@ def test_add_zero_transfer_amount(mock_account_ids): airdrop_tx.add_approved_token_transfer_with_decimals(token_id, account_id, 0, 1) -def test_add_unbalanced_transfer_amount(mock_account_ids): - sender, receiver, _, token_id, _ = mock_account_ids - airdrop_tx = TokenAirdropTransaction() - airdrop_tx.add_token_transfer(token_id, sender, -1) - airdrop_tx.add_token_transfer(token_id, receiver, -2) - - with pytest.raises(ValueError): - airdrop_tx.build_transaction_body() - - -def test_add_invalid_transfer(mock_account_ids): - _, _, _, _, _ = mock_account_ids - airdrop_tx = TokenAirdropTransaction() - - with pytest.raises(ValueError): - airdrop_tx.build_transaction_body() - - def test_sign_transaction(mock_account_ids, mock_client): """Test signing the token airdrop transaction with a private key.""" sender, receiver, _, token_id_1, token_id_2 = mock_account_ids @@ -204,7 +186,7 @@ def test_sign_transaction(mock_account_ids, mock_client): airdrop_tx.sign(private_key) node_id = mock_client.network.current_node._account_id - body_bytes = airdrop_tx._transaction_body_bytes[node_id] + body_bytes = airdrop_tx._transaction_body_bytes[airdrop_tx.transaction_id][node_id] assert body_bytes in airdrop_tx._signature_map, "Body bytes should be a key in the signature map dictionary" assert len(airdrop_tx._signature_map[body_bytes].sigPair) == 1 diff --git a/tests/unit/token_associate_transaction_test.py b/tests/unit/token_associate_transaction_test.py index 0ff7897e3..f65bd7bc3 100644 --- a/tests/unit/token_associate_transaction_test.py +++ b/tests/unit/token_associate_transaction_test.py @@ -81,7 +81,7 @@ def test_sign_transaction(mock_account_ids, mock_client): associate_tx.sign(private_key) node_id = mock_client.network.current_node._account_id - body_bytes = associate_tx._transaction_body_bytes[node_id] + body_bytes = associate_tx._transaction_body_bytes[associate_tx.transaction_id][node_id] assert body_bytes in associate_tx._signature_map, "Body bytes should be a key in the signature map dictionary" assert len(associate_tx._signature_map[body_bytes].sigPair) == 1 diff --git a/tests/unit/token_create_transaction_test.py b/tests/unit/token_create_transaction_test.py index bac9bf40b..0fbca7265 100644 --- a/tests/unit/token_create_transaction_test.py +++ b/tests/unit/token_create_transaction_test.py @@ -241,7 +241,7 @@ def test_sign_transaction(mock_account_ids, mock_client): token_tx.sign(private_key_admin) # Since admin key exists node_id = mock_client.network.current_node._account_id - body_bytes = token_tx._transaction_body_bytes[node_id] + body_bytes = token_tx._transaction_body_bytes[token_tx.transaction_id][node_id] # Expect 2 sigPairs assert len(token_tx._signature_map[body_bytes].sigPair) == 2 diff --git a/tests/unit/token_delete_transaction_test.py b/tests/unit/token_delete_transaction_test.py index bb961eb4b..406350d08 100644 --- a/tests/unit/token_delete_transaction_test.py +++ b/tests/unit/token_delete_transaction_test.py @@ -70,7 +70,7 @@ def test_sign_transaction(mock_account_ids, mock_client): delete_tx.sign(private_key) node_id = mock_client.network.current_node._account_id - body_bytes = delete_tx._transaction_body_bytes[node_id] + body_bytes = delete_tx._transaction_body_bytes[delete_tx.transaction_id][node_id] assert len(delete_tx._signature_map[body_bytes].sigPair) == 1 sig_pair = delete_tx._signature_map[body_bytes].sigPair[0] diff --git a/tests/unit/token_dissociate_transaction_test.py b/tests/unit/token_dissociate_transaction_test.py index 5d920b26d..9749cd1ec 100644 --- a/tests/unit/token_dissociate_transaction_test.py +++ b/tests/unit/token_dissociate_transaction_test.py @@ -127,7 +127,7 @@ def test_sign_transaction(mock_account_ids, mock_client): dissociate_tx.sign(private_key) node_id = mock_client.network.current_node._account_id - body_bytes = dissociate_tx._transaction_body_bytes[node_id] + body_bytes = dissociate_tx._transaction_body_bytes[dissociate_tx.transaction_id][node_id] assert len(dissociate_tx._signature_map[body_bytes].sigPair) == 1 sig_pair = dissociate_tx._signature_map[body_bytes].sigPair[0] diff --git a/tests/unit/token_freeze_transaction_test.py b/tests/unit/token_freeze_transaction_test.py index 3cddaf765..6c77f4e51 100644 --- a/tests/unit/token_freeze_transaction_test.py +++ b/tests/unit/token_freeze_transaction_test.py @@ -90,7 +90,7 @@ def test_sign_transaction(mock_account_ids, mock_client): freeze_tx.sign(freeze_key) node_id = mock_client.network.current_node._account_id - body_bytes = freeze_tx._transaction_body_bytes[node_id] + body_bytes = freeze_tx._transaction_body_bytes[freeze_tx.transaction_id][node_id] assert len(freeze_tx._signature_map[body_bytes].sigPair) == 1 sig_pair = freeze_tx._signature_map[body_bytes].sigPair[0] diff --git a/tests/unit/token_mint_transaction_test.py b/tests/unit/token_mint_transaction_test.py index 6b3567071..3cdbe4aaa 100644 --- a/tests/unit/token_mint_transaction_test.py +++ b/tests/unit/token_mint_transaction_test.py @@ -153,7 +153,7 @@ def test_sign_transaction_fungible(mock_account_ids, amount, mock_client): mint_tx.sign(supply_key) node_id = mock_client.network.current_node._account_id - body_bytes = mint_tx._transaction_body_bytes[node_id] + body_bytes = mint_tx._transaction_body_bytes[mint_tx.transaction_id][node_id] assert len(mint_tx._signature_map[body_bytes].sigPair) == 1 sig_pair = mint_tx._signature_map[body_bytes].sigPair[0] @@ -180,7 +180,7 @@ def test_sign_transaction_nft(mock_account_ids, metadata, mock_client): mint_tx.sign(supply_key) node_id = mock_client.network.current_node._account_id - body_bytes = mint_tx._transaction_body_bytes[node_id] + body_bytes = mint_tx._transaction_body_bytes[mint_tx.transaction_id][node_id] assert len(mint_tx._signature_map[body_bytes].sigPair) == 1 sig_pair = mint_tx._signature_map[body_bytes].sigPair[0] diff --git a/tests/unit/token_pause_transaction_test.py b/tests/unit/token_pause_transaction_test.py index 6881eb4f8..07917b7b5 100644 --- a/tests/unit/token_pause_transaction_test.py +++ b/tests/unit/token_pause_transaction_test.py @@ -33,11 +33,9 @@ def test_build_transaction_body(mock_account_ids, token_id): pause_tx.operator_account_id = account_id pause_tx.node_account_id = node_account_id - transaction_body = pause_tx.build_transaction_body() # Will generate a transaction_id + transaction_body = pause_tx.build_transaction_body() assert transaction_body.token_pause.token == token_id._to_proto() - assert transaction_body.transactionID == pause_tx.transaction_id._to_proto() - assert transaction_body.nodeAccountID == pause_tx.node_account_id._to_proto() def test_build_transaction_body_nft(mock_account_ids, nft_id): @@ -54,8 +52,6 @@ def test_build_transaction_body_nft(mock_account_ids, nft_id): transaction_body = pause_tx.build_transaction_body() assert transaction_body.token_pause.token == base_token_id._to_proto() - assert transaction_body.transactionID == pause_tx.transaction_id._to_proto() - assert transaction_body.nodeAccountID == pause_tx.node_account_id._to_proto() # This test uses fixture (token_id, mock_client) as parameter diff --git a/tests/unit/token_unfreeze_transaction_test.py b/tests/unit/token_unfreeze_transaction_test.py index 2d0232acf..947444ebc 100644 --- a/tests/unit/token_unfreeze_transaction_test.py +++ b/tests/unit/token_unfreeze_transaction_test.py @@ -86,7 +86,7 @@ def test_sign_transaction(mock_account_ids, mock_client): unfreeze_tx.sign(freeze_key) node_id = mock_client.network.current_node._account_id - body_bytes = unfreeze_tx._transaction_body_bytes[node_id] + body_bytes = unfreeze_tx._transaction_body_bytes[unfreeze_tx.transaction_id][node_id] assert len(unfreeze_tx._signature_map[body_bytes].sigPair) == 1 sig_pair = unfreeze_tx._signature_map[body_bytes].sigPair[0] diff --git a/tests/unit/token_unpause_transaction_test.py b/tests/unit/token_unpause_transaction_test.py index d6c57af7d..4b00c68fc 100644 --- a/tests/unit/token_unpause_transaction_test.py +++ b/tests/unit/token_unpause_transaction_test.py @@ -115,7 +115,7 @@ def test_sign_transaction(mock_account_ids, mock_client): unpause_tx.sign(private_key) node_id = mock_client.network.current_node._account_id - body_bytes = unpause_tx._transaction_body_bytes[node_id] + body_bytes = unpause_tx._transaction_body_bytes[unpause_tx.transaction_id][node_id] assert len(unpause_tx._signature_map[body_bytes].sigPair) == 1 sig_pair = unpause_tx._signature_map[body_bytes].sigPair[0] diff --git a/tests/unit/topic_create_transaction_test.py b/tests/unit/topic_create_transaction_test.py index b6722899d..7e5a39660 100644 --- a/tests/unit/topic_create_transaction_test.py +++ b/tests/unit/topic_create_transaction_test.py @@ -220,43 +220,21 @@ def test_build_scheduled_body(mock_account_ids, custom_fixed_fee, key_type, use_ ) -# This test uses fixture mock_account_ids as parameter -def test_missing_operator_in_topic_create(mock_account_ids): - """ - Test that building the body fails if no operator ID is set. - """ - _, _, node_account_id, _, _ = mock_account_ids - - tx = TopicCreateTransaction(memo="No Operator") - tx.node_account_id = node_account_id - - with pytest.raises(ValueError, match="Operator account ID is not set."): - tx.build_transaction_body() - - -def test_missing_node_in_topic_create(): - """ - Test that building the body fails if no node account ID is set. - """ - tx = TopicCreateTransaction(memo="No Node") - tx.operator_account_id = AccountId(0, 0, 2) - - with pytest.raises(ValueError, match="Node account ID is not set."): - tx.build_transaction_body() - - # This test uses fixtures (mock_account_ids, private_key) as parameters def test_sign_topic_create_transaction(mock_account_ids, private_key): """ Test signing the TopicCreateTransaction with a private key. """ - _, _, node_account_id, _, _ = mock_account_ids + opertor_id, _, node_account_id, _, _ = mock_account_ids + transaction_id = TransactionId.generate(opertor_id) + tx = TopicCreateTransaction(memo="Signing test") tx.operator_account_id = AccountId(0, 0, 2) tx.node_account_id = node_account_id + tx.transaction_id = transaction_id body_bytes = tx.build_transaction_body().SerializeToString() - tx._transaction_body_bytes.setdefault(node_account_id, body_bytes) + tx._transaction_body_bytes.setdefault(transaction_id, dict(node_account_id=body_bytes)) tx.sign(private_key) assert len(tx._signature_map[body_bytes].sigPair) == 1 diff --git a/tests/unit/topic_delete_transaction_test.py b/tests/unit/topic_delete_transaction_test.py index 925c212fe..00b2adf24 100644 --- a/tests/unit/topic_delete_transaction_test.py +++ b/tests/unit/topic_delete_transaction_test.py @@ -17,6 +17,7 @@ SchedulableTransactionBody, ) from hiero_sdk_python.response_code import ResponseCode +from hiero_sdk_python.transaction.transaction_id import TransactionId from tests.unit.mock_server import mock_hedera_servers @@ -73,13 +74,15 @@ def test_missing_topic_id_in_delete(mock_account_ids): # This test uses fixtures (mock_account_ids, topic_id, private_key) as parameters def test_sign_topic_delete_transaction(mock_account_ids, topic_id, private_key): """Test signing the TopicDeleteTransaction with a private key.""" - _, _, node_account_id, _, _ = mock_account_ids + operator_id, _, node_account_id, _, _ = mock_account_ids + transaction_id = TransactionId.generate(operator_id) + tx = TopicDeleteTransaction(topic_id=topic_id) tx.operator_account_id = AccountId(0, 0, 2) tx.node_account_id = node_account_id body_bytes = tx.build_transaction_body().SerializeToString() - tx._transaction_body_bytes.setdefault(node_account_id, body_bytes) + tx._transaction_body_bytes.setdefault(transaction_id, dict(node_account_id=body_bytes)) tx.sign(private_key) assert len(tx._signature_map[body_bytes].sigPair) == 1 diff --git a/tests/unit/topic_message_submit_transaction_test.py b/tests/unit/topic_message_submit_transaction_test.py index 7d95527ea..da0fd5af9 100644 --- a/tests/unit/topic_message_submit_transaction_test.py +++ b/tests/unit/topic_message_submit_transaction_test.py @@ -8,6 +8,7 @@ from hiero_sdk_python.account.account_id import AccountId from hiero_sdk_python.consensus.topic_message_submit_transaction import TopicMessageSubmitTransaction +from hiero_sdk_python.crypto.private_key import PrivateKey from hiero_sdk_python.exceptions import PrecheckError, ReceiptStatusError from hiero_sdk_python.hapi.services import ( response_header_pb2, @@ -22,6 +23,7 @@ ) from hiero_sdk_python.response_code import ResponseCode from hiero_sdk_python.transaction.custom_fee_limit import CustomFeeLimit +from hiero_sdk_python.transaction.transaction import Transaction from hiero_sdk_python.transaction.transaction_id import TransactionId from hiero_sdk_python.transaction.transaction_receipt import TransactionReceipt from hiero_sdk_python.transaction.transaction_response import TransactionResponse @@ -566,3 +568,165 @@ def test_execute_raises_when_message_is_empty(topic_id, mock_client): with pytest.raises(ValueError, match="Missing required fields: message"): transaction.freeze_with(mock_client) + + +def test_frezee_with_client_generate_all_transaction_body_bytes(topic_id, mock_client): + """Test freeze_with() generate all required transaction body bytes.""" + message = "A" * 40 # will create 4 chunks + + transaction = TopicMessageSubmitTransaction().set_topic_id(topic_id).set_chunk_size(10).set_message(message) + + assert len(transaction._transaction_ids) == 0 + assert not transaction._transaction_body_bytes + + transaction.freeze_with(mock_client) + + # generated transaction ids == number of chunks i.e 4 (40/10) + assert len(transaction._transaction_ids) == 4 + + transaction_ids = transaction._transaction_ids + initial_transaction = transaction_ids[0] + + for i, _ in enumerate(transaction_ids): + assert transaction_ids[i].account_id == initial_transaction.account_id + assert transaction_ids[i].valid_start.nanos == initial_transaction.valid_start.nanos + i + + # create body bytes for each transaction_id and every node_account_ids + assert len(transaction._transaction_body_bytes) == 4 + for transaction_id in transaction._transaction_ids: + assert len(transaction._transaction_body_bytes[transaction_id]) == len(mock_client.network.nodes) + + +def test_manual_frezee_generate_all_transaction_body_bytes(topic_id): + """Test freeze() generate all required transaction body bytes.""" + transaction_id = TransactionId.generate(AccountId(0, 0, 1)) + node_account_ids = [AccountId(0, 0, 4), AccountId(0, 0, 5)] + + message = "A" * 20 # will create 2 chunks + + transaction = ( + TopicMessageSubmitTransaction() + .set_topic_id(topic_id) + .set_chunk_size(10) + .set_message(message) + .set_transaction_id(transaction_id) + .set_node_account_ids(node_account_ids) + ) + + # single transaction_id which is set + assert len(transaction._transaction_ids) == 1 + assert not transaction._transaction_body_bytes + + transaction.freeze() + + # generated transaction ids == number of chunks i.e 2 (20/10) + assert len(transaction._transaction_ids) == 2 + + transaction_ids = transaction._transaction_ids + + assert transaction_ids[0] == transaction_id + + for i, _ in enumerate(transaction_ids): + assert transaction_ids[i].account_id == transaction_id.account_id + assert transaction_ids[i].valid_start.nanos == transaction_id.valid_start.nanos + i + + # create body bytes for each transaction_id and every node_account_ids + assert len(transaction._transaction_body_bytes) == 2 + for transaction_id in transaction._transaction_ids: + assert len(transaction._transaction_body_bytes[transaction_id]) == len(node_account_ids) + + +def test_serialize_chunk_transaction_preserve_signature_map(topic_id): + """Test serialize chunk transaction preserve signature maps.""" + transaction_id = TransactionId.generate(AccountId(0, 0, 1)) + node_account_ids = [AccountId(0, 0, 4), AccountId(0, 0, 5)] + key = PrivateKey.generate_ecdsa() + + message = "A" * 20 # will create 2 chunks + + tx1 = ( + TopicMessageSubmitTransaction() + .set_topic_id(topic_id) + .set_chunk_size(10) + .set_message(message) + .set_transaction_id(transaction_id) + .set_node_account_ids(node_account_ids) + .freeze() + .sign(key) + ) + + assert tx1._signature_map + + for transaction_id in tx1._transaction_ids: + for node_id in node_account_ids: + body_bytes = tx1._transaction_body_bytes[transaction_id][node_id] + sig_pairs = tx1._signature_map[body_bytes].sigPair + + assert len(sig_pairs) == 1 + + pubkey_prefixes = {sp.pubKeyPrefix for sp in sig_pairs} + assert pubkey_prefixes == {key.public_key().to_bytes_raw()} + + # will create transaction_bytes like + # {tx_id1: {node_id1: bytes, node_id2: bytes}, tx_id2: {node_id1: bytes, node_id2: bytes}} + + tx_bytes = tx1.to_bytes() + tx2 = Transaction.from_bytes(tx_bytes) + + assert isinstance(tx2, TopicMessageSubmitTransaction) + + assert tx2._signature_map + + for transaction_id in tx2._transaction_ids: + for node_id in node_account_ids: + body_bytes = tx2._transaction_body_bytes[transaction_id][node_id] + sig_pairs = tx2._signature_map[body_bytes].sigPair + + assert len(sig_pairs) == 1 + + pubkey_prefixes = {sp.pubKeyPrefix for sp in sig_pairs} + assert pubkey_prefixes == {key.public_key().to_bytes_raw()} + + +def test_signing_serialize_chunk_transaction_sign_all_available_bytes(topic_id): + """Test that signing the serialize chunk transaction sign all available bytes.""" + transaction_id = TransactionId.generate(AccountId(0, 0, 1)) + node_account_ids = [AccountId(0, 0, 4), AccountId(0, 0, 5)] + + message = "A" * 20 # will create 2 chunks + + tx1 = ( + TopicMessageSubmitTransaction() + .set_topic_id(topic_id) + .set_chunk_size(10) + .set_message(message) + .set_transaction_id(transaction_id) + .set_node_account_ids(node_account_ids) + .freeze() + ) + + assert not tx1._signature_map + + # will create transaction_bytes like + # {tx_id1: {node_id1: bytes, node_id2: bytes}, tx_id2: {node_id1: bytes, node_id2: bytes}} + + tx_bytes = tx1.to_bytes() + tx2 = Transaction.from_bytes(tx_bytes) + + assert isinstance(tx2, TopicMessageSubmitTransaction) + + key = PrivateKey.generate_ecdsa() + + tx2.sign(key) + + assert tx2._signature_map + + for transaction_id in tx2._transaction_ids: + for node_id in node_account_ids: + body_bytes = tx2._transaction_body_bytes[transaction_id][node_id] + sig_pairs = tx2._signature_map[body_bytes].sigPair + + assert len(sig_pairs) == 1 + + pubkey_prefixes = {sp.pubKeyPrefix for sp in sig_pairs} + assert pubkey_prefixes == {key.public_key().to_bytes_raw()} diff --git a/tests/unit/topic_update_transaction_test.py b/tests/unit/topic_update_transaction_test.py index 24b1d7dd6..363f920cc 100644 --- a/tests/unit/topic_update_transaction_test.py +++ b/tests/unit/topic_update_transaction_test.py @@ -22,6 +22,7 @@ ) from hiero_sdk_python.response_code import ResponseCode from hiero_sdk_python.tokens.custom_fixed_fee import CustomFixedFee +from hiero_sdk_python.transaction.transaction_id import TransactionId from tests.unit.mock_server import mock_hedera_servers @@ -274,13 +275,15 @@ def test_missing_topic_id_in_update(mock_account_ids): # This test uses fixtures (mock_account_ids, topic_id, private_key) as parameters def test_sign_topic_update_transaction(mock_account_ids, topic_id, private_key): """Test signing the TopicUpdateTransaction with a private key.""" - _, _, node_account_id, _, _ = mock_account_ids + operator_id, _, node_account_id, _, _ = mock_account_ids + transaction_id = TransactionId.generate(operator_id) + tx = TopicUpdateTransaction(topic_id=topic_id, memo="Signature test") tx.operator_account_id = AccountId(0, 0, 2) tx.node_account_id = node_account_id body_bytes = tx.build_transaction_body().SerializeToString() - tx._transaction_body_bytes.setdefault(node_account_id, body_bytes) + tx._transaction_body_bytes.setdefault(transaction_id, dict(node_account_id=body_bytes)) tx.sign(private_key) assert len(tx._signature_map[body_bytes].sigPair) == 1 diff --git a/tests/unit/transaction_freeze_and_bytes_test.py b/tests/unit/transaction_freeze_and_bytes_test.py index c61302b39..4b850f404 100644 --- a/tests/unit/transaction_freeze_and_bytes_test.py +++ b/tests/unit/transaction_freeze_and_bytes_test.py @@ -10,11 +10,13 @@ import pytest +from hiero_sdk_python.account.account_create_transaction import AccountCreateTransaction from hiero_sdk_python.account.account_id import AccountId from hiero_sdk_python.crypto.private_key import PrivateKey from hiero_sdk_python.hapi.services.transaction_response_pb2 import ( TransactionResponse as TransactionResponseProto, ) +from hiero_sdk_python.transaction.transaction import Transaction from hiero_sdk_python.transaction.transaction_id import TransactionId from hiero_sdk_python.transaction.transfer_transaction import TransferTransaction @@ -45,12 +47,13 @@ def test_freeze_with_valid_parameters(): operator_id = AccountId.from_string("0.0.1234") node_id = AccountId.from_string("0.0.3") receiver_id = AccountId.from_string("0.0.5678") + transaction_id = TransactionId.generate(operator_id) transaction = ( TransferTransaction().add_hbar_transfer(operator_id, -100_000_000).add_hbar_transfer(receiver_id, 100_000_000) ) - transaction.transaction_id = TransactionId.generate(operator_id) + transaction.transaction_id = transaction_id transaction.node_account_id = node_id # Should not raise any errors @@ -61,7 +64,7 @@ def test_freeze_with_valid_parameters(): # Should have transaction body bytes set assert len(transaction._transaction_body_bytes) > 0 - assert node_id in transaction._transaction_body_bytes + assert transaction.transaction_id in transaction._transaction_body_bytes def test_freeze_is_idempotent(): @@ -86,14 +89,6 @@ def test_freeze_is_idempotent(): assert len(transaction._transaction_body_bytes) > 0 -def test_to_bytes_requires_frozen_transaction(): - """Test that to_bytes() raises error if transaction is not frozen.""" - transaction = TransferTransaction() - - with pytest.raises(Exception, match="Transaction is not frozen"): - transaction.to_bytes() - - def test_to_bytes_returns_bytes(): """Test that to_bytes() returns bytes after freezing and signing.""" operator_id = AccountId.from_string("0.0.1234") @@ -470,7 +465,7 @@ def test_freeze_only_builds_for_single_node(): # Should only have one node in the transaction body bytes map assert len(transaction._transaction_body_bytes) == 1 - assert node_id in transaction._transaction_body_bytes + assert transaction.transaction_id in transaction._transaction_body_bytes def test_signed_and_unsigned_bytes_are_different(): @@ -538,33 +533,6 @@ def test_multiple_signatures_increase_size(): assert len(bytes_3_sig) > len(bytes_2_sig) -def test_changing_node_after_freeze_fails_for_to_bytes(): - """Test that changing node_account_id after freeze causes to_bytes() to fail.""" - operator_id = AccountId.from_string("0.0.1234") - node_id_1 = AccountId.from_string("0.0.3") - node_id_2 = AccountId.from_string("0.0.4") - receiver_id = AccountId.from_string("0.0.5678") - - transaction = ( - TransferTransaction().add_hbar_transfer(operator_id, -100_000_000).add_hbar_transfer(receiver_id, 100_000_000) - ) - - transaction.transaction_id = TransactionId.generate(operator_id) - transaction.node_account_id = node_id_1 - transaction.freeze() - - # This should work - bytes_node_1 = transaction.to_bytes() - assert isinstance(bytes_node_1, bytes) - - # Change to a different node that wasn't frozen - transaction.node_account_id = node_id_2 - - # This should fail - no transaction body for node_id_2 - with pytest.raises(ValueError, match="No transaction body found for node"): - transaction.to_bytes() - - def test_unsigned_transaction_can_be_signed_after_to_bytes(): """Test that you can call to_bytes(), then sign, then to_bytes() again.""" operator_id = AccountId.from_string("0.0.1234") @@ -608,7 +576,8 @@ def test_transaction_freeze_with_node_ids(mock_client): assert tx.node_account_ids == [single_node_id] # Verify creates transaction_bytes for single node_id assert len(tx._transaction_body_bytes) == 1 - assert set(tx._transaction_body_bytes.keys()) == {single_node_id} + assert set(tx._transaction_body_bytes.keys()) == {tx._transaction_ids[0]} + # TODO check fro node ids # Case 2 node_account_id list node_account_ids = [AccountId(0, 0, 3), AccountId(0, 0, 4)] @@ -619,8 +588,9 @@ def test_transaction_freeze_with_node_ids(mock_client): assert tx.node_account_ids == node_account_ids # Verify creates transaction_bytes for two node_ids - assert len(tx._transaction_body_bytes) == 2 - assert set(tx._transaction_body_bytes.keys()) == set(node_account_ids) + assert len(tx._transaction_body_bytes) == 1 + assert set(tx._transaction_body_bytes.keys()) == {tx._transaction_ids[0]} + # TODO check fro node ids def test_transaction_freeze_with_node_ids_without_client(): @@ -639,8 +609,10 @@ def test_transaction_freeze_with_node_ids_without_client(): assert tx.node_account_ids == [single_node_id] # Verify creates transaction_bytes for single node_id - assert len(tx._transaction_body_bytes) == 1 - assert set(tx._transaction_body_bytes.keys()) == {single_node_id} + assert len(tx._transaction_body_bytes) == len(tx._transaction_ids) + assert set(tx._transaction_body_bytes.keys()) == {tx.transaction_id} + + # TODO: check for node_ids # Case 2 node_account_id list node_account_ids = [AccountId(0, 0, 3), AccountId(0, 0, 4)] @@ -652,8 +624,9 @@ def test_transaction_freeze_with_node_ids_without_client(): assert tx.node_account_ids == node_account_ids # Verify creates transaction_bytes for two node_ids - assert len(tx._transaction_body_bytes) == 2 - assert set(tx._transaction_body_bytes.keys()) == set(node_account_ids) + assert len(tx._transaction_body_bytes) == 1 + assert set(tx._transaction_body_bytes.keys()) == {tx._transaction_ids[0]} + # TODO: check for the node ids def test_transaction_freeze_without_node_ids(mock_client): @@ -663,10 +636,12 @@ def test_transaction_freeze_without_node_ids(mock_client): tx = TransferTransaction() tx.freeze_with(mock_client) - assert tx.node_account_ids == [] + assert tx.node_account_ids == [node._account_id for node in mock_client.network.nodes] # Verify creates transaction_bytes for client network nodes - assert len(tx._transaction_body_bytes) == len(mock_client.network.nodes) - assert set(tx._transaction_body_bytes.keys()) == set(node._account_id for node in mock_client.network.nodes) + assert len(tx._transaction_body_bytes) == len(tx._transaction_ids) + assert set(tx._transaction_body_bytes.keys()) == set(tx_id for tx_id in tx._transaction_ids) + + # TODO: check for node_ids def test_map_response_raises_if_proto_request_is_not_transaction(): @@ -685,3 +660,220 @@ def test_map_response_raises_if_proto_request_is_not_transaction(): node_id=mock_node_id, proto_request=invalid_proto_request, ) + + +def test_to_bytes_with_base_fields(): + """Test serialization and deserialization of an unfrozen transaction with base fields""" + tx = ( + AccountCreateTransaction() + .set_key_without_alias(PrivateKey.generate_ecdsa()) + .set_initial_balance(1) + .set_account_memo("test_account") + ) + + restored_tx = Transaction.from_bytes(tx.to_bytes()) + + assert restored_tx.key.to_bytes() == tx.key.to_bytes() + assert restored_tx.initial_balance == tx.initial_balance + assert restored_tx.account_memo == tx.account_memo + assert restored_tx.transaction_id is None + assert len(restored_tx.node_account_ids) == 0 + assert not restored_tx._transaction_body_bytes + + +def test_to_bytes_without_freeze_multiple_node_account_ids(): + """Test serialization and deserialization of an unfrozen transaction with multiple node account IDs.""" + tx = ( + AccountCreateTransaction() + .set_key_without_alias(PrivateKey.generate_ecdsa()) + .set_initial_balance(1) + .set_account_memo("test_account") + .set_transaction_id(TransactionId.generate(AccountId(0, 0, 1))) + .set_node_account_ids([AccountId(0, 0, 3), AccountId(0, 0, 4)]) + ) + + restored_tx = Transaction.from_bytes(tx.to_bytes()) + + assert restored_tx.key.to_bytes() == tx.key.to_bytes() + assert restored_tx.initial_balance == tx.initial_balance + assert restored_tx.account_memo == tx.account_memo + assert restored_tx.transaction_id == tx.transaction_id + assert restored_tx.node_account_ids == tx.node_account_ids + assert not restored_tx._transaction_body_bytes + + +# Deprecated +def test_to_bytes_without_freeze_single_node_account_id(): + """Test serialization and deserialization of an unfrozen transaction with a single node account ID.""" + tx = ( + AccountCreateTransaction() + .set_key_without_alias(PrivateKey.generate_ecdsa()) + .set_initial_balance(1) + .set_account_memo("test_account") + .set_transaction_id(TransactionId.generate(AccountId(0, 0, 1))) + .set_node_account_id(AccountId(0, 0, 3)) + ) + + restored_tx = Transaction.from_bytes(tx.to_bytes()) + + assert restored_tx.key.to_bytes() == tx.key.to_bytes() + assert restored_tx.initial_balance == tx.initial_balance + assert restored_tx.account_memo == tx.account_memo + assert restored_tx.transaction_id == tx.transaction_id + assert restored_tx.node_account_ids == tx.node_account_ids + assert not restored_tx._transaction_body_bytes + + +def test_to_bytes_after_freeze_multiple_node_account_ids(): + """Test serialization and deserialization of a frozen transaction with multiple node account IDs.""" + tx = ( + AccountCreateTransaction() + .set_key_without_alias(PrivateKey.generate_ecdsa()) + .set_initial_balance(1) + .set_account_memo("test_account") + .set_transaction_id(TransactionId.generate(AccountId(0, 0, 1))) + .set_node_account_ids([AccountId(0, 0, 3), AccountId(0, 0, 4)]) + .freeze() + ) + + restored_tx = Transaction.from_bytes(tx.to_bytes()) + + assert restored_tx.key.to_bytes() == tx.key.to_bytes() + assert restored_tx.initial_balance == tx.initial_balance + assert restored_tx.account_memo == tx.account_memo + assert restored_tx.transaction_id == tx.transaction_id + assert restored_tx.node_account_ids == tx.node_account_ids + + assert restored_tx._transaction_body_bytes == tx._transaction_body_bytes + assert restored_tx._signature_map == tx._signature_map + + +# Deprecated +def test_to_bytes_after_freeze_single_node_account_id(): + """Test serialization and deserialization of a frozen transaction with a single node account ID.""" + tx = ( + AccountCreateTransaction() + .set_key_without_alias(PrivateKey.generate_ecdsa()) + .set_initial_balance(1) + .set_account_memo("test_account") + .set_transaction_id(TransactionId.generate(AccountId(0, 0, 1))) + .set_node_account_id(AccountId(0, 0, 3)) + .freeze() + ) + + restored_tx = Transaction.from_bytes(tx.to_bytes()) + + assert restored_tx.key.to_bytes() == tx.key.to_bytes() + assert restored_tx.initial_balance == tx.initial_balance + assert restored_tx.account_memo == tx.account_memo + assert restored_tx.transaction_id == tx.transaction_id + assert restored_tx.node_account_ids == tx.node_account_ids + + assert restored_tx._transaction_body_bytes == tx._transaction_body_bytes + assert restored_tx._signature_map == tx._signature_map + + +def test_to_bytes_after_freeze_and_sign_multiple_node_account_ids(): + """Test serialization and deserialization of a signed frozen transaction with multiple node account IDs.""" + private_key = PrivateKey.generate_ecdsa() + + tx = ( + AccountCreateTransaction() + .set_key_without_alias(private_key.public_key()) + .set_initial_balance(1) + .set_account_memo("test_account") + .set_transaction_id(TransactionId.generate(AccountId(0, 0, 1))) + .set_node_account_ids([AccountId(0, 0, 3), AccountId(0, 0, 4)]) + ) + + tx.freeze() + tx.sign(private_key) + + restored_tx = Transaction.from_bytes(tx.to_bytes()) + + assert restored_tx.key.to_bytes() == tx.key.to_bytes() + assert restored_tx.initial_balance == tx.initial_balance + assert restored_tx.account_memo == tx.account_memo + assert restored_tx.transaction_id == tx.transaction_id + assert restored_tx.node_account_ids == tx.node_account_ids + + assert restored_tx._transaction_body_bytes == tx._transaction_body_bytes + assert restored_tx._signature_map == tx._signature_map + + +# Deprecated +def test_to_bytes_after_freeze_and_sign_single_node_account_id(): + """Test serialization and deserialization of a signed frozen transaction with a single node account ID.""" + private_key = PrivateKey.generate_ecdsa() + + tx = ( + AccountCreateTransaction() + .set_key_without_alias(private_key.public_key()) + .set_initial_balance(1) + .set_account_memo("test_account") + .set_transaction_id(TransactionId.generate(AccountId(0, 0, 1))) + .set_node_account_id(AccountId(0, 0, 3)) + ) + + tx.freeze() + tx.sign(private_key) + + restored_tx = Transaction.from_bytes(tx.to_bytes()) + + assert restored_tx.key.to_bytes() == tx.key.to_bytes() + assert restored_tx.initial_balance == tx.initial_balance + assert restored_tx.account_memo == tx.account_memo + assert restored_tx.transaction_id == tx.transaction_id + assert restored_tx.node_account_ids == tx.node_account_ids + + assert restored_tx._transaction_body_bytes == tx._transaction_body_bytes + assert restored_tx._signature_map == tx._signature_map + + +def test_to_bytes_after_freeze_with_client(mock_client): + """Test serialization and deserialization of a transaction frozen with a client.""" + tx = ( + AccountCreateTransaction() + .set_key_without_alias(PrivateKey.generate_ecdsa()) + .set_initial_balance(1) + .set_account_memo("test_account") + ) + + tx.freeze_with(mock_client) + + restored_tx = Transaction.from_bytes(tx.to_bytes()) + + assert restored_tx.key.to_bytes() == tx.key.to_bytes() + assert restored_tx.initial_balance == tx.initial_balance + assert restored_tx.account_memo == tx.account_memo + assert restored_tx.transaction_id == tx.transaction_id + assert restored_tx.node_account_ids == tx.node_account_ids + + assert restored_tx._transaction_body_bytes == tx._transaction_body_bytes + assert restored_tx._signature_map == tx._signature_map + + +def test_to_bytes_after_freeze_with_client_and_sign_multiple_node_account_ids(mock_client): + """Test serialization and deserialization of a signed transaction frozen with a client.""" + private_key = PrivateKey.generate_ecdsa() + + tx = ( + AccountCreateTransaction() + .set_key_without_alias(private_key.public_key()) + .set_initial_balance(1) + .set_account_memo("test_account") + ) + + tx.freeze_with(mock_client) + tx.sign(private_key) + + restored_tx = Transaction.from_bytes(tx.to_bytes()) + + assert restored_tx.key.to_bytes() == tx.key.to_bytes() + assert restored_tx.initial_balance == tx.initial_balance + assert restored_tx.account_memo == tx.account_memo + assert restored_tx.transaction_id == tx.transaction_id + assert restored_tx.node_account_ids == tx.node_account_ids + + assert restored_tx._transaction_body_bytes == tx._transaction_body_bytes + assert restored_tx._signature_map == tx._signature_map diff --git a/tests/unit/transaction_test.py b/tests/unit/transaction_test.py index 97698a16a..e32718648 100644 --- a/tests/unit/transaction_test.py +++ b/tests/unit/transaction_test.py @@ -6,6 +6,7 @@ from hiero_sdk_python.account.account_id import AccountId from hiero_sdk_python.consensus.topic_message_submit_transaction import TopicMessageSubmitTransaction from hiero_sdk_python.crypto.private_key import PrivateKey +from hiero_sdk_python.Duration import Duration from hiero_sdk_python.exceptions import ReceiptStatusError from hiero_sdk_python.file.file_append_transaction import FileAppendTransaction from hiero_sdk_python.file.file_create_transaction import FileCreateTransaction @@ -19,6 +20,7 @@ transaction_receipt_pb2, transaction_response_pb2, ) +from hiero_sdk_python.hapi.services.transaction_contents_pb2 import SignedTransaction from hiero_sdk_python.hbar import Hbar from hiero_sdk_python.response_code import ResponseCode from hiero_sdk_python.tokens.token_id import TokenId @@ -27,6 +29,7 @@ from hiero_sdk_python.transaction.transaction_id import TransactionId from hiero_sdk_python.transaction.transaction_receipt import TransactionReceipt from hiero_sdk_python.transaction.transaction_response import TransactionResponse +from hiero_sdk_python.transaction.transfer_transaction import TransferTransaction from tests.unit.mock_server import mock_hedera_servers @@ -340,7 +343,6 @@ def test_message_submit_chunk_tx_should_return_list_of_body_sizes(topic_id, acco sizes = tx.body_size_all_chunks assert isinstance(sizes, list) assert len(sizes) == 3 - assert tx._current_chunk_index == 0 def test_message_submit_single_chunk_tx_return_list_of_len_one(topic_id, account_id, transaction_id): @@ -408,7 +410,7 @@ def test_chunked_tx_return_proper_sizes(file_id, account_id, transaction_id): assert large_size > 1024 # The larger chunked transaction should be bigger than the single-chunk transaction assert large_size > small_size - assert large_tx._current_chunk_index == 0 + assert large_tx._current_chunk_index is None def test_chunked_tx_differ_size_if_chunk_are_not_equal(topic_id, account_id, transaction_id): @@ -485,7 +487,8 @@ def test_high_volume_is_included_in_protobuf_output( assert transaction._transaction_body_bytes - body_bytes = next(iter(transaction._transaction_body_bytes.values())) + node_bytes = next(iter(transaction._transaction_body_bytes.values())) + body_bytes = next(iter(node_bytes.values())) body = transaction_pb2.TransactionBody() body.ParseFromString(body_bytes) @@ -502,7 +505,8 @@ def test_high_volume_is_included_in_protobuf_output( .freeze() ) - body_bytes_false = next(iter(transaction_false._transaction_body_bytes.values())) + node_bytes_false = next(iter(transaction_false._transaction_body_bytes.values())) + body_bytes_false = next(iter(node_bytes_false.values())) body_false = transaction_pb2.TransactionBody() body_false.ParseFromString(body_bytes_false) @@ -553,3 +557,176 @@ def test_transaction_default_max_fee(account_id): assert tx_body is not None assert tx_body.transactionFee == Hbar(2).to_tinybars() + + +def test_build_transaction_body(): + """Test that build_transaction_body create transaction body with basic fields.""" + tx = TransferTransaction() + transaction_body = tx.build_base_transaction_body() + + assert transaction_body is not None + assert transaction_body.high_volume == False + assert transaction_body.transactionValidDuration == Duration(120)._to_proto() + # TODO: Should be change to 2hbar in future. + # Since the deault fee for AbstractTokenTransfer can be remove, a default_fee is now updated to 2-hbar + assert transaction_body.transactionFee == Hbar(1).to_tinybars() + + assert not transaction_body.HasField("transactionID") + assert not transaction_body.HasField("nodeAccountID") + + +def test_set_node_account_id_updates_node_account_ids(): + """Test that setting a single node account ID updates node_account_ids.""" + tx = TransferTransaction().set_transaction_id(TransactionId.generate(AccountId(0, 0, 1))) + + account_node_id1 = AccountId(0, 0, 3) + tx.set_node_account_id(account_node_id1) + + assert tx.node_account_ids == [account_node_id1] + assert tx.node_account_id == account_node_id1 # Deprecated + + # Test backward compatibility + node_account_id2 = AccountId(0, 0, 4) + tx.node_account_id = node_account_id2 + + assert tx.node_account_ids == [node_account_id2] + assert tx.node_account_id == node_account_id2 # Deprecated + + +def test_set_node_account_ids_updates_node_account_ids(): + """Test that setting a node account ID list updates node_account_ids.""" + tx = TransferTransaction().set_transaction_id(TransactionId.generate(AccountId(0, 0, 1))) + + node_account_ids1 = [AccountId(0, 0, 3), AccountId(0, 0, 4)] + tx.set_node_account_ids(node_account_ids1) + + assert tx.node_account_ids == node_account_ids1 + assert tx.node_account_id == node_account_ids1[0] # Deprecated, will return 1st element of list + + # Test property setter + node_account_ids2 = [AccountId(0, 0, 5), AccountId(0, 0, 6)] + tx.node_account_ids = node_account_ids2 + + assert tx.node_account_ids == node_account_ids2 + assert tx.node_account_id == node_account_ids2[0] # Deprecated, will return 1st element of list + + +def test_freeze_transaction_sets_transaction_id_and_node_ids(): + """Test that freeze() preserves the transaction ID and builds body bytes for all node IDs.""" + tx_id = TransactionId.generate(AccountId(0, 0, 1)) + node_account_ids = [AccountId(0, 0, 3), AccountId(0, 0, 4)] + + tx = TransferTransaction().set_transaction_id(tx_id).set_node_account_ids(node_account_ids).freeze() + + assert tx.transaction_id == tx_id + assert tx.node_account_ids == node_account_ids + + assert len(tx._transaction_body_bytes) == 1 + assert set(tx._transaction_body_bytes.keys()) == {tx_id} + + for node_id in node_account_ids: + body_bytes = tx._transaction_body_bytes[tx_id][node_id] + assert body_bytes is not None + assert len(body_bytes) > 0 + + +def test_freeze_with_sets_transaction_id_and_node_ids_from_client(mock_client): + """Test that freeze_with() populates the transaction ID and node IDs from the client.""" + tx = TransferTransaction().freeze_with(mock_client) + expected_node_ids = [node._account_id for node in mock_client.network.nodes] + + # Generated when freeze with using clinet + expected_transaction_ids = tx._transaction_ids + + assert tx.transaction_id is not None + assert tx.node_account_ids == expected_node_ids + + assert len(tx._transaction_body_bytes) == len(expected_node_ids) + assert set(tx._transaction_body_bytes.keys()) == set(expected_transaction_ids) + + for node_id in expected_node_ids: + body_bytes = tx._transaction_body_bytes[expected_transaction_ids[0]][node_id] + assert body_bytes is not None + assert len(body_bytes) > 0 + + +# Deprecated, Only to test backward compatibility. +def test_freeze_transaction_sets_transaction_id_and_node_id(): + """Test that freeze() with a single node account ID builds the transaction body.""" + tx_id = TransactionId.generate(AccountId(0, 0, 1)) + node_account_id = AccountId(0, 0, 3) + + tx = TransferTransaction().set_transaction_id(tx_id).set_node_account_id(node_account_id).freeze() + + assert tx.transaction_id == tx_id + assert tx._transaction_ids == [tx_id] + assert tx.node_account_id == node_account_id + + assert len(tx._transaction_body_bytes) == 1 + assert set(tx._transaction_body_bytes.keys()) == {tx_id} + assert set(tx._transaction_body_bytes[tx_id].keys()) == {node_account_id} + + body_bytes = tx._transaction_body_bytes[tx_id][node_account_id] + assert body_bytes is not None + assert len(body_bytes) > 0 + + +def test_generate_transaction_ids(): + """Test _generate_transaction_ids create all transaction_id for given count.""" + count = 2 + initial_transaction = TransactionId.generate(AccountId(0, 0, 3)) + + tx = TopicMessageSubmitTransaction() + tx._generate_transaction_ids(initial_transaction, count) + + assert len(tx._transaction_ids) == count + + transaction_ids = tx._transaction_ids + + assert transaction_ids[0] == initial_transaction + for i in range(count): + assert transaction_ids[i].account_id == initial_transaction.account_id + assert transaction_ids[i].valid_start.nanos == initial_transaction.valid_start.nanos + i + + +def test_setter_and_getter_for_transaction_id(): + """Test the getter and setter for the transaction_id,""" + transaction_id = TransactionId.generate(AccountId(0, 0, 3)) + + tx1 = AccountCreateTransaction() + + assert tx1.transaction_id is None + tx1.set_transaction_id(transaction_id) + assert tx1.transaction_id == transaction_id + + # Test backward compatiblity + tx2 = AccountCreateTransaction() + + assert tx2.transaction_id is None + tx2.transaction_id = transaction_id + assert tx2.transaction_id == transaction_id + + +# Test backward comptiblity +def test_serialization_of_single_transaction(): + """Test serialization of single transaction.""" + key = PrivateKey.generate_ecdsa() + transaction_id = TransactionId.generate(AccountId(0, 0, 5)) + node_account_id = AccountId(0, 0, 3) + + tx1 = AccountCreateTransaction().set_key_without_alias(key).set_initial_balance(1).set_account_memo("test account") + + tx_body = tx1.build_transaction_body() + tx_body.transactionID.CopyFrom(transaction_id._to_proto()) + tx_body.nodeAccountID.CopyFrom(node_account_id._to_proto()) + + tx_bytes = transaction_pb2.Transaction( + signedTransactionBytes=SignedTransaction(bodyBytes=tx_body.SerializeToString()).SerializeToString() + ).SerializeToString() + + tx2 = Transaction.from_bytes(tx_bytes) + assert isinstance(tx2, AccountCreateTransaction) + assert tx1.account_memo == tx2.account_memo + assert tx1.initial_balance == tx2.initial_balance + assert tx2.node_account_id == node_account_id + assert tx2.transaction_id == transaction_id diff --git a/tests/unit/transfer_transaction_test.py b/tests/unit/transfer_transaction_test.py index 45b1b8505..6a2f501d5 100644 --- a/tests/unit/transfer_transaction_test.py +++ b/tests/unit/transfer_transaction_test.py @@ -658,9 +658,8 @@ def test_nft_transfer_reconstruction_from_protobuf(mock_account_ids): transfer_tx.operator_account_id = account_id_sender body = transfer_tx.build_transaction_body() - body_bytes = body.SerializeToString() - reconstructed = TransferTransaction._from_protobuf(body, body_bytes, None) + reconstructed = TransferTransaction._from_protobuf(body) assert len(reconstructed.nft_transfers[token_id_1]) == 1 nft = reconstructed.nft_transfers[token_id_1][0] @@ -681,9 +680,8 @@ def test_nft_transfers_unapproved_reconstruction(mock_account_ids): transfer_tx.operator_account_id = account_id_sender body = transfer_tx.build_transaction_body() - body_bytes = body.SerializeToString() - reconstructed = TransferTransaction._from_protobuf(body, body_bytes, None) + reconstructed = TransferTransaction._from_protobuf(body) assert len(reconstructed.nft_transfers[token_id_1]) == 1 nft = reconstructed.nft_transfers[token_id_1][0] @@ -701,9 +699,8 @@ def test_token_transfer_with_expected_decimals_reconstruction(mock_account_ids): transfer_tx.operator_account_id = account_id_sender body = transfer_tx.build_transaction_body() - body_bytes = body.SerializeToString() - reconstructed = TransferTransaction._from_protobuf(body, body_bytes, None) + reconstructed = TransferTransaction._from_protobuf(body) assert len(reconstructed.token_transfers[token_id_1]) == 2 for token_transfer in reconstructed.token_transfers[token_id_1]: @@ -728,9 +725,8 @@ def test_combined_transfers_reconstruction(mock_account_ids): transfer_tx.operator_account_id = account_id_sender body = transfer_tx.build_transaction_body() - body_bytes = body.SerializeToString() - reconstructed = TransferTransaction._from_protobuf(body, body_bytes, None) + reconstructed = TransferTransaction._from_protobuf(body) assert len(reconstructed.hbar_transfers) == 2 assert len(reconstructed.token_transfers[token_id_1]) == 2 @@ -754,9 +750,8 @@ def test_expected_decimals_field_preservation(mock_account_ids): transfer_tx.operator_account_id = account_id_sender body = transfer_tx.build_transaction_body() - body_bytes = body.SerializeToString() - reconstructed = TransferTransaction._from_protobuf(body, body_bytes, None) + reconstructed = TransferTransaction._from_protobuf(body) for token_transfer in reconstructed.token_transfers[token_id_1]: assert token_transfer.expected_decimals is not None @@ -774,9 +769,8 @@ def test_nft_transfer_fields_preservation(mock_account_ids): transfer_tx.operator_account_id = account_id_sender body = transfer_tx.build_transaction_body() - body_bytes = body.SerializeToString() - reconstructed = TransferTransaction._from_protobuf(body, body_bytes, None) + reconstructed = TransferTransaction._from_protobuf(body) nft_transfers = reconstructed.nft_transfers[token_id_1] assert len(nft_transfers) == 1 @@ -801,9 +795,8 @@ def test_multiple_nft_transfers_all_fields(mock_account_ids): transfer_tx.operator_account_id = account_id_sender body = transfer_tx.build_transaction_body() - body_bytes = body.SerializeToString() - reconstructed = TransferTransaction._from_protobuf(body, body_bytes, None) + reconstructed = TransferTransaction._from_protobuf(body) nft_transfers = reconstructed.nft_transfers[token_id_1] assert len(nft_transfers) == 3 @@ -830,9 +823,8 @@ def test_token_transfer_without_expected_decimals(mock_account_ids): transfer_tx.operator_account_id = account_id_sender body = transfer_tx.build_transaction_body() - body_bytes = body.SerializeToString() - reconstructed = TransferTransaction._from_protobuf(body, body_bytes, None) + reconstructed = TransferTransaction._from_protobuf(body) token_transfers = reconstructed.token_transfers[token_id_1] assert len(token_transfers) == 2