From ee4ce4409217e1f99272cf573bff7251909e9376 Mon Sep 17 00:00:00 2001 From: iron-prog Date: Tue, 26 May 2026 12:05:07 +0530 Subject: [PATCH 1/6] chore: add mnemonic package dependency Signed-off-by: iron-prog --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 83b5161f3..e4c111460 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "pycryptodome>=3.18.0,<4", "eth-abi>=5.1.0,<6", "python-dotenv>=1.2.1,<3", + "mnemonic>=0.21,<1", ] classifiers = [ "Development Status :: 2 - Pre-Alpha", From 484d7e4b20839c4f7d1630df873fce4aae177912 Mon Sep 17 00:00:00 2001 From: iron-prog Date: Tue, 26 May 2026 12:05:13 +0530 Subject: [PATCH 2/6] feat(crypto): implement BIP-39 MnemonicPhrase utility Signed-off-by: iron-prog --- src/hiero_sdk_python/crypto/mnemonic.py | 45 +++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/hiero_sdk_python/crypto/mnemonic.py diff --git a/src/hiero_sdk_python/crypto/mnemonic.py b/src/hiero_sdk_python/crypto/mnemonic.py new file mode 100644 index 000000000..a2a0ecbcf --- /dev/null +++ b/src/hiero_sdk_python/crypto/mnemonic.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from mnemonic import Mnemonic + +from hiero_sdk_python.crypto.private_key import PrivateKey + + +@dataclass(frozen=True) +class MnemonicPhrase: + """BIP-39 mnemonic utilities for deterministic wallet workflows.""" + + phrase: str + + @classmethod + def generate(cls, strength: int = 256) -> MnemonicPhrase: + """Generate a valid English BIP-39 mnemonic phrase.""" + return cls(Mnemonic("english").generate(strength=strength)) + + @classmethod + def from_phrase(cls, phrase: str) -> MnemonicPhrase: + """Construct and validate a BIP-39 mnemonic phrase.""" + mnemonic = Mnemonic("english") + normalized = " ".join(phrase.strip().split()) + if not mnemonic.check(normalized): + raise ValueError("Invalid BIP-39 mnemonic phrase.") + return cls(normalized) + + def is_valid(self) -> bool: + """Check whether the phrase has valid BIP-39 checksum and words.""" + return Mnemonic("english").check(self.phrase) + + def to_seed(self, passphrase: str = "") -> bytes: + """Convert mnemonic phrase to BIP-39 seed bytes.""" + return Mnemonic.to_seed(self.phrase, passphrase=passphrase) + + def to_private_key_ed25519(self, passphrase: str = "") -> PrivateKey: + """Derive an Ed25519 private key from the first 32 bytes of BIP-39 seed. + + This provides deterministic key material suitable for local development + and reproducible testing workflows. + """ + seed = self.to_seed(passphrase=passphrase) + return PrivateKey.from_bytes_ed25519(seed[:32]) From 0f4b52d3abe303de9faa06bd1a14dac2ad5b528d Mon Sep 17 00:00:00 2001 From: iron-prog Date: Tue, 26 May 2026 12:05:24 +0530 Subject: [PATCH 3/6] feat(crypto): export MnemonicPhrase from SDK and crypto packages Signed-off-by: iron-prog --- src/hiero_sdk_python/__init__.py | 2 ++ src/hiero_sdk_python/crypto/__init__.py | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/src/hiero_sdk_python/__init__.py b/src/hiero_sdk_python/__init__.py index b890f3330..06e998f37 100644 --- a/src/hiero_sdk_python/__init__.py +++ b/src/hiero_sdk_python/__init__.py @@ -39,6 +39,7 @@ # Crypto from .crypto.evm_address import EvmAddress +from .crypto.mnemonic import MnemonicPhrase from .crypto.private_key import PrivateKey from .crypto.public_key import PublicKey @@ -172,6 +173,7 @@ "AccountRecordsQuery", # Crypto "PrivateKey", + "MnemonicPhrase", "PublicKey", "EvmAddress", # Tokens diff --git a/src/hiero_sdk_python/crypto/__init__.py b/src/hiero_sdk_python/crypto/__init__.py index e69de29bb..c2cd3f68b 100644 --- a/src/hiero_sdk_python/crypto/__init__.py +++ b/src/hiero_sdk_python/crypto/__init__.py @@ -0,0 +1,8 @@ +from hiero_sdk_python.crypto.key import Key +from hiero_sdk_python.crypto.key_list import KeyList +from hiero_sdk_python.crypto.mnemonic import MnemonicPhrase +from hiero_sdk_python.crypto.private_key import PrivateKey +from hiero_sdk_python.crypto.public_key import PublicKey + + +__all__ = ["Key", "KeyList", "MnemonicPhrase", "PrivateKey", "PublicKey"] From 911a55b15b9fddcbf618231c375c96024cf93c06 Mon Sep 17 00:00:00 2001 From: iron-prog Date: Tue, 26 May 2026 12:05:30 +0530 Subject: [PATCH 4/6] test(crypto): add unit tests for MnemonicPhrase validation and derivation Signed-off-by: iron-prog --- tests/unit/mnemonic_test.py | 71 +++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tests/unit/mnemonic_test.py diff --git a/tests/unit/mnemonic_test.py b/tests/unit/mnemonic_test.py new file mode 100644 index 000000000..cd93d4e04 --- /dev/null +++ b/tests/unit/mnemonic_test.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import pytest + +from hiero_sdk_python.crypto.mnemonic import MnemonicPhrase +from hiero_sdk_python.crypto.private_key import PrivateKey + + +def test_generate_is_valid_default_24_words(): + """Generated default mnemonic should be valid and contain 24 words.""" + phrase = MnemonicPhrase.generate() + assert phrase.is_valid() + assert len(phrase.phrase.split()) == 24 + + +def test_generate_with_128_strength_returns_12_words(): + """128-bit entropy should generate a valid 12-word mnemonic.""" + phrase = MnemonicPhrase.generate(strength=128) + + assert isinstance(phrase, MnemonicPhrase) + assert phrase.is_valid() + assert len(phrase.phrase.split()) == 12 + + +def test_from_phrase_normalizes_and_validates(): + """Input phrase should be whitespace-normalized and pass checksum validation.""" + phrase = MnemonicPhrase.from_phrase( + " abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about " + ) + + assert isinstance(phrase, MnemonicPhrase) + assert ( + phrase.phrase == "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + ) + + +def test_invalid_phrase_raises_value_error(): + """Invalid checksum mnemonic should raise ValueError during construction.""" + invalid = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon" + + with pytest.raises(ValueError, match="Invalid BIP-39 mnemonic phrase."): + MnemonicPhrase.from_phrase(invalid) + + +def test_seed_and_ed25519_key_derivation_is_deterministic(): + """Known mnemonic+passphrase should produce deterministic seed and Ed25519 key bytes.""" + phrase = MnemonicPhrase.from_phrase( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + ) + + seed = phrase.to_seed(passphrase="TREZOR") + + assert isinstance(seed, bytes) + assert seed.hex().startswith("c55257c360c07c72029aebc1b53c05ed") + + private_key = phrase.to_private_key_ed25519(passphrase="TREZOR") + + assert isinstance(private_key, PrivateKey) + assert private_key.to_string_ed25519_raw() == seed[:32].hex() + + +def test_to_seed_without_passphrase_is_deterministic(): + """Mnemonic seed derivation should work with default empty passphrase.""" + phrase = MnemonicPhrase.from_phrase( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + ) + + seed = phrase.to_seed() + + assert isinstance(seed, bytes) + assert seed.hex().startswith("5eb00bbddcf069084889a8ab91555681") From 158cb82af8c94b03ece51fefe100b74bcdff98a7 Mon Sep 17 00:00:00 2001 From: iron-prog Date: Sun, 31 May 2026 00:05:38 +0530 Subject: [PATCH 5/6] feat: add mirror node response models Signed-off-by: iron-prog --- .../mirror_node/mirror_node_client.py | 411 ++++++++++++++++++ 1 file changed, 411 insertions(+) create mode 100644 src/hiero_sdk_python/mirror_node/mirror_node_client.py diff --git a/src/hiero_sdk_python/mirror_node/mirror_node_client.py b/src/hiero_sdk_python/mirror_node/mirror_node_client.py new file mode 100644 index 000000000..9649e761d --- /dev/null +++ b/src/hiero_sdk_python/mirror_node/mirror_node_client.py @@ -0,0 +1,411 @@ +"""Typed, testable client for Hiero/Hedera mirror-node REST APIs.""" + +from __future__ import annotations + +from collections.abc import Iterable, Iterator, Mapping +from dataclasses import dataclass, field +from typing import Any, ClassVar, Literal +from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse + +import requests + +from hiero_sdk_python.account.account_id import AccountId +from hiero_sdk_python.hbar import Hbar +from hiero_sdk_python.hbar_unit import HbarUnit +from hiero_sdk_python.tokens.token_id import TokenId +from hiero_sdk_python.transaction.transaction_id import TransactionId + + +JsonObject = dict[str, Any] +MirrorNodeNetwork = Literal["mainnet", "testnet", "previewnet", "local-node"] + + +class MirrorNodeError(Exception): + """Raised when a mirror node request fails or returns malformed data.""" + + def __init__(self, message: str, *, status_code: int | None = None, response_body: str | None = None) -> None: + super().__init__(message) + self.status_code = status_code + self.response_body = response_body + + +@dataclass(frozen=True) +class MirrorNodeBalance: + """Native currency and token balances returned by a mirror node.""" + + hbars: Hbar + tokens: dict[TokenId, int] = field(default_factory=dict) + timestamp: str | None = None + + @classmethod + def from_json(cls, data: Mapping[str, Any]) -> MirrorNodeBalance: + """Build a balance from the mirror node account balance payload.""" + balance = data.get("balance", {}) + if not isinstance(balance, Mapping): + raise MirrorNodeError("Mirror node balance payload is missing the balance object.") + + tinybars = _require_int(balance, "balance") + tokens: dict[TokenId, int] = {} + token_entries = balance.get("tokens", []) + if token_entries is None: + token_entries = [] + if not isinstance(token_entries, list): + raise MirrorNodeError("Mirror node balance tokens field must be a list.") + + for entry in token_entries: + if not isinstance(entry, Mapping): + raise MirrorNodeError("Mirror node balance token entry must be an object.") + token_id = TokenId.from_string(_require_str(entry, "token_id")) + tokens[token_id] = _require_int(entry, "balance") + + return cls(hbars=Hbar.from_tinybars(tinybars), tokens=tokens, timestamp=_optional_str(balance, "timestamp")) + + +@dataclass(frozen=True) +class MirrorNodeAccount: + """Subset of account data most callers need from `/api/v1/accounts/{id}`.""" + + account_id: AccountId + balance: MirrorNodeBalance | None = None + alias: str | None = None + deleted: bool | None = None + evm_address: str | None = None + key: JsonObject | None = None + memo: str | None = None + receiver_sig_required: bool | None = None + staking_info: JsonObject | None = None + raw: JsonObject = field(default_factory=dict) + + @classmethod + def from_json(cls, data: Mapping[str, Any]) -> MirrorNodeAccount: + """Build an account model from a mirror node account payload.""" + account = _require_str(data, "account") + key = data.get("key") + staking_info = data.get("staking_info") + return cls( + account_id=AccountId.from_string(account), + balance=MirrorNodeBalance.from_json(data) if isinstance(data.get("balance"), Mapping) else None, + alias=_optional_str(data, "alias"), + deleted=_optional_bool(data, "deleted"), + evm_address=_optional_str(data, "evm_address"), + key=dict(key) if isinstance(key, Mapping) else None, + memo=_optional_str(data, "memo"), + receiver_sig_required=_optional_bool(data, "receiver_sig_required"), + staking_info=dict(staking_info) if isinstance(staking_info, Mapping) else None, + raw=dict(data), + ) + + +@dataclass(frozen=True) +class MirrorNodeToken: + """Token metadata returned by `/api/v1/tokens/{id}`.""" + + token_id: TokenId + name: str | None = None + symbol: str | None = None + decimals: int | None = None + total_supply: int | None = None + treasury_account_id: AccountId | None = None + token_type: str | None = None + deleted: bool | None = None + raw: JsonObject = field(default_factory=dict) + + @classmethod + def from_json(cls, data: Mapping[str, Any]) -> MirrorNodeToken: + """Build token metadata from a mirror node token payload.""" + token_id = TokenId.from_string(_require_str(data, "token_id")) + treasury = _optional_str(data, "treasury_account_id") + return cls( + token_id=token_id, + name=_optional_str(data, "name"), + symbol=_optional_str(data, "symbol"), + decimals=_optional_int(data, "decimals"), + total_supply=_optional_int(data, "total_supply"), + treasury_account_id=AccountId.from_string(treasury) if treasury is not None else None, + token_type=_optional_str(data, "type"), + deleted=_optional_bool(data, "deleted"), + raw=dict(data), + ) + + +@dataclass(frozen=True) +class MirrorNodeTransaction: + """Transaction summary returned by mirror node transaction endpoints.""" + + transaction_id: TransactionId + consensus_timestamp: str + result: str | None = None + name: str | None = None + charged_tx_fee: Hbar | None = None + max_fee: Hbar | None = None + memo_base64: str | None = None + node_account_id: AccountId | None = None + raw: JsonObject = field(default_factory=dict) + + @classmethod + def from_json(cls, data: Mapping[str, Any]) -> MirrorNodeTransaction: + """Build a transaction model from a mirror node transaction payload.""" + node_account_id = _optional_str(data, "node") + return cls( + transaction_id=TransactionId.from_string(_require_str(data, "transaction_id")), + consensus_timestamp=_require_str(data, "consensus_timestamp"), + result=_optional_str(data, "result"), + name=_optional_str(data, "name"), + charged_tx_fee=_optional_hbar(data, "charged_tx_fee"), + max_fee=_optional_hbar(data, "max_fee"), + memo_base64=_optional_str(data, "memo_base64"), + node_account_id=AccountId.from_string(node_account_id) if node_account_id is not None else None, + raw=dict(data), + ) + + +@dataclass(frozen=True) +class MirrorNodePage: + """One mirror node collection page plus its optional next link.""" + + items: list[Any] + next: str | None + raw: JsonObject = field(default_factory=dict) + + @property + def has_next(self) -> bool: + """Return True when the mirror node included a `links.next` URL.""" + return self.next is not None + + +class MirrorNodeClient: + """Small synchronous mirror-node REST client with typed response helpers. + + The client deliberately wraps only common read paths and keeps raw JSON on every + model so callers can access fields the SDK has not promoted to first-class + attributes yet. The underlying HTTP session is injectable to make the client + easy to verify with unit tests or custom transports. + """ + + DEFAULT_BASE_URLS: ClassVar[dict[str, str]] = { + "mainnet": "https://mainnet-public.mirrornode.hedera.com", + "testnet": "https://testnet.mirrornode.hedera.com", + "previewnet": "https://previewnet.mirrornode.hedera.com", + "local-node": "http://localhost:5551", + } + + def __init__( + self, + base_url: str | None = None, + *, + network: MirrorNodeNetwork | str | None = "testnet", + session: requests.Session | None = None, + timeout: float = 10.0, + headers: Mapping[str, str] | None = None, + ) -> None: + if base_url is None: + if network is None: + raise ValueError("Either base_url or network must be provided.") + base_url = self.DEFAULT_BASE_URLS.get(str(network), str(network)) + if not base_url.startswith(("http://", "https://")): + raise ValueError("Mirror node base_url must start with http:// or https://.") + if timeout <= 0: + raise ValueError("Mirror node timeout must be greater than zero.") + + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self.session = session if session is not None else requests.Session() + self.headers = dict(headers or {}) + + @classmethod + def for_network(cls, network: MirrorNodeNetwork | str, **kwargs: Any) -> MirrorNodeClient: + """Create a client for a named network or a custom base URL string.""" + return cls(network=network, **kwargs) + + def get_account(self, account_id: AccountId | str, *, transactions: bool | None = None) -> MirrorNodeAccount: + """Fetch account metadata by account ID, alias, or EVM address.""" + params = {"transactions": str(transactions).lower()} if transactions is not None else None + data = self.get_json(f"/api/v1/accounts/{_id_to_str(account_id)}", params=params) + return MirrorNodeAccount.from_json(data) + + def get_account_balance(self, account_id: AccountId | str) -> MirrorNodeBalance: + """Fetch only the native and token balances for an account.""" + return self.get_account(account_id).balance or MirrorNodeBalance(hbars=Hbar.ZERO) + + def get_token(self, token_id: TokenId | str) -> MirrorNodeToken: + """Fetch token metadata by token ID.""" + data = self.get_json(f"/api/v1/tokens/{_id_to_str(token_id)}") + return MirrorNodeToken.from_json(data) + + def get_transaction(self, transaction_id: TransactionId | str) -> MirrorNodeTransaction: + """Fetch the first transaction matching a transaction ID.""" + page = self.list_transactions(transaction_id=transaction_id, limit=1) + if not page.items: + raise MirrorNodeError(f"No transaction found for transaction_id {transaction_id}.") + return page.items[0] + + def list_transactions( + self, + *, + account_id: AccountId | str | None = None, + transaction_id: TransactionId | str | None = None, + result: str | None = None, + transaction_type: str | None = None, + order: Literal["asc", "desc"] | None = None, + limit: int | None = None, + ) -> MirrorNodePage: + """List transactions with common mirror node filters.""" + params: dict[str, Any] = {} + if account_id is not None: + params["account.id"] = _id_to_str(account_id) + if transaction_id is not None: + params["transactionid"] = str(transaction_id) + if result is not None: + params["result"] = result + if transaction_type is not None: + params["type"] = transaction_type + if order is not None: + params["order"] = order + if limit is not None: + _validate_limit(limit) + params["limit"] = limit + + data = self.get_json("/api/v1/transactions", params=params) + return self._page(data, "transactions", MirrorNodeTransaction.from_json) + + def iterate_transactions(self, *, max_pages: int | None = None, **filters: Any) -> Iterator[MirrorNodeTransaction]: + """Yield transactions across pages until the result set is exhausted.""" + page_count = 0 + page = self.list_transactions(**filters) + while True: + page_count += 1 + yield from page.items + if page.next is None or (max_pages is not None and page_count >= max_pages): + return + page = self.get_next_page(page, MirrorNodeTransaction.from_json) + + def get_next_page(self, page: MirrorNodePage, item_factory: Any) -> MirrorNodePage: + """Fetch a previously returned `links.next` page using the supplied item factory.""" + if page.next is None: + raise ValueError("Cannot fetch a next page when page.next is None.") + data = self.get_json(page.next) + collection_name = _detect_collection_name(data) + return self._page(data, collection_name, item_factory) + + def get_json(self, path_or_url: str, *, params: Mapping[str, Any] | None = None) -> JsonObject: + """Perform a GET request and return a JSON object, raising MirrorNodeError on failure.""" + url = self._build_url(path_or_url, params=params) + response = self.session.get(url, timeout=self.timeout, headers=self.headers) + if response.status_code >= 400: + raise MirrorNodeError( + f"Mirror node request failed with HTTP {response.status_code}.", + status_code=response.status_code, + response_body=response.text, + ) + try: + data = response.json() + except ValueError as exc: + raise MirrorNodeError("Mirror node response did not contain valid JSON.", response_body=response.text) from exc + if not isinstance(data, dict): + raise MirrorNodeError("Mirror node response JSON must be an object.") + return data + + def _build_url(self, path_or_url: str, *, params: Mapping[str, Any] | None = None) -> str: + parsed = urlparse(path_or_url) + url = path_or_url if parsed.scheme and parsed.netloc else urljoin(f"{self.base_url}/", path_or_url.lstrip("/")) + return _merge_query(url, params) + + @staticmethod + def _page(data: Mapping[str, Any], collection_name: str, item_factory: Any) -> MirrorNodePage: + collection = data.get(collection_name) + if not isinstance(collection, list): + raise MirrorNodeError(f"Mirror node response is missing the {collection_name} list.") + links = data.get("links", {}) + next_link = links.get("next") if isinstance(links, Mapping) else None + if next_link is not None and not isinstance(next_link, str): + raise MirrorNodeError("Mirror node links.next value must be a string or null.") + return MirrorNodePage(items=[item_factory(item) for item in collection], next=next_link, raw=dict(data)) + + +def _id_to_str(value: AccountId | TokenId | str) -> str: + if isinstance(value, str): + if not value: + raise ValueError("Identifier strings must not be empty.") + return value + return str(value) + + +def _merge_query(url: str, params: Mapping[str, Any] | None) -> str: + if not params: + return url + parsed = urlparse(url) + query_items = parse_qsl(parsed.query, keep_blank_values=True) + for key, value in params.items(): + if value is None: + continue + if isinstance(value, Iterable) and not isinstance(value, (str, bytes)): + query_items.extend((key, str(item)) for item in value) + else: + query_items.append((key, str(value))) + return urlunparse(parsed._replace(query=urlencode(query_items))) + + +def _detect_collection_name(data: Mapping[str, Any]) -> str: + collection_names = [key for key, value in data.items() if isinstance(value, list) and key != "links"] + if len(collection_names) != 1: + raise MirrorNodeError("Unable to detect collection name in mirror node page.") + return collection_names[0] + + +def _validate_limit(limit: int) -> None: + if isinstance(limit, bool) or not isinstance(limit, int): + raise TypeError("limit must be an integer.") + if limit < 1 or limit > 100: + raise ValueError("limit must be between 1 and 100.") + + +def _require_str(data: Mapping[str, Any], key: str) -> str: + value = data.get(key) + if not isinstance(value, str) or value == "": + raise MirrorNodeError(f"Mirror node payload is missing required string field '{key}'.") + return value + + +def _optional_str(data: Mapping[str, Any], key: str) -> str | None: + value = data.get(key) + if value is None: + return None + if not isinstance(value, str): + raise MirrorNodeError(f"Mirror node field '{key}' must be a string when present.") + return value + + +def _require_int(data: Mapping[str, Any], key: str) -> int: + value = data.get(key) + if isinstance(value, bool): + raise MirrorNodeError(f"Mirror node field '{key}' must be an integer.") + if isinstance(value, int): + return value + if isinstance(value, str) and value.isdecimal(): + return int(value) + raise MirrorNodeError(f"Mirror node payload is missing required integer field '{key}'.") + + +def _optional_int(data: Mapping[str, Any], key: str) -> int | None: + value = data.get(key) + if value is None: + return None + return _require_int(data, key) + + +def _optional_bool(data: Mapping[str, Any], key: str) -> bool | None: + value = data.get(key) + if value is None: + return None + if not isinstance(value, bool): + raise MirrorNodeError(f"Mirror node field '{key}' must be a boolean when present.") + return value + + +def _optional_hbar(data: Mapping[str, Any], key: str) -> Hbar | None: + value = data.get(key) + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, int): + raise MirrorNodeError(f"Mirror node field '{key}' must be an integer tinybar amount when present.") + return Hbar(value, HbarUnit.TINYBAR) From 53578bf789b1d28a79d65a18f24e04720996f92e Mon Sep 17 00:00:00 2001 From: iron-prog Date: Sun, 31 May 2026 00:06:08 +0530 Subject: [PATCH 6/6] test: add mirror node client coverage Signed-off-by: iron-prog --- src/hiero_sdk_python/__init__.py | 19 ++ tests/unit/mirror_node_client_test.py | 285 ++++++++++++++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 tests/unit/mirror_node_client_test.py diff --git a/src/hiero_sdk_python/__init__.py b/src/hiero_sdk_python/__init__.py index 06e998f37..c37c6627d 100644 --- a/src/hiero_sdk_python/__init__.py +++ b/src/hiero_sdk_python/__init__.py @@ -67,6 +67,17 @@ from .logger.log_level import LogLevel from .logger.logger import Logger +#Mirror Node +from .mirror_node.mirror_node_client import ( + MirrorNodeAccount, + MirrorNodeBalance, + MirrorNodeClient, + MirrorNodeError, + MirrorNodePage, + MirrorNodeToken, + MirrorNodeTransaction, +) + # Nodes from .nodes.node_create_transaction import NodeCreateTransaction from .nodes.node_delete_transaction import NodeDeleteTransaction @@ -279,6 +290,14 @@ "ScheduleInfo", "ScheduleSignTransaction", "ScheduleDeleteTransaction", + # Mirror node + "MirrorNodeAccount", + "MirrorNodeBalance", + "MirrorNodeClient", + "MirrorNodeError", + "MirrorNodePage", + "MirrorNodeToken", + "MirrorNodeTransaction", # Nodes "NodeCreateTransaction", "NodeUpdateTransaction", diff --git a/tests/unit/mirror_node_client_test.py b/tests/unit/mirror_node_client_test.py new file mode 100644 index 000000000..60a45a48a --- /dev/null +++ b/tests/unit/mirror_node_client_test.py @@ -0,0 +1,285 @@ +"""Unit tests for the mirror node REST client.""" + +from __future__ import annotations + +import pytest + +from hiero_sdk_python import MirrorNodeClient +from hiero_sdk_python.account.account_id import AccountId +from hiero_sdk_python.hbar import Hbar +from hiero_sdk_python.mirror_node import MirrorNodeError +from hiero_sdk_python.mirror_node.mirror_node_client import ( + MirrorNodeAccount, + MirrorNodeBalance, + MirrorNodePage, + MirrorNodeTransaction, +) +from hiero_sdk_python.tokens.token_id import TokenId + + +pytestmark = pytest.mark.unit + + +class FakeResponse: + def __init__(self, status_code=200, payload=None, text=""): + self.status_code = status_code + self.payload = payload if payload is not None else {} + self.text = text + + def json(self): + if isinstance(self.payload, Exception): + raise self.payload + return self.payload + + +class FakeSession: + def __init__(self, responses): + self.responses = list(responses) + self.calls = [] + + def get(self, url, *, timeout, headers): + self.calls.append({"url": url, "timeout": timeout, "headers": headers}) + if not self.responses: + raise AssertionError(f"Unexpected request to {url}") + return self.responses.pop(0) + + +def account_payload(): + return { + "account": "0.0.1001", + "alias": "HIEROALIAS", + "balance": { + "balance": 123456789, + "timestamp": "1710000000.000000001", + "tokens": [ + {"token_id": "0.0.2002", "balance": 50}, + {"token_id": "0.0.2003", "balance": "75"}, + ], + }, + "deleted": False, + "evm_address": "0x00000000000000000000000000000000000003e9", + "key": {"_type": "ED25519", "key": "abc"}, + "memo": "sample account", + "receiver_sig_required": True, + "staking_info": {"decline_reward": False}, + } + + +def transaction_payload(transaction_id="0.0.1001@1710000000.123456789", consensus="1710000001.000000001"): + return { + "transaction_id": transaction_id, + "consensus_timestamp": consensus, + "result": "SUCCESS", + "name": "CRYPTOTRANSFER", + "charged_tx_fee": 150000, + "max_fee": 200000000, + "memo_base64": "SGllcm8=", + "node": "0.0.3", + } + + +def test_for_network_uses_default_mirror_node_base_url(): + client = MirrorNodeClient.for_network("mainnet") + + assert client.base_url == "https://mainnet-public.mirrornode.hedera.com" + + +def test_custom_network_string_is_treated_as_base_url(): + client = MirrorNodeClient.for_network("http://mirror.local:5551") + + assert client.base_url == "http://mirror.local:5551" + + +def test_rejects_invalid_base_url_and_timeout(): + with pytest.raises(ValueError, match="base_url"): + MirrorNodeClient(base_url="mirror.local") + + with pytest.raises(ValueError, match="timeout"): + MirrorNodeClient(base_url="http://mirror.local", timeout=0) + + +def test_get_account_builds_request_and_maps_balances(): + session = FakeSession([FakeResponse(payload=account_payload())]) + client = MirrorNodeClient(base_url="http://mirror.local", session=session, timeout=2.5, headers={"x-api-key": "test"}) + + account = client.get_account(AccountId(0, 0, 1001), transactions=True) + + assert session.calls == [ + { + "url": "http://mirror.local/api/v1/accounts/0.0.1001?transactions=true", + "timeout": 2.5, + "headers": {"x-api-key": "test"}, + } + ] + assert account.account_id == AccountId(0, 0, 1001) + assert account.alias == "HIEROALIAS" + assert account.evm_address == "0x00000000000000000000000000000000000003e9" + assert account.balance.hbars == Hbar.from_tinybars(123456789) + assert account.balance.tokens[TokenId(0, 0, 2002)] == 50 + assert account.balance.tokens[TokenId(0, 0, 2003)] == 75 + assert account.receiver_sig_required is True + assert account.raw["memo"] == "sample account" + + +def test_get_account_balance_returns_zero_when_payload_has_no_balance(): + session = FakeSession([FakeResponse(payload={"account": "0.0.1001"})]) + client = MirrorNodeClient(base_url="http://mirror.local", session=session) + + balance = client.get_account_balance("0.0.1001") + + assert balance.hbars == Hbar.ZERO + assert balance.tokens == {} + + +def test_get_token_maps_common_metadata(): + session = FakeSession( + [ + FakeResponse( + payload={ + "token_id": "0.0.2002", + "name": "Example Token", + "symbol": "EXT", + "decimals": "8", + "total_supply": "1000000000", + "treasury_account_id": "0.0.1001", + "type": "FUNGIBLE_COMMON", + "deleted": False, + } + ) + ] + ) + client = MirrorNodeClient(base_url="http://mirror.local", session=session) + + token = client.get_token(TokenId(0, 0, 2002)) + + assert session.calls[0]["url"] == "http://mirror.local/api/v1/tokens/0.0.2002" + assert token.token_id == TokenId(0, 0, 2002) + assert token.name == "Example Token" + assert token.decimals == 8 + assert token.total_supply == 1000000000 + assert token.treasury_account_id == AccountId(0, 0, 1001) + + +def test_list_transactions_sends_common_filters_and_maps_page(): + session = FakeSession( + [ + FakeResponse( + payload={ + "transactions": [transaction_payload()], + "links": {"next": "/api/v1/transactions?limit=1&order=asc×tamp=gt:1"}, + } + ) + ] + ) + client = MirrorNodeClient(base_url="http://mirror.local", session=session) + + page = client.list_transactions( + account_id=AccountId(0, 0, 1001), + result="success", + transaction_type="cryptotransfer", + order="asc", + limit=1, + ) + + assert session.calls[0]["url"] == ( + "http://mirror.local/api/v1/transactions?account.id=0.0.1001&result=success" + "&type=cryptotransfer&order=asc&limit=1" + ) + assert page.has_next is True + assert page.next == "/api/v1/transactions?limit=1&order=asc×tamp=gt:1" + assert page.items[0].transaction_id.account_id == AccountId(0, 0, 1001) + assert page.items[0].charged_tx_fee == Hbar.from_tinybars(150000) + assert page.items[0].node_account_id == AccountId(0, 0, 3) + + +def test_get_transaction_uses_transaction_id_filter_and_returns_first_match(): + session = FakeSession([FakeResponse(payload={"transactions": [transaction_payload()], "links": {"next": None}})]) + client = MirrorNodeClient(base_url="http://mirror.local", session=session) + + transaction = client.get_transaction("0.0.1001@1710000000.123456789") + + assert session.calls[0]["url"] == ( + "http://mirror.local/api/v1/transactions?transactionid=0.0.1001%401710000000.123456789&limit=1" + ) + assert transaction.result == "SUCCESS" + + +def test_get_transaction_raises_when_no_transaction_matches(): + session = FakeSession([FakeResponse(payload={"transactions": [], "links": {"next": None}})]) + client = MirrorNodeClient(base_url="http://mirror.local", session=session) + + with pytest.raises(MirrorNodeError, match="No transaction found"): + client.get_transaction("0.0.1001@1710000000.123456789") + + +def test_iterate_transactions_follows_next_links(): + session = FakeSession( + [ + FakeResponse( + payload={ + "transactions": [transaction_payload(consensus="1.000000001")], + "links": {"next": "/api/v1/transactions?limit=1×tamp=gt:1.000000001"}, + } + ), + FakeResponse( + payload={ + "transactions": [transaction_payload(consensus="2.000000001")], + "links": {"next": None}, + } + ), + ] + ) + client = MirrorNodeClient(base_url="http://mirror.local", session=session) + + transactions = list(client.iterate_transactions(limit=1)) + + assert [transaction.consensus_timestamp for transaction in transactions] == ["1.000000001", "2.000000001"] + assert session.calls[1]["url"] == "http://mirror.local/api/v1/transactions?limit=1×tamp=gt:1.000000001" + + +def test_get_next_page_rejects_missing_next_link(): + client = MirrorNodeClient(base_url="http://mirror.local", session=FakeSession([])) + page = MirrorNodePage(items=[], next=None) + + with pytest.raises(ValueError, match="next page"): + client.get_next_page(page, MirrorNodeTransaction.from_json) + + +def test_limit_validation_is_strict(): + client = MirrorNodeClient(base_url="http://mirror.local", session=FakeSession([])) + + with pytest.raises(TypeError, match="limit"): + client.list_transactions(limit=True) + + with pytest.raises(ValueError, match="between 1 and 100"): + client.list_transactions(limit=101) + + +def test_http_errors_include_status_code_and_body(): + session = FakeSession([FakeResponse(status_code=404, payload={"message": "not found"}, text="not found")]) + client = MirrorNodeClient(base_url="http://mirror.local", session=session) + + with pytest.raises(MirrorNodeError) as exc_info: + client.get_account("0.0.404") + + assert exc_info.value.status_code == 404 + assert exc_info.value.response_body == "not found" + + +def test_invalid_json_and_non_object_json_raise_mirror_node_error(): + bad_json_client = MirrorNodeClient(base_url="http://mirror.local", session=FakeSession([FakeResponse(payload=ValueError())])) + list_json_client = MirrorNodeClient(base_url="http://mirror.local", session=FakeSession([FakeResponse(payload=[])])) + + with pytest.raises(MirrorNodeError, match="valid JSON"): + bad_json_client.get_json("/api/v1/accounts/0.0.1001") + + with pytest.raises(MirrorNodeError, match="must be an object"): + list_json_client.get_json("/api/v1/accounts/0.0.1001") + + +def test_malformed_payloads_raise_clear_errors(): + with pytest.raises(MirrorNodeError, match="required string field 'account'"): + MirrorNodeAccount.from_json({"balance": {"balance": 1}}) + + with pytest.raises(MirrorNodeError, match="tokens field must be a list"): + MirrorNodeBalance.from_json({"balance": {"balance": 1, "tokens": {}}})