Skip to content
Draft
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
40 changes: 30 additions & 10 deletions src/hiero_sdk_python/executable.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,27 @@ 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 +130,11 @@ 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,12 +366,12 @@ 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
# Temp workaround till the chunk-tx fix
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")
raise RuntimeError("No nodes available for execution")

def _should_retry_exponentially(self, err: Exception) -> bool:
"""
Expand Down Expand Up @@ -429,10 +449,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 +462,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 +503,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 +538,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 +549,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
6 changes: 6 additions & 0 deletions src/hiero_sdk_python/query/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ def _before_execute(self, client: Client) -> None:
"""
self.operator = self.operator or client.operator

if not self.node_account_ids:
self.node_account_ids = [node._account_id for node in client.network.nodes]

# If no payment amount was specified and payment is required for this query,
# get the cost from the network and set it as the payment amount
if self.payment_amount is None and self._is_payment_required():
Expand Down Expand Up @@ -295,6 +298,9 @@ def get_cost(self, client: Client) -> Hbar:
if client is None or client.operator is None:
raise ValueError("Client and operator must be set to get the cost")

if not self.node_account_ids:
self.node_account_ids = [node._account_id for node in client.network.nodes]

# Here we execute the query to get the cost of it
resp = self._execute(client)
query_response = self._get_query_response(resp)
Expand Down
39 changes: 16 additions & 23 deletions src/hiero_sdk_python/transaction/transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,9 +216,9 @@ def _to_proto(self):
# We require the transaction to be frozen before converting to protobuf
self._require_frozen()

body_bytes = self._transaction_body_bytes.get(self.node_account_id)
body_bytes = self._transaction_body_bytes.get(self._node_account_id)
if body_bytes is None:
raise ValueError(f"No transaction body found for node {self.node_account_id}")
raise ValueError(f"No transaction body found for node {self._node_account_id}")

# Get signature map, or create empty one if transaction is not signed
sig_map = self._signature_map.get(body_bytes)
Expand All @@ -245,7 +245,7 @@ def _resolve_transaction_id(self, client: Client):
)

def _resolve_node_ids(self, client: Client):
if self.node_account_id is None and len(self.node_account_ids) == 0 and client is None:
if len(self.node_account_ids) == 0 and client is None:
raise ValueError(
"Node account ID must be set before freezing. Use freeze_with(client) or manually set node_account_ids."
)
Expand Down Expand Up @@ -297,27 +297,18 @@ def freeze_with(self, client: Client):

if self.batch_key:
# For Inner Transaction of batch transaction node_account_id=0.0.0
self.node_account_id = AccountId(0, 0, 0)
self.node_account_ids = [AccountId(0, 0, 0)]
self._node_account_id = AccountId(0, 0, 0)
self._transaction_body_bytes[AccountId(0, 0, 0)] = self.build_transaction_body().SerializeToString()
return self

# Single node
if self.node_account_id:
self.set_node_account_id(self.node_account_id)
self._transaction_body_bytes[self.node_account_id] = self.build_transaction_body().SerializeToString()
return self

# Multiple node
if len(self.node_account_ids) > 0:
for node_account_id in self.node_account_ids:
self.node_account_id = node_account_id
self._transaction_body_bytes[node_account_id] = self.build_transaction_body().SerializeToString()
# If not nodes set manually use the all network nodes available in client
if len(self.node_account_ids) == 0:
self.node_account_ids = [node._account_id for node in client.network.nodes]

else:
# Use all nodes from client network
for node in client.network.nodes:
self.node_account_id = node._account_id
self._transaction_body_bytes[node._account_id] = self.build_transaction_body().SerializeToString()
for node_account_id in self.node_account_ids:
self._node_account_id = node_account_id
self._transaction_body_bytes[node_account_id] = self.build_transaction_body().SerializeToString()

return self

Expand Down Expand Up @@ -405,7 +396,7 @@ def is_signed_by(self, public_key):
"""
public_key_bytes = public_key.to_bytes_raw()

sig_map = self._signature_map.get(self._transaction_body_bytes.get(self.node_account_id))
sig_map = self._signature_map.get(self._transaction_body_bytes.get(self._node_account_id))

if sig_map is None:
return False
Expand Down Expand Up @@ -459,7 +450,8 @@ def build_base_transaction_body(self) -> transaction_pb2.TransactionBody:

transaction_id_proto = self.transaction_id._to_proto()

selected_node = self.node_account_id or (self.node_account_ids[0] if self.node_account_ids else None)
# This will change with the fix for the chunk tx to create bytes on freeze
selected_node = self._node_account_id or (self.node_account_ids[0] if self.node_account_ids else None)
if selected_node is None:
raise ValueError("Node account ID is not set.")

Expand Down Expand Up @@ -877,7 +869,8 @@ def _from_protobuf(cls, transaction_body, body_bytes: bytes, sig_map):
transaction.transaction_id = TransactionId._from_proto(transaction_body.transactionID)

if transaction_body.HasField("nodeAccountID"):
transaction.node_account_id = AccountId._from_proto(transaction_body.nodeAccountID)
transaction._node_account_id = AccountId._from_proto(transaction_body.nodeAccountID)
transaction.node_account_ids = [AccountId._from_proto(transaction_body.nodeAccountID)]

transaction.transaction_fee = transaction_body.transactionFee
transaction.transaction_valid_duration = transaction_body.transactionValidDuration.seconds
Expand Down
16 changes: 9 additions & 7 deletions tests/unit/executable_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -816,7 +816,7 @@ def test_execution_config_inherits_from_client(mock_client):
mock_client._request_timeout = 20

tx = AccountCreateTransaction()

tx.set_node_account_ids([AccountId(0, 0, 3)])
tx._resolve_execution_config(mock_client, None)

assert tx._max_attempts == 7
Expand All @@ -831,19 +831,19 @@ def test_executable_overrides_client_config(mock_client):
mock_client.max_attempts = 10

tx = AccountCreateTransaction().set_max_attempts(3)
tx.set_node_account_ids([AccountId(0, 0, 3)])
tx._resolve_execution_config(mock_client, None)

assert tx._max_attempts == 3


def test_no_healthy_nodes_raises(mock_client):
"""Test that execution fails if no healthy nodes are available."""
mock_client.network._healthy_nodes = []

def test_no_nodes_raises_exception(mock_client):
"""Test that execution fails if no nodes are available."""
mock_client.network.nodes = []
tx = AccountCreateTransaction().set_key_without_alias(PrivateKey.generate().public_key()).set_initial_balance(1)

with pytest.raises(RuntimeError, match="No healthy nodes available"):
tx.execute(mock_client)
with pytest.raises(RuntimeError, match="No nodes available for execution"):
tx._resolve_execution_config(mock_client, None)


def test_set_node_account_ids_overrides_client_nodes(mock_client):
Expand All @@ -859,6 +859,7 @@ def test_set_node_account_ids_overrides_client_nodes(mock_client):
def test_parameter_timeout_overrides_client_default(mock_client):
"""Explicit timeout pass on the executable should override the client default timeout."""
tx = AccountCreateTransaction()
tx.set_node_account_ids([AccountId(0, 0, 3)])
tx._resolve_execution_config(mock_client, 2)

assert tx._request_timeout == 2
Expand All @@ -868,6 +869,7 @@ def test_set_timeout_overrides_parameter_timeout(mock_client):
"""Explicit timeout set on the tx should override the pass timeout."""
tx = AccountCreateTransaction()
tx.set_request_timeout(5)
tx.set_node_account_ids([AccountId(0, 0, 3)])
tx._resolve_execution_config(mock_client, 2)

assert tx._request_timeout == 5
Expand Down
12 changes: 6 additions & 6 deletions tests/unit/query_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,9 @@ def test_before_execute_payment_not_required(query, mock_client):
# payment_amount is None, should not set payment_amount
query._before_execute(mock_client)

# since node_account_ids is not set it will be empty
# query internally use node form client
assert query.node_account_ids == []
# since node_account_ids is not set it will use node form client
assert len(query.node_account_ids) == len(mock_client.network.nodes)
assert query.node_account_ids == [node._account_id for node in mock_client.network.nodes]
assert query.operator == mock_client.operator
assert query.payment_amount is None

Expand All @@ -79,9 +79,9 @@ def test_before_execute_payment_required(query_requires_payment, mock_client):
# payment_amount is None, should set payment_amount to 2 Hbars
query_requires_payment._before_execute(mock_client)

# since node_account_ids is not set it will be empty
# query internally use node form client
assert query_requires_payment.node_account_ids == []
# since node_account_ids is not set it will use node form client
assert len(query_requires_payment.node_account_ids) == len(mock_client.network.nodes)
assert query_requires_payment.node_account_ids == [node._account_id for node in mock_client.network.nodes]
assert query_requires_payment.operator == mock_client.operator
assert query_requires_payment.payment_amount.to_tinybars() == Hbar(2).to_tinybars()

Expand Down
6 changes: 4 additions & 2 deletions tests/unit/transaction_freeze_and_bytes_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,7 @@ def test_changing_node_after_freeze_fails_for_to_bytes():
assert isinstance(bytes_node_1, bytes)

# Change to a different node that wasn't frozen
transaction.node_account_id = node_id_2
transaction._node_account_id = node_id_2

# This should fail - no transaction body for node_id_2
with pytest.raises(ValueError, match="No transaction body found for node"):
Expand Down Expand Up @@ -663,7 +663,9 @@ def test_transaction_freeze_without_node_ids(mock_client):
tx = TransferTransaction()
tx.freeze_with(mock_client)

assert tx.node_account_ids == []
assert len(tx.node_account_ids) == len(mock_client.network.nodes)
assert tx.node_account_ids == [node._account_id for node in mock_client.network.nodes]

# Verify creates transaction_bytes for client network nodes
assert len(tx._transaction_body_bytes) == len(mock_client.network.nodes)
assert set(tx._transaction_body_bytes.keys()) == set(node._account_id for node in mock_client.network.nodes)
Expand Down
34 changes: 34 additions & 0 deletions tests/unit/transaction_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -553,3 +553,37 @@ def test_transaction_default_max_fee(account_id):

assert tx_body is not None
assert tx_body.transactionFee == Hbar(2).to_tinybars()


def test_transaction_body_bytes_for_each_node_id_on_freeze(mock_client):
"""Test create transaction body bytes for each network node when frozen."""
tx = AccountCreateTransaction().set_key_without_alias(PrivateKey.generate_ecdsa())
tx.freeze_with(mock_client)

body_bytes = tx._transaction_body_bytes

assert body_bytes
assert body_bytes.keys() == {node._account_id for node in mock_client.network.nodes}

for node_id, body_bytes_value in body_bytes.items():
body = transaction_pb2.TransactionBody()
body.ParseFromString(body_bytes_value)
assert body.nodeAccountID == AccountId._to_proto(node_id)


def test_transaction_body_bytes_for_each_node_id_on_freeze_manual(mock_client):
"""Test create transaction body bytes for manually configured node account IDs."""
node_ids = [AccountId(0, 0, 3), AccountId(0, 0, 4)]

tx = AccountCreateTransaction().set_key_without_alias(PrivateKey.generate_ecdsa()).set_node_account_ids(node_ids)
tx.freeze_with(mock_client)

body_bytes = tx._transaction_body_bytes

assert body_bytes
assert set(body_bytes) == set(node_ids)

for node_id, body_bytes_value in body_bytes.items():
body = transaction_pb2.TransactionBody()
body.ParseFromString(body_bytes_value)
assert body.nodeAccountID == AccountId._to_proto(node_id)
Loading