Skip to content

Commit e228937

Browse files
authored
feat: Add max query payment support to Query (hiero-ledger#1347)
Signed-off-by: Manish Dait <[email protected]>
1 parent cd41b04 commit e228937

File tree

12 files changed

+372
-22
lines changed

12 files changed

+372
-22
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.
100100
- 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)]
101101
- Improved unit test coverage for `TransactionId` class, covering parsing logic, hashing, and scheduled transactions.
102102
- Add contract_id support for CryptoGetAccountBalanceQuery([#1293](https://github.com/hiero-ledger/hiero-sdk-python/issues/1293))
103+
- 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.
103104
- 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)
104105
- Add GitHub Actions script and workflow for automatic spam list updates.
105106
- Added technical docstrings and hardening (set -euo pipefail) to the pr-check-test-files.sh script (#1336)

examples/contract/contract_call_query.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,15 +114,18 @@ def query_contract_call():
114114

115115
contract_id = create_contract(client, file_id)
116116

117-
result = (
117+
query = (
118118
ContractCallQuery()
119119
.set_contract_id(contract_id)
120120
.set_gas(2000000)
121121
.set_function(
122122
"getMessageAndOwner"
123123
) # Call the contract's getMessageAndOwner() function
124-
.execute(client)
125124
)
125+
cost = query.get_cost(client)
126+
query.set_max_query_payment(cost)
127+
128+
result = query.execute(client)
126129
# You can also use set_function_parameters() instead of set_function() e.g.:
127130
# .set_function_parameters(ContractFunctionParameters("getMessageAndOwner"))
128131

examples/contract/contract_execute_transaction.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,14 +107,18 @@ def create_contract(client, file_id):
107107
def get_contract_message(client, contract_id):
108108
"""Get the message from the contract"""
109109
# Query the contract function to verify that the message was set
110-
result = (
110+
query = (
111111
ContractCallQuery()
112112
.set_contract_id(contract_id)
113113
.set_gas(2000000)
114114
.set_function("getMessage")
115-
.execute(client)
116115
)
117116

117+
cost = query.get_cost(client)
118+
query.set_max_query_payment(cost)
119+
120+
result = query.execute(client)
121+
118122
# The contract returns bytes32, which we decode to string
119123
# This removes any padding and converts to readable text
120124
return result.get_bytes32(0).decode("utf-8")

examples/contract/ethereum_transaction.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,14 +146,18 @@ def create_contract(client, file_id):
146146
def get_contract_message(client, contract_id):
147147
"""Get the message from the contract"""
148148
# Query the contract function to verify that the message was set
149-
result = (
149+
query = (
150150
ContractCallQuery()
151151
.set_contract_id(contract_id)
152152
.set_gas(2000000)
153153
.set_function("getMessage")
154-
.execute(client)
155154
)
156155

156+
cost = query.get_cost(client)
157+
query.set_max_query_payment(cost)
158+
159+
result = query.execute(client)
160+
157161
# The contract returns bytes32, which we decode
158162
# Remove null bytes padding and decode as UTF-8 text
159163
message_bytes = result.get_bytes32(0).rstrip(b"\x00")

src/hiero_sdk_python/client/client.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22
Client module for interacting with the Hedera network.
33
"""
44

5+
from decimal import Decimal
56
import os
67
from typing import NamedTuple, List, Union, Optional, Literal
78
from dotenv import load_dotenv
89
import grpc
910

11+
from hiero_sdk_python.hbar import Hbar
1012
from hiero_sdk_python.logger.logger import Logger, LogLevel
1113
from hiero_sdk_python.hapi.mirror import (
1214
consensus_service_pb2_grpc as mirror_consensus_grpc,
@@ -18,6 +20,8 @@
1820

1921
from .network import Network
2022

23+
DEFAULT_MAX_QUERY_PAYMENT = Hbar(1)
24+
2125
NetworkName = Literal["mainnet", "testnet", "previewnet"]
2226

2327
class Operator(NamedTuple):
@@ -45,6 +49,7 @@ def __init__(self, network: Network = None) -> None:
4549
self.mirror_stub: mirror_consensus_grpc.ConsensusServiceStub = None
4650

4751
self.max_attempts: int = 10
52+
self.default_max_query_payment: Hbar = DEFAULT_MAX_QUERY_PAYMENT
4853

4954
self._init_mirror_stub()
5055

@@ -240,6 +245,37 @@ def get_tls_root_certificates(self) -> Optional[bytes]:
240245
Retrieve the configured root certificates for TLS connections.
241246
"""
242247
return self.network.get_tls_root_certificates()
248+
249+
def set_default_max_query_payment(self, max_query_payment: Union[int, float, Decimal, Hbar]) -> "Client":
250+
"""
251+
Sets the default maximum Hbar amount allowed for any query executed by this client.
252+
253+
The SDK fetches the actual query cost and fails early if it exceeds this limit.
254+
Individual queries may override this value via `Query.set_max_query_payment()`.
255+
256+
Args:
257+
max_query_payment (Union[int, float, Decimal, Hbar]):
258+
The maximum amount of Hbar that any single query is allowed to cost.
259+
Returns:
260+
Client: The current client instance for method chaining.
261+
"""
262+
if isinstance(max_query_payment, bool) or not isinstance(max_query_payment, (int, float, Decimal, Hbar)):
263+
raise TypeError(
264+
"max_query_payment must be int, float, Decimal, or Hbar, "
265+
f"got {type(max_query_payment).__name__}"
266+
)
267+
268+
value = (
269+
max_query_payment
270+
if isinstance(max_query_payment, Hbar)
271+
else Hbar(max_query_payment)
272+
)
273+
274+
if value < Hbar(0):
275+
raise ValueError("max_query_payment must be non-negative")
276+
277+
self.default_max_query_payment = value
278+
return self
243279

244280
def __enter__(self) -> "Client":
245281
"""

src/hiero_sdk_python/hbar.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
and validating amounts of the network utility token (HBAR).
77
"""
88

9+
import math
910
import re
1011
import warnings
1112
from decimal import Decimal
@@ -39,6 +40,11 @@ def __init__(
3940
amount: The numeric amount of hbar or tinybar.
4041
unit: Unit of the provided amount.
4142
"""
43+
if isinstance(amount, bool) or not isinstance(amount, (int, float, Decimal)):
44+
raise TypeError("Amount must be of type int, float, or Decimal")
45+
46+
if isinstance(amount, float) and not math.isfinite(amount):
47+
raise ValueError("Hbar amount must be finite")
4248

4349
if unit == HbarUnit.TINYBAR:
4450
if not isinstance(amount, int):
@@ -48,12 +54,11 @@ def __init__(
4854

4955
if isinstance(amount, (float, int)):
5056
amount = Decimal(str(amount))
51-
elif not isinstance(amount, Decimal):
52-
raise TypeError("Amount must be of type int, float, or Decimal")
5357

5458
tinybar = amount * Decimal(unit.tinybar)
5559
if tinybar % 1 != 0:
5660
raise ValueError("Fractional tinybar value not allowed")
61+
5762
self._amount_in_tinybar = int(tinybar)
5863

5964
def to(self, unit: HbarUnit) -> float:
@@ -104,7 +109,7 @@ def from_tinybars(cls, tinybars: int) -> "Hbar":
104109
Returns:
105110
Hbar: A new Hbar instance.
106111
"""
107-
if not isinstance(tinybars, int):
112+
if isinstance(tinybars, bool) or not isinstance(tinybars, int):
108113
raise TypeError("tinybars must be an int.")
109114
return cls(tinybars, unit=HbarUnit.TINYBAR)
110115

src/hiero_sdk_python/query/query.py

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
Base class for all network queries.
33
"""
44

5+
from decimal import Decimal
56
import time
6-
from typing import Any, List, Optional, Union
7+
from typing import Any, Optional, Union
78

89
from hiero_sdk_python.account.account_id import AccountId
910
from hiero_sdk_python.channels import _Channel
@@ -18,8 +19,7 @@
1819
query_header_pb2,
1920
query_pb2,
2021
transaction_pb2,
21-
transaction_contents_pb2,
22-
transaction_pb2,
22+
transaction_contents_pb2
2323
)
2424
from hiero_sdk_python.hbar import Hbar
2525
from hiero_sdk_python.response_code import ResponseCode
@@ -57,6 +57,7 @@ def __init__(self) -> None:
5757
self.operator: Optional[Operator] = None
5858
self.node_index: int = 0
5959
self.payment_amount: Optional[Hbar] = None
60+
self.max_query_payment: Optional[Hbar] = None
6061

6162
def _get_query_response(self, response: Any) -> query_pb2.Query:
6263
"""
@@ -91,6 +92,40 @@ def set_query_payment(self, payment_amount: Hbar) -> "Query":
9192
"""
9293
self.payment_amount = payment_amount
9394
return self
95+
96+
def set_max_query_payment(self, max_query_payment: Union[int, float, Decimal, Hbar]) -> "Query":
97+
"""
98+
Sets the maximum Hbar amount that this query is allowed to cost.
99+
100+
Before executing a paid query, the SDK will fetch the actual query cost
101+
from the network and compare it against this value. If the cost exceeds
102+
the specified maximum, execution will fail early with an error instead
103+
of submitting the query.
104+
105+
Args:
106+
max_query_payment (Union[int, float, Decimal, Hbar]):
107+
The maximum amount of Hbar that any single query is allowed to cost.
108+
109+
Returns:
110+
Query: The current query instance for method chaining.
111+
"""
112+
if isinstance(max_query_payment, bool) or not isinstance(max_query_payment, (int, float, Decimal, Hbar)):
113+
raise TypeError(
114+
"max_query_payment must be int, float, Decimal, or Hbar, "
115+
f"got {type(max_query_payment).__name__}"
116+
)
117+
118+
value = (
119+
max_query_payment
120+
if isinstance(max_query_payment, Hbar)
121+
else Hbar(max_query_payment)
122+
)
123+
124+
if value < Hbar(0):
125+
raise ValueError("max_query_payment must be non-negative")
126+
127+
self.max_query_payment = value
128+
return self
94129

95130
def _before_execute(self, client: Client) -> None:
96131
"""
@@ -111,6 +146,20 @@ def _before_execute(self, client: Client) -> None:
111146
# get the cost from the network and set it as the payment amount
112147
if self.payment_amount is None and self._is_payment_required():
113148
self.payment_amount = self.get_cost(client)
149+
150+
# if max_query_payment not set fall back to the client-level default max query payment
151+
max_payment = (
152+
self.max_query_payment
153+
if self.max_query_payment is not None
154+
else client.default_max_query_payment
155+
)
156+
157+
if self.payment_amount > max_payment:
158+
raise ValueError(
159+
f"Query cost ℏ{self.payment_amount.to_hbars()} HBAR "
160+
f"exceeds max set query payment: ℏ{max_payment.to_hbars()} HBAR"
161+
)
162+
114163

115164
def _make_request_header(self) -> query_header_pb2.QueryHeader:
116165
"""

tests/integration/contract_call_query_e2e_test.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,18 @@ def test_integration_contract_call_query_can_execute_with_constructor(env):
6363
contract_id = receipt.contract_id
6464
assert contract_id is not None, "Contract ID should not be None"
6565

66-
result = (
66+
query = (
6767
ContractCallQuery()
6868
.set_contract_id(contract_id)
6969
.set_gas(10000000)
7070
.set_function("getMessage")
71-
.execute(env.client)
7271
)
7372

73+
cost = query.get_cost(env.client)
74+
query.set_max_query_payment(cost)
75+
76+
result = query.execute(env.client)
77+
7478
assert result is not None, "Contract call result should not be None"
7579
assert result.get_bytes32(0) == message
7680

@@ -109,14 +113,17 @@ def test_integration_contract_call_query_can_execute(env):
109113
contract_id = receipt.contract_id
110114
assert contract_id is not None, "Contract ID should not be None"
111115

112-
result = (
116+
query = (
113117
ContractCallQuery()
114118
.set_contract_id(contract_id)
115119
.set_gas(10000000)
116120
.set_function("greet")
117-
.execute(env.client)
118121
)
119122

123+
cost = query.get_cost(env.client)
124+
query.set_max_query_payment(cost)
125+
126+
result = query.execute(env.client)
120127
assert result is not None, "Contract call result should not be None"
121128
assert result.get_string(0) == "Hello, world!"
122129

tests/integration/query_e2e_test.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66
from hiero_sdk_python.exceptions import PrecheckError
77
from hiero_sdk_python.hbar import Hbar
88
from hiero_sdk_python.query.account_balance_query import CryptoGetAccountBalanceQuery
9+
from hiero_sdk_python.query.account_info_query import AccountInfoQuery
910
from hiero_sdk_python.query.token_info_query import TokenInfoQuery
1011
from hiero_sdk_python.response_code import ResponseCode
11-
from tests.integration.utils import IntegrationTestEnv, create_fungible_token
12+
from tests.integration.utils import IntegrationTestEnv, env, create_fungible_token
1213

1314
@pytest.mark.integration
1415
def test_integration_free_query_no_cost():
@@ -236,4 +237,24 @@ def test_integration_paid_query_payment_too_high_fails():
236237
with pytest.raises(PrecheckError, match="failed precheck with status: INSUFFICIENT_PAYER_BALANCE"):
237238
query.execute(env.client)
238239
finally:
239-
env.close()
240+
env.close()
241+
242+
@pytest.mark.integration
243+
def test_integration_query_exceeds_max_payment(env):
244+
"""Test that Query fails when cost exceeds max_query_payment."""
245+
receipt = env.create_account(1)
246+
account_id = receipt.id
247+
248+
# Set max payment below actual cost
249+
query = (
250+
AccountInfoQuery()
251+
.set_account_id(account_id)
252+
.set_max_query_payment(Hbar.from_tinybars(1)) # Intentionally too low to fail
253+
)
254+
255+
with pytest.raises(ValueError) as e:
256+
query.execute(env.client)
257+
258+
msg = str(e.value)
259+
assert "Query cost" in msg and "exceeds max set query payment:" in msg
260+

0 commit comments

Comments
 (0)