Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/hiero_sdk_python/account/account_create_transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,3 +371,35 @@ def _get_method(self, channel: _Channel) -> _Method:
_Method: An instance of _Method containing the transaction and query functions.
"""
return _Method(transaction_func=channel.crypto.createAccount, query_func=None)

@classmethod
def _from_protobuf(cls, transaction_body):
"""Create account create transaction from protobuf message."""
transaction = super()._from_protobuf(transaction_body)

if transaction_body.HasField("cryptoCreateAccount"):
body = transaction_body.cryptoCreateAccount

if body.HasField("key"):
transaction.key = Key.from_proto_key(body.key)

transaction.initial_balance = body.initialBalance
transaction.receiver_signature_required = body.receiverSigRequired
transaction.auto_renew_period = None

if body.HasField("autoRenewPeriod"):
transaction.auto_renew_period = Duration._from_proto(body.autoRenewPeriod)
Comment on lines +388 to +391

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)


transaction.account_memo = body.memo if body.memo else None
transaction.max_automatic_token_associations = body.max_automatic_token_associations
transaction.alias = EvmAddress.from_bytes(body.alias) if body.alias else None
transaction.decline_staking_reward = body.decline_reward
staked_id = body.WhichOneof("staked_id")

if staked_id == "staked_account_id":
transaction.staked_account_id = AccountId._from_proto(body.staked_account_id)

elif staked_id == "staked_node_id":
transaction.staked_node_id = body.staked_node_id

return transaction
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

from hiero_sdk_python.channels import _Channel
from hiero_sdk_python.consensus.topic_id import TopicId
from hiero_sdk_python.crypto.private_key import PrivateKey
from hiero_sdk_python.executable import _Method
from hiero_sdk_python.hapi.services import consensus_submit_message_pb2, transaction_pb2
from hiero_sdk_python.hapi.services.schedulable_transaction_body_pb2 import (
Expand Down Expand Up @@ -107,33 +106,6 @@ def set_message(self, message: bytes | str) -> TopicMessageSubmitTransaction:
self._total_chunks = self.get_required_chunks()
return self

def set_chunk_size(self, chunk_size: int) -> TopicMessageSubmitTransaction:
"""
Set maximum chunk size in bytes.

Args:
chunk_size (int): The size of each chunk in bytes.

Returns:
TopicMessageSubmitTransaction: This transaction instance (for chaining).
"""
super().set_chunk_size(chunk_size)
self._total_chunks = self.get_required_chunks()
return self

def set_max_chunks(self, max_chunks: int) -> TopicMessageSubmitTransaction:
"""
Set maximum allowed chunks.

Args:
max_chunks (int): The maximum number of chunks allowed.

Returns:
TopicMessageSubmitTransaction: This transaction instance (for chaining).
"""
super().set_max_chunks(max_chunks)
return self

def set_custom_fee_limits(self, custom_fee_limits: list[CustomFeeLimit]) -> TopicMessageSubmitTransaction:
"""
Sets the maximum custom fees that the user is willing to pay for the message.
Expand Down Expand Up @@ -177,25 +149,28 @@ def _build_proto_body(self) -> consensus_submit_message_pb2.ConsensusSubmitMessa

content = self._message_as_bytes()

start_index = self._current_chunk_index * self.chunk_size
end_index = min(start_index + self.chunk_size, len(content))
chunk_content = content[start_index:end_index]
if self._current_chunk_index is not None:
chunk_info = consensus_submit_message_pb2.ConsensusMessageChunkInfo(
initialTransactionID=self._initial_transaction_id._to_proto(),
total=self._total_chunks,
number=self._current_chunk_index + 1,
)

body = consensus_submit_message_pb2.ConsensusSubmitMessageTransactionBody(
topicID=self.topic_id._to_proto() if self.topic_id else None, message=chunk_content
)
start_index = (self._current_chunk_index) * self.chunk_size
end_index = min(start_index + self.chunk_size, len(content))

# Multi-chunk metadata
if self._total_chunks > 1:
body.chunkInfo.CopyFrom(
consensus_submit_message_pb2.ConsensusMessageChunkInfo(
initialTransactionID=self._initial_transaction_id._to_proto(),
total=self._total_chunks,
number=self._current_chunk_index + 1,
)
chunk_content = content[start_index:end_index]

return consensus_submit_message_pb2.ConsensusSubmitMessageTransactionBody(
topicID=self.topic_id._to_proto() if self.topic_id else None,
message=chunk_content,
chunkInfo=chunk_info,
)

return body
return consensus_submit_message_pb2.ConsensusSubmitMessageTransactionBody(
topicID=self.topic_id._to_proto() if self.topic_id else None,
message=content,
)

def build_transaction_body(self) -> transaction_pb2.TransactionBody:
"""
Expand Down Expand Up @@ -233,15 +208,16 @@ def _get_method(self, channel: _Channel) -> _Method:
"""
return _Method(transaction_func=channel.topic.submitMessage, query_func=None)

def sign(self, private_key: PrivateKey) -> TopicMessageSubmitTransaction:
"""
Signs the transaction using the provided private key.
@classmethod
def _from_protobuf(cls, transaction_body):
transaction = super()._from_protobuf(transaction_body)

Args:
private_key (PrivateKey): The private key to sign the transaction with.
if transaction_body.HasField("consensusSubmitMessage"):
body = transaction_body.consensusSubmitMessage

Returns:
TopicMessageSubmitTransaction: This transaction instance (for chaining).
"""
super().sign(private_key)
return self
if body.HasField("topicID"):
transaction.topic_id = TopicId._from_proto(body.topicID)
transaction.message = body.message.decode("utf-8") if body.message else None

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

transaction._total_chunks = transaction.get_required_chunks()

return transaction
39 changes: 30 additions & 9 deletions src/hiero_sdk_python/executable.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,28 @@ def __init__(self):
self._grpc_deadline: float | None = None
self._request_timeout: float | None = None

self.node_account_id: AccountId | None = None
self._node_account_id: AccountId | None = None
self.node_account_ids: list[AccountId] = []

self._used_node_account_id: AccountId | None = None
self._node_account_ids_index: int = 0

@property
def node_account_id(self) -> AccountId | None:
warnings.warn(
"'node_account_id' is deprecated; use 'node_account_ids' instead.", DeprecationWarning, stacklevel=2
)

return self.node_account_ids[0] if len(self.node_account_ids) > 0 else None

@node_account_id.setter
def node_account_id(self, account_id: AccountId) -> None:
warnings.warn(
"'node_account_id' is deprecated; use 'node_account_ids' instead.", DeprecationWarning, stacklevel=2
)

self.node_account_ids = [account_id] if account_id is not None else []

def set_node_account_ids(self, node_account_ids: list[AccountId]):
"""
Explicitly set the node account IDs to execute against.
Expand All @@ -115,6 +131,12 @@ def set_node_account_id(self, node_account_id: AccountId):
Returns:
The current instance of the class for chaining.
"""
warnings.warn(
"Method 'set_node_account_id()' is deprecated; use 'set_node_account_ids()' instead.",
DeprecationWarning,
stacklevel=2,
)

return self.set_node_account_ids([node_account_id])

def set_max_attempts(self, max_attempts: int):
Expand Down Expand Up @@ -346,9 +368,8 @@ def _resolve_execution_config(self, client: Client, timeout: int | float | None)
if getattr(self, attr) is None:
setattr(self, attr, default)

# nodes to which the executaion must be run against, if not provided used nodes from client
if not self.node_account_ids:
self.node_account_ids = [node._account_id for node in client.network._healthy_nodes]
self.node_account_ids = [node._account_id for node in client.network.nodes]

if not self.node_account_ids:
raise RuntimeError("No healthy nodes available for execution")
Comment on lines 371 to 375

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")

Expand Down Expand Up @@ -429,10 +450,10 @@ def _execute(self, client: Client, timeout: int | float | None = None):
node = client.network._get_node(node_id)

if node is None:
raise RuntimeError(f"No node found for node_account_id: {self.node_account_id}")
raise RuntimeError(f"No node found for node_account_id: {self._node_account_id}")

# Store for logging and receipts
self.node_account_id = node._account_id
self._node_account_id = node._account_id

# Create a channel wrapper from the client's channel
channel = node._get_channel()
Expand All @@ -442,7 +463,7 @@ def _execute(self, client: Client, timeout: int | float | None = None):
"requestId",
self._get_request_id(),
"nodeAccountID",
self.node_account_id,
self._node_account_id,
"attempt",
attempt + 1,
"maxAttempts",
Expand Down Expand Up @@ -483,7 +504,7 @@ def _execute(self, client: Client, timeout: int | float | None = None):
logger.trace(
f"{self.__class__.__name__} status received",
"nodeAccountID",
self.node_account_id,
self._node_account_id,
"network",
client.network.network,
"state",
Expand Down Expand Up @@ -518,7 +539,7 @@ def _execute(self, client: Client, timeout: int | float | None = None):
case _ExecutionState.FINISHED:
# If the transaction completed successfully, map the response and return it
logger.trace(f"{self.__class__.__name__} finished execution")
return self._map_response(response, self.node_account_id, proto_request)
return self._map_response(response, self._node_account_id, proto_request)

logger.error(
"Exceeded maximum attempts for request",
Expand All @@ -529,7 +550,7 @@ def _execute(self, client: Client, timeout: int | float | None = None):
)
raise MaxAttemptsError(
"Exceeded maximum attempts or request timeout",
self.node_account_id,
self._node_account_id,
err_persistant,
)

Expand Down
68 changes: 26 additions & 42 deletions src/hiero_sdk_python/file/file_append_transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,26 +79,6 @@ def _encode_contents(self, contents: str | bytes | None) -> bytes | None:
return contents.encode("utf-8")
return contents

def _calculate_total_chunks(self) -> int:
"""
Calculates the total number of chunks needed for the current contents.

Returns:
int: The total number of chunks needed.
"""
if self.contents is None:
return 1
return math.ceil(len(self.contents) / self.chunk_size)

def get_required_chunks(self) -> int:
"""
Gets the number of chunks required for the current contents.

Returns:
int: The number of chunks required.
"""
return self._calculate_total_chunks()

def set_file_id(self, file_id: FileId) -> FileAppendTransaction:
"""
Sets the file ID for this file append transaction.
Expand Down Expand Up @@ -129,31 +109,25 @@ def set_contents(self, contents: str | bytes | None) -> FileAppendTransaction:
self._total_chunks = self._calculate_total_chunks()
return self

def set_max_chunks(self, max_chunks: int) -> FileAppendTransaction:
def _calculate_total_chunks(self) -> int:
"""
Sets the maximum number of chunks allowed for this transaction.

Args:
max_chunks (int): The maximum number of chunks allowed.
Calculates the total number of chunks needed for the current contents.

Returns:
FileAppendTransaction: This transaction instance.
int: The total number of chunks needed.
"""
super().set_max_chunks(max_chunks)
return self
if self.contents is None:
return 1
return math.ceil(len(self.contents) / self.chunk_size)

def set_chunk_size(self, chunk_size: int) -> FileAppendTransaction:
def get_required_chunks(self) -> int:
"""
Sets the chunk size for this transaction.

Args:
chunk_size (int): The size of each chunk in bytes.
Gets the number of chunks required for the current contents.

Returns:
FileAppendTransaction: This transaction instance.
int: The number of chunks required.
"""
super().set_chunk_size(chunk_size)
return self
return self._calculate_total_chunks()

def _build_proto_body(self) -> file_append_pb2.FileAppendTransactionBody:
"""
Expand All @@ -169,15 +143,18 @@ def _build_proto_body(self) -> file_append_pb2.FileAppendTransactionBody:
if self.file_id is None:
raise ValueError("Missing required FileID")

if self.contents is None:
chunk_contents = b""
else:
contents = self.contents if self.contents is not None else b""

if self._current_chunk_index is not None:
start_index = self._current_chunk_index * self.chunk_size
end_index = min(start_index + self.chunk_size, len(self.contents))
chunk_contents = self.contents[start_index:end_index]
end_index = min(start_index + self.chunk_size, len(contents))

chunk_contents = contents[start_index:end_index]
return file_append_pb2.FileAppendTransactionBody(
fileID=self.file_id._to_proto() if self.file_id else None, contents=chunk_contents
)
return file_append_pb2.FileAppendTransactionBody(
fileID=self.file_id._to_proto() if self.file_id else None, contents=chunk_contents
fileID=self.file_id._to_proto() if self.file_id else None, contents=contents
)

def build_transaction_body(self) -> Any:
Expand Down Expand Up @@ -235,3 +212,10 @@ def _from_proto(self, proto: file_append_pb2.FileAppendTransactionBody) -> FileA
self.contents = proto.contents
self._total_chunks = self._calculate_total_chunks()
return self

@classmethod
def _from_protobuf(cls, transaction_body):
transaction = super()._from_protobuf(transaction_body)
if transaction_body.HasField("fileAppend"):
transaction._from_proto(transaction_body.fileAppend)
return transaction
4 changes: 2 additions & 2 deletions src/hiero_sdk_python/nodes/node_create_transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,8 +301,8 @@ def _get_method(self, channel: _Channel) -> _Method:
return _Method(transaction_func=channel.address_book.createNode, query_func=None)

@classmethod
def _from_protobuf(cls, transaction_body, body_bytes: bytes, sig_map):
transaction = super()._from_protobuf(transaction_body, body_bytes, sig_map)
def _from_protobuf(cls, transaction_body):
transaction = super()._from_protobuf(transaction_body)

if transaction_body.HasField("nodeCreate"):
pb = transaction_body.nodeCreate
Expand Down
4 changes: 2 additions & 2 deletions src/hiero_sdk_python/nodes/node_update_transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,8 +360,8 @@ def _get_method(self, channel: _Channel) -> _Method:
return _Method(transaction_func=channel.address_book.updateNode, query_func=None)

@classmethod
def _from_protobuf(cls, transaction_body, body_bytes: bytes, sig_map):
transaction = super()._from_protobuf(transaction_body, body_bytes, sig_map)
def _from_protobuf(cls, transaction_body):
transaction = super()._from_protobuf(transaction_body)

if transaction_body.HasField("nodeUpdate"):
pb = transaction_body.nodeUpdate
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,8 @@ def _get_method(self, channel: _Channel) -> _Method:
return _Method(transaction_func=channel.address_book.createRegisteredNode, query_func=None)

@classmethod
def _from_protobuf(cls, transaction_body, body_bytes: bytes, sig_map):
transaction = super()._from_protobuf(transaction_body, body_bytes, sig_map)
def _from_protobuf(cls, transaction_body):
transaction = super()._from_protobuf(transaction_body)

if transaction_body.HasField("registeredNodeCreate"):
pb = transaction_body.registeredNodeCreate
Expand Down
Loading
Loading