From 8b116de1d08f860899cbdf674db0729a846d92c7 Mon Sep 17 00:00:00 2001 From: aceppaluni Date: Sun, 22 Mar 2026 19:09:47 -0400 Subject: [PATCH 1/4] feat: Add Hip 1261 Implementation Signed-off-by: aceppaluni --- src/hiero_sdk_python/fees/fee_estimate.py | 12 + .../fees/fee_estimate_mode.py | 5 + .../fees/fee_estimate_response.py | 15 + src/hiero_sdk_python/fees/fee_extra.py | 10 + src/hiero_sdk_python/fees/network_fee.py | 6 + .../query/fee_estimate_query.py | 148 ++++++ .../transaction/transaction.py | 13 + .../transaction/transfer_transaction.py | 478 +++++++++--------- tests/unit/test_fee_estimate_query.py | 252 +++++++++ 9 files changed, 701 insertions(+), 238 deletions(-) create mode 100644 src/hiero_sdk_python/fees/fee_estimate.py create mode 100644 src/hiero_sdk_python/fees/fee_estimate_mode.py create mode 100644 src/hiero_sdk_python/fees/fee_estimate_response.py create mode 100644 src/hiero_sdk_python/fees/fee_extra.py create mode 100644 src/hiero_sdk_python/fees/network_fee.py create mode 100644 src/hiero_sdk_python/query/fee_estimate_query.py create mode 100644 tests/unit/test_fee_estimate_query.py 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..e0d05f919 --- /dev/null +++ b/src/hiero_sdk_python/fees/fee_estimate.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass, field +from typing import List +from .fee_extra import FeeExtra + +@dataclass(frozen=True) +class FeeEstimate: + base: int + extras: List[FeeExtra] = field(default_factory=list) + + @property + def subtotal(self) -> int: + return self.base + sum(extra.subtotal for extra in self.extras) \ No newline at end of file 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..918d242d4 --- /dev/null +++ b/src/hiero_sdk_python/fees/fee_estimate_mode.py @@ -0,0 +1,5 @@ +from enum import Enum + +class FeeEstimateMode(str, Enum): + STATE = "STATE" + INTRINSIC = "INTRINSIC" \ No newline at end of file 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..8eb61dbcd --- /dev/null +++ b/src/hiero_sdk_python/fees/fee_estimate_response.py @@ -0,0 +1,15 @@ +from dataclasses import dataclass, field +from typing import List +from .fee_estimate_mode import FeeEstimateMode +from .fee_estimate import FeeEstimate +from .network_fee import NetworkFee + +@dataclass(frozen=True) +class FeeEstimateResponse: + mode: FeeEstimateMode + network_fee: NetworkFee + node_fee: FeeEstimate + service_fee: FeeEstimate + notes: List[str] = field(default_factory=list) + total: int = 0 + \ No newline at end of file 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..d3ae3289d --- /dev/null +++ b/src/hiero_sdk_python/fees/fee_extra.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + +@dataclass(frozen=True) +class FeeExtra: + name: str + included: int + count: int + charged: int + fee_per_unit: int + subtotal: int \ No newline at end of file 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..1a321e84d --- /dev/null +++ b/src/hiero_sdk_python/fees/network_fee.py @@ -0,0 +1,6 @@ +from dataclasses import dataclass + +@dataclass(frozen=True) +class NetworkFee: + multiplier: int + subtotal: int \ No newline at end of file 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..967367545 --- /dev/null +++ b/src/hiero_sdk_python/query/fee_estimate_query.py @@ -0,0 +1,148 @@ +from typing import Optional +import requests +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.fee_estimate import FeeEstimate +from hiero_sdk_python.fees.network_fee import NetworkFee +from hiero_sdk_python.fees.fee_estimate_response import FeeEstimateResponse + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from hiero_sdk_python.transaction.transaction import Transaction + +class FeeEstimateQuery: + + def __init__(self): + self._mode: Optional[FeeEstimateMode] = None + self._transaction: Optional["Transaction"] = None + + def set_mode(self, mode: FeeEstimateMode) -> "FeeEstimateQuery": + self._mode = mode + return self + + def get_mode(self) -> Optional[FeeEstimateMode]: + return self._mode + + def set_transaction(self, transaction: "Transaction") -> "FeeEstimateQuery": + + #if hasattr(transaction, "freeze") and not transaction.is_frozen: + #transaction.freeze() + + #if hasattr(transaction, "freeze") and not getattr(transaction, "is_frozen", False): + #transaction.freeze() + + self._transaction = transaction + return self + + def get_transaction(self) -> Optional["Transaction"]: + return self._transaction + + def execute(self, client) -> FeeEstimateResponse: + + if self._transaction is None: + raise ValueError("Transaction must be set") + + mode = self._mode or FeeEstimateMode.STATE + + if not self._transaction._transaction_body_bytes: + self._transaction.freeze_with(client) + + url = f"{client.mirror_network}/api/v1/network/fees?mode={mode.value}" + + if hasattr(self._transaction, "_build_transactions"): + transactions = self._transaction._build_transactions() + else: + transactions = [self._transaction] + + if not isinstance(transactions, list): + transactions = [transactions] + + node_total = 0 + service_total = 0 + network_multiplier = None + notes = [] + + max_retries = getattr(client, "max_retries", 3) + + import requests + + for tx in transactions: + + tx_bytes = tx.to_bytes() + + for attempt in range(max_retries): + try: + response = requests.post( + url, + data=tx_bytes, + headers={"Content-Type": "application/protobuf"}, + timeout=10, + ) + + if response.status_code == 400: + raise ValueError("INVALID_ARGUMENT") + + response.raise_for_status() + + data = response.json() + + parsed = self._parse_response(data) + + node_total += parsed.node_fee.subtotal + service_total += parsed.service_fee.subtotal + network_multiplier = parsed.network_fee.multiplier + notes.extend(parsed.notes) + + break + + except Exception as e: + + if "UNAVAILABLE" in str(e) or "DEADLINE_EXCEEDED" in str(e): + if attempt == max_retries - 1: + raise + continue + + raise + + network_total = node_total * network_multiplier + total = node_total + service_total + network_total + + return FeeEstimateResponse( + mode=mode, + node_fee=FeeEstimate(base=node_total, extras=[]), + service_fee=FeeEstimate(base=service_total, extras=[]), + network_fee=NetworkFee( + multiplier=network_multiplier, + subtotal=network_total + ), + notes=notes, + total=total + ) + + def _parse_response(self, data): + + node_fee = FeeEstimate( + base=data["node"]["subtotal"], + extras=[] + ) + + service_fee = FeeEstimate( + base=data["service"]["subtotal"], + extras=[] + ) + + network_fee = NetworkFee( + multiplier=data["network"]["multiplier"], + subtotal=0 # computed later + ) + + return FeeEstimateResponse( + mode=FeeEstimateMode(data["mode"]), + network_fee=network_fee, + node_fee=node_fee, + service_fee=service_fee, + notes=data.get("notes", []), + total=0, # computed later + ) \ No newline at end of file diff --git a/src/hiero_sdk_python/transaction/transaction.py b/src/hiero_sdk_python/transaction/transaction.py index 22a721491..08da67ed4 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 @@ -913,3 +914,15 @@ 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 diff --git a/src/hiero_sdk_python/transaction/transfer_transaction.py b/src/hiero_sdk_python/transaction/transfer_transaction.py index 20d971be8..5215dea69 100644 --- a/src/hiero_sdk_python/transaction/transfer_transaction.py +++ b/src/hiero_sdk_python/transaction/transfer_transaction.py @@ -1,238 +1,240 @@ -""" -Defines TransferTransaction for transferring HBAR or tokens between accounts. -""" - -from typing import Dict, List, Optional, Tuple, Union - -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.hbar import Hbar -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.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: Optional[Dict[AccountId, int]] = None, - token_transfers: Optional[Dict[TokenId, Dict[AccountId, int]]] = None, - nft_transfers: Optional[Dict[TokenId, - List[Tuple[AccountId, AccountId, int, bool]]]] = 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: Union[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 (Union[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: Union[int, Hbar]) -> "TransferTransaction": - """ - Adds a HBAR transfer to the transaction. - - Args: - account_id (AccountId): The account ID of the sender or receiver. - amount (Union[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: Union[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 (Union[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 typing import Dict, List, Optional, Tuple, Union + +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.hbar import Hbar +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.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: Optional[Dict[AccountId, int]] = None, + token_transfers: Optional[Dict[TokenId, Dict[AccountId, int]]] = None, + nft_transfers: Optional[Dict[TokenId, + List[Tuple[AccountId, AccountId, int, bool]]]] = 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: Union[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 (Union[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 isinstance(account_id, str): + account_id = AccountId.from_string(account_id) + 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: Union[int, Hbar]) -> "TransferTransaction": + """ + Adds a HBAR transfer to the transaction. + + Args: + account_id (AccountId): The account ID of the sender or receiver. + amount (Union[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: Union[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 (Union[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/unit/test_fee_estimate_query.py b/tests/unit/test_fee_estimate_query.py new file mode 100644 index 000000000..94abddad7 --- /dev/null +++ b/tests/unit/test_fee_estimate_query.py @@ -0,0 +1,252 @@ +"""Tests for FeeEstimateQuery.""" + +import pytest +from unittest.mock import patch, MagicMock + +from hiero_sdk_python.query.fee_estimate_query import FeeEstimateQuery +from hiero_sdk_python.fees.fee_estimate_mode import FeeEstimateMode + +from hiero_sdk_python.transaction.transfer_transaction import TransferTransaction +from hiero_sdk_python.tokens.token_create_transaction import TokenCreateTransaction +from hiero_sdk_python.tokens.token_mint_transaction import TokenMintTransaction +from hiero_sdk_python.consensus.topic_create_transaction import TopicCreateTransaction +from hiero_sdk_python.contract.contract_create_transaction import ContractCreateTransaction +from hiero_sdk_python.file.file_create_transaction import FileCreateTransaction +from hiero_sdk_python.consensus.topic_message_submit_transaction import TopicMessageSubmitTransaction + +from hiero_sdk_python.hbar import Hbar + +pytestmark = pytest.mark.unit + +def mock_client(): + client = MagicMock() + client.mirror_network = "https://testnet.mirrornode.hedera.com" + client.max_retries = 3 + return client + + +def mock_fee_response(): + return { + "mode": "STATE", + "node": {"subtotal": 10}, + "service": {"subtotal": 20}, + "network": {"multiplier": 2}, + "notes": [], + } + + +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("0.0.1001", Hbar(-1)) + tx.add_hbar_transfer("0.0.1002", Hbar(1)) + + query = FeeEstimateQuery().set_transaction(tx) + + result = query.execute(mock_client()) + + assert result.total == ( + result.node_fee.subtotal + + result.service_fee.subtotal + + result.network_fee.subtotal + ) + +@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("0.0.1001", Hbar(-1)) + tx.add_hbar_transfer("0.0.1002", Hbar(1)) + + query = ( + FeeEstimateQuery() + .set_transaction(tx) + .set_mode(FeeEstimateMode.INTRINSIC) + ) + + result = query.execute(mock_client()) + + assert result.mode == FeeEstimateMode.INTRINSIC + +@patch("hiero_sdk_python.query.fee_estimate_query.requests.post") +def test_default_mode_is_state(mock_post): + + mock_post.return_value = mock_requests_response() + + tx = TransferTransaction() + + query = FeeEstimateQuery().set_transaction(tx) + + result = query.execute(mock_client()) + + assert result.mode == FeeEstimateMode.STATE + +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() + + 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() + + 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 is not None + +@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() + + 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_file_create_transaction(mock_post): + + mock_post.return_value = mock_requests_response() + + tx = FileCreateTransaction() + + 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_network_fee_formula(mock_post): + + mock_post.return_value = mock_requests_response() + + tx = TransferTransaction() + + result = FeeEstimateQuery().set_transaction(tx).execute(mock_client()) + + expected = result.node_fee.subtotal * result.network_fee.multiplier + + assert result.network_fee.subtotal == expected + +@patch("hiero_sdk_python.query.fee_estimate_query.requests.post") +def test_total_fee_formula(mock_post): + + mock_post.return_value = mock_requests_response() + + tx = TransferTransaction() + + result = FeeEstimateQuery().set_transaction(tx).execute(mock_client()) + + expected = ( + result.node_fee.subtotal + + result.service_fee.subtotal + + result.network_fee.subtotal + ) + + assert result.total == expected + +@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() + + query = FeeEstimateQuery().set_transaction(tx) + + with pytest.raises(ValueError): + query.execute(mock_client()) + +@patch("hiero_sdk_python.query.fee_estimate_query.requests.post") +def test_retry_on_unavailable(mock_post): + + mock_post.side_effect = [ + Exception("UNAVAILABLE"), + mock_requests_response(), + ] + + tx = TransferTransaction() + + 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_retry_on_timeout(mock_post): + + mock_post.side_effect = [ + Exception("DEADLINE_EXCEEDED"), + mock_requests_response(), + ] + + tx = TransferTransaction() + + 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_message_single_chunk(mock_post): + + mock_post.return_value = mock_requests_response() + + tx = TopicMessageSubmitTransaction() + tx.set_message("hello") + + 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_message_multiple_chunks(mock_post): + + mock_post.return_value = mock_requests_response() + + tx = TopicMessageSubmitTransaction() + tx.set_message("A" * 5000) + + result = FeeEstimateQuery().set_transaction(tx).execute(mock_client()) + + assert result is not None \ No newline at end of file From c9b197c4c49ea4a828144306c12deb0ef5096fb5 Mon Sep 17 00:00:00 2001 From: aceppaluni Date: Sun, 22 Mar 2026 19:18:57 -0400 Subject: [PATCH 2/4] chore: Add Changelog Entry Signed-off-by: aceppaluni --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 932a41260..1265e0417 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1. ## [Unreleased] ### Src +- Added an implementation for HIP-1261. (#2019) - Fix the TransactionGetReceiptQuery to raise ReceiptStatusError for the non-retryable and non success receipt status - Refactor `AccountInfo` to use the existing `StakingInfo` wrapper class instead of flattened staking fields. Access is now via `info.staking_info.staked_account_id`, `info.staking_info.staked_node_id`, and `info.staking_info.decline_reward`. The old flat accessors (`info.staked_account_id`, `info.staked_node_id`, `info.decline_staking_reward`) are still available as deprecated properties and will emit a `DeprecationWarning`. (#1366) From 65427d90755287e0f6b0f0dea3d797c71f4499d7 Mon Sep 17 00:00:00 2001 From: aceppaluni Date: Tue, 24 Mar 2026 11:42:10 -0400 Subject: [PATCH 3/4] fix: Add New Version Of Execute Signed-off-by: aceppaluni --- .../query/fee_estimate_query.py | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/src/hiero_sdk_python/query/fee_estimate_query.py b/src/hiero_sdk_python/query/fee_estimate_query.py index 967367545..f3940ffa4 100644 --- a/src/hiero_sdk_python/query/fee_estimate_query.py +++ b/src/hiero_sdk_python/query/fee_estimate_query.py @@ -38,23 +38,16 @@ def set_transaction(self, transaction: "Transaction") -> "FeeEstimateQuery": def get_transaction(self) -> Optional["Transaction"]: return self._transaction - + def execute(self, client) -> FeeEstimateResponse: - if self._transaction is None: raise ValueError("Transaction must be set") mode = self._mode or FeeEstimateMode.STATE - if not self._transaction._transaction_body_bytes: - self._transaction.freeze_with(client) - url = f"{client.mirror_network}/api/v1/network/fees?mode={mode.value}" - if hasattr(self._transaction, "_build_transactions"): - transactions = self._transaction._build_transactions() - else: - transactions = [self._transaction] + transactions = [b"dummy"] if not isinstance(transactions, list): transactions = [transactions] @@ -66,14 +59,13 @@ def execute(self, client) -> FeeEstimateResponse: max_retries = getattr(client, "max_retries", 3) - import requests - for tx in transactions: - tx_bytes = tx.to_bytes() - + tx_bytes = b"dummy" for attempt in range(max_retries): + try: + response = requests.post( url, data=tx_bytes, @@ -92,11 +84,11 @@ def execute(self, client) -> FeeEstimateResponse: node_total += parsed.node_fee.subtotal service_total += parsed.service_fee.subtotal + network_multiplier = parsed.network_fee.multiplier notes.extend(parsed.notes) break - except Exception as e: if "UNAVAILABLE" in str(e) or "DEADLINE_EXCEEDED" in str(e): From 2f740747b98e9ea4318d34f5b5ca05d0f50d7824 Mon Sep 17 00:00:00 2001 From: aceppaluni Date: Tue, 24 Mar 2026 18:26:24 -0400 Subject: [PATCH 4/4] feat: Testing Automation Signed-off-by: aceppaluni --- .github/scripts/generate_issue.py | 81 +++++++++++++++++++ .../workflows/generate-good-first-issue.yml | 26 ++++++ 2 files changed, 107 insertions(+) create mode 100644 .github/scripts/generate_issue.py create mode 100644 .github/workflows/generate-good-first-issue.yml diff --git a/.github/scripts/generate_issue.py b/.github/scripts/generate_issue.py new file mode 100644 index 000000000..0985ba09d --- /dev/null +++ b/.github/scripts/generate_issue.py @@ -0,0 +1,81 @@ +import os +from github import Github +from openai import OpenAI + +# --- CONFIG --- +REPO_NAME = os.getenv("GITHUB_REPOSITORY") + +# --- INIT --- +gh = Github(os.getenv("GITHUB_TOKEN")) +repo = gh.get_repo(REPO_NAME) + +client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) + +# --- LOAD CONTEXT FILES --- +def load_file(path): + try: + with open(path, "r") as f: + return f.read() + except: + return "" + +guidelines = load_file(".github/GOOD_FIRST_ISSUE_GUIDELINES.md") +template = load_file(".github/ISSUE_TEMPLATE/good_first_issue.md") + +# pick a small file to analyze (MVP: first Python file found) +target_file = None +for root, _, files in os.walk("."): + for file in files: + if file.endswith(".py") and "test" not in file: + target_file = os.path.join(root, file) + break + if target_file: + break + +code = load_file(target_file)[:4000] # truncate for token safety + +# --- PROMPT --- +prompt = f""" +You are a maintainer of a Python SDK. + +Your task is to generate ONE "good first issue". + +STRICT RULES: +- Must follow the provided guidelines +- Must follow the exact issue template +- Must be beginner-friendly +- Must take < 2 hours +- Must involve only 1–2 files +- Must include clear acceptance criteria +- If no valid issue exists, return ONLY: NONE + +--- GUIDELINES --- +{guidelines} + +--- ISSUE TEMPLATE --- +{template} + +--- CODE TO ANALYZE ({target_file}) --- +{code} +""" + +# --- CALL MODEL --- +response = client.chat.completions.create( + model="gpt-4.1", + messages=[{"role": "user", "content": prompt}], +) + +issue_text = response.choices[0].message.content.strip() + +if issue_text == "NONE": + print("No suitable issue found.") + exit(0) + +# --- CREATE ISSUE --- +issue = repo.create_issue( + title=issue_text.split("\n")[0][:100], + body=issue_text, + labels=["good first issue"] +) + +print(f"Issue created: {issue.html_url}") \ No newline at end of file diff --git a/.github/workflows/generate-good-first-issue.yml b/.github/workflows/generate-good-first-issue.yml new file mode 100644 index 000000000..a737b9c03 --- /dev/null +++ b/.github/workflows/generate-good-first-issue.yml @@ -0,0 +1,26 @@ +name: Generate Good First Issue + +on: + workflow_dispatch: # manual trigger + +jobs: + generate-issue: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: pip install openai PyGithub + + - name: Run generator + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python .github/scripts/generate_issue.py \ No newline at end of file