diff --git a/examples/nodes/registered_node_lifecycle.py b/examples/nodes/registered_node_lifecycle.py new file mode 100644 index 000000000..6a9eb55e5 --- /dev/null +++ b/examples/nodes/registered_node_lifecycle.py @@ -0,0 +1,208 @@ +""" +Demonstrates the full lifecycle of a registered node on the Hedera network. + +This example shows how to: +1. Create a registered node with BlockNodeServiceEndpoint and multiple endpoint APIs +2. Query the registered node via the mirror node REST API +3. Update the registered node's description and service endpoints +4. Associate / disassociate the registered node with a consensus node +5. Delete the registered node + +NOTE: This is a privileged transaction. Regular developers do not have the required +permissions to manage registered nodes on testnet or mainnet as this operation +requires special authorization. + +This example is provided to demonstrate the API for educational purposes or for use +in private network deployments where you have the necessary administrative privileges. +""" + +import sys +import time + +from dotenv import load_dotenv + +from hiero_sdk_python import AccountId, Client, Network, PrivateKey +from hiero_sdk_python.address_book.block_node_api import BlockNodeApi +from hiero_sdk_python.address_book.block_node_service_endpoint import BlockNodeServiceEndpoint +from hiero_sdk_python.address_book.registered_node_address_book_query import RegisteredNodeAddressBookQuery +from hiero_sdk_python.exceptions import PrecheckError +from hiero_sdk_python.nodes.node_update_transaction import NodeUpdateTransaction +from hiero_sdk_python.nodes.registered_node_create_transaction import RegisteredNodeCreateTransaction +from hiero_sdk_python.nodes.registered_node_delete_transaction import RegisteredNodeDeleteTransaction +from hiero_sdk_python.nodes.registered_node_update_transaction import RegisteredNodeUpdateTransaction +from hiero_sdk_python.response_code import ResponseCode + + +def setup_client(): + """Initialize and set up the client with operator account.""" + load_dotenv() + network = Network(network="solo") + client = Client(network) + print(f"Connecting to Hedera {network} network!") + + # Account 0.0.2 is a special administrative account with + # elevated privileges for network management operations. + # The private key is intentionally public for local development. + # Note: This setup only works on solo network and will not work on testnet/mainnet. + original_operator_key = PrivateKey.from_string_der( + "302e020100300506032b65700422042091132178e72057a1d7528025956fe39b0b847f200ab59b2fdd367017f3087137" + ) + client.set_operator(AccountId(0, 0, 2), original_operator_key) + + return client + + +def registered_node_lifecycle(): + """Demonstrates create, query, update, associate, disassociate, and delete of a registered node.""" + client = setup_client() + + # Generate an admin key for the registered node + admin_key = PrivateKey.generate_ed25519() + + # ── Step 1: Create a registered node ──────────────────────────── + print("\n--- Step 1: Creating registered node ---") + + block_endpoint = BlockNodeServiceEndpoint( + ip_address=bytes([127, 0, 0, 1]), + port=443, + requires_tls=True, + endpoint_apis=[BlockNodeApi.STATUS, BlockNodeApi.SUBSCRIBE_STREAM], + ) + + try: + receipt = ( + RegisteredNodeCreateTransaction() + .set_admin_key(admin_key.public_key()) + .set_description("My Block Node") + .set_service_endpoints([block_endpoint]) + .freeze_with(client) + .sign(admin_key) + .execute(client) + ) + except (PrecheckError, ValueError) as e: + print(f"Registered node creation failed: {e}") + sys.exit(1) + + if receipt.status != ResponseCode.SUCCESS: + print(f"Registered node creation failed: {ResponseCode(receipt.status).name}") + sys.exit(1) + + registered_node_id = receipt.registered_node_id + print(f"Registered node created with ID: {registered_node_id}") + + # ── Step 2: Query the registered node via mirror node ─────────── + print("\n--- Step 2: Querying registered node via mirror node ---") + + # Wait for mirror node ingestion + time.sleep(5) + + try: + address_book = RegisteredNodeAddressBookQuery().set_registered_node_id(registered_node_id).execute(client) + + if len(address_book) > 0: + node = address_book[0] + print(f"Fetched registered node: {node}") + print(f" Description: {node.description}") + print(f" Endpoints: {len(node.service_endpoints)}") + else: + print("No registered nodes returned (mirror may still be ingesting)") + except RuntimeError as e: + print(f"Query failed (mirror node may not support this endpoint): {e}") + + # ── Step 3: Update the registered node ────────────────────────── + print("\n--- Step 3: Updating registered node ---") + + update_endpoint = BlockNodeServiceEndpoint( + domain_name="block-node.example.com", + port=443, + requires_tls=True, + endpoint_apis=[BlockNodeApi.STATUS], + ) + + try: + receipt = ( + RegisteredNodeUpdateTransaction() + .set_registered_node_id(registered_node_id) + .set_description("My Updated Block Node") + .set_service_endpoints([block_endpoint, update_endpoint]) + .freeze_with(client) + .sign(admin_key) + .execute(client) + ) + except (PrecheckError, ValueError) as e: + print(f"Registered node update failed: {e}") + sys.exit(1) + + if receipt.status != ResponseCode.SUCCESS: + print(f"Registered node update failed: {ResponseCode(receipt.status).name}") + sys.exit(1) + + print("Registered node updated successfully") + + # ── Step 4: Associate registered node with consensus node ─────── + print("\n--- Step 4: Associating registered node with consensus node 0 ---") + + try: + receipt = ( + NodeUpdateTransaction() + .set_node_id(0) + .add_associated_registered_node(registered_node_id) + .freeze_with(client) + .execute(client) + ) + except (PrecheckError, ValueError) as e: + print(f"Association failed: {e}") + sys.exit(1) + + if receipt.status != ResponseCode.SUCCESS: + print(f"Association failed: {ResponseCode(receipt.status).name}") + sys.exit(1) + + print(f"Registered node {registered_node_id} associated with consensus node 0") + + # ── Step 5: Disassociate registered node from consensus node ──── + print("\n--- Step 5: Disassociating registered node from consensus node 0 ---") + + try: + receipt = ( + NodeUpdateTransaction() + .set_node_id(0) + .clear_associated_registered_nodes() + .freeze_with(client) + .execute(client) + ) + except (PrecheckError, ValueError) as e: + print(f"Disassociation failed: {e}") + sys.exit(1) + + if receipt.status != ResponseCode.SUCCESS: + print(f"Disassociation failed: {ResponseCode(receipt.status).name}") + sys.exit(1) + + print(f"Registered node {registered_node_id} disassociated from consensus node 0") + + # ── Step 6: Delete the registered node ────────────────────────── + print("\n--- Step 6: Deleting registered node ---") + + try: + receipt = ( + RegisteredNodeDeleteTransaction() + .set_registered_node_id(registered_node_id) + .freeze_with(client) + .sign(admin_key) + .execute(client) + ) + except (PrecheckError, ValueError) as e: + print(f"Registered node deletion failed: {e}") + sys.exit(1) + + if receipt.status != ResponseCode.SUCCESS: + print(f"Registered node deletion failed: {ResponseCode(receipt.status).name}") + sys.exit(1) + + print("Registered node deleted successfully") + print("\n--- Registered node lifecycle complete! ---") + + +if __name__ == "__main__": + registered_node_lifecycle() diff --git a/generate_proto.py b/generate_proto.py index e38dde2eb..24dced5cf 100644 --- a/generate_proto.py +++ b/generate_proto.py @@ -34,7 +34,7 @@ from urllib.parse import urlparse -VERSION = "v0.72.1" +VERSION = "v0.73.0" SOURCES = [ { "name": "hedera-protobufs", diff --git a/src/hiero_sdk_python/__init__.py b/src/hiero_sdk_python/__init__.py index 5036d9944..3b51b4f6f 100644 --- a/src/hiero_sdk_python/__init__.py +++ b/src/hiero_sdk_python/__init__.py @@ -9,8 +9,17 @@ from .account.account_update_transaction import AccountUpdateTransaction # Address book +from .address_book.block_node_api import BlockNodeApi +from .address_book.block_node_service_endpoint import BlockNodeServiceEndpoint from .address_book.endpoint import Endpoint +from .address_book.general_service_endpoint import GeneralServiceEndpoint +from .address_book.mirror_node_service_endpoint import MirrorNodeServiceEndpoint from .address_book.node_address import NodeAddress +from .address_book.registered_node import RegisteredNode +from .address_book.registered_node_address_book import RegisteredNodeAddressBook +from .address_book.registered_node_address_book_query import RegisteredNodeAddressBookQuery +from .address_book.registered_service_endpoint import RegisteredServiceEndpoint +from .address_book.rpc_relay_service_endpoint import RpcRelayServiceEndpoint # Client and Network from .client.client import Client @@ -77,6 +86,9 @@ from .nodes.node_create_transaction import NodeCreateTransaction from .nodes.node_delete_transaction import NodeDeleteTransaction from .nodes.node_update_transaction import NodeUpdateTransaction +from .nodes.registered_node_create_transaction import RegisteredNodeCreateTransaction +from .nodes.registered_node_delete_transaction import RegisteredNodeDeleteTransaction +from .nodes.registered_node_update_transaction import RegisteredNodeUpdateTransaction # PRNG from .prng_transaction import PrngTransaction @@ -245,8 +257,17 @@ "TokenInfoQuery", "AccountInfoQuery", # Address book + "BlockNodeApi", + "BlockNodeServiceEndpoint", "Endpoint", + "GeneralServiceEndpoint", + "MirrorNodeServiceEndpoint", "NodeAddress", + "RegisteredNode", + "RegisteredNodeAddressBook", + "RegisteredNodeAddressBookQuery", + "RegisteredServiceEndpoint", + "RpcRelayServiceEndpoint", # Logger "Logger", "LogLevel", @@ -296,6 +317,9 @@ "NodeCreateTransaction", "NodeUpdateTransaction", "NodeDeleteTransaction", + "RegisteredNodeCreateTransaction", + "RegisteredNodeUpdateTransaction", + "RegisteredNodeDeleteTransaction", # PRNG "PrngTransaction", # Custom Fees diff --git a/src/hiero_sdk_python/address_book/block_node_api.py b/src/hiero_sdk_python/address_book/block_node_api.py new file mode 100644 index 000000000..50fd48cbe --- /dev/null +++ b/src/hiero_sdk_python/address_book/block_node_api.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from enum import IntEnum + + +class BlockNodeApi(IntEnum): + """Maps to the BlockNodeApi enum defined inside RegisteredServiceEndpoint.BlockNodeEndpoint.""" + + OTHER = 0 + STATUS = 1 + PUBLISH = 2 + SUBSCRIBE_STREAM = 3 + STATE_PROOF = 4 diff --git a/src/hiero_sdk_python/address_book/block_node_service_endpoint.py b/src/hiero_sdk_python/address_book/block_node_service_endpoint.py new file mode 100644 index 000000000..6cfad7381 --- /dev/null +++ b/src/hiero_sdk_python/address_book/block_node_service_endpoint.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from hiero_sdk_python.address_book.block_node_api import BlockNodeApi +from hiero_sdk_python.address_book.registered_service_endpoint import RegisteredServiceEndpoint +from hiero_sdk_python.hapi.services.registered_service_endpoint_pb2 import ( + RegisteredServiceEndpoint as RegisteredServiceEndpointProto, +) + + +class BlockNodeServiceEndpoint(RegisteredServiceEndpoint): + """A registered service endpoint for a block node.""" + + def __init__( + self, + ip_address: bytes | None = None, + domain_name: str | None = None, + port: int = 0, + requires_tls: bool = False, + endpoint_apis: list[BlockNodeApi] | None = None, + ) -> None: + super().__init__(ip_address=ip_address, domain_name=domain_name, port=port, requires_tls=requires_tls) + self.endpoint_apis: list[BlockNodeApi] = [BlockNodeApi(api) for api in (endpoint_apis or [])] + + def set_endpoint_apis(self, endpoint_apis: list[BlockNodeApi]) -> BlockNodeServiceEndpoint: + """Set the list of block node endpoint APIs.""" + self.endpoint_apis = [BlockNodeApi(api) for api in endpoint_apis] + return self + + def _set_endpoint_type(self, proto: RegisteredServiceEndpointProto) -> None: + block_node = proto.block_node + for api in self.endpoint_apis: + block_node.endpoint_api.append(api.value) + + @classmethod + def _from_proto_inner( + cls, + proto: RegisteredServiceEndpointProto, + ip_address: bytes | None, + domain_name: str | None, + port: int, + requires_tls: bool, + ) -> BlockNodeServiceEndpoint: + apis = [BlockNodeApi(v) for v in proto.block_node.endpoint_api] + return cls( + ip_address=ip_address, + domain_name=domain_name, + port=port, + requires_tls=requires_tls, + endpoint_apis=apis, + ) + + @classmethod + def _from_dict_inner(cls, type_data: dict, **base_kwargs) -> BlockNodeServiceEndpoint: + """Build from the ``block_node`` sub-dict of a mirror-node JSON endpoint.""" + raw_apis = type_data.get("endpoint_apis") or [] + apis = [BlockNodeApi[a.upper()] if isinstance(a, str) else BlockNodeApi(a) for a in raw_apis] + return cls(endpoint_apis=apis, **base_kwargs) diff --git a/src/hiero_sdk_python/address_book/general_service_endpoint.py b/src/hiero_sdk_python/address_book/general_service_endpoint.py new file mode 100644 index 000000000..3a89b8d0a --- /dev/null +++ b/src/hiero_sdk_python/address_book/general_service_endpoint.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from hiero_sdk_python.address_book.registered_service_endpoint import RegisteredServiceEndpoint +from hiero_sdk_python.hapi.services.registered_service_endpoint_pb2 import ( + RegisteredServiceEndpoint as RegisteredServiceEndpointProto, +) + + +class GeneralServiceEndpoint(RegisteredServiceEndpoint): + """A registered service endpoint for a general service.""" + + def __init__( + self, + ip_address: bytes | None = None, + domain_name: str | None = None, + port: int = 0, + requires_tls: bool = False, + description: str | None = None, + ) -> None: + super().__init__(ip_address=ip_address, domain_name=domain_name, port=port, requires_tls=requires_tls) + if description is not None and len(description.encode("utf-8")) > 100: + raise ValueError("description must be 100 UTF-8 bytes or fewer") + self.description: str | None = description + + def set_description(self, description: str | None) -> GeneralServiceEndpoint: + """Set the description for this general service endpoint.""" + if description is not None and len(description.encode("utf-8")) > 100: + raise ValueError("description must be 100 UTF-8 bytes or fewer") + self.description = description + return self + + def _set_endpoint_type(self, proto: RegisteredServiceEndpointProto) -> None: + if self.description is not None: + proto.general_service.description = self.description + else: + proto.general_service.SetInParent() + + @classmethod + def _from_proto_inner( + cls, + proto: RegisteredServiceEndpointProto, + ip_address: bytes | None, + domain_name: str | None, + port: int, + requires_tls: bool, + ) -> GeneralServiceEndpoint: + desc = proto.general_service.description or None + return cls( + ip_address=ip_address, + domain_name=domain_name, + port=port, + requires_tls=requires_tls, + description=desc, + ) + + @classmethod + def _from_dict_inner(cls, type_data: dict, **base_kwargs) -> GeneralServiceEndpoint: + """Build from the ``general_service`` sub-dict of a mirror-node JSON endpoint.""" + desc = type_data.get("description") or None + return cls(description=desc, **base_kwargs) diff --git a/src/hiero_sdk_python/address_book/mirror_node_service_endpoint.py b/src/hiero_sdk_python/address_book/mirror_node_service_endpoint.py new file mode 100644 index 000000000..db1e1df89 --- /dev/null +++ b/src/hiero_sdk_python/address_book/mirror_node_service_endpoint.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from hiero_sdk_python.address_book.registered_service_endpoint import RegisteredServiceEndpoint +from hiero_sdk_python.hapi.services.registered_service_endpoint_pb2 import ( + RegisteredServiceEndpoint as RegisteredServiceEndpointProto, +) + + +class MirrorNodeServiceEndpoint(RegisteredServiceEndpoint): + """A registered service endpoint for a mirror node.""" + + def _set_endpoint_type(self, proto: RegisteredServiceEndpointProto) -> None: + # Accessing the field initializes the oneof to mirror_node + proto.mirror_node.SetInParent() + + @classmethod + def _from_proto_inner( + cls, + _proto: RegisteredServiceEndpointProto, + ip_address: bytes | None, + domain_name: str | None, + port: int, + requires_tls: bool, + ) -> MirrorNodeServiceEndpoint: + return cls( + ip_address=ip_address, + domain_name=domain_name, + port=port, + requires_tls=requires_tls, + ) + + @classmethod + def _from_dict_inner(cls, _type_data: dict, **base_kwargs) -> MirrorNodeServiceEndpoint: + """Build from the ``mirror_node`` sub-dict of a mirror-node JSON endpoint.""" + return cls(**base_kwargs) diff --git a/src/hiero_sdk_python/address_book/registered_node.py b/src/hiero_sdk_python/address_book/registered_node.py new file mode 100644 index 000000000..fa1a2efdb --- /dev/null +++ b/src/hiero_sdk_python/address_book/registered_node.py @@ -0,0 +1,89 @@ +"""Read-side model for a registered node.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from hiero_sdk_python.address_book.registered_service_endpoint import RegisteredServiceEndpoint +from hiero_sdk_python.crypto.public_key import PublicKey +from hiero_sdk_python.hapi.services.state.addressbook.registered_node_pb2 import ( + RegisteredNode as RegisteredNodeProto, +) + + +@dataclass(frozen=True) +class RegisteredNode: + """Immutable model representing a registered node from network/mirror state.""" + + registered_node_id: int + admin_key: PublicKey | None = None + description: str | None = None + service_endpoints: tuple[RegisteredServiceEndpoint, ...] = field(default_factory=tuple) + + def __post_init__(self): + if not isinstance(self.registered_node_id, int) or isinstance(self.registered_node_id, bool): + raise ValueError("registered_node_id must be a positive integer") + if self.registered_node_id <= 0: + raise ValueError("registered_node_id must be a positive integer") + # Ensure service_endpoints is a tuple + object.__setattr__(self, "service_endpoints", tuple(self.service_endpoints)) + + @classmethod + def _from_proto(cls, proto: RegisteredNodeProto) -> RegisteredNode: + """Create a RegisteredNode from a protobuf RegisteredNode state message.""" + admin_key: PublicKey | None = None + if proto.HasField("admin_key"): + admin_key = PublicKey._from_proto(proto.admin_key) + + description: str | None = proto.description or None + + endpoints = tuple(RegisteredServiceEndpoint._from_proto(ep) for ep in proto.service_endpoint) + + return cls( + registered_node_id=proto.registered_node_id, + admin_key=admin_key, + description=description, + service_endpoints=endpoints, + ) + + def _to_proto(self) -> RegisteredNodeProto: + """Convert this RegisteredNode to a protobuf RegisteredNode.""" + proto = RegisteredNodeProto( + registered_node_id=self.registered_node_id, + ) + if self.admin_key is not None: + proto.admin_key.CopyFrom(self.admin_key._to_proto()) + if self.description is not None: + proto.description = self.description + for ep in self.service_endpoints: + proto.service_endpoint.append(ep._to_proto()) + return proto + + def __repr__(self) -> str: + return f"RegisteredNode(registered_node_id={self.registered_node_id}, description={self.description!r})" + + @classmethod + def _from_dict(cls, data: dict) -> RegisteredNode: + """Create a RegisteredNode from a mirror-node JSON dict.""" + admin_key: PublicKey | None = None + admin_key_data = data.get("admin_key") + if admin_key_data and isinstance(admin_key_data, dict): + key_type = (admin_key_data.get("_type") or "").upper() + key_hex = admin_key_data.get("key", "") + if key_type == "ED25519": + admin_key = PublicKey.from_string_ed25519(key_hex) + elif key_type == "ECDSA_SECP256K1": + admin_key = PublicKey.from_string_ecdsa(key_hex) + elif key_hex: + admin_key = PublicKey.from_string(key_hex) + + description: str | None = data.get("description") or None + + endpoints = tuple(RegisteredServiceEndpoint._from_dict(ep) for ep in data.get("service_endpoints", [])) + + return cls( + registered_node_id=data["registered_node_id"], + admin_key=admin_key, + description=description, + service_endpoints=endpoints, + ) diff --git a/src/hiero_sdk_python/address_book/registered_node_address_book.py b/src/hiero_sdk_python/address_book/registered_node_address_book.py new file mode 100644 index 000000000..c031f0d59 --- /dev/null +++ b/src/hiero_sdk_python/address_book/registered_node_address_book.py @@ -0,0 +1,35 @@ +"""Read-side container for a collection of registered nodes.""" + +from __future__ import annotations + +from collections.abc import Iterator +from dataclasses import dataclass, field + +from hiero_sdk_python.address_book.registered_node import RegisteredNode + + +@dataclass(frozen=True) +class RegisteredNodeAddressBook: + """Immutable container of :class:`RegisteredNode` entries. + + No protobuf ``RegisteredNodeAddressBook`` message exists in the current + protobufs, so this is a pure SDK-side convenience wrapper that mirrors + the role of the legacy ``NodeAddressBook`` model. + """ + + nodes: tuple[RegisteredNode, ...] = field(default_factory=tuple) + + def __post_init__(self): + object.__setattr__(self, "nodes", tuple(self.nodes)) + + def __len__(self) -> int: + return len(self.nodes) + + def __iter__(self) -> Iterator[RegisteredNode]: + return iter(self.nodes) + + def __getitem__(self, index: int) -> RegisteredNode: + return self.nodes[index] + + def __repr__(self) -> str: + return f"RegisteredNodeAddressBook(nodes={len(self.nodes)})" diff --git a/src/hiero_sdk_python/address_book/registered_node_address_book_query.py b/src/hiero_sdk_python/address_book/registered_node_address_book_query.py new file mode 100644 index 000000000..37a8e33e6 --- /dev/null +++ b/src/hiero_sdk_python/address_book/registered_node_address_book_query.py @@ -0,0 +1,200 @@ +"""Query for fetching the registered-node address book from the mirror node.""" + +from __future__ import annotations + +import logging +import time +from urllib.parse import urlencode + +import requests + +from hiero_sdk_python.address_book.registered_node import RegisteredNode +from hiero_sdk_python.address_book.registered_node_address_book import ( + RegisteredNodeAddressBook, +) + + +logger = logging.getLogger(__name__) + +_DEFAULT_LIMIT = 25 +_RETRYABLE_HTTP_STATUSES = {408, 429, 500, 502, 503, 504} + + +class RegisteredNodeAddressBookQuery: + """Query to retrieve the registered-node address book from the mirror node REST API. + + The query hits ``GET /api/v1/network/registered-nodes`` and follows + pagination links automatically. Transient HTTP errors are retried with + exponential back-off. + """ + + def __init__(self) -> None: + self._max_registered_node_count: int | None = None + self._registered_node_id: int | None = None + self._limit: int = _DEFAULT_LIMIT + self._max_attempts: int = 10 + self._max_backoff: float = 8.0 + + # -- setters (fluent) --------------------------------------------------- + + def set_max_registered_node_count(self, count: int) -> RegisteredNodeAddressBookQuery: + """Limit the total number of registered nodes returned. + + Args: + count: Maximum number of nodes. Must be positive. + + Returns: + This query instance for method chaining. + """ + if not isinstance(count, int) or isinstance(count, bool) or count <= 0: + raise ValueError("count must be a positive integer") + self._max_registered_node_count = count + return self + + def set_registered_node_id(self, node_id: int) -> RegisteredNodeAddressBookQuery: + """Filter the query to a single registered node. + + Args: + node_id: The registered node identifier. + + Returns: + This query instance for method chaining. + """ + if not isinstance(node_id, int) or isinstance(node_id, bool) or node_id < 0: + raise ValueError("node_id must be a non-negative integer") + self._registered_node_id = node_id + return self + + def set_limit(self, limit: int) -> RegisteredNodeAddressBookQuery: + """Set the page size for each REST API request. + + Args: + limit: Maximum nodes per page. Must be positive. + + Returns: + This query instance for method chaining. + """ + if not isinstance(limit, int) or isinstance(limit, bool) or limit <= 0: + raise ValueError("limit must be a positive integer") + self._limit = limit + return self + + def set_max_attempts(self, attempts: int) -> RegisteredNodeAddressBookQuery: + """Set the maximum number of retry attempts per page fetch. + + Args: + attempts: Must be >= 1. + + Returns: + This query instance for method chaining. + """ + if not isinstance(attempts, int) or isinstance(attempts, bool) or attempts < 1: + raise ValueError("attempts must be a positive integer") + self._max_attempts = attempts + return self + + def set_max_backoff(self, seconds: float) -> RegisteredNodeAddressBookQuery: + """Set the maximum exponential back-off delay in seconds. + + Args: + seconds: Must be > 0. + + Returns: + This query instance for method chaining. + """ + if not isinstance(seconds, (int, float)) or seconds <= 0: + raise ValueError("seconds must be a positive number") + self._max_backoff = float(seconds) + return self + + # -- execution ---------------------------------------------------------- + + def execute(self, client) -> RegisteredNodeAddressBook: + """Execute the query against the mirror node. + + Args: + client: A :class:`Client` instance with a configured network. + + Returns: + RegisteredNodeAddressBook containing the fetched nodes. + + Raises: + RuntimeError: If a page fetch fails after all retry attempts. + """ + if client is None: + raise ValueError("client must not be None") + + base_url = self._build_base_url(client) + path = self._build_initial_path() + nodes: list[RegisteredNode] = [] + + while path is not None: + data = self._fetch_page(base_url + path) + + for entry in data.get("registered_nodes", []): + nodes.append(RegisteredNode._from_dict(entry)) + if self._max_registered_node_count and len(nodes) >= self._max_registered_node_count: + return RegisteredNodeAddressBook(nodes=nodes) + + path = self._next_page_path(data) + + return RegisteredNodeAddressBook(nodes=nodes) + + # -- internals ---------------------------------------------------------- + + def _build_base_url(self, client) -> str: + """Derive the mirror-node base URL (without ``/api/v1``).""" + rest_url = client.network.get_mirror_rest_url() # e.g. http://localhost:5551/api/v1 + + # For localhost / solo, use port 8084 for registered-node calls + if "localhost:5551" in rest_url or "127.0.0.1:5551" in rest_url: + rest_url = rest_url.replace(":5551", ":8084") + + # Strip /api/v1 suffix so we can append full paths from pagination links + return rest_url.removesuffix("/api/v1") + + def _build_initial_path(self) -> str: + params: dict[str, int] = {"limit": self._limit} + if self._registered_node_id is not None: + params["registerednode.id"] = self._registered_node_id + return f"/api/v1/network/registered-nodes?{urlencode(params)}" + + def _fetch_page(self, url: str) -> dict: + """GET a single page with retry and exponential back-off.""" + last_exc: Exception | None = None + + for attempt in range(self._max_attempts): + try: + resp = requests.get(url, timeout=30) + + if resp.status_code == 200: + return resp.json() + + if resp.status_code not in _RETRYABLE_HTTP_STATUSES or attempt == self._max_attempts - 1: + raise RuntimeError(f"Mirror node error: HTTP {resp.status_code} — {resp.text}") + + last_exc = RuntimeError(f"HTTP {resp.status_code}") + + except (requests.Timeout, requests.ConnectionError) as exc: + if attempt == self._max_attempts - 1: + raise RuntimeError(f"Failed to fetch registered nodes after {self._max_attempts} attempts") from exc + last_exc = exc + + delay = min(0.5 * (2**attempt), self._max_backoff) + logger.warning( + "Error fetching registered nodes (attempt %d/%d). Retrying in %.1fs: %s", + attempt + 1, + self._max_attempts, + delay, + last_exc, + ) + time.sleep(delay) + + raise RuntimeError(f"Failed to fetch registered nodes after {self._max_attempts} attempts") + + @staticmethod + def _next_page_path(data: dict) -> str | None: + links = data.get("links") + if links and isinstance(links, dict): + return links.get("next") + return None diff --git a/src/hiero_sdk_python/address_book/registered_service_endpoint.py b/src/hiero_sdk_python/address_book/registered_service_endpoint.py new file mode 100644 index 000000000..2bb5f718e --- /dev/null +++ b/src/hiero_sdk_python/address_book/registered_service_endpoint.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from hiero_sdk_python.hapi.services.registered_service_endpoint_pb2 import ( + RegisteredServiceEndpoint as RegisteredServiceEndpointProto, +) + + +class RegisteredServiceEndpoint: + """Base SDK model for a registered service endpoint.""" + + def __init__( + self, + ip_address: bytes | None = None, + domain_name: str | None = None, + port: int = 0, + requires_tls: bool = False, + ) -> None: + self._validate_address_init(ip_address, domain_name) + self._validate_port(port) + if not isinstance(requires_tls, bool): + raise ValueError("requires_tls must be a bool") + + self.ip_address: bytes | None = ip_address + self.domain_name: str | None = domain_name + self.port: int = port + self.requires_tls: bool = requires_tls + + def set_ip_address(self, ip_address: bytes) -> RegisteredServiceEndpoint: + """Set the IP address, clearing any existing domain name.""" + self._validate_ip_address(ip_address) + self.ip_address = ip_address + self.domain_name = None + return self + + def set_domain_name(self, domain_name: str) -> RegisteredServiceEndpoint: + """Set the domain name, clearing any existing IP address.""" + self._validate_domain_name(domain_name) + self.domain_name = domain_name + self.ip_address = None + return self + + def set_port(self, port: int) -> RegisteredServiceEndpoint: + """Set the port number.""" + self._validate_port(port) + self.port = port + return self + + def set_requires_tls(self, requires_tls: bool) -> RegisteredServiceEndpoint: + """Set whether TLS is required.""" + if not isinstance(requires_tls, bool): + raise ValueError("requires_tls must be a bool") + self.requires_tls = requires_tls + return self + + @staticmethod + def _validate_address_init(ip_address: bytes | None, domain_name: str | None) -> None: + """Validate address arguments at construction time. + + Allows both to be ``None`` so that the builder/setter pattern works:: + + endpoint = BlockNodeServiceEndpoint().set_domain_name("example.com") + + Rejects providing *both* at the same time. + """ + if ip_address is not None and domain_name is not None: + raise ValueError("Exactly one of ip_address or domain_name must be provided, not both") + if ip_address is not None: + RegisteredServiceEndpoint._validate_ip_address(ip_address) + if domain_name is not None: + RegisteredServiceEndpoint._validate_domain_name(domain_name) + + @staticmethod + def _validate_ip_address(ip_address: bytes) -> None: + if not isinstance(ip_address, bytes) or len(ip_address) not in (4, 16): + raise ValueError("ip_address must be 4 bytes (IPv4) or 16 bytes (IPv6)") + + @staticmethod + def _validate_domain_name(domain_name: str) -> None: + if not isinstance(domain_name, str): + raise ValueError("domain_name must be a string") + try: + domain_name.encode("ascii") + except UnicodeEncodeError as err: + raise ValueError("domain_name must be ASCII") from err + if len(domain_name) > 250: + raise ValueError("domain_name must be 250 characters or fewer") + + @staticmethod + def _validate_port(port: int) -> None: + if not isinstance(port, int) or isinstance(port, bool): + raise ValueError("port must be an int") + if port < 0 or port > 65535: + raise ValueError("port must be in range 0 to 65535") + + def _to_proto(self) -> RegisteredServiceEndpointProto: + if self.ip_address is None and self.domain_name is None: + raise ValueError("Exactly one of ip_address or domain_name must be set before serialization") + proto = RegisteredServiceEndpointProto( + port=self.port, + requires_tls=self.requires_tls, + ) + if self.ip_address is not None: + proto.ip_address = self.ip_address + else: + proto.domain_name = self.domain_name + + self._set_endpoint_type(proto) + return proto + + def _set_endpoint_type(self, proto: RegisteredServiceEndpointProto) -> None: + """Subclasses override to set the endpoint_type oneof field.""" + + @classmethod + def _from_proto(cls, proto: RegisteredServiceEndpointProto) -> RegisteredServiceEndpoint: + """Deserialize from protobuf, returning the appropriate subclass.""" + from hiero_sdk_python.address_book.block_node_service_endpoint import BlockNodeServiceEndpoint + from hiero_sdk_python.address_book.general_service_endpoint import GeneralServiceEndpoint + from hiero_sdk_python.address_book.mirror_node_service_endpoint import MirrorNodeServiceEndpoint + from hiero_sdk_python.address_book.rpc_relay_service_endpoint import RpcRelayServiceEndpoint + + ip_address: bytes | None = None + domain_name: str | None = None + address_field = proto.WhichOneof("address") + if address_field == "ip_address": + ip_address = proto.ip_address + elif address_field == "domain_name": + domain_name = proto.domain_name + + port = proto.port + requires_tls = proto.requires_tls + + endpoint_type = proto.WhichOneof("endpoint_type") + if endpoint_type == "block_node": + return BlockNodeServiceEndpoint._from_proto_inner(proto, ip_address, domain_name, port, requires_tls) + if endpoint_type == "mirror_node": + return MirrorNodeServiceEndpoint._from_proto_inner(proto, ip_address, domain_name, port, requires_tls) + if endpoint_type == "rpc_relay": + return RpcRelayServiceEndpoint._from_proto_inner(proto, ip_address, domain_name, port, requires_tls) + if endpoint_type == "general_service": + return GeneralServiceEndpoint._from_proto_inner(proto, ip_address, domain_name, port, requires_tls) + raise ValueError(f"Unknown endpoint_type: {endpoint_type!r}") + + @classmethod + def _from_dict(cls, data: dict) -> RegisteredServiceEndpoint: + """Deserialize from a mirror-node JSON dict, returning the appropriate subclass.""" + from hiero_sdk_python.address_book.block_node_service_endpoint import BlockNodeServiceEndpoint + from hiero_sdk_python.address_book.general_service_endpoint import GeneralServiceEndpoint + from hiero_sdk_python.address_book.mirror_node_service_endpoint import MirrorNodeServiceEndpoint + from hiero_sdk_python.address_book.rpc_relay_service_endpoint import RpcRelayServiceEndpoint + + ip_address: bytes | None = None + domain_name: str | None = data.get("domain_name") or None + + raw_ip = data.get("ip_address") + if raw_ip: + import ipaddress as _ipaddress + + ip_address = _ipaddress.ip_address(raw_ip).packed + + port = data.get("port", 0) + requires_tls = data.get("requires_tls", False) + + ep_type = (data.get("type") or "").upper() + + base_kwargs = dict(ip_address=ip_address, domain_name=domain_name, port=port, requires_tls=requires_tls) + + if ep_type == "BLOCK_NODE": + return BlockNodeServiceEndpoint._from_dict_inner(data.get("block_node", {}), **base_kwargs) + if ep_type == "MIRROR_NODE": + return MirrorNodeServiceEndpoint._from_dict_inner(data.get("mirror_node", {}), **base_kwargs) + if ep_type == "RPC_RELAY": + return RpcRelayServiceEndpoint._from_dict_inner(data.get("rpc_relay", {}), **base_kwargs) + if ep_type == "GENERAL_SERVICE": + return GeneralServiceEndpoint._from_dict_inner(data.get("general_service", {}), **base_kwargs) + raise ValueError(f"Unknown endpoint type from mirror node: {ep_type!r}") diff --git a/src/hiero_sdk_python/address_book/rpc_relay_service_endpoint.py b/src/hiero_sdk_python/address_book/rpc_relay_service_endpoint.py new file mode 100644 index 000000000..4b66a73ca --- /dev/null +++ b/src/hiero_sdk_python/address_book/rpc_relay_service_endpoint.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from hiero_sdk_python.address_book.registered_service_endpoint import RegisteredServiceEndpoint +from hiero_sdk_python.hapi.services.registered_service_endpoint_pb2 import ( + RegisteredServiceEndpoint as RegisteredServiceEndpointProto, +) + + +class RpcRelayServiceEndpoint(RegisteredServiceEndpoint): + """A registered service endpoint for an RPC relay.""" + + def _set_endpoint_type(self, proto: RegisteredServiceEndpointProto) -> None: + proto.rpc_relay.SetInParent() + + @classmethod + def _from_proto_inner( + cls, + _proto: RegisteredServiceEndpointProto, + ip_address: bytes | None, + domain_name: str | None, + port: int, + requires_tls: bool, + ) -> RpcRelayServiceEndpoint: + return cls( + ip_address=ip_address, + domain_name=domain_name, + port=port, + requires_tls=requires_tls, + ) + + @classmethod + def _from_dict_inner(cls, _type_data: dict, **base_kwargs) -> RpcRelayServiceEndpoint: + """Build from the ``rpc_relay`` sub-dict of a mirror-node JSON endpoint.""" + return cls(**base_kwargs) diff --git a/src/hiero_sdk_python/nodes/node_create_transaction.py b/src/hiero_sdk_python/nodes/node_create_transaction.py index 0431a1f8f..472d08c25 100644 --- a/src/hiero_sdk_python/nodes/node_create_transaction.py +++ b/src/hiero_sdk_python/nodes/node_create_transaction.py @@ -43,6 +43,7 @@ class NodeCreateParams: admin_key: PublicKey | None = None decline_reward: bool | None = None grpc_web_proxy_endpoint: Endpoint | None = None + associated_registered_nodes: list[int] = field(default_factory=list) class NodeCreateTransaction(Transaction): @@ -75,6 +76,7 @@ def __init__(self, node_create_params: NodeCreateParams | None = None): self.admin_key: PublicKey | None = node_create_params.admin_key self.decline_reward: bool | None = node_create_params.decline_reward self.grpc_web_proxy_endpoint: Endpoint | None = node_create_params.grpc_web_proxy_endpoint + self.associated_registered_nodes: list[int] = node_create_params.associated_registered_nodes def set_account_id(self, account_id: AccountId | None) -> NodeCreateTransaction: """ @@ -211,6 +213,34 @@ def set_grpc_web_proxy_endpoint(self, grpc_web_proxy_endpoint: Endpoint | None) self.grpc_web_proxy_endpoint = grpc_web_proxy_endpoint return self + def set_associated_registered_nodes(self, registered_node_ids: list[int]) -> NodeCreateTransaction: + """ + Sets the associated registered node IDs for this node create transaction. + + Args: + registered_node_ids (list[int]): The registered node IDs to associate. + + Returns: + NodeCreateTransaction: This transaction instance. + """ + self._require_not_frozen() + self.associated_registered_nodes = registered_node_ids + return self + + def add_associated_registered_node(self, registered_node_id: int) -> NodeCreateTransaction: + """ + Adds an associated registered node ID to this node create transaction. + + Args: + registered_node_id (int): The registered node ID to add. + + Returns: + NodeCreateTransaction: This transaction instance. + """ + self._require_not_frozen() + self.associated_registered_nodes.append(registered_node_id) + return self + def _build_proto_body(self) -> NodeCreateTransactionBody: """ Returns the protobuf body for the node create transaction. @@ -228,6 +258,7 @@ def _build_proto_body(self) -> NodeCreateTransactionBody: admin_key=self.admin_key._to_proto() if self.admin_key else None, decline_reward=self.decline_reward, grpc_proxy_endpoint=(self.grpc_web_proxy_endpoint._to_proto() if self.grpc_web_proxy_endpoint else None), + associated_registered_node=self.associated_registered_nodes, ) def build_transaction_body(self) -> TransactionBody: @@ -268,3 +299,29 @@ def _get_method(self, channel: _Channel) -> _Method: _Method: An object containing the transaction function to create a node. """ return _Method(transaction_func=channel.address_book.createNode, query_func=None) + + @classmethod + def _from_protobuf(cls, transaction_body, body_bytes: bytes, sig_map): + transaction = super()._from_protobuf(transaction_body, body_bytes, sig_map) + + if transaction_body.HasField("nodeCreate"): + pb = transaction_body.nodeCreate + if pb.HasField("account_id"): + transaction.account_id = AccountId._from_proto(pb.account_id) + if pb.description: + transaction.description = pb.description + transaction.gossip_endpoints = [Endpoint._from_proto(ep) for ep in pb.gossip_endpoint] + transaction.service_endpoints = [Endpoint._from_proto(ep) for ep in pb.service_endpoint] + if pb.gossip_ca_certificate: + transaction.gossip_ca_certificate = pb.gossip_ca_certificate + if pb.grpc_certificate_hash: + transaction.grpc_certificate_hash = pb.grpc_certificate_hash + if pb.HasField("admin_key"): + transaction.admin_key = PublicKey._from_proto(pb.admin_key) + if pb.decline_reward: + transaction.decline_reward = pb.decline_reward + if pb.HasField("grpc_proxy_endpoint"): + transaction.grpc_web_proxy_endpoint = Endpoint._from_proto(pb.grpc_proxy_endpoint) + transaction.associated_registered_nodes = list(pb.associated_registered_node) + + return transaction diff --git a/src/hiero_sdk_python/nodes/node_update_transaction.py b/src/hiero_sdk_python/nodes/node_update_transaction.py index da9e169ae..92d2d81ac 100644 --- a/src/hiero_sdk_python/nodes/node_update_transaction.py +++ b/src/hiero_sdk_python/nodes/node_update_transaction.py @@ -12,7 +12,10 @@ from hiero_sdk_python.channels import _Channel from hiero_sdk_python.crypto.public_key import PublicKey from hiero_sdk_python.executable import _Method -from hiero_sdk_python.hapi.services.node_update_pb2 import NodeUpdateTransactionBody +from hiero_sdk_python.hapi.services.node_update_pb2 import ( + AssociatedRegisteredNodeList, + NodeUpdateTransactionBody, +) from hiero_sdk_python.hapi.services.schedulable_transaction_body_pb2 import ( SchedulableTransactionBody, ) @@ -48,6 +51,7 @@ class NodeUpdateParams: admin_key: PublicKey | None = None decline_reward: bool | None = None grpc_web_proxy_endpoint: Endpoint | None = None + associated_registered_nodes: list[int] | None = None class NodeUpdateTransaction(Transaction): @@ -81,6 +85,7 @@ def __init__(self, node_update_params: NodeUpdateParams | None = None): self.admin_key: PublicKey | None = node_update_params.admin_key self.decline_reward: bool | None = node_update_params.decline_reward self.grpc_web_proxy_endpoint: Endpoint | None = node_update_params.grpc_web_proxy_endpoint + self.associated_registered_nodes: list[int] | None = node_update_params.associated_registered_nodes def set_node_id(self, node_id: int | None) -> NodeUpdateTransaction: """ @@ -232,6 +237,48 @@ def set_grpc_web_proxy_endpoint(self, grpc_web_proxy_endpoint: Endpoint | None) self.grpc_web_proxy_endpoint = grpc_web_proxy_endpoint return self + def set_associated_registered_nodes(self, registered_node_ids: list[int]) -> NodeUpdateTransaction: + """ + Sets the associated registered node IDs for this node update transaction. + + Args: + registered_node_ids (list[int]): The registered node IDs to associate. + + Returns: + NodeUpdateTransaction: This transaction instance. + """ + self._require_not_frozen() + self.associated_registered_nodes = registered_node_ids + return self + + def add_associated_registered_node(self, registered_node_id: int) -> NodeUpdateTransaction: + """ + Adds an associated registered node ID to this node update transaction. + + Args: + registered_node_id (int): The registered node ID to add. + + Returns: + NodeUpdateTransaction: This transaction instance. + """ + self._require_not_frozen() + if self.associated_registered_nodes is None: + self.associated_registered_nodes = [] + self.associated_registered_nodes.append(registered_node_id) + return self + + def clear_associated_registered_nodes(self) -> NodeUpdateTransaction: + """ + Clears all associated registered node IDs, which will remove existing + associations when the transaction is submitted. + + Returns: + NodeUpdateTransaction: This transaction instance. + """ + self._require_not_frozen() + self.associated_registered_nodes = [] + return self + def _convert_to_proto(self, obj: Any | None) -> Any: """Convert object to proto if it exists, otherwise return None.""" return obj._to_proto() if obj else None @@ -247,7 +294,7 @@ def _build_proto_body(self) -> NodeUpdateTransactionBody: Returns: NodeUpdateTransactionBody: The protobuf body for this transaction. """ - return NodeUpdateTransactionBody( + body = NodeUpdateTransactionBody( node_id=self.node_id, account_id=self._convert_to_proto(self.account_id), description=(StringValue(value=self.description) if self.description is not None else None), @@ -264,6 +311,15 @@ def _build_proto_body(self) -> NodeUpdateTransactionBody: grpc_proxy_endpoint=self._convert_to_proto(self.grpc_web_proxy_endpoint), ) + if self.associated_registered_nodes is not None: + body.associated_registered_node_list.CopyFrom( + AssociatedRegisteredNodeList( + associated_registered_node=self.associated_registered_nodes, + ) + ) + + return body + def build_transaction_body(self) -> TransactionBody: """ Builds the transaction body for node update transaction. @@ -302,3 +358,33 @@ def _get_method(self, channel: _Channel) -> _Method: _Method: An object containing the transaction function to update a node. """ return _Method(transaction_func=channel.address_book.updateNode, query_func=None) + + @classmethod + def _from_protobuf(cls, transaction_body, body_bytes: bytes, sig_map): + transaction = super()._from_protobuf(transaction_body, body_bytes, sig_map) + + if transaction_body.HasField("nodeUpdate"): + pb = transaction_body.nodeUpdate + transaction.node_id = pb.node_id + if pb.HasField("account_id"): + transaction.account_id = AccountId._from_proto(pb.account_id) + if pb.HasField("description"): + transaction.description = pb.description.value + transaction.gossip_endpoints = [Endpoint._from_proto(ep) for ep in pb.gossip_endpoint] + transaction.service_endpoints = [Endpoint._from_proto(ep) for ep in pb.service_endpoint] + if pb.HasField("gossip_ca_certificate"): + transaction.gossip_ca_certificate = pb.gossip_ca_certificate.value + if pb.HasField("grpc_certificate_hash"): + transaction.grpc_certificate_hash = pb.grpc_certificate_hash.value + if pb.HasField("admin_key"): + transaction.admin_key = PublicKey._from_proto(pb.admin_key) + if pb.HasField("decline_reward"): + transaction.decline_reward = pb.decline_reward.value + if pb.HasField("grpc_proxy_endpoint"): + transaction.grpc_web_proxy_endpoint = Endpoint._from_proto(pb.grpc_proxy_endpoint) + if pb.HasField("associated_registered_node_list"): + transaction.associated_registered_nodes = list( + pb.associated_registered_node_list.associated_registered_node + ) + + return transaction diff --git a/src/hiero_sdk_python/nodes/registered_node_create_transaction.py b/src/hiero_sdk_python/nodes/registered_node_create_transaction.py new file mode 100644 index 000000000..c11e329f2 --- /dev/null +++ b/src/hiero_sdk_python/nodes/registered_node_create_transaction.py @@ -0,0 +1,114 @@ +"""RegisteredNodeCreateTransaction class.""" + +from __future__ import annotations + +from hiero_sdk_python.address_book.registered_service_endpoint import RegisteredServiceEndpoint +from hiero_sdk_python.channels import _Channel +from hiero_sdk_python.crypto.key import Key +from hiero_sdk_python.executable import _Method +from hiero_sdk_python.hapi.services.registered_node_create_pb2 import RegisteredNodeCreateTransactionBody +from hiero_sdk_python.hapi.services.schedulable_transaction_body_pb2 import ( + SchedulableTransactionBody, +) +from hiero_sdk_python.hapi.services.transaction_pb2 import TransactionBody +from hiero_sdk_python.transaction.transaction import Transaction + + +class RegisteredNodeCreateTransaction(Transaction): + """Creates a new registered node on the network.""" + + def __init__(self): + super().__init__() + self.admin_key: Key | None = None + self.description: str | None = None + self.service_endpoints: list[RegisteredServiceEndpoint] = [] + + def set_admin_key(self, admin_key: Key | None) -> RegisteredNodeCreateTransaction: + """Sets the admin key for the registered node. + + Args: + admin_key: The admin key, or None to clear. + + Returns: + RegisteredNodeCreateTransaction: This transaction instance. + """ + self._require_not_frozen() + self.admin_key = admin_key + return self + + def set_description(self, description: str | None) -> RegisteredNodeCreateTransaction: + """Sets the description for the registered node. + + Args: + description: The description, or None to clear. + + Returns: + RegisteredNodeCreateTransaction: This transaction instance. + """ + self._require_not_frozen() + self.description = description + return self + + def set_service_endpoints( + self, service_endpoints: list[RegisteredServiceEndpoint] + ) -> RegisteredNodeCreateTransaction: + """Sets the service endpoints for the registered node. + + Args: + service_endpoints: The list of service endpoints. + + Returns: + RegisteredNodeCreateTransaction: This transaction instance. + """ + self._require_not_frozen() + self.service_endpoints = service_endpoints + return self + + def add_service_endpoint(self, endpoint: RegisteredServiceEndpoint) -> RegisteredNodeCreateTransaction: + """Adds a service endpoint to the registered node. + + Args: + endpoint: The service endpoint to add. + + Returns: + RegisteredNodeCreateTransaction: This transaction instance. + """ + self._require_not_frozen() + self.service_endpoints.append(endpoint) + return self + + def _build_proto_body(self) -> RegisteredNodeCreateTransactionBody: + return RegisteredNodeCreateTransactionBody( + admin_key=self.admin_key.to_proto_key() if self.admin_key else None, + description=self.description or "", + service_endpoint=[ep._to_proto() for ep in self.service_endpoints], + ) + + def build_transaction_body(self) -> TransactionBody: + body = self._build_proto_body() + transaction_body = self.build_base_transaction_body() + transaction_body.registeredNodeCreate.CopyFrom(body) + return transaction_body + + def build_scheduled_body(self) -> SchedulableTransactionBody: + body = self._build_proto_body() + scheduled_body = self.build_base_scheduled_body() + scheduled_body.registeredNodeCreate.CopyFrom(body) + return scheduled_body + + def _get_method(self, channel: _Channel) -> _Method: + return _Method(transaction_func=channel.address_book.createRegisteredNode, query_func=None) + + @classmethod + def _from_protobuf(cls, transaction_body, body_bytes: bytes, sig_map): + transaction = super()._from_protobuf(transaction_body, body_bytes, sig_map) + + if transaction_body.HasField("registeredNodeCreate"): + pb = transaction_body.registeredNodeCreate + if pb.HasField("admin_key"): + transaction.admin_key = Key.from_proto_key(pb.admin_key) + if pb.description: + transaction.description = pb.description + transaction.service_endpoints = [RegisteredServiceEndpoint._from_proto(ep) for ep in pb.service_endpoint] + + return transaction diff --git a/src/hiero_sdk_python/nodes/registered_node_delete_transaction.py b/src/hiero_sdk_python/nodes/registered_node_delete_transaction.py new file mode 100644 index 000000000..3ce37b124 --- /dev/null +++ b/src/hiero_sdk_python/nodes/registered_node_delete_transaction.py @@ -0,0 +1,73 @@ +"""RegisteredNodeDeleteTransaction class.""" + +from __future__ import annotations + +from hiero_sdk_python.channels import _Channel +from hiero_sdk_python.executable import _Method +from hiero_sdk_python.hapi.services.registered_node_delete_pb2 import RegisteredNodeDeleteTransactionBody +from hiero_sdk_python.hapi.services.schedulable_transaction_body_pb2 import ( + SchedulableTransactionBody, +) +from hiero_sdk_python.hapi.services.transaction_pb2 import TransactionBody +from hiero_sdk_python.transaction.transaction import Transaction + + +class RegisteredNodeDeleteTransaction(Transaction): + """Deletes an existing registered node from the network.""" + + def __init__(self, registered_node_id: int | None = None): + super().__init__() + self.registered_node_id: int | None = registered_node_id + + def set_registered_node_id(self, registered_node_id: int | None) -> RegisteredNodeDeleteTransaction: + """Sets the ID of the registered node to delete. + + Args: + registered_node_id: The registered node ID, or None to clear. + + Returns: + RegisteredNodeDeleteTransaction: This transaction instance. + """ + self._require_not_frozen() + self.registered_node_id = registered_node_id + return self + + def _build_proto_body(self) -> RegisteredNodeDeleteTransactionBody: + if self.registered_node_id is None: + raise ValueError("Missing required registered_node_id") + if ( + not isinstance(self.registered_node_id, int) + or isinstance(self.registered_node_id, bool) + or self.registered_node_id <= 0 + ): + raise ValueError("registered_node_id must be a positive integer") + + return RegisteredNodeDeleteTransactionBody( + registered_node_id=self.registered_node_id, + ) + + def build_transaction_body(self) -> TransactionBody: + body = self._build_proto_body() + transaction_body = self.build_base_transaction_body() + transaction_body.registeredNodeDelete.CopyFrom(body) + return transaction_body + + def build_scheduled_body(self) -> SchedulableTransactionBody: + body = self._build_proto_body() + scheduled_body = self.build_base_scheduled_body() + scheduled_body.registeredNodeDelete.CopyFrom(body) + return scheduled_body + + def _get_method(self, channel: _Channel) -> _Method: + return _Method(transaction_func=channel.address_book.deleteRegisteredNode, query_func=None) + + @classmethod + def _from_protobuf(cls, transaction_body, body_bytes: bytes, sig_map): + transaction = super()._from_protobuf(transaction_body, body_bytes, sig_map) + + # Extract registered node fields if the body contains a registeredNodeDelete message + if transaction_body.HasField("registeredNodeDelete"): + pb = transaction_body.registeredNodeDelete + transaction.registered_node_id = pb.registered_node_id + + return transaction diff --git a/src/hiero_sdk_python/nodes/registered_node_update_transaction.py b/src/hiero_sdk_python/nodes/registered_node_update_transaction.py new file mode 100644 index 000000000..37520a219 --- /dev/null +++ b/src/hiero_sdk_python/nodes/registered_node_update_transaction.py @@ -0,0 +1,140 @@ +"""RegisteredNodeUpdateTransaction class.""" + +from __future__ import annotations + +from google.protobuf.wrappers_pb2 import StringValue + +from hiero_sdk_python.address_book.registered_service_endpoint import RegisteredServiceEndpoint +from hiero_sdk_python.channels import _Channel +from hiero_sdk_python.crypto.key import Key +from hiero_sdk_python.executable import _Method +from hiero_sdk_python.hapi.services.registered_node_update_pb2 import RegisteredNodeUpdateTransactionBody +from hiero_sdk_python.hapi.services.schedulable_transaction_body_pb2 import ( + SchedulableTransactionBody, +) +from hiero_sdk_python.hapi.services.transaction_pb2 import TransactionBody +from hiero_sdk_python.transaction.transaction import Transaction + + +class RegisteredNodeUpdateTransaction(Transaction): + """Updates an existing registered node on the network.""" + + def __init__(self): + super().__init__() + self.registered_node_id: int | None = None + self.admin_key: Key | None = None + self.description: str | None = None + self.service_endpoints: list[RegisteredServiceEndpoint] | None = None + + def set_registered_node_id(self, registered_node_id: int | None) -> RegisteredNodeUpdateTransaction: + """Sets the registered node ID to update. + + Args: + registered_node_id: The ID of the registered node to update. + + Returns: + RegisteredNodeUpdateTransaction: This transaction instance. + """ + self._require_not_frozen() + self.registered_node_id = registered_node_id + return self + + def set_admin_key(self, admin_key: Key | None) -> RegisteredNodeUpdateTransaction: + """Sets the new admin key for the registered node. + + Args: + admin_key: The new admin key, or None to leave unchanged. + + Returns: + RegisteredNodeUpdateTransaction: This transaction instance. + """ + self._require_not_frozen() + self.admin_key = admin_key + return self + + def set_description(self, description: str | None) -> RegisteredNodeUpdateTransaction: + """Sets the new description for the registered node. + + Args: + description: The new description, or None to leave unchanged. + + Returns: + RegisteredNodeUpdateTransaction: This transaction instance. + """ + self._require_not_frozen() + self.description = description + return self + + def set_service_endpoints( + self, service_endpoints: list[RegisteredServiceEndpoint] | None + ) -> RegisteredNodeUpdateTransaction: + """Sets the new service endpoints for the registered node. + + Args: + service_endpoints: The new service endpoints, or None to leave unchanged. + + Returns: + RegisteredNodeUpdateTransaction: This transaction instance. + """ + self._require_not_frozen() + self.service_endpoints = service_endpoints + return self + + def add_service_endpoint(self, endpoint: RegisteredServiceEndpoint) -> RegisteredNodeUpdateTransaction: + """Adds a service endpoint to the registered node. + + Args: + endpoint: The service endpoint to add. + + Returns: + RegisteredNodeUpdateTransaction: This transaction instance. + """ + self._require_not_frozen() + if self.service_endpoints is None: + self.service_endpoints = [] + self.service_endpoints.append(endpoint) + return self + + def _build_proto_body(self) -> RegisteredNodeUpdateTransactionBody: + body = RegisteredNodeUpdateTransactionBody( + registered_node_id=self.registered_node_id, + admin_key=self.admin_key.to_proto_key() if self.admin_key else None, + description=(StringValue(value=self.description) if self.description is not None else None), + ) + if self.service_endpoints is not None: + for ep in self.service_endpoints: + body.service_endpoint.append(ep._to_proto()) + return body + + def build_transaction_body(self) -> TransactionBody: + body = self._build_proto_body() + transaction_body = self.build_base_transaction_body() + transaction_body.registeredNodeUpdate.CopyFrom(body) + return transaction_body + + def build_scheduled_body(self) -> SchedulableTransactionBody: + body = self._build_proto_body() + scheduled_body = self.build_base_scheduled_body() + scheduled_body.registeredNodeUpdate.CopyFrom(body) + return scheduled_body + + def _get_method(self, channel: _Channel) -> _Method: + return _Method(transaction_func=channel.address_book.updateRegisteredNode, query_func=None) + + @classmethod + def _from_protobuf(cls, transaction_body, body_bytes: bytes, sig_map): + transaction = super()._from_protobuf(transaction_body, body_bytes, sig_map) + + if transaction_body.HasField("registeredNodeUpdate"): + pb = transaction_body.registeredNodeUpdate + transaction.registered_node_id = pb.registered_node_id + if pb.HasField("admin_key"): + transaction.admin_key = Key.from_proto_key(pb.admin_key) + if pb.HasField("description"): + transaction.description = pb.description.value + if pb.service_endpoint: + transaction.service_endpoints = [ + RegisteredServiceEndpoint._from_proto(ep) for ep in pb.service_endpoint + ] + + return transaction diff --git a/src/hiero_sdk_python/response_code.py b/src/hiero_sdk_python/response_code.py index f233644dc..b5741eea8 100644 --- a/src/hiero_sdk_python/response_code.py +++ b/src/hiero_sdk_python/response_code.py @@ -358,6 +358,54 @@ class ResponseCode(IntEnum): MISSING_BATCH_KEY = 392 BATCH_KEY_SET_ON_NON_INNER_TRANSACTION = 393 INVALID_BATCH_KEY = 394 + SCHEDULE_EXPIRY_NOT_CONFIGURABLE = 395 + CREATING_SYSTEM_ENTITIES = 396 + THROTTLE_GROUP_LCM_OVERFLOW = 397 + AIRDROP_CONTAINS_MULTIPLE_SENDERS_FOR_A_TOKEN = 398 + GRPC_WEB_PROXY_NOT_SUPPORTED = 399 + NFT_TRANSFERS_ONLY_ALLOWED_FOR_NON_FUNGIBLE_UNIQUE = 400 + INVALID_SERIALIZED_TX_MESSAGE_HASH_ALGORITHM = 401 + + # Hook status codes (499–528) + WRONG_HOOK_ENTITY_TYPE = 499 + EVM_HOOK_GAS_THROTTLED = 500 + HOOK_ID_IN_USE = 501 + BAD_HOOK_REQUEST = 502 + REJECTED_BY_ACCOUNT_ALLOWANCE_HOOK = 503 + HOOK_NOT_FOUND = 504 + EVM_HOOK_STORAGE_UPDATE_BYTES_TOO_LONG = 505 + EVM_HOOK_STORAGE_UPDATE_BYTES_MUST_USE_MINIMAL_REPRESENTATION = 506 + INVALID_HOOK_ID = 507 + EMPTY_EVM_HOOK_STORAGE_UPDATE = 508 + HOOK_ID_REPEATED_IN_CREATION_DETAILS = 509 + HOOKS_NOT_ENABLED = 510 + HOOK_IS_NOT_AN_EVM_HOOK = 511 + HOOK_DELETED = 512 + TOO_MANY_EVM_HOOK_STORAGE_UPDATES = 513 + HOOK_CREATION_BYTES_MUST_USE_MINIMAL_REPRESENTATION = 514 + HOOK_CREATION_BYTES_TOO_LONG = 515 + INVALID_HOOK_CREATION_SPEC = 516 + HOOK_EXTENSION_EMPTY = 517 + INVALID_HOOK_ADMIN_KEY = 518 + HOOK_DELETION_REQUIRES_ZERO_STORAGE_SLOTS = 519 + CANNOT_SET_HOOKS_AND_APPROVAL = 520 + TRANSACTION_REQUIRES_ZERO_HOOKS = 521 + INVALID_HOOK_CALL = 522 + HOOKS_ARE_NOT_SUPPORTED_IN_AIRDROPS = 523 + ACCOUNT_IS_LINKED_TO_A_NODE = 524 + HOOKS_EXECUTIONS_REQUIRE_TOP_LEVEL_CRYPTO_TRANSFER = 525 + NODE_ACCOUNT_HAS_ZERO_BALANCE = 526 + TRANSFER_TO_FEE_COLLECTION_ACCOUNT_NOT_ALLOWED = 527 + TOO_MANY_HOOK_INVOCATIONS = 528 + + # Registered node status codes + INVALID_REGISTERED_NODE_ID = 529 + INVALID_REGISTERED_ENDPOINT = 530 + REGISTERED_ENDPOINTS_EXCEEDED_LIMIT = 531 + INVALID_REGISTERED_ENDPOINT_ADDRESS = 532 + INVALID_REGISTERED_ENDPOINT_TYPE = 533 + REGISTERED_NODE_STILL_ASSOCIATED = 534 + MAX_REGISTERED_NODES_EXCEEDED = 535 @classmethod def _missing_(cls, value: object) -> ResponseCode: diff --git a/src/hiero_sdk_python/transaction/transaction.py b/src/hiero_sdk_python/transaction/transaction.py index 9d2f1ebff..3d0d77708 100644 --- a/src/hiero_sdk_python/transaction/transaction.py +++ b/src/hiero_sdk_python/transaction/transaction.py @@ -804,6 +804,9 @@ def _get_transaction_class(transaction_type: str): "nodeCreate": "hiero_sdk_python.nodes.node_create_transaction.NodeCreateTransaction", "nodeUpdate": "hiero_sdk_python.nodes.node_update_transaction.NodeUpdateTransaction", "nodeDelete": "hiero_sdk_python.nodes.node_delete_transaction.NodeDeleteTransaction", + "registeredNodeCreate": "hiero_sdk_python.nodes.registered_node_create_transaction.RegisteredNodeCreateTransaction", + "registeredNodeUpdate": "hiero_sdk_python.nodes.registered_node_update_transaction.RegisteredNodeUpdateTransaction", + "registeredNodeDelete": "hiero_sdk_python.nodes.registered_node_delete_transaction.RegisteredNodeDeleteTransaction", "utilPrng": "hiero_sdk_python.prng_transaction.PrngTransaction", "tokenReject": "hiero_sdk_python.tokens.token_reject_transaction.TokenRejectTransaction", "tokenAirdrop": "hiero_sdk_python.tokens.token_airdrop_transaction.TokenAirdropTransaction", diff --git a/src/hiero_sdk_python/transaction/transaction_receipt.py b/src/hiero_sdk_python/transaction/transaction_receipt.py index c67cf558d..0ca69582c 100644 --- a/src/hiero_sdk_python/transaction/transaction_receipt.py +++ b/src/hiero_sdk_python/transaction/transaction_receipt.py @@ -178,6 +178,18 @@ def node_id(self): """ return self._receipt_proto.node_id + @property + def registered_node_id(self) -> int | None: + """ + Returns the registered node ID associated with this receipt. + + Returns: + int or None: The registered node ID if present; otherwise, None. + """ + if self._receipt_proto.registered_node_id: + return self._receipt_proto.registered_node_id + return None + @property def topic_sequence_number(self) -> int: """ diff --git a/tests/integration/associated_registered_nodes_e2e_test.py b/tests/integration/associated_registered_nodes_e2e_test.py new file mode 100644 index 000000000..c1893ab3f --- /dev/null +++ b/tests/integration/associated_registered_nodes_e2e_test.py @@ -0,0 +1,148 @@ +""" +Integration tests for associated_registered_nodes on NodeCreate/NodeUpdate. +""" + +from __future__ import annotations + +from hiero_sdk_python.account.account_create_transaction import AccountCreateTransaction +from hiero_sdk_python.account.account_id import AccountId +from hiero_sdk_python.address_book.block_node_api import BlockNodeApi +from hiero_sdk_python.address_book.block_node_service_endpoint import BlockNodeServiceEndpoint +from hiero_sdk_python.address_book.endpoint import Endpoint +from hiero_sdk_python.client.client import Client +from hiero_sdk_python.client.network import Network +from hiero_sdk_python.crypto.private_key import PrivateKey +from hiero_sdk_python.nodes.node_create_transaction import NodeCreateTransaction +from hiero_sdk_python.nodes.node_update_transaction import NodeUpdateTransaction +from hiero_sdk_python.nodes.registered_node_create_transaction import RegisteredNodeCreateTransaction +from hiero_sdk_python.response_code import ResponseCode + + +# DER-encoded x509 certificate for gossip protocol communication +GOSSIP_CERTIFICATE = "3082052830820310a003020102020101300d06092a864886f70d01010c05003010310e300c060355040313056e6f6465333024170d3234313030383134333233395a181332313234313030383134333233392e3337395a3010310e300c060355040313056e6f64653330820222300d06092a864886f70d01010105000382020f003082020a0282020100af111cff0c4ad8125d2f4b8691ce87332fecc867f7a94ddc0f3f96514cc4224d44af516394f7384c1ef0a515d29aa6116b65bc7e4d7e2d848cf79fbfffedae3a6583b3957a438bdd780c4981b800676ea509bc8c619ae04093b5fc642c4484152f0e8bcaabf19eae025b630028d183a2f47caf6d9f1075efb30a4248679d871beef1b7e9115382270cbdb68682fae4b1fd592cadb414d918c0a8c23795c7c5a91e22b3e90c410825a2bc1a840efc5bf9976a7f474c7ed7dc047e4ddd2db631b68bb4475f173baa3edc234c4bed79c83e2f826f79e07d0aade2d984da447a8514135bfa4145274a7f62959a23c4f0fae5adc6855974e7c04164951d052beb5d45cb1f3cdfd005da894dea9151cb62ba43f4731c6bb0c83e10fd842763ba6844ef499f71bc67fa13e4917fb39f2ad18112170d31cdcb3c61c9e3253accf703dbd8427fdcb87ece78b787b6cfdc091e8fedea8ad95dc64074e1fc6d0e42ea2337e18a5e54e4aaab3791a98dfcef282e2ae1caec9cf986fabe8f36e6a21c8711647177e492d264415e765a86c58599cd97b103cb4f6a01d2edd06e3b60470cf64daca7aecf831197b466cae04baeeac19840a05394bef628aed04b611cfa13677724b08ddfd662b02fd0ef0af17eb7f4fb8c1c17fbe9324f6dc7bcc02449622636cc45ec04909b3120ab4df4726b21bf79e955fe8f832699d2196dcd7a58bfeafb170203010001a38186308183300f0603551d130101ff04053003020100300e0603551d0f0101ff0404030204b030200603551d250101ff0416301406082b0601050507030106082b06010505070302301d0603551d0e04160414643118e05209035edd83d44a0c368de2fb2fe4c0301f0603551d23041830168014643118e05209035edd83d44a0c368de2fb2fe4c0300d06092a864886f70d01010c05000382020100ad41c32bb52650eb4b76fce439c9404e84e4538a94916b3dc7983e8b5c58890556e7384601ca7440dde68233bb07b97bf879b64487b447df510897d2a0a4e789c409a9b237a6ad240ad5464f2ce80c58ddc4d07a29a74eb25e1223db6c00e334d7a27d32bfa6183a82f5e35bccf497c2445a526eabb0c068aba9b94cc092ea4756b0dcfb574f6179f0089e52b174ccdbd04123eeb6d70daeabd8513fcba6be0bc2b45ca9a69802dae11cc4d9ff6053b3a87fd8b0c6bf72fffc3b81167f73cca2b3fd656c5d353c8defca8a76e2ad535f984870a590af4e28fed5c5a125bf360747c5e7742e7813d1bd39b5498c8eb6ba72f267eda034314fdbc596f6b967a0ef8be5231d364e634444c84e64bd7919425171016fcd9bb05f01c58a303dee28241f6e860fc3aac3d92aad7dac2801ce79a3b41a0e1f1509fc0d86e96d94edb18616c000152490f64561713102128990fedd3a5fa642f2ff22dc11bc4dc5b209986a0c3e4eb2bdfdd40e9fdf246f702441cac058dd8d0d51eb0796e2bea2ce1b37b2a2f468505e1f8980a9f66d719df034a6fbbd2f9585991d259678fb9a4aebdc465d22c240351ed44abffbdd11b79a706fdf7c40158d3da87f68d7bd557191a8016b5b899c07bf1b87590feb4fa4203feea9a2a7a73ec224813a12b7a21e5dc93fcde4f0a7620f570d31fe27e9b8d65b74db7dc18a5e51adc42d7805d4661938" + + +def _setup_client(): + """Set up admin client for solo network.""" + network = Network(network="solo") + client = Client(network) + original_operator_key = PrivateKey.from_string_der( + "302e020100300506032b65700422042091132178e72057a1d7528025956fe39b0b847f200ab59b2fdd367017f3087137" + ) + client.set_operator(AccountId(0, 0, 2), original_operator_key) + return client + + +def _create_consensus_node(client, admin_key, associated_ids=None): + """Helper: create a consensus node with optional associated_registered_nodes.""" + account_id = ( + AccountCreateTransaction() + .set_key_without_alias(PrivateKey.generate_ecdsa()) + .freeze_with(client) + .execute(client) + .account_id + ) + endpoint = Endpoint(domain_name="test.com", port=123) + grpc_proxy_endpoint = Endpoint(domain_name="testWeb.com", port=12345) + valid_gossip_cert = bytes.fromhex(GOSSIP_CERTIFICATE) + + tx = ( + NodeCreateTransaction() + .set_account_id(account_id) + .set_description("test node") + .set_gossip_endpoints([endpoint]) + .set_service_endpoints([endpoint]) + .set_gossip_ca_certificate(valid_gossip_cert) + .set_admin_key(admin_key.public_key()) + .set_grpc_web_proxy_endpoint(grpc_proxy_endpoint) + .set_decline_reward(True) + ) + + if associated_ids is not None: + tx.set_associated_registered_nodes(associated_ids) + + receipt = tx.freeze_with(client).sign(admin_key).execute(client) + assert receipt.status == ResponseCode.SUCCESS, f"Node create failed: {ResponseCode(receipt.status).name}" + return receipt.node_id + + +def _create_registered_node(client, admin_key): + """Helper: create a registered node and return its ID.""" + block_endpoint = BlockNodeServiceEndpoint( + domain_name="block.example.com", + port=443, + requires_tls=True, + endpoint_apis=[BlockNodeApi.STATUS], + ) + receipt = ( + RegisteredNodeCreateTransaction() + .set_admin_key(admin_key.public_key()) + .set_description("registered node for association") + .set_service_endpoints([block_endpoint]) + .freeze_with(client) + .sign(admin_key) + .execute(client) + ) + assert receipt.status == ResponseCode.SUCCESS, ( + f"Helper: registered node creation failed: {ResponseCode(receipt.status).name}" + ) + return receipt.registered_node_id + + +def test_node_create_with_associated_registered_nodes(): + """Test NodeCreateTransaction with associated_registered_nodes.""" + client = _setup_client() + admin_key = PrivateKey.generate_ed25519() + + # First create a registered node to associate + reg_node_id = _create_registered_node(client, admin_key) + + # Create a consensus node associated with the registered node + node_id = _create_consensus_node(client, admin_key, associated_ids=[reg_node_id]) + assert node_id is not None + + +def test_node_update_set_associated_registered_nodes(): + """Test NodeUpdateTransaction replacing associated_registered_nodes.""" + client = _setup_client() + admin_key = PrivateKey.generate_ed25519() + + # Create a consensus node without associations + node_id = _create_consensus_node(client, admin_key) + + # Create a registered node + reg_node_id = _create_registered_node(client, admin_key) + + # Update the consensus node to add the association + receipt = ( + NodeUpdateTransaction() + .set_node_id(node_id) + .set_associated_registered_nodes([reg_node_id]) + .freeze_with(client) + .sign(admin_key) + .execute(client) + ) + + assert receipt.status == ResponseCode.SUCCESS, f"Node update failed: {ResponseCode(receipt.status).name}" + + +def test_node_update_clear_associated_registered_nodes(): + """Test NodeUpdateTransaction clearing associated_registered_nodes with empty list.""" + client = _setup_client() + admin_key = PrivateKey.generate_ed25519() + + # Create registered node and consensus node with association + reg_node_id = _create_registered_node(client, admin_key) + node_id = _create_consensus_node(client, admin_key, associated_ids=[reg_node_id]) + + # Clear the associations by setting empty list + receipt = ( + NodeUpdateTransaction() + .set_node_id(node_id) + .set_associated_registered_nodes([]) + .freeze_with(client) + .sign(admin_key) + .execute(client) + ) + + assert receipt.status == ResponseCode.SUCCESS, f"Node update clear failed: {ResponseCode(receipt.status).name}" diff --git a/tests/integration/registered_node_create_transaction_e2e_test.py b/tests/integration/registered_node_create_transaction_e2e_test.py new file mode 100644 index 000000000..a4bfd9dc3 --- /dev/null +++ b/tests/integration/registered_node_create_transaction_e2e_test.py @@ -0,0 +1,240 @@ +""" +Integration tests for RegisteredNodeCreateTransaction. +""" + +from __future__ import annotations + +import pytest + +from hiero_sdk_python.account.account_id import AccountId +from hiero_sdk_python.address_book.block_node_api import BlockNodeApi +from hiero_sdk_python.address_book.block_node_service_endpoint import BlockNodeServiceEndpoint +from hiero_sdk_python.address_book.general_service_endpoint import GeneralServiceEndpoint +from hiero_sdk_python.address_book.mirror_node_service_endpoint import MirrorNodeServiceEndpoint +from hiero_sdk_python.address_book.rpc_relay_service_endpoint import RpcRelayServiceEndpoint +from hiero_sdk_python.client.client import Client +from hiero_sdk_python.client.network import Network +from hiero_sdk_python.crypto.private_key import PrivateKey +from hiero_sdk_python.exceptions import PrecheckError +from hiero_sdk_python.nodes.registered_node_create_transaction import RegisteredNodeCreateTransaction +from hiero_sdk_python.nodes.registered_node_delete_transaction import RegisteredNodeDeleteTransaction +from hiero_sdk_python.response_code import ResponseCode + + +# Account 0.0.2 is the address book admin on solo networks. +# The private key is the well-known genesis key for local development only. +_ADMIN_OPERATOR_KEY = PrivateKey.from_string_der( + "302e020100300506032b65700422042091132178e72057a1d7528025956fe39b0b847f200ab59b2fdd367017f3087137" +) +_ADMIN_ACCOUNT_ID = AccountId(0, 0, 2) + + +@pytest.fixture +def admin_client(): + """Client with address book admin privileges (0.0.2) for registered node operations.""" + network = Network(network="solo") + client = Client(network) + client.set_operator(_ADMIN_ACCOUNT_ID, _ADMIN_OPERATOR_KEY) + yield client + client.close() + + +def test_registered_node_create_with_block_endpoint(admin_client): + """Test creating a registered node with a BlockNodeServiceEndpoint.""" + admin_key = PrivateKey.generate_ed25519() + + block_endpoint = BlockNodeServiceEndpoint( + domain_name="block.example.com", + port=443, + requires_tls=True, + endpoint_apis=[BlockNodeApi.STATUS, BlockNodeApi.SUBSCRIBE_STREAM], + ) + + receipt = ( + RegisteredNodeCreateTransaction() + .set_admin_key(admin_key.public_key()) + .set_description("test registered node") + .set_service_endpoints([block_endpoint]) + .freeze_with(admin_client) + .sign(admin_key) + .execute(admin_client) + ) + + assert receipt.status == ResponseCode.SUCCESS, ( + f"Registered node create failed with status {ResponseCode(receipt.status).name}" + ) + assert receipt.registered_node_id is not None, "registered_node_id should not be None" + assert receipt.registered_node_id > 0, "registered_node_id should be positive" + + # Cleanup: delete the registered node + RegisteredNodeDeleteTransaction().set_registered_node_id(receipt.registered_node_id).freeze_with(admin_client).sign( + admin_key + ).execute(admin_client) + + +def test_registered_node_create_with_mixed_endpoints(admin_client): + """Test creating a registered node with multiple endpoint types.""" + admin_key = PrivateKey.generate_ed25519() + + block_endpoint = BlockNodeServiceEndpoint( + domain_name="block.example.com", + port=443, + requires_tls=True, + endpoint_apis=[BlockNodeApi.PUBLISH], + ) + mirror_endpoint = MirrorNodeServiceEndpoint( + domain_name="mirror.example.com", + port=5600, + requires_tls=True, + ) + + receipt = ( + RegisteredNodeCreateTransaction() + .set_admin_key(admin_key.public_key()) + .set_description("mixed endpoints node") + .set_service_endpoints([block_endpoint, mirror_endpoint]) + .freeze_with(admin_client) + .sign(admin_key) + .execute(admin_client) + ) + + assert receipt.status == ResponseCode.SUCCESS, ( + f"Registered node create failed with status {ResponseCode(receipt.status).name}" + ) + assert receipt.registered_node_id is not None + + # Cleanup + RegisteredNodeDeleteTransaction().set_registered_node_id(receipt.registered_node_id).freeze_with(admin_client).sign( + admin_key + ).execute(admin_client) + + +def test_registered_node_create_with_mirror_endpoint(admin_client): + """Test creating a registered node with a MirrorNodeServiceEndpoint.""" + admin_key = PrivateKey.generate_ed25519() + + mirror_endpoint = MirrorNodeServiceEndpoint( + domain_name="mirror.example.com", + port=5600, + requires_tls=True, + ) + + receipt = ( + RegisteredNodeCreateTransaction() + .set_admin_key(admin_key.public_key()) + .set_description("mirror node endpoint") + .set_service_endpoints([mirror_endpoint]) + .freeze_with(admin_client) + .sign(admin_key) + .execute(admin_client) + ) + + assert receipt.status == ResponseCode.SUCCESS, ( + f"Registered node create failed with status {ResponseCode(receipt.status).name}" + ) + assert receipt.registered_node_id is not None, "registered_node_id should not be None" + assert receipt.registered_node_id > 0, "registered_node_id should be positive" + + # Cleanup + RegisteredNodeDeleteTransaction().set_registered_node_id(receipt.registered_node_id).freeze_with(admin_client).sign( + admin_key + ).execute(admin_client) + + +def test_registered_node_create_with_rpc_relay_endpoint(admin_client): + """Test creating a registered node with an RpcRelayServiceEndpoint.""" + admin_key = PrivateKey.generate_ed25519() + + rpc_endpoint = RpcRelayServiceEndpoint( + domain_name="rpc.example.com", + port=8545, + requires_tls=True, + ) + + receipt = ( + RegisteredNodeCreateTransaction() + .set_admin_key(admin_key.public_key()) + .set_description("rpc relay endpoint") + .set_service_endpoints([rpc_endpoint]) + .freeze_with(admin_client) + .sign(admin_key) + .execute(admin_client) + ) + + assert receipt.status == ResponseCode.SUCCESS, ( + f"Registered node create failed with status {ResponseCode(receipt.status).name}" + ) + assert receipt.registered_node_id is not None, "registered_node_id should not be None" + assert receipt.registered_node_id > 0, "registered_node_id should be positive" + + # Cleanup + RegisteredNodeDeleteTransaction().set_registered_node_id(receipt.registered_node_id).freeze_with(admin_client).sign( + admin_key + ).execute(admin_client) + + +def test_registered_node_create_with_general_endpoint(admin_client): + """Test creating a registered node with a GeneralServiceEndpoint with a description.""" + admin_key = PrivateKey.generate_ed25519() + + general_endpoint = GeneralServiceEndpoint( + domain_name="general.example.com", + port=8080, + requires_tls=False, + description="general purpose service", + ) + + receipt = ( + RegisteredNodeCreateTransaction() + .set_admin_key(admin_key.public_key()) + .set_description("general service endpoint") + .set_service_endpoints([general_endpoint]) + .freeze_with(admin_client) + .sign(admin_key) + .execute(admin_client) + ) + + assert receipt.status == ResponseCode.SUCCESS, ( + f"Registered node create failed with status {ResponseCode(receipt.status).name}" + ) + assert receipt.registered_node_id is not None, "registered_node_id should not be None" + assert receipt.registered_node_id > 0, "registered_node_id should be positive" + + # Cleanup or delete the registered node + RegisteredNodeDeleteTransaction().set_registered_node_id(receipt.registered_node_id).freeze_with(admin_client).sign( + admin_key + ).execute(admin_client) + + +def test_registered_node_create_fails_without_endpoints(admin_client): + """Test that creating a registered node with no endpoints fails at the network level.""" + admin_key = PrivateKey.generate_ed25519() + + with pytest.raises(PrecheckError): + ( + RegisteredNodeCreateTransaction() + .set_admin_key(admin_key.public_key()) + .set_description("no endpoints") + .freeze_with(admin_client) + .sign(admin_key) + .execute(admin_client) + ) + + +def test_registered_node_create_fails_without_admin_key(admin_client): + """Test that creating a registered node without an admin key fails at the network level.""" + block_endpoint = BlockNodeServiceEndpoint( + domain_name="block.example.com", + port=443, + requires_tls=True, + endpoint_apis=[BlockNodeApi.STATUS], + ) + + with pytest.raises(PrecheckError): + ( + RegisteredNodeCreateTransaction() + .set_description("no admin key") + .set_service_endpoints([block_endpoint]) + .freeze_with(admin_client) + .execute(admin_client) + ) diff --git a/tests/integration/registered_node_delete_transaction_e2e_test.py b/tests/integration/registered_node_delete_transaction_e2e_test.py new file mode 100644 index 000000000..32e3e82b8 --- /dev/null +++ b/tests/integration/registered_node_delete_transaction_e2e_test.py @@ -0,0 +1,120 @@ +""" +Integration tests for RegisteredNodeDeleteTransaction. +""" + +from __future__ import annotations + +import pytest + +from hiero_sdk_python.account.account_id import AccountId +from hiero_sdk_python.address_book.block_node_api import BlockNodeApi +from hiero_sdk_python.address_book.block_node_service_endpoint import BlockNodeServiceEndpoint +from hiero_sdk_python.client.client import Client +from hiero_sdk_python.client.network import Network +from hiero_sdk_python.crypto.private_key import PrivateKey +from hiero_sdk_python.nodes.registered_node_create_transaction import RegisteredNodeCreateTransaction +from hiero_sdk_python.nodes.registered_node_delete_transaction import RegisteredNodeDeleteTransaction +from hiero_sdk_python.response_code import ResponseCode + + +_ADMIN_OPERATOR_KEY = PrivateKey.from_string_der( + "302e020100300506032b65700422042091132178e72057a1d7528025956fe39b0b847f200ab59b2fdd367017f3087137" +) +_ADMIN_ACCOUNT_ID = AccountId(0, 0, 2) + + +@pytest.fixture +def admin_client(): + """Client with address book admin privileges (0.0.2) for registered node operations.""" + network = Network(network="solo") + client = Client(network) + client.set_operator(_ADMIN_ACCOUNT_ID, _ADMIN_OPERATOR_KEY) + yield client + client.close() + + +def _create_registered_node(client, admin_key): + """Helper: create a registered node and return its ID.""" + block_endpoint = BlockNodeServiceEndpoint( + domain_name="block.example.com", + port=443, + requires_tls=True, + endpoint_apis=[BlockNodeApi.STATUS], + ) + receipt = ( + RegisteredNodeCreateTransaction() + .set_admin_key(admin_key.public_key()) + .set_description("node for delete test") + .set_service_endpoints([block_endpoint]) + .freeze_with(client) + .sign(admin_key) + .execute(client) + ) + assert receipt.status == ResponseCode.SUCCESS, ( + f"Helper: registered node creation failed: {ResponseCode(receipt.status).name}" + ) + return receipt.registered_node_id + + +def test_registered_node_delete(admin_client): + """Test creating then deleting a registered node.""" + admin_key = PrivateKey.generate_ed25519() + registered_node_id = _create_registered_node(admin_client, admin_key) + + receipt = ( + RegisteredNodeDeleteTransaction() + .set_registered_node_id(registered_node_id) + .freeze_with(admin_client) + .sign(admin_key) + .execute(admin_client) + ) + + assert receipt.status == ResponseCode.SUCCESS, ( + f"Registered node delete failed with status {ResponseCode(receipt.status).name}" + ) + + +def test_registered_node_delete_invalid_id(admin_client): + """Test that deleting a nonexistent registered node fails at the network level.""" + admin_key = PrivateKey.generate_ed25519() + + receipt = ( + RegisteredNodeDeleteTransaction() + .set_registered_node_id(999999999) + .freeze_with(admin_client) + .sign(admin_key) + .execute(admin_client) + ) + assert receipt.status == ResponseCode.INVALID_REGISTERED_NODE_ID, ( + f"Expected INVALID_REGISTERED_NODE_ID but got {ResponseCode(receipt.status).name}" + ) + + +def test_registered_node_delete_already_deleted(admin_client): + """Test that deleting a registered node that was already deleted fails with INVALID_REGISTERED_NODE_ID.""" + admin_key = PrivateKey.generate_ed25519() + registered_node_id = _create_registered_node(admin_client, admin_key) + + # First delete should succeed + receipt = ( + RegisteredNodeDeleteTransaction() + .set_registered_node_id(registered_node_id) + .freeze_with(admin_client) + .sign(admin_key) + .execute(admin_client) + ) + assert receipt.status == ResponseCode.SUCCESS, ( + f"First delete failed with status {ResponseCode(receipt.status).name}" + ) + + # Second delete of the same node should fail + receipt = ( + RegisteredNodeDeleteTransaction() + .set_registered_node_id(registered_node_id) + .freeze_with(admin_client) + .sign(admin_key) + .execute(admin_client) + ) + assert receipt.status == ResponseCode.INVALID_REGISTERED_NODE_ID, ( + f"Expected INVALID_REGISTERED_NODE_ID but got {ResponseCode(receipt.status).name}" + ) diff --git a/tests/integration/registered_node_update_transaction_e2e_test.py b/tests/integration/registered_node_update_transaction_e2e_test.py new file mode 100644 index 000000000..7fc85aa52 --- /dev/null +++ b/tests/integration/registered_node_update_transaction_e2e_test.py @@ -0,0 +1,252 @@ +""" +Integration tests for RegisteredNodeUpdateTransaction. +""" + +from __future__ import annotations + +import time + +import pytest + +from hiero_sdk_python.account.account_id import AccountId +from hiero_sdk_python.address_book.block_node_api import BlockNodeApi +from hiero_sdk_python.address_book.block_node_service_endpoint import BlockNodeServiceEndpoint +from hiero_sdk_python.address_book.mirror_node_service_endpoint import MirrorNodeServiceEndpoint +from hiero_sdk_python.address_book.registered_node_address_book_query import RegisteredNodeAddressBookQuery +from hiero_sdk_python.client.client import Client +from hiero_sdk_python.client.network import Network +from hiero_sdk_python.crypto.private_key import PrivateKey +from hiero_sdk_python.exceptions import PrecheckError +from hiero_sdk_python.nodes.registered_node_create_transaction import RegisteredNodeCreateTransaction +from hiero_sdk_python.nodes.registered_node_delete_transaction import RegisteredNodeDeleteTransaction +from hiero_sdk_python.nodes.registered_node_update_transaction import RegisteredNodeUpdateTransaction +from hiero_sdk_python.response_code import ResponseCode + + +_ADMIN_OPERATOR_KEY = PrivateKey.from_string_der( + "302e020100300506032b65700422042091132178e72057a1d7528025956fe39b0b847f200ab59b2fdd367017f3087137" +) +_ADMIN_ACCOUNT_ID = AccountId(0, 0, 2) + + +@pytest.fixture +def admin_client(): + """Client with address book admin privileges (0.0.2) for registered node operations.""" + network = Network(network="solo") + client = Client(network) + client.set_operator(_ADMIN_ACCOUNT_ID, _ADMIN_OPERATOR_KEY) + yield client + client.close() + + +def _create_registered_node(client, admin_key): + """Helper: create a registered node and return its ID.""" + block_endpoint = BlockNodeServiceEndpoint( + domain_name="block.example.com", + port=443, + requires_tls=True, + endpoint_apis=[BlockNodeApi.STATUS], + ) + receipt = ( + RegisteredNodeCreateTransaction() + .set_admin_key(admin_key.public_key()) + .set_description("node for update test") + .set_service_endpoints([block_endpoint]) + .freeze_with(client) + .sign(admin_key) + .execute(client) + ) + assert receipt.status == ResponseCode.SUCCESS, ( + f"Helper: registered node creation failed: {ResponseCode(receipt.status).name}" + ) + return receipt.registered_node_id + + +def test_registered_node_update_description(admin_client): + """Test updating a registered node's description.""" + admin_key = PrivateKey.generate_ed25519() + registered_node_id = _create_registered_node(admin_client, admin_key) + + try: + receipt = ( + RegisteredNodeUpdateTransaction() + .set_registered_node_id(registered_node_id) + .set_description("updated description") + .freeze_with(admin_client) + .sign(admin_key) + .execute(admin_client) + ) + + assert receipt.status == ResponseCode.SUCCESS, ( + f"Registered node update failed with status {ResponseCode(receipt.status).name}" + ) + + # Allow mirror node to sync + time.sleep(5) + + # Query the registered node to verify the description was updated + address_book = RegisteredNodeAddressBookQuery().set_registered_node_id(registered_node_id).execute(admin_client) + assert len(address_book.nodes) == 1, "Expected exactly one registered node" + assert address_book.nodes[0].description == "updated description", ( + f"Expected 'updated description' but got {address_book.nodes[0].description!r}" + ) + finally: + # Cleanup + RegisteredNodeDeleteTransaction().set_registered_node_id(registered_node_id).freeze_with(admin_client).sign( + admin_key + ).execute(admin_client) + + +def test_registered_node_update_service_endpoints(admin_client): + """Test replacing a registered node's service endpoints.""" + admin_key = PrivateKey.generate_ed25519() + registered_node_id = _create_registered_node(admin_client, admin_key) + + try: + new_endpoint = MirrorNodeServiceEndpoint( + domain_name="mirror.updated.com", + port=5600, + requires_tls=True, + ) + + receipt = ( + RegisteredNodeUpdateTransaction() + .set_registered_node_id(registered_node_id) + .set_service_endpoints([new_endpoint]) + .freeze_with(admin_client) + .sign(admin_key) + .execute(admin_client) + ) + + assert receipt.status == ResponseCode.SUCCESS, ( + f"Registered node update failed with status {ResponseCode(receipt.status).name}" + ) + finally: + # Cleanup + RegisteredNodeDeleteTransaction().set_registered_node_id(registered_node_id).freeze_with(admin_client).sign( + admin_key + ).execute(admin_client) + + +def test_registered_node_update_invalid_id(admin_client): + """Test that updating a nonexistent registered node fails at the network level.""" + admin_key = PrivateKey.generate_ed25519() + + try: + receipt = ( + RegisteredNodeUpdateTransaction() + .set_registered_node_id(999999999) + .set_description("should fail") + .freeze_with(admin_client) + .sign(admin_key) + .execute(admin_client) + ) + assert receipt.status == ResponseCode.INVALID_REGISTERED_NODE_ID, ( + f"Expected INVALID_REGISTERED_NODE_ID but got {ResponseCode(receipt.status).name}" + ) + except PrecheckError: + pass # Also acceptable: network rejects at precheck + + +def test_registered_node_update_admin_key_both_sign(admin_client): + """Test updating admin key when both old and new keys sign succeeds.""" + old_admin_key = PrivateKey.generate_ed25519() + new_admin_key = PrivateKey.generate_ed25519() + registered_node_id = _create_registered_node(admin_client, old_admin_key) + + try: + receipt = ( + RegisteredNodeUpdateTransaction() + .set_registered_node_id(registered_node_id) + .set_admin_key(new_admin_key.public_key()) + .freeze_with(admin_client) + .sign(old_admin_key) + .sign(new_admin_key) + .execute(admin_client) + ) + + assert receipt.status == ResponseCode.SUCCESS, ( + f"Registered node update admin key failed with status {ResponseCode(receipt.status).name}" + ) + finally: + # Cleanup: must sign with the new admin key now + RegisteredNodeDeleteTransaction().set_registered_node_id(registered_node_id).freeze_with(admin_client).sign( + new_admin_key + ).execute(admin_client) + + +def test_registered_node_update_admin_key_only_old_signs(admin_client): + """Test updating admin key when only old key signs fails with INVALID_SIGNATURE.""" + old_admin_key = PrivateKey.generate_ed25519() + new_admin_key = PrivateKey.generate_ed25519() + registered_node_id = _create_registered_node(admin_client, old_admin_key) + + try: + receipt = ( + RegisteredNodeUpdateTransaction() + .set_registered_node_id(registered_node_id) + .set_admin_key(new_admin_key.public_key()) + .freeze_with(admin_client) + .sign(old_admin_key) + .execute(admin_client) + ) + assert receipt.status == ResponseCode.INVALID_SIGNATURE, ( + f"Expected INVALID_SIGNATURE but got {ResponseCode(receipt.status).name}" + ) + except PrecheckError: + pass # Also acceptable: network rejects at precheck + finally: + # Cleanup: admin key was not changed, so old key still works + RegisteredNodeDeleteTransaction().set_registered_node_id(registered_node_id).freeze_with(admin_client).sign( + old_admin_key + ).execute(admin_client) + + +def test_registered_node_update_ip_to_domain_endpoint(admin_client): + """Test updating a registered node from an IP address endpoint to a domain name endpoint.""" + admin_key = PrivateKey.generate_ed25519() + + # Create registered node with an IP address endpoint + ip_endpoint = BlockNodeServiceEndpoint( + ip_address=bytes([10, 0, 0, 1]), + port=443, + requires_tls=True, + endpoint_apis=[BlockNodeApi.STATUS], + ) + receipt = ( + RegisteredNodeCreateTransaction() + .set_admin_key(admin_key.public_key()) + .set_description("ip endpoint node") + .set_service_endpoints([ip_endpoint]) + .freeze_with(admin_client) + .sign(admin_key) + .execute(admin_client) + ) + assert receipt.status == ResponseCode.SUCCESS + registered_node_id = receipt.registered_node_id + + try: + # Update to a domain name endpoint + domain_endpoint = BlockNodeServiceEndpoint( + domain_name="block.updated.com", + port=443, + requires_tls=True, + endpoint_apis=[BlockNodeApi.STATUS], + ) + receipt = ( + RegisteredNodeUpdateTransaction() + .set_registered_node_id(registered_node_id) + .set_service_endpoints([domain_endpoint]) + .freeze_with(admin_client) + .sign(admin_key) + .execute(admin_client) + ) + + assert receipt.status == ResponseCode.SUCCESS, ( + f"Registered node update IP to domain failed with status {ResponseCode(receipt.status).name}" + ) + finally: + # Cleanup + RegisteredNodeDeleteTransaction().set_registered_node_id(registered_node_id).freeze_with(admin_client).sign( + admin_key + ).execute(admin_client) diff --git a/tests/unit/associated_registered_nodes_test.py b/tests/unit/associated_registered_nodes_test.py new file mode 100644 index 000000000..eba6ee749 --- /dev/null +++ b/tests/unit/associated_registered_nodes_test.py @@ -0,0 +1,211 @@ +"""Tests for associated registered nodes on NodeCreate/NodeUpdate transactions.""" + +from __future__ import annotations + +import pytest + +from hiero_sdk_python.account.account_id import AccountId +from hiero_sdk_python.nodes.node_create_transaction import ( + NodeCreateParams, + NodeCreateTransaction, +) +from hiero_sdk_python.nodes.node_update_transaction import ( + NodeUpdateParams, + NodeUpdateTransaction, +) +from hiero_sdk_python.transaction.transaction import Transaction +from hiero_sdk_python.transaction.transaction_id import TransactionId + + +pytestmark = pytest.mark.unit + + +def _freeze(tx): + tx.transaction_id = TransactionId.generate(AccountId(0, 0, 100)) + tx.node_account_id = AccountId(0, 0, 3) + tx.freeze() + return tx + + +# --------------------------------------------------------------------------- +# NodeCreateTransaction – associated_registered_nodes +# --------------------------------------------------------------------------- + + +class TestNodeCreateAssociatedRegisteredNodes: + """Tests for associated_registered_nodes on NodeCreateTransaction.""" + + def test_serializes_associated_registered_nodes(self, mock_account_ids): + """Verify associated_registered_nodes are serialized into the protobuf body.""" + operator_id, _, node_account_id, _, _ = mock_account_ids + tx = NodeCreateTransaction() + tx.set_associated_registered_nodes([1, 2, 3]) + tx.operator_account_id = operator_id + tx.node_account_id = node_account_id + body = tx.build_transaction_body() + assert list(body.nodeCreate.associated_registered_node) == [1, 2, 3] + + def test_add_associated_registered_node_chains(self, mock_account_ids): + """Verify add_associated_registered_node returns self for chaining and appends values.""" + operator_id, _, node_account_id, _, _ = mock_account_ids + tx = NodeCreateTransaction() + result = tx.add_associated_registered_node(10) + assert result is tx + tx.add_associated_registered_node(20) + assert tx.associated_registered_nodes == [10, 20] + + tx.operator_account_id = operator_id + tx.node_account_id = node_account_id + body = tx.build_transaction_body() + assert list(body.nodeCreate.associated_registered_node) == [10, 20] + + def test_set_replaces_list(self): + """Verify set_associated_registered_nodes replaces the existing list.""" + tx = NodeCreateTransaction() + tx.add_associated_registered_node(1) + result = tx.set_associated_registered_nodes([5, 6]) + assert result is tx + assert tx.associated_registered_nodes == [5, 6] + + def test_from_bytes_round_trip(self): + """Verify associated_registered_nodes survive serialization and deserialization.""" + tx = NodeCreateTransaction() + tx.set_associated_registered_nodes([7, 8, 9]) + _freeze(tx) + + restored = Transaction.from_bytes(tx.to_bytes()) + assert isinstance(restored, NodeCreateTransaction) + assert list(restored.associated_registered_nodes) == [7, 8, 9] + + def test_preserves_behavior_when_unset(self, mock_account_ids): + """Verify an empty list is serialized when no nodes are set.""" + operator_id, _, node_account_id, _, _ = mock_account_ids + tx = NodeCreateTransaction() + tx.operator_account_id = operator_id + tx.node_account_id = node_account_id + body = tx.build_transaction_body() + assert len(body.nodeCreate.associated_registered_node) == 0 + + def test_default_empty_list(self): + """Verify default associated_registered_nodes is an empty list.""" + tx = NodeCreateTransaction() + assert tx.associated_registered_nodes == [] + + def test_constructor_compat_no_break(self): + """Verify backward compatibility when constructing with NodeCreateParams.""" + params = NodeCreateParams(account_id=AccountId(0, 0, 1)) + tx = NodeCreateTransaction(params) + assert tx.account_id == AccountId(0, 0, 1) + assert tx.associated_registered_nodes == [] + + +# --------------------------------------------------------------------------- +# NodeUpdateTransaction – associated_registered_nodes +# --------------------------------------------------------------------------- + + +class TestNodeUpdateAssociatedRegisteredNodes: + """Tests for associated_registered_nodes on NodeUpdateTransaction.""" + + def test_does_not_serialize_when_none(self, mock_account_ids): + """Verify no associated_registered_node_list field when nodes are not set.""" + operator_id, _, node_account_id, _, _ = mock_account_ids + tx = NodeUpdateTransaction() + tx.node_id = 1 + tx.operator_account_id = operator_id + tx.node_account_id = node_account_id + body = tx.build_transaction_body() + assert not body.nodeUpdate.HasField("associated_registered_node_list") + + def test_serializes_empty_when_cleared(self, mock_account_ids): + """Verify an explicit clear serializes an empty list in the protobuf.""" + operator_id, _, node_account_id, _, _ = mock_account_ids + tx = NodeUpdateTransaction() + tx.node_id = 1 + tx.clear_associated_registered_nodes() + tx.operator_account_id = operator_id + tx.node_account_id = node_account_id + body = tx.build_transaction_body() + assert body.nodeUpdate.HasField("associated_registered_node_list") + assert len(body.nodeUpdate.associated_registered_node_list.associated_registered_node) == 0 + + def test_serializes_non_empty(self, mock_account_ids): + """Verify non-empty associated_registered_nodes are serialized correctly.""" + operator_id, _, node_account_id, _, _ = mock_account_ids + tx = NodeUpdateTransaction() + tx.node_id = 1 + tx.set_associated_registered_nodes([10, 20]) + tx.operator_account_id = operator_id + tx.node_account_id = node_account_id + body = tx.build_transaction_body() + assert body.nodeUpdate.HasField("associated_registered_node_list") + assert list(body.nodeUpdate.associated_registered_node_list.associated_registered_node) == [10, 20] + + def test_add_initializes_and_appends(self): + """Verify add_associated_registered_node initializes the list and appends values.""" + tx = NodeUpdateTransaction() + assert tx.associated_registered_nodes is None + result = tx.add_associated_registered_node(5) + assert result is tx + assert tx.associated_registered_nodes == [5] + tx.add_associated_registered_node(6) + assert tx.associated_registered_nodes == [5, 6] + + def test_set_replaces_list(self): + """Verify set_associated_registered_nodes replaces the existing list.""" + tx = NodeUpdateTransaction() + tx.add_associated_registered_node(1) + result = tx.set_associated_registered_nodes([100, 200]) + assert result is tx + assert tx.associated_registered_nodes == [100, 200] + + def test_from_bytes_round_trip_non_empty(self): + """Verify non-empty associated_registered_nodes survive round-trip serialization.""" + tx = NodeUpdateTransaction() + tx.node_id = 5 + tx.set_associated_registered_nodes([10, 20, 30]) + _freeze(tx) + + restored = Transaction.from_bytes(tx.to_bytes()) + assert isinstance(restored, NodeUpdateTransaction) + assert list(restored.associated_registered_nodes) == [10, 20, 30] + + def test_from_bytes_round_trip_cleared(self): + """Verify cleared associated_registered_nodes survive round-trip serialization.""" + tx = NodeUpdateTransaction() + tx.node_id = 5 + tx.clear_associated_registered_nodes() + _freeze(tx) + + restored = Transaction.from_bytes(tx.to_bytes()) + assert isinstance(restored, NodeUpdateTransaction) + assert restored.associated_registered_nodes == [] + + def test_from_bytes_round_trip_unset(self): + """Verify unset associated_registered_nodes remain None after round-trip.""" + tx = NodeUpdateTransaction() + tx.node_id = 5 + _freeze(tx) + + restored = Transaction.from_bytes(tx.to_bytes()) + assert isinstance(restored, NodeUpdateTransaction) + assert restored.associated_registered_nodes is None + + def test_preserves_existing_behavior(self, mock_account_ids): + """Existing fields still work when associated_registered_nodes is not used.""" + operator_id, _, node_account_id, _, _ = mock_account_ids + tx = NodeUpdateTransaction() + tx.node_id = 1 + tx.set_description("hello") + tx.operator_account_id = operator_id + tx.node_account_id = node_account_id + body = tx.build_transaction_body() + assert body.nodeUpdate.description.value == "hello" + assert not body.nodeUpdate.HasField("associated_registered_node_list") + + def test_constructor_compat_no_break(self): + """Verify backward compatibility when constructing with NodeUpdateParams.""" + params = NodeUpdateParams(node_id=5) + tx = NodeUpdateTransaction(params) + assert tx.node_id == 5 + assert tx.associated_registered_nodes is None diff --git a/tests/unit/registered_node_hardening_test.py b/tests/unit/registered_node_hardening_test.py new file mode 100644 index 000000000..843f878cf --- /dev/null +++ b/tests/unit/registered_node_hardening_test.py @@ -0,0 +1,337 @@ +"""Hardening tests for registered node validation, status codes, retry, and exports.""" + +from __future__ import annotations + +import pytest + +from hiero_sdk_python.address_book.block_node_api import BlockNodeApi +from hiero_sdk_python.address_book.block_node_service_endpoint import BlockNodeServiceEndpoint +from hiero_sdk_python.address_book.general_service_endpoint import GeneralServiceEndpoint +from hiero_sdk_python.address_book.mirror_node_service_endpoint import MirrorNodeServiceEndpoint +from hiero_sdk_python.address_book.registered_service_endpoint import RegisteredServiceEndpoint +from hiero_sdk_python.crypto.private_key import PrivateKey +from hiero_sdk_python.nodes.registered_node_create_transaction import RegisteredNodeCreateTransaction +from hiero_sdk_python.nodes.registered_node_delete_transaction import RegisteredNodeDeleteTransaction +from hiero_sdk_python.nodes.registered_node_update_transaction import RegisteredNodeUpdateTransaction +from hiero_sdk_python.response_code import ResponseCode + + +pytestmark = pytest.mark.unit + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + + +def _mirror_ep() -> MirrorNodeServiceEndpoint: + return MirrorNodeServiceEndpoint(domain_name="m.example.com", port=443, requires_tls=True) + + +def _make_key(): + return PrivateKey.generate().public_key() + + +# --------------------------------------------------------------------------- +# Registered node status codes in ResponseCode enum +# --------------------------------------------------------------------------- + + +class TestRegisteredNodeStatusCodes: + """Verify all registered node status codes are present in the SDK ResponseCode enum.""" + + @pytest.mark.parametrize( + "name,value", + [ + ("INVALID_REGISTERED_NODE_ID", 529), + ("INVALID_REGISTERED_ENDPOINT", 530), + ("REGISTERED_ENDPOINTS_EXCEEDED_LIMIT", 531), + ("INVALID_REGISTERED_ENDPOINT_ADDRESS", 532), + ("INVALID_REGISTERED_ENDPOINT_TYPE", 533), + ("REGISTERED_NODE_STILL_ASSOCIATED", 534), + ("MAX_REGISTERED_NODES_EXCEEDED", 535), + ], + ) + def test_status_code_exists(self, name, value): + code = ResponseCode(value) + assert code.name == name + assert code.value == value + assert not code.is_unknown + + @pytest.mark.parametrize( + "value", + [529, 530, 531, 532, 533, 534, 535], + ) + def test_status_codes_are_not_retryable(self, value): + """Registered node statuses must NOT be in the retryable set.""" + # The SDK retry classifier only retries these 4 statuses + retryable = { + ResponseCode.PLATFORM_TRANSACTION_NOT_CREATED, + ResponseCode.PLATFORM_NOT_ACTIVE, + ResponseCode.BUSY, + ResponseCode.INVALID_NODE_ACCOUNT, + } + code = ResponseCode(value) + assert code not in retryable + + def test_status_codes_match_protobuf(self): + """SDK enum values must match generated protobuf values.""" + from hiero_sdk_python.hapi.services.response_code_pb2 import ResponseCodeEnum + + for name in [ + "INVALID_REGISTERED_NODE_ID", + "INVALID_REGISTERED_ENDPOINT", + "REGISTERED_ENDPOINTS_EXCEEDED_LIMIT", + "INVALID_REGISTERED_ENDPOINT_ADDRESS", + "INVALID_REGISTERED_ENDPOINT_TYPE", + "REGISTERED_NODE_STILL_ASSOCIATED", + "MAX_REGISTERED_NODES_EXCEEDED", + ]: + proto_val = ResponseCodeEnum.Value(name) + sdk_code = ResponseCode[name] + assert sdk_code.value == proto_val, f"{name}: SDK={sdk_code.value} != proto={proto_val}" + + +# --------------------------------------------------------------------------- +# Endpoint validation edge cases +# --------------------------------------------------------------------------- + + +class TestEndpointValidationEdgeCases: + """Tests for edge-case validation of RegisteredServiceEndpoint fields.""" + + def test_ip_address_not_bytes(self): + """Verify passing a string as ip_address raises ValueError.""" + with pytest.raises(ValueError, match="ip_address"): + RegisteredServiceEndpoint(ip_address="192.168.1.1", port=80) + + def test_domain_name_not_str(self): + """Verify passing a non-string as domain_name raises an error.""" + with pytest.raises((ValueError, TypeError)): + RegisteredServiceEndpoint(domain_name=12345, port=80) + + def test_port_not_int(self): + """Verify passing a string as port raises ValueError.""" + with pytest.raises(ValueError, match="port"): + RegisteredServiceEndpoint(domain_name="example.com", port="80") + + def test_port_is_bool(self): + """Verify passing a bool as port raises ValueError.""" + with pytest.raises(ValueError, match="port"): + RegisteredServiceEndpoint(domain_name="example.com", port=True) + + def test_requires_tls_not_bool(self): + """Verify passing a non-bool as requires_tls raises ValueError.""" + with pytest.raises(ValueError, match="requires_tls"): + RegisteredServiceEndpoint(domain_name="example.com", port=80, requires_tls="yes") + + def test_block_node_invalid_endpoint_api_item(self): + """Verify an invalid BlockNodeApi value raises an error.""" + with pytest.raises((ValueError, KeyError)): + BlockNodeServiceEndpoint( + domain_name="block.example.com", + port=443, + endpoint_apis=[999], # not a valid BlockNodeApi + ) + + def test_block_node_endpoint_apis_accepts_int_enum_values(self): + """BlockNodeApi(0) is OTHER — ints that map to valid enum values are accepted.""" + ep = BlockNodeServiceEndpoint( + domain_name="block.example.com", + port=443, + endpoint_apis=[0, 1], # OTHER=0, STATUS=1 + ) + assert ep.endpoint_apis == [BlockNodeApi.OTHER, BlockNodeApi.STATUS] + + def test_general_endpoint_description_multibyte_utf8(self): + """Verify a description exceeding 100 UTF-8 bytes raises ValueError.""" + # Each emoji is 4 UTF-8 bytes. 26 emojis = 104 bytes > 100 limit. + long_desc = "\U0001f600" * 26 + assert len(long_desc.encode("utf-8")) > 100 + with pytest.raises(ValueError, match="100 UTF-8 bytes"): + GeneralServiceEndpoint(domain_name="g.example.com", port=80, description=long_desc) + + def test_general_endpoint_description_exactly_100_bytes(self): + """Verify a description of exactly 100 UTF-8 bytes is accepted.""" + # 25 emojis = exactly 100 UTF-8 bytes — should succeed + desc = "\U0001f600" * 25 + assert len(desc.encode("utf-8")) == 100 + ep = GeneralServiceEndpoint(domain_name="g.example.com", port=80, description=desc) + assert ep.description == desc + + +# --------------------------------------------------------------------------- +# RegisteredNodeCreateTransaction validation +# --------------------------------------------------------------------------- + + +class TestRegisteredNodeCreateTransactionValidation: + """Tests for RegisteredNodeCreateTransaction build validation.""" + + def test_valid_build_succeeds(self): + """Verify a valid transaction with admin_key and endpoints builds successfully.""" + tx = RegisteredNodeCreateTransaction() + tx.admin_key = _make_key() + tx.set_service_endpoints([_mirror_ep()]) + body = tx._build_proto_body() + assert body.HasField("admin_key") + + # endpoint wrapper assertions + endpoint = body.service_endpoint[0] + + assert endpoint.HasField("mirror_node") + assert not endpoint.HasField("block_node") + assert not endpoint.HasField("rpc_relay") + assert not endpoint.HasField("general_service") + + # endpoint field assertions + assert endpoint.domain_name == "m.example.com" + assert endpoint.port == 443 + assert endpoint.requires_tls is True + + def test_build_without_admin_key_succeeds(self): + """admin_key is no longer validated client-side; the node handles it.""" + tx = RegisteredNodeCreateTransaction() + tx.set_service_endpoints([_mirror_ep()]) + body = tx._build_proto_body() + assert not body.HasField("admin_key") + + def test_build_without_endpoints_succeeds(self): + """service_endpoints are no longer validated client-side; the node handles it.""" + tx = RegisteredNodeCreateTransaction() + tx.admin_key = _make_key() + body = tx._build_proto_body() + assert len(body.service_endpoint) == 0 + + +# --------------------------------------------------------------------------- +# RegisteredNodeUpdateTransaction validation +# --------------------------------------------------------------------------- + + +class TestRegisteredNodeUpdateTransactionValidation: + """Tests for RegisteredNodeUpdateTransaction build validation.""" + + def test_build_with_valid_id_succeeds(self): + """Verify building with a valid registered_node_id succeeds.""" + tx = RegisteredNodeUpdateTransaction() + tx.registered_node_id = 1 + body = tx._build_proto_body() + assert body.registered_node_id == 1 + + def test_build_with_endpoints_succeeds(self): + """Verify building with service endpoints succeeds.""" + tx = RegisteredNodeUpdateTransaction() + tx.registered_node_id = 1 + tx.service_endpoints = [_mirror_ep()] + body = tx._build_proto_body() + assert len(body.service_endpoint) == 1 + + +# --------------------------------------------------------------------------- +# RegisteredNodeDeleteTransaction validation +# --------------------------------------------------------------------------- + + +class TestRegisteredNodeDeleteTransactionValidation: + """Tests for RegisteredNodeDeleteTransaction build validation.""" + + def test_registered_node_id_zero_fails(self): + """Verify registered_node_id of zero raises ValueError.""" + tx = RegisteredNodeDeleteTransaction() + tx.registered_node_id = 0 + with pytest.raises(ValueError, match="positive integer"): + tx._build_proto_body() + + def test_registered_node_id_negative_fails(self): + """Verify negative registered_node_id raises ValueError.""" + tx = RegisteredNodeDeleteTransaction() + tx.registered_node_id = -1 + with pytest.raises(ValueError, match="positive integer"): + tx._build_proto_body() + + def test_registered_node_id_bool_fails(self): + """Verify bool registered_node_id raises ValueError.""" + tx = RegisteredNodeDeleteTransaction() + tx.registered_node_id = True + with pytest.raises(ValueError, match="positive integer"): + tx._build_proto_body() + + +# --------------------------------------------------------------------------- +# associated_registered_nodes rejects non-int +# --------------------------------------------------------------------------- + + +class TestAssociatedRegisteredNodesNonInt: + """Tests for associated_registered_nodes accepting non-int values.""" + + def test_node_create_accepts_any_values(self): + """Validation is now delegated to the consensus node.""" + from hiero_sdk_python.nodes.node_create_transaction import NodeCreateTransaction + + tx = NodeCreateTransaction() + tx.set_associated_registered_nodes(["abc"]) + assert tx.associated_registered_nodes == ["abc"] + + def test_node_update_accepts_any_values(self): + """Validation is now delegated to the consensus node.""" + from hiero_sdk_python.nodes.node_update_transaction import NodeUpdateTransaction + + tx = NodeUpdateTransaction() + tx.set_associated_registered_nodes([1.5]) + assert tx.associated_registered_nodes == [1.5] + + +# --------------------------------------------------------------------------- +# Public imports +# --------------------------------------------------------------------------- + + +class TestPublicImports: + """All registered node public classes must be importable from the package.""" + + @pytest.mark.parametrize( + "name", + [ + "BlockNodeApi", + "RegisteredServiceEndpoint", + "BlockNodeServiceEndpoint", + "MirrorNodeServiceEndpoint", + "RpcRelayServiceEndpoint", + "GeneralServiceEndpoint", + "RegisteredNodeCreateTransaction", + "RegisteredNodeUpdateTransaction", + "RegisteredNodeDeleteTransaction", + "RegisteredNode", + "RegisteredNodeAddressBook", + "RegisteredNodeAddressBookQuery", + ], + ) + def test_importable(self, name): + import hiero_sdk_python + + cls = getattr(hiero_sdk_python, name) + assert cls is not None + + @pytest.mark.parametrize( + "name", + [ + "BlockNodeApi", + "BlockNodeServiceEndpoint", + "GeneralServiceEndpoint", + "MirrorNodeServiceEndpoint", + "RegisteredServiceEndpoint", + "RpcRelayServiceEndpoint", + "RegisteredNodeCreateTransaction", + "RegisteredNodeUpdateTransaction", + "RegisteredNodeDeleteTransaction", + "RegisteredNode", + "RegisteredNodeAddressBook", + "RegisteredNodeAddressBookQuery", + ], + ) + def test_in_all(self, name): + import hiero_sdk_python + + assert name in hiero_sdk_python.__all__ diff --git a/tests/unit/registered_node_model_test.py b/tests/unit/registered_node_model_test.py new file mode 100644 index 000000000..bbe696177 --- /dev/null +++ b/tests/unit/registered_node_model_test.py @@ -0,0 +1,637 @@ +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from hiero_sdk_python.address_book.block_node_api import BlockNodeApi +from hiero_sdk_python.address_book.block_node_service_endpoint import BlockNodeServiceEndpoint +from hiero_sdk_python.address_book.general_service_endpoint import GeneralServiceEndpoint +from hiero_sdk_python.address_book.mirror_node_service_endpoint import MirrorNodeServiceEndpoint +from hiero_sdk_python.address_book.registered_node import RegisteredNode +from hiero_sdk_python.address_book.registered_node_address_book import RegisteredNodeAddressBook +from hiero_sdk_python.address_book.registered_node_address_book_query import ( + RegisteredNodeAddressBookQuery, +) +from hiero_sdk_python.address_book.rpc_relay_service_endpoint import RpcRelayServiceEndpoint +from hiero_sdk_python.crypto.private_key import PrivateKey +from hiero_sdk_python.hapi.services.state.addressbook.registered_node_pb2 import ( + RegisteredNode as RegisteredNodeProto, +) + + +pytestmark = pytest.mark.unit + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + + +def _make_key(): + """Return a fresh Ed25519 key pair for testing.""" + private = PrivateKey.generate() + return private.public_key() + + +def _block_endpoint() -> BlockNodeServiceEndpoint: + return BlockNodeServiceEndpoint( + domain_name="block.example.com", + port=443, + requires_tls=True, + endpoint_apis=[BlockNodeApi.PUBLISH, BlockNodeApi.SUBSCRIBE_STREAM], + ) + + +def _mirror_endpoint() -> MirrorNodeServiceEndpoint: + return MirrorNodeServiceEndpoint( + domain_name="mirror.example.com", + port=5600, + requires_tls=True, + ) + + +def _rpc_relay_endpoint() -> RpcRelayServiceEndpoint: + return RpcRelayServiceEndpoint( + domain_name="rpc.example.com", + port=8545, + requires_tls=False, + ) + + +def _general_endpoint() -> GeneralServiceEndpoint: + return GeneralServiceEndpoint( + domain_name="general.example.com", + port=9000, + requires_tls=False, + description="general node", + ) + + +# --------------------------------------------------------------------------- +# RegisteredNode +# --------------------------------------------------------------------------- + + +class TestRegisteredNode: + def test_from_proto_minimal(self): + proto = RegisteredNodeProto(registered_node_id=42) + node = RegisteredNode._from_proto(proto) + assert node.registered_node_id == 42 + assert node.admin_key is None + assert node.description is None + assert node.service_endpoints == () + + def test_from_proto_with_admin_key(self): + pub = _make_key() + proto = RegisteredNodeProto( + registered_node_id=1, + admin_key=pub._to_proto(), + ) + node = RegisteredNode._from_proto(proto) + assert node.admin_key is not None + assert node.admin_key.to_bytes_raw() == pub.to_bytes_raw() + + def test_from_proto_with_description(self): + proto = RegisteredNodeProto( + registered_node_id=5, + description="test node", + ) + node = RegisteredNode._from_proto(proto) + assert node.description == "test node" + + def test_from_proto_with_service_endpoints(self): + block_ep = _block_endpoint() + mirror_ep = _mirror_endpoint() + proto = RegisteredNodeProto( + registered_node_id=10, + service_endpoint=[block_ep._to_proto(), mirror_ep._to_proto()], + ) + node = RegisteredNode._from_proto(proto) + assert len(node.service_endpoints) == 2 + + def test_from_proto_block_node_endpoint_type(self): + block_ep = _block_endpoint() + proto = RegisteredNodeProto( + registered_node_id=10, + service_endpoint=[block_ep._to_proto()], + ) + node = RegisteredNode._from_proto(proto) + assert isinstance(node.service_endpoints[0], BlockNodeServiceEndpoint) + + def test_from_proto_mirror_node_endpoint_type(self): + mirror_ep = _mirror_endpoint() + proto = RegisteredNodeProto( + registered_node_id=10, + service_endpoint=[mirror_ep._to_proto()], + ) + node = RegisteredNode._from_proto(proto) + assert isinstance(node.service_endpoints[0], MirrorNodeServiceEndpoint) + + def test_from_proto_rpc_relay_endpoint_type(self): + rpc_ep = _rpc_relay_endpoint() + proto = RegisteredNodeProto( + registered_node_id=10, + service_endpoint=[rpc_ep._to_proto()], + ) + node = RegisteredNode._from_proto(proto) + assert isinstance(node.service_endpoints[0], RpcRelayServiceEndpoint) + + def test_from_proto_general_endpoint_type(self): + gen_ep = _general_endpoint() + proto = RegisteredNodeProto( + registered_node_id=10, + service_endpoint=[gen_ep._to_proto()], + ) + node = RegisteredNode._from_proto(proto) + assert isinstance(node.service_endpoints[0], GeneralServiceEndpoint) + + def test_to_proto_round_trip(self): + pub = _make_key() + mirror_ep = _mirror_endpoint() + node = RegisteredNode( + registered_node_id=7, + admin_key=pub, + description="round-trip", + service_endpoints=(mirror_ep,), + ) + proto = node._to_proto() + restored = RegisteredNode._from_proto(proto) + assert restored.registered_node_id == 7 + assert restored.admin_key.to_bytes_raw() == pub.to_bytes_raw() + assert restored.description == "round-trip" + assert len(restored.service_endpoints) == 1 + + def test_rejects_zero_id(self): + with pytest.raises(ValueError, match="positive integer"): + RegisteredNode(registered_node_id=0) + + def test_rejects_negative_id(self): + with pytest.raises(ValueError, match="positive integer"): + RegisteredNode(registered_node_id=-3) + + def test_rejects_non_int_id(self): + with pytest.raises(ValueError, match="positive integer"): + RegisteredNode(registered_node_id="abc") # type: ignore[arg-type] + + def test_rejects_bool_id(self): + with pytest.raises(ValueError, match="positive integer"): + RegisteredNode(registered_node_id=True) + + def test_immutable_service_endpoints(self): + node = RegisteredNode(registered_node_id=1, service_endpoints=[_mirror_endpoint()]) + assert isinstance(node.service_endpoints, tuple) + + def test_repr(self): + node = RegisteredNode(registered_node_id=42, description="hello") + r = repr(node) + assert "42" in r + assert "hello" in r + + def test_from_dict_minimal(self): + data = {"registered_node_id": 42} + node = RegisteredNode._from_dict(data) + assert node.registered_node_id == 42 + assert node.admin_key is None + assert node.description is None + assert node.service_endpoints == () + + def test_from_dict_with_admin_key_ed25519(self): + pub = _make_key() + data = { + "registered_node_id": 1, + "admin_key": {"_type": "ED25519", "key": pub.to_string_raw()}, + } + node = RegisteredNode._from_dict(data) + assert node.admin_key is not None + + def test_from_dict_with_description(self): + data = {"registered_node_id": 5, "description": "test node"} + node = RegisteredNode._from_dict(data) + assert node.description == "test node" + + def test_from_dict_with_mirror_endpoint(self): + data = { + "registered_node_id": 10, + "service_endpoints": [ + { + "domain_name": "mirror.example.com", + "port": 5600, + "requires_tls": True, + "type": "MIRROR_NODE", + } + ], + } + node = RegisteredNode._from_dict(data) + assert len(node.service_endpoints) == 1 + assert isinstance(node.service_endpoints[0], MirrorNodeServiceEndpoint) + + def test_from_dict_with_block_endpoint(self): + data = { + "registered_node_id": 10, + "service_endpoints": [ + { + "domain_name": "block.example.com", + "port": 443, + "requires_tls": True, + "type": "BLOCK_NODE", + "block_node": {"endpoint_apis": ["PUBLISH", "SUBSCRIBE_STREAM"]}, + } + ], + } + node = RegisteredNode._from_dict(data) + ep = node.service_endpoints[0] + assert isinstance(ep, BlockNodeServiceEndpoint) + assert BlockNodeApi.PUBLISH in ep.endpoint_apis + + def test_from_dict_with_general_endpoint(self): + data = { + "registered_node_id": 10, + "service_endpoints": [ + { + "domain_name": "general.example.com", + "port": 9000, + "requires_tls": False, + "type": "GENERAL_SERVICE", + "general_service": {"description": "my service"}, + } + ], + } + node = RegisteredNode._from_dict(data) + ep = node.service_endpoints[0] + assert isinstance(ep, GeneralServiceEndpoint) + assert ep.description == "my service" + + def test_from_dict_with_rpc_relay_endpoint(self): + data = { + "registered_node_id": 10, + "service_endpoints": [ + { + "domain_name": "rpc.example.com", + "port": 8545, + "requires_tls": False, + "type": "RPC_RELAY", + } + ], + } + node = RegisteredNode._from_dict(data) + assert isinstance(node.service_endpoints[0], RpcRelayServiceEndpoint) + + +# --------------------------------------------------------------------------- +# RegisteredNodeAddressBook +# --------------------------------------------------------------------------- + + +class TestRegisteredNodeAddressBook: + def test_empty(self): + book = RegisteredNodeAddressBook() + assert len(book) == 0 + assert list(book) == [] + + def test_multiple_nodes(self): + nodes = ( + RegisteredNode(registered_node_id=1), + RegisteredNode(registered_node_id=2), + RegisteredNode(registered_node_id=3), + ) + book = RegisteredNodeAddressBook(nodes=nodes) + assert len(book) == 3 + assert book[0].registered_node_id == 1 + assert book[2].registered_node_id == 3 + + def test_iteration(self): + nodes = ( + RegisteredNode(registered_node_id=10), + RegisteredNode(registered_node_id=20), + ) + book = RegisteredNodeAddressBook(nodes=nodes) + ids = [n.registered_node_id for n in book] + assert ids == [10, 20] + + def test_len(self): + book = RegisteredNodeAddressBook(nodes=(RegisteredNode(registered_node_id=1),)) + assert len(book) == 1 + + def test_repr(self): + book = RegisteredNodeAddressBook( + nodes=(RegisteredNode(registered_node_id=1), RegisteredNode(registered_node_id=2)) + ) + assert "2" in repr(book) + + def test_nodes_property_is_tuple(self): + book = RegisteredNodeAddressBook(nodes=[RegisteredNode(registered_node_id=1)]) + assert isinstance(book.nodes, tuple) + + +# --------------------------------------------------------------------------- +# RegisteredNodeAddressBookQuery +# --------------------------------------------------------------------------- + + +class TestRegisteredNodeAddressBookQuery: + def test_importable(self): + q = RegisteredNodeAddressBookQuery() + assert q is not None + + def test_execute_rejects_none_client(self): + q = RegisteredNodeAddressBookQuery() + with pytest.raises(ValueError, match="client must not be None"): + q.execute(None) + + def test_set_max_registered_node_count(self): + q = RegisteredNodeAddressBookQuery() + result = q.set_max_registered_node_count(10) + assert result is q # chaining + + def test_set_max_registered_node_count_rejects_zero(self): + q = RegisteredNodeAddressBookQuery() + with pytest.raises(ValueError, match="positive integer"): + q.set_max_registered_node_count(0) + + def test_set_max_registered_node_count_rejects_negative(self): + q = RegisteredNodeAddressBookQuery() + with pytest.raises(ValueError, match="positive integer"): + q.set_max_registered_node_count(-1) + + def test_set_max_registered_node_count_rejects_bool(self): + q = RegisteredNodeAddressBookQuery() + with pytest.raises(ValueError, match="positive integer"): + q.set_max_registered_node_count(True) + + def test_set_max_registered_node_count_rejects_float(self): + q = RegisteredNodeAddressBookQuery() + with pytest.raises(ValueError, match="positive integer"): + q.set_max_registered_node_count(1.0) + + def test_set_max_registered_node_count_rejects_string(self): + q = RegisteredNodeAddressBookQuery() + with pytest.raises(ValueError, match="positive integer"): + q.set_max_registered_node_count("10") + + def test_set_registered_node_id(self): + q = RegisteredNodeAddressBookQuery() + result = q.set_registered_node_id(5) + assert result is q + + def test_set_registered_node_id_rejects_negative(self): + q = RegisteredNodeAddressBookQuery() + with pytest.raises(ValueError, match="non-negative integer"): + q.set_registered_node_id(-1) + + def test_set_registered_node_id_rejects_bool(self): + q = RegisteredNodeAddressBookQuery() + with pytest.raises(ValueError, match="non-negative integer"): + q.set_registered_node_id(True) + + def test_set_limit(self): + q = RegisteredNodeAddressBookQuery() + result = q.set_limit(50) + assert result is q + + def test_set_limit_rejects_zero(self): + q = RegisteredNodeAddressBookQuery() + with pytest.raises(ValueError, match="positive integer"): + q.set_limit(0) + + def test_set_max_attempts(self): + q = RegisteredNodeAddressBookQuery() + result = q.set_max_attempts(5) + assert result is q + + def test_set_max_attempts_rejects_zero(self): + q = RegisteredNodeAddressBookQuery() + with pytest.raises(ValueError, match="positive integer"): + q.set_max_attempts(0) + + def test_set_max_backoff(self): + q = RegisteredNodeAddressBookQuery() + result = q.set_max_backoff(10.0) + assert result is q + + def test_set_max_backoff_rejects_zero(self): + q = RegisteredNodeAddressBookQuery() + with pytest.raises(ValueError, match="positive number"): + q.set_max_backoff(0) + + def test_build_initial_path_default(self): + q = RegisteredNodeAddressBookQuery() + path = q._build_initial_path() + assert "/api/v1/network/registered-nodes" in path + assert "limit=25" in path + + def test_build_initial_path_with_node_id(self): + q = RegisteredNodeAddressBookQuery() + q.set_registered_node_id(7) + path = q._build_initial_path() + assert "registerednode.id=7" in path + + def test_next_page_path_with_link(self): + data = {"links": {"next": "/api/v1/network/registered-nodes?limit=25®isterednode.id=gt:10"}} + assert RegisteredNodeAddressBookQuery._next_page_path(data) is not None + + def test_next_page_path_without_link(self): + data = {"links": {"next": None}} + assert RegisteredNodeAddressBookQuery._next_page_path(data) is None + + def test_next_page_path_no_links_key(self): + data = {} + assert RegisteredNodeAddressBookQuery._next_page_path(data) is None + + +# --------------------------------------------------------------------------- +# RegisteredNode._from_dict edge cases +# --------------------------------------------------------------------------- + + +class TestRegisteredNodeFromDictEdgeCases: + """Tests for RegisteredNode._from_dict with different key types.""" + + def test_from_dict_with_ecdsa_admin_key(self): + """Verify _from_dict handles ECDSA_SECP256K1 keys.""" + pub = PrivateKey.generate_ecdsa().public_key() + data = { + "registered_node_id": 1, + "admin_key": {"_type": "ECDSA_SECP256K1", "key": pub.to_string_raw()}, + } + node = RegisteredNode._from_dict(data) + assert node.admin_key is not None + + def test_from_dict_with_generic_key_hex(self): + """Verify _from_dict handles a generic key hex without explicit _type.""" + pub = PrivateKey.generate().public_key() + data = { + "registered_node_id": 2, + "admin_key": {"key": pub.to_string()}, + } + node = RegisteredNode._from_dict(data) + assert node.admin_key is not None + + def test_from_dict_empty_description_is_none(self): + """Verify _from_dict treats empty description as None.""" + data = {"registered_node_id": 3, "description": ""} + node = RegisteredNode._from_dict(data) + assert node.description is None + + def test_from_dict_with_ip_endpoint(self): + """Verify _from_dict handles endpoints with ip_address.""" + data = { + "registered_node_id": 4, + "service_endpoints": [ + { + "ip_address": "127.0.0.1", + "port": 443, + "requires_tls": True, + "type": "MIRROR_NODE", + } + ], + } + node = RegisteredNode._from_dict(data) + assert len(node.service_endpoints) == 1 + assert node.service_endpoints[0].ip_address == b"\x7f\x00\x00\x01" + + +# --------------------------------------------------------------------------- +# RegisteredNodeAddressBookQuery execution with mocking +# --------------------------------------------------------------------------- + + +class TestRegisteredNodeAddressBookQueryExecution: + """Tests for query execution, URL building, and retry logic using mocks.""" + + def _make_client(self, rest_url="http://testnet.example.com/api/v1"): + """Create a mock client with a configurable mirror REST URL.""" + client = MagicMock() + client.network.get_mirror_rest_url.return_value = rest_url + return client + + def test_build_base_url_strips_api_v1(self): + """Verify _build_base_url strips /api/v1 suffix.""" + q = RegisteredNodeAddressBookQuery() + client = self._make_client("http://mirror.example.com/api/v1") + assert q._build_base_url(client) == "http://mirror.example.com" + + def test_build_base_url_localhost_port_replacement(self): + """Verify localhost:5551 is replaced with :8084.""" + q = RegisteredNodeAddressBookQuery() + client = self._make_client("http://localhost:5551/api/v1") + assert ":8084" in q._build_base_url(client) + assert ":5551" not in q._build_base_url(client) + + def test_build_base_url_127_port_replacement(self): + """Verify 127.0.0.1:5551 is replaced with :8084.""" + q = RegisteredNodeAddressBookQuery() + client = self._make_client("http://127.0.0.1:5551/api/v1") + assert ":8084" in q._build_base_url(client) + + @patch("hiero_sdk_python.address_book.registered_node_address_book_query.requests.get") + def test_execute_single_page(self, mock_get): + """Verify execute returns nodes from a single-page response.""" + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "registered_nodes": [ + {"registered_node_id": 1, "service_endpoints": []}, + {"registered_node_id": 2, "service_endpoints": []}, + ], + "links": {"next": None}, + } + mock_get.return_value = mock_resp + + q = RegisteredNodeAddressBookQuery() + client = self._make_client() + result = q.execute(client) + + assert isinstance(result, RegisteredNodeAddressBook) + assert len(result) == 2 + assert result[0].registered_node_id == 1 + + @patch("hiero_sdk_python.address_book.registered_node_address_book_query.requests.get") + def test_execute_with_pagination(self, mock_get): + """Verify execute follows pagination links.""" + page1 = MagicMock() + page1.status_code = 200 + page1.json.return_value = { + "registered_nodes": [{"registered_node_id": 1, "service_endpoints": []}], + "links": {"next": "/api/v1/network/registered-nodes?limit=1®isterednode.id=gt:1"}, + } + page2 = MagicMock() + page2.status_code = 200 + page2.json.return_value = { + "registered_nodes": [{"registered_node_id": 2, "service_endpoints": []}], + "links": {"next": None}, + } + mock_get.side_effect = [page1, page2] + + q = RegisteredNodeAddressBookQuery().set_limit(1) + result = q.execute(self._make_client()) + + assert len(result) == 2 + assert mock_get.call_count == 2 + + @patch("hiero_sdk_python.address_book.registered_node_address_book_query.requests.get") + def test_execute_respects_max_count(self, mock_get): + """Verify execute stops when max_registered_node_count is reached.""" + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "registered_nodes": [ + {"registered_node_id": 1, "service_endpoints": []}, + {"registered_node_id": 2, "service_endpoints": []}, + {"registered_node_id": 3, "service_endpoints": []}, + ], + "links": {"next": "/api/v1/network/registered-nodes?limit=25®isterednode.id=gt:3"}, + } + mock_get.return_value = mock_resp + + q = RegisteredNodeAddressBookQuery().set_max_registered_node_count(2) + result = q.execute(self._make_client()) + + assert len(result) == 2 + + @patch("hiero_sdk_python.address_book.registered_node_address_book_query.requests.get") + def test_fetch_page_non_retryable_error(self, mock_get): + """Verify non-retryable HTTP errors raise immediately.""" + mock_resp = MagicMock() + mock_resp.status_code = 404 + mock_resp.text = "Not Found" + mock_get.return_value = mock_resp + + q = RegisteredNodeAddressBookQuery().set_max_attempts(3) + with pytest.raises(RuntimeError, match="HTTP 404"): + q._fetch_page("http://example.com/api/v1/network/registered-nodes") + assert mock_get.call_count == 1 + + @patch("hiero_sdk_python.address_book.registered_node_address_book_query.time.sleep") + @patch("hiero_sdk_python.address_book.registered_node_address_book_query.requests.get") + def test_fetch_page_retries_on_503(self, mock_get, mock_sleep): + """Verify retryable HTTP errors are retried.""" + fail_resp = MagicMock() + fail_resp.status_code = 503 + fail_resp.text = "Service Unavailable" + + ok_resp = MagicMock() + ok_resp.status_code = 200 + ok_resp.json.return_value = {"registered_nodes": []} + + mock_get.side_effect = [fail_resp, ok_resp] + + q = RegisteredNodeAddressBookQuery().set_max_attempts(3) + result = q._fetch_page("http://example.com/test") + + assert result == {"registered_nodes": []} + assert mock_get.call_count == 2 + assert mock_sleep.call_count == 1 + + @patch("hiero_sdk_python.address_book.registered_node_address_book_query.time.sleep") + @patch("hiero_sdk_python.address_book.registered_node_address_book_query.requests.get") + def test_fetch_page_exhausts_retries(self, mock_get, _mock_sleep): + """Verify exhausting retries raises RuntimeError.""" + import requests as req + + mock_get.side_effect = req.ConnectionError("refused") + + q = RegisteredNodeAddressBookQuery().set_max_attempts(2).set_max_backoff(0.1) + with pytest.raises(RuntimeError, match="Failed to fetch"): + q._fetch_page("http://example.com/test") + assert mock_get.call_count == 2 diff --git a/tests/unit/registered_node_tck_aligned_test.py b/tests/unit/registered_node_tck_aligned_test.py new file mode 100644 index 000000000..be8459faa --- /dev/null +++ b/tests/unit/registered_node_tck_aligned_test.py @@ -0,0 +1,207 @@ +""" +TCK-aligned unit tests for registered node transactions. + +These tests mirror the structure expected by the Hedera TCK (Technology +Compatibility Kit) for registered node operations. When TCK handlers are +added for registered nodes, these tests document the expected behaviour. + +TODO: Add TCK JSON-RPC handlers (tck/handlers/registered_node.py) and + corresponding param/response dataclasses once the TCK specification + for registered nodes is finalized. +""" + +from __future__ import annotations + +import pytest + +from hiero_sdk_python.address_book.block_node_api import BlockNodeApi +from hiero_sdk_python.address_book.block_node_service_endpoint import BlockNodeServiceEndpoint +from hiero_sdk_python.address_book.general_service_endpoint import GeneralServiceEndpoint +from hiero_sdk_python.address_book.mirror_node_service_endpoint import MirrorNodeServiceEndpoint +from hiero_sdk_python.address_book.rpc_relay_service_endpoint import RpcRelayServiceEndpoint +from hiero_sdk_python.crypto.private_key import PrivateKey +from hiero_sdk_python.nodes.registered_node_create_transaction import RegisteredNodeCreateTransaction +from hiero_sdk_python.nodes.registered_node_delete_transaction import RegisteredNodeDeleteTransaction +from hiero_sdk_python.nodes.registered_node_update_transaction import RegisteredNodeUpdateTransaction + + +pytestmark = pytest.mark.unit + + +# --------------------------------------------------------------------------- +# RegisteredNodeCreateTransaction – TCK scenarios +# --------------------------------------------------------------------------- + + +class TestTckRegisteredNodeCreate: + """TCK-style create scenarios.""" + + def test_create_with_admin_key_and_block_endpoint(self): + """TCK: createRegisteredNode with adminKey + BlockNodeServiceEndpoint.""" + admin_key = PrivateKey.generate_ed25519().public_key() + ep = BlockNodeServiceEndpoint( + domain_name="block.tck.example.com", + port=443, + requires_tls=True, + endpoint_apis=[BlockNodeApi.STATUS, BlockNodeApi.SUBSCRIBE_STREAM], + ) + tx = RegisteredNodeCreateTransaction() + tx.set_admin_key(admin_key) + tx.set_description("tck block node") + tx.set_service_endpoints([ep]) + body = tx._build_proto_body() + assert body.admin_key is not None + assert body.description == "tck block node" + assert len(body.service_endpoint) == 1 + + def test_create_with_all_endpoint_types(self): + """TCK: createRegisteredNode with every endpoint subtype.""" + admin_key = PrivateKey.generate_ed25519().public_key() + endpoints = [ + BlockNodeServiceEndpoint( + domain_name="block.tck.example.com", + port=443, + requires_tls=True, + endpoint_apis=[BlockNodeApi.PUBLISH], + ), + MirrorNodeServiceEndpoint( + domain_name="mirror.tck.example.com", + port=5600, + requires_tls=True, + ), + RpcRelayServiceEndpoint( + domain_name="rpc.tck.example.com", + port=7546, + requires_tls=False, + ), + GeneralServiceEndpoint( + domain_name="general.tck.example.com", + port=8080, + requires_tls=False, + description="general svc", + ), + ] + tx = RegisteredNodeCreateTransaction() + tx.set_admin_key(admin_key) + tx.set_service_endpoints(endpoints) + body = tx._build_proto_body() + assert len(body.service_endpoint) == 4 + + def test_create_without_admin_key_builds(self): + """TCK: createRegisteredNode without adminKey should build (node validates).""" + ep = BlockNodeServiceEndpoint( + domain_name="block.tck.example.com", + port=443, + requires_tls=True, + endpoint_apis=[BlockNodeApi.STATUS], + ) + tx = RegisteredNodeCreateTransaction() + tx.set_service_endpoints([ep]) + body = tx._build_proto_body() + assert not body.HasField("admin_key") + + def test_create_without_endpoints_builds(self): + """TCK: createRegisteredNode with empty endpoints should build (node validates).""" + admin_key = PrivateKey.generate_ed25519().public_key() + tx = RegisteredNodeCreateTransaction() + tx.set_admin_key(admin_key) + body = tx._build_proto_body() + assert len(body.service_endpoint) == 0 + + def test_create_description_optional(self): + """TCK: createRegisteredNode without description sets empty string.""" + admin_key = PrivateKey.generate_ed25519().public_key() + ep = MirrorNodeServiceEndpoint( + domain_name="mirror.tck.example.com", + port=5600, + requires_tls=True, + ) + tx = RegisteredNodeCreateTransaction() + tx.set_admin_key(admin_key) + tx.set_service_endpoints([ep]) + body = tx._build_proto_body() + assert body.description == "" + + +# --------------------------------------------------------------------------- +# RegisteredNodeUpdateTransaction – TCK scenarios +# --------------------------------------------------------------------------- + + +class TestTckRegisteredNodeUpdate: + """TCK-style update scenarios.""" + + def test_update_description(self): + """TCK: updateRegisteredNode with new description.""" + tx = RegisteredNodeUpdateTransaction() + tx.set_registered_node_id(42) + tx.set_description("updated via tck") + body = tx._build_proto_body() + assert body.registered_node_id == 42 + assert body.description.value == "updated via tck" + + def test_update_service_endpoints(self): + """TCK: updateRegisteredNode replacing service endpoints.""" + ep = RpcRelayServiceEndpoint( + domain_name="rpc.tck.example.com", + port=7546, + requires_tls=False, + ) + tx = RegisteredNodeUpdateTransaction() + tx.set_registered_node_id(42) + tx.set_service_endpoints([ep]) + body = tx._build_proto_body() + assert len(body.service_endpoint) == 1 + + def test_update_admin_key(self): + """TCK: updateRegisteredNode rotating admin key.""" + new_key = PrivateKey.generate_ed25519().public_key() + tx = RegisteredNodeUpdateTransaction() + tx.set_registered_node_id(42) + tx.set_admin_key(new_key) + body = tx._build_proto_body() + assert body.admin_key is not None + + def test_update_id_zero_builds(self): + """TCK: updateRegisteredNode with id=0 should build (node validates).""" + tx = RegisteredNodeUpdateTransaction() + tx.set_registered_node_id(0) + body = tx._build_proto_body() + assert body.registered_node_id == 0 + + def test_update_id_negative_raises_from_protobuf(self): + """TCK: updateRegisteredNode with negative id raises from protobuf serialization.""" + tx = RegisteredNodeUpdateTransaction() + tx.set_registered_node_id(-1) + with pytest.raises(ValueError): + tx._build_proto_body() + + +# --------------------------------------------------------------------------- +# RegisteredNodeDeleteTransaction – TCK scenarios +# --------------------------------------------------------------------------- + + +class TestTckRegisteredNodeDelete: + """TCK-style delete scenarios.""" + + def test_delete_by_id(self): + """TCK: deleteRegisteredNode with valid id.""" + tx = RegisteredNodeDeleteTransaction() + tx.set_registered_node_id(99) + body = tx._build_proto_body() + assert body.registered_node_id == 99 + + def test_delete_invalid_id_zero(self): + """TCK: deleteRegisteredNode with id=0 should raise on build.""" + tx = RegisteredNodeDeleteTransaction() + tx.set_registered_node_id(0) + with pytest.raises(ValueError): + tx._build_proto_body() + + def test_delete_invalid_id_negative(self): + """TCK: deleteRegisteredNode with negative id should raise on build.""" + tx = RegisteredNodeDeleteTransaction() + tx.set_registered_node_id(-1) + with pytest.raises(ValueError): + tx._build_proto_body() diff --git a/tests/unit/registered_node_transaction_test.py b/tests/unit/registered_node_transaction_test.py new file mode 100644 index 000000000..48006941b --- /dev/null +++ b/tests/unit/registered_node_transaction_test.py @@ -0,0 +1,387 @@ +"""Tests for registered node transactions.""" + +from __future__ import annotations + +import pytest + +from hiero_sdk_python.account.account_id import AccountId +from hiero_sdk_python.address_book.block_node_api import BlockNodeApi +from hiero_sdk_python.address_book.block_node_service_endpoint import BlockNodeServiceEndpoint +from hiero_sdk_python.address_book.mirror_node_service_endpoint import MirrorNodeServiceEndpoint +from hiero_sdk_python.crypto.private_key import PrivateKey +from hiero_sdk_python.hapi.services import transaction_receipt_pb2 +from hiero_sdk_python.nodes.registered_node_create_transaction import RegisteredNodeCreateTransaction +from hiero_sdk_python.nodes.registered_node_delete_transaction import RegisteredNodeDeleteTransaction +from hiero_sdk_python.nodes.registered_node_update_transaction import RegisteredNodeUpdateTransaction +from hiero_sdk_python.transaction.transaction import Transaction +from hiero_sdk_python.transaction.transaction_id import TransactionId +from hiero_sdk_python.transaction.transaction_receipt import TransactionReceipt + + +pytestmark = pytest.mark.unit + + +def _make_block_endpoint(): + return BlockNodeServiceEndpoint( + ip_address=b"\x7f\x00\x00\x01", + port=8080, + requires_tls=True, + endpoint_apis=[BlockNodeApi.PUBLISH], + ) + + +def _make_mirror_endpoint(): + return MirrorNodeServiceEndpoint( + domain_name="mirror.example.com", + port=443, + requires_tls=True, + ) + + +def _freeze(tx): + """Freeze a transaction with minimal required fields.""" + tx.transaction_id = TransactionId.generate(AccountId(0, 0, 100)) + tx.node_account_id = AccountId(0, 0, 3) + tx.freeze() + return tx + + +# --------------------------------------------------------------------------- +# RegisteredNodeCreateTransaction +# --------------------------------------------------------------------------- + + +class TestRegisteredNodeCreateTransaction: + """Tests for RegisteredNodeCreateTransaction serialization and round-trip.""" + + def test_builds_proto_with_all_fields(self, mock_account_ids): + """Verify all fields are serialized into the protobuf body.""" + operator_id, _, node_account_id, _, _ = mock_account_ids + key = PrivateKey.generate_ed25519().public_key() + + tx = RegisteredNodeCreateTransaction() + tx.set_admin_key(key) + tx.set_description("test node") + tx.set_service_endpoints([_make_block_endpoint()]) + + tx.operator_account_id = operator_id + tx.node_account_id = node_account_id + body = tx.build_transaction_body() + + assert body.HasField("registeredNodeCreate") + pb = body.registeredNodeCreate + assert pb.admin_key == key._to_proto() + assert pb.description == "test node" + assert len(pb.service_endpoint) == 1 + + def test_multiple_service_endpoints(self, mock_account_ids): + """Verify multiple service endpoints are serialized correctly.""" + operator_id, _, node_account_id, _, _ = mock_account_ids + + tx = RegisteredNodeCreateTransaction() + tx.set_admin_key(PrivateKey.generate_ed25519().public_key()) + tx.set_service_endpoints([_make_block_endpoint(), _make_mirror_endpoint()]) + + tx.operator_account_id = operator_id + tx.node_account_id = node_account_id + body = tx.build_transaction_body() + assert len(body.registeredNodeCreate.service_endpoint) == 2 + + def test_block_endpoint_with_multiple_apis(self, mock_account_ids): + """Verify a block endpoint with multiple APIs is serialized correctly.""" + operator_id, _, node_account_id, _, _ = mock_account_ids + ep = BlockNodeServiceEndpoint( + ip_address=b"\x7f\x00\x00\x01", + port=9090, + endpoint_apis=[BlockNodeApi.PUBLISH, BlockNodeApi.SUBSCRIBE_STREAM, BlockNodeApi.STATE_PROOF], + ) + tx = RegisteredNodeCreateTransaction() + tx.set_admin_key(PrivateKey.generate_ed25519().public_key()) + tx.set_service_endpoints([ep]) + tx.operator_account_id = operator_id + tx.node_account_id = node_account_id + body = tx.build_transaction_body() + block_node = body.registeredNodeCreate.service_endpoint[0].block_node + assert len(block_node.endpoint_api) == 3 + + def test_builds_schedulable_body(self): + """Verify registeredNodeCreate is present in the schedulable body.""" + tx = RegisteredNodeCreateTransaction() + tx.set_admin_key(PrivateKey.generate_ed25519().public_key()) + tx.set_service_endpoints([_make_block_endpoint()]) + scheduled = tx.build_scheduled_body() + assert scheduled.HasField("registeredNodeCreate") + + def test_from_bytes_round_trip(self): + """Verify all fields survive serialization and deserialization.""" + key = PrivateKey.generate_ed25519().public_key() + tx = RegisteredNodeCreateTransaction() + tx.set_admin_key(key) + tx.set_description("round trip") + tx.set_service_endpoints([_make_block_endpoint(), _make_mirror_endpoint()]) + + _freeze(tx) + restored = Transaction.from_bytes(tx.to_bytes()) + + assert isinstance(restored, RegisteredNodeCreateTransaction) + assert restored.admin_key.to_bytes_raw() == key.to_bytes_raw() + assert restored.description == "round trip" + assert len(restored.service_endpoints) == 2 + assert isinstance(restored.service_endpoints[0], BlockNodeServiceEndpoint) + assert isinstance(restored.service_endpoints[1], MirrorNodeServiceEndpoint) + + def test_builds_without_endpoints(self, mock_account_ids): + """Building without endpoints succeeds; validation is delegated to consensus node.""" + operator_id, _, node_account_id, _, _ = mock_account_ids + tx = RegisteredNodeCreateTransaction() + tx.set_admin_key(PrivateKey.generate_ed25519().public_key()) + tx.operator_account_id = operator_id + tx.node_account_id = node_account_id + body = tx.build_transaction_body() + assert len(body.registeredNodeCreate.service_endpoint) == 0 + + def test_builds_with_many_endpoints(self, mock_account_ids): + """Building with many endpoints succeeds; validation is delegated to consensus node.""" + operator_id, _, node_account_id, _, _ = mock_account_ids + endpoints = [_make_mirror_endpoint() for _ in range(51)] + tx = RegisteredNodeCreateTransaction() + tx.set_admin_key(PrivateKey.generate_ed25519().public_key()) + tx.set_service_endpoints(endpoints) + tx.operator_account_id = operator_id + tx.node_account_id = node_account_id + body = tx.build_transaction_body() + assert len(body.registeredNodeCreate.service_endpoint) == 51 + + def test_builds_with_long_description(self, mock_account_ids): + """Building with long description succeeds; validation is delegated to consensus node.""" + operator_id, _, node_account_id, _, _ = mock_account_ids + tx = RegisteredNodeCreateTransaction() + tx.set_admin_key(PrivateKey.generate_ed25519().public_key()) + tx.set_description("x" * 101) + tx.set_service_endpoints([_make_block_endpoint()]) + tx.operator_account_id = operator_id + tx.node_account_id = node_account_id + body = tx.build_transaction_body() + assert body.registeredNodeCreate.description == "x" * 101 + + def test_add_service_endpoint(self): + """Verify add_service_endpoint appends endpoints individually.""" + tx = RegisteredNodeCreateTransaction() + tx.add_service_endpoint(_make_block_endpoint()) + tx.add_service_endpoint(_make_mirror_endpoint()) + assert len(tx.service_endpoints) == 2 + + +# --------------------------------------------------------------------------- +# RegisteredNodeUpdateTransaction +# --------------------------------------------------------------------------- + + +class TestRegisteredNodeUpdateTransaction: + """Tests for RegisteredNodeUpdateTransaction serialization and round-trip.""" + + def test_builds_proto_with_registered_node_id(self, mock_account_ids): + """Verify registered_node_id is serialized into the protobuf body.""" + operator_id, _, node_account_id, _, _ = mock_account_ids + tx = RegisteredNodeUpdateTransaction() + tx.set_registered_node_id(42) + tx.operator_account_id = operator_id + tx.node_account_id = node_account_id + body = tx.build_transaction_body() + assert body.HasField("registeredNodeUpdate") + assert body.registeredNodeUpdate.registered_node_id == 42 + + def test_updates_admin_key(self, mock_account_ids): + """Verify admin_key update is serialized correctly.""" + operator_id, _, node_account_id, _, _ = mock_account_ids + key = PrivateKey.generate_ed25519().public_key() + tx = RegisteredNodeUpdateTransaction() + tx.set_registered_node_id(1) + tx.set_admin_key(key) + tx.operator_account_id = operator_id + tx.node_account_id = node_account_id + body = tx.build_transaction_body() + assert body.registeredNodeUpdate.admin_key == key._to_proto() + + def test_updates_description(self, mock_account_ids): + """Verify description update is serialized correctly.""" + operator_id, _, node_account_id, _, _ = mock_account_ids + tx = RegisteredNodeUpdateTransaction() + tx.set_registered_node_id(1) + tx.set_description("updated desc") + tx.operator_account_id = operator_id + tx.node_account_id = node_account_id + body = tx.build_transaction_body() + assert body.registeredNodeUpdate.description.value == "updated desc" + + def test_replaces_endpoints_when_provided(self, mock_account_ids): + """Verify service endpoints replace existing ones when provided.""" + operator_id, _, node_account_id, _, _ = mock_account_ids + tx = RegisteredNodeUpdateTransaction() + tx.set_registered_node_id(1) + tx.set_service_endpoints([_make_block_endpoint(), _make_mirror_endpoint()]) + tx.operator_account_id = operator_id + tx.node_account_id = node_account_id + body = tx.build_transaction_body() + assert len(body.registeredNodeUpdate.service_endpoint) == 2 + + def test_does_not_serialize_endpoints_when_unset(self, mock_account_ids): + """Verify no endpoints are serialized when none are set.""" + operator_id, _, node_account_id, _, _ = mock_account_ids + tx = RegisteredNodeUpdateTransaction() + tx.set_registered_node_id(1) + tx.operator_account_id = operator_id + tx.node_account_id = node_account_id + body = tx.build_transaction_body() + assert len(body.registeredNodeUpdate.service_endpoint) == 0 + + def test_builds_schedulable_body(self): + """Verify registeredNodeUpdate is present in the schedulable body.""" + tx = RegisteredNodeUpdateTransaction() + tx.set_registered_node_id(1) + scheduled = tx.build_scheduled_body() + assert scheduled.HasField("registeredNodeUpdate") + + def test_from_bytes_round_trip(self): + """Verify all update fields survive serialization and deserialization.""" + key = PrivateKey.generate_ed25519().public_key() + tx = RegisteredNodeUpdateTransaction() + tx.set_registered_node_id(99) + tx.set_admin_key(key) + tx.set_description("updated") + tx.set_service_endpoints([_make_mirror_endpoint()]) + + _freeze(tx) + restored = Transaction.from_bytes(tx.to_bytes()) + + assert isinstance(restored, RegisteredNodeUpdateTransaction) + assert restored.registered_node_id == 99 + assert restored.admin_key.to_bytes_raw() == key.to_bytes_raw() + assert restored.description == "updated" + assert len(restored.service_endpoints) == 1 + assert isinstance(restored.service_endpoints[0], MirrorNodeServiceEndpoint) + + def test_builds_without_registered_node_id(self, mock_account_ids): + """Building without registered_node_id succeeds; validation is delegated to consensus node.""" + operator_id, _, node_account_id, _, _ = mock_account_ids + tx = RegisteredNodeUpdateTransaction() + tx.operator_account_id = operator_id + tx.node_account_id = node_account_id + body = tx.build_transaction_body() + assert body.registeredNodeUpdate.registered_node_id == 0 + + def test_builds_with_empty_endpoints(self, mock_account_ids): + """Building with empty endpoints succeeds; validation is delegated to consensus node.""" + operator_id, _, node_account_id, _, _ = mock_account_ids + tx = RegisteredNodeUpdateTransaction() + tx.set_registered_node_id(1) + tx.set_service_endpoints([]) + tx.operator_account_id = operator_id + tx.node_account_id = node_account_id + body = tx.build_transaction_body() + assert len(body.registeredNodeUpdate.service_endpoint) == 0 + + def test_builds_with_many_endpoints(self, mock_account_ids): + """Building with many endpoints succeeds; validation is delegated to consensus node.""" + operator_id, _, node_account_id, _, _ = mock_account_ids + tx = RegisteredNodeUpdateTransaction() + tx.set_registered_node_id(1) + tx.set_service_endpoints([_make_mirror_endpoint() for _ in range(51)]) + tx.operator_account_id = operator_id + tx.node_account_id = node_account_id + body = tx.build_transaction_body() + assert len(body.registeredNodeUpdate.service_endpoint) == 51 + + +# --------------------------------------------------------------------------- +# RegisteredNodeDeleteTransaction +# --------------------------------------------------------------------------- + + +class TestRegisteredNodeDeleteTransaction: + """Tests for RegisteredNodeDeleteTransaction serialization and round-trip.""" + + def test_builds_proto_with_registered_node_id(self, mock_account_ids): + """Verify registered_node_id is serialized into the protobuf body.""" + operator_id, _, node_account_id, _, _ = mock_account_ids + tx = RegisteredNodeDeleteTransaction() + tx.set_registered_node_id(7) + tx.operator_account_id = operator_id + tx.node_account_id = node_account_id + body = tx.build_transaction_body() + assert body.HasField("registeredNodeDelete") + assert body.registeredNodeDelete.registered_node_id == 7 + + def test_builds_schedulable_body(self): + """Verify registeredNodeDelete is present in the schedulable body.""" + tx = RegisteredNodeDeleteTransaction() + tx.set_registered_node_id(7) + scheduled = tx.build_scheduled_body() + assert scheduled.HasField("registeredNodeDelete") + assert scheduled.registeredNodeDelete.registered_node_id == 7 + + def test_from_bytes_round_trip(self): + """Verify registered_node_id survives round-trip serialization.""" + tx = RegisteredNodeDeleteTransaction() + tx.set_registered_node_id(42) + + _freeze(tx) + restored = Transaction.from_bytes(tx.to_bytes()) + + assert isinstance(restored, RegisteredNodeDeleteTransaction) + assert restored.registered_node_id == 42 + + def test_fails_when_registered_node_id_missing(self, mock_account_ids): + """Verify building without registered_node_id raises ValueError.""" + operator_id, _, node_account_id, _, _ = mock_account_ids + tx = RegisteredNodeDeleteTransaction() + tx.operator_account_id = operator_id + tx.node_account_id = node_account_id + + with pytest.raises(ValueError, match="registered_node_id"): + tx.build_transaction_body() + + +# --------------------------------------------------------------------------- +# TransactionReceipt.registered_node_id +# --------------------------------------------------------------------------- + + +class TestTransactionReceiptRegisteredNodeId: + """Tests for registered_node_id on TransactionReceipt.""" + + def test_parses_when_present(self): + """Verify registered_node_id is parsed from the protobuf receipt.""" + proto = transaction_receipt_pb2.TransactionReceipt(registered_node_id=123) + receipt = TransactionReceipt(proto) + assert receipt.registered_node_id == 123 + + def test_none_when_absent(self): + """Verify registered_node_id is None when not present in the receipt.""" + proto = transaction_receipt_pb2.TransactionReceipt() + receipt = TransactionReceipt(proto) + assert receipt.registered_node_id is None + + +# --------------------------------------------------------------------------- +# Transaction deserialization mapping +# --------------------------------------------------------------------------- + + +class TestTransactionDeserialization: + """Tests for transaction class resolution from protobuf field names.""" + + def test_resolves_registered_node_create(self): + """Verify registeredNodeCreate maps to RegisteredNodeCreateTransaction.""" + cls = Transaction._get_transaction_class("registeredNodeCreate") + assert cls is RegisteredNodeCreateTransaction + + def test_resolves_registered_node_update(self): + """Verify registeredNodeUpdate maps to RegisteredNodeUpdateTransaction.""" + cls = Transaction._get_transaction_class("registeredNodeUpdate") + assert cls is RegisteredNodeUpdateTransaction + + def test_resolves_registered_node_delete(self): + """Verify registeredNodeDelete maps to RegisteredNodeDeleteTransaction.""" + cls = Transaction._get_transaction_class("registeredNodeDelete") + assert cls is RegisteredNodeDeleteTransaction diff --git a/tests/unit/registered_service_endpoint_test.py b/tests/unit/registered_service_endpoint_test.py new file mode 100644 index 000000000..25fce3226 --- /dev/null +++ b/tests/unit/registered_service_endpoint_test.py @@ -0,0 +1,422 @@ +from __future__ import annotations + +import pytest + +from hiero_sdk_python.address_book.block_node_api import BlockNodeApi +from hiero_sdk_python.address_book.block_node_service_endpoint import BlockNodeServiceEndpoint +from hiero_sdk_python.address_book.general_service_endpoint import GeneralServiceEndpoint +from hiero_sdk_python.address_book.mirror_node_service_endpoint import MirrorNodeServiceEndpoint +from hiero_sdk_python.address_book.registered_service_endpoint import RegisteredServiceEndpoint +from hiero_sdk_python.address_book.rpc_relay_service_endpoint import RpcRelayServiceEndpoint +from hiero_sdk_python.hapi.services.registered_service_endpoint_pb2 import ( + RegisteredServiceEndpoint as RegisteredServiceEndpointProto, +) + + +pytestmark = pytest.mark.unit + + +# --- BlockNodeApi --- + + +class TestBlockNodeApi: + """Tests for BlockNodeApi enum values.""" + + def test_enum_values_match_protobuf(self): + """BlockNodeApi numeric values must match generated protobuf enum.""" + proto_enum = RegisteredServiceEndpointProto.BlockNodeEndpoint.BlockNodeApi + assert proto_enum.Value("OTHER") == BlockNodeApi.OTHER + assert proto_enum.Value("STATUS") == BlockNodeApi.STATUS + assert proto_enum.Value("PUBLISH") == BlockNodeApi.PUBLISH + assert proto_enum.Value("SUBSCRIBE_STREAM") == BlockNodeApi.SUBSCRIBE_STREAM + assert proto_enum.Value("STATE_PROOF") == BlockNodeApi.STATE_PROOF + + +# --- BlockNodeServiceEndpoint --- + + +class TestBlockNodeServiceEndpoint: + """Tests for BlockNodeServiceEndpoint serialization and round-trip.""" + + def test_round_trip_ip_address(self): + """Verify round-trip with an IP address preserves all fields.""" + ep = BlockNodeServiceEndpoint( + ip_address=b"\xc0\xa8\x01\x01", + port=8080, + requires_tls=True, + endpoint_apis=[BlockNodeApi.PUBLISH], + ) + proto = ep._to_proto() + restored = RegisteredServiceEndpoint._from_proto(proto) + assert isinstance(restored, BlockNodeServiceEndpoint) + assert restored.ip_address == b"\xc0\xa8\x01\x01" + assert restored.domain_name is None + assert restored.port == 8080 + assert restored.requires_tls is True + assert restored.endpoint_apis == [BlockNodeApi.PUBLISH] + + def test_round_trip_domain_name(self): + """Verify round-trip with a domain name preserves all fields.""" + ep = BlockNodeServiceEndpoint( + domain_name="block.example.com", + port=443, + requires_tls=True, + endpoint_apis=[BlockNodeApi.STATUS], + ) + proto = ep._to_proto() + restored = RegisteredServiceEndpoint._from_proto(proto) + assert isinstance(restored, BlockNodeServiceEndpoint) + assert restored.domain_name == "block.example.com" + assert restored.ip_address is None + assert restored.port == 443 + assert restored.requires_tls is True + + def test_multiple_endpoint_apis(self): + """Verify multiple endpoint APIs survive round-trip serialization.""" + apis = [BlockNodeApi.PUBLISH, BlockNodeApi.SUBSCRIBE_STREAM, BlockNodeApi.STATE_PROOF] + ep = BlockNodeServiceEndpoint( + ip_address=b"\x7f\x00\x00\x01", + port=9090, + requires_tls=False, + endpoint_apis=apis, + ) + proto = ep._to_proto() + restored = RegisteredServiceEndpoint._from_proto(proto) + assert isinstance(restored, BlockNodeServiceEndpoint) + assert restored.endpoint_apis == apis + + def test_empty_endpoint_apis_allowed(self): + """Verify an empty endpoint_apis list is accepted.""" + ep = BlockNodeServiceEndpoint( + ip_address=b"\x7f\x00\x00\x01", + port=80, + endpoint_apis=[], + ) + assert ep.endpoint_apis == [] + + def test_none_endpoint_apis_defaults_to_empty(self): + """Verify None endpoint_apis defaults to an empty list.""" + ep = BlockNodeServiceEndpoint( + ip_address=b"\x7f\x00\x00\x01", + port=80, + endpoint_apis=None, + ) + assert ep.endpoint_apis == [] + + +# --- MirrorNodeServiceEndpoint --- + + +class TestMirrorNodeServiceEndpoint: + """Tests for MirrorNodeServiceEndpoint serialization and round-trip.""" + + def test_round_trip(self): + """Verify round-trip preserves all fields.""" + ep = MirrorNodeServiceEndpoint( + ip_address=b"\x0a\x00\x00\x01", + port=5600, + requires_tls=False, + ) + proto = ep._to_proto() + restored = RegisteredServiceEndpoint._from_proto(proto) + assert isinstance(restored, MirrorNodeServiceEndpoint) + assert restored.ip_address == b"\x0a\x00\x00\x01" + assert restored.port == 5600 + assert restored.requires_tls is False + + +# --- RpcRelayServiceEndpoint --- + + +class TestRpcRelayServiceEndpoint: + """Tests for RpcRelayServiceEndpoint serialization and round-trip.""" + + def test_round_trip(self): + """Verify round-trip preserves all fields.""" + ep = RpcRelayServiceEndpoint( + domain_name="relay.example.com", + port=7546, + requires_tls=True, + ) + proto = ep._to_proto() + restored = RegisteredServiceEndpoint._from_proto(proto) + assert isinstance(restored, RpcRelayServiceEndpoint) + assert restored.domain_name == "relay.example.com" + assert restored.port == 7546 + assert restored.requires_tls is True + + +# --- GeneralServiceEndpoint --- + + +class TestGeneralServiceEndpoint: + """Tests for GeneralServiceEndpoint serialization and round-trip.""" + + def test_round_trip_with_description(self): + """Verify round-trip with a description preserves all fields.""" + ep = GeneralServiceEndpoint( + ip_address=b"\xc0\xa8\x00\x01", + port=3000, + requires_tls=False, + description="My custom service", + ) + proto = ep._to_proto() + restored = RegisteredServiceEndpoint._from_proto(proto) + assert isinstance(restored, GeneralServiceEndpoint) + assert restored.description == "My custom service" + assert restored.ip_address == b"\xc0\xa8\x00\x01" + assert restored.port == 3000 + + def test_round_trip_without_description(self): + """Verify round-trip without a description preserves None.""" + ep = GeneralServiceEndpoint( + domain_name="general.example.com", + port=8080, + requires_tls=True, + description=None, + ) + proto = ep._to_proto() + restored = RegisteredServiceEndpoint._from_proto(proto) + assert isinstance(restored, GeneralServiceEndpoint) + assert restored.description is None + + def test_description_too_long_raises(self): + """Verify a description exceeding 100 UTF-8 bytes raises ValueError.""" + # 101 UTF-8 bytes (e.g. 101 ASCII characters) + long_desc = "x" * 101 + with pytest.raises(ValueError, match="100 UTF-8 bytes"): + GeneralServiceEndpoint( + ip_address=b"\x7f\x00\x00\x01", + port=80, + description=long_desc, + ) + + def test_multibyte_description_utf8_limit(self): + """Verify multi-byte characters are counted as UTF-8 bytes for the limit.""" + # Each emoji is 4 UTF-8 bytes, 26 emojis = 104 bytes > 100 + long_desc = "\U0001f600" * 26 + with pytest.raises(ValueError, match="100 UTF-8 bytes"): + GeneralServiceEndpoint( + ip_address=b"\x7f\x00\x00\x01", + port=80, + description=long_desc, + ) + + +# --- Address validation tests --- + + +class TestAddressValidation: + """Tests for ip_address vs domain_name address validation.""" + + def test_ip_address_round_trip(self): + """Verify IP address is preserved and domain_name is None after round-trip.""" + ep = MirrorNodeServiceEndpoint(ip_address=b"\x7f\x00\x00\x01", port=443, requires_tls=True) + proto = ep._to_proto() + restored = RegisteredServiceEndpoint._from_proto(proto) + assert restored.ip_address == b"\x7f\x00\x00\x01" + assert restored.domain_name is None + + def test_domain_name_round_trip(self): + """Verify domain name is preserved and ip_address is None after round-trip.""" + ep = MirrorNodeServiceEndpoint(domain_name="mirror.hedera.com", port=443, requires_tls=True) + proto = ep._to_proto() + restored = RegisteredServiceEndpoint._from_proto(proto) + assert restored.domain_name == "mirror.hedera.com" + assert restored.ip_address is None + + def test_ipv6_round_trip(self): + ipv6 = b"\x00" * 16 + ep = RpcRelayServiceEndpoint(ip_address=ipv6, port=8545, requires_tls=False) + proto = ep._to_proto() + restored = RegisteredServiceEndpoint._from_proto(proto) + assert restored.ip_address == ipv6 + + def test_both_ip_and_domain_raises(self): + with pytest.raises(ValueError, match="Exactly one"): + MirrorNodeServiceEndpoint( + ip_address=b"\x7f\x00\x00\x01", + domain_name="example.com", + port=80, + ) + + def test_neither_ip_nor_domain_allowed_for_builder_pattern(self): + """Constructor allows neither address for builder/setter pattern; _to_proto raises.""" + ep = MirrorNodeServiceEndpoint(port=80) + assert ep.ip_address is None + assert ep.domain_name is None + with pytest.raises(ValueError, match="serialization"): + ep._to_proto() + + def test_invalid_ip_length_raises(self): + with pytest.raises(ValueError, match="4 bytes.*or 16 bytes"): + MirrorNodeServiceEndpoint(ip_address=b"\x7f\x00\x00", port=80) + + def test_non_ascii_domain_raises(self): + with pytest.raises(ValueError, match="ASCII"): + MirrorNodeServiceEndpoint(domain_name="münchen.de", port=80) + + def test_domain_longer_than_250_raises(self): + with pytest.raises(ValueError, match="250 characters"): + MirrorNodeServiceEndpoint(domain_name="a" * 251, port=80) + + def test_port_below_zero_raises(self): + with pytest.raises(ValueError, match="range 0 to 65535"): + MirrorNodeServiceEndpoint(ip_address=b"\x7f\x00\x00\x01", port=-1) + + def test_port_above_65535_raises(self): + with pytest.raises(ValueError, match="range 0 to 65535"): + MirrorNodeServiceEndpoint(ip_address=b"\x7f\x00\x00\x01", port=65536) + + +# --- Setter tests --- + + +class TestRegisteredServiceEndpointSetters: + """Tests for set_* methods on RegisteredServiceEndpoint and subclasses.""" + + def test_set_ip_address_clears_domain(self): + """Verify set_ip_address replaces domain_name with ip_address.""" + ep = MirrorNodeServiceEndpoint(domain_name="example.com", port=443) + result = ep.set_ip_address(b"\x7f\x00\x00\x01") + assert result is ep + assert ep.ip_address == b"\x7f\x00\x00\x01" + assert ep.domain_name is None + + def test_set_domain_name_clears_ip(self): + """Verify set_domain_name replaces ip_address with domain_name.""" + ep = MirrorNodeServiceEndpoint(ip_address=b"\x7f\x00\x00\x01", port=443) + result = ep.set_domain_name("example.com") + assert result is ep + assert ep.domain_name == "example.com" + assert ep.ip_address is None + + def test_set_port(self): + """Verify set_port updates the port.""" + ep = MirrorNodeServiceEndpoint(domain_name="example.com", port=80) + result = ep.set_port(443) + assert result is ep + assert ep.port == 443 + + def test_set_port_rejects_invalid(self): + """Verify set_port rejects an out-of-range port.""" + ep = MirrorNodeServiceEndpoint(domain_name="example.com", port=80) + with pytest.raises(ValueError, match="range 0 to 65535"): + ep.set_port(70000) + + def test_set_requires_tls(self): + """Verify set_requires_tls updates the flag.""" + ep = MirrorNodeServiceEndpoint(domain_name="example.com", port=443, requires_tls=False) + result = ep.set_requires_tls(True) + assert result is ep + assert ep.requires_tls is True + + def test_set_requires_tls_rejects_non_bool(self): + """Verify set_requires_tls rejects non-bool values.""" + ep = MirrorNodeServiceEndpoint(domain_name="example.com", port=443) + with pytest.raises(ValueError, match="requires_tls must be a bool"): + ep.set_requires_tls("yes") + + def test_set_ip_address_rejects_invalid(self): + """Verify set_ip_address rejects invalid byte lengths.""" + ep = MirrorNodeServiceEndpoint(domain_name="example.com", port=80) + with pytest.raises(ValueError, match="ip_address"): + ep.set_ip_address(b"\x7f\x00") + + def test_set_domain_name_rejects_non_ascii(self): + """Verify set_domain_name rejects non-ASCII strings.""" + ep = MirrorNodeServiceEndpoint(ip_address=b"\x7f\x00\x00\x01", port=80) + with pytest.raises(ValueError, match="ASCII"): + ep.set_domain_name("münchen.de") + + def test_set_endpoint_apis_on_block_node(self): + """Verify set_endpoint_apis replaces the API list on BlockNodeServiceEndpoint.""" + ep = BlockNodeServiceEndpoint( + domain_name="block.example.com", + port=443, + endpoint_apis=[BlockNodeApi.OTHER], + ) + result = ep.set_endpoint_apis([BlockNodeApi.PUBLISH, BlockNodeApi.STATUS]) + assert result is ep + assert ep.endpoint_apis == [BlockNodeApi.PUBLISH, BlockNodeApi.STATUS] + + def test_set_description_on_general_endpoint(self): + """Verify set_description updates the description on GeneralServiceEndpoint.""" + ep = GeneralServiceEndpoint( + domain_name="general.example.com", + port=80, + description="old", + ) + result = ep.set_description("new description") + assert result is ep + assert ep.description == "new description" + + def test_set_description_none(self): + """Verify set_description accepts None.""" + ep = GeneralServiceEndpoint( + domain_name="general.example.com", + port=80, + description="something", + ) + ep.set_description(None) + assert ep.description is None + + def test_set_description_rejects_too_long(self): + """Verify set_description rejects descriptions over 100 UTF-8 bytes.""" + ep = GeneralServiceEndpoint(domain_name="general.example.com", port=80) + with pytest.raises(ValueError, match="100 UTF-8 bytes"): + ep.set_description("x" * 101) + + +# --- _from_dict tests --- + + +class TestFromDict: + """Tests for _from_dict deserialization from mirror-node JSON.""" + + def test_from_dict_ip_address_parsing(self): + """Verify _from_dict correctly parses IP address strings.""" + data = { + "ip_address": "192.168.1.1", + "port": 443, + "requires_tls": True, + "type": "MIRROR_NODE", + } + ep = RegisteredServiceEndpoint._from_dict(data) + assert isinstance(ep, MirrorNodeServiceEndpoint) + assert ep.ip_address == b"\xc0\xa8\x01\x01" + assert ep.domain_name is None + + def test_from_dict_unknown_type_raises(self): + """Verify _from_dict raises ValueError for unknown endpoint types.""" + data = { + "domain_name": "example.com", + "port": 80, + "type": "UNKNOWN_TYPE", + } + with pytest.raises(ValueError, match="Unknown endpoint type"): + RegisteredServiceEndpoint._from_dict(data) + + def test_from_dict_rpc_relay(self): + """Verify _from_dict correctly parses an RPC_RELAY endpoint.""" + data = { + "domain_name": "relay.example.com", + "port": 7546, + "requires_tls": True, + "type": "RPC_RELAY", + } + ep = RegisteredServiceEndpoint._from_dict(data) + assert isinstance(ep, RpcRelayServiceEndpoint) + assert ep.domain_name == "relay.example.com" + + def test_from_dict_general_service(self): + """Verify _from_dict correctly parses a GENERAL_SERVICE endpoint.""" + data = { + "domain_name": "gen.example.com", + "port": 9000, + "type": "GENERAL_SERVICE", + "general_service": {"description": "my service"}, + } + ep = RegisteredServiceEndpoint._from_dict(data) + assert isinstance(ep, GeneralServiceEndpoint) + assert ep.description == "my service"