Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ src/hiero_sdk_python/hapi
# Pytest
.pytest_cache

# Hypothesis state
.hypothesis/

# Lock files
uv.lock
pdm.lock
pubkey.asc
pubkey.asc
3 changes: 1 addition & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.
- Refactored `examples/consensus/topic_delete_transaction.py` to use Client.from_env() for simplified client initialization, removed manual setup code, and cleaned up unused imports (`os`, `AccountId`, `PrivateKey`). (`#1971`)

### Tests
- Implement basic fuzz testing [#1872](https://github.com/hiero-ledger/hiero-sdk-python/issues/1872)

### Docs
- Replaced relative documentation links in `README.md` with absolute GitHub URLs to fix broken PyPI rendering.
Expand Down Expand Up @@ -110,8 +111,6 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.
- docs: Clarify issues need to be assigned in template files. (#1884)
- doc: Fix testnet link in README.md. (#1879)



### Tests
- Format `tests/unit/endpoint_test.py` using black. (`#1792`)
- Implement TCK JSON-RPC server with request handling and error management
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ dev = [
"grpcio-tools>=1.76.0,<2",
"pytest>=8.3.4,<10",
"pytest-cov>=7.0.0,<8",
"hypothesis>=6.0"
Comment thread
exploreriii marked this conversation as resolved.
Outdated
]

lint = [
Expand Down
3 changes: 2 additions & 1 deletion pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ pythonpath = src

markers =
integration: mark a test as an integration test.
unit: mark a test as a unit test.
unit: mark a test as a unit test.
fuzz: mark a test as fuzz test.
8 changes: 5 additions & 3 deletions src/hiero_sdk_python/hbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ def __init__(

if isinstance(amount, float) and not math.isfinite(amount):
raise ValueError("Hbar amount must be finite")
if isinstance(amount, Decimal) and not amount.is_finite():
raise ValueError("Hbar amount must be finite")

if unit == HbarUnit.TINYBAR:
if not isinstance(amount, int):
Expand All @@ -56,7 +58,7 @@ def __init__(
amount = Decimal(str(amount))

tinybar = amount * Decimal(unit.tinybar)
if tinybar % 1 != 0:
if tinybar != tinybar.to_integral_value():
raise ValueError("Fractional tinybar value not allowed")

self._amount_in_tinybar = int(tinybar)
Expand All @@ -67,7 +69,7 @@ def to(self, unit: HbarUnit) -> float:

def to_tinybars(self) -> int:
"""Return the amount of hbars in tinybars."""
return int(self.to(HbarUnit.TINYBAR))
return self._amount_in_tinybar

def to_hbars(self) -> float:
"""
Expand Down Expand Up @@ -220,4 +222,4 @@ def __ge__(self, other: object) -> bool:

Hbar.ZERO = Hbar(0)
Hbar.MAX = Hbar(50_000_000_000)
Hbar.MIN = Hbar(-50_000_000_000)
Hbar.MIN = Hbar(-50_000_000_000)
1 change: 1 addition & 0 deletions tests/fuzz/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# with <3 from Anto
Comment thread
AntonioCeppellini marked this conversation as resolved.
32 changes: 32 additions & 0 deletions tests/fuzz/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Shared Hypothesis setup, fixtures, and compatibility re-exports for fuzz tests."""

from tests.fuzz.support.classes import (
AccountIdAliasCase,
ContractValueCase,
EntityIdCase,
HbarConstructorCase,
HbarStringCase,
InvalidContractValueCase,
)
from tests.fuzz.support.profiles import load_hypothesis_profile
from tests.fuzz.support.registry import (
FUZZ_STRATEGIES,
fuzz_strategies_fixture,
get_strategy,
get_strategy_fixture,
)

load_hypothesis_profile()

__all__ = [
"AccountIdAliasCase",
"ContractValueCase",
"EntityIdCase",
"FUZZ_STRATEGIES",
"HbarConstructorCase",
"HbarStringCase",
"InvalidContractValueCase",
"fuzz_strategies_fixture",
"get_strategy",
"get_strategy_fixture",
]
20 changes: 20 additions & 0 deletions tests/fuzz/support/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from tests.fuzz.support.classes import (
AccountIdAliasCase,
ContractValueCase,
EntityIdCase,
HbarConstructorCase,
HbarStringCase,
InvalidContractValueCase,
)
from tests.fuzz.support.registry import FUZZ_STRATEGIES, get_strategy

__all__ = [
"AccountIdAliasCase",
"ContractValueCase",
"EntityIdCase",
"FUZZ_STRATEGIES",
"HbarConstructorCase",
"HbarStringCase",
"InvalidContractValueCase",
"get_strategy",
]
61 changes: 61 additions & 0 deletions tests/fuzz/support/classes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from dataclasses import dataclass
from decimal import Decimal
from typing import Any

from hiero_sdk_python import HbarUnit


@dataclass(frozen=True)
class EntityIdCase:
"""A parsed entity ID expectation for public string parsers."""

text: str
shard: int
realm: int
value: int
checksum: str | None = None


@dataclass(frozen=True)
class AccountIdAliasCase:
"""A valid account alias or EVM-address input."""

text: str
shard: int
realm: int
alias_hex: str | None = None
evm_hex: str | None = None


@dataclass(frozen=True)
class HbarStringCase:
"""A valid public Hbar string and its exact tinybar value."""

text: str
tinybars: int


@dataclass(frozen=True)
class HbarConstructorCase:
"""A valid Hbar constructor input and its exact tinybar value."""

amount: int | float | Decimal
unit: HbarUnit
tinybars: int


@dataclass(frozen=True)
class ContractValueCase:
"""A valid contract parameter case routed to an explicit public add_* method."""

method_name: str
value: Any


@dataclass(frozen=True)
class InvalidContractValueCase:
"""An invalid contract parameter case with a precise expected exception."""

method_name: str
value: Any
expected_exception: type[BaseException]
65 changes: 65 additions & 0 deletions tests/fuzz/support/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from decimal import Decimal

from hypothesis import strategies as st
from hypothesis.strategies import SearchStrategy

from hiero_sdk_python import AccountId, HbarUnit, PrivateKey, TransactionId, TransferTransaction

from tests.fuzz.support.classes import HbarConstructorCase, HbarStringCase


def sized_hex(byte_length: int) -> SearchStrategy[str]:
"""Return a fixed-length hex string strategy."""
return st.binary(min_size=byte_length, max_size=byte_length).map(bytes.hex)


def with_optional_0x(hex_strategy: SearchStrategy[str]) -> SearchStrategy[str]:
"""Allow a hex string with or without the `0x` prefix."""
return st.one_of(hex_strategy, hex_strategy.map(lambda value: f"0x{value}"))


def decimal_string(value: Decimal) -> str:
"""Format a Decimal without trailing zeros."""
text = format(value, "f")
if "." in text:
text = text.rstrip("0").rstrip(".")
return text or "0"


def hbar_string_case(unit: HbarUnit, tinybars: int) -> HbarStringCase:
"""Build a valid Hbar string case from an exact tinybar amount."""
amount = Decimal(tinybars) / Decimal(unit.tinybar)
if unit == HbarUnit.HBAR:
return HbarStringCase(text=decimal_string(amount), tinybars=tinybars)
return HbarStringCase(text=f"{decimal_string(amount)} {unit.symbol}", tinybars=tinybars)


def hbar_constructor_case(unit: HbarUnit, tinybars: int) -> HbarConstructorCase:
"""Build a valid Hbar constructor case from an exact tinybar amount."""
if unit == HbarUnit.TINYBAR:
amount: int | float | Decimal = tinybars
else:
amount = Decimal(tinybars) / Decimal(unit.tinybar)
return HbarConstructorCase(amount=amount, unit=unit, tinybars=tinybars)


def build_valid_transaction_bytes() -> tuple[bytes, bytes]:
"""Build one valid unsigned and one valid signed transaction payload."""
operator_id = AccountId.from_string("0.0.1234")
node_id = AccountId.from_string("0.0.3")
receiver_id = AccountId.from_string("0.0.5678")

tx = (
TransferTransaction()
.add_hbar_transfer(operator_id, -100_000_000)
.add_hbar_transfer(receiver_id, 100_000_000)
)
tx.transaction_id = TransactionId.generate(operator_id)
tx.node_account_id = node_id
tx.freeze()
unsigned_bytes = tx.to_bytes()

signed_tx = TransferTransaction.from_bytes(unsigned_bytes)
signed_tx.sign(PrivateKey.from_string_ed25519("02" * 32))
signed_bytes = signed_tx.to_bytes()
return unsigned_bytes, signed_bytes
31 changes: 31 additions & 0 deletions tests/fuzz/support/profiles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import os

from hypothesis import HealthCheck, settings


def load_hypothesis_profile() -> None:
"""Register and load the active Hypothesis profile."""
settings.register_profile(
"ci",
settings(
derandomize=True,
max_examples=300,
deadline=750,
suppress_health_check=[HealthCheck.too_slow],
),
)
settings.register_profile(
"local",
settings(
derandomize=False,
max_examples=1000,
deadline=None,
),
)

requested = os.getenv("HYPOTHESIS_PROFILE")
if requested:
settings.load_profile(requested)
return

settings.load_profile("ci" if os.getenv("CI") else "local")
Loading
Loading