Skip to content
Draft
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
14 changes: 14 additions & 0 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

self._min_backoff: float = DEFAULT_MIN_BACKOFF
self._max_backoff: float = DEFAULT_MAX_BACKOFF
Expand Down Expand Up @@ -288,6 +289,19 @@ def set_default_max_query_payment(self, max_query_payment: int | float | Decimal
self.default_max_query_payment = value
return self

def set_max_transaction_fee(
Comment on lines 291 to +292

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.

ideally can just call that same thing from the hbar class

self,
max_transaction_fee: int | float | Decimal | Hbar,
) -> Client:
"""Sets the default maximum Hbar fee allowed for any transaction executed by this client."""
value = max_transaction_fee if isinstance(max_transaction_fee, Hbar) else Hbar(max_transaction_fee)

if value < Hbar(0):
raise ValueError("max_transaction_fee must be non-negative")

self.default_max_transaction_fee = value
return self
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def set_max_attempts(self, max_attempts: int) -> Client:
"""
Set the maximum number of execution attempts for all transactions and queries
Expand Down
48 changes: 42 additions & 6 deletions src/hiero_sdk_python/transaction/transaction.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
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
from hiero_sdk_python.client.client import Client
from hiero_sdk_python.crypto.key import Key
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_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.hapi.services import (
basic_types_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.query.fee_estimate_query import FeeEstimateQuery
from hiero_sdk_python.response_code import ResponseCode
Expand Down Expand Up @@ -64,8 +73,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)
# changed from Hbar: 0.02 to Hbar: 2
self._default_transaction_fee = Hbar(2)
self.operator_account_id = None
self.batch_key: Key | None = None

Expand Down Expand Up @@ -295,6 +304,17 @@ def freeze_with(self, client: Client):
# For each node, set the node_account_id and build the transaction body
# This allows the transaction to be submitted to any node in the network

# Use all nodes from client network
Comment thread
tech0priyanshu marked this conversation as resolved.
# Resolve fee priority before building bodies:
# 1. Explicit transaction fee (self.transaction_fee)
# 2. Client default_max_transaction_fee
# 3. Transaction class default (_default_transaction_fee)
if self.transaction_fee is None:
Comment thread
tech0priyanshu marked this conversation as resolved.
if client is not None and getattr(client, "default_max_transaction_fee", None) is not None:
Comment thread
tech0priyanshu marked this conversation as resolved.
self.transaction_fee = client.default_max_transaction_fee
else:
self.transaction_fee = self._default_transaction_fee

if self.batch_key:
# For Inner Transaction of batch transaction node_account_id=0.0.0
self.node_account_id = AccountId(0, 0, 0)
Expand All @@ -314,7 +334,6 @@ def freeze_with(self, client: Client):
self._transaction_body_bytes[node_account_id] = self.build_transaction_body().SerializeToString()

else:
# Use all nodes from client network
for node in client.network.nodes:
self.node_account_id = node._account_id
self._transaction_body_bytes[node._account_id] = self.build_transaction_body().SerializeToString()
Expand Down Expand Up @@ -775,6 +794,23 @@ def from_bytes(transaction_bytes: bytes):
transaction_body, signed_transaction.bodyBytes, signed_transaction.sigMap
)

def set_max_transaction_fee(self, max_transaction_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.

Some of the logic here is quite complex, and duplicated elsewhere, which has also caused some typing curiosities

why not extract the fee calculation and place it e.g. in the hbar file?

self.default_max_transaction_fee = _coerce_fee(max_transaction_fee)

Then create

def _coerce_fee(value: int | float | Decimal | Hbar) -> Hbar:

and call that from the multiple points

# Accept int, float, Decimal, or Hbar (but not bool)
self._require_not_frozen()

if isinstance(max_transaction_fee, bool) or not isinstance(max_transaction_fee, (int, float, Decimal, Hbar)):
raise TypeError(
f"max_transaction_fee must be int, float, Decimal, or Hbar, got {type(max_transaction_fee).__name__}"
)

value = max_transaction_fee if isinstance(max_transaction_fee, Hbar) else Hbar(max_transaction_fee)

if value < Hbar(0):
raise ValueError("max_transaction_fee must be non-negative")

self.transaction_fee = value
return self
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@staticmethod
def _get_transaction_class(transaction_type: str):
"""
Expand Down
10 changes: 6 additions & 4 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 @@ -283,10 +284,11 @@ 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)
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_max_transaction_fee converts inputs to Hbar and stores them."""
client = Client.for_testnet()

returned = client.set_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_max_transaction_fee rejects invalid types."""
client = Client.for_testnet()

with pytest.raises(TypeError):
client.set_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_max_transaction_fee rejects negative values."""
client = Client.for_testnet()

with pytest.raises(ValueError):
client.set_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
1 change: 1 addition & 0 deletions tests/unit/fee_estimate_query_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ def mock_client():
client.mirror_network = "https://testnet.mirrornode.hedera.com"
client.max_retries = 3

client.default_max_transaction_fee = Hbar(2)
client.generate_transaction_id.return_value = TransactionId.generate(AccountId(0, 0, 1001))
client.operator_account_id._to_proto.return_value = AccountId(0, 0, 1)._to_proto()

Expand Down
2 changes: 2 additions & 0 deletions tests/unit/file_append_transaction_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ def test_freeze_with_generates_transaction_ids():

# Mock client and transaction_id
mock_client = MagicMock()
mock_client.default_max_transaction_fee = Hbar(1)
mock_transaction_id = TransactionId(account_id=MagicMock(), valid_start=Timestamp(0, 1))
file_tx.transaction_id = mock_transaction_id

Expand Down Expand Up @@ -135,6 +136,7 @@ def test_multi_chunk_execution():

# Mock client and responses
mock_client = MagicMock()
mock_client.default_max_transaction_fee = Hbar(1)
mock_receipt = MagicMock(spec=TransactionReceipt)
mock_receipt.status = ResponseCode.SUCCESS

Expand Down
105 changes: 105 additions & 0 deletions tests/unit/transaction_freeze_and_bytes_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@

from __future__ import annotations

from decimal import Decimal

import pytest

from hiero_sdk_python.account.account_id import AccountId
from hiero_sdk_python.crypto.private_key import PrivateKey
from hiero_sdk_python.hapi.services.transaction_response_pb2 import (
TransactionResponse as TransactionResponseProto,
)
from hiero_sdk_python.hbar import Hbar
from hiero_sdk_python.transaction.transaction_id import TransactionId
from hiero_sdk_python.transaction.transfer_transaction import TransferTransaction

Expand Down Expand Up @@ -64,6 +67,45 @@ def test_freeze_with_valid_parameters():
assert node_id in transaction._transaction_body_bytes


@pytest.mark.parametrize(
"valid_amount,expected",
[
(1, 100_000_000),
(0.1, 10_000_000),
(Decimal("0.1"), 10_000_000),
(Hbar(1), 100_000_000),
(Hbar(0), 0),
],
)
def test_set_max_transaction_fee_valid_param(valid_amount, expected):
"""Transaction.set_max_transaction_fee should accept various numeric types and Hbar."""
tx = TransferTransaction()

returned = tx.set_max_transaction_fee(valid_amount)
assert tx.transaction_fee == expected
assert returned is tx


@pytest.mark.parametrize("invalid_amount", ["1", True, False, None, object()])
def test_set_max_transaction_fee_invalid_param(invalid_amount):
"""Transaction.set_max_transaction_fee should reject invalid types."""
tx = TransferTransaction()

with pytest.raises(TypeError):
tx.set_max_transaction_fee(invalid_amount)


@pytest.mark.parametrize("negative_amount", [-1, -0.1, Decimal("-0.1"), Hbar(-1)])
def test_set_max_transaction_fee_negative_value(negative_amount):
"""Transaction.set_max_transaction_fee should reject negative values."""
tx = TransferTransaction()

with pytest.raises(ValueError):
tx.set_max_transaction_fee(negative_amount)
# checking state un modified
assert len(tx._transaction_body_bytes) == 0


def test_freeze_is_idempotent():
"""Test that calling freeze() multiple times doesn't cause issues."""
operator_id = AccountId.from_string("0.0.1234")
Expand Down Expand Up @@ -685,3 +727,66 @@ def test_map_response_raises_if_proto_request_is_not_transaction():
node_id=mock_node_id,
proto_request=invalid_proto_request,
)


def test_fee_resolution_transaction_precedence(mock_client):
Comment thread
tech0priyanshu marked this conversation as resolved.
"""Transaction fee explicitly set should take precedence over client default."""
tx = TransferTransaction()
tx.set_max_transaction_fee(Hbar(10))

# client has different default
mock_client.set_max_transaction_fee(Hbar(5))

before = tx.transaction_fee
tx.freeze_with(mock_client)

assert tx.transaction_fee == 1_000_000_000
assert tx.transaction_fee == before


def test_fee_resolution_client_default_used_when_transaction_missing(mock_client):
"""When transaction fee is not set, client.default_max_transaction_fee should be used."""
tx = TransferTransaction()
# leave tx.transaction_fee as None

mock_client.set_max_transaction_fee(Hbar(7))

tx.freeze_with(mock_client)

assert tx.transaction_fee == 700_000_000


def test_fee_resolution_falls_back_to_transaction_default(mock_client):
"""When neither transaction nor client provide a fee, fallback to transaction default Hbar(1)."""
tx = TransferTransaction()
tx.set_transaction_id(TransactionId.generate(AccountId.from_string("0.0.1234")))
# Ensure client default is None
mock_client.default_max_transaction_fee = None

tx.freeze_with(mock_client)

Comment thread
tech0priyanshu marked this conversation as resolved.
assert tx.transaction_fee == 100_000_000 # Default fee for TransferTransaction


def test_resolved_fee_serialized_into_transaction_body(mock_client):
"""The resolved fee must reach the serialized proto transactionFee."""
tx = TransferTransaction()

tx.set_max_transaction_fee(Hbar(2))

body = tx.build_base_scheduled_body()

assert body.transactionFee == Hbar(2).to_tinybars()


def test_max_transaction_fee_survives_to_bytes_round_trip(mock_client):
"""An explicitly set max fee must survive a to_bytes -> from_bytes round trip."""
tx = TransferTransaction()

tx.set_max_transaction_fee(Hbar(2))

tx.freeze_with(mock_client) # Serlize to_bytes()
data = tx.to_bytes()

restored = TransferTransaction.from_bytes(data) # Deserialize from_bytes().
assert restored._transaction_fee == Hbar(2).to_tinybars()
Loading