Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 13 additions & 10 deletions src/hiero_sdk_python/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ def __init__(self, network: Network = None) -> None:

self.max_attempts: int = 10
self.default_max_query_payment: Hbar = DEFAULT_MAX_QUERY_PAYMENT
self.default_max_transaction_fee: Hbar | None = None
Comment thread
exploreriii marked this conversation as resolved.

self._min_backoff: float = DEFAULT_MIN_BACKOFF
self._max_backoff: float = DEFAULT_MAX_BACKOFF
Expand Down Expand Up @@ -277,19 +278,21 @@ def set_default_max_query_payment(self, max_query_payment: int | float | Decimal
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(
f"max_query_payment must be int, float, Decimal, or Hbar, 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")

value = Hbar._coerce_non_negative(max_query_payment, "max_query_payment")
self.default_max_query_payment = value
return self

def set_default_max_transaction_fee(
Comment thread
exploreriii marked this conversation as resolved.
self,
max_transaction_fee: int | float | Decimal | Hbar,
) -> Client:
"""
Sets the default maximum Hbar fee allowed for any transaction executed by this client.
"""
value = Hbar._coerce_non_negative(max_transaction_fee, "max_transaction_fee")
self.default_max_transaction_fee = value
return self

def set_max_attempts(self, max_attempts: int) -> Client:
"""
Set the maximum number of execution attempts for all transactions and queries
Expand Down
23 changes: 23 additions & 0 deletions src/hiero_sdk_python/hbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,29 @@ def negated(self) -> Hbar:
"""
return Hbar.from_tinybars(-self._amount_in_tinybar)

@staticmethod
def _coerce_non_negative(value: int | float | Decimal | Hbar, name: str = "value") -> Hbar:
"""
Coerce a value to a non-negative Hbar. Accepts int, float, Decimal, or Hbar (but not bool).

Args:
value: The value to coerce.
name: The field name used in error messages. Defaults to "value".

Returns:
Hbar: A non-negative Hbar instance.

Raises:
TypeError: If value is not int, float, Decimal, or Hbar.
ValueError: If value is negative.
"""
if isinstance(value, bool) or not isinstance(value, (int, float, Decimal, Hbar)):
raise TypeError(f"{name} must be int, float, Decimal, or Hbar, got {type(value).__name__}")
result = value if isinstance(value, Hbar) else Hbar(value)
if result < Hbar.ZERO:
raise ValueError(f"{name} must be non-negative")
return result

@classmethod
def of(cls, amount: int | float | Decimal, unit: HbarUnit) -> Hbar:
"""
Expand Down
60 changes: 41 additions & 19 deletions src/hiero_sdk_python/transaction/transaction.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import hashlib
from decimal import Decimal
from typing import TYPE_CHECKING, Literal, overload

from hiero_sdk_python.account.account_id import AccountId
Expand Down Expand Up @@ -265,6 +266,20 @@ def _resolve_node_ids(self, client: Client):
if self._node_account_ids.is_empty:
self._node_account_ids.set_list([node._account_id for node in client.network.nodes])

def _resolve_transaction_fee(self, client: Client | None) -> None:
"""Resolve the max transaction fee: explicit fee, else client default, else per-type default."""
if self._transaction_fee is not None:
return

default = client.default_max_transaction_fee if client is not None else None
if not isinstance(default, Hbar):
default = None

if default is not None:
self.transaction_fee = default
else:
self._transaction_fee = self._default_transaction_fee

def freeze(self):
"""
Freezes the transaction by building the transaction body and setting necessary IDs.
Expand Down Expand Up @@ -304,6 +319,7 @@ def freeze_with(self, client: Client):
# Resolve transaction_id and node_accountids to be set when using freeze()
self._resolve_transaction_id(client)
self._resolve_node_ids(client)
self._resolve_transaction_fee(client)

required_chunks = self.get_required_chunks()
self._generate_transaction_ids(self._transaction_ids.get(0), required_chunks)
Expand Down Expand Up @@ -509,8 +525,8 @@ def build_base_transaction_body(self) -> transaction_pb2.TransactionBody:
"""
transaction_body = transaction_pb2.TransactionBody()

fee = self._transaction_fee or self._default_transaction_fee
if hasattr(fee, "to_tinybars"):
fee = self._transaction_fee if self._transaction_fee is not None else self._default_transaction_fee
if isinstance(fee, Hbar):
transaction_body.transactionFee = int(fee.to_tinybars())
else:
transaction_body.transactionFee = int(fee)
Expand All @@ -537,8 +553,8 @@ def build_base_scheduled_body(self) -> SchedulableTransactionBody:
"""
schedulable_body = SchedulableTransactionBody()

fee = self._transaction_fee or self._default_transaction_fee
if hasattr(fee, "to_tinybars"):
fee = self._transaction_fee if self._transaction_fee is not None else self._default_transaction_fee
if isinstance(fee, Hbar):
schedulable_body.transactionFee = int(fee.to_tinybars())
else:
schedulable_body.transactionFee = int(fee)
Expand Down Expand Up @@ -681,19 +697,7 @@ def transaction_fee(self, fee: Hbar | int):
"""
Set the maximum transaction fee for this transaction.
"""
self._require_not_frozen()

if isinstance(fee, Hbar):
tinybars = fee.to_tinybars()
elif isinstance(fee, bool) or not isinstance(fee, int):
raise TypeError("fee must be of type Hbar or int")
else:
tinybars = fee

if tinybars < 0:
raise ValueError("fee must be greater than or equal to 0")

self._transaction_fee = tinybars
self.set_max_transaction_fee(fee)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

git show b9cfe74e266e03705fb082ced217961f9e6b9c65:src/hiero_sdk_python/transaction/transaction.py | grep -n -A25 'transaction_fee.setter'
sed -n '680,710p;840,870p' src/hiero_sdk_python/transaction/transaction.py
sed -n '80,110p' src/hiero_sdk_python/hbar.py
sed -n '560,590p' tests/unit/transaction_test.py

Repository: hiero-ledger/hiero-sdk-python

Length of output: 5715


Preserve tinybar units in transaction_fee assignments.

At the merge base, integer assignments to transaction_fee were stored as tinybars. The new delegation sends integers to set_max_transaction_fee(), where Hbar(value) interprets them as Hbar. Therefore, tx.transaction_fee = 100_000_000 now serializes as 100,000,000 Hbar instead of 1 Hbar.

Keep integer assignments in tinybars. Preserve the property-specific transaction_fee validation messages expected by tests/unit/transaction_test.py.


def to_bytes(self) -> bytes:
"""
Expand Down Expand Up @@ -837,6 +841,25 @@ def from_bytes(transaction_bytes: bytes):
transaction_body, signed_transaction.bodyBytes, signed_transaction.sigMap
)

def set_max_transaction_fee(self, max_transaction_fee: int | float | Decimal | Hbar) -> Transaction:
"""
Set the maximum transaction fee the payer is willing to pay for this transaction.

Args:
max_transaction_fee (int | float | Decimal | Hbar): The maximum fee.
Numeric values are interpreted as Hbar.

Returns:
Transaction: This transaction instance for method chaining.
Raises:
TypeError: If the value is not int, float, Decimal, or Hbar.
ValueError: If the value is negative.
Exception: If the transaction has already been frozen.
"""
self._require_not_frozen()
self._transaction_fee = Hbar._coerce_non_negative(max_transaction_fee, "max_transaction_fee").to_tinybars()
return self

@staticmethod
def _get_transaction_class(transaction_type: str):
"""
Expand Down Expand Up @@ -942,7 +965,7 @@ def _from_protobuf(cls, transaction_body, body_bytes: bytes, sig_map):
if transaction_body.HasField("nodeAccountID"):
transaction._node_account_ids.set_list([AccountId._from_proto(transaction_body.nodeAccountID)])

transaction.transaction_fee = transaction_body.transactionFee
transaction._transaction_fee = transaction_body.transactionFee
transaction.transaction_valid_duration = transaction_body.transactionValidDuration.seconds
transaction.generate_record = transaction_body.generateRecord
transaction._high_volume = transaction_body.high_volume
Expand All @@ -967,7 +990,6 @@ def _from_protobuf(cls, transaction_body, body_bytes: bytes, sig_map):

if sig_map and sig_map.sigPair:
transaction._signature_map[body_bytes] = sig_map

return transaction

def set_batch_key(self, key: Key):
Expand Down
34 changes: 29 additions & 5 deletions tests/integration/account_update_transaction_e2e_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from hiero_sdk_python.crypto.key_list import KeyList
from hiero_sdk_python.crypto.private_key import PrivateKey
from hiero_sdk_python.Duration import Duration
from hiero_sdk_python.exceptions import PrecheckError
from hiero_sdk_python.hbar import Hbar
from hiero_sdk_python.query.account_info_query import AccountInfoQuery
from hiero_sdk_python.response_code import ResponseCode
Expand Down Expand Up @@ -248,7 +249,6 @@ def _apply_tiny_max_fee_if_supported(tx, client) -> bool:
# Try client-level default
for attr in (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same for this one we can directly do client.set_default_max_transaction_fee()

"set_default_max_transaction_fee",
"set_max_transaction_fee",
"set_default_max_fee",
"setMaxTransactionFee",
):
Expand Down Expand Up @@ -283,16 +283,40 @@ def test_account_update_insufficient_fee_with_valid_expiration_bump(env):
if not _apply_tiny_max_fee_if_supported(tx, env.client):
pytest.skip("SDK lacks a max-fee API; cannot deterministically trigger INSUFFICIENT_TX_FEE.")

receipt = tx.execute(env.client)
assert receipt.status == ResponseCode.INSUFFICIENT_TX_FEE, (
f"Expected INSUFFICIENT_TX_FEE but got {ResponseCode(receipt.status).name}"
)
# If it succeeds or raises a different error, the test will fail.
with pytest.raises(PrecheckError) as exc_info:
tx.execute(env.client)

assert exc_info.value.status == ResponseCode.INSUFFICIENT_TX_FEE

# Confirm expiration time did not change
info_after = AccountInfoQuery(account_id).execute(env.client)
assert int(info_after.expiration_time.seconds) == base_expiry_secs


@pytest.mark.integration
def test_account_update_insufficient_fee_via_client_default(env):
"""A client-level default max fee must apply to transactions that set no explicit fee."""
receipt = (
AccountCreateTransaction()
.set_key(env.operator_key.public_key())
.set_initial_balance(Hbar(1))
.execute(env.client)
)
assert receipt.status == ResponseCode.SUCCESS
account_id = receipt.account_id

env.client.set_default_max_transaction_fee(Hbar.from_tinybars(1))

# No tx-level fee: the 1-tinybar client default must be resolved at freeze and rejected at precheck.
tx = AccountUpdateTransaction().set_account_id(account_id).set_account_memo("client default fee test")

with pytest.raises(PrecheckError) as exc_info:
tx.execute(env.client)

assert exc_info.value.status == ResponseCode.INSUFFICIENT_TX_FEE


@pytest.mark.integration
def test_integration_account_update_transaction_with_only_account_id(env):
"""Test that AccountUpdateTransaction can execute with only account ID set."""
Expand Down
43 changes: 43 additions & 0 deletions tests/unit/client_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,49 @@ def test_set_default_max_query_payment_valid_param(valid_amount, expected):
assert client.default_max_query_payment == expected


def test_default_max_transaction_fee_is_none():
"""Default `default_max_transaction_fee` should be None."""
client = Client.for_testnet()
assert client.default_max_transaction_fee is None


@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_transaction_fee_valid_param(valid_amount, expected):
"""Test set_default_max_transaction_fee converts inputs to Hbar and stores them."""
client = Client.for_testnet()

returned = client.set_default_max_transaction_fee(valid_amount)
assert client.default_max_transaction_fee == expected
assert returned is client


@pytest.mark.parametrize("invalid_amount", ["1", True, False, None, object()])
def test_set_default_max_transaction_fee_invalid_param(invalid_amount):
"""Test set_default_max_transaction_fee rejects invalid types."""
client = Client.for_testnet()

with pytest.raises(TypeError):
client.set_default_max_transaction_fee(invalid_amount)


@pytest.mark.parametrize("negative_amount", [-1, -0.1, Decimal("-0.1"), Hbar(-1)])
def test_set_default_max_transaction_fee_negative_value(negative_amount):
"""Test set_default_max_transaction_fee rejects negative values."""
client = Client.for_testnet()

with pytest.raises(ValueError):
client.set_default_max_transaction_fee(negative_amount)


@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."""
Expand Down
Loading
Loading