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 099c34b84..627c41831 100644 --- a/src/hiero_sdk_python/consensus/topic_message_submit_transaction.py +++ b/src/hiero_sdk_python/consensus/topic_message_submit_transaction.py @@ -1,36 +1,33 @@ from __future__ import annotations import math -from typing import Literal, overload from hiero_sdk_python.channels import _Channel -from hiero_sdk_python.client.client import Client 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, timestamp_pb2, transaction_pb2 +from hiero_sdk_python.hapi.services import consensus_submit_message_pb2, transaction_pb2 from hiero_sdk_python.hapi.services.schedulable_transaction_body_pb2 import ( SchedulableTransactionBody, ) +from hiero_sdk_python.transaction.chunked_transaction import ChunkedTransaction 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 -class TopicMessageSubmitTransaction(Transaction): +class TopicMessageSubmitTransaction(ChunkedTransaction): """ Represents a transaction that submits a message to a Hedera Consensus Service topic. Allows setting the target topic ID and message, building the transaction body, and executing the submission through a network channel. + + Supports automatic chunking for large messages. """ def __init__( self, topic_id: TopicId | None = None, - message: str | None = None, + message: bytes | str | None = None, chunk_size: int | None = None, max_chunks: int | None = None, ) -> None: @@ -39,21 +36,34 @@ def __init__( Args: topic_id (TopicId, optional): The ID of the topic. - message (str, optional): The message to submit. - chunk_size (int, optional): The maximum chunk size in bytes, Default: 1024. - max_chunks (int, optional): The maximum number of chunks allowed, Default: 20. + message (str or bytes, optional): The message to submit to the topic. + chunk_size (int, optional): The maximum chunk size in bytes. Default: 1024. + max_chunks (int, optional): The maximum number of chunks allowed. Default: 20. """ super().__init__() self.topic_id: TopicId | None = topic_id - self.message: str | None = message - self.chunk_size: int = chunk_size or 1024 - self.max_chunks: int = max_chunks or 20 + self.message: bytes | str | None = message + self.chunk_size: int = 1024 + self.max_chunks: int = 20 + if chunk_size is not None: + self.set_chunk_size(chunk_size) + + if max_chunks is not None: + self.set_max_chunks(max_chunks) - self._current_chunk_index = 0 self._total_chunks = self.get_required_chunks() - self._initial_transaction_id: TransactionId | None = None - self._transaction_ids: list[TransactionId] = [] - self._signing_keys: list[PrivateKey] = [] + + def _message_as_bytes(self) -> bytes: + """ + Returns the message encoded as bytes for chunking and protobuf serialization. + + Returns: + bytes: The message as bytes. + """ + if self.message is None: + return b"" + + return self.message.encode("utf-8") if isinstance(self.message, str) else self.message def get_required_chunks(self) -> int: """ @@ -65,7 +75,7 @@ def get_required_chunks(self) -> int: if not self.message: return 1 - content = self.message.encode("utf-8") + content = self._message_as_bytes() return math.ceil(len(content) / self.chunk_size) def set_topic_id(self, topic_id: TopicId) -> TopicMessageSubmitTransaction: @@ -82,12 +92,12 @@ def set_topic_id(self, topic_id: TopicId) -> TopicMessageSubmitTransaction: self.topic_id = topic_id return self - def set_message(self, message: str) -> TopicMessageSubmitTransaction: + def set_message(self, message: bytes | str) -> TopicMessageSubmitTransaction: """ Sets the message to submit to the topic. Args: - message (str): The message to submit to the topic. + message (str or bytes): The message to submit to the topic. Returns: TopicMessageSubmitTransaction: This transaction instance (for chaining). @@ -102,16 +112,12 @@ 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. + chunk_size (int): The size of each chunk in bytes. Returns: TopicMessageSubmitTransaction: This transaction instance (for chaining). """ - self._require_not_frozen() - if chunk_size <= 0: - raise ValueError("chunk_size must be positive") - - self.chunk_size = chunk_size + super().set_chunk_size(chunk_size) self._total_chunks = self.get_required_chunks() return self @@ -125,11 +131,7 @@ def set_max_chunks(self, max_chunks: int) -> TopicMessageSubmitTransaction: Returns: TopicMessageSubmitTransaction: This transaction instance (for chaining). """ - self._require_not_frozen() - if max_chunks <= 0: - raise ValueError("max_chunks must be positive") - - self.max_chunks = max_chunks + super().set_max_chunks(max_chunks) return self def set_custom_fee_limits(self, custom_fee_limits: list[CustomFeeLimit]) -> TopicMessageSubmitTransaction: @@ -160,21 +162,6 @@ def add_custom_fee_limit(self, custom_fee_limit: CustomFeeLimit) -> TopicMessage self.custom_fee_limits.append(custom_fee_limit) return self - def _validate_chunking(self) -> None: - """ - Validates that chunk count does not exceed max_chunks. - - Raises: - ValueError: If chunk count exceeds `max_chunks`. - """ - required = self.get_required_chunks() - - if self.max_chunks and required > self.max_chunks: - raise ValueError( - f"Message requires {required} chunks but max_chunks={self.max_chunks}. " - f"Increase limit with set_max_chunks()." - ) - def _build_proto_body(self) -> consensus_submit_message_pb2.ConsensusSubmitMessageTransactionBody: """ Returns the protobuf body for the topic message submit transaction. @@ -185,10 +172,10 @@ def _build_proto_body(self) -> consensus_submit_message_pb2.ConsensusSubmitMessa Raises: ValueError: If required fields (message) are missing. """ - if self.message is None or self.message == "": + if not self.message: raise ValueError("Missing required fields: message.") - content = self.message.encode("utf-8") + content = self._message_as_bytes() start_index = self._current_chunk_index * self.chunk_size end_index = min(start_index + self.chunk_size, len(content)) @@ -215,8 +202,7 @@ def build_transaction_body(self) -> transaction_pb2.TransactionBody: Builds and returns the protobuf transaction body for message submission. Returns: - TransactionBody: The protobuf transaction body containing - the message submission details. + TransactionBody: The protobuf transaction body containing the message submission details. """ consensus_submit_message_body = self._build_proto_body() transaction_body = self.build_base_transaction_body() @@ -247,180 +233,15 @@ def _get_method(self, channel: _Channel) -> _Method: """ return _Method(transaction_func=channel.topic.submitMessage, query_func=None) - def freeze_with(self, client: Client) -> TopicMessageSubmitTransaction: - if self._transaction_body_bytes: - return self - - self._resolve_transaction_id(client) - - if not self._transaction_ids: - base_timestamp = self.transaction_id.valid_start - - for i in range(self.get_required_chunks()): - if i == 0: - if self._initial_transaction_id is None: - self._initial_transaction_id = self.transaction_id - - chunk_transaction_id = self.transaction_id - else: - next_nanos = base_timestamp.nanos + i - - 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 - ) - - self._transaction_ids.append(chunk_transaction_id) - - return super().freeze_with(client) - - @overload - def execute( - self, - client: Client, - timeout: int | float | None = None, - wait_for_receipt: Literal[True] = True, - validate_status: bool = False, - ) -> TransactionReceipt: ... - - @overload - def execute( - self, - client: Client, - timeout: int | float | None = None, - wait_for_receipt: Literal[False] = False, - validate_status: bool = False, - ) -> TransactionResponse: ... - - def execute( - self, - client: Client, - timeout: int | float | None = None, - wait_for_receipt: bool = True, - validate_status: bool = False, - ) -> TransactionReceipt | TransactionResponse: - """ - Executes the topic message submit transaction. - - For multi-chunk transactions, this method will execute all chunks sequentially and return first response. - - Args: - client: The client to execute the transaction with. - timeout (int | float | None, optional): The total execution timeout (in seconds) for this execution. - wait_for_receipt (bool, optional): Whether to wait for consensus and return the receipt. - If False, the method returns a TransactionResponse immediately after submission. - validate_status: (bool): Whether the query should automatically validate the transaction status (default False). - - Returns: - TransactionReceipt: If wait_for_receipt is True (default) - TransactionResponse: If wait_for_receipt is False - """ - # Return the first response as the JS SDK does - return self.execute_all(client, timeout, wait_for_receipt, validate_status)[0] - - @overload - def execute_all( - self, - client: Client, - timeout: int | float | None = None, - wait_for_receipt: Literal[True] = True, - validate_status: bool = False, - ) -> list[TransactionReceipt]: ... - - @overload - def execute_all( - self, - client: Client, - timeout: int | float | None = None, - wait_for_receipt: Literal[False] = False, - validate_status: bool = False, - ) -> list[TransactionResponse]: ... - - def execute_all( - self, - client: Client, - timeout: int | float | None = None, - wait_for_receipt: bool = True, - validate_status: bool = False, - ) -> list[TransactionReceipt] | list[TransactionResponse]: - """ - Executes the topic message submit transaction. - - This method will execute all chunks sequentially and return list of all responses. - - Args: - client: The client to execute the transaction with. - timeout (int | float | None, optional): The total execution timeout (in seconds) for this execution. - wait_for_receipt (bool, optional): Whether to wait for consensus and return the receipt. - If False, the method returns a TransactionResponse immediately after submission. - validate_status: (bool): Whether the query should automatically validate the transaction status (default False). - - Returns: - List[TransactionReceipt]: If wait_for_receipt is True (default) - List[TransactionResponse]: If wait_for_receipt is False - """ - self._validate_chunking() - - if self.get_required_chunks() == 1: - return [super().execute(client, timeout, wait_for_receipt, validate_status)] - - # Multi-chunk transaction - execute all chunks - responses = [] - - for chunk_index in range(self.get_required_chunks()): - self._current_chunk_index = chunk_index - - if self._transaction_ids and chunk_index < len(self._transaction_ids): - self.transaction_id = self._transaction_ids[chunk_index] - - self._transaction_body_bytes.clear() - self._signature_map.clear() - - self.freeze_with(client) - - for signing_key in self._signing_keys: - super().sign(signing_key) - - # Execute the chunk - response = super().execute(client, timeout, wait_for_receipt, validate_status) - responses.append(response) - - return responses - - def sign(self, private_key: PrivateKey): + def sign(self, private_key: PrivateKey) -> TopicMessageSubmitTransaction: """ Signs the transaction using the provided private key. - For multi-chunk transactions, this stores the signing key for later use. - Args: private_key (PrivateKey): The private key to sign the transaction with. - """ - if private_key not in self._signing_keys: - self._signing_keys.append(private_key) + Returns: + TopicMessageSubmitTransaction: This transaction instance (for chaining). + """ super().sign(private_key) return self - - @property - def body_size_all_chunks(self) -> list[int]: - """Returns an array of body sizes for transactions with multiple chunks.""" - self._require_frozen() - sizes = [] - - original_index = self._current_chunk_index - original_transaction_id = self.transaction_id - - try: - for i, transaction_id in enumerate(self._transaction_ids): - 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 - - return sizes diff --git a/src/hiero_sdk_python/file/file_append_transaction.py b/src/hiero_sdk_python/file/file_append_transaction.py index 3019296a5..b944efb59 100644 --- a/src/hiero_sdk_python/file/file_append_transaction.py +++ b/src/hiero_sdk_python/file/file_append_transaction.py @@ -7,37 +7,28 @@ The transaction supports chunking for large files, automatically breaking content into smaller chunks if the content exceeds the chunk size limit. -Inherits from the base Transaction class and implements the required methods +Inherits from the base ChunkedTransaction class and implements the required methods to build and execute a file append transaction. """ from __future__ import annotations import math -from typing import TYPE_CHECKING, Any, Literal, overload +from typing import TYPE_CHECKING, Any from hiero_sdk_python.file.file_id import FileId -from hiero_sdk_python.hapi.services import file_append_pb2, timestamp_pb2 -from hiero_sdk_python.hapi.services.schedulable_transaction_body_pb2 import ( - SchedulableTransactionBody, -) +from hiero_sdk_python.hapi.services import file_append_pb2 +from hiero_sdk_python.hapi.services.schedulable_transaction_body_pb2 import SchedulableTransactionBody from hiero_sdk_python.hbar import Hbar -from hiero_sdk_python.transaction.transaction import Transaction -from hiero_sdk_python.transaction.transaction_id import TransactionId +from hiero_sdk_python.transaction.chunked_transaction import ChunkedTransaction -# Use TYPE_CHECKING to avoid circular import errors if TYPE_CHECKING: from hiero_sdk_python.channels import _Channel - from hiero_sdk_python.client.client import Client - from hiero_sdk_python.crypto.private_key import PrivateKey from hiero_sdk_python.executable import _Method - from hiero_sdk_python.transaction.transaction import TransactionReceipt - from hiero_sdk_python.transaction.transaction_response import TransactionResponse -# pylint: disable=too-many-instance-attributes -class FileAppendTransaction(Transaction): +class FileAppendTransaction(ChunkedTransaction): """ Represents a file append transaction on the network. @@ -47,7 +38,7 @@ class FileAppendTransaction(Transaction): The transaction supports chunking for large files, automatically breaking content into smaller chunks if the content exceeds the chunk size limit. - Inherits from the base Transaction class and implements the required methods + Inherits from the base ChunkedTransaction class and implements the required methods to build and execute a file append transaction. """ @@ -58,28 +49,19 @@ def __init__( max_chunks: int | None = None, chunk_size: int | None = None, ): - """ - Initializes a new FileAppendTransaction instance with the specified parameters. - - Args: - file_id (Optional[FileId], optional): The ID of the file to append to. - contents (Optional[str | bytes], optional): The contents to append to the file. - Strings will be automatically encoded as UTF-8 bytes. - max_chunks (Optional[int], optional): Maximum number of chunks allowed. Defaults to 20. - chunk_size (Optional[int], optional): Size of each chunk in bytes. Defaults to 4096. - """ super().__init__() self.file_id: FileId | None = file_id self.contents: bytes | None = self._encode_contents(contents) - self.max_chunks: int = max_chunks if max_chunks is not None else 20 - self.chunk_size: int = chunk_size if chunk_size is not None else 4096 + self.max_chunks: int = 20 + self.chunk_size: int = 4096 self._default_transaction_fee = Hbar(5).to_tinybars() - # Internal tracking for chunking - self._current_chunk_index: int = 0 - self._total_chunks: int = self._calculate_total_chunks() - self._transaction_ids: list[TransactionId] = [] - self._signing_keys: list[PrivateKey] = [] # Use string annotation to avoid import issues + if max_chunks is not None: + self.set_max_chunks(max_chunks) + if chunk_size is not None: + self.set_chunk_size(chunk_size) + + self._total_chunks = self._calculate_total_chunks() def _encode_contents(self, contents: str | bytes | None) -> bytes | None: """ @@ -157,8 +139,7 @@ def set_max_chunks(self, max_chunks: int) -> FileAppendTransaction: Returns: FileAppendTransaction: This transaction instance. """ - self._require_not_frozen() - self.max_chunks = max_chunks + super().set_max_chunks(max_chunks) return self def set_chunk_size(self, chunk_size: int) -> FileAppendTransaction: @@ -171,9 +152,7 @@ def set_chunk_size(self, chunk_size: int) -> FileAppendTransaction: Returns: FileAppendTransaction: This transaction instance. """ - self._require_not_frozen() - self.chunk_size = chunk_size - self._total_chunks = self._calculate_total_chunks() + super().set_chunk_size(chunk_size) return self def _build_proto_body(self) -> file_append_pb2.FileAppendTransactionBody: @@ -256,217 +235,3 @@ def _from_proto(self, proto: file_append_pb2.FileAppendTransactionBody) -> FileA self.contents = proto.contents self._total_chunks = self._calculate_total_chunks() return self - - def _validate_chunking(self) -> None: - """ - Validates that the transaction doesn't exceed the maximum number of chunks. - - Raises: - ValueError: If the transaction exceeds the maximum number of chunks. - """ - if self.max_chunks and self.get_required_chunks() > self.max_chunks: - raise ValueError( - f"Cannot execute FileAppendTransaction with more than {self.max_chunks} chunks. " - f"Required: {self.get_required_chunks()}" - ) - - def freeze_with(self, client: Client) -> FileAppendTransaction: - """ - Freezes the transaction by building the transaction body and setting necessary IDs. - - For multi-chunk transactions, this method generates multiple transaction IDs - with incremented timestamps based on the chunk interval. - - Args: - client (Client): The client instance to use for setting defaults. - - Returns: - FileAppendTransaction: The current transaction instance for method chaining. - """ - if self._transaction_body_bytes: - return self - - self._resolve_transaction_id(client) - - # Generate transaction IDs for all chunks - if not self._transaction_ids: - base_timestamp = self.transaction_id.valid_start - - for i in range(self.get_required_chunks()): - if i == 0: - # First chunk uses the original transaction 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 - - 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 - ) - - self._transaction_ids.append(chunk_transaction_id) - - return super().freeze_with(client) - - @overload - def execute( - self, - client: Client, - timeout: int | float | None = None, - wait_for_receipt: Literal[True] = True, - validate_status: bool = False, - ) -> TransactionReceipt: ... - - @overload - def execute( - self, - client: Client, - timeout: int | float | None = None, - wait_for_receipt: Literal[False] = False, - validate_status: bool = False, - ) -> TransactionResponse: ... - - def execute( - self, - client: Client, - timeout: int | float | None = None, - wait_for_receipt: bool = True, - validate_status: bool = False, - ) -> TransactionReceipt | TransactionResponse: - """ - Executes the file append transaction. - - For multi-chunk transactions, this method will execute all chunks sequentially and return first response. - - Args: - client: The client to execute the transaction with. - timeout (int | float | None, optional): The total execution timeout (in seconds) for this execution. - wait_for_receipt (bool, optional): Whether to wait for consensus and return the receipt. - If False, the method returns a TransactionResponse immediately after submission. - validate_status: (bool): Whether the query should automatically validate the transaction status (default False). - - Returns: - TransactionReceipt: If wait_for_receipt is True (default) - TransactionResponse: If wait_for_receipt is False - """ - # Return the first response (as per JavaScript implementation) - return self.execute_all(client, timeout, wait_for_receipt, validate_status)[0] - - @overload - def execute_all( - self, - client: Client, - timeout: int | float | None = None, - wait_for_receipt: Literal[True] = True, - validate_status: bool = False, - ) -> list[TransactionReceipt]: ... - - @overload - def execute_all( - self, - client: Client, - timeout: int | float | None = None, - wait_for_receipt: Literal[False] = False, - validate_status: bool = False, - ) -> list[TransactionResponse]: ... - - def execute_all( - self, - client: Client, - timeout: int | float | None = None, - wait_for_receipt: bool = True, - validate_status: bool = False, - ) -> list[TransactionReceipt] | list[TransactionResponse]: - """ - Executes the file append transaction. - - This method will execute all chunks sequentially and return list of response for each chunked - - Args: - client: The client to execute the transaction with. - timeout (int | float | None, optional): The total execution timeout (in seconds) for this execution. - wait_for_receipt (bool, optional): Whether to wait for consensus and return the receipt. - If False, the method returns a TransactionResponse immediately after submission. - validate_status: (bool): Whether the query should automatically validate the transaction status (default False). - - Returns: - List[TransactionReceipt]: If wait_for_receipt is True (default) - List[TransactionResponse]: If wait_for_receipt is False - """ - self._validate_chunking() - - if self.get_required_chunks() == 1: - # Single chunk transaction - return [super().execute(client, timeout, wait_for_receipt, validate_status)] - - # Multi-chunk transaction - execute all chunks - responses = [] - - for chunk_index in range(self.get_required_chunks()): - self._current_chunk_index = chunk_index - - # Set the transaction ID for this chunk - if self._transaction_ids and chunk_index < len(self._transaction_ids): - self.transaction_id = self._transaction_ids[chunk_index] - # Clear the frozen state to allow rebuilding with new transaction ID - self._transaction_body_bytes.clear() - self._signature_map.clear() - - # Freeze the transaction for this chunk if not already frozen - self.freeze_with(client) - - # Sign with all stored signing keys for this chunk - for signing_key in self._signing_keys: - # Call parent sign directly to avoid modifying _signing_keys - super().sign(signing_key) - - # Execute the chunk - response = super().execute(client, timeout, wait_for_receipt, validate_status) - responses.append(response) - - return responses - - def sign(self, private_key: PrivateKey) -> FileAppendTransaction: - """ - Signs the transaction using the provided private key. - - For multi-chunk transactions, this stores the signing key for later use. - - Args: - private_key (PrivateKey): The private key to sign the transaction with. - - Returns: - FileAppendTransaction: The current transaction instance for method chaining. - """ - # Store the signing key for multi-chunk transactions (avoid duplicates) - if private_key not in self._signing_keys: - self._signing_keys.append(private_key) - - # Call the parent sign method for the current transaction - super().sign(private_key) - return self - - @property - def body_size_all_chunks(self) -> list[int]: - """Returns an array of body sizes for transactions with multiple chunks.""" - self._require_frozen() - sizes = [] - - original_index = self._current_chunk_index - original_transaction_id = self.transaction_id - - try: - for i, transaction_id in enumerate(self._transaction_ids): - 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 - - return sizes diff --git a/src/hiero_sdk_python/transaction/chunked_transaction.py b/src/hiero_sdk_python/transaction/chunked_transaction.py new file mode 100644 index 000000000..b2c940bb8 --- /dev/null +++ b/src/hiero_sdk_python/transaction/chunked_transaction.py @@ -0,0 +1,344 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Literal, 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 + + +class ChunkedTransaction(Transaction, ABC): + """ + Abstract base class for transactions that support chunking. + + Centralizes common chunking logic for transactions like TopicMessageSubmitTransaction + and FileAppendTransaction that need to split large content into multiple chunks. + + Subclasses must implement: + - get_required_chunks(): Calculate the number of chunks needed + - _build_proto_body(): Build the protobuf body for the current chunk + """ + + def __init__(self) -> None: + """Initializes a new ChunkedTransaction instance.""" + super().__init__() + + # Chunking state + self._current_chunk_index: int = 0 + 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): + """ + Builds the protobuf body for the current chunk. + + This method is called during freeze_with() and execute() for each chunk. + Subclasses must implement this to extract the appropriate chunk content + and build the transaction-specific body. + + Returns: + The transaction-specific protobuf body (e.g., ConsensusSubmitMessageTransactionBody) + + Raises: + ValueError: If required fields are missing. + """ + pass + + def set_chunk_size(self, chunk_size: int) -> ChunkedTransaction: + """ + Sets the chunk size for this transaction. + + Args: + chunk_size (int): The size of each chunk in bytes. + + Returns: + ChunkedTransaction: This transaction instance for chaining. + + Raises: + ValueError: If chunk_size is not positive. + """ + self._require_not_frozen() + if chunk_size <= 0: + raise ValueError("chunk_size must be positive") + + self.chunk_size = chunk_size + self._total_chunks = self.get_required_chunks() + return self + + def set_max_chunks(self, max_chunks: int) -> ChunkedTransaction: + """ + Sets the maximum number of chunks allowed. + + Args: + max_chunks (int): The maximum number of chunks allowed. + + Returns: + ChunkedTransaction: This transaction instance for chaining. + + Raises: + ValueError: If max_chunks is not positive. + """ + self._require_not_frozen() + if max_chunks <= 0: + raise ValueError("max_chunks must be positive") + + self.max_chunks = max_chunks + return self + + def _validate_chunking(self) -> int: + """ + Validates that the required chunks don't exceed max_chunks. + + Raises: + ValueError: If required chunks exceed max_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: + raise ValueError( + f"Message requires {required} chunks but max_chunks={self.max_chunks}. " + f"Increase limit with set_max_chunks()." + ) + return required + + def freeze_with(self, client: Client) -> ChunkedTransaction: + """ + Freezes the transaction by building transaction bodies for all chunks. + + For multi-chunk transactions, generates sequential TransactionIds with + incremented timestamps to ensure proper chunk ordering. + + Args: + client (Client): The client instance to use for setting defaults. + + Returns: + ChunkedTransaction: This transaction instance for chaining. + """ + if self._transaction_body_bytes: + return self + + self._validate_chunking() + self._resolve_transaction_id(client) + + if self.transaction_id.valid_start is None: + raise ValueError("Transaction ID with valid_start must be set before freezing chunked transaction.") + + # Generate transaction IDs for all chunks if not already done + if not self._transaction_ids: + base_timestamp = self.transaction_id.valid_start + + 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 + + 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 + + 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 + ) + + self._transaction_ids.append(chunk_transaction_id) + + return super().freeze_with(client) + + @overload + def execute( + self, + client: Client, + timeout: int | float | None = None, + wait_for_receipt: Literal[True] = True, + validate_status: bool = False, + ) -> TransactionReceipt: ... + + @overload + def execute( + self, + client: Client, + timeout: int | float | None = None, + wait_for_receipt: Literal[False] = False, + validate_status: bool = False, + ) -> TransactionResponse: ... + + def execute( + self, + client: Client, + timeout: int | float | None = None, + wait_for_receipt: bool = True, + validate_status: bool = False, + ) -> TransactionReceipt | TransactionResponse: + """ + Executes the chunked transaction. + + For multi-chunk transactions, executes all chunks sequentially and returns + the first response. Single-chunk transactions are executed normally. + + Args: + client: The client to execute the transaction with. + timeout (int | float | None, optional): The total execution timeout (in seconds). + wait_for_receipt (bool, optional): Whether to wait for consensus and return receipt. + validate_status: (bool): Whether to automatically validate the transaction status. + + Returns: + TransactionReceipt: If wait_for_receipt is True (default) + TransactionResponse: If wait_for_receipt is False + """ + # Return the first response as per existing implementations + return self.execute_all(client, timeout, wait_for_receipt, validate_status)[0] + + @overload + def execute_all( + self, + client: Client, + timeout: int | float | None = None, + wait_for_receipt: Literal[True] = True, + validate_status: bool = False, + ) -> list[TransactionReceipt]: ... + + @overload + def execute_all( + self, + client: Client, + timeout: int | float | None = None, + wait_for_receipt: Literal[False] = False, + validate_status: bool = False, + ) -> list[TransactionResponse]: ... + + def execute_all( + self, + client: Client, + timeout: int | float | None = None, + wait_for_receipt: bool = True, + validate_status: bool = False, + ) -> list[TransactionReceipt] | list[TransactionResponse]: + """ + Executes all chunks of the transaction sequentially. + + Returns a list of responses for each chunk executed. + + Args: + client: The client to execute the transaction with. + timeout (int | float | None, optional): The total execution timeout (in seconds). + wait_for_receipt (bool, optional): Whether to wait for consensus and return receipts. + validate_status: (bool): Whether to automatically validate transaction statuses. + + Returns: + List[TransactionReceipt]: If wait_for_receipt is True (default) + List[TransactionResponse]: If wait_for_receipt is False + """ + 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) + + 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, + ) + 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]: + """ + Returns an array of body sizes for each chunk in the transaction. + + Useful for estimating the total fee when dealing with multi-chunk transactions. + + Returns: + list[int]: List of body sizes in bytes for each chunk. + + Raises: + Exception: If the transaction is not frozen. + """ + self._require_frozen() + sizes = [] + + original_index = self._current_chunk_index + original_transaction_id = self.transaction_id + + try: + for i, transaction_id in enumerate(self._transaction_ids): + 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 + + return sizes diff --git a/tests/integration/file_append_transaction_e2e_test.py b/tests/integration/file_append_transaction_e2e_test.py index ace6d0acb..1238a3d7f 100644 --- a/tests/integration/file_append_transaction_e2e_test.py +++ b/tests/integration/file_append_transaction_e2e_test.py @@ -233,7 +233,9 @@ def test_integration_file_append_transaction_max_chunks_exceeded(env): ) # Should fail with max chunks exceeded - with pytest.raises(ValueError, match="more than.*chunks"): + with pytest.raises( + ValueError, match="Message requires 100 chunks but max_chunks=5. Increase limit with set_max_chunks()." + ): append_tx.execute(env.client) diff --git a/tests/integration/topic_message_submit_transaction_e2e_test.py b/tests/integration/topic_message_submit_transaction_e2e_test.py index 23393e150..4fbb52ff7 100644 --- a/tests/integration/topic_message_submit_transaction_e2e_test.py +++ b/tests/integration/topic_message_submit_transaction_e2e_test.py @@ -104,13 +104,7 @@ def test_topic_message_submit_transaction_fails_if_max_chunks_less_than_requied( message = "A" * (1024 * 14) # message with (1024 * 14) bytes ie 14 chunks - message_tx = ( - TopicMessageSubmitTransaction() - .set_topic_id(topic_id) - .set_message(message) - .set_max_chunks(2) - .freeze_with(env.client) - ) + message_tx = TopicMessageSubmitTransaction().set_topic_id(topic_id).set_message(message).set_max_chunks(2) with pytest.raises(ValueError): message_tx.execute(env.client) @@ -291,7 +285,6 @@ def test_integration_topic_message_submit_transaction_fails_if_required_chunk_gr message="A" * (1024 * 4), # requires 4 chunks ) message_transaction.set_max_chunks(2) - message_transaction.freeze_with(env.client) with pytest.raises( ValueError, match="Message requires 4 chunks but max_chunks=2. Increase limit with set_max_chunks()." ): diff --git a/tests/unit/chunked_transaction_test.py b/tests/unit/chunked_transaction_test.py new file mode 100644 index 000000000..aeb989024 --- /dev/null +++ b/tests/unit/chunked_transaction_test.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from hiero_sdk_python.account.account_id import AccountId +from hiero_sdk_python.hapi.services import timestamp_pb2 +from hiero_sdk_python.transaction.chunked_transaction import ChunkedTransaction +from hiero_sdk_python.transaction.transaction import Transaction +from hiero_sdk_python.transaction.transaction_id import TransactionId + + +pytestmark = pytest.mark.unit + + +class DummyChunkedTransaction(ChunkedTransaction): + def __init__(self, required_chunks: int = 1) -> None: + super().__init__() + self.required_chunks = required_chunks + + def _get_method(self, _channel): + method = type("Method", (), {})() + method.query = None + method.transaction = None + return method + + def get_required_chunks(self) -> int: + return self.required_chunks + + def _build_proto_body(self): + return self.build_base_transaction_body() + + def build_transaction_body(self): + return self.build_base_transaction_body() + + def build_scheduled_body(self): + return self.build_base_scheduled_body() + + +def test_constructor_sets_default_chunk_configuration(): + """Test that the constructor sets the default chunk configuration.""" + tx = DummyChunkedTransaction() + + assert tx.chunk_size == 1024 + assert tx.max_chunks == 20 + assert tx._current_chunk_index == 0 + assert tx._total_chunks == 1 + assert tx._transaction_ids == [] + assert tx._signing_keys == [] + + +@pytest.mark.parametrize( + "setter_name, value, message", + [("set_chunk_size", 0, "chunk_size must be positive"), ("set_max_chunks", 0, "max_chunks must be positive")], +) +def test_setters_reject_non_positive_values(mock_client, setter_name, value, message): + tx = DummyChunkedTransaction() + + with pytest.raises(ValueError, match=message): + 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\(\)\.", + ): + tx._validate_chunking() + + +def test_freeze_with_rejects_too_many_chunks(mock_client): + """Test that freeze_with raises a ValueError when the required chunks exceeds max_chunks.""" + tx = DummyChunkedTransaction(required_chunks=5).set_max_chunks(3) + + with pytest.raises( + ValueError, + match=r"Message requires 5 chunks but max_chunks=3\. Increase limit with set_max_chunks\(\)\.", + ): + tx.freeze_with(mock_client) + + +def test_freeze_with_builds_chunk_transaction_ids(mock_client): + tx = DummyChunkedTransaction(required_chunks=3) + base_timestamp = timestamp_pb2.Timestamp(seconds=123, nanos=456) + tx.transaction_id = TransactionId(account_id=AccountId(0, 0, 1234), valid_start=base_timestamp) + + tx.freeze_with(mock_client) + + assert tx._total_chunks == 3 + assert len(tx._transaction_ids) == 3 + assert tx._initial_transaction_id == tx.transaction_id + 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 + assert tx._transaction_ids[2].valid_start.seconds == 123 + 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( + account_id=AccountId(0, 0, 1234), + valid_start=timestamp_pb2.Timestamp(seconds=123, nanos=456), + ) + tx.freeze_with(mock_client) + + original_chunk_index = tx._current_chunk_index + original_transaction_id = tx.transaction_id + + sizes = tx.body_size_all_chunks + + assert len(sizes) == 3 + assert all(size > 0 for size in sizes) + assert tx._current_chunk_index == original_chunk_index + assert tx.transaction_id == original_transaction_id + + +def test_execute_all_single_chunk_delegates_to_transaction_execute(mock_client): + tx = DummyChunkedTransaction(required_chunks=1) + tx.freeze_with(mock_client) + + with patch.object(Transaction, "execute", return_value="single-chunk-response") as mock_execute: + responses = tx.execute_all(mock_client) + + assert responses == ["single-chunk-response"] + assert mock_execute.call_count == 1 + assert mock_execute.call_args.args[0] is mock_client + + +def test_execute_all_multi_chunk_replays_each_chunk(mock_client, private_key): + tx = DummyChunkedTransaction(required_chunks=3) + tx.transaction_id = TransactionId( + account_id=AccountId(0, 0, 1234), + valid_start=timestamp_pb2.Timestamp(seconds=123, nanos=456), + ) + tx.freeze_with(mock_client) + tx.sign(private_key) + + with patch.object(Transaction, "execute", side_effect=["chunk-1", "chunk-2", "chunk-3"]) as mock_execute: + responses = tx.execute_all(mock_client) + + assert responses == ["chunk-1", "chunk-2", "chunk-3"] + assert mock_execute.call_count == 3 + assert tx._current_chunk_index == 2 + + +def test_validate_chunking_allows_required_equal_to_max_chunks(): + """Test that _validate_chunking does not raise an error when required_chunks equals max_chunks.""" + tx = DummyChunkedTransaction(required_chunks=3).set_max_chunks(3) + + tx._validate_chunking() + assert tx._total_chunks == 3 + assert tx._current_chunk_index == 0 diff --git a/tests/unit/file_append_transaction_test.py b/tests/unit/file_append_transaction_test.py index 4b832c43e..655cc3be2 100644 --- a/tests/unit/file_append_transaction_test.py +++ b/tests/unit/file_append_transaction_test.py @@ -119,7 +119,9 @@ def test_validate_chunking(): file_tx = FileAppendTransaction(contents=large_content, chunk_size=100, max_chunks=5) # Should raise error when required chunks > max_chunks - with pytest.raises(ValueError, match="Cannot execute FileAppendTransaction with more than 5 chunks"): + with pytest.raises( + ValueError, match="Message requires 140 chunks but max_chunks=5. Increase limit with set_max_chunks()." + ): file_tx._validate_chunking()