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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
12 changes: 12 additions & 0 deletions src/hiero_sdk_python/fees/fee_estimate.py
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 5 additions & 0 deletions src/hiero_sdk_python/fees/fee_estimate_mode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from enum import Enum

class FeeEstimateMode(str, Enum):
STATE = "STATE"
INTRINSIC = "INTRINSIC"
15 changes: 15 additions & 0 deletions src/hiero_sdk_python/fees/fee_estimate_response.py
Original file line number Diff line number Diff line change
@@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

these fields may be absent in the response better to mark them as the Optional[None]:

network_fee: Optional[NetworkFee] = None
node_fee: Optional[FeeEstimate] = None
service_fee: Optional[FeeEstimate] = None

notes: List[str] = field(default_factory=list)
total: int = 0
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Avoid storing total as a mutable constructor value; derive it from components.

Line 14 defaults total to 0, which can drift from node_fee/network_fee/service_fee and violate the fee formula invariant. Make total a computed property.

Suggested patch
 `@dataclass`(frozen=True)
 class FeeEstimateResponse:
@@
     service_fee: FeeEstimate
     notes: List[str] = field(default_factory=list)
-    total: int = 0
+
+    `@property`
+    def total(self) -> int:
+        return self.node_fee.subtotal + self.network_fee.subtotal + self.service_fee.subtotal


10 changes: 10 additions & 0 deletions src/hiero_sdk_python/fees/fee_extra.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from dataclasses import dataclass

@dataclass(frozen=True)
class FeeExtra:
name: str
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This field may not always be present in the response

Suggested change
name: str
name: Optional[str] = None

included: int
count: int
charged: int
fee_per_unit: int
subtotal: int
6 changes: 6 additions & 0 deletions src/hiero_sdk_python/fees/network_fee.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from dataclasses import dataclass

@dataclass(frozen=True)
class NetworkFee:
multiplier: int
subtotal: int
140 changes: 140 additions & 0 deletions src/hiero_sdk_python/query/fee_estimate_query.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

consider adding query level max_attempts and max_backoff configuration and make them configurable by adding setters

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
Comment on lines +28 to +37
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

The supplied transaction is never serialized or chunked.

set_transaction() only stores the object, while execute() always iterates over [b"dummy"] and posts b"dummy". That makes every transaction type produce the same estimate and prevents chunked transactions from ever aggregating per-chunk fees. The auto-freeze behavior called out in the PR objective is also effectively absent because the transaction bytes are never derived from self._transaction.

Also applies to: 50-65


def get_transaction(self) -> Optional["Transaction"]:
return self._transaction

def execute(self, client) -> FeeEstimateResponse:

Check warning on line 42 in src/hiero_sdk_python/query/fee_estimate_query.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/hiero_sdk_python/query/fee_estimate_query.py#L42

Method execute has a cyclomatic complexity of 11 (limit is 8)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

consider splitting into smaller helper methods to reduce complexity.

if self._transaction is None:
raise ValueError("Transaction must be set")

mode = self._mode or FeeEstimateMode.STATE

url = f"{client.mirror_network}/api/v1/network/fees?mode={mode.value}"

transactions = [b"dummy"]

if not isinstance(transactions, list):
transactions = [transactions]

node_total = 0
service_total = 0
network_multiplier = None
notes = []

max_retries = getattr(client, "max_retries", 3)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

we can remove this and use the max_attempts instead

Suggested change
max_retries = getattr(client, "max_retries", 3)
if self.attempts is None:
self.max_attempts = client.max_attempts


for tx in transactions:

tx_bytes = b"dummy"
for attempt in range(max_retries):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
for attempt in range(max_retries):
for attempt in range(self.max_attempts):


try:

response = requests.post(
url,
data=tx_bytes,
headers={"Content-Type": "application/protobuf"},
timeout=10,
)

if response.status_code == 400:
raise ValueError("INVALID_ARGUMENT")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

can impose retry logic on stautus code like:

408, 429, 5xx

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:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
if attempt == max_retries - 1:
if attempt == self.max_attempts - 1:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

we can add backoff if we want to retry similar to what we do in the execute() function of the a normal Query

def backoff(self, attempt: int):
        delay = min(0.5 * (2 ** attempt), self._max_backoff)
        time.sleep(delay)

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
)
Comment on lines +83 to +114
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Preserve the parsed response instead of rebuilding it from request state.

_parse_response() already deserializes the Mirror Node payload, but execute() throws that object away and returns a new FeeEstimateResponse built from aggregated subtotals plus mode=mode from the request. That can misreport what the service actually returned and drops any detail _parse_response() captured. Aggregate the parsed responses instead of reconstructing a new one from request-side values.

Also applies to: 116-140


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
)
13 changes: 13 additions & 0 deletions src/hiero_sdk_python/transaction/transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading