From 78dd43fc288e01b66b6653635042abbaada58f21 Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Sun, 18 Oct 2026 18:27:47 +0300 Subject: [PATCH] fix: validate chunk order and identity when deserializing chunked transactions Transaction.from_bytes() now rejects a TransactionList for FileAppendTransaction or TopicMessageSubmitTransaction unless its chunk groups share one payer, have strictly increasing valid starts and, for topic messages, carry chunk info matching the chunk sequence. The checks are derived from each chunk group's body rather than from the transaction IDs collected in first-seen order, which always matched the input order and let reordered chunk groups pass. from_bytes() now only falls back to parsing a single Transaction when the bytes do not decode to a non-empty TransactionList, so validation errors reach the caller instead of being replaced by an unrelated parse error. Signed-off-by: Ntege Daniel --- .../transaction/transaction.py | 142 +++++++-- .../chunked_transaction_from_bytes_test.py | 276 ++++++++++++++++++ tests/unit/file_append_transaction_test.py | 5 +- .../topic_message_submit_transaction_test.py | 9 +- .../unit/transaction_freeze_and_bytes_test.py | 46 +++ tests/unit/transaction_test.py | 10 +- 6 files changed, 460 insertions(+), 28 deletions(-) create mode 100644 tests/unit/chunked_transaction_from_bytes_test.py diff --git a/src/hiero_sdk_python/transaction/transaction.py b/src/hiero_sdk_python/transaction/transaction.py index 8a730e63b..5dcdc653e 100644 --- a/src/hiero_sdk_python/transaction/transaction.py +++ b/src/hiero_sdk_python/transaction/transaction.py @@ -1,8 +1,11 @@ from __future__ import annotations import hashlib +from itertools import pairwise from typing import TYPE_CHECKING, Literal, overload +from google.protobuf.message import DecodeError + from hiero_sdk_python.account.account_id import AccountId from hiero_sdk_python.client.client import Client from hiero_sdk_python.crypto.key import Key @@ -914,22 +917,48 @@ def from_bytes(transaction_bytes: bytes): Raises: ValueError: If the bytes are invalid, contain no transactions, - or contain an unknown transaction type. + contain an unknown transaction type, or contain chunks that are + incomplete, out of order, or from different chunked transactions. """ if not isinstance(transaction_bytes, bytes): raise ValueError("transaction_bytes must be bytes") if not transaction_bytes: raise ValueError("transaction_bytes cannot be empty") - try: - return Transaction._process_transaction_list_bytes(transaction_bytes) - except Exception: + transaction_list = Transaction._parse_transaction_list(transaction_bytes) + + if transaction_list is None: try: # Backward compatibility return Transaction._process_single_transaction_bytes(transaction_bytes) except Exception as e: raise ValueError(f"Failed to parse transaction_bytes {e}") from e + # The bytes are a TransactionList, so surface its validation errors instead of + # retrying them as a single Transaction, which would hide the real cause. + try: + return Transaction._process_transaction_list(transaction_list) + except ValueError: + raise + except Exception as e: + raise ValueError(f"Failed to parse transaction_bytes {e}") from e + + @staticmethod + def _parse_transaction_list(transaction_bytes: bytes) -> TransactionList | None: + """ + Decodes bytes as a TransactionList. + + Returns: + TransactionList | None: The decoded list, or None if the bytes do not decode + to a non-empty TransactionList (e.g. a single serialized Transaction). + """ + try: + transaction_list = TransactionList.FromString(transaction_bytes) + except DecodeError: + return None + + return transaction_list if transaction_list.transaction_list else None + @staticmethod def _process_base_transaction( transaction_proto: transaction_pb2.Transaction, @@ -995,19 +1024,10 @@ def _process_single_transaction_bytes(transaction_bytes: bytes) -> Transaction: return transaction @staticmethod - def _process_transaction_list_bytes(transaction_bytes: bytes) -> Transaction: - """Deserializes a TransactionList protobuf.""" - try: - transaction_list = TransactionList() - transaction_list.ParseFromString(transaction_bytes) - except Exception as e: - raise ValueError(f"Failed to parse TransactionList: {e}") from e - - if not transaction_list.transaction_list: - raise ValueError("TransactionList contains no transactions") - + def _process_transaction_list(transaction_list: TransactionList) -> Transaction: + """Restores a transaction from a decoded, non-empty TransactionList protobuf.""" restored_transaction: Transaction | None = None - transaction_ids: list[Transaction] = [] + transaction_ids: list[TransactionId] = [] node_account_ids: list[AccountId] = [] bodies: list[transaction_pb2.TransactionBody] = [] @@ -1169,6 +1189,13 @@ def _validate_transaction_bodies( if has_unexpected_body_count or has_invalid_transaction_count: raise ValueError("Failed to validate transaction bodies") + if is_chunk_transaction: + Transaction._validate_chunked_transaction_bodies( + transaction_type, + bodies, + group_size, + ) + return # `_transaction_body_bytes` maps each transaction ID to its node-specific body bytes: `transaction_id -> {node_id -> body_bytes}`. @@ -1207,10 +1234,11 @@ def _validate_chunked_transaction_bodies( transaction_type: str, bodies: list[transaction_pb2.TransactionBody], group_size: int ) -> None: """ - Validate invariant fields shared across chunk groups. + Validate that the chunk groups form a single chunked transaction, in sequence. Raises: - ValueError: If invariant fields differ between chunk groups. + ValueError: If invariant fields differ between chunk groups, or the chunk + groups are out of order or belong to different chunked transactions. """ ignored_fields = {"nodeAccountID", "transactionID"} @@ -1229,6 +1257,84 @@ def _validate_chunked_transaction_bodies( ): raise ValueError("Failed to validate transaction bodies") + # The transaction IDs are collected in first-seen order, so they always match the input order. + # Chunk order is therefore checked against each chunk group's own body instead. + chunk_bodies = bodies[::group_size] + + Transaction._validate_chunk_transaction_ids(chunk_bodies) + + if transaction_type == "consensusSubmitMessage": + Transaction._validate_topic_message_chunk_info(chunk_bodies) + + @staticmethod + def _validate_chunk_transaction_ids(chunk_bodies: list[transaction_pb2.TransactionBody]) -> None: + """ + Validate that chunk transaction IDs share one payer and have strictly increasing valid starts. + + Only the order is enforced, not the step between chunks: other SDKs space chunk + valid starts differently (the JS SDK's FileAppendTransaction defaults to 10ns). + + Raises: + ValueError: If a chunk has a different payer or does not start after the previous chunk. + """ + payer_account_id = chunk_bodies[0].transactionID.accountID + + for chunk_number, (previous_body, body) in enumerate(pairwise(chunk_bodies), start=2): + if body.transactionID.accountID != payer_account_id: + raise ValueError( + f"Failed to validate transaction bodies: chunk {chunk_number} has a different payer account than chunk 1" + ) + + previous_start = previous_body.transactionID.transactionValidStart + valid_start = body.transactionID.transactionValidStart + + if (valid_start.seconds, valid_start.nanos) <= (previous_start.seconds, previous_start.nanos): + raise ValueError( + "Failed to validate transaction bodies: chunks are out of order, " + f"chunk {chunk_number} does not start after chunk {chunk_number - 1}" + ) + + @staticmethod + def _validate_topic_message_chunk_info(chunk_bodies: list[transaction_pb2.TransactionBody]) -> None: + """ + Validate topic message chunk info against the chunk sequence. + + A single-chunk message may omit chunk info. Otherwise every chunk must carry its + 1-based position, the total chunk count, and the first chunk's transaction ID. + + Raises: + ValueError: If chunk info is missing or does not match the chunk sequence. + """ + total = len(chunk_bodies) + + if total == 1 and not chunk_bodies[0].consensusSubmitMessage.HasField("chunkInfo"): + return + + initial_transaction_id = chunk_bodies[0].transactionID + + for number, body in enumerate(chunk_bodies, start=1): + if not body.consensusSubmitMessage.HasField("chunkInfo"): + raise ValueError(f"Failed to validate transaction bodies: chunk {number} is missing chunk info") + + chunk_info = body.consensusSubmitMessage.chunkInfo + + if chunk_info.number != number: + raise ValueError( + f"Failed to validate transaction bodies: chunk {number} has chunk number {chunk_info.number}" + ) + + if chunk_info.total != total: + raise ValueError( + f"Failed to validate transaction bodies: chunk {number} has chunk total {chunk_info.total}, " + f"expected {total}" + ) + + if chunk_info.initialTransactionID != initial_transaction_id: + raise ValueError( + f"Failed to validate transaction bodies: chunk {number} initial transaction ID " + "does not match the transaction ID of chunk 1" + ) + @staticmethod def _compare_transaction_bodies( first: transaction_pb2.TransactionBody, diff --git a/tests/unit/chunked_transaction_from_bytes_test.py b/tests/unit/chunked_transaction_from_bytes_test.py new file mode 100644 index 000000000..152556b98 --- /dev/null +++ b/tests/unit/chunked_transaction_from_bytes_test.py @@ -0,0 +1,276 @@ +""" +Unit tests for restoring chunked transactions from a serialized TransactionList. + +Transaction.from_bytes() must reject chunk groups of a FileAppendTransaction or +TopicMessageSubmitTransaction that are out of order, incomplete, or belong to +different chunked transactions, while valid lists still round-trip unchanged. +""" + +from __future__ import annotations + +from collections.abc import Callable + +import pytest + +from hiero_sdk_python.account.account_id import AccountId +from hiero_sdk_python.consensus.topic_id import TopicId +from hiero_sdk_python.consensus.topic_message_submit_transaction import TopicMessageSubmitTransaction +from hiero_sdk_python.crypto.private_key import PrivateKey +from hiero_sdk_python.file.file_append_transaction import FileAppendTransaction +from hiero_sdk_python.file.file_id import FileId +from hiero_sdk_python.hapi.sdk.transaction_list_pb2 import TransactionList +from hiero_sdk_python.hapi.services import transaction_contents_pb2, transaction_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 + +BodyRewrite = Callable[[int, transaction_pb2.TransactionBody], None] + + +def _file_append_transaction(node_account_ids: list[AccountId], contents: bytes = b"AAAABBBB") -> FileAppendTransaction: + """Return a frozen FileAppendTransaction with one chunk per 4 bytes of contents.""" + return ( + FileAppendTransaction() + .set_file_id(FileId(0, 0, 101)) + .set_chunk_size(4) + .set_contents(contents) + .set_transaction_id(TransactionId.from_string("0.0.1001@1700000000.0")) + .set_node_account_ids(node_account_ids) + .freeze() + ) + + +def _topic_message_transaction( + node_account_ids: list[AccountId], message: bytes = b"AAAABBBB" +) -> TopicMessageSubmitTransaction: + """Return a frozen TopicMessageSubmitTransaction with one chunk per 4 bytes of message.""" + return ( + TopicMessageSubmitTransaction() + .set_topic_id(TopicId(0, 0, 1234)) + .set_chunk_size(4) + .set_message(message) + .set_transaction_id(TransactionId.from_string("0.0.1001@1700000000.0")) + .set_node_account_ids(node_account_ids) + .freeze() + ) + + +@pytest.fixture(params=[_file_append_transaction, _topic_message_transaction], ids=["file_append", "topic_message"]) +def build_chunked_transaction(request) -> Callable[..., ChunkedTransaction]: + """Fixture that provides a builder for each chunked transaction type.""" + return request.param + + +@pytest.fixture( + params=[[AccountId(0, 0, 3)], [AccountId(0, 0, 3), AccountId(0, 0, 4)]], + ids=["single_node", "multiple_nodes"], +) +def node_account_ids(request) -> list[AccountId]: + """Fixture that provides the node account IDs each chunk is built for.""" + return request.param + + +def _rewrite_bodies(tx_bytes: bytes, node_count: int, rewrite: BodyRewrite) -> bytes: + """Apply rewrite(chunk_index, body) to every body in a TransactionList and re-serialize it.""" + transaction_list = TransactionList.FromString(tx_bytes) + + for index, transaction in enumerate(transaction_list.transaction_list): + signed_transaction = transaction_contents_pb2.SignedTransaction.FromString(transaction.signedTransactionBytes) + body = transaction_pb2.TransactionBody.FromString(signed_transaction.bodyBytes) + + rewrite(index // node_count, body) + + signed_transaction.bodyBytes = body.SerializeToString() + transaction.signedTransactionBytes = signed_transaction.SerializeToString() + + return transaction_list.SerializeToString() + + +def _set_valid_start_nanos(tx_bytes: bytes, node_count: int, nanos: list[int]) -> bytes: + """Re-time each chunk's transaction ID, keeping a topic message's initial transaction ID on chunk 1.""" + + def rewrite(chunk_index: int, body: transaction_pb2.TransactionBody) -> None: + body.transactionID.transactionValidStart.nanos = nanos[chunk_index] + + if body.consensusSubmitMessage.HasField("chunkInfo"): + body.consensusSubmitMessage.chunkInfo.initialTransactionID.transactionValidStart.nanos = nanos[0] + + return _rewrite_bodies(tx_bytes, node_count, rewrite) + + +def _swap_first_chunk_groups(tx_bytes: bytes, node_count: int) -> bytes: + """Return the TransactionList with its first two chunk groups swapped.""" + transactions = list(TransactionList.FromString(tx_bytes).transaction_list) + first_group = transactions[:node_count] + second_group = transactions[node_count : 2 * node_count] + + return TransactionList( + transaction_list=second_group + first_group + transactions[2 * node_count :] + ).SerializeToString() + + +@pytest.mark.parametrize("contents", [b"AAAA", b"AAAABBBB", b"AAAABBBBCC"], ids=["1_chunk", "2_chunks", "3_chunks"]) +@pytest.mark.parametrize("signed", [False, True], ids=["unsigned", "signed"]) +def test_from_bytes_round_trips_chunked_transaction(build_chunked_transaction, node_account_ids, contents, signed): + """Test that frozen chunked transactions restore and re-serialize to identical bytes.""" + tx = build_chunked_transaction(node_account_ids, contents) + + if signed: + tx.sign(PrivateKey.generate_ed25519()) + + tx_bytes = tx.to_bytes() + restored = Transaction.from_bytes(tx_bytes) + + assert type(restored) is type(tx) + assert restored._transaction_ids.get_list() == tx._transaction_ids.get_list() + assert restored.to_bytes() == tx_bytes + + +def test_from_bytes_accepts_wider_chunk_interval(build_chunked_transaction, node_account_ids): + """Test that chunk valid starts only need to increase, since other SDKs space chunks by more than 1ns.""" + tx = build_chunked_transaction(node_account_ids, b"AAAABBBBCC") + tx_bytes = _set_valid_start_nanos(tx.to_bytes(), len(node_account_ids), [0, 10, 20]) + + restored = Transaction.from_bytes(tx_bytes) + + assert [transaction_id.valid_start.nanos for transaction_id in restored._transaction_ids] == [0, 10, 20] + assert restored.to_bytes() == tx_bytes + + +def test_from_bytes_accepts_single_chunk_topic_message_with_chunk_info(node_account_ids): + """Test that a single-chunk topic message carrying chunk info (as the JS SDK serializes it) is accepted.""" + tx = _topic_message_transaction(node_account_ids, b"AAAA") + + def add_chunk_info(_chunk_index: int, body: transaction_pb2.TransactionBody) -> None: + chunk_info = body.consensusSubmitMessage.chunkInfo + chunk_info.initialTransactionID.CopyFrom(body.transactionID) + chunk_info.total = 1 + chunk_info.number = 1 + + tx_bytes = _rewrite_bodies(tx.to_bytes(), len(node_account_ids), add_chunk_info) + + assert Transaction.from_bytes(tx_bytes).to_bytes() == tx_bytes + + +def test_from_bytes_rejects_swapped_chunk_groups(build_chunked_transaction, node_account_ids): + """Test that a TransactionList whose chunk groups were reordered is rejected.""" + tx = build_chunked_transaction(node_account_ids) + swapped_bytes = _swap_first_chunk_groups(tx.to_bytes(), len(node_account_ids)) + + with pytest.raises(ValueError, match="chunks are out of order, chunk 2 does not start after chunk 1"): + Transaction.from_bytes(swapped_bytes) + + +def test_from_bytes_rejects_chunks_with_different_payers(build_chunked_transaction, node_account_ids): + """Test that chunks paid for by different accounts are rejected.""" + tx = build_chunked_transaction(node_account_ids) + + def change_second_chunk_payer(chunk_index: int, body: transaction_pb2.TransactionBody) -> None: + if chunk_index == 1: + body.transactionID.accountID.CopyFrom(AccountId(0, 0, 1002)._to_proto()) + + tx_bytes = _rewrite_bodies(tx.to_bytes(), len(node_account_ids), change_second_chunk_payer) + + with pytest.raises(ValueError, match="chunk 2 has a different payer account than chunk 1"): + Transaction.from_bytes(tx_bytes) + + +@pytest.mark.parametrize( + "nanos, error", + [ + ([10, 5], "chunks are out of order, chunk 2 does not start after chunk 1"), + ([0, 2, 1], "chunks are out of order, chunk 3 does not start after chunk 2"), + # Equal valid starts make the chunk transaction IDs identical, which the transaction-node pair check rejects + ([0, 0], r"^Failed to validate transaction bodies$"), + ], + ids=["decreasing", "decreasing_later_chunk", "equal"], +) +def test_from_bytes_rejects_non_increasing_valid_start(build_chunked_transaction, node_account_ids, nanos, error): + """Test that chunk transaction IDs must have strictly increasing valid starts.""" + contents = b"AAAA" * len(nanos) + tx = build_chunked_transaction(node_account_ids, contents) + tx_bytes = _set_valid_start_nanos(tx.to_bytes(), len(node_account_ids), nanos) + + with pytest.raises(ValueError, match=error): + Transaction.from_bytes(tx_bytes) + + +def _swap_chunk_numbers(chunk_index: int, body: transaction_pb2.TransactionBody) -> None: + body.consensusSubmitMessage.chunkInfo.number = 2 - chunk_index + + +def _overstate_chunk_total(_chunk_index: int, body: transaction_pb2.TransactionBody) -> None: + body.consensusSubmitMessage.chunkInfo.total = 3 + + +def _point_initial_transaction_id_at_own_chunk(_chunk_index: int, body: transaction_pb2.TransactionBody) -> None: + body.consensusSubmitMessage.chunkInfo.initialTransactionID.CopyFrom(body.transactionID) + + +def _point_initial_transaction_id_at_other_transaction( + _chunk_index: int, body: transaction_pb2.TransactionBody +) -> None: + other_transaction_id = TransactionId.from_string("0.0.1001@1600000000.0") + body.consensusSubmitMessage.chunkInfo.initialTransactionID.CopyFrom(other_transaction_id._to_proto()) + + +def _drop_second_chunk_info(chunk_index: int, body: transaction_pb2.TransactionBody) -> None: + if chunk_index == 1: + body.consensusSubmitMessage.ClearField("chunkInfo") + + +@pytest.mark.parametrize( + "rewrite, error", + [ + (_swap_chunk_numbers, "chunk 1 has chunk number 2"), + (_overstate_chunk_total, "chunk 1 has chunk total 3, expected 2"), + (_point_initial_transaction_id_at_own_chunk, "chunk 2 initial transaction ID does not match"), + (_point_initial_transaction_id_at_other_transaction, "chunk 1 initial transaction ID does not match"), + (_drop_second_chunk_info, "chunk 2 is missing chunk info"), + ], + ids=["wrong_number", "wrong_total", "initial_id_per_chunk", "initial_id_elsewhere", "missing_chunk_info"], +) +def test_from_bytes_rejects_inconsistent_topic_message_chunk_info(node_account_ids, rewrite, error): + """Test that topic message chunk info must match the chunk sequence.""" + tx = _topic_message_transaction(node_account_ids) + tx_bytes = _rewrite_bodies(tx.to_bytes(), len(node_account_ids), rewrite) + + with pytest.raises(ValueError, match=error): + Transaction.from_bytes(tx_bytes) + + +def test_from_bytes_rejects_incomplete_topic_message(node_account_ids): + """Test that a topic message missing its last chunk group is rejected.""" + tx = _topic_message_transaction(node_account_ids) + transactions = TransactionList.FromString(tx.to_bytes()).transaction_list + first_group_bytes = TransactionList(transaction_list=transactions[: len(node_account_ids)]).SerializeToString() + + with pytest.raises(ValueError, match="chunk 1 has chunk total 2, expected 1"): + Transaction.from_bytes(first_group_bytes) + + +def _clear_node_account_id(_chunk_index: int, body: transaction_pb2.TransactionBody) -> None: + body.ClearField("nodeAccountID") + + +def test_from_bytes_accepts_ordered_chunks_without_node_account_ids(build_chunked_transaction): + """Test that in-order chunks without node account IDs are still accepted.""" + tx = build_chunked_transaction([AccountId(0, 0, 3)]) + tx_bytes = _rewrite_bodies(tx.to_bytes(), 1, _clear_node_account_id) + + restored = Transaction.from_bytes(tx_bytes) + + assert type(restored) is type(tx) + assert restored._transaction_ids.get_list() == tx._transaction_ids.get_list() + + +def test_from_bytes_rejects_swapped_chunks_without_node_account_ids(build_chunked_transaction): + """Test that chunk order is validated even when the bodies have no node account IDs.""" + tx = build_chunked_transaction([AccountId(0, 0, 3)]) + tx_bytes = _rewrite_bodies(tx.to_bytes(), 1, _clear_node_account_id) + + with pytest.raises(ValueError, match="chunks are out of order, chunk 2 does not start after chunk 1"): + Transaction.from_bytes(_swap_first_chunk_groups(tx_bytes, 1)) diff --git a/tests/unit/file_append_transaction_test.py b/tests/unit/file_append_transaction_test.py index 5d7853b46..84c7a6d51 100644 --- a/tests/unit/file_append_transaction_test.py +++ b/tests/unit/file_append_transaction_test.py @@ -707,8 +707,9 @@ def test_serialization_chunk_transaction_freeze(file_id): def test_validate_transaction_bodies_for_chunk_file_append_tx(): """Test validation of chunked file append transaction bodies.""" - transaction_id_1 = TransactionId.generate(AccountId(0, 0, 2)) - transaction_id_2 = TransactionId.generate(AccountId(0, 0, 2)) + # Chunk transaction IDs share the payer and have increasing valid starts, as freeze() generates them + transaction_id_1 = TransactionId.from_string("0.0.2@1700000000.0") + transaction_id_2 = TransactionId.from_string("0.0.2@1700000000.1") chunk_1 = file_append_pb2.FileAppendTransactionBody( fileID=FileId.from_string("0.0.101")._to_proto(), contents=b"Hello" diff --git a/tests/unit/topic_message_submit_transaction_test.py b/tests/unit/topic_message_submit_transaction_test.py index b346562ba..01e6e18fd 100644 --- a/tests/unit/topic_message_submit_transaction_test.py +++ b/tests/unit/topic_message_submit_transaction_test.py @@ -895,14 +895,15 @@ def test_signing_serialize_chunk_transaction_sign_all_available_bytes(topic_id): def test_validate_transaction_bodies_for_chunk_message_submit_tx(): """Test validation of chunked message submit transaction bodies.""" - transaction_id_1 = TransactionId.generate(AccountId(0, 0, 2)) - transaction_id_2 = TransactionId.generate(AccountId(0, 0, 2)) + # Chunk transaction IDs share the payer and have increasing valid starts, as freeze() generates them + transaction_id_1 = TransactionId.from_string("0.0.2@1700000000.0") + transaction_id_2 = TransactionId.from_string("0.0.2@1700000000.1") chunk_1 = consensus_submit_message_pb2.ConsensusSubmitMessageTransactionBody( topicID=TopicId.from_string("0.0.1")._to_proto(), message=b"Hello", chunkInfo=consensus_submit_message_pb2.ConsensusMessageChunkInfo( - initialTransactionID=TransactionId.generate(AccountId.from_string("0.0.2"))._to_proto(), total=2, number=1 + initialTransactionID=transaction_id_1._to_proto(), total=2, number=1 ), ) @@ -910,7 +911,7 @@ def test_validate_transaction_bodies_for_chunk_message_submit_tx(): topicID=TopicId.from_string("0.0.1")._to_proto(), message=b"World", chunkInfo=consensus_submit_message_pb2.ConsensusMessageChunkInfo( - initialTransactionID=TransactionId.generate(AccountId.from_string("0.0.2"))._to_proto(), total=2, number=1 + initialTransactionID=transaction_id_1._to_proto(), total=2, number=2 ), ) diff --git a/tests/unit/transaction_freeze_and_bytes_test.py b/tests/unit/transaction_freeze_and_bytes_test.py index fddbe8bc8..283236604 100644 --- a/tests/unit/transaction_freeze_and_bytes_test.py +++ b/tests/unit/transaction_freeze_and_bytes_test.py @@ -13,6 +13,7 @@ from hiero_sdk_python.account.account_create_transaction import AccountCreateTransaction from hiero_sdk_python.account.account_id import AccountId from hiero_sdk_python.crypto.private_key import PrivateKey +from hiero_sdk_python.hapi.sdk.transaction_list_pb2 import TransactionList from hiero_sdk_python.hapi.services.transaction_response_pb2 import ( TransactionResponse as TransactionResponseProto, ) @@ -181,6 +182,51 @@ def test_from_bytes_invalid_input(): TransferTransaction.from_bytes(b"invalid protobuf data") +def test_from_bytes_accepts_single_transaction_bytes(): + """Test that from_bytes() still restores a single serialized Transaction (not a TransactionList).""" + node_id = AccountId.from_string("0.0.3") + transaction = ( + TransferTransaction() + .add_hbar_transfer(AccountId.from_string("0.0.1234"), -100_000_000) + .add_hbar_transfer(AccountId.from_string("0.0.5678"), 100_000_000) + .set_transaction_id(TransactionId.generate(AccountId.from_string("0.0.1234"))) + .set_node_account_ids([node_id]) + .freeze() + ) + single_transaction_bytes = ( + TransactionList.FromString(transaction.to_bytes()).transaction_list[0].SerializeToString() + ) + + restored_transaction = Transaction.from_bytes(single_transaction_bytes) + + assert isinstance(restored_transaction, TransferTransaction) + assert restored_transaction.transaction_id == transaction.transaction_id + assert restored_transaction.node_account_ids == [node_id] + + +def test_from_bytes_surfaces_transaction_list_validation_error(): + """Test that from_bytes() reports why a TransactionList is invalid instead of a fallback parse error.""" + transaction_id = TransactionId.generate(AccountId.from_string("0.0.1234")) + node_ids = [AccountId.from_string("0.0.3")] + transfer = TransferTransaction().set_transaction_id(transaction_id).set_node_account_ids(node_ids).freeze() + account_create = ( + AccountCreateTransaction() + .set_key_without_alias(PrivateKey.generate_ed25519()) + .set_transaction_id(transaction_id) + .set_node_account_ids(node_ids) + .freeze() + ) + mixed_list = TransactionList( + transaction_list=[ + *TransactionList.FromString(transfer.to_bytes()).transaction_list, + *TransactionList.FromString(account_create.to_bytes()).transaction_list, + ] + ) + + with pytest.raises(ValueError, match="TransactionList contains different transaction types"): + Transaction.from_bytes(mixed_list.SerializeToString()) + + def test_freeze_and_sign_workflow(): """Test the complete workflow: create -> freeze -> sign -> to_bytes.""" operator_id = AccountId.from_string("0.0.1234") diff --git a/tests/unit/transaction_test.py b/tests/unit/transaction_test.py index 2d0a316d1..61f099f07 100644 --- a/tests/unit/transaction_test.py +++ b/tests/unit/transaction_test.py @@ -904,8 +904,9 @@ def test_validate_transaction_bodies_multiple_nodes(): def test_validate_transaction_bodies_chunk_transaction(topic_id): """Test validation of a chunked transaction.""" - transaction_id1 = TransactionId.generate(AccountId(0, 0, 2)) - transaction_id2 = TransactionId.generate(AccountId(0, 0, 3)) + # Chunk transaction IDs share the payer and have increasing valid starts, as freeze() generates them + transaction_id1 = TransactionId.from_string("0.0.2@1700000000.0") + transaction_id2 = TransactionId.from_string("0.0.2@1700000000.1") chunk1 = (TopicMessageSubmitTransaction().set_topic_id(topic_id).set_message("Hello"))._build_proto_body() @@ -949,8 +950,9 @@ def test_validate_transaction_bodies_chunk_transaction_mutiple_node_ids(topic_id node1 = AccountId.from_string("0.0.101") node2 = AccountId.from_string("0.0.102") - transaction_id1 = TransactionId.generate(AccountId(0, 0, 2)) - transaction_id2 = TransactionId.generate(AccountId(0, 0, 3)) + # Chunk transaction IDs share the payer and have increasing valid starts, as freeze() generates them + transaction_id1 = TransactionId.from_string("0.0.2@1700000000.0") + transaction_id2 = TransactionId.from_string("0.0.2@1700000000.1") chunk1 = (TopicMessageSubmitTransaction().set_topic_id(topic_id).set_message("Hello"))._build_proto_body()