diff --git a/.github/workflows/pr-check-secondary-unit-integration-test.yml b/.github/workflows/pr-check-secondary-unit-integration-test.yml index 8e87f223b..b47571ddd 100644 --- a/.github/workflows/pr-check-secondary-unit-integration-test.yml +++ b/.github/workflows/pr-check-secondary-unit-integration-test.yml @@ -132,6 +132,7 @@ jobs: uses: hiero-ledger/hiero-solo-action@692b186bd2e4c8d46b9deb1c067dc6ddcf0abcd7 # v0.15.0 with: installMirrorNode: true + mirrorNodeVersion: v0.153.0 - name: Run integration tests id: integration 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..dd31ede43 --- /dev/null +++ b/src/hiero_sdk_python/fees/fee_estimate.py @@ -0,0 +1,22 @@ +"""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) + + def __post_init__(self) -> None: + object.__setattr__(self, "extras", tuple(self.extras)) 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..7e12fabe4 --- /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..515b2be71 --- /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..f24b6dff2 --- /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..ca8d76ded --- /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..d9314d036 --- /dev/null +++ b/src/hiero_sdk_python/query/fee_estimate_query.py @@ -0,0 +1,308 @@ +""" +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 + +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).""" + 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.""" + 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 url.__contains__("localhost:5551") or url.__contains__("127.0.0.1:5551"): + url = url.replace(":5551", ":8084") + + try: + self._transaction.freeze_with(client) + except (RuntimeError, ValueError) as e: + # Ignore if it's already frozen + if "already frozen" not in str(e).lower(): + raise + + self._ensure_frozen(self._transaction, client) + + if self._is_chunked(): + return self._execute_chunked(client, url, mode) + + return self._execute_single(client, url, mode) + + def _build_url(self, client: Client, mode: FeeEstimateMode) -> str: + return f"{client.network.get_mirror_rest_url()}/network/fees?mode={mode.name}" + + def _ensure_frozen(self, tx: Transaction, client) -> None: + """Ensure the transaction is frozen before serialization.""" + try: + tx._require_frozen() # pylint: disable=protected-access + except (RuntimeError, ValueError): # fallback + if hasattr(tx, "freeze_with"): + tx.freeze_with(client) + else: + tx.freeze() + + def _serialize(self, tx: Transaction, client) -> bytes: + """Serialize transaction to protobuf bytes.""" + if not getattr(tx, "_transaction_body_bytes", None): + tx.freeze_with(client) + + return tx.to_bytes() + + 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, client, url: str, mode: FeeEstimateMode) -> FeeEstimateResponse: + data = self._post(url, self._serialize(self._transaction, client)) + 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) + + 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 = 1 + final_hvm = 1 + + 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) + + total_node_base += response.node_fee.base + total_service_base += response.service_fee.base + total_network_subtotal += response.network_fee.subtotal + total_combined += response.total + + node_extras.extend(response.node_fee.extras) + service_extras.extend(response.service_fee.extras) + + final_multiplier = response.network_fee.multiplier + 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() + + 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"), + count=item.get("count"), + charged=item.get("charged"), + fee_per_unit=item.get("fee_per_unit"), + subtotal=item.get("subtotal"), + ) + for item in extra_list + ] + + def _is_chunked(self) -> bool: + from hiero_sdk_python.consensus.topic_message_submit_transaction import ( + TopicMessageSubmitTransaction, + ) + from hiero_sdk_python.file.file_append_transaction import ( + FileAppendTransaction, + ) + + return isinstance(self._transaction, (TopicMessageSubmitTransaction | FileAppendTransaction)) diff --git a/src/hiero_sdk_python/transaction/transaction.py b/src/hiero_sdk_python/transaction/transaction.py index 7b1369de9..d20622209 100644 --- a/src/hiero_sdk_python/transaction/transaction.py +++ b/src/hiero_sdk_python/transaction/transaction.py @@ -11,6 +11,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 @@ -894,3 +895,18 @@ def batchify(self, client: Client, batch_key: Key): self.freeze_with(client) self.sign(client.operator_private_key) return self + + def get_required_chunks(self) -> int: + return 1 + + 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 diff --git a/src/hiero_sdk_python/transaction/transfer_transaction.py b/src/hiero_sdk_python/transaction/transfer_transaction.py index 300200d4f..afe9028fd 100644 --- a/src/hiero_sdk_python/transaction/transfer_transaction.py +++ b/src/hiero_sdk_python/transaction/transfer_transaction.py @@ -1,217 +1,217 @@ -"""Defines TransferTransaction for transferring HBAR or tokens between accounts.""" - -from __future__ import annotations - -from hiero_sdk_python.account.account_id import AccountId -from hiero_sdk_python.channels import _Channel -from hiero_sdk_python.executable import _Method -from hiero_sdk_python.hapi.services import basic_types_pb2, crypto_transfer_pb2, transaction_pb2 -from hiero_sdk_python.hapi.services.schedulable_transaction_body_pb2 import ( - SchedulableTransactionBody, -) -from hiero_sdk_python.hbar import Hbar -from hiero_sdk_python.tokens.abstract_token_transfer_transaction import AbstractTokenTransferTransaction -from hiero_sdk_python.tokens.hbar_transfer import HbarTransfer -from hiero_sdk_python.tokens.token_id import TokenId -from hiero_sdk_python.tokens.token_nft_transfer import TokenNftTransfer -from hiero_sdk_python.tokens.token_transfer import TokenTransfer - - -class TransferTransaction(AbstractTokenTransferTransaction["TransferTransaction"]): - """Represents a transaction to transfer HBAR or tokens between accounts.""" - - def __init__( - self, - hbar_transfers: dict[AccountId, int] | None = None, - token_transfers: dict[TokenId, dict[AccountId, int]] | None = None, - nft_transfers: dict[TokenId, list[tuple[AccountId, AccountId, int, bool]]] | None = None, - ) -> None: - """ - Initializes a new TransferTransaction instance. - - Args: - hbar_transfers (dict[AccountId, int], optional): Initial HBAR transfers. - token_transfers (dict[TokenId, dict[AccountId, int]], optional): - Initial token transfers. - nft_transfers (dict[TokenId, list[tuple[AccountId, AccountId, int, bool]]], optional): - Initial NFT transfers. - """ - super().__init__() - self.hbar_transfers: list[HbarTransfer] = [] - - if hbar_transfers: - self._init_hbar_transfers(hbar_transfers) - if token_transfers: - self._init_token_transfers(token_transfers) - if nft_transfers: - self._init_nft_transfers(nft_transfers) - - def _init_hbar_transfers(self, hbar_transfers: dict[AccountId, int]) -> None: - """Initializes HBAR transfers from a dictionary.""" - for account_id, amount in hbar_transfers.items(): - self.add_hbar_transfer(account_id, amount) - - def _add_hbar_transfer( - self, account_id: AccountId, amount: int | Hbar, is_approved: bool = False - ) -> TransferTransaction: - """ - Internal method to add a HBAR transfer to the transaction. - - Args: - account_id (AccountId): The account ID of the sender or receiver. - amount (int | Hbar): The amount of the HBAR to transfer. - is_approved (bool, optional): Whether the transfer is approved. Defaults to False. - - Returns: - TransferTransaction: The current instance of the transaction for chaining. - """ - self._require_not_frozen() - if not isinstance(account_id, AccountId): - raise TypeError("account_id must be an AccountId instance.") - if isinstance(amount, Hbar): - amount = amount.to_tinybars() - elif not isinstance(amount, int): - raise TypeError("amount must be an int or Hbar instance.") - if amount == 0: - raise ValueError("Amount must be a non-zero value.") - if not isinstance(is_approved, bool): - raise TypeError("is_approved must be a boolean.") - - for transfer in self.hbar_transfers: - if transfer.account_id == account_id: - transfer.amount += amount - return self - - self.hbar_transfers.append(HbarTransfer(account_id, amount, is_approved)) - return self - - def add_hbar_transfer(self, account_id: AccountId, amount: int | Hbar) -> TransferTransaction: - """ - Adds a HBAR transfer to the transaction. - - Args: - account_id (AccountId): The account ID of the sender or receiver. - amount (int | Hbar): The amount of the HBAR to transfer. - - Returns: - TransferTransaction: The current instance of the transaction for chaining. - """ - self._add_hbar_transfer(account_id, amount, False) - return self - - def add_approved_hbar_transfer(self, account_id: AccountId, amount: int | Hbar) -> TransferTransaction: - """ - Adds a HBAR transfer with approval to the transaction. - - Args: - account_id (AccountId): The account ID of the sender or receiver. - amount (int | Hbar): The amount of the HBAR to transfer. - - Returns: - TransferTransaction: The current instance of the transaction for chaining. - """ - self._add_hbar_transfer(account_id, amount, True) - return self - - def _build_proto_body(self) -> crypto_transfer_pb2.CryptoTransferTransactionBody: - """Returns the protobuf body for the transfer transaction.""" - crypto_transfer_tx_body = crypto_transfer_pb2.CryptoTransferTransactionBody() - - # HBAR - if self.hbar_transfers: - transfer_list = basic_types_pb2.TransferList() - for hbar_transfer in self.hbar_transfers: - transfer_list.accountAmounts.append(hbar_transfer._to_proto()) - - crypto_transfer_tx_body.transfers.CopyFrom(transfer_list) - - # NFTs/Tokens - token_transfers = self.build_token_transfers() - - for transfer in token_transfers: - crypto_transfer_tx_body.tokenTransfers.append(transfer) - - return crypto_transfer_tx_body - - def build_transaction_body(self) -> transaction_pb2.TransactionBody: - """ - Builds and returns the protobuf transaction body for a transfer transaction. - - Returns: - TransactionBody: The built transaction body. - """ - crypto_transfer_tx_body = self._build_proto_body() - - transaction_body = self.build_base_transaction_body() - transaction_body.cryptoTransfer.CopyFrom(crypto_transfer_tx_body) - - return transaction_body - - def build_scheduled_body(self) -> SchedulableTransactionBody: - """ - Builds the transaction body for this transfer transaction. - - Returns: - SchedulableTransactionBody: The built scheduled transaction body. - """ - crypto_transfer_tx_body = self._build_proto_body() - - schedulable_body = self.build_base_scheduled_body() - schedulable_body.cryptoTransfer.CopyFrom(crypto_transfer_tx_body) - return schedulable_body - - def _get_method(self, channel: _Channel) -> _Method: - return _Method(transaction_func=channel.crypto.cryptoTransfer, query_func=None) - - @classmethod - def _from_protobuf(cls, transaction_body, body_bytes: bytes, sig_map): - """ - Creates a TransferTransaction instance from protobuf components. - - Args: - transaction_body: The parsed TransactionBody protobuf - body_bytes (bytes): The raw bytes of the transaction body - sig_map: The SignatureMap protobuf containing signatures - - Returns: - TransferTransaction: A new transaction instance with all fields restored - """ - transaction = super()._from_protobuf(transaction_body, body_bytes, sig_map) - - if transaction_body.HasField("cryptoTransfer"): - crypto_transfer = transaction_body.cryptoTransfer - - if crypto_transfer.HasField("transfers"): - for account_amount in crypto_transfer.transfers.accountAmounts: - account_id = AccountId._from_proto(account_amount.accountID) - amount = account_amount.amount - is_approved = account_amount.is_approval - transaction.hbar_transfers.append(HbarTransfer(account_id, amount, is_approved)) - - for token_transfer_list in crypto_transfer.tokenTransfers: - token_id = TokenId._from_proto(token_transfer_list.token) - - for transfer in token_transfer_list.transfers: - account_id = AccountId._from_proto(transfer.accountID) - amount = transfer.amount - is_approved = transfer.is_approval - - expected_decimals = None - if token_transfer_list.HasField("expected_decimals"): - expected_decimals = token_transfer_list.expected_decimals.value - - transaction.token_transfers[token_id].append( - TokenTransfer(token_id, account_id, amount, expected_decimals, is_approved) - ) - - for nft_transfer in token_transfer_list.nftTransfers: - sender_id = AccountId._from_proto(nft_transfer.senderAccountID) - receiver_id = AccountId._from_proto(nft_transfer.receiverAccountID) - serial_number = nft_transfer.serialNumber - is_approved = nft_transfer.is_approval - - transaction.nft_transfers[token_id].append( - TokenNftTransfer(token_id, sender_id, receiver_id, serial_number, is_approved) - ) - - return transaction +"""Defines TransferTransaction for transferring HBAR or tokens between accounts.""" + +from __future__ import annotations + +from hiero_sdk_python.account.account_id import AccountId +from hiero_sdk_python.channels import _Channel +from hiero_sdk_python.executable import _Method +from hiero_sdk_python.hapi.services import basic_types_pb2, crypto_transfer_pb2, transaction_pb2 +from hiero_sdk_python.hapi.services.schedulable_transaction_body_pb2 import ( + SchedulableTransactionBody, +) +from hiero_sdk_python.hbar import Hbar +from hiero_sdk_python.tokens.abstract_token_transfer_transaction import AbstractTokenTransferTransaction +from hiero_sdk_python.tokens.hbar_transfer import HbarTransfer +from hiero_sdk_python.tokens.token_id import TokenId +from hiero_sdk_python.tokens.token_nft_transfer import TokenNftTransfer +from hiero_sdk_python.tokens.token_transfer import TokenTransfer + + +class TransferTransaction(AbstractTokenTransferTransaction["TransferTransaction"]): + """Represents a transaction to transfer HBAR or tokens between accounts.""" + + def __init__( + self, + hbar_transfers: dict[AccountId, int] | None = None, + token_transfers: dict[TokenId, dict[AccountId, int]] | None = None, + nft_transfers: dict[TokenId, list[tuple[AccountId, AccountId, int, bool]]] | None = None, + ) -> None: + """ + Initializes a new TransferTransaction instance. + + Args: + hbar_transfers (dict[AccountId, int], optional): Initial HBAR transfers. + token_transfers (dict[TokenId, dict[AccountId, int]], optional): + Initial token transfers. + nft_transfers (dict[TokenId, list[tuple[AccountId, AccountId, int, bool]]], optional): + Initial NFT transfers. + """ + super().__init__() + self.hbar_transfers: list[HbarTransfer] = [] + + if hbar_transfers: + self._init_hbar_transfers(hbar_transfers) + if token_transfers: + self._init_token_transfers(token_transfers) + if nft_transfers: + self._init_nft_transfers(nft_transfers) + + def _init_hbar_transfers(self, hbar_transfers: dict[AccountId, int]) -> None: + """Initializes HBAR transfers from a dictionary.""" + for account_id, amount in hbar_transfers.items(): + self.add_hbar_transfer(account_id, amount) + + def _add_hbar_transfer( + self, account_id: AccountId, amount: int | Hbar, is_approved: bool = False + ) -> TransferTransaction: + """ + Internal method to add a HBAR transfer to the transaction. + + Args: + account_id (AccountId): The account ID of the sender or receiver. + amount (int | Hbar): The amount of the HBAR to transfer. + is_approved (bool, optional): Whether the transfer is approved. Defaults to False. + + Returns: + TransferTransaction: The current instance of the transaction for chaining. + """ + self._require_not_frozen() + if not isinstance(account_id, AccountId): + raise TypeError("account_id must be an AccountId instance.") + if isinstance(amount, Hbar): + amount = amount.to_tinybars() + elif not isinstance(amount, int): + raise TypeError("amount must be an int or Hbar instance.") + if amount == 0: + raise ValueError("Amount must be a non-zero value.") + if not isinstance(is_approved, bool): + raise TypeError("is_approved must be a boolean.") + + for transfer in self.hbar_transfers: + if transfer.account_id == account_id: + transfer.amount += amount + return self + + self.hbar_transfers.append(HbarTransfer(account_id, amount, is_approved)) + return self + + def add_hbar_transfer(self, account_id: AccountId, amount: int | Hbar) -> TransferTransaction: + """ + Adds a HBAR transfer to the transaction. + + Args: + account_id (AccountId): The account ID of the sender or receiver. + amount (int | Hbar): The amount of the HBAR to transfer. + + Returns: + TransferTransaction: The current instance of the transaction for chaining. + """ + self._add_hbar_transfer(account_id, amount, False) + return self + + def add_approved_hbar_transfer(self, account_id: AccountId, amount: int | Hbar) -> TransferTransaction: + """ + Adds a HBAR transfer with approval to the transaction. + + Args: + account_id (AccountId): The account ID of the sender or receiver. + amount (int | Hbar): The amount of the HBAR to transfer. + + Returns: + TransferTransaction: The current instance of the transaction for chaining. + """ + self._add_hbar_transfer(account_id, amount, True) + return self + + def _build_proto_body(self) -> crypto_transfer_pb2.CryptoTransferTransactionBody: + """Returns the protobuf body for the transfer transaction.""" + crypto_transfer_tx_body = crypto_transfer_pb2.CryptoTransferTransactionBody() + + # HBAR + if self.hbar_transfers: + transfer_list = basic_types_pb2.TransferList() + for hbar_transfer in self.hbar_transfers: + transfer_list.accountAmounts.append(hbar_transfer._to_proto()) + + crypto_transfer_tx_body.transfers.CopyFrom(transfer_list) + + # NFTs/Tokens + token_transfers = self.build_token_transfers() + + for transfer in token_transfers: + crypto_transfer_tx_body.tokenTransfers.append(transfer) + + return crypto_transfer_tx_body + + def build_transaction_body(self) -> transaction_pb2.TransactionBody: + """ + Builds and returns the protobuf transaction body for a transfer transaction. + + Returns: + TransactionBody: The built transaction body. + """ + crypto_transfer_tx_body = self._build_proto_body() + + transaction_body = self.build_base_transaction_body() + transaction_body.cryptoTransfer.CopyFrom(crypto_transfer_tx_body) + + return transaction_body + + def build_scheduled_body(self) -> SchedulableTransactionBody: + """ + Builds the transaction body for this transfer transaction. + + Returns: + SchedulableTransactionBody: The built scheduled transaction body. + """ + crypto_transfer_tx_body = self._build_proto_body() + + schedulable_body = self.build_base_scheduled_body() + schedulable_body.cryptoTransfer.CopyFrom(crypto_transfer_tx_body) + return schedulable_body + + def _get_method(self, channel: _Channel) -> _Method: + return _Method(transaction_func=channel.crypto.cryptoTransfer, query_func=None) + + @classmethod + def _from_protobuf(cls, transaction_body, body_bytes: bytes, sig_map): + """ + Creates a TransferTransaction instance from protobuf components. + + Args: + transaction_body: The parsed TransactionBody protobuf + body_bytes (bytes): The raw bytes of the transaction body + sig_map: The SignatureMap protobuf containing signatures + + Returns: + TransferTransaction: A new transaction instance with all fields restored + """ + transaction = super()._from_protobuf(transaction_body, body_bytes, sig_map) + + if transaction_body.HasField("cryptoTransfer"): + crypto_transfer = transaction_body.cryptoTransfer + + if crypto_transfer.HasField("transfers"): + for account_amount in crypto_transfer.transfers.accountAmounts: + account_id = AccountId._from_proto(account_amount.accountID) + amount = account_amount.amount + is_approved = account_amount.is_approval + transaction.hbar_transfers.append(HbarTransfer(account_id, amount, is_approved)) + + for token_transfer_list in crypto_transfer.tokenTransfers: + token_id = TokenId._from_proto(token_transfer_list.token) + + for transfer in token_transfer_list.transfers: + account_id = AccountId._from_proto(transfer.accountID) + amount = transfer.amount + is_approved = transfer.is_approval + + expected_decimals = None + if token_transfer_list.HasField("expected_decimals"): + expected_decimals = token_transfer_list.expected_decimals.value + + transaction.token_transfers[token_id].append( + TokenTransfer(token_id, account_id, amount, expected_decimals, is_approved) + ) + + for nft_transfer in token_transfer_list.nftTransfers: + sender_id = AccountId._from_proto(nft_transfer.senderAccountID) + receiver_id = AccountId._from_proto(nft_transfer.receiverAccountID) + serial_number = nft_transfer.serialNumber + is_approved = nft_transfer.is_approval + + transaction.nft_transfers[token_id].append( + TokenNftTransfer(token_id, sender_id, receiver_id, serial_number, is_approved) + ) + + return transaction 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..4a2d006ed --- /dev/null +++ b/tests/integration/fee_estimate_query_e2e_test.py @@ -0,0 +1,64 @@ +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): + tx = AccountCreateTransaction().set_key_without_alias(PrivateKey.generate_ed25519()).set_initial_balance(1) + query = FeeEstimateQuery().set_transaction(tx) + result = query.execute(env.client) + + print(result) + assert result is not None + + +@pytest.mark.integration +def test_can_execute_fee_estimation_query2(env): + 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) + + print(result) + print(type(result.total)) + assert result is not None + + +@pytest.mark.integration +def test__fee_estimation_query_chuck_tx_can_execute(env): + + 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) + + print(result) + assert result is not None + + +@pytest.mark.integration +def test_can_execute_fee_estimation_query_chuck_tx(env): + + 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) + + print(result) + 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..dc3f34254 --- /dev/null +++ b/tests/unit/fee_estimate_query_test.py @@ -0,0 +1,331 @@ +"""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()) + + +@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 diff --git a/tests/unit/transaction_test.py b/tests/unit/transaction_test.py index efe8cbe84..9e85e754b 100644 --- a/tests/unit/transaction_test.py +++ b/tests/unit/transaction_test.py @@ -13,6 +13,7 @@ transaction_receipt_pb2, transaction_response_pb2, ) +from hiero_sdk_python.query.fee_estimate_query import FeeEstimateQuery from hiero_sdk_python.response_code import ResponseCode from hiero_sdk_python.transaction.transaction_receipt import TransactionReceipt from hiero_sdk_python.transaction.transaction_response import TransactionResponse @@ -117,3 +118,13 @@ def test_execute_returns_receipt_without_error_when_validation_disabled(): receipt = tx.execute(client) assert receipt.status == ResponseCode.INVALID_SIGNATURE + + +def test_estimate_fee_returns_fee_query(): + + tx = AccountCreateTransaction() + + query = tx.estimate_fee() + + assert isinstance(query, FeeEstimateQuery) + assert query.get_transaction() == tx