diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 90e3ac04b..ae01493ce 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,15 +22,15 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 with: egress-policy: audit - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: ${{ matrix.python-version }} cache: 'pip' @@ -49,7 +49,7 @@ jobs: - name: Prepare Hiero Solo id: solo - uses: hiero-ledger/hiero-solo-action@fbca3e7a99ce9aa8a250563a81187abe115e0dad # v0.15.0 + uses: hiero-ledger/hiero-solo-action@dd0048139ef1e40fd6067f01bf94eb42a67294f4 # v0.15.0 with: installMirrorNode: true @@ -105,7 +105,14 @@ jobs: - name: Fail workflow if any tests failed shell: bash run: | - if [ "${{ steps.integration.outputs.integration_failed }}" != "0" ] || [ "${{ steps.unit.outputs.unit_failed }}" != "0" ]; then + integration_failed="${{ steps.integration.outputs.integration_failed }}" + unit_failed="${{ steps.unit.outputs.unit_failed }}" + + # Default to 0 if empty + integration_failed="${integration_failed:-0}" + unit_failed="${unit_failed:-0}" + + if [ "$integration_failed" != "0" ] || [ "$unit_failed" != "0" ]; then echo "❌ Some tests failed. Failing workflow." exit 1 else diff --git a/CHANGELOG.md b/CHANGELOG.md index e00cee42f..2150586a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,17 +7,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1. ## [Unreleased] ### Added - -- Added **str**() to CustomFixedFee and updated examples and tests accordingly. -- Added a github template for good first issues -- Added `.github/workflows/bot-assignment-check.yml` to limit non-maintainers to 2 concurrent issue assignments. -- Added all missing fields to **str**() method and updated `test_tokem_info.py` -- Add examples/tokens/token_create_transaction_pause_key.py example demonstrating token pause/unpause behavior and pause key usage (#833) -- Added `docs/sdk_developers/training/transaction_lifecycle.md` to explain the typical lifecycle of executing a transaction using the Hedera Python SDK. -- Add inactivity bot workflow to unassign stale issue assignees (#952) -- Made custom fraction fee end to end -- Added Acceptance Criteria section to Good First Issue template for better contributor guidance (#997) -- Added __str__() to CustomRoyaltyFee and updated examples and tests accordingly (#986) +- Support selecting specific node account ID(s) for queries and transactions and added `Network._get_node()` with updated execution flow (#362) ### Changed diff --git a/src/hiero_sdk_python/client/network.py b/src/hiero_sdk_python/client/network.py index 35dd30ebb..b3ddc4bbf 100644 --- a/src/hiero_sdk_python/client/network.py +++ b/src/hiero_sdk_python/client/network.py @@ -177,6 +177,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..412c26e91 100644 --- a/src/hiero_sdk_python/executable.py +++ b/src/hiero_sdk_python/executable.py @@ -1,12 +1,13 @@ from os import error import time -from typing import Callable, Optional, Any, TYPE_CHECKING +from typing import Callable, Optional, Any, TYPE_CHECKING, List import grpc from abc import ABC, abstractmethod from enum import IntEnum from hiero_sdk_python.channels import _Channel from hiero_sdk_python.exceptions import MaxAttemptsError +from hiero_sdk_python.account.account_id import AccountId if TYPE_CHECKING: from hiero_sdk_python.client.client import Client @@ -75,6 +76,33 @@ def __init__(self): self._grpc_deadline = DEFAULT_GRPC_DEADLINE self.node_account_id = None + self.node_account_ids: List[AccountId] = [] + self._used_node_account_id: Optional[AccountId] = None + self._node_account_ids_index: int = 0 + + 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 current node from the list if available, otherwise None.""" + if self.node_account_ids: + # Use modulo to cycle through the list + selected = self.node_account_ids[self._node_account_ids_index % len(self.node_account_ids)] + self._used_node_account_id = selected + return selected + return None + + def _advance_node_index(self): + """Advance to the next node in the list.""" + if self.node_account_ids: + self._node_account_ids_index += 1 + @abstractmethod def _should_retry(self, response) -> _ExecutionState: """ @@ -176,10 +204,20 @@ 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 - + + # Advance to next node for the next attempt (if using explicit node list) + self._advance_node_index() + # Create a channel wrapper from the client's channel channel = node._get_channel() @@ -210,6 +248,10 @@ def _execute(self, client: "Client"): case _ExecutionState.RETRY: # If we should retry, wait for the backoff period and try again err_persistant = status_error + # If not using explicit node list, switch to next node for retry + if not self.node_account_ids: + node = client.network._select_node() + logger.trace("Switched to a different node for retry", "error", err_persistant, "from node", self.node_account_id, "to node", node._account_id) _delay_for_attempt(self._get_request_id(), current_backoff, attempt, logger, err_persistant) continue case _ExecutionState.EXPIRED: @@ -223,8 +265,11 @@ def _execute(self, client: "Client"): except grpc.RpcError as e: # Save the error err_persistant = f"Status: {e.code()}, Details: {e.details()}" - node = client.network._select_node() - logger.trace("Switched to a different node for the next attempt", "error", err_persistant, "from node", self.node_account_id, "to node", node._account_id) + # If not using explicit node list, switch to next node for retry + if not self.node_account_ids: + node = client.network._select_node() + logger.trace("Switched to a different node for the next attempt", "error", err_persistant, "from node", self.node_account_id, "to node", node._account_id) + _delay_for_attempt(self._get_request_id(), current_backoff, attempt, logger, err_persistant) continue logger.error("Exceeded maximum attempts for request", "requestId", self._get_request_id(), "last exception being", err_persistant) @@ -261,4 +306,4 @@ def _execute_method(method, proto_request): return method.transaction(proto_request) elif method.query is not None: return method.query(proto_request) - raise Exception("No method to execute") \ No newline at end of file + raise Exception("No method to execute") \ No newline at end of file diff --git a/src/hiero_sdk_python/query/query.py b/src/hiero_sdk_python/query/query.py index fb7d9f858..9c60b2525 100644 --- a/src/hiero_sdk_python/query/query.py +++ b/src/hiero_sdk_python/query/query.py @@ -54,7 +54,6 @@ def __init__(self) -> None: super().__init__() self.timestamp: int = int(time.time()) - self.node_account_ids: List[AccountId] = [] self.operator: Optional[Operator] = None self.node_index: int = 0 self.payment_amount: Optional[Hbar] = None @@ -106,11 +105,7 @@ def _before_execute(self, client: Client) -> None: Args: client: The client instance to use for execution """ - if not self.node_account_ids: - self.node_account_ids = client.get_node_account_ids() - self.operator = self.operator or client.operator - self.node_account_ids = list(set(self.node_account_ids)) # 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 @@ -379,3 +374,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 4f629b9f6..2e81a1511 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 @@ -9,10 +9,9 @@ from hiero_sdk_python.crypto.private_key import PrivateKey from hiero_sdk_python.exceptions import PrecheckError from hiero_sdk_python.executable import _Executable, _ExecutionState -from hiero_sdk_python.hapi.services import (basic_types_pb2, transaction_pb2, transaction_contents_pb2) +from hiero_sdk_python.hapi.services import (basic_types_pb2, transaction_pb2, transaction_contents_pb2, transaction_pb2) from hiero_sdk_python.hapi.services.schedulable_transaction_body_pb2 import SchedulableTransactionBody from hiero_sdk_python.hapi.services.transaction_response_pb2 import (TransactionResponse as TransactionResponseProto) -from hiero_sdk_python.hbar import Hbar from hiero_sdk_python.response_code import ResponseCode from hiero_sdk_python.transaction.transaction_id import TransactionId from hiero_sdk_python.transaction.transaction_response import TransactionResponse @@ -62,9 +61,8 @@ def __init__(self) -> None: # This allows us to maintain the signatures for each unique transaction # and ensures that the correct signatures are used when submitting transactions self._signature_map: dict[bytes, basic_types_pb2.SignatureMap] = {} - # changed from int: 2_000_000 to Hbar: 0.02 - self._default_transaction_fee = Hbar(0.02) - self.operator_account_id = None + self._default_transaction_fee = 2_000_000 + self.operator_account_id = None self.batch_key: Optional[PrivateKey] = None def _make_request(self): @@ -421,11 +419,7 @@ def build_base_transaction_body(self) -> transaction_pb2.TransactionBody: transaction_body.transactionID.CopyFrom(transaction_id_proto) transaction_body.nodeAccountID.CopyFrom(self.node_account_id._to_proto()) - fee = self.transaction_fee or self._default_transaction_fee - if hasattr(fee, "to_tinybars"): - transaction_body.transactionFee = int(fee.to_tinybars()) - else: - transaction_body.transactionFee = int(fee) + transaction_body.transactionFee = self.transaction_fee or self._default_transaction_fee transaction_body.transactionValidDuration.seconds = self.transaction_valid_duration transaction_body.generateRecord = self.generate_record @@ -447,13 +441,9 @@ def build_base_scheduled_body(self) -> SchedulableTransactionBody: The protobuf SchedulableTransactionBody message with common fields set. """ schedulable_body = SchedulableTransactionBody() - - fee = self.transaction_fee or self._default_transaction_fee - if hasattr(fee, "to_tinybars"): - schedulable_body.transactionFee = int(fee.to_tinybars()) - else: - schedulable_body.transactionFee = int(fee) - + schedulable_body.transactionFee = ( + self.transaction_fee or self._default_transaction_fee + ) schedulable_body.memo = self.memo custom_fee_limits = [custom_fee._to_proto() for custom_fee in self.custom_fee_limits] schedulable_body.max_custom_fees.extend(custom_fee_limits) diff --git a/tests/unit/test_executable.py b/tests/unit/test_executable.py index 784853946..bfd844ab3 100644 --- a/tests/unit/test_executable.py +++ b/tests/unit/test_executable.py @@ -46,7 +46,7 @@ def test_retry_success_before_max_attempts(): # First server gives 2 BUSY responses then OK on the 3rd try response_sequences = [[busy_response, busy_response, ok_response, receipt_response]] - with mock_hedera_servers(response_sequences) as client, patch('time.sleep'): + with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep'): # Configure client to allow 3 attempts - should succeed on the last try client.max_attempts = 3 @@ -70,7 +70,7 @@ def test_retry_failure_after_max_attempts(): response_sequences = [[busy_response, busy_response]] - with mock_hedera_servers(response_sequences) as client, patch('time.sleep'): + with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep'): client.max_attempts = 2 transaction = ( @@ -112,7 +112,7 @@ def test_node_switching_after_single_grpc_error(): [error], ] - with mock_hedera_servers(response_sequences) as client, patch('time.sleep'): + with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep'): transaction = ( AccountCreateTransaction() .set_key(PrivateKey.generate().public_key()) @@ -149,7 +149,7 @@ def test_node_switching_after_multiple_grpc_errors(): [ok_response, receipt_response], ] - with mock_hedera_servers(response_sequences) as client, patch('time.sleep'): + with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep'): transaction = ( AccountCreateTransaction() .set_key(PrivateKey.generate().public_key()) @@ -185,7 +185,7 @@ def test_transaction_with_expired_error_not_retried(): [error_response] ] - with mock_hedera_servers(response_sequences) as client, patch('time.sleep'): + with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep'): transaction = ( AccountCreateTransaction() .set_key(PrivateKey.generate().public_key()) @@ -216,7 +216,7 @@ def test_transaction_with_fatal_error_not_retried(): [error_response] ] - with mock_hedera_servers(response_sequences) as client, patch('time.sleep'): + with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep'): transaction = ( AccountCreateTransaction() .set_key(PrivateKey.generate().public_key()) @@ -248,7 +248,7 @@ def test_exponential_backoff_retry(): response_sequences = [[busy_response, busy_response, busy_response, ok_response, receipt_response]] # Use a mock for time.sleep to capture the delay values - with mock_hedera_servers(response_sequences) as client, patch('time.sleep') as mock_sleep: + with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep') as mock_sleep: client.max_attempts = 5 transaction = ( @@ -288,7 +288,7 @@ def test_retriable_error_does_not_switch_node(): ) ) response_sequences = [[busy_response, ok_response, receipt_response]] - with mock_hedera_servers(response_sequences) as client, patch('time.sleep'): + with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep'): transaction = ( AccountCreateTransaction() .set_key(PrivateKey.generate().public_key()) @@ -333,7 +333,7 @@ def test_topic_create_transaction_retry_on_busy(): [busy_response, ok_response, receipt_response], ] - with mock_hedera_servers(response_sequences) as client, patch('time.sleep') as mock_sleep: + with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep') as mock_sleep: client.max_attempts = 3 tx = ( @@ -367,7 +367,7 @@ def test_topic_create_transaction_fails_on_nonretriable_error(): [error_response], ] - with mock_hedera_servers(response_sequences) as client, patch('time.sleep'): + with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep'): tx = ( TopicCreateTransaction() .set_memo("Test with error") @@ -400,7 +400,7 @@ def test_transaction_node_switching_body_bytes(): [ok_response, receipt_response], ] - with mock_hedera_servers(response_sequences) as client, patch('time.sleep'): + with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep'): # We set the current node to 0 client.network._node_index = 0 client.network.current_node = client.network.nodes[0] @@ -467,8 +467,9 @@ def test_query_retry_on_busy(): [ok_response], ] - with mock_hedera_servers(response_sequences) as client, patch('time.sleep') as mock_sleep: + with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep') as mock_sleep: # We set the current node to the first node so we are sure it will return BUSY response + client.network._node_index = 0 client.network.current_node = client.network.nodes[0] query = CryptoGetAccountBalanceQuery() 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]