Skip to content

feat: Improve to_bytes/from_bytes serialization and deserialization#2390

Open
manishdait wants to merge 17 commits into
hiero-ledger:mainfrom
manishdait:feat/improve-serialization-deserialization
Open

feat: Improve to_bytes/from_bytes serialization and deserialization#2390
manishdait wants to merge 17 commits into
hiero-ledger:mainfrom
manishdait:feat/improve-serialization-deserialization

Conversation

@manishdait

@manishdait manishdait commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Description:
This PR improve the to_bytes/from_bytes serialization and deserialization, to support parsing multiple node_account_ids in transaction.

Changes Made:

  • Refactor build_base_transaction() to create the transaction body without the transaction_id and node_account_id
  • Updated freeze_with() method to handle the node_account_ids list
  • Deprecated the use of node_account_id and set_node_account_id() to prefer the set_node_account_ids instead to match other sdks
  • Updated chunked transaction to build all the transaction bytes on freeze instead, of creating them on execute, and to help with serialization
  • Updated uint test for the changes

Related issue(s):

Fixes #2302

Notes for reviewer:

  • Most of the changes are internal that will not cause breaking changes, to prevent some breaking changes additional methods and properties are added. which are mark as deprecated
  • We can remove this deprecated method in future if we want.
  • Also added a _from_protobuf method only to AccountCreateTransaction for now to test changes. Since we have another PR that is currently working on adding the protobuf method to other transaction
  • Remove unit test for the to_bytes that previously depends on the require_frozen
  • Still in initial draft, need to verify and update some e2e/unit test
  • Need to change some unit test cause the tx_ids and the node_ids are now set inside freeze instead of the build_proto_body.

Checklist

  • Documented (Code comments, README, etc.)
  • Tested (unit, integration, etc.)

@github-actions github-actions Bot added the skill: advanced requires knowledge of multiple areas in the codebase without defined steps to implement or examples label Jun 27, 2026
@codecov

codecov Bot commented Jun 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.52941% with 4 lines in your changes missing coverage. Please review.

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #2390      +/-   ##
==========================================
+ Coverage   95.01%   95.07%   +0.06%     
==========================================
  Files         164      164              
  Lines       10464    10547      +83     
==========================================
+ Hits         9942    10028      +86     
+ Misses        522      519       -3     
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@manishdait manishdait added the notes: extra care requires extra attention to detail or requirements label Jul 6, 2026
@manishdait
manishdait force-pushed the feat/improve-serialization-deserialization branch from 03e8ca7 to a4d0127 Compare July 6, 2026 20:59
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

Hi, this is WorkflowBot.
Your pull request cannot be merged as it is not passing all our workflow checks.
Please click on each check to review the logs and resolve issues so all checks pass.
To help you:

@manishdait
manishdait force-pushed the feat/improve-serialization-deserialization branch from a4d0127 to 2b2fc79 Compare July 7, 2026 08:56

@aceppaluni aceppaluni left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left some questions:

Should we consider restoring all temporary mutable state (such as _current_chunk_index) after helper methods that iterate through chunks?

return

if signed_transaction.sigMap.sigPair:
restored_transaction._signature_map[signed_transaction.bodyBytes] = signed_transaction.sigMap

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like the assumption here is bodyBytes uniquely identifies a transaction body. However, is it possible to have duplicate body bytes across different nodes?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

possibly no cause the node_id will be diffrent


if transaction_body.HasField("transactionID"):
transaction.transaction_id = TransactionId._from_proto(transaction_body.transactionID)
transaction._transaction_ids = [TransactionId._from_proto(transaction_body.transactionID)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For chunked transactions, is the ordering guaranteed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes

@aceppaluni aceppaluni added the status: Needs Developer Revision Author needs to apply suggested changes/improvements label Jul 8, 2026
@manishdait
manishdait force-pushed the feat/improve-serialization-deserialization branch 3 times, most recently from d32d076 to eae34cb Compare July 16, 2026 11:03
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Transactions now support multi-node and chunked serialization through nested transaction-ID/node mappings and TransactionList. Protobuf reconstruction APIs were simplified, chunk execution was refactored, node selection was updated, and unit/integration coverage was expanded for round trips, signatures, and execution.

Changes

Transaction serialization and execution

Layer / File(s) Summary
Transaction state and TransactionList serialization
src/hiero_sdk_python/transaction/transaction.py, tests/unit/transaction_freeze_and_bytes_test.py
Transaction body bytes, transaction IDs, signatures, and protobuf reconstruction now support multiple serialized transaction entries and node contexts.
Chunk generation and execution
src/hiero_sdk_python/transaction/chunked_transaction.py, tests/unit/chunked_transaction_test.py
Chunk IDs and body bytes are generated during freezing, chunks execute sequentially, and chunk indexes reset after processing.
Node selection and fee estimation
src/hiero_sdk_python/executable.py, src/hiero_sdk_python/query/fee_estimate_query.py, src/hiero_sdk_python/query/query.py
Execution uses internal selected-node state, deprecated single-node configuration remains available, and fee estimation serializes each active chunk body.
Subtype protobuf reconstruction and payloads
src/hiero_sdk_python/account/..., src/hiero_sdk_python/file/..., src/hiero_sdk_python/consensus/..., src/hiero_sdk_python/nodes/..., src/hiero_sdk_python/transaction/*_transaction.py
Subtype _from_protobuf methods use a single transaction-body argument, while topic and file append payloads distinguish chunked and unchunked serialization.
Serialization, execution, and signature tests
tests/unit/*, tests/integration/*
Tests cover nested body-byte storage, serialization round trips, chunk signatures, protobuf reconstruction, node selection, and execution results.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Transaction
  participant TransactionList
  participant Network
  Client->>Transaction: freeze and generate transaction IDs
  Transaction->>Transaction: build body bytes per transaction ID and node
  Client->>Transaction: sign available body bytes
  Transaction->>TransactionList: serialize bodies and signature maps
  TransactionList->>Transaction: deserialize and restore transaction state
  Transaction->>Network: execute reconstructed transaction chunks
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes align with #2302 by adding TransactionList serialization, restoring multi-node bodies, preserving compatibility, and keeping signatures tied to body bytes.
Out of Scope Changes check ✅ Passed The modified transaction, chunking, and test updates all support the stated serialization goals, with no obvious unrelated code changes.
Docstring Coverage ✅ Passed Docstring coverage is 88.71% which is sufficient. The required threshold is 80.00%.
Title check ✅ Passed The title clearly summarizes the main change: improved to_bytes/from_bytes serialization and deserialization.
Description check ✅ Passed The description matches the PR's multi-node serialization, chunked transaction, and deprecation changes.
📋 Issue Planner

Built with CodeRabbit's Coding Plans for faster development and fewer bugs.

View plan used: #2302

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Signed-off-by: Manish Dait <daitmanish88@gmail.com>
Signed-off-by: Manish Dait <daitmanish88@gmail.com>
Signed-off-by: Manish Dait <daitmanish88@gmail.com>
Signed-off-by: Manish Dait <daitmanish88@gmail.com>
Signed-off-by: Manish Dait <daitmanish88@gmail.com>
Signed-off-by: Manish Dait <daitmanish88@gmail.com>
Signed-off-by: Manish Dait <daitmanish88@gmail.com>
Signed-off-by: Manish Dait <daitmanish88@gmail.com>
Signed-off-by: Manish Dait <daitmanish88@gmail.com>
Signed-off-by: Manish Dait <daitmanish88@gmail.com>
Signed-off-by: Manish Dait <daitmanish88@gmail.com>
Signed-off-by: Manish Dait <daitmanish88@gmail.com>
Signed-off-by: Manish Dait <daitmanish88@gmail.com>
Signed-off-by: Manish Dait <daitmanish88@gmail.com>
Signed-off-by: Manish Dait <daitmanish88@gmail.com>
Signed-off-by: Manish Dait <daitmanish88@gmail.com>
Signed-off-by: Manish Dait <daitmanish88@gmail.com>
@manishdait
manishdait force-pushed the feat/improve-serialization-deserialization branch from eae34cb to 9dd5458 Compare July 20, 2026 09:53
@manishdait
manishdait force-pushed the feat/improve-serialization-deserialization branch from d9ed7bf to 9dd5458 Compare July 20, 2026 11:26
@manishdait
manishdait marked this pull request as ready for review July 20, 2026 11:37
@manishdait
manishdait requested review from a team as code owners July 20, 2026 11:37

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
tests/unit/transaction_freeze_and_bytes_test.py (1)

579-593: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Node-id dimension of _transaction_body_bytes is left untested (# TODO check fro node ids).

These tests assert the outer TransactionId keys but the multi-node body-byte mapping is the central feature of this PR. The inner {node_account_id: bytes} layer is never asserted, so a regression that drops or miskeys per-node bodies would pass. Consider asserting set(tx._transaction_body_bytes[tx._transaction_ids[0]].keys()) == set(node_account_ids). (Note: frofor in the comments.)

Want me to draft the per-node assertions for these cases?

Source: Path instructions

src/hiero_sdk_python/executable.py (1)

448-456: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Error message references the wrong variable.

self._node_account_id is only assigned on Line 456 (after this check), so on a failed lookup it prints its stale value (None on the first attempt) rather than the node_id that could not be resolved. Reference node_id for a useful diagnostic.

Proposed fix
             if node is None:
-                raise RuntimeError(f"No node found for node_account_id: {self._node_account_id}")
+                raise RuntimeError(f"No node found for node_account_id: {node_id}")
src/hiero_sdk_python/transaction/transfer_transaction.py (1)

165-177: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Update _from_protobuf docstrings to match the new single-argument signature. Both overrides dropped body_bytes and sig_map, but their docstrings still document those parameters, which is now misleading.

  • src/hiero_sdk_python/transaction/transfer_transaction.py#L165-L177: remove the body_bytes (bytes) and sig_map lines from the Args section.
  • src/hiero_sdk_python/transaction/batch_transaction.py#L97-L110: remove the body_bytes (bytes) and sig_map lines from the Args section.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c96605d6-4bcb-4da6-ab80-aa6d49d427fa

📥 Commits

Reviewing files that changed from the base of the PR and between a6f6388 and 9dd5458.

📒 Files selected for processing (58)
  • src/hiero_sdk_python/account/account_create_transaction.py
  • src/hiero_sdk_python/consensus/topic_message_submit_transaction.py
  • src/hiero_sdk_python/executable.py
  • src/hiero_sdk_python/file/file_append_transaction.py
  • src/hiero_sdk_python/nodes/node_create_transaction.py
  • src/hiero_sdk_python/nodes/node_update_transaction.py
  • src/hiero_sdk_python/nodes/registered_node_create_transaction.py
  • src/hiero_sdk_python/nodes/registered_node_delete_transaction.py
  • src/hiero_sdk_python/nodes/registered_node_update_transaction.py
  • src/hiero_sdk_python/query/fee_estimate_query.py
  • src/hiero_sdk_python/query/query.py
  • src/hiero_sdk_python/transaction/batch_transaction.py
  • src/hiero_sdk_python/transaction/chunked_transaction.py
  • src/hiero_sdk_python/transaction/transaction.py
  • src/hiero_sdk_python/transaction/transfer_transaction.py
  • tests/integration/file_append_transaction_e2e_test.py
  • tests/integration/topic_message_submit_transaction_e2e_test.py
  • tests/integration/transaction_freeze_e2e_test.py
  • tests/unit/account_create_transaction_test.py
  • tests/unit/account_delete_transaction_test.py
  • tests/unit/account_update_transaction_test.py
  • tests/unit/batch_transaction_test.py
  • tests/unit/chunked_transaction_test.py
  • tests/unit/contract_delete_transaction_test.py
  • tests/unit/contract_execute_transaction_test.py
  • tests/unit/ethereum_transaction_test.py
  • tests/unit/executable_test.py
  • tests/unit/fee_estimate_query_test.py
  • tests/unit/file_append_transaction_test.py
  • tests/unit/file_delete_transaction_test.py
  • tests/unit/file_update_transaction_test.py
  • tests/unit/freeze_transaction_test.py
  • tests/unit/node_create_transaction_test.py
  • tests/unit/node_delete_transaction_test.py
  • tests/unit/node_update_transaction_test.py
  • tests/unit/prng_transaction_test.py
  • tests/unit/query_test.py
  • tests/unit/schedule_create_transaction_test.py
  • tests/unit/schedule_delete_transaction_test.py
  • tests/unit/schedule_sign_transaction_test.py
  • tests/unit/token_airdrop_transaction_cancel_test.py
  • tests/unit/token_airdrop_transaction_test.py
  • tests/unit/token_associate_transaction_test.py
  • tests/unit/token_create_transaction_test.py
  • tests/unit/token_delete_transaction_test.py
  • tests/unit/token_dissociate_transaction_test.py
  • tests/unit/token_freeze_transaction_test.py
  • tests/unit/token_mint_transaction_test.py
  • tests/unit/token_pause_transaction_test.py
  • tests/unit/token_unfreeze_transaction_test.py
  • tests/unit/token_unpause_transaction_test.py
  • tests/unit/topic_create_transaction_test.py
  • tests/unit/topic_delete_transaction_test.py
  • tests/unit/topic_message_submit_transaction_test.py
  • tests/unit/topic_update_transaction_test.py
  • tests/unit/transaction_freeze_and_bytes_test.py
  • tests/unit/transaction_test.py
  • tests/unit/transfer_transaction_test.py

Comment on lines +388 to +391
transaction.auto_renew_period = None

if body.HasField("autoRenewPeriod"):
transaction.auto_renew_period = Duration._from_proto(body.autoRenewPeriod)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

auto_renew_period = None can make the round-tripped transaction unserializable.

_build_proto_body() (line 322) does duration_pb2.Duration(seconds=self.auto_renew_period.seconds), which raises AttributeError when auto_renew_period is None. If a deserialized proto lacks autoRenewPeriod, this reconstruction sets it to None, so a subsequent build_transaction_body()/execute() on the restored object crashes. Prefer leaving the constructor default in place when the field is absent.

🛡️ Proposed change
-            transaction.initial_balance = body.initialBalance
-            transaction.receiver_signature_required = body.receiverSigRequired
-            transaction.auto_renew_period = None
-
-            if body.HasField("autoRenewPeriod"):
-                transaction.auto_renew_period = Duration._from_proto(body.autoRenewPeriod)
+            transaction.initial_balance = body.initialBalance
+            transaction.receiver_signature_required = body.receiverSigRequired
+
+            if body.HasField("autoRenewPeriod"):
+                transaction.auto_renew_period = Duration._from_proto(body.autoRenewPeriod)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
transaction.auto_renew_period = None
if body.HasField("autoRenewPeriod"):
transaction.auto_renew_period = Duration._from_proto(body.autoRenewPeriod)
transaction.initial_balance = body.initialBalance
transaction.receiver_signature_required = body.receiverSigRequired
if body.HasField("autoRenewPeriod"):
transaction.auto_renew_period = Duration._from_proto(body.autoRenewPeriod)

return self
if body.HasField("topicID"):
transaction.topic_id = TopicId._from_proto(body.topicID)
transaction.message = body.message.decode("utf-8") if body.message else None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

decode("utf-8") breaks round-trip for binary messages.

set_message() accepts bytes | str, so a message can be arbitrary binary. Unconditionally decoding as UTF-8 raises UnicodeDecodeError for non-UTF-8 payloads, causing Transaction.from_bytes() to fail entirely on a previously valid serialized transaction. Guard the decode and fall back to the raw bytes so both text and binary messages round-trip.

🛡️ Proposed change
-            transaction.message = body.message.decode("utf-8") if body.message else None
+            if not body.message:
+                transaction.message = None
+            else:
+                try:
+                    transaction.message = body.message.decode("utf-8")
+                except UnicodeDecodeError:
+                    transaction.message = body.message
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
transaction.message = body.message.decode("utf-8") if body.message else None
if not body.message:
transaction.message = None
else:
try:
transaction.message = body.message.decode("utf-8")
except UnicodeDecodeError:
transaction.message = body.message

Source: Path instructions

Comment on lines 371 to 375
if not self.node_account_ids:
self.node_account_ids = [node._account_id for node in client.network._healthy_nodes]
self.node_account_ids = [node._account_id for node in client.network.nodes]

if not self.node_account_ids:
raise RuntimeError("No healthy nodes available for execution")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Node source switched to all nodes, but the error text still says "healthy".

Selection now seeds node_account_ids from client.network.nodes instead of _healthy_nodes, so health is deferred to is_healthy()/_handle_unhealthy_node() during execution. Confirm this behavioral change is intended (bodies are pre-built for every node to support failover), and correct the misleading message at Line 375 — it now only fires when the node list is empty, not when all nodes are unhealthy.

Proposed message fix
         if not self.node_account_ids:
-            raise RuntimeError("No healthy nodes available for execution")
+            raise RuntimeError("No nodes available for execution")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if not self.node_account_ids:
self.node_account_ids = [node._account_id for node in client.network._healthy_nodes]
self.node_account_ids = [node._account_id for node in client.network.nodes]
if not self.node_account_ids:
raise RuntimeError("No healthy nodes available for execution")
if not self.node_account_ids:
self.node_account_ids = [node._account_id for node in client.network.nodes]
if not self.node_account_ids:
raise RuntimeError("No nodes available for execution")

Comment on lines +401 to +419
def _generate_transaction_ids(self, initial_transaction: TransactionId, count: int):
"""Generate all transaction_id for require chunks."""
self._transaction_ids = []

if count == 1:
self._transaction_ids = [initial_transaction]
return

transaction_id = initial_transaction
for i in range(count):
self._transaction_ids.append(transaction_id)

next_nanos = initial_transaction.valid_start.nanos + (i + 1)
next_valid_start = timestamp_pb2.Timestamp(
seconds=initial_transaction.valid_start.seconds + next_nanos // 1_000_000_000,
nanos=next_nanos % 1_000_000_000,
)

transaction_id = TransactionId(account_id=self.transaction_id.account_id, valid_start=next_valid_start)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use initial_transaction.account_id instead of self.transaction_id.account_id.

Inside the loop, self.transaction_id resolves to _transaction_ids[self._current_transaction_id_index]. This only returns the initial id because the index happens to be 0 at freeze time; it silently couples id generation to _current_transaction_id_index (which is mutated during chunked execution / fee estimation). Reference the passed-in initial_transaction directly to remove the hidden dependency.

♻️ Proposed change
-        transaction_id = TransactionId(account_id=self.transaction_id.account_id, valid_start=next_valid_start)
+        transaction_id = TransactionId(account_id=initial_transaction.account_id, valid_start=next_valid_start)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _generate_transaction_ids(self, initial_transaction: TransactionId, count: int):
"""Generate all transaction_id for require chunks."""
self._transaction_ids = []
if count == 1:
self._transaction_ids = [initial_transaction]
return
transaction_id = initial_transaction
for i in range(count):
self._transaction_ids.append(transaction_id)
next_nanos = initial_transaction.valid_start.nanos + (i + 1)
next_valid_start = timestamp_pb2.Timestamp(
seconds=initial_transaction.valid_start.seconds + next_nanos // 1_000_000_000,
nanos=next_nanos % 1_000_000_000,
)
transaction_id = TransactionId(account_id=self.transaction_id.account_id, valid_start=next_valid_start)
def _generate_transaction_ids(self, initial_transaction: TransactionId, count: int):
"""Generate all transaction_id for require chunks."""
self._transaction_ids = []
if count == 1:
self._transaction_ids = [initial_transaction]
return
transaction_id = initial_transaction
for i in range(count):
self._transaction_ids.append(transaction_id)
next_nanos = initial_transaction.valid_start.nanos + (i + 1)
next_valid_start = timestamp_pb2.Timestamp(
seconds=initial_transaction.valid_start.seconds + next_nanos // 1_000_000_000,
nanos=next_nanos % 1_000_000_000,
)
transaction_id = TransactionId(account_id=initial_transaction.account_id, valid_start=next_valid_start)

Comment on lines +847 to +848
if transaction._node_account_id is not None:
transaction.set_node_account_id(transaction._node_account_id)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Avoid calling the deprecated set_node_account_id() from library internals.

_node_account_id is already populated by _from_protobuf; this call only mirrors it into node_account_ids, but set_node_account_id() emits a DeprecationWarning (see executable.py) that callers of from_bytes() cannot suppress. Set node_account_ids directly instead.

♻️ Proposed change
-        if transaction._node_account_id is not None:
-            transaction.set_node_account_id(transaction._node_account_id)
+        if transaction._node_account_id is not None:
+            transaction.node_account_ids = [transaction._node_account_id]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if transaction._node_account_id is not None:
transaction.set_node_account_id(transaction._node_account_id)
if transaction._node_account_id is not None:
transaction.node_account_ids = [transaction._node_account_id]

Comment on lines +238 to +239
ValueError,
match="Cannot execute ChunkedTransaction with more than 5 chunks. Required: 100 Increase limit with set_max_chunks().",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Escape regex metacharacters in match=.

The match string contains unescaped (, ), and ., which pytest.raises treats as a regex (via re.search). This happens to still match here only by accident (the empty group () plus wildcard . absorb the literal parentheses), but it doesn't assert the literal wording precisely and would silently tolerate unrelated message changes. Wrap with re.escape(...) for a deterministic check.

🐛 Proposed fix
+    import re
+
     with pytest.raises(
         ValueError,
-        match="Cannot execute ChunkedTransaction with more than 5 chunks. Required: 100 Increase limit with set_max_chunks().",
+        match=re.escape(
+            "Cannot execute ChunkedTransaction with more than 5 chunks. Required: 100 Increase limit with set_max_chunks()."
+        ),
     ):
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ValueError,
match="Cannot execute ChunkedTransaction with more than 5 chunks. Required: 100 Increase limit with set_max_chunks().",
ValueError,
match=re.escape(
"Cannot execute ChunkedTransaction with more than 5 chunks. Required: 100 Increase limit with set_max_chunks()."
),

Comment on lines +25 to +28
assert len(tx._transaction_body_bytes) == len(tx._transaction_ids)
assert set(tx._transaction_body_bytes.keys()) == set(tx._transaction_ids)

# TODO: check for node_ids

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Node-id verification dropped from all three freeze e2e tests, only a TODO left behind.

Each test replaced a stronger assertion (that _transaction_body_bytes inner keys matched the expected node AccountId set) with a check against _transaction_ids only. Since freeze_with unconditionally creates one _transaction_body_bytes entry per transaction id, this new check is nearly tautological and no longer verifies which nodes the bodies were built for — the actual behavior these three tests are meant to cover for the multi-node feature this PR introduces.

  • tests/integration/transaction_freeze_e2e_test.py#L25-L28: add an assertion that the inner keys of tx._transaction_body_bytes[tx_id] equal the set of node AccountIds from env.client.network.nodes for the default (no explicit node_account_ids) case.
  • tests/integration/transaction_freeze_e2e_test.py#L46-L49: add an assertion that the inner keys equal set(node_account_ids) (the two explicitly provided nodes).
  • tests/integration/transaction_freeze_e2e_test.py#L68-L71: add an assertion that the inner keys equal {node_account_id} (the single explicitly provided node).
📍 Affects 1 file
  • tests/integration/transaction_freeze_e2e_test.py#L25-L28 (this comment)
  • tests/integration/transaction_freeze_e2e_test.py#L46-L49
  • tests/integration/transaction_freeze_e2e_test.py#L68-L71

Comment on lines 224 to +237
def test_sign_topic_create_transaction(mock_account_ids, private_key):
"""
Test signing the TopicCreateTransaction with a private key.
"""
_, _, node_account_id, _, _ = mock_account_ids
opertor_id, _, node_account_id, _, _ = mock_account_ids
transaction_id = TransactionId.generate(opertor_id)

tx = TopicCreateTransaction(memo="Signing test")
tx.operator_account_id = AccountId(0, 0, 2)
tx.node_account_id = node_account_id
tx.transaction_id = transaction_id

body_bytes = tx.build_transaction_body().SerializeToString()
tx._transaction_body_bytes.setdefault(node_account_id, body_bytes)
tx._transaction_body_bytes.setdefault(transaction_id, dict(node_account_id=body_bytes))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

dict(node_account_id=body_bytes) keys by the literal string "node_account_id", not the actual node AccountId. Both sites use Python keyword-argument dict construction, which treats node_account_id as a string key rather than the in-scope node_account_id variable, producing {"node_account_id": body_bytes} instead of {node_account_id: body_bytes}. This mismatches the real _transaction_body_bytes: dict[TransactionId, dict[AccountId, bytes]] contract; the tests still pass only because sign() iterates .values(), but they no longer validate the true key shape.

  • tests/unit/topic_create_transaction_test.py#L224-L237: replace dict(node_account_id=body_bytes) with {node_account_id: body_bytes}.
  • tests/unit/topic_delete_transaction_test.py#L75-L85: replace dict(node_account_id=body_bytes) with {node_account_id: body_bytes}.
📍 Affects 2 files
  • tests/unit/topic_create_transaction_test.py#L224-L237 (this comment)
  • tests/unit/topic_delete_transaction_test.py#L75-L85

Comment on lines +573 to +598
def test_frezee_with_client_generate_all_transaction_body_bytes(topic_id, mock_client):
"""Test freeze_with() generate all required transaction body bytes."""
message = "A" * 40 # will create 4 chunks

transaction = TopicMessageSubmitTransaction().set_topic_id(topic_id).set_chunk_size(10).set_message(message)

assert len(transaction._transaction_ids) == 0
assert not transaction._transaction_body_bytes

transaction.freeze_with(mock_client)

# generated transaction ids == number of chunks i.e 4 (40/10)
assert len(transaction._transaction_ids) == 4

transaction_ids = transaction._transaction_ids
initial_transaction = transaction_ids[0]

for i, _ in enumerate(transaction_ids):
assert transaction_ids[i].account_id == initial_transaction.account_id
assert transaction_ids[i].valid_start.nanos == initial_transaction.valid_start.nanos + i

# create body bytes for each transaction_id and every node_account_ids
assert len(transaction._transaction_body_bytes) == 4
for transaction_id in transaction._transaction_ids:
assert len(transaction._transaction_body_bytes[transaction_id]) == len(mock_client.network.nodes)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider a boundary test for nanos overflow in chunked freeze_with().

These tests only exercise small chunk counts (+i for i in 0-3), never a case where base_timestamp.nanos + i would exceed 999_999_999. This is the specific overflow risk called out for freeze_with()'s chunk TransactionId generation in this feature area; a test seeding valid_start.nanos near the boundary would protect against a wraparound/invalid-timestamp regression.

Comment on lines 285 to +286
body_bytes = tx.build_transaction_body().SerializeToString()
tx._transaction_body_bytes.setdefault(node_account_id, body_bytes)
tx._transaction_body_bytes.setdefault(transaction_id, dict(node_account_id=body_bytes))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Fix incorrect dictionary key initialization.

Using dict(node_account_id=body_bytes) creates a dictionary with the literal string key "node_account_id" instead of using the local variable node_account_id. This violates the internal mapping type (dict[AccountId, bytes]).

Replace it with standard dictionary syntax to use the variable as the key.

💡 Proposed fix
-    tx._transaction_body_bytes.setdefault(transaction_id, dict(node_account_id=body_bytes))
+    tx._transaction_body_bytes.setdefault(transaction_id, {node_account_id: body_bytes})
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
body_bytes = tx.build_transaction_body().SerializeToString()
tx._transaction_body_bytes.setdefault(node_account_id, body_bytes)
tx._transaction_body_bytes.setdefault(transaction_id, dict(node_account_id=body_bytes))
body_bytes = tx.build_transaction_body().SerializeToString()
tx._transaction_body_bytes.setdefault(transaction_id, {node_account_id: body_bytes})

@github-actions github-actions Bot added open to community review PR is open for community review and feedback queue:junior-committer PR awaiting initial quality review labels Jul 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

notes: extra care requires extra attention to detail or requirements open to community review PR is open for community review and feedback queue:junior-committer PR awaiting initial quality review skill: advanced requires knowledge of multiple areas in the codebase without defined steps to implement or examples status: Needs Developer Revision Author needs to apply suggested changes/improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Update Transaction.to_bytes() / from_bytes() to support multi-node transactions using TransactionList

4 participants