diff --git a/CHANGELOG.md b/CHANGELOG.md index 62b941d59..2a46e5ede 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1. - Added expiration_time, auto_renew_period, auto_renew_account, fee_schedule_key, kyc_key in `TokenCreateTransaction`, `TokenUpdateTransaction` classes - Added comprehensive Google-style docstrings to the `CustomFee` class and its methods in `custom_fee.py`. - docs: Add `docs/sdk_developers/project_structure.md` to explain repository layout and import paths. +- Support selecting specific node account ID(s) for queries and transactions and added `Network._get_node()` with updated execution flow (#362) ### Changed - chore: replaced hardcoded 'testnet' messages with environment network name diff --git a/src/hiero_sdk_python/client/network.py b/src/hiero_sdk_python/client/network.py index 1a3f3f24c..99323b289 100644 --- a/src/hiero_sdk_python/client/network.py +++ b/src/hiero_sdk_python/client/network.py @@ -171,6 +171,21 @@ def _select_node(self) -> _Node: self._node_index = (self._node_index + 1) % len(self.nodes) self.current_node = self.nodes[self._node_index] return self.current_node + + def _get_node(self, account_id: AccountId) -> Optional[_Node]: + """ + Get a node matching the given account ID. + + Args: + account_id (AccountId): The account ID of the node to locate. + + Returns: + Optional[_Node]: The matching node, or None if not found. + """ + for node in self.nodes: + if node._account_id == account_id: + return node + return None def get_mirror_address(self) -> str: """ diff --git a/src/hiero_sdk_python/executable.py b/src/hiero_sdk_python/executable.py index 72236b730..8bf9d295f 100644 --- a/src/hiero_sdk_python/executable.py +++ b/src/hiero_sdk_python/executable.py @@ -75,6 +75,26 @@ def __init__(self): self._grpc_deadline = DEFAULT_GRPC_DEADLINE self.node_account_id = None + self.node_account_ids: Optional[List[AccountId]] = None + self._used_node_account_id: Optional[AccountId] = None + + def set_node_account_ids(self, node_account_ids: List[AccountId]): + """Select node account IDs for sending the request.""" + self.node_account_ids = node_account_ids + return self + + def set_node_account_id(self, node_account_id: AccountId): + """Convenience wrapper to set a single node account ID.""" + return self.set_node_account_ids([node_account_id]) + + def _select_node_account_id(self) -> Optional[AccountId]: + """Pick the first preferred node if available, otherwise None.""" + if self.node_account_ids: + selected = self.node_account_ids[0] + self._used_node_account_id = selected + return selected + return None + @abstractmethod def _should_retry(self, response) -> _ExecutionState: """ @@ -176,10 +196,17 @@ def _execute(self, client: "Client"): if attempt > 0 and current_backoff < self._max_backoff: current_backoff *= 2 - # Set the node account id to the client's node account id - node = client.network.current_node + # Select preferred node if provided, fallback to client's default + selected = self._select_node_account_id() + + if selected is not None: + node = client.network._get_node(selected) + else: + node = client.network.current_node + + #Store for logging and receipts self.node_account_id = node._account_id - + # Create a channel wrapper from the client's channel channel = node._get_channel() diff --git a/src/hiero_sdk_python/query/query.py b/src/hiero_sdk_python/query/query.py index fb7d9f858..49862921e 100644 --- a/src/hiero_sdk_python/query/query.py +++ b/src/hiero_sdk_python/query/query.py @@ -59,6 +59,9 @@ def __init__(self) -> None: self.node_index: int = 0 self.payment_amount: Optional[Hbar] = None + self.node_account_ids: Optional[List[AccountId]] = None + self._used_node_account_id: Optional[AccountId] = None + def _get_query_response(self, response: Any) -> query_pb2.Query: """ Extracts the query-specific response object from the full response. @@ -379,3 +382,4 @@ def _is_payment_required(self) -> bool: bool: True if payment is required, False otherwise """ return True + \ No newline at end of file diff --git a/src/hiero_sdk_python/transaction/transaction.py b/src/hiero_sdk_python/transaction/transaction.py index 097661f6a..3026b57b6 100644 --- a/src/hiero_sdk_python/transaction/transaction.py +++ b/src/hiero_sdk_python/transaction/transaction.py @@ -1,5 +1,5 @@ import hashlib -from typing import Optional +from typing import List, Optional from typing import TYPE_CHECKING @@ -59,7 +59,7 @@ def __init__(self) -> None: # and ensures that the correct signatures are used when submitting transactions self._signature_map: dict[bytes, basic_types_pb2.SignatureMap] = {} self._default_transaction_fee = 2_000_000 - self.operator_account_id = None + self.operator_account_id = None def _make_request(self): """ diff --git a/tests/unit/test_query_nodes.py b/tests/unit/test_query_nodes.py new file mode 100644 index 000000000..6696be9a7 --- /dev/null +++ b/tests/unit/test_query_nodes.py @@ -0,0 +1,31 @@ +import pytest +from hiero_sdk_python.query.query import Query +from hiero_sdk_python.account.account_id import AccountId + +def test_set_single_node_account_id(): + q = Query() + node = AccountId(0, 0, 3) + + q.set_node_account_id(node) + + assert q.node_account_ids == [node] + assert q._used_node_account_id is None # not selected until execution + +def test_set_multiple_node_account_ids(): + q = Query() + nodes = [AccountId(0, 0, 3), AccountId(0, 0, 4)] + + q.set_node_account_ids(nodes) + + assert q.node_account_ids == nodes + assert q._used_node_account_id is None + +def test_select_node_account_id(): + q = Query() + nodes = [AccountId(0, 0, 3), AccountId(0, 0, 4)] + q.set_node_account_ids(nodes) + + selected = q._select_node_account_id() + + assert selected == nodes[0] + assert q._used_node_account_id == nodes[0] diff --git a/tests/unit/test_transaction_nodes.py b/tests/unit/test_transaction_nodes.py new file mode 100644 index 000000000..7f0d8e718 --- /dev/null +++ b/tests/unit/test_transaction_nodes.py @@ -0,0 +1,52 @@ +import pytest +from hiero_sdk_python.transaction.transaction import Transaction +from hiero_sdk_python.account.account_id import AccountId + + +class DummyTransaction(Transaction): + """ + Minimal subclass of Transaction for testing. + Transaction is abstract (requires build methods), so we stub them out. + """ + def __init__(self): + super().__init__() + + def build_base_transaction_body(self): + return None # stub + + def _make_request(self): + return None # stub + + def _get_method(self): + return None # stub + + +def test_set_single_node_account_id(): + txn = DummyTransaction() + node = AccountId(0, 0, 3) + + txn.set_node_account_id(node) + + assert txn.node_account_ids == [node] + assert txn._used_node_account_id is None + + +def test_set_multiple_node_account_ids(): + txn = DummyTransaction() + nodes = [AccountId(0, 0, 3), AccountId(0, 0, 4)] + + txn.set_node_account_ids(nodes) + + assert txn.node_account_ids == nodes + assert txn._used_node_account_id is None + + +def test_select_node_account_id(): + txn = DummyTransaction() + nodes = [AccountId(0, 0, 3), AccountId(0, 0, 4)] + txn.set_node_account_ids(nodes) + + selected = txn._select_node_account_id() + + assert selected == nodes[0] + assert txn._used_node_account_id == nodes[0]