From 16e354063b8578fc992b8aee99497aa50a397524 Mon Sep 17 00:00:00 2001 From: MonaaEid Date: Fri, 26 Jun 2026 01:26:58 +0300 Subject: [PATCH 01/12] Create ChunkedTransaction class Signed-off-by: MonaaEid --- .../topic_message_submit_transaction.py | 272 +++---------- .../file/file_append_transaction.py | 260 +------------ .../transaction/chunked_transaction.py | 368 ++++++++++++++++++ ...pic_message_submit_transaction_e2e_test.py | 7 +- tests/unit/chunked_transaction_test.py | 150 +++++++ 5 files changed, 579 insertions(+), 478 deletions(-) create mode 100644 src/hiero_sdk_python/transaction/chunked_transaction.py create mode 100644 tests/unit/chunked_transaction_test.py 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 0cc9529d5..834b76a42 100644 --- a/src/hiero_sdk_python/consensus/topic_message_submit_transaction.py +++ b/src/hiero_sdk_python/consensus/topic_message_submit_transaction.py @@ -1,38 +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, - chunk_size: int | None = None, - max_chunks: int | None = None, + self, + topic_id: TopicId | None = None, + message: bytes | str | None = None, + chunk_size: int | None = None, + max_chunks: int | None = None, ) -> None: """ Initializes a new TopicMessageSubmitTransaction instance. @@ -40,20 +35,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. + 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: + super().set_chunk_size(chunk_size) + + if max_chunks is not None: + super().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 +74,8 @@ def get_required_chunks(self) -> int: if not self.message: return 1 - content = self.message.encode("utf-8") + content = self._message_as_bytes() + import math return math.ceil(len(content) / self.chunk_size) def set_topic_id(self, topic_id: TopicId) -> TopicMessageSubmitTransaction: @@ -82,7 +92,7 @@ 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. @@ -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. @@ -190,14 +177,15 @@ def _build_proto_body(self) -> consensus_submit_message_pb2.ConsensusSubmitMessa if self.message is None: 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)) chunk_content = content[start_index:end_index] body = consensus_submit_message_pb2.ConsensusSubmitMessageTransactionBody( - topicID=self.topic_id._to_proto(), message=chunk_content + topicID=self.topic_id._to_proto(), + message=chunk_content ) # Multi-chunk metadata @@ -217,8 +205,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() @@ -249,180 +236,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..68a89879f 100644 --- a/src/hiero_sdk_python/file/file_append_transaction.py +++ b/src/hiero_sdk_python/file/file_append_transaction.py @@ -10,64 +10,31 @@ Inherits from the base Transaction 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): - """ - Represents a file append transaction on the network. - - This transaction appends data to an existing file on the network. If a file has multiple keys, - all keys must sign to modify its contents. - 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 - to build and execute a file append transaction. - """ +class FileAppendTransaction(ChunkedTransaction): def __init__( - self, - file_id: FileId | None = None, - contents: str | bytes | None = None, - max_chunks: int | None = None, - chunk_size: int | None = None, + self, + file_id: FileId | None = None, + contents: str | bytes | None = None, + 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) @@ -75,11 +42,7 @@ def __init__( self.chunk_size: int = chunk_size if chunk_size is not None else 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 + self._total_chunks = self._calculate_total_chunks() def _encode_contents(self, contents: str | bytes | None) -> bytes | None: """ @@ -269,204 +232,3 @@ def _validate_chunking(self) -> None: 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..c87fa6192 --- /dev/null +++ b/src/hiero_sdk_python/transaction/chunked_transaction.py @@ -0,0 +1,368 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, 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 + + +if TYPE_CHECKING: + pass + + +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 get_required_chunks(self) -> int: + """ + Returns the number of chunks required for the current content. + + Subclasses must implement this based on their content type. + + Returns: + int: Number of chunks required. + """ + pass + + @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 is None or 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() + + required_chunks = self.get_required_chunks() + + if required_chunks == 1: + return [ + super().execute( + client, + timeout=timeout, + wait_for_receipt=wait_for_receipt, + validate_status=validate_status, + ) + ] + + # Ensure the initial transaction ID and chunk transaction IDs exist. + if not self._transaction_ids: + self.freeze_with(client) + + responses = [] + + for chunk_index in range(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/topic_message_submit_transaction_e2e_test.py b/tests/integration/topic_message_submit_transaction_e2e_test.py index 23393e150..0dadc4ee4 100644 --- a/tests/integration/topic_message_submit_transaction_e2e_test.py +++ b/tests/integration/topic_message_submit_transaction_e2e_test.py @@ -32,6 +32,7 @@ def create_topic(client, admin_key=None, submit_key=None, custom_fees=None): tx.set_submit_key(submit_key) if custom_fees: tx.set_custom_fees(custom_fees) + tx.transaction_fee = Hbar(30).to_tinybars() receipt = tx.execute(client) assert receipt.status == ResponseCode.SUCCESS, f"Topic creation failed: {ResponseCode(receipt.status).name}" @@ -109,11 +110,10 @@ def test_topic_message_submit_transaction_fails_if_max_chunks_less_than_requied( .set_topic_id(topic_id) .set_message(message) .set_max_chunks(2) - .freeze_with(env.client) ) with pytest.raises(ValueError): - message_tx.execute(env.client) + message_tx.freeze_with(env.client) delete_topic(env.client, topic_id) @@ -291,11 +291,10 @@ 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()." ): - message_transaction.execute(env.client) + message_transaction.freeze_with(env.client) delete_topic(env.client, topic_id) diff --git a/tests/unit/chunked_transaction_test.py b/tests/unit/chunked_transaction_test.py new file mode 100644 index 000000000..c912a36b6 --- /dev/null +++ b/tests/unit/chunked_transaction_test.py @@ -0,0 +1,150 @@ +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(): + 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(): + 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(): + 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_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 From 5961cce2efd5746a1ecaff4b9a358b4a8c699ab0 Mon Sep 17 00:00:00 2001 From: MonaaEid Date: Mon, 29 Jun 2026 22:43:05 +0300 Subject: [PATCH 02/12] Create ChunkedTransaction class Signed-off-by: MonaaEid --- .../topic_message_submit_transaction.py | 10 +-- .../file/file_append_transaction.py | 10 +-- .../transaction/chunked_transaction.py | 66 +++++++++---------- ...pic_message_submit_transaction_e2e_test.py | 7 +- tests/unit/chunked_transaction_test.py | 5 +- 5 files changed, 47 insertions(+), 51 deletions(-) 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 0c388d64b..a8fd82407 100644 --- a/src/hiero_sdk_python/consensus/topic_message_submit_transaction.py +++ b/src/hiero_sdk_python/consensus/topic_message_submit_transaction.py @@ -23,11 +23,11 @@ class TopicMessageSubmitTransaction(ChunkedTransaction): """ def __init__( - self, - topic_id: TopicId | None = None, - message: bytes | str | None = None, - chunk_size: int | None = None, - max_chunks: int | None = None, + self, + topic_id: TopicId | None = None, + message: bytes | str | None = None, + chunk_size: int | None = None, + max_chunks: int | None = None, ) -> None: """ Initializes a new TopicMessageSubmitTransaction instance. diff --git a/src/hiero_sdk_python/file/file_append_transaction.py b/src/hiero_sdk_python/file/file_append_transaction.py index 68a89879f..b99a10805 100644 --- a/src/hiero_sdk_python/file/file_append_transaction.py +++ b/src/hiero_sdk_python/file/file_append_transaction.py @@ -29,11 +29,11 @@ class FileAppendTransaction(ChunkedTransaction): def __init__( - self, - file_id: FileId | None = None, - contents: str | bytes | None = None, - max_chunks: int | None = None, - chunk_size: int | None = None, + self, + file_id: FileId | None = None, + contents: str | bytes | None = None, + max_chunks: int | None = None, + chunk_size: int | None = None, ): super().__init__() self.file_id: FileId | None = file_id diff --git a/src/hiero_sdk_python/transaction/chunked_transaction.py b/src/hiero_sdk_python/transaction/chunked_transaction.py index c87fa6192..415c070f5 100644 --- a/src/hiero_sdk_python/transaction/chunked_transaction.py +++ b/src/hiero_sdk_python/transaction/chunked_transaction.py @@ -172,12 +172,10 @@ def freeze_with(self, client: Client) -> ChunkedTransaction: 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 + 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 + account_id=self.transaction_id.account_id, valid_start=chunk_valid_start ) self._transaction_ids.append(chunk_transaction_id) @@ -186,30 +184,30 @@ def freeze_with(self, client: Client) -> ChunkedTransaction: @overload def execute( - self, - client: Client, - timeout: int | float | None = None, - wait_for_receipt: Literal[True] = True, - validate_status: bool = False, + 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, + 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, + self, + client: Client, + timeout: int | float | None = None, + wait_for_receipt: bool = True, + validate_status: bool = False, ) -> TransactionReceipt | TransactionResponse: """ Executes the chunked transaction. @@ -232,30 +230,30 @@ def execute( @overload def execute_all( - self, - client: Client, - timeout: int | float | None = None, - wait_for_receipt: Literal[True] = True, - validate_status: bool = False, + 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, + 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, + 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. diff --git a/tests/integration/topic_message_submit_transaction_e2e_test.py b/tests/integration/topic_message_submit_transaction_e2e_test.py index 0dadc4ee4..85901e430 100644 --- a/tests/integration/topic_message_submit_transaction_e2e_test.py +++ b/tests/integration/topic_message_submit_transaction_e2e_test.py @@ -105,12 +105,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) - ) + message_tx = TopicMessageSubmitTransaction().set_topic_id(topic_id).set_message(message).set_max_chunks(2) with pytest.raises(ValueError): message_tx.freeze_with(env.client) diff --git a/tests/unit/chunked_transaction_test.py b/tests/unit/chunked_transaction_test.py index c912a36b6..1a743ec03 100644 --- a/tests/unit/chunked_transaction_test.py +++ b/tests/unit/chunked_transaction_test.py @@ -49,7 +49,10 @@ def test_constructor_sets_default_chunk_configuration(): 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")]) +@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() From 04c44962df55de5f8b087d871d85b04d6dedd52f Mon Sep 17 00:00:00 2001 From: MonaaEid Date: Mon, 29 Jun 2026 22:59:36 +0300 Subject: [PATCH 03/12] Create ChunkedTransaction class Signed-off-by: MonaaEid --- .../consensus/topic_message_submit_transaction.py | 4 ++-- .../file/file_append_transaction.py | 1 + .../transaction/chunked_transaction.py | 13 ++++--------- tests/unit/chunked_transaction_test.py | 2 +- 4 files changed, 8 insertions(+), 12 deletions(-) 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 a8fd82407..82c9f5ffa 100644 --- a/src/hiero_sdk_python/consensus/topic_message_submit_transaction.py +++ b/src/hiero_sdk_python/consensus/topic_message_submit_transaction.py @@ -1,5 +1,7 @@ from __future__ import annotations +import math + 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 @@ -63,7 +65,6 @@ def _message_as_bytes(self) -> bytes: return self.message.encode("utf-8") if isinstance(self.message, str) else self.message - def get_required_chunks(self) -> int: """ Returns the number of chunks required for the current message. @@ -75,7 +76,6 @@ def get_required_chunks(self) -> int: return 1 content = self._message_as_bytes() - import math return math.ceil(len(content) / self.chunk_size) def set_topic_id(self, topic_id: TopicId) -> TopicMessageSubmitTransaction: diff --git a/src/hiero_sdk_python/file/file_append_transaction.py b/src/hiero_sdk_python/file/file_append_transaction.py index b99a10805..0b954304a 100644 --- a/src/hiero_sdk_python/file/file_append_transaction.py +++ b/src/hiero_sdk_python/file/file_append_transaction.py @@ -10,6 +10,7 @@ Inherits from the base Transaction class and implements the required methods to build and execute a file append transaction. """ + from __future__ import annotations import math diff --git a/src/hiero_sdk_python/transaction/chunked_transaction.py b/src/hiero_sdk_python/transaction/chunked_transaction.py index 415c070f5..67ffdb7d5 100644 --- a/src/hiero_sdk_python/transaction/chunked_transaction.py +++ b/src/hiero_sdk_python/transaction/chunked_transaction.py @@ -132,7 +132,6 @@ def _validate_chunking(self) -> int: ) return required - def freeze_with(self, client: Client) -> ChunkedTransaction: """ Freezes the transaction by building transaction bodies for all chunks. @@ -189,8 +188,7 @@ def execute( timeout: int | float | None = None, wait_for_receipt: Literal[True] = True, validate_status: bool = False, - ) -> TransactionReceipt: - ... + ) -> TransactionReceipt: ... @overload def execute( @@ -199,8 +197,7 @@ def execute( timeout: int | float | None = None, wait_for_receipt: Literal[False] = False, validate_status: bool = False, - ) -> TransactionResponse: - ... + ) -> TransactionResponse: ... def execute( self, @@ -235,8 +232,7 @@ def execute_all( timeout: int | float | None = None, wait_for_receipt: Literal[True] = True, validate_status: bool = False, - ) -> list[TransactionReceipt]: - ... + ) -> list[TransactionReceipt]: ... @overload def execute_all( @@ -245,8 +241,7 @@ def execute_all( timeout: int | float | None = None, wait_for_receipt: Literal[False] = False, validate_status: bool = False, - ) -> list[TransactionResponse]: - ... + ) -> list[TransactionResponse]: ... def execute_all( self, diff --git a/tests/unit/chunked_transaction_test.py b/tests/unit/chunked_transaction_test.py index 1a743ec03..3fd39fc58 100644 --- a/tests/unit/chunked_transaction_test.py +++ b/tests/unit/chunked_transaction_test.py @@ -52,7 +52,7 @@ def test_constructor_sets_default_chunk_configuration(): @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() From 3071135dfb5bc3ab713cb35f9aad4b490769d4b4 Mon Sep 17 00:00:00 2001 From: MonaaEid Date: Sat, 4 Jul 2026 19:16:54 +0300 Subject: [PATCH 04/12] Create ChunkedTransaction class Signed-off-by: MonaaEid --- .github/workflows/pr-check-primary-codeql.yml | 4 +- .../file/file_append_transaction.py | 15 +++--- .../transaction/chunked_transaction.py | 32 +++--------- tck/handlers/token.py | 49 +++++++++++++++++++ tck/param/token.py | 28 +++++++++++ tck/response/token.py | 5 ++ ...pic_message_submit_transaction_e2e_test.py | 4 +- 7 files changed, 103 insertions(+), 34 deletions(-) diff --git a/.github/workflows/pr-check-primary-codeql.yml b/.github/workflows/pr-check-primary-codeql.yml index cad50be74..51f930a66 100644 --- a/.github/workflows/pr-check-primary-codeql.yml +++ b/.github/workflows/pr-check-primary-codeql.yml @@ -42,7 +42,7 @@ jobs: egress-policy: audit - name: Initialize CodeQL - uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -66,6 +66,6 @@ jobs: run: uv sync --frozen --all-groups --all-packages --all-extras - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3 with: category: "/language:${{matrix.language}}" diff --git a/src/hiero_sdk_python/file/file_append_transaction.py b/src/hiero_sdk_python/file/file_append_transaction.py index 0b954304a..f53f5eaae 100644 --- a/src/hiero_sdk_python/file/file_append_transaction.py +++ b/src/hiero_sdk_python/file/file_append_transaction.py @@ -39,10 +39,15 @@ def __init__( 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() + 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: @@ -121,8 +126,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: @@ -135,8 +139,7 @@ def set_chunk_size(self, chunk_size: int) -> FileAppendTransaction: Returns: FileAppendTransaction: This transaction instance. """ - self._require_not_frozen() - self.chunk_size = chunk_size + super().set_max_chunks(max_chunks) self._total_chunks = self._calculate_total_chunks() return self diff --git a/src/hiero_sdk_python/transaction/chunked_transaction.py b/src/hiero_sdk_python/transaction/chunked_transaction.py index 67ffdb7d5..9982e921b 100644 --- a/src/hiero_sdk_python/transaction/chunked_transaction.py +++ b/src/hiero_sdk_python/transaction/chunked_transaction.py @@ -12,10 +12,6 @@ from hiero_sdk_python.transaction.transaction_response import TransactionResponse -if TYPE_CHECKING: - pass - - class ChunkedTransaction(Transaction, ABC): """ Abstract base class for transactions that support chunking. @@ -43,17 +39,6 @@ def __init__(self) -> None: self.chunk_size: int = 1024 self.max_chunks: int = 20 - @abstractmethod - def get_required_chunks(self) -> int: - """ - Returns the number of chunks required for the current content. - - Subclasses must implement this based on their content type. - - Returns: - int: Number of chunks required. - """ - pass @abstractmethod def _build_proto_body(self): @@ -148,10 +133,9 @@ def freeze_with(self, client: Client) -> ChunkedTransaction: if self._transaction_body_bytes: return self - self._validate_chunking() self._resolve_transaction_id(client) - if self.transaction_id is None or self.transaction_id.valid_start is None: + 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 @@ -265,11 +249,10 @@ def execute_all( List[TransactionReceipt]: If wait_for_receipt is True (default) List[TransactionResponse]: If wait_for_receipt is False """ - self._validate_chunking() - - required_chunks = self.get_required_chunks() + self._validate_chunking() # Moved here - if required_chunks == 1: + # For single-chunk transactions, delegate to the standard execution flow. + if self.get_required_chunks() == 1: return [ super().execute( client, @@ -279,13 +262,14 @@ def execute_all( ) ] - # Ensure the initial transaction ID and chunk transaction IDs exist. - if not self._transaction_ids: + # For multi-chunk transactions, ensure we are frozen before proceeding. + if not self._transaction_body_bytes: self.freeze_with(client) responses = [] + required_chunks = self.get_required_chunks() - for chunk_index in range(required_chunks): + for chunk_index in range(self.get_required_chunks()): self._current_chunk_index = chunk_index if chunk_index < len(self._transaction_ids): diff --git a/tck/handlers/token.py b/tck/handlers/token.py index 2f8198548..a66b4b01e 100644 --- a/tck/handlers/token.py +++ b/tck/handlers/token.py @@ -13,6 +13,8 @@ from hiero_sdk_python.tokens.fee_assessment_method import FeeAssessmentMethod from hiero_sdk_python.tokens.nft_id import NftId from hiero_sdk_python.tokens.supply_type import SupplyType +from hiero_sdk_python.tokens.token_airdrop_claim import TokenClaimAirdropTransaction +from hiero_sdk_python.tokens.token_airdrop_pending_id import PendingAirdropId from hiero_sdk_python.tokens.token_airdrop_transaction import TokenAirdropTransaction from hiero_sdk_python.tokens.token_associate_transaction import TokenAssociateTransaction from hiero_sdk_python.tokens.token_create_transaction import TokenCreateTransaction @@ -28,6 +30,7 @@ from tck.param.token import ( AirdropTokenParams, AssociateTokenParams, + ClaimTokenParams, CreateTokenParams, DeleteTokenParams, FreezeTokenParams, @@ -37,6 +40,7 @@ from tck.response.token import ( AirdropTokenResponse, AssociateTokenResponse, + ClaimTokenResponse, CreateTokenResponse, DeleteTokenResponse, FreezeTokenResponse, @@ -429,3 +433,48 @@ def airdrop_token(params: AirdropTokenParams) -> AirdropTokenResponse: ) return AirdropTokenResponse(status=ResponseCode(receipt.status).name) + + +def _build_claim_token_transaction(params: ClaimTokenParams) -> TokenClaimAirdropTransaction: + """Build a TokenClaimAirdropTransaction from TCK params.""" + transaction = TokenClaimAirdropTransaction().set_grpc_deadline(DEFAULT_GRPC_TIMEOUT) + + sender_id = AccountId.from_string(params.senderAccountId) + receiver_id = AccountId.from_string(params.receiverAccountId) + token_id = TokenId.from_string(params.tokenId) + + if params.serialNumbers: + for serial_number in params.serialNumbers: + transaction.add_pending_airdrop_id( + PendingAirdropId( + sender_id=sender_id, + receiver_id=receiver_id, + nft_id=NftId(token_id=token_id, serial_number=int(serial_number)), + ) + ) + else: + transaction.add_pending_airdrop_id( + PendingAirdropId( + sender_id=sender_id, + receiver_id=receiver_id, + token_id=token_id, + ) + ) + + return transaction + + +@rpc_method("claimToken") +def claim_token(params: ClaimTokenParams) -> ClaimTokenResponse: + """Claim pending token airdrops using TCK claimToken parameters.""" + client = get_client(params.sessionId) + + transaction = _build_claim_token_transaction(params) + + if params.commonTransactionParams is not None: + params.commonTransactionParams.apply_common_params(transaction, client) + + response = transaction.execute(client, wait_for_receipt=False) + receipt: TransactionReceipt = response.get_receipt(client, validate_status=True) + + return ClaimTokenResponse(status=ResponseCode(receipt.status).name) diff --git a/tck/param/token.py b/tck/param/token.py index 2950b9a62..fe34cc7c3 100644 --- a/tck/param/token.py +++ b/tck/param/token.py @@ -204,3 +204,31 @@ def parse_json_params(cls, params: dict) -> AirdropTokenParams: sessionId=parse_session_id(params), commonTransactionParams=parse_common_transaction_params(params), ) + + +@dataclass +class ClaimTokenParams(BaseTransactionParams): + """Request parameters for the claimToken endpoint.""" + + senderAccountId: str | None = None + receiverAccountId: str | None = None + tokenId: str | None = None + serialNumbers: list[str] | None = None + + @classmethod + def parse_json_params(cls, params: dict) -> ClaimTokenParams: + """Parse JSON-RPC params into a ClaimTokenParams instance.""" + serial_numbers = params.get("serialNumbers") + if serial_numbers is not None and not isinstance(serial_numbers, list): + raise ValueError("serialNumbers must be a list") + if serial_numbers is not None and any(not isinstance(serial_number, str) for serial_number in serial_numbers): + raise ValueError("each serialNumbers item must be a string") + + return cls( + senderAccountId=params.get("senderAccountId"), + receiverAccountId=params.get("receiverAccountId"), + tokenId=params.get("tokenId"), + serialNumbers=serial_numbers, + sessionId=parse_session_id(params), + commonTransactionParams=parse_common_transaction_params(params), + ) diff --git a/tck/response/token.py b/tck/response/token.py index 56469ec73..e124f91ec 100644 --- a/tck/response/token.py +++ b/tck/response/token.py @@ -47,3 +47,8 @@ class PauseTokenResponse(StatusOnlyResponse): @dataclass class AirdropTokenResponse(StatusOnlyResponse): """Response payload for airdropToken.""" + + +@dataclass +class ClaimTokenResponse(StatusOnlyResponse): + """Response payload for claimToken.""" diff --git a/tests/integration/topic_message_submit_transaction_e2e_test.py b/tests/integration/topic_message_submit_transaction_e2e_test.py index 85901e430..26a62886b 100644 --- a/tests/integration/topic_message_submit_transaction_e2e_test.py +++ b/tests/integration/topic_message_submit_transaction_e2e_test.py @@ -108,7 +108,7 @@ def test_topic_message_submit_transaction_fails_if_max_chunks_less_than_requied( message_tx = TopicMessageSubmitTransaction().set_topic_id(topic_id).set_message(message).set_max_chunks(2) with pytest.raises(ValueError): - message_tx.freeze_with(env.client) + message_tx.execute(env.client) delete_topic(env.client, topic_id) @@ -289,7 +289,7 @@ def test_integration_topic_message_submit_transaction_fails_if_required_chunk_gr with pytest.raises( ValueError, match="Message requires 4 chunks but max_chunks=2. Increase limit with set_max_chunks()." ): - message_transaction.freeze_with(env.client) + message_transaction.execute(env.client) delete_topic(env.client, topic_id) From f8bd8cf65ab6bb43ad6531ff15f4ed9ae9956c03 Mon Sep 17 00:00:00 2001 From: MonaaEid Date: Sat, 4 Jul 2026 19:17:12 +0300 Subject: [PATCH 05/12] Create ChunkedTransaction class Signed-off-by: MonaaEid --- src/hiero_sdk_python/transaction/chunked_transaction.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hiero_sdk_python/transaction/chunked_transaction.py b/src/hiero_sdk_python/transaction/chunked_transaction.py index 9982e921b..0433b8489 100644 --- a/src/hiero_sdk_python/transaction/chunked_transaction.py +++ b/src/hiero_sdk_python/transaction/chunked_transaction.py @@ -39,7 +39,6 @@ def __init__(self) -> None: self.chunk_size: int = 1024 self.max_chunks: int = 20 - @abstractmethod def _build_proto_body(self): """ @@ -133,6 +132,7 @@ def freeze_with(self, client: Client) -> ChunkedTransaction: if self._transaction_body_bytes: return self + self._validate_chunking() self._resolve_transaction_id(client) if self.transaction_id.valid_start is None: @@ -249,7 +249,7 @@ def execute_all( List[TransactionReceipt]: If wait_for_receipt is True (default) List[TransactionResponse]: If wait_for_receipt is False """ - self._validate_chunking() # Moved here + self._validate_chunking() # For single-chunk transactions, delegate to the standard execution flow. if self.get_required_chunks() == 1: From 327859d17dcec97615a7d0226313dc00e8dc6635 Mon Sep 17 00:00:00 2001 From: MonaaEid Date: Sat, 4 Jul 2026 19:44:06 +0300 Subject: [PATCH 06/12] Create ChunkedTransaction class Signed-off-by: MonaaEid --- src/hiero_sdk_python/file/file_append_transaction.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hiero_sdk_python/file/file_append_transaction.py b/src/hiero_sdk_python/file/file_append_transaction.py index f53f5eaae..cec4f2254 100644 --- a/src/hiero_sdk_python/file/file_append_transaction.py +++ b/src/hiero_sdk_python/file/file_append_transaction.py @@ -47,7 +47,7 @@ def __init__( 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: @@ -139,7 +139,7 @@ def set_chunk_size(self, chunk_size: int) -> FileAppendTransaction: Returns: FileAppendTransaction: This transaction instance. """ - super().set_max_chunks(max_chunks) + super().set_chunk_size(chunk_size) self._total_chunks = self._calculate_total_chunks() return self From 99ba75c40a240b3b0012bd01171be5b01131f3ba Mon Sep 17 00:00:00 2001 From: MonaaEid Date: Sat, 4 Jul 2026 19:50:20 +0300 Subject: [PATCH 07/12] Create ChunkedTransaction class Signed-off-by: MonaaEid --- src/hiero_sdk_python/transaction/chunked_transaction.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hiero_sdk_python/transaction/chunked_transaction.py b/src/hiero_sdk_python/transaction/chunked_transaction.py index 0433b8489..7522c1f3e 100644 --- a/src/hiero_sdk_python/transaction/chunked_transaction.py +++ b/src/hiero_sdk_python/transaction/chunked_transaction.py @@ -1,7 +1,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Literal, overload +from typing import Literal, overload from hiero_sdk_python.client.client import Client from hiero_sdk_python.crypto.private_key import PrivateKey @@ -267,7 +267,7 @@ def execute_all( self.freeze_with(client) responses = [] - required_chunks = self.get_required_chunks() + self.get_required_chunks() for chunk_index in range(self.get_required_chunks()): self._current_chunk_index = chunk_index From 34149b5a4629c29eae2157016feb556a30596386 Mon Sep 17 00:00:00 2001 From: MonaaEid Date: Mon, 6 Jul 2026 23:40:08 +0300 Subject: [PATCH 08/12] Create ChunkedTransaction class Signed-off-by: MonaaEid --- tests/integration/topic_message_submit_transaction_e2e_test.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/integration/topic_message_submit_transaction_e2e_test.py b/tests/integration/topic_message_submit_transaction_e2e_test.py index 26a62886b..4fbb52ff7 100644 --- a/tests/integration/topic_message_submit_transaction_e2e_test.py +++ b/tests/integration/topic_message_submit_transaction_e2e_test.py @@ -32,7 +32,6 @@ def create_topic(client, admin_key=None, submit_key=None, custom_fees=None): tx.set_submit_key(submit_key) if custom_fees: tx.set_custom_fees(custom_fees) - tx.transaction_fee = Hbar(30).to_tinybars() receipt = tx.execute(client) assert receipt.status == ResponseCode.SUCCESS, f"Topic creation failed: {ResponseCode(receipt.status).name}" From 5438830d639bdb0d0de80ec0dabd8f806e342c80 Mon Sep 17 00:00:00 2001 From: MonaaEid Date: Sun, 12 Jul 2026 18:47:04 +0300 Subject: [PATCH 09/12] Create ChunkedTransaction class Signed-off-by: MonaaEid --- .../topic_message_submit_transaction.py | 10 +++---- .../file/file_append_transaction.py | 28 +++++++++---------- .../transaction/chunked_transaction.py | 1 - tests/unit/chunked_transaction_test.py | 13 +++++++++ tests/unit/file_append_transaction_test.py | 2 +- 5 files changed, 32 insertions(+), 22 deletions(-) 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 82c9f5ffa..627c41831 100644 --- a/src/hiero_sdk_python/consensus/topic_message_submit_transaction.py +++ b/src/hiero_sdk_python/consensus/topic_message_submit_transaction.py @@ -36,7 +36,7 @@ def __init__( Args: topic_id (TopicId, optional): The ID of the topic. - message (str, optional): The message to submit. + 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. """ @@ -46,10 +46,10 @@ def __init__( self.chunk_size: int = 1024 self.max_chunks: int = 20 if chunk_size is not None: - super().set_chunk_size(chunk_size) + self.set_chunk_size(chunk_size) if max_chunks is not None: - super().set_max_chunks(max_chunks) + self.set_max_chunks(max_chunks) self._total_chunks = self.get_required_chunks() @@ -97,7 +97,7 @@ 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). @@ -172,7 +172,7 @@ 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_as_bytes() diff --git a/src/hiero_sdk_python/file/file_append_transaction.py b/src/hiero_sdk_python/file/file_append_transaction.py index cec4f2254..4164656ba 100644 --- a/src/hiero_sdk_python/file/file_append_transaction.py +++ b/src/hiero_sdk_python/file/file_append_transaction.py @@ -7,7 +7,7 @@ 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. """ @@ -29,6 +29,18 @@ class FileAppendTransaction(ChunkedTransaction): + """ + Represents a file append transaction on the network. + + This transaction appends data to an existing file on the network. If a file has multiple keys, + all keys must sign to modify its contents. + + 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 ChunkedTransaction class and implements the required methods + to build and execute a file append transaction. + """ def __init__( self, file_id: FileId | None = None, @@ -140,7 +152,6 @@ def set_chunk_size(self, chunk_size: int) -> FileAppendTransaction: FileAppendTransaction: This transaction instance. """ super().set_chunk_size(chunk_size) - self._total_chunks = self._calculate_total_chunks() return self def _build_proto_body(self) -> file_append_pb2.FileAppendTransactionBody: @@ -223,16 +234,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()}" - ) diff --git a/src/hiero_sdk_python/transaction/chunked_transaction.py b/src/hiero_sdk_python/transaction/chunked_transaction.py index 7522c1f3e..b2c940bb8 100644 --- a/src/hiero_sdk_python/transaction/chunked_transaction.py +++ b/src/hiero_sdk_python/transaction/chunked_transaction.py @@ -267,7 +267,6 @@ def execute_all( self.freeze_with(client) responses = [] - self.get_required_chunks() for chunk_index in range(self.get_required_chunks()): self._current_chunk_index = chunk_index diff --git a/tests/unit/chunked_transaction_test.py b/tests/unit/chunked_transaction_test.py index 3fd39fc58..772e80967 100644 --- a/tests/unit/chunked_transaction_test.py +++ b/tests/unit/chunked_transaction_test.py @@ -39,6 +39,7 @@ def build_scheduled_body(self): def test_constructor_sets_default_chunk_configuration(): + """Test that the constructor sets the default chunk configuration.""" tx = DummyChunkedTransaction() assert tx.chunk_size == 1024 @@ -61,6 +62,8 @@ def test_setters_reject_non_positive_values(mock_client, setter_name, value, mes 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"): @@ -68,6 +71,7 @@ def test_validate_chunking_rejects_zero_required_chunks(): 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( @@ -76,7 +80,16 @@ def test_validate_chunking_rejects_too_many_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) diff --git a/tests/unit/file_append_transaction_test.py b/tests/unit/file_append_transaction_test.py index 4b832c43e..fb9aa5e97 100644 --- a/tests/unit/file_append_transaction_test.py +++ b/tests/unit/file_append_transaction_test.py @@ -119,7 +119,7 @@ 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() From 140e752c7cd04ce6464fe63fe46862e8eaa382d4 Mon Sep 17 00:00:00 2001 From: MonaaEid Date: Sun, 12 Jul 2026 18:57:41 +0300 Subject: [PATCH 10/12] Create ChunkedTransaction class Signed-off-by: MonaaEid --- src/hiero_sdk_python/file/file_append_transaction.py | 1 + tests/unit/chunked_transaction_test.py | 5 +++-- tests/unit/file_append_transaction_test.py | 4 +++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/hiero_sdk_python/file/file_append_transaction.py b/src/hiero_sdk_python/file/file_append_transaction.py index 4164656ba..b944efb59 100644 --- a/src/hiero_sdk_python/file/file_append_transaction.py +++ b/src/hiero_sdk_python/file/file_append_transaction.py @@ -41,6 +41,7 @@ class FileAppendTransaction(ChunkedTransaction): Inherits from the base ChunkedTransaction class and implements the required methods to build and execute a file append transaction. """ + def __init__( self, file_id: FileId | None = None, diff --git a/tests/unit/chunked_transaction_test.py b/tests/unit/chunked_transaction_test.py index 772e80967..6f630fec1 100644 --- a/tests/unit/chunked_transaction_test.py +++ b/tests/unit/chunked_transaction_test.py @@ -62,8 +62,7 @@ def test_setters_reject_non_positive_values(mock_client, setter_name, value, mes def test_validate_chunking_rejects_zero_required_chunks(): - """Test that _validate_chunking raises a ValueError when the required chunks is zero. - """ + """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"): @@ -80,6 +79,7 @@ def test_validate_chunking_rejects_too_many_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) @@ -90,6 +90,7 @@ def test_freeze_with_rejects_too_many_chunks(mock_client): ): 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) diff --git a/tests/unit/file_append_transaction_test.py b/tests/unit/file_append_transaction_test.py index fb9aa5e97..0b3c738d4 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="Message requires 140 chunks but max_chunks=5. Increase limit with set_max_chunks()."): + with pytest.raises( + ValueError, match="Message requires 140 chunks but max_chunks=5. Increase limit with set_max_chunks()." + ): file_tx._validate_chunking() From 19bf43177ce9cc01dbff57f963666a36eca0418a Mon Sep 17 00:00:00 2001 From: MonaaEid Date: Sun, 12 Jul 2026 19:00:30 +0300 Subject: [PATCH 11/12] Create ChunkedTransaction class Signed-off-by: MonaaEid --- tests/unit/chunked_transaction_test.py | 2 +- tests/unit/file_append_transaction_test.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/chunked_transaction_test.py b/tests/unit/chunked_transaction_test.py index 6f630fec1..f9bc20fd5 100644 --- a/tests/unit/chunked_transaction_test.py +++ b/tests/unit/chunked_transaction_test.py @@ -89,7 +89,7 @@ def test_freeze_with_rejects_too_many_chunks(mock_client): 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) diff --git a/tests/unit/file_append_transaction_test.py b/tests/unit/file_append_transaction_test.py index 0b3c738d4..655cc3be2 100644 --- a/tests/unit/file_append_transaction_test.py +++ b/tests/unit/file_append_transaction_test.py @@ -121,7 +121,7 @@ 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()." - ): + ): file_tx._validate_chunking() From c6d2ebab77b10ab37a0da5b057338c24a2c97a2e Mon Sep 17 00:00:00 2001 From: MonaaEid Date: Tue, 14 Jul 2026 19:18:26 +0300 Subject: [PATCH 12/12] Create ChunkedTransaction class Signed-off-by: MonaaEid --- tests/integration/file_append_transaction_e2e_test.py | 4 +++- tests/unit/chunked_transaction_test.py | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) 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/unit/chunked_transaction_test.py b/tests/unit/chunked_transaction_test.py index f9bc20fd5..aeb989024 100644 --- a/tests/unit/chunked_transaction_test.py +++ b/tests/unit/chunked_transaction_test.py @@ -165,3 +165,12 @@ 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 + + +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