diff --git a/src/hiero_sdk_python/__init__.py b/src/hiero_sdk_python/__init__.py index b890f3330..5036d9944 100644 --- a/src/hiero_sdk_python/__init__.py +++ b/src/hiero_sdk_python/__init__.py @@ -48,6 +48,13 @@ # Errors from .exceptions import PrecheckError, ReceiptStatusError +# Fee +from .fees.fee_estimate import FeeEstimate +from .fees.fee_estimate_mode import FeeEstimateMode +from .fees.fee_estimate_response import FeeEstimateResponse +from .fees.fee_extra import FeeExtra +from .fees.network_fee import NetworkFee + # File from .file.file_append_transaction import FileAppendTransaction from .file.file_contents_query import FileContentsQuery @@ -77,6 +84,7 @@ # Queries from .query.account_balance_query import CryptoGetAccountBalanceQuery from .query.account_info_query import AccountInfoQuery +from .query.fee_estimate_query import FeeEstimateQuery from .query.token_info_query import TokenInfoQuery from .query.token_nft_info_query import TokenNftInfoQuery from .query.topic_info_query import TopicInfoQuery @@ -227,6 +235,7 @@ "TopicDeleteTransaction", "TopicId", # Queries + "FeeEstimateQuery", "TopicInfoQuery", "TopicMessageQuery", "TransactionGetReceiptQuery", @@ -257,6 +266,12 @@ "FileContentsQuery", "FileUpdateTransaction", "FileDeleteTransaction", + # Fee + "FeeEstimateMode", + "FeeEstimate", + "FeeEstimateResponse", + "FeeExtra", + "NetworkFee", # Contract "ContractCreateTransaction", "ContractCallQuery", diff --git a/src/hiero_sdk_python/consensus/topic_message_submit_transaction.py b/src/hiero_sdk_python/consensus/topic_message_submit_transaction.py index 960a1e17a..d79c2415f 100644 --- a/src/hiero_sdk_python/consensus/topic_message_submit_transaction.py +++ b/src/hiero_sdk_python/consensus/topic_message_submit_transaction.py @@ -49,7 +49,7 @@ def __init__( self.chunk_size: int = chunk_size or 1024 self.max_chunks: int = max_chunks or 20 - self._current_index = 0 + self._current_chunk_index = 0 self._total_chunks = self.get_required_chunks() self._initial_transaction_id: TransactionId | None = None self._transaction_ids: list[TransactionId] = [] @@ -192,7 +192,7 @@ def _build_proto_body(self) -> consensus_submit_message_pb2.ConsensusSubmitMessa content = self.message.encode("utf-8") - start_index = self._current_index * self.chunk_size + start_index = self._current_chunk_index * self.chunk_size end_index = min(start_index + self.chunk_size, len(content)) chunk_content = content[start_index:end_index] @@ -206,7 +206,7 @@ def _build_proto_body(self) -> consensus_submit_message_pb2.ConsensusSubmitMessa consensus_submit_message_pb2.ConsensusMessageChunkInfo( initialTransactionID=self._initial_transaction_id._to_proto(), total=self._total_chunks, - number=self._current_index + 1, + number=self._current_chunk_index + 1, ) ) @@ -371,7 +371,7 @@ def execute_all( responses = [] for chunk_index in range(self.get_required_chunks()): - self._current_index = chunk_index + self._current_chunk_index = chunk_index if self._transaction_ids and chunk_index < len(self._transaction_ids): self.transaction_id = self._transaction_ids[chunk_index] diff --git a/src/hiero_sdk_python/fees/fee_estimate.py b/src/hiero_sdk_python/fees/fee_estimate.py new file mode 100644 index 000000000..85a6daa03 --- /dev/null +++ b/src/hiero_sdk_python/fees/fee_estimate.py @@ -0,0 +1,19 @@ +"""Fee estimation models for calculating base and extra fees. + +This module defines the FeeEstimate dataclass, which aggregates +a base fee with optional extra fee components. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from hiero_sdk_python.fees.fee_extra import FeeExtra + + +@dataclass(frozen=True) +class FeeEstimate: + """Represents a fee estimate composed of a base amount and optional extras.""" + + base: int + extras: list[FeeExtra] = field(default_factory=list) diff --git a/src/hiero_sdk_python/fees/fee_estimate_mode.py b/src/hiero_sdk_python/fees/fee_estimate_mode.py new file mode 100644 index 000000000..2ba85d097 --- /dev/null +++ b/src/hiero_sdk_python/fees/fee_estimate_mode.py @@ -0,0 +1,15 @@ +"""Enumeration of fee estimation modes. + +Defines the available strategies for calculating fee estimates. +""" + +from __future__ import annotations + +from enum import Enum + + +class FeeEstimateMode(str, Enum): + """Supported modes for fee estimation.""" + + STATE = "STATE" + INTRINSIC = "INTRINSIC" diff --git a/src/hiero_sdk_python/fees/fee_estimate_response.py b/src/hiero_sdk_python/fees/fee_estimate_response.py new file mode 100644 index 000000000..924d65c44 --- /dev/null +++ b/src/hiero_sdk_python/fees/fee_estimate_response.py @@ -0,0 +1,25 @@ +"""Response model for fee estimation results. + +Contains the calculated fees across different categories along with +the estimation mode and optional notes. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from hiero_sdk_python.fees.fee_estimate import FeeEstimate +from hiero_sdk_python.fees.fee_estimate_mode import FeeEstimateMode +from hiero_sdk_python.fees.network_fee import NetworkFee + + +@dataclass(frozen=True) +class FeeEstimateResponse: + """Represents the result of a fee estimation operation.""" + + mode: FeeEstimateMode + network_fee: NetworkFee | None = None + node_fee: FeeEstimate | None = None + service_fee: FeeEstimate | None = None + total: int = 0 + high_volume_multiplier: int = 0 diff --git a/src/hiero_sdk_python/fees/fee_extra.py b/src/hiero_sdk_python/fees/fee_extra.py new file mode 100644 index 000000000..8b26f63a0 --- /dev/null +++ b/src/hiero_sdk_python/fees/fee_extra.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class FeeExtra: + """ + Represents additional fee details associated with a transaction or operation. + """ + + name: str | None = None + included: int | None = None + count: int | None = None + charged: int | None = None + fee_per_unit: int | None = None + subtotal: int | None = None diff --git a/src/hiero_sdk_python/fees/network_fee.py b/src/hiero_sdk_python/fees/network_fee.py new file mode 100644 index 000000000..e206797c4 --- /dev/null +++ b/src/hiero_sdk_python/fees/network_fee.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class NetworkFee: + multiplier: int + subtotal: int diff --git a/src/hiero_sdk_python/query/fee_estimate_query.py b/src/hiero_sdk_python/query/fee_estimate_query.py new file mode 100644 index 000000000..b6e8c1551 --- /dev/null +++ b/src/hiero_sdk_python/query/fee_estimate_query.py @@ -0,0 +1,303 @@ +""" +Fee estimation query module. + +Provides `FeeEstimateQuery`, a mirror-node-backed query that estimates +transaction fees without submitting the transaction to the network. +""" + +from __future__ import annotations + +import logging +import time +from typing import TYPE_CHECKING +from urllib.parse import urlencode + +import requests + +from hiero_sdk_python.client.client import Client +from hiero_sdk_python.fees.fee_estimate import FeeEstimate +from hiero_sdk_python.fees.fee_estimate_mode import FeeEstimateMode +from hiero_sdk_python.fees.fee_estimate_response import FeeEstimateResponse +from hiero_sdk_python.fees.fee_extra import FeeExtra +from hiero_sdk_python.fees.network_fee import NetworkFee + + +if TYPE_CHECKING: + from hiero_sdk_python.transaction.transaction import Transaction + +logger = logging.getLogger(__name__) + + +class FeeEstimateQuery: + """ + Query for estimating transaction fees via the mirror node REST API. + + This query sends a serialized HAPI transaction to + `/api/v1/network/fees` and returns a structured + `FeeEstimateResponse` containing node, service, and network fees. + + Key behaviors: + - Defaults to INTRINSIC mode unless explicitly set + - Automatically freezes transactions before execution + - Retries transient failures (HTTP 500/503, timeouts) + - Aggregates fees for internally chunked transactions + """ + + def __init__(self) -> None: + self._mode: FeeEstimateMode | None = None + self._transaction: Transaction | None = None + self._high_volume_throttle: int = 0 + self._max_attempts: int = 3 + self._max_backoff: float = 2.0 + + # ------------------------------------------------------------------------- + # Configuration + # ------------------------------------------------------------------------- + + def set_mode(self, mode: FeeEstimateMode) -> FeeEstimateQuery: + """Set the estimation mode (STATE or INTRINSIC).""" + if not isinstance(mode, FeeEstimateMode): + raise TypeError("mode must be a FeeEstimateMode") + self._mode = mode + return self + + def get_mode(self) -> FeeEstimateMode | None: + """Return the configured estimation mode.""" + return self._mode + + def set_transaction(self, transaction: Transaction) -> FeeEstimateQuery: + """Attach the transaction to estimate.""" + from hiero_sdk_python.transaction.transaction import Transaction + + if not isinstance(transaction, Transaction): + raise TypeError(f"transaction must be an instance of Transaction, got {type(transaction).__name__}") + self._transaction = transaction + return self + + def get_transaction(self) -> Transaction | None: + """Return the attached transaction.""" + return self._transaction + + def set_high_volume_throttle(self, high_volume_throttle: int) -> FeeEstimateQuery: + """Set high-volume throttle utilization in basis points (0-10000, where 10000 = 100%).""" + if not isinstance(high_volume_throttle, int): + raise TypeError("high_volume_throttle must be an integer") + + if high_volume_throttle < 0 or high_volume_throttle > 10000: + raise ValueError("high_volume_throttle must be between 0 and 10000") + + self._high_volume_throttle = high_volume_throttle + return self + + def get_high_volume_throttle(self) -> int: + """Return high-volume throttle utilization in basis points.""" + return self._high_volume_throttle + + def set_max_attempts(self, attempts: int) -> FeeEstimateQuery: + """Set retry attempt limit.""" + if not isinstance(attempts, int): + raise TypeError("attempts must be an integer") + + if attempts < 1: + raise ValueError("attempts must be >= 1") + + self._max_attempts = attempts + return self + + def set_max_backoff(self, seconds: float) -> FeeEstimateQuery: + """Set maximum exponential backoff delay.""" + if not isinstance(seconds, (int | float)): + raise TypeError("seconds must be a number") + + if seconds <= 0: + raise ValueError("seconds must be > 0") + + self._max_backoff = float(seconds) + return self + + def execute(self, client) -> FeeEstimateResponse: + """ + Execute the fee estimation query. + + Returns: + FeeEstimateResponse + + Raises: + ValueError: if no transaction is set or request is invalid (HTTP 400) + """ + if self._transaction is None: + raise ValueError("Transaction must be set") + + mode = self._mode or FeeEstimateMode.INTRINSIC + url = self._build_url(client, mode) + + if "localhost:5551" in url or "127.0.0.1:5551" in url: + url = url.replace(":5551", ":8084") + + self._ensure_frozen(self._transaction, client) + + if self._is_chunked(): + return self._execute_chunked(client, url, mode) + + return self._execute_single(url, mode) + + def _build_url(self, client: Client, mode: FeeEstimateMode) -> str: + base = f"{client.network.get_mirror_rest_url()}/network/fees" + + query = { + "mode": mode.name, + "high_volume_throttle": self._high_volume_throttle, + } + + return f"{base}?{urlencode(query)}" + + def _ensure_frozen(self, tx: Transaction, client) -> None: + """Ensure the transaction is frozen before serialization.""" + if not tx._transaction_body_bytes: + tx.freeze_with(client) if hasattr(tx, "freeze_with") else tx.freeze() + + def _post(self, url: str, payload: bytes) -> dict: + """POST with retry for transient failures.""" + for attempt in range(self._max_attempts): + try: + resp = requests.post( + url, + data=payload, + headers={"Content-Type": "application/protobuf"}, + timeout=10, + ) + + if resp.status_code == 200: + return resp.json() + + retryable_statuses = {408, 429, 500, 502, 503, 504} + if resp.status_code not in retryable_statuses or attempt == self._max_attempts - 1: + raise RuntimeError( + f"Failed to fetch fee estimate. HTTP status: {resp.status_code} body: {resp.text}" + ) + + error_msg = f"HTTP status: {resp.status_code}" + + except (requests.Timeout, requests.ConnectionError) as exc: # noqa: PERF203 + if attempt == self._max_attempts - 1: + raise + + error_msg = str(exc) + + delay = min(0.5 * (2**attempt), self._max_backoff) + logger.debug("Retrying after error: %s (%.2fs)", error_msg, delay) + time.sleep(delay) + + raise RuntimeError("Unreachable") + + def _execute_single(self, url: str, mode: FeeEstimateMode) -> FeeEstimateResponse: + data = self._post(url, self._transaction.to_bytes()) + return self._to_response(data, mode) + + def _execute_chunked(self, client, url: str, mode: FeeEstimateMode) -> FeeEstimateResponse: + """ + Aggregate fees across all chunks into a single response. + """ + + # Save original state to restore later + original_id = self._transaction.transaction_id + original_index = getattr(self._transaction, "_current_chunk_index", 0) + original_bodies = dict(self._transaction._transaction_body_bytes) + original_signatures = dict(self._transaction._signature_map) + + total_node_base = 0 + total_service_base = 0 + total_network_subtotal = 0 + total_combined = 0 + node_extras: list[FeeExtra] = [] + service_extras: list[FeeExtra] = [] + + final_multiplier = 0 + final_hvm = 0 + + try: + for i, chunk_tx_id in enumerate(self._transaction._transaction_ids): + self._transaction._current_chunk_index = i + self._transaction.transaction_id = chunk_tx_id + + self._transaction._transaction_body_bytes.clear() + + self._transaction.freeze_with(client) + + tx_bytes = self._transaction.to_bytes() + data = self._post(url, tx_bytes) + response = self._to_response(data, mode) + + if response.node_fee: + total_node_base += response.node_fee.base + node_extras.extend(response.node_fee.extras) + if response.service_fee: + total_service_base += response.service_fee.base + service_extras.extend(response.service_fee.extras) + if response.network_fee: + total_network_subtotal += response.network_fee.subtotal + final_multiplier = response.network_fee.multiplier + total_combined += response.total + + final_hvm = response.high_volume_multiplier + + finally: + self._transaction.transaction_id = original_id + self._transaction._current_chunk_index = original_index + self._transaction._transaction_body_bytes.clear() + self._transaction._transaction_body_bytes.update(original_bodies) + self._transaction._signature_map.clear() + self._transaction._signature_map.update(original_signatures) + + return FeeEstimateResponse( + mode=mode, + node_fee=FeeEstimate(base=total_node_base, extras=node_extras), + service_fee=FeeEstimate(base=total_service_base, extras=service_extras), + network_fee=NetworkFee(multiplier=final_multiplier, subtotal=total_network_subtotal), + total=total_combined, + high_volume_multiplier=final_hvm, + ) + + def _to_response(self, data: dict, mode: FeeEstimateMode) -> FeeEstimateResponse: + + node_data = data.get("node", {}) + service_data = data.get("service", {}) + network_data = data.get("network", {}) + + node_fee = FeeEstimate(base=node_data.get("base", 0), extras=self._parse_extras(node_data.get("extras"))) + + service_fee = FeeEstimate( + base=service_data.get("base", 0), extras=self._parse_extras(service_data.get("extras")) + ) + + network_fee = NetworkFee(multiplier=network_data.get("multiplier", 1), subtotal=network_data.get("subtotal", 0)) + + total = data.get("total", 0) + high_volume_multiplier = data.get("high_volume_multiplier", 0) + + return FeeEstimateResponse( + mode=mode, + node_fee=node_fee, + service_fee=service_fee, + network_fee=network_fee, + total=total, + high_volume_multiplier=high_volume_multiplier, + ) + + def _parse_extras(self, extra_list: list[dict]) -> list[FeeExtra]: + if not extra_list: + return [] + return [ + FeeExtra( + name=item.get("name", None), + included=item.get("included", 0), + count=item.get("count", 0), + charged=item.get("charged", 0), + fee_per_unit=item.get("fee_per_unit", 0), + subtotal=item.get("subtotal", 0), + ) + for item in extra_list + ] + + def _is_chunked(self) -> bool: + return self._transaction.get_required_chunks() > 1 diff --git a/src/hiero_sdk_python/transaction/transaction.py b/src/hiero_sdk_python/transaction/transaction.py index 763aac655..14691e09a 100644 --- a/src/hiero_sdk_python/transaction/transaction.py +++ b/src/hiero_sdk_python/transaction/transaction.py @@ -12,6 +12,7 @@ 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 from hiero_sdk_python.transaction.transaction_id import TransactionId from hiero_sdk_python.transaction.transaction_receipt import TransactionReceipt @@ -899,3 +900,24 @@ def batchify(self, client: Client, batch_key: Key): self.freeze_with(client) self.sign(client.operator_private_key) return self + + def estimate_fee(self) -> FeeEstimateQuery: + """ + Creates a FeeEstimateQuery for this transaction. + + Returns: + FeeEstimateQuery: A query configured to estimate fees for this transaction. + """ + + query = FeeEstimateQuery() + query.set_transaction(self) + return query + + def get_required_chunks(self): + """ + Returns the number of chunks required for the current message. + + Returns: + int: Number of chunks required. + """ + return 1 diff --git a/tests/integration/fee_estimate_query_e2e_test.py b/tests/integration/fee_estimate_query_e2e_test.py new file mode 100644 index 000000000..f90e0a1cd --- /dev/null +++ b/tests/integration/fee_estimate_query_e2e_test.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import pytest + +from hiero_sdk_python.account.account_create_transaction import AccountCreateTransaction +from hiero_sdk_python.consensus.topic_id import TopicId +from hiero_sdk_python.consensus.topic_message_submit_transaction import TopicMessageSubmitTransaction +from hiero_sdk_python.crypto.private_key import PrivateKey +from hiero_sdk_python.fees.fee_estimate_mode import FeeEstimateMode +from hiero_sdk_python.file.file_append_transaction import FileAppendTransaction +from hiero_sdk_python.file.file_id import FileId +from hiero_sdk_python.query.fee_estimate_query import FeeEstimateQuery + + +@pytest.mark.integration +def test_can_execute_fee_estimation_query(env): + """ + Integration test that verifies a fee estimation query executes successfully and returns a non-null result. + """ + tx = AccountCreateTransaction().set_key_without_alias(PrivateKey.generate_ed25519()).set_initial_balance(1) + query = FeeEstimateQuery().set_transaction(tx) + result = query.execute(env.client) + + assert result is not None + + +@pytest.mark.integration +def test_can_execute_fee_estimation_query2(env): + """Integration test that verifies a state-mode fee estimation query + executes successfully and returns a non-null result. + """ + tx = AccountCreateTransaction().set_key_without_alias(PrivateKey.generate_ed25519()).set_initial_balance(1) + query = FeeEstimateQuery().set_mode(FeeEstimateMode.STATE).set_transaction(tx) + result = query.execute(env.client) + + assert result is not None + + +@pytest.mark.integration +def test__fee_estimation_query_chunk_tx_can_execute(env): + """Integration test that verifies a state-mode fee estimation query executes + successfully for a chunked file append transaction and returns a non-null result. + """ + tx = FileAppendTransaction().set_file_id(FileId(0, 0, 2)).set_chunk_size(10).set_contents("s" * 33) # 4 chunks + + tx.freeze_with(env.client) + query = FeeEstimateQuery().set_mode(FeeEstimateMode.STATE).set_transaction(tx) + result = query.execute(env.client) + + assert result is not None + + +@pytest.mark.integration +def test_can_execute_fee_estimation_query_chunk_tx(env): + """Integration test that verifies a state-mode fee estimation query executes successfully + for a chunked topic message submit transaction and returns a non-null result. + """ + tx = ( + TopicMessageSubmitTransaction().set_topic_id(TopicId(0, 0, 2)).set_chunk_size(10).set_message("s" * 20) + ) # 2 chunks + + # 2. IMPORTANT: Let freeze_with generate the valid transaction ID sequence + # This ensures tx._transaction_ids is populated correctly. + + tx.freeze_with(env.client) + query = FeeEstimateQuery().set_mode(FeeEstimateMode.STATE).set_transaction(tx) + result = query.execute(env.client) + + assert result is not None diff --git a/tests/unit/fee_estimate_query_test.py b/tests/unit/fee_estimate_query_test.py new file mode 100644 index 000000000..a24f707ed --- /dev/null +++ b/tests/unit/fee_estimate_query_test.py @@ -0,0 +1,333 @@ +"""Tests for FeeEstimateQuery.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from hiero_sdk_python.account.account_id import AccountId +from hiero_sdk_python.consensus.topic_create_transaction import TopicCreateTransaction +from hiero_sdk_python.consensus.topic_id import TopicId +from hiero_sdk_python.consensus.topic_message_submit_transaction import TopicMessageSubmitTransaction +from hiero_sdk_python.contract.contract_create_transaction import ContractCreateTransaction +from hiero_sdk_python.fees.fee_estimate_mode import FeeEstimateMode +from hiero_sdk_python.file.file_create_transaction import FileCreateTransaction +from hiero_sdk_python.file.file_id import FileId +from hiero_sdk_python.hbar import Hbar +from hiero_sdk_python.query.fee_estimate_query import FeeEstimateQuery +from hiero_sdk_python.tokens.token_create_transaction import TokenCreateTransaction +from hiero_sdk_python.tokens.token_id import TokenId +from hiero_sdk_python.tokens.token_mint_transaction import TokenMintTransaction +from hiero_sdk_python.transaction.transaction_id import TransactionId +from hiero_sdk_python.transaction.transfer_transaction import TransferTransaction + + +pytestmark = pytest.mark.unit + + +def mock_client(): + client = MagicMock() + client.mirror_network = "https://testnet.mirrornode.hedera.com" + client.max_retries = 3 + + 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() + + node = MagicMock() + node._account_id = AccountId(0, 0, 3) + + client.network = MagicMock() + client.network.nodes = [node] + + return client + + +def mock_fee_response(): + return { + "high_volume_multiplier": 1, + "network": {"multiplier": 9, "subtotal": 1}, + "node": {"base": 10, "extras": []}, + "service": {"base": 20, "extras": []}, + "total": 210, + } + + +def mock_requests_response(): + response = MagicMock() + response.status_code = 200 + response.json.return_value = mock_fee_response() + return response + + +@patch("hiero_sdk_python.query.fee_estimate_query.requests.post") +def test_transfer_transaction_state_mode(mock_post): + mock_post.return_value = mock_requests_response() + + tx = TransferTransaction() + tx.add_hbar_transfer(AccountId.from_string("0.0.1001"), Hbar(-1)) + tx.add_hbar_transfer(AccountId.from_string("0.0.1002"), Hbar(1)) + + result = FeeEstimateQuery().set_mode(FeeEstimateMode.STATE).set_transaction(tx).execute(mock_client()) + + assert result.mode == FeeEstimateMode.STATE + assert result.total >= 0 + + +@patch("hiero_sdk_python.query.fee_estimate_query.requests.post") +def test_transfer_transaction_intrinsic_mode(mock_post): + mock_post.return_value = mock_requests_response() + + tx = TransferTransaction() + tx.add_hbar_transfer(AccountId.from_string("0.0.1001"), Hbar(-1)) + tx.add_hbar_transfer(AccountId.from_string("0.0.1002"), Hbar(1)) + + result = FeeEstimateQuery().set_mode(FeeEstimateMode.INTRINSIC).set_transaction(tx).execute(mock_client()) + + assert result.mode == FeeEstimateMode.INTRINSIC + + +@patch("hiero_sdk_python.query.fee_estimate_query.requests.post") +def test_default_mode_is_intrinsic(mock_post): + mock_post.return_value = mock_requests_response() + + tx = TransferTransaction() + + result = FeeEstimateQuery().set_transaction(tx).execute(mock_client()) + + assert result.mode == FeeEstimateMode.INTRINSIC + + +def test_transaction_required(): + query = FeeEstimateQuery() + + with pytest.raises(ValueError): + query.execute(mock_client()) + + +@patch("hiero_sdk_python.query.fee_estimate_query.requests.post") +def test_token_create_transaction(mock_post): + mock_post.return_value = mock_requests_response() + + tx = TokenCreateTransaction().set_token_name("Test Token").set_token_symbol("TK").set_initial_supply(1) + + result = FeeEstimateQuery().set_transaction(tx).execute(mock_client()) + + assert result is not None + + +@patch("hiero_sdk_python.query.fee_estimate_query.requests.post") +def test_token_mint_transaction(mock_post): + mock_post.return_value = mock_requests_response() + + tx = TokenMintTransaction().set_token_id(TokenId(0, 0, 2)).set_amount(20) + + result = FeeEstimateQuery().set_transaction(tx).execute(mock_client()) + + assert result is not None + + +@patch("hiero_sdk_python.query.fee_estimate_query.requests.post") +def test_topic_create_transaction(mock_post): + mock_post.return_value = mock_requests_response() + + tx = TopicCreateTransaction() + + result = FeeEstimateQuery().set_transaction(tx).execute(mock_client()) + + assert result.total >= 0 + + +@patch("hiero_sdk_python.query.fee_estimate_query.requests.post") +def test_contract_create_transaction(mock_post): + mock_post.return_value = mock_requests_response() + + tx = ContractCreateTransaction().set_bytecode_file_id(FileId(0, 0, 1)).set_gas(10) + + result = FeeEstimateQuery().set_transaction(tx).execute(mock_client()) + + assert result.total >= 0 + + +@patch("hiero_sdk_python.query.fee_estimate_query.requests.post") +def test_file_create_transaction(mock_post): + mock_post.return_value = mock_requests_response() + + tx = FileCreateTransaction() + + result = FeeEstimateQuery().set_transaction(tx).execute(mock_client()) + + assert result.total >= 0 + + +# --------------------------------------------------------------------- +# Error handling +# --------------------------------------------------------------------- + + +@patch("hiero_sdk_python.query.fee_estimate_query.requests.post") +def test_invalid_argument_error(mock_post): + response = MagicMock() + response.status_code = 400 + + mock_post.return_value = response + + tx = TransferTransaction() + + with pytest.raises(RuntimeError): + FeeEstimateQuery().set_transaction(tx).execute(mock_client()) + + assert mock_post.call_count == 1, "HTTP 400 (INVALID_ARGUMENT) must not be retried" + + +@patch("hiero_sdk_python.query.fee_estimate_query.requests.post") +def test_retry_on_timeout(mock_post): + mock_post.side_effect = [ + requests.Timeout(), + mock_requests_response(), + ] + + tx = TransferTransaction() + + result = FeeEstimateQuery().set_transaction(tx).execute(mock_client()) + + assert result.total >= 0 + assert mock_post.call_count == 2 + + +@patch("hiero_sdk_python.query.fee_estimate_query.requests.post") +def test_retry_on_503(mock_post): + error_response = MagicMock() + error_response.status_code = 503 + + mock_post.side_effect = [ + error_response, + mock_requests_response(), + ] + + tx = TransferTransaction() + + result = FeeEstimateQuery().set_transaction(tx).execute(mock_client()) + + assert result.total >= 0 + assert mock_post.call_count == 2 + + +# --------------------------------------------------------------------- +# Chunked transactions +# --------------------------------------------------------------------- + + +@patch("hiero_sdk_python.query.fee_estimate_query.requests.post") +def test_topic_message_single_chunk(mock_post): + mock_post.return_value = mock_requests_response() + + tx = ( + TopicMessageSubmitTransaction() + .set_topic_id(TopicId(0, 0, 4)) + .set_transaction_id(TransactionId.generate(AccountId(0, 0, 3))) + .set_node_account_id(AccountId(0, 0, 4)) + ) + tx.set_message("hello") + + result = FeeEstimateQuery().set_transaction(tx).execute(mock_client()) + + assert result.total >= 0 + assert mock_post.call_count == 1 + + +@patch("hiero_sdk_python.query.fee_estimate_query.requests.post") +def test_topic_message_multiple_chunks(mock_post): + mock_post.side_effect = [ + mock_requests_response(), + mock_requests_response(), + ] + + tx = ( + TopicMessageSubmitTransaction() + .set_topic_id(TopicId(0, 0, 4)) + .set_transaction_id(TransactionId.generate(AccountId(0, 0, 3))) + .set_node_account_id(AccountId(0, 0, 4)) + ) + tx.set_message("A" * 2048) # force chunking + + result = FeeEstimateQuery().set_transaction(tx).execute(mock_client()) + + # aggregated expectations + # 'network': {'multiplier': 9, 'subtotal': 1 * 2}, + # 'node': {'base': 10 * 2, 'extras': []}, + # 'service': {'base': 20 * 2, 'extras': []}, + # 'total': 210 * 2 + + assert mock_post.call_count == 2 + assert result.node_fee.base == 10 * 2 + assert result.service_fee.base == 20 * 2 + assert result.network_fee.subtotal == 1 * 2 + assert result.total == 210 * 2 + + +# --------------------------------------------------------------------- +# Configuration / validation coverage +# --------------------------------------------------------------------- + + +def test_high_volume_throttle_validation(): + q = FeeEstimateQuery() + + with pytest.raises(TypeError): + q.set_high_volume_throttle("100") + + with pytest.raises(ValueError): + q.set_high_volume_throttle(-1) + + with pytest.raises(ValueError): + q.set_high_volume_throttle(10001) + + # valid case + q.set_high_volume_throttle(5000) + assert q.get_high_volume_throttle() == 5000 + + +def test_max_attempts_validation(): + q = FeeEstimateQuery() + + with pytest.raises(TypeError): + q.set_max_attempts("3") + + with pytest.raises(ValueError): + q.set_max_attempts(0) + + # valid case + q.set_max_attempts(2) + assert q._max_attempts == 2 + + +def test_max_backoff_validation(): + q = FeeEstimateQuery() + + with pytest.raises(TypeError): + q.set_max_backoff("fast") + + with pytest.raises(ValueError): + q.set_max_backoff(0) + + # valid case + q.set_max_backoff(1.5) + assert q._max_backoff == 1.5 + + +def test_getters_and_defaults(): + q = FeeEstimateQuery() + + assert q.get_mode() is None + assert q.get_transaction() is None + assert q.get_high_volume_throttle() == 0 + + +def test_setters_are_chainable(): + q = FeeEstimateQuery() + + result = q.set_max_attempts(2).set_max_backoff(1.0).set_high_volume_throttle(100) + + assert result is q