diff --git a/CHANGELOG.md b/CHANGELOG.md index 3849e2017..5d0172c1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1. - Added `Client.from_env()` and network-specific factory methods (e.g., `Client.for_testnet()`) to simplify client initialization and reduce boilerplate. [[#1251](https://github.com/hiero-ledger/hiero-sdk-python/issues/1251)] - Improved unit test coverage for `TransactionId` class, covering parsing logic, hashing, and scheduled transactions. - Add contract_id support for CryptoGetAccountBalanceQuery([#1293](https://github.com/hiero-ledger/hiero-sdk-python/issues/1293)) +- Support for setting `max_query_payment`, `Query.set_max_query_payment()` allows setting a per-query maximum Hbar payment and `Client.set_default_max_query_payment()` sets a client-wide default maximum payment. - Chained Good First Issue assignment with mentor assignment to bypass GitHub's anti-recursion protection - mentor assignment now occurs immediately after successful user assignment in the same workflow execution. (#1369) - Add GitHub Actions script and workflow for automatic spam list updates. - Added technical docstrings and hardening (set -euo pipefail) to the pr-check-test-files.sh script (#1336) diff --git a/examples/contract/contract_call_query.py b/examples/contract/contract_call_query.py index 5c55745e1..3551d95c1 100644 --- a/examples/contract/contract_call_query.py +++ b/examples/contract/contract_call_query.py @@ -114,15 +114,18 @@ def query_contract_call(): contract_id = create_contract(client, file_id) - result = ( + query = ( ContractCallQuery() .set_contract_id(contract_id) .set_gas(2000000) .set_function( "getMessageAndOwner" ) # Call the contract's getMessageAndOwner() function - .execute(client) ) + cost = query.get_cost(client) + query.set_max_query_payment(cost) + + result = query.execute(client) # You can also use set_function_parameters() instead of set_function() e.g.: # .set_function_parameters(ContractFunctionParameters("getMessageAndOwner")) diff --git a/examples/contract/contract_execute_transaction.py b/examples/contract/contract_execute_transaction.py index 5f869df74..c5671b8e6 100644 --- a/examples/contract/contract_execute_transaction.py +++ b/examples/contract/contract_execute_transaction.py @@ -107,14 +107,18 @@ def create_contract(client, file_id): def get_contract_message(client, contract_id): """Get the message from the contract""" # Query the contract function to verify that the message was set - result = ( + query = ( ContractCallQuery() .set_contract_id(contract_id) .set_gas(2000000) .set_function("getMessage") - .execute(client) ) + cost = query.get_cost(client) + query.set_max_query_payment(cost) + + result = query.execute(client) + # The contract returns bytes32, which we decode to string # This removes any padding and converts to readable text return result.get_bytes32(0).decode("utf-8") diff --git a/examples/contract/ethereum_transaction.py b/examples/contract/ethereum_transaction.py index 72b9d4907..3698ab90b 100644 --- a/examples/contract/ethereum_transaction.py +++ b/examples/contract/ethereum_transaction.py @@ -146,14 +146,18 @@ def create_contract(client, file_id): def get_contract_message(client, contract_id): """Get the message from the contract""" # Query the contract function to verify that the message was set - result = ( + query = ( ContractCallQuery() .set_contract_id(contract_id) .set_gas(2000000) .set_function("getMessage") - .execute(client) ) + cost = query.get_cost(client) + query.set_max_query_payment(cost) + + result = query.execute(client) + # The contract returns bytes32, which we decode # Remove null bytes padding and decode as UTF-8 text message_bytes = result.get_bytes32(0).rstrip(b"\x00") diff --git a/src/hiero_sdk_python/client/client.py b/src/hiero_sdk_python/client/client.py index d65840af1..93c3ddda8 100644 --- a/src/hiero_sdk_python/client/client.py +++ b/src/hiero_sdk_python/client/client.py @@ -2,11 +2,13 @@ Client module for interacting with the Hedera network. """ +from decimal import Decimal import os from typing import NamedTuple, List, Union, Optional, Literal from dotenv import load_dotenv import grpc +from hiero_sdk_python.hbar import Hbar from hiero_sdk_python.logger.logger import Logger, LogLevel from hiero_sdk_python.hapi.mirror import ( consensus_service_pb2_grpc as mirror_consensus_grpc, @@ -18,6 +20,8 @@ from .network import Network +DEFAULT_MAX_QUERY_PAYMENT = Hbar(1) + NetworkName = Literal["mainnet", "testnet", "previewnet"] class Operator(NamedTuple): @@ -45,6 +49,7 @@ def __init__(self, network: Network = None) -> None: self.mirror_stub: mirror_consensus_grpc.ConsensusServiceStub = None self.max_attempts: int = 10 + self.default_max_query_payment: Hbar = DEFAULT_MAX_QUERY_PAYMENT self._init_mirror_stub() @@ -240,6 +245,37 @@ def get_tls_root_certificates(self) -> Optional[bytes]: Retrieve the configured root certificates for TLS connections. """ return self.network.get_tls_root_certificates() + + def set_default_max_query_payment(self, max_query_payment: Union[int, float, Decimal, Hbar]) -> "Client": + """ + Sets the default maximum Hbar amount allowed for any query executed by this client. + + The SDK fetches the actual query cost and fails early if it exceeds this limit. + Individual queries may override this value via `Query.set_max_query_payment()`. + + Args: + max_query_payment (Union[int, float, Decimal, Hbar]): + The maximum amount of Hbar that any single query is allowed to cost. + Returns: + Client: The current client instance for method chaining. + """ + if isinstance(max_query_payment, bool) or not isinstance(max_query_payment, (int, float, Decimal, Hbar)): + raise TypeError( + "max_query_payment must be int, float, Decimal, or Hbar, " + f"got {type(max_query_payment).__name__}" + ) + + value = ( + max_query_payment + if isinstance(max_query_payment, Hbar) + else Hbar(max_query_payment) + ) + + if value < Hbar(0): + raise ValueError("max_query_payment must be non-negative") + + self.default_max_query_payment = value + return self def __enter__(self) -> "Client": """ diff --git a/src/hiero_sdk_python/hbar.py b/src/hiero_sdk_python/hbar.py index 65bc62eb2..cc080b66e 100644 --- a/src/hiero_sdk_python/hbar.py +++ b/src/hiero_sdk_python/hbar.py @@ -6,6 +6,7 @@ and validating amounts of the network utility token (HBAR). """ +import math import re import warnings from decimal import Decimal @@ -39,6 +40,11 @@ def __init__( amount: The numeric amount of hbar or tinybar. unit: Unit of the provided amount. """ + if isinstance(amount, bool) or not isinstance(amount, (int, float, Decimal)): + raise TypeError("Amount must be of type int, float, or Decimal") + + if isinstance(amount, float) and not math.isfinite(amount): + raise ValueError("Hbar amount must be finite") if unit == HbarUnit.TINYBAR: if not isinstance(amount, int): @@ -48,12 +54,11 @@ def __init__( if isinstance(amount, (float, int)): amount = Decimal(str(amount)) - elif not isinstance(amount, Decimal): - raise TypeError("Amount must be of type int, float, or Decimal") tinybar = amount * Decimal(unit.tinybar) if tinybar % 1 != 0: raise ValueError("Fractional tinybar value not allowed") + self._amount_in_tinybar = int(tinybar) def to(self, unit: HbarUnit) -> float: @@ -104,7 +109,7 @@ def from_tinybars(cls, tinybars: int) -> "Hbar": Returns: Hbar: A new Hbar instance. """ - if not isinstance(tinybars, int): + if isinstance(tinybars, bool) or not isinstance(tinybars, int): raise TypeError("tinybars must be an int.") return cls(tinybars, unit=HbarUnit.TINYBAR) diff --git a/src/hiero_sdk_python/query/query.py b/src/hiero_sdk_python/query/query.py index 9c60b2525..d4f85df1e 100644 --- a/src/hiero_sdk_python/query/query.py +++ b/src/hiero_sdk_python/query/query.py @@ -2,8 +2,9 @@ Base class for all network queries. """ +from decimal import Decimal import time -from typing import Any, List, Optional, Union +from typing import Any, Optional, Union from hiero_sdk_python.account.account_id import AccountId from hiero_sdk_python.channels import _Channel @@ -18,8 +19,7 @@ query_header_pb2, query_pb2, transaction_pb2, - transaction_contents_pb2, - transaction_pb2, + transaction_contents_pb2 ) from hiero_sdk_python.hbar import Hbar from hiero_sdk_python.response_code import ResponseCode @@ -57,6 +57,7 @@ def __init__(self) -> None: self.operator: Optional[Operator] = None self.node_index: int = 0 self.payment_amount: Optional[Hbar] = None + self.max_query_payment: Optional[Hbar] = None def _get_query_response(self, response: Any) -> query_pb2.Query: """ @@ -91,6 +92,40 @@ def set_query_payment(self, payment_amount: Hbar) -> "Query": """ self.payment_amount = payment_amount return self + + def set_max_query_payment(self, max_query_payment: Union[int, float, Decimal, Hbar]) -> "Query": + """ + Sets the maximum Hbar amount that this query is allowed to cost. + + Before executing a paid query, the SDK will fetch the actual query cost + from the network and compare it against this value. If the cost exceeds + the specified maximum, execution will fail early with an error instead + of submitting the query. + + Args: + max_query_payment (Union[int, float, Decimal, Hbar]): + The maximum amount of Hbar that any single query is allowed to cost. + + Returns: + Query: The current query instance for method chaining. + """ + if isinstance(max_query_payment, bool) or not isinstance(max_query_payment, (int, float, Decimal, Hbar)): + raise TypeError( + "max_query_payment must be int, float, Decimal, or Hbar, " + f"got {type(max_query_payment).__name__}" + ) + + value = ( + max_query_payment + if isinstance(max_query_payment, Hbar) + else Hbar(max_query_payment) + ) + + if value < Hbar(0): + raise ValueError("max_query_payment must be non-negative") + + self.max_query_payment = value + return self def _before_execute(self, client: Client) -> None: """ @@ -111,6 +146,20 @@ def _before_execute(self, client: Client) -> None: # get the cost from the network and set it as the payment amount if self.payment_amount is None and self._is_payment_required(): self.payment_amount = self.get_cost(client) + + # if max_query_payment not set fall back to the client-level default max query payment + max_payment = ( + self.max_query_payment + if self.max_query_payment is not None + else client.default_max_query_payment + ) + + if self.payment_amount > max_payment: + raise ValueError( + f"Query cost ℏ{self.payment_amount.to_hbars()} HBAR " + f"exceeds max set query payment: ℏ{max_payment.to_hbars()} HBAR" + ) + def _make_request_header(self) -> query_header_pb2.QueryHeader: """ diff --git a/tests/integration/contract_call_query_e2e_test.py b/tests/integration/contract_call_query_e2e_test.py index cf5d63863..fd60ef5ed 100644 --- a/tests/integration/contract_call_query_e2e_test.py +++ b/tests/integration/contract_call_query_e2e_test.py @@ -63,14 +63,18 @@ def test_integration_contract_call_query_can_execute_with_constructor(env): contract_id = receipt.contract_id assert contract_id is not None, "Contract ID should not be None" - result = ( + query = ( ContractCallQuery() .set_contract_id(contract_id) .set_gas(10000000) .set_function("getMessage") - .execute(env.client) ) + cost = query.get_cost(env.client) + query.set_max_query_payment(cost) + + result = query.execute(env.client) + assert result is not None, "Contract call result should not be None" assert result.get_bytes32(0) == message @@ -109,14 +113,17 @@ def test_integration_contract_call_query_can_execute(env): contract_id = receipt.contract_id assert contract_id is not None, "Contract ID should not be None" - result = ( + query = ( ContractCallQuery() .set_contract_id(contract_id) .set_gas(10000000) .set_function("greet") - .execute(env.client) ) + cost = query.get_cost(env.client) + query.set_max_query_payment(cost) + + result = query.execute(env.client) assert result is not None, "Contract call result should not be None" assert result.get_string(0) == "Hello, world!" diff --git a/tests/integration/query_e2e_test.py b/tests/integration/query_e2e_test.py index 105c9a715..5f087195c 100644 --- a/tests/integration/query_e2e_test.py +++ b/tests/integration/query_e2e_test.py @@ -6,9 +6,10 @@ from hiero_sdk_python.exceptions import PrecheckError from hiero_sdk_python.hbar import Hbar from hiero_sdk_python.query.account_balance_query import CryptoGetAccountBalanceQuery +from hiero_sdk_python.query.account_info_query import AccountInfoQuery from hiero_sdk_python.query.token_info_query import TokenInfoQuery from hiero_sdk_python.response_code import ResponseCode -from tests.integration.utils import IntegrationTestEnv, create_fungible_token +from tests.integration.utils import IntegrationTestEnv, env, create_fungible_token @pytest.mark.integration def test_integration_free_query_no_cost(): @@ -236,4 +237,24 @@ def test_integration_paid_query_payment_too_high_fails(): with pytest.raises(PrecheckError, match="failed precheck with status: INSUFFICIENT_PAYER_BALANCE"): query.execute(env.client) finally: - env.close() \ No newline at end of file + env.close() + +@pytest.mark.integration +def test_integration_query_exceeds_max_payment(env): + """Test that Query fails when cost exceeds max_query_payment.""" + receipt = env.create_account(1) + account_id = receipt.id + + # Set max payment below actual cost + query = ( + AccountInfoQuery() + .set_account_id(account_id) + .set_max_query_payment(Hbar.from_tinybars(1)) # Intentionally too low to fail + ) + + with pytest.raises(ValueError) as e: + query.execute(env.client) + + msg = str(e.value) + assert "Query cost" in msg and "exceeds max set query payment:" in msg + \ No newline at end of file diff --git a/tests/unit/client_factory_test.py b/tests/unit/client_test.py similarity index 74% rename from tests/unit/client_factory_test.py rename to tests/unit/client_test.py index 22ce27361..2085b0831 100644 --- a/tests/unit/client_factory_test.py +++ b/tests/unit/client_test.py @@ -1,7 +1,8 @@ """ -Unit tests for Client factory methods (from_env, for_testnet, for_mainnet, for_previewnet). +Unit tests for Client methods (eg. from_env, for_testnet, for_mainnet, for_previewnet). """ +from decimal import Decimal import os import pytest from unittest.mock import patch @@ -13,6 +14,7 @@ AccountId, PrivateKey ) +from hiero_sdk_python.hbar import Hbar @pytest.mark.parametrize( "factory_method, expected_network", @@ -184,4 +186,58 @@ def test_from_env_with_malformed_operator_key(): with patch.object(client_module, 'load_dotenv'): with patch.dict(os.environ, env_vars, clear=True): with pytest.raises(ValueError): - Client.from_env() \ No newline at end of file + Client.from_env() + +@pytest.mark.parametrize( + 'valid_amount,expected', + [ + (1, Hbar(1)), + (0.1, Hbar(0.1)), + (Decimal('0.1'), Hbar(Decimal('0.1'))), + (Hbar(1), Hbar(1)), + (Hbar(0), Hbar(0)) + ] +) +def test_set_default_max_query_payment_valid_param(valid_amount, expected): + """Test that set_default_max_query_payment correctly converts various input types to Hbar.""" + client = Client.for_testnet() + # by default is 1 hbar before setting it + assert client.default_max_query_payment == Hbar(1) + client.set_default_max_query_payment(valid_amount) + assert client.default_max_query_payment == expected + +@pytest.mark.parametrize( + 'negative_amount', + [-1, -0.1, Decimal('-0.1'), Decimal('-1'), Hbar(-1)] +) +def test_set_default_max_query_payment_negative_value(negative_amount): + """Test set_default_max_query_payment for negative amount values.""" + client = Client.for_testnet() + + with pytest.raises(ValueError, match="max_query_payment must be non-negative"): + client.set_default_max_query_payment(negative_amount) + +@pytest.mark.parametrize( + 'invalid_amount', + ['1', 'abc', True, False, None, object()] +) +def test_set_default_max_query_payment_invalid_param(invalid_amount): + """Test that set_default_max_query_payment raise error for invalid param.""" + client = Client.for_testnet() + + with pytest.raises(TypeError, match=( + "max_query_payment must be int, float, Decimal, or Hbar, " + f"got {type(invalid_amount).__name__}" + )): + client.set_default_max_query_payment(invalid_amount) + +@pytest.mark.parametrize( + 'invalid_amount', + [float('inf'), float('nan')] +) +def test_set_default_max_query_payment_non_finite_value(invalid_amount): + """Test that set_default_max_query_payment raise error for non finite value.""" + client = Client.for_testnet() + + with pytest.raises(ValueError, match="Hbar amount must be finite"): + client.set_default_max_query_payment(invalid_amount) diff --git a/tests/unit/hbar_test.py b/tests/unit/hbar_test.py index 8f8d873c1..6978783c8 100644 --- a/tests/unit/hbar_test.py +++ b/tests/unit/hbar_test.py @@ -21,6 +21,24 @@ def test_constructor(): assert hbar3.to_tinybars() == 50_000_000 assert hbar3.to_hbars() == 0.5 +@pytest.mark.parametrize( + 'invalid_amount', + ['1', True, False, {}, object] +) +def test_constructor_invalid_amount_type(invalid_amount): + """Test creation with invalid type raise errors.""" + with pytest.raises(TypeError, match="Amount must be of type int, float, or Decimal"): + Hbar(invalid_amount) + +@pytest.mark.parametrize( + 'invalid_amount', + [float('inf'), float('nan')] +) +def test_constructor_non_finite_amount_value(invalid_amount): + """Test creation raise errors for non finite amount.""" + with pytest.raises(ValueError, match="Hbar amount must be finite"): + Hbar(invalid_amount) + def test_constructor_with_tinybar_unit(): """Test creation with unit set to HbarUnit.TINYBAR.""" hbar1 = Hbar(50, unit=HbarUnit.TINYBAR) @@ -200,4 +218,13 @@ def test_factory_methods(): assert Hbar.from_gigabars(1).to_hbars() == 1_000_000_000 assert Hbar.from_gigabars(0).to_hbars() == 0 assert Hbar.from_gigabars(-1).to_hbars() == -1_000_000_000 - assert Hbar.from_gigabars(1).to_tinybars() == 100_000_000_000_000_000 \ No newline at end of file + assert Hbar.from_gigabars(1).to_tinybars() == 100_000_000_000_000_000 + +@pytest.mark.parametrize( + 'invalid_tinybars', + ['1', 0.1, Decimal('0.1'), True, False, object, {}] +) +def test_from_tinybars_invalid_type_param(invalid_tinybars): + """Test from_tinybar method raises error if the type is not int.""" + with pytest.raises(TypeError, match=re.escape("tinybars must be an int.")): + Hbar.from_tinybars(invalid_tinybars) \ No newline at end of file diff --git a/tests/unit/query_test.py b/tests/unit/query_test.py index d6315487a..695782866 100644 --- a/tests/unit/query_test.py +++ b/tests/unit/query_test.py @@ -1,3 +1,5 @@ +from decimal import Decimal +import re import pytest from unittest.mock import MagicMock @@ -10,6 +12,8 @@ from hiero_sdk_python.hapi.services import query_header_pb2, response_pb2, response_header_pb2, crypto_get_account_balance_pb2, token_get_info_pb2 from tests.unit.mock_server import mock_hedera_servers +pytestmark = pytest.mark.unit + # By default we test query that doesn't require payment @pytest.fixture def query(): @@ -55,6 +59,7 @@ def test_before_execute_payment_required(query_requires_payment, mock_client): mock_get_cost = MagicMock() mock_get_cost.return_value = Hbar(2) query_requires_payment.get_cost = mock_get_cost + query_requires_payment.set_max_query_payment(Hbar(3)) # payment_amount is None, should set payment_amount to 2 Hbars query_requires_payment._before_execute(mock_client) @@ -245,4 +250,136 @@ def test_query_payment_requirement_defaults_to_true(query_requires_payment): query = Query() assert query._is_payment_required() == True # Verify that payment-requiring query also defaults to requiring payment - assert query_requires_payment._is_payment_required() == True \ No newline at end of file + assert query_requires_payment._is_payment_required() == True + +@pytest.mark.parametrize( + 'valid_amount,expected', + [ + (1, Hbar(1)), + (0.1, Hbar(0.1)), + (Decimal('0.1'), Hbar(Decimal('0.1'))), + (Hbar(1), Hbar(1)), + (Hbar(0), Hbar(0)) + ] +) +def test_set_max_query_payment_valid_param(query, valid_amount, expected): + """Test that set_max_query_payment correctly converts various input types to Hbar.""" + # by default is none before setting it + assert query.max_query_payment is None + query.set_max_query_payment(valid_amount) + assert query.max_query_payment == expected + +@pytest.mark.parametrize( + 'negative_amount', + [-1, -0.1, Decimal('-0.1'), Decimal('-1'), Hbar(-1), Hbar(-0.2)] +) +def test_set_max_query_payment_negative_value(query, negative_amount): + """Test set_max_query_payment for negative amount values.""" + with pytest.raises(ValueError, match="max_query_payment must be non-negative"): + query.set_max_query_payment(negative_amount) + +@pytest.mark.parametrize( + 'invalid_amount', + ['1', 'abc', True, False, None, object()] +) +def test_set_max_query_payment_invalid_param(query, invalid_amount): + """Test that set_max_query_payment raise error for invalid param.""" + with pytest.raises(TypeError, match=( + "max_query_payment must be int, float, Decimal, or Hbar, " + f"got {type(invalid_amount).__name__}" + )): + query.set_max_query_payment(invalid_amount) + +@pytest.mark.parametrize( + 'invalid_amount', + [float('inf'), float('nan')] +) +def test_set_max_query_payment_non_finite_value(query, invalid_amount): + """Test that set_max_query_payment raise error for non finite value.""" + with pytest.raises(ValueError, match="Hbar amount must be finite"): + query.set_max_query_payment(invalid_amount) + +def test_set_max_payment_override_client_max_payment(query_requires_payment, mock_client): + """ + Test that a query can override the Client's default_max_query_payment + """ + # assert mock_client default max_query_payment = 1 hbar + assert mock_client.default_max_query_payment == Hbar(1) + + # set max_payment in query to 2 hbar + query_requires_payment.set_max_query_payment(2) + assert query_requires_payment.max_query_payment == Hbar(2) + + # mock the get_cost to return 2 hbar as required paymnet + mock_get_cost = MagicMock() + mock_get_cost.return_value = Hbar(2) + + # should succeed because 2 <= query.max_query_payment + query_requires_payment.get_cost = mock_get_cost + query_requires_payment._before_execute(mock_client) + + assert query_requires_payment.payment_amount == Hbar(2) + +def test_set_max_payment_override_client_max_payment_and_error(query_requires_payment, mock_client): + """ + Test that a query can override the Client's default_max_query_payment + and that execution fails if the query cost exceeds the query-specific max. + """ + # Check Client's default max_query_payment + assert mock_client.default_max_query_payment == Hbar(1) + + # Update Client's default max_query_payment to 2 Hbar + mock_client.set_default_max_query_payment(Hbar(2)) + assert mock_client.default_max_query_payment == Hbar(2) + + # Set max_query_payment for this query to 1 Hbar (override client default) + query_requires_payment.set_max_query_payment(1) + assert query_requires_payment.max_query_payment == Hbar(1) + + # Mock get_cost to return 2 Hbar, exceeding the query.max_query_payment + mock_get_cost = MagicMock(return_value=Hbar(2)) + query_requires_payment.get_cost = mock_get_cost + + # Execution should raise ValueError because 2 > query.max_query_payment (1) + expected_msg = "Query cost ℏ2.0 HBAR exceeds max set query payment: ℏ1.0 HBAR" + with pytest.raises(ValueError, match=re.escape(expected_msg)): + query_requires_payment._before_execute(mock_client) + + +def test_payment_query_use_client_max_payment(query_requires_payment, mock_client): + """ + Test that a query uses Client's default_max_query_payment if no query override. + """ + # assert mock_client default max_query_payment = 1 hbar + assert mock_client.default_max_query_payment == Hbar(1) + + # Update Client's default max_query_payment to 2 Hbar + mock_client.set_default_max_query_payment(Hbar(2)) + assert mock_client.default_max_query_payment == Hbar(2) + + # mock the get_cost to return 2 hbar as required paymnet + mock_get_cost = MagicMock() + mock_get_cost.return_value = Hbar(2) + + # should succeed because 2 <= client.default_max_query_payment + query_requires_payment.get_cost = mock_get_cost + query_requires_payment._before_execute(mock_client) + + assert query_requires_payment.payment_amount == Hbar(2) + +def test_payment_query_use_client_max_payment_and_error(query_requires_payment, mock_client): + """ + Test that execution fails if cost > client default max when query doesn't override. + """ + # Check Client's default max_query_payment + assert mock_client.default_max_query_payment == Hbar(1) + + # Mock get_cost to return 2 Hbar, exceeding the client.default_max_query_payment + mock_get_cost = MagicMock(return_value=Hbar(2)) + query_requires_payment.get_cost = mock_get_cost + + # Execution should raise ValueError because 2 > client.default_max_query_payment + expected_msg = "Query cost ℏ2.0 HBAR exceeds max set query payment: ℏ1.0 HBAR" + with pytest.raises(ValueError, match=re.escape(expected_msg)): + query_requires_payment._before_execute(mock_client) +