Skip to content

Commit 0a71cd3

Browse files
feat: add basic fuzz testing (#1896)
Signed-off-by: Antonio Ceppellini <antonio.ceppellini@gmail.com>
1 parent d2b19dc commit 0a71cd3

17 files changed

Lines changed: 1275 additions & 4 deletions

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@ src/hiero_sdk_python/hapi
3131
# Pytest
3232
.pytest_cache
3333

34+
# Hypothesis state
35+
.hypothesis/
36+
3437
# Lock files
3538
uv.lock
3639
pdm.lock
37-
pubkey.asc
40+
pubkey.asc

CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.
2121
- Added TCK endpoint for the createAccount method
2222
- Renamed `delegate_contract_id.py` to `delegate_contract_id_test.py` (#2004)
2323
- Fix Flaky tests for `mock_server` by enforcing non-tls port and adding a mock_tls certificate
24+
- Implement basic fuzz testing [#1872](https://github.com/hiero-ledger/hiero-sdk-python/issues/1872)
25+
2426

2527
### Docs
2628
- Add Chocolatey as a prerequisite in the Windows setup guide (#1961)
@@ -121,8 +123,6 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.
121123
- docs: Clarify issues need to be assigned in template files. (#1884)
122124
- doc: Fix testnet link in README.md. (#1879)
123125

124-
125-
126126
### Tests
127127
- Format `tests/unit/endpoint_test.py` using black. (`#1792`)
128128
- Implement TCK JSON-RPC server with request handling and error management

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ dev = [
4848
"grpcio-tools>=1.76.0,<2",
4949
"pytest>=8.3.4,<10",
5050
"pytest-cov>=7.0.0,<8",
51+
"hypothesis>=6.137.2"
5152
]
5253

5354
lint = [

pytest.ini

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ pythonpath = src
44

55
markers =
66
integration: mark a test as an integration test.
7-
unit: mark a test as a unit test.
7+
unit: mark a test as a unit test.
8+
fuzz: mark a test as fuzz test.

tests/fuzz/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# with <3 from Anto

tests/fuzz/conftest.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Shared Hypothesis setup, fixtures, and compatibility re-exports for fuzz tests."""
2+
3+
from tests.fuzz.support.classes import (
4+
AccountIdAliasCase,
5+
ContractValueCase,
6+
EntityIdCase,
7+
HbarConstructorCase,
8+
HbarStringCase,
9+
InvalidContractValueCase,
10+
)
11+
from tests.fuzz.support.profiles import load_hypothesis_profile
12+
from tests.fuzz.support.registry import (
13+
FUZZ_STRATEGIES,
14+
fuzz_strategies_fixture,
15+
get_strategy,
16+
get_strategy_fixture,
17+
)
18+
19+
load_hypothesis_profile()
20+
21+
__all__ = [
22+
"AccountIdAliasCase",
23+
"ContractValueCase",
24+
"EntityIdCase",
25+
"FUZZ_STRATEGIES",
26+
"HbarConstructorCase",
27+
"HbarStringCase",
28+
"InvalidContractValueCase",
29+
"fuzz_strategies_fixture",
30+
"get_strategy",
31+
"get_strategy_fixture",
32+
]

tests/fuzz/support/__init__.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from tests.fuzz.support.classes import (
2+
AccountIdAliasCase,
3+
ContractValueCase,
4+
EntityIdCase,
5+
HbarConstructorCase,
6+
HbarStringCase,
7+
InvalidContractValueCase,
8+
)
9+
from tests.fuzz.support.registry import FUZZ_STRATEGIES, get_strategy
10+
11+
__all__ = [
12+
"AccountIdAliasCase",
13+
"ContractValueCase",
14+
"EntityIdCase",
15+
"FUZZ_STRATEGIES",
16+
"HbarConstructorCase",
17+
"HbarStringCase",
18+
"InvalidContractValueCase",
19+
"get_strategy",
20+
]

tests/fuzz/support/classes.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from dataclasses import dataclass
2+
from decimal import Decimal
3+
from typing import Any
4+
5+
from hiero_sdk_python import HbarUnit
6+
7+
8+
@dataclass(frozen=True)
9+
class EntityIdCase:
10+
"""A parsed entity ID expectation for public string parsers."""
11+
12+
text: str
13+
shard: int
14+
realm: int
15+
value: int
16+
checksum: str | None = None
17+
18+
19+
@dataclass(frozen=True)
20+
class AccountIdAliasCase:
21+
"""A valid account alias or EVM-address input."""
22+
23+
text: str
24+
shard: int
25+
realm: int
26+
alias_hex: str | None = None
27+
evm_hex: str | None = None
28+
29+
30+
@dataclass(frozen=True)
31+
class HbarStringCase:
32+
"""A valid public Hbar string and its exact tinybar value."""
33+
34+
text: str
35+
tinybars: int
36+
37+
38+
@dataclass(frozen=True)
39+
class HbarConstructorCase:
40+
"""A valid Hbar constructor input and its exact tinybar value."""
41+
42+
amount: int | float | Decimal
43+
unit: HbarUnit
44+
tinybars: int
45+
46+
47+
@dataclass(frozen=True)
48+
class ContractValueCase:
49+
"""A valid contract parameter case routed to an explicit public add_* method."""
50+
51+
method_name: str
52+
value: Any
53+
54+
55+
@dataclass(frozen=True)
56+
class InvalidContractValueCase:
57+
"""An invalid contract parameter case with a precise expected exception."""
58+
59+
method_name: str
60+
value: Any
61+
expected_exception: type[BaseException]

tests/fuzz/support/helpers.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from decimal import Decimal
2+
3+
from hypothesis import strategies as st
4+
from hypothesis.strategies import SearchStrategy
5+
6+
from hiero_sdk_python import AccountId, HbarUnit, PrivateKey, TransactionId, TransferTransaction
7+
8+
from tests.fuzz.support.classes import HbarConstructorCase, HbarStringCase
9+
10+
11+
def sized_hex(byte_length: int) -> SearchStrategy[str]:
12+
"""Return a fixed-length hex string strategy."""
13+
return st.binary(min_size=byte_length, max_size=byte_length).map(bytes.hex)
14+
15+
16+
def with_optional_0x(hex_strategy: SearchStrategy[str]) -> SearchStrategy[str]:
17+
"""Allow a hex string with or without the `0x` prefix."""
18+
return st.one_of(hex_strategy, hex_strategy.map(lambda value: f"0x{value}"))
19+
20+
21+
def decimal_string(value: Decimal) -> str:
22+
"""Format a Decimal without trailing zeros."""
23+
text = format(value, "f")
24+
if "." in text:
25+
text = text.rstrip("0").rstrip(".")
26+
return text or "0"
27+
28+
29+
def hbar_string_case(unit: HbarUnit, tinybars: int) -> HbarStringCase:
30+
"""Build a valid Hbar string case from an exact tinybar amount."""
31+
amount = Decimal(tinybars) / Decimal(unit.tinybar)
32+
if unit == HbarUnit.HBAR:
33+
return HbarStringCase(text=decimal_string(amount), tinybars=tinybars)
34+
return HbarStringCase(text=f"{decimal_string(amount)} {unit.symbol}", tinybars=tinybars)
35+
36+
37+
def hbar_constructor_case(unit: HbarUnit, tinybars: int) -> HbarConstructorCase:
38+
"""Build a valid Hbar constructor case from an exact tinybar amount."""
39+
if unit == HbarUnit.TINYBAR:
40+
amount: int | float | Decimal = tinybars
41+
else:
42+
amount = Decimal(tinybars) / Decimal(unit.tinybar)
43+
return HbarConstructorCase(amount=amount, unit=unit, tinybars=tinybars)
44+
45+
46+
def build_valid_transaction_bytes() -> tuple[bytes, bytes]:
47+
"""Build one valid unsigned and one valid signed transaction payload."""
48+
operator_id = AccountId.from_string("0.0.1234")
49+
node_id = AccountId.from_string("0.0.3")
50+
receiver_id = AccountId.from_string("0.0.5678")
51+
52+
tx = (
53+
TransferTransaction()
54+
.add_hbar_transfer(operator_id, -100_000_000)
55+
.add_hbar_transfer(receiver_id, 100_000_000)
56+
)
57+
tx.transaction_id = TransactionId.generate(operator_id)
58+
tx.node_account_id = node_id
59+
tx.freeze()
60+
unsigned_bytes = tx.to_bytes()
61+
62+
signed_tx = TransferTransaction.from_bytes(unsigned_bytes)
63+
signed_tx.sign(PrivateKey.from_string_ed25519("02" * 32))
64+
signed_bytes = signed_tx.to_bytes()
65+
return unsigned_bytes, signed_bytes

tests/fuzz/support/profiles.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import os
2+
3+
from hypothesis import HealthCheck, settings
4+
5+
6+
def load_hypothesis_profile() -> None:
7+
"""Register and load the active Hypothesis profile."""
8+
settings.register_profile(
9+
"ci",
10+
settings(
11+
derandomize=True,
12+
max_examples=300,
13+
deadline=750,
14+
suppress_health_check=[HealthCheck.too_slow],
15+
),
16+
)
17+
settings.register_profile(
18+
"local",
19+
settings(
20+
derandomize=False,
21+
max_examples=1000,
22+
deadline=None,
23+
),
24+
)
25+
26+
requested = os.getenv("HYPOTHESIS_PROFILE")
27+
if requested:
28+
settings.load_profile(requested)
29+
return
30+
31+
settings.load_profile("ci" if os.getenv("CI") else "local")

0 commit comments

Comments
 (0)