Skip to content
Open
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 5 additions & 2 deletions examples/contract/contract_call_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))

Expand Down
8 changes: 6 additions & 2 deletions examples/contract/contract_execute_transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
8 changes: 6 additions & 2 deletions examples/contract/ethereum_transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
36 changes: 36 additions & 0 deletions src/hiero_sdk_python/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -18,6 +20,8 @@

from .network import Network

DEFAULT_MAX_QUERY_PAYMENT = Hbar(1)

NetworkName = Literal["mainnet", "testnet", "previewnet"]

class Operator(NamedTuple):
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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":
"""
Expand Down
11 changes: 8 additions & 3 deletions src/hiero_sdk_python/hbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
and validating amounts of the network utility token (HBAR).
"""

import math
import re
import warnings
from decimal import Decimal
Expand Down Expand Up @@ -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):
Expand All @@ -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:
Expand Down Expand Up @@ -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)

Expand Down
55 changes: 52 additions & 3 deletions src/hiero_sdk_python/query/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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:
"""
Expand All @@ -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:
"""
Expand Down
15 changes: 11 additions & 4 deletions tests/integration/contract_call_query_e2e_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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!"

Expand Down
25 changes: 23 additions & 2 deletions tests/integration/query_e2e_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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()
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

Loading