From 209bf5c6e3da4e3d2136da697ed5c4b6cd6e29c4 Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Wed, 6 May 2026 21:21:03 +0300 Subject: [PATCH 01/31] feat: update protobufs to v0.73.0 for HIP-1137 Signed-off-by: Ntege Daniel --- generate_proto.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From 48e9322b8e6b9cc8fd8c4aadadaa1a310cef884f Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Wed, 6 May 2026 22:55:50 +0300 Subject: [PATCH 02/31] feat: add HIP-1137 registered service endpoint models Signed-off-by: Ntege Daniel --- src/hiero_sdk_python/__init__.py | 6 + .../address_book/block_node_api.py | 13 + .../block_node_service_endpoint.py | 47 ++++ .../address_book/general_service_endpoint.py | 47 ++++ .../mirror_node_service_endpoint.py | 14 + .../registered_service_endpoint.py | 101 ++++++++ .../rpc_relay_service_endpoint.py | 13 + .../unit/registered_service_endpoint_test.py | 240 ++++++++++++++++++ 8 files changed, 481 insertions(+) create mode 100644 src/hiero_sdk_python/address_book/block_node_api.py create mode 100644 src/hiero_sdk_python/address_book/block_node_service_endpoint.py create mode 100644 src/hiero_sdk_python/address_book/general_service_endpoint.py create mode 100644 src/hiero_sdk_python/address_book/mirror_node_service_endpoint.py create mode 100644 src/hiero_sdk_python/address_book/registered_service_endpoint.py create mode 100644 src/hiero_sdk_python/address_book/rpc_relay_service_endpoint.py create mode 100644 tests/unit/registered_service_endpoint_test.py diff --git a/src/hiero_sdk_python/__init__.py b/src/hiero_sdk_python/__init__.py index b890f3330..f85ff260f 100644 --- a/src/hiero_sdk_python/__init__.py +++ b/src/hiero_sdk_python/__init__.py @@ -9,8 +9,14 @@ 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_service_endpoint import RegisteredServiceEndpoint +from .address_book.rpc_relay_service_endpoint import RpcRelayServiceEndpoint # Client and Network from .client.client import Client 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..31f36bcef --- /dev/null +++ b/src/hiero_sdk_python/address_book/block_node_service_endpoint.py @@ -0,0 +1,47 @@ +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) + if endpoint_apis is None or len(endpoint_apis) == 0: + raise ValueError("endpoint_apis must be non-empty") + self.endpoint_apis: list[BlockNodeApi] = [BlockNodeApi(api) for api in endpoint_apis] + + 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, + ) 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..ad35e59a1 --- /dev/null +++ b/src/hiero_sdk_python/address_book/general_service_endpoint.py @@ -0,0 +1,47 @@ +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_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, + ) 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..51e741e14 --- /dev/null +++ b/src/hiero_sdk_python/address_book/mirror_node_service_endpoint.py @@ -0,0 +1,14 @@ +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() 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..f44aee37c --- /dev/null +++ b/src/hiero_sdk_python/address_book/registered_service_endpoint.py @@ -0,0 +1,101 @@ +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 HIP-1137 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(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 + + @staticmethod + def _validate_address(ip_address: bytes | None, domain_name: str | None) -> None: + 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 None and domain_name is None: + raise ValueError("Exactly one of ip_address or domain_name must be provided") + if ip_address is not None and (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)") + if domain_name is not 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: + 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( + ip_address=ip_address, domain_name=domain_name, port=port, requires_tls=requires_tls + ) + if endpoint_type == "rpc_relay": + return RpcRelayServiceEndpoint( + ip_address=ip_address, domain_name=domain_name, port=port, requires_tls=requires_tls + ) + if endpoint_type == "general_service": + return GeneralServiceEndpoint._from_proto_inner(proto, ip_address, domain_name, port, requires_tls) + return cls(ip_address=ip_address, domain_name=domain_name, port=port, requires_tls=requires_tls) 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..c99b227ee --- /dev/null +++ b/src/hiero_sdk_python/address_book/rpc_relay_service_endpoint.py @@ -0,0 +1,13 @@ +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() diff --git a/tests/unit/registered_service_endpoint_test.py b/tests/unit/registered_service_endpoint_test.py new file mode 100644 index 000000000..1eca3ec00 --- /dev/null +++ b/tests/unit/registered_service_endpoint_test.py @@ -0,0 +1,240 @@ +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: + 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: + def test_round_trip_ip_address(self): + 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): + 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): + 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_raises(self): + with pytest.raises(ValueError, match="endpoint_apis must be non-empty"): + BlockNodeServiceEndpoint( + ip_address=b"\x7f\x00\x00\x01", + port=80, + endpoint_apis=[], + ) + + def test_none_endpoint_apis_raises(self): + with pytest.raises(ValueError, match="endpoint_apis must be non-empty"): + BlockNodeServiceEndpoint( + ip_address=b"\x7f\x00\x00\x01", + port=80, + endpoint_apis=None, + ) + + +# --- MirrorNodeServiceEndpoint --- + + +class TestMirrorNodeServiceEndpoint: + def test_round_trip(self): + 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: + def test_round_trip(self): + 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: + def test_round_trip_with_description(self): + 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): + 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): + # 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): + # 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: + def test_ip_address_round_trip(self): + 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): + 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_raises(self): + with pytest.raises(ValueError, match="Exactly one"): + MirrorNodeServiceEndpoint(port=80) + + 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) From 6c5812648866c020451bef941823069c4926ff8d Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Wed, 6 May 2026 23:05:09 +0300 Subject: [PATCH 03/31] feat: add HIP-1137 registered node transactions Signed-off-by: Ntege Daniel --- src/hiero_sdk_python/__init__.py | 3 + .../registered_node_create_transaction.py | 89 +++++ .../registered_node_delete_transaction.py | 58 +++ .../registered_node_update_transaction.py | 110 ++++++ .../transaction/transaction.py | 3 + .../transaction/transaction_receipt.py | 12 + .../unit/registered_node_transaction_test.py | 349 ++++++++++++++++++ 7 files changed, 624 insertions(+) create mode 100644 src/hiero_sdk_python/nodes/registered_node_create_transaction.py create mode 100644 src/hiero_sdk_python/nodes/registered_node_delete_transaction.py create mode 100644 src/hiero_sdk_python/nodes/registered_node_update_transaction.py create mode 100644 tests/unit/registered_node_transaction_test.py diff --git a/src/hiero_sdk_python/__init__.py b/src/hiero_sdk_python/__init__.py index f85ff260f..2c9b39c0d 100644 --- a/src/hiero_sdk_python/__init__.py +++ b/src/hiero_sdk_python/__init__.py @@ -76,6 +76,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 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..28021d818 --- /dev/null +++ b/src/hiero_sdk_python/nodes/registered_node_create_transaction.py @@ -0,0 +1,89 @@ +"""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.public_key import PublicKey +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 (HIP-1137).""" + + def __init__(self): + super().__init__() + self.admin_key: PublicKey | None = None + self.description: str | None = None + self.service_endpoints: list[RegisteredServiceEndpoint] = [] + + def set_admin_key(self, admin_key: PublicKey | None) -> RegisteredNodeCreateTransaction: + self._require_not_frozen() + self.admin_key = admin_key + return self + + def set_description(self, description: str | None) -> RegisteredNodeCreateTransaction: + self._require_not_frozen() + self.description = description + return self + + def set_service_endpoints( + self, service_endpoints: list[RegisteredServiceEndpoint] + ) -> RegisteredNodeCreateTransaction: + self._require_not_frozen() + self.service_endpoints = service_endpoints + return self + + def add_service_endpoint(self, endpoint: RegisteredServiceEndpoint) -> RegisteredNodeCreateTransaction: + self._require_not_frozen() + self.service_endpoints.append(endpoint) + return self + + def _build_proto_body(self) -> RegisteredNodeCreateTransactionBody: + if not self.service_endpoints: + raise ValueError("service_endpoints must have at least 1 entry") + if len(self.service_endpoints) > 50: + raise ValueError("service_endpoints must have at most 50 entries") + if self.description is not None and len(self.description.encode("utf-8")) > 100: + raise ValueError("description must be 100 UTF-8 bytes or fewer") + + return RegisteredNodeCreateTransactionBody( + admin_key=self.admin_key._to_proto() 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 = PublicKey._from_proto(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..e7385839a --- /dev/null +++ b/src/hiero_sdk_python/nodes/registered_node_delete_transaction.py @@ -0,0 +1,58 @@ +"""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 (HIP-1137).""" + + 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: + 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") + + 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) + + 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..6a126459a --- /dev/null +++ b/src/hiero_sdk_python/nodes/registered_node_update_transaction.py @@ -0,0 +1,110 @@ +"""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.public_key import PublicKey +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 (HIP-1137).""" + + def __init__(self): + super().__init__() + self.registered_node_id: int | None = None + self.admin_key: PublicKey | 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: + self._require_not_frozen() + self.registered_node_id = registered_node_id + return self + + def set_admin_key(self, admin_key: PublicKey | None) -> RegisteredNodeUpdateTransaction: + self._require_not_frozen() + self.admin_key = admin_key + return self + + def set_description(self, description: str | None) -> RegisteredNodeUpdateTransaction: + self._require_not_frozen() + self.description = description + return self + + def set_service_endpoints( + self, service_endpoints: list[RegisteredServiceEndpoint] | None + ) -> RegisteredNodeUpdateTransaction: + self._require_not_frozen() + self.service_endpoints = service_endpoints + return self + + def add_service_endpoint(self, endpoint: RegisteredServiceEndpoint) -> RegisteredNodeUpdateTransaction: + 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: + if self.registered_node_id is None: + raise ValueError("Missing required registered_node_id") + if self.description is not None and len(self.description.encode("utf-8")) > 100: + raise ValueError("description must be 100 UTF-8 bytes or fewer") + if self.service_endpoints is not None: + if len(self.service_endpoints) == 0: + raise ValueError("service_endpoints must have at least 1 entry when provided") + if len(self.service_endpoints) > 50: + raise ValueError("service_endpoints must have at most 50 entries") + + body = RegisteredNodeUpdateTransactionBody( + registered_node_id=self.registered_node_id, + admin_key=self.admin_key._to_proto() 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 = PublicKey._from_proto(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/transaction/transaction.py b/src/hiero_sdk_python/transaction/transaction.py index 763aac655..abb239478 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..84ad52afc 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 (HIP-1137). + + 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/unit/registered_node_transaction_test.py b/tests/unit/registered_node_transaction_test.py new file mode 100644 index 000000000..fa0473f1a --- /dev/null +++ b/tests/unit/registered_node_transaction_test.py @@ -0,0 +1,349 @@ +"""Tests for HIP-1137 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: + def test_builds_proto_with_all_fields(self, mock_account_ids): + 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): + operator_id, _, node_account_id, _, _ = mock_account_ids + + tx = RegisteredNodeCreateTransaction() + 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): + 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_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): + tx = RegisteredNodeCreateTransaction() + tx.set_service_endpoints([_make_block_endpoint()]) + scheduled = tx.build_scheduled_body() + assert scheduled.HasField("registeredNodeCreate") + + def test_from_bytes_round_trip(self): + 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_fails_with_zero_endpoints(self, mock_account_ids): + operator_id, _, node_account_id, _, _ = mock_account_ids + tx = RegisteredNodeCreateTransaction() + tx.operator_account_id = operator_id + tx.node_account_id = node_account_id + + with pytest.raises(ValueError, match="at least 1"): + tx.build_transaction_body() + + def test_fails_with_more_than_50_endpoints(self, mock_account_ids): + operator_id, _, node_account_id, _, _ = mock_account_ids + endpoints = [_make_mirror_endpoint() for _ in range(51)] + tx = RegisteredNodeCreateTransaction() + tx.set_service_endpoints(endpoints) + tx.operator_account_id = operator_id + tx.node_account_id = node_account_id + + with pytest.raises(ValueError, match="at most 50"): + tx.build_transaction_body() + + def test_fails_with_long_description(self, mock_account_ids): + operator_id, _, node_account_id, _, _ = mock_account_ids + tx = RegisteredNodeCreateTransaction() + 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 + + with pytest.raises(ValueError, match="100 UTF-8 bytes"): + tx.build_transaction_body() + + def test_add_service_endpoint(self): + 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: + def test_builds_proto_with_registered_node_id(self, mock_account_ids): + 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): + 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): + 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): + 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): + 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): + tx = RegisteredNodeUpdateTransaction() + tx.set_registered_node_id(1) + scheduled = tx.build_scheduled_body() + assert scheduled.HasField("registeredNodeUpdate") + + def test_from_bytes_round_trip(self): + 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_fails_when_registered_node_id_missing(self, mock_account_ids): + operator_id, _, node_account_id, _, _ = mock_account_ids + tx = RegisteredNodeUpdateTransaction() + 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() + + def test_fails_when_endpoints_empty(self, mock_account_ids): + 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 + + with pytest.raises(ValueError, match="at least 1"): + tx.build_transaction_body() + + def test_fails_when_endpoints_exceed_50(self, mock_account_ids): + 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 + + with pytest.raises(ValueError, match="at most 50"): + tx.build_transaction_body() + + +# --------------------------------------------------------------------------- +# RegisteredNodeDeleteTransaction +# --------------------------------------------------------------------------- + + +class TestRegisteredNodeDeleteTransaction: + def test_builds_proto_with_registered_node_id(self, mock_account_ids): + 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): + 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): + 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): + 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: + def test_parses_when_present(self): + proto = transaction_receipt_pb2.TransactionReceipt(registered_node_id=123) + receipt = TransactionReceipt(proto) + assert receipt.registered_node_id == 123 + + def test_none_when_absent(self): + proto = transaction_receipt_pb2.TransactionReceipt() + receipt = TransactionReceipt(proto) + assert receipt.registered_node_id is None + + +# --------------------------------------------------------------------------- +# Transaction deserialization mapping +# --------------------------------------------------------------------------- + + +class TestTransactionDeserialization: + def test_resolves_registered_node_create(self): + cls = Transaction._get_transaction_class("registeredNodeCreate") + assert cls is RegisteredNodeCreateTransaction + + def test_resolves_registered_node_update(self): + cls = Transaction._get_transaction_class("registeredNodeUpdate") + assert cls is RegisteredNodeUpdateTransaction + + def test_resolves_registered_node_delete(self): + cls = Transaction._get_transaction_class("registeredNodeDelete") + assert cls is RegisteredNodeDeleteTransaction From cd2de3996c6077fbcad801b84f44ea686f8d3515 Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Wed, 6 May 2026 23:14:53 +0300 Subject: [PATCH 04/31] feat: support associated registered nodes on node transactions Signed-off-by: Ntege Daniel --- .../nodes/node_create_transaction.py | 50 ++++ .../nodes/node_update_transaction.py | 83 +++++- .../unit/associated_registered_nodes_test.py | 246 ++++++++++++++++++ 3 files changed, 377 insertions(+), 2 deletions(-) create mode 100644 tests/unit/associated_registered_nodes_test.py diff --git a/src/hiero_sdk_python/nodes/node_create_transaction.py b/src/hiero_sdk_python/nodes/node_create_transaction.py index 0431a1f8f..3f5a144b6 100644 --- a/src/hiero_sdk_python/nodes/node_create_transaction.py +++ b/src/hiero_sdk_python/nodes/node_create_transaction.py @@ -75,6 +75,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] = [] def set_account_id(self, account_id: AccountId | None) -> NodeCreateTransaction: """ @@ -211,6 +212,42 @@ 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 + + @staticmethod + def _validate_associated_registered_nodes(ids: list[int]) -> None: + if len(ids) > 20: + raise ValueError("associated_registered_nodes must have at most 20 entries") + for node_id in ids: + if not isinstance(node_id, int) or isinstance(node_id, bool) or node_id <= 0: + raise ValueError("Each associated registered node ID must be a positive integer") + def _build_proto_body(self) -> NodeCreateTransactionBody: """ Returns the protobuf body for the node create transaction. @@ -218,6 +255,8 @@ def _build_proto_body(self) -> NodeCreateTransactionBody: Returns: NodeCreateTransactionBody: The protobuf body for this transaction. """ + self._validate_associated_registered_nodes(self.associated_registered_nodes) + return NodeCreateTransactionBody( account_id=self.account_id._to_proto() if self.account_id else None, description=self.description, @@ -228,6 +267,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 +308,13 @@ 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 + 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..01b073efc 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, ) @@ -81,6 +84,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 = None def set_node_id(self, node_id: int | None) -> NodeUpdateTransaction: """ @@ -232,6 +236,56 @@ 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 + + @staticmethod + def _validate_associated_registered_nodes(ids: list[int]) -> None: + if len(ids) > 20: + raise ValueError("associated_registered_nodes must have at most 20 entries") + for node_id in ids: + if not isinstance(node_id, int) or isinstance(node_id, bool) or node_id <= 0: + raise ValueError("Each associated registered node ID must be a positive integer") + 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 +301,10 @@ def _build_proto_body(self) -> NodeUpdateTransactionBody: Returns: NodeUpdateTransactionBody: The protobuf body for this transaction. """ - return NodeUpdateTransactionBody( + if self.associated_registered_nodes is not None: + self._validate_associated_registered_nodes(self.associated_registered_nodes) + + 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 +321,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 +368,16 @@ 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 + 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/tests/unit/associated_registered_nodes_test.py b/tests/unit/associated_registered_nodes_test.py new file mode 100644 index 000000000..eaac6ec08 --- /dev/null +++ b/tests/unit/associated_registered_nodes_test.py @@ -0,0 +1,246 @@ +"""Tests for HIP-1137 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: + def test_serializes_associated_registered_nodes(self, mock_account_ids): + 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): + 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): + tx = NodeCreateTransaction() + tx.add_associated_registered_node(1) + tx.set_associated_registered_nodes([5, 6]) + assert tx.associated_registered_nodes == [5, 6] + + def test_from_bytes_round_trip(self): + 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_fails_more_than_20_ids(self, mock_account_ids): + operator_id, _, node_account_id, _, _ = mock_account_ids + tx = NodeCreateTransaction() + tx.set_associated_registered_nodes(list(range(1, 22))) + tx.operator_account_id = operator_id + tx.node_account_id = node_account_id + with pytest.raises(ValueError, match="at most 20"): + tx.build_transaction_body() + + def test_fails_id_zero(self, mock_account_ids): + operator_id, _, node_account_id, _, _ = mock_account_ids + tx = NodeCreateTransaction() + tx.set_associated_registered_nodes([0]) + tx.operator_account_id = operator_id + tx.node_account_id = node_account_id + with pytest.raises(ValueError, match="positive integer"): + tx.build_transaction_body() + + def test_fails_id_negative(self, mock_account_ids): + operator_id, _, node_account_id, _, _ = mock_account_ids + tx = NodeCreateTransaction() + tx.set_associated_registered_nodes([-1]) + tx.operator_account_id = operator_id + tx.node_account_id = node_account_id + with pytest.raises(ValueError, match="positive integer"): + tx.build_transaction_body() + + def test_preserves_behavior_when_unset(self, mock_account_ids): + 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): + tx = NodeCreateTransaction() + assert tx.associated_registered_nodes == [] + + def test_constructor_compat_no_break(self): + 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: + def test_does_not_serialize_when_none(self, mock_account_ids): + 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): + 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): + 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): + 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): + tx = NodeUpdateTransaction() + tx.add_associated_registered_node(1) + tx.set_associated_registered_nodes([100, 200]) + assert tx.associated_registered_nodes == [100, 200] + + def test_from_bytes_round_trip_non_empty(self): + 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): + 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): + 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_fails_more_than_20_ids(self, mock_account_ids): + operator_id, _, node_account_id, _, _ = mock_account_ids + tx = NodeUpdateTransaction() + tx.node_id = 1 + tx.set_associated_registered_nodes(list(range(1, 22))) + tx.operator_account_id = operator_id + tx.node_account_id = node_account_id + with pytest.raises(ValueError, match="at most 20"): + tx.build_transaction_body() + + def test_fails_id_zero(self, mock_account_ids): + operator_id, _, node_account_id, _, _ = mock_account_ids + tx = NodeUpdateTransaction() + tx.node_id = 1 + tx.set_associated_registered_nodes([0]) + tx.operator_account_id = operator_id + tx.node_account_id = node_account_id + with pytest.raises(ValueError, match="positive integer"): + tx.build_transaction_body() + + def test_fails_id_negative(self, mock_account_ids): + operator_id, _, node_account_id, _, _ = mock_account_ids + tx = NodeUpdateTransaction() + tx.node_id = 1 + tx.set_associated_registered_nodes([-5]) + tx.operator_account_id = operator_id + tx.node_account_id = node_account_id + with pytest.raises(ValueError, match="positive integer"): + tx.build_transaction_body() + + 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): + params = NodeUpdateParams(node_id=5) + tx = NodeUpdateTransaction(params) + assert tx.node_id == 5 + assert tx.associated_registered_nodes is None From 0d7605089aa779de3c935547676a2040729cff6e Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Wed, 6 May 2026 23:25:54 +0300 Subject: [PATCH 05/31] feat: add HIP-1137 registered node address book models Signed-off-by: Ntege Daniel --- src/hiero_sdk_python/__init__.py | 6 + .../address_book/registered_node.py | 80 ++++++ .../registered_node_address_book.py | 35 +++ .../registered_node_address_book_query.py | 45 +++ tests/unit/registered_node_model_test.py | 272 ++++++++++++++++++ 5 files changed, 438 insertions(+) create mode 100644 src/hiero_sdk_python/address_book/registered_node.py create mode 100644 src/hiero_sdk_python/address_book/registered_node_address_book.py create mode 100644 src/hiero_sdk_python/address_book/registered_node_address_book_query.py create mode 100644 tests/unit/registered_node_model_test.py diff --git a/src/hiero_sdk_python/__init__.py b/src/hiero_sdk_python/__init__.py index 2c9b39c0d..5ebc1aab6 100644 --- a/src/hiero_sdk_python/__init__.py +++ b/src/hiero_sdk_python/__init__.py @@ -15,6 +15,9 @@ 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 @@ -247,6 +250,9 @@ # Address book "Endpoint", "NodeAddress", + "RegisteredNode", + "RegisteredNodeAddressBook", + "RegisteredNodeAddressBookQuery", # Logger "Logger", "LogLevel", 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..ff9424db9 --- /dev/null +++ b/src/hiero_sdk_python/address_book/registered_node.py @@ -0,0 +1,80 @@ +"""Read-side model for a HIP-1137 registered node.""" + +from __future__ import annotations + +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, +) + + +class RegisteredNode: + """Immutable model representing a registered node from network/mirror state.""" + + def __init__( + self, + registered_node_id: int, + admin_key: PublicKey | None = None, + description: str | None = None, + service_endpoints: tuple[RegisteredServiceEndpoint, ...] = (), + ) -> None: + if not isinstance(registered_node_id, int) or isinstance(registered_node_id, bool): + raise ValueError("registered_node_id must be a positive integer") + if registered_node_id <= 0: + raise ValueError("registered_node_id must be a positive integer") + + self._registered_node_id = registered_node_id + self._admin_key = admin_key + self._description = description + self._service_endpoints = tuple(service_endpoints) + + @property + def registered_node_id(self) -> int: + return self._registered_node_id + + @property + def admin_key(self) -> PublicKey | None: + return self._admin_key + + @property + def description(self) -> str | None: + return self._description + + @property + def service_endpoints(self) -> tuple[RegisteredServiceEndpoint, ...]: + return 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 if proto.description else 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})" 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..707d300f5 --- /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 HIP-1137 registered nodes.""" + +from __future__ import annotations + +from collections.abc import Iterator + +from hiero_sdk_python.address_book.registered_node import RegisteredNode + + +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. + """ + + def __init__(self, nodes: tuple[RegisteredNode, ...] = ()) -> None: + self._nodes = tuple(nodes) + + @property + def nodes(self) -> tuple[RegisteredNode, ...]: + return 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..c3c01e070 --- /dev/null +++ b/src/hiero_sdk_python/address_book/registered_node_address_book_query.py @@ -0,0 +1,45 @@ +"""Skeleton query for fetching the registered-node address book (HIP-1137).""" + +from __future__ import annotations + +from hiero_sdk_python.address_book.registered_node_address_book import RegisteredNodeAddressBook + + +class RegisteredNodeAddressBookQuery: + """Query to retrieve the registered-node address book from the network. + + .. warning:: + + Query execution is **not yet available**. The mirror-node API endpoint + required by this query has not been implemented in this SDK release. + Calling :meth:`execute` will raise :class:`NotImplementedError`. + """ + + def __init__(self) -> None: + self._max_registered_node_count: int | None = None + + def set_max_registered_node_count(self, count: int) -> RegisteredNodeAddressBookQuery: + """Limit the 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 execute(self, client: object = None) -> RegisteredNodeAddressBook: + """Execute the query against the network. + + Raises: + NotImplementedError: Always. Mirror-node API support for + registered-node queries is not yet available in this SDK. + """ + raise NotImplementedError( + "RegisteredNodeAddressBookQuery requires HIP-1137 mirror node API support, " + "which is not yet available in this SDK." + ) diff --git a/tests/unit/registered_node_model_test.py b/tests/unit/registered_node_model_test.py new file mode 100644 index 000000000..0b19cac0e --- /dev/null +++ b/tests/unit/registered_node_model_test.py @@ -0,0 +1,272 @@ +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_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 + + +# --------------------------------------------------------------------------- +# 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_raises_not_implemented(self): + q = RegisteredNodeAddressBookQuery() + with pytest.raises(NotImplementedError, match="HIP-1137 mirror node API support"): + q.execute() + + def test_error_message_is_clear(self): + q = RegisteredNodeAddressBookQuery() + with pytest.raises(NotImplementedError) as exc_info: + q.execute() + msg = str(exc_info.value) + assert "RegisteredNodeAddressBookQuery" in msg + assert "not yet available" in msg + + 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) From 87b9d90631a94636ef5d8cf5f93a9ae5b51f2cbf Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Wed, 6 May 2026 23:46:33 +0300 Subject: [PATCH 06/31] test: harden HIP-1137 validation and status handling Signed-off-by: Ntege Daniel --- src/hiero_sdk_python/__init__.py | 9 + .../registered_node_create_transaction.py | 5 + .../registered_node_delete_transaction.py | 6 + .../registered_node_update_transaction.py | 9 + src/hiero_sdk_python/response_code.py | 48 +++ tests/unit/hip1137_hardening_test.py | 312 ++++++++++++++++++ .../unit/registered_node_transaction_test.py | 6 + 7 files changed, 395 insertions(+) create mode 100644 tests/unit/hip1137_hardening_test.py diff --git a/src/hiero_sdk_python/__init__.py b/src/hiero_sdk_python/__init__.py index 5ebc1aab6..44ce92ff1 100644 --- a/src/hiero_sdk_python/__init__.py +++ b/src/hiero_sdk_python/__init__.py @@ -248,11 +248,17 @@ "TokenInfoQuery", "AccountInfoQuery", # Address book + "BlockNodeApi", + "BlockNodeServiceEndpoint", "Endpoint", + "GeneralServiceEndpoint", + "MirrorNodeServiceEndpoint", "NodeAddress", "RegisteredNode", "RegisteredNodeAddressBook", "RegisteredNodeAddressBookQuery", + "RegisteredServiceEndpoint", + "RpcRelayServiceEndpoint", # Logger "Logger", "LogLevel", @@ -296,6 +302,9 @@ "NodeCreateTransaction", "NodeUpdateTransaction", "NodeDeleteTransaction", + "RegisteredNodeCreateTransaction", + "RegisteredNodeUpdateTransaction", + "RegisteredNodeDeleteTransaction", # PRNG "PrngTransaction", # Custom Fees diff --git a/src/hiero_sdk_python/nodes/registered_node_create_transaction.py b/src/hiero_sdk_python/nodes/registered_node_create_transaction.py index 28021d818..31004621e 100644 --- a/src/hiero_sdk_python/nodes/registered_node_create_transaction.py +++ b/src/hiero_sdk_python/nodes/registered_node_create_transaction.py @@ -46,10 +46,15 @@ def add_service_endpoint(self, endpoint: RegisteredServiceEndpoint) -> Registere return self def _build_proto_body(self) -> RegisteredNodeCreateTransactionBody: + if self.admin_key is None: + raise ValueError("admin_key is required") if not self.service_endpoints: raise ValueError("service_endpoints must have at least 1 entry") if len(self.service_endpoints) > 50: raise ValueError("service_endpoints must have at most 50 entries") + for ep in self.service_endpoints: + if not isinstance(ep, RegisteredServiceEndpoint): + raise TypeError("service_endpoints must contain RegisteredServiceEndpoint instances") if self.description is not None and len(self.description.encode("utf-8")) > 100: raise ValueError("description must be 100 UTF-8 bytes or fewer") diff --git a/src/hiero_sdk_python/nodes/registered_node_delete_transaction.py b/src/hiero_sdk_python/nodes/registered_node_delete_transaction.py index e7385839a..10fc93203 100644 --- a/src/hiero_sdk_python/nodes/registered_node_delete_transaction.py +++ b/src/hiero_sdk_python/nodes/registered_node_delete_transaction.py @@ -27,6 +27,12 @@ def set_registered_node_id(self, registered_node_id: int | None) -> RegisteredNo 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, diff --git a/src/hiero_sdk_python/nodes/registered_node_update_transaction.py b/src/hiero_sdk_python/nodes/registered_node_update_transaction.py index 6a126459a..0e2ba9364 100644 --- a/src/hiero_sdk_python/nodes/registered_node_update_transaction.py +++ b/src/hiero_sdk_python/nodes/registered_node_update_transaction.py @@ -58,6 +58,12 @@ def add_service_endpoint(self, endpoint: RegisteredServiceEndpoint) -> Registere def _build_proto_body(self) -> RegisteredNodeUpdateTransactionBody: 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") if self.description is not None and len(self.description.encode("utf-8")) > 100: raise ValueError("description must be 100 UTF-8 bytes or fewer") if self.service_endpoints is not None: @@ -65,6 +71,9 @@ def _build_proto_body(self) -> RegisteredNodeUpdateTransactionBody: raise ValueError("service_endpoints must have at least 1 entry when provided") if len(self.service_endpoints) > 50: raise ValueError("service_endpoints must have at most 50 entries") + for ep in self.service_endpoints: + if not isinstance(ep, RegisteredServiceEndpoint): + raise TypeError("service_endpoints must contain RegisteredServiceEndpoint instances") body = RegisteredNodeUpdateTransactionBody( registered_node_id=self.registered_node_id, diff --git a/src/hiero_sdk_python/response_code.py b/src/hiero_sdk_python/response_code.py index f233644dc..16348e405 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 + + # HIP-1137: 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/tests/unit/hip1137_hardening_test.py b/tests/unit/hip1137_hardening_test.py new file mode 100644 index 000000000..7be702a5f --- /dev/null +++ b/tests/unit/hip1137_hardening_test.py @@ -0,0 +1,312 @@ +"""Phase 6 hardening tests — validation, status codes, retry, 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() + + +# --------------------------------------------------------------------------- +# HIP-1137 status codes in ResponseCode enum +# --------------------------------------------------------------------------- + + +class TestHip1137StatusCodes: + """Verify all HIP-1137 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): + """HIP-1137 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: + def test_ip_address_not_bytes(self): + with pytest.raises(ValueError, match="ip_address"): + RegisteredServiceEndpoint(ip_address="192.168.1.1", port=80) + + def test_domain_name_not_str(self): + with pytest.raises((ValueError, TypeError)): + RegisteredServiceEndpoint(domain_name=12345, port=80) + + def test_port_not_int(self): + with pytest.raises(ValueError, match="port"): + RegisteredServiceEndpoint(domain_name="example.com", port="80") + + def test_port_is_bool(self): + with pytest.raises(ValueError, match="port"): + RegisteredServiceEndpoint(domain_name="example.com", port=True) + + def test_requires_tls_not_bool(self): + 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): + 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): + # 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): + # 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: + def test_missing_admin_key_fails(self): + tx = RegisteredNodeCreateTransaction() + tx.set_service_endpoints([_mirror_ep()]) + with pytest.raises(ValueError, match="admin_key is required"): + tx._build_proto_body() + + def test_invalid_endpoint_object_fails(self): + tx = RegisteredNodeCreateTransaction() + tx.admin_key = _make_key() + tx.service_endpoints = ["not an endpoint"] + with pytest.raises(TypeError, match="RegisteredServiceEndpoint"): + tx._build_proto_body() + + def test_valid_build_succeeds(self): + tx = RegisteredNodeCreateTransaction() + tx.admin_key = _make_key() + tx.set_service_endpoints([_mirror_ep()]) + body = tx._build_proto_body() + assert body.HasField("admin_key") + + +# --------------------------------------------------------------------------- +# RegisteredNodeUpdateTransaction validation +# --------------------------------------------------------------------------- + + +class TestRegisteredNodeUpdateTransactionValidation: + def test_registered_node_id_zero_fails(self): + tx = RegisteredNodeUpdateTransaction() + tx.registered_node_id = 0 + with pytest.raises(ValueError, match="positive integer"): + tx._build_proto_body() + + def test_registered_node_id_negative_fails(self): + tx = RegisteredNodeUpdateTransaction() + tx.registered_node_id = -5 + with pytest.raises(ValueError, match="positive integer"): + tx._build_proto_body() + + def test_empty_service_endpoints_fails(self): + tx = RegisteredNodeUpdateTransaction() + tx.registered_node_id = 1 + tx.service_endpoints = [] + with pytest.raises(ValueError, match="at least 1 entry"): + tx._build_proto_body() + + def test_invalid_endpoint_object_fails(self): + tx = RegisteredNodeUpdateTransaction() + tx.registered_node_id = 1 + tx.service_endpoints = [42] + with pytest.raises(TypeError, match="RegisteredServiceEndpoint"): + tx._build_proto_body() + + +# --------------------------------------------------------------------------- +# RegisteredNodeDeleteTransaction validation +# --------------------------------------------------------------------------- + + +class TestRegisteredNodeDeleteTransactionValidation: + def test_registered_node_id_zero_fails(self): + 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): + 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): + 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: + def test_node_create_rejects_string_id(self): + from hiero_sdk_python.nodes.node_create_transaction import NodeCreateTransaction + + tx = NodeCreateTransaction() + tx.set_associated_registered_nodes(["abc"]) + with pytest.raises(ValueError, match="positive integer"): + tx._validate_associated_registered_nodes(tx.associated_registered_nodes) + + def test_node_update_rejects_float_id(self): + from hiero_sdk_python.nodes.node_update_transaction import NodeUpdateTransaction + + tx = NodeUpdateTransaction() + tx.set_associated_registered_nodes([1.5]) + with pytest.raises(ValueError, match="positive integer"): + tx._validate_associated_registered_nodes(tx.associated_registered_nodes) + + +# --------------------------------------------------------------------------- +# Public imports +# --------------------------------------------------------------------------- + + +class TestPublicImports: + """All intended HIP-1137 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_transaction_test.py b/tests/unit/registered_node_transaction_test.py index fa0473f1a..806fe31e7 100644 --- a/tests/unit/registered_node_transaction_test.py +++ b/tests/unit/registered_node_transaction_test.py @@ -75,6 +75,7 @@ def test_multiple_service_endpoints(self, mock_account_ids): 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 @@ -90,6 +91,7 @@ def test_block_endpoint_with_multiple_apis(self, mock_account_ids): 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 @@ -99,6 +101,7 @@ def test_block_endpoint_with_multiple_apis(self, mock_account_ids): def test_builds_schedulable_body(self): 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") @@ -123,6 +126,7 @@ def test_from_bytes_round_trip(self): def test_fails_with_zero_endpoints(self, mock_account_ids): 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 @@ -133,6 +137,7 @@ def test_fails_with_more_than_50_endpoints(self, mock_account_ids): 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 @@ -143,6 +148,7 @@ def test_fails_with_more_than_50_endpoints(self, mock_account_ids): def test_fails_with_long_description(self, mock_account_ids): 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 From 550ffdbe3c82f6b9fcb6acdaa1e3d62a8a1e9e53 Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Thu, 7 May 2026 00:45:24 +0300 Subject: [PATCH 07/31] test: add HIP-1137 integration coverage and docs Signed-off-by: Ntege Daniel --- examples/nodes/registered_node_lifecycle.py | 129 +++++++++++ .../associated_registered_nodes_e2e_test.py | 151 +++++++++++++ ...stered_node_create_transaction_e2e_test.py | 120 ++++++++++ ...stered_node_delete_transaction_e2e_test.py | 89 ++++++++ ...stered_node_update_transaction_e2e_test.py | 127 +++++++++++ tests/unit/hip1137_tck_aligned_test.py | 207 ++++++++++++++++++ 6 files changed, 823 insertions(+) create mode 100644 examples/nodes/registered_node_lifecycle.py create mode 100644 tests/integration/associated_registered_nodes_e2e_test.py create mode 100644 tests/integration/registered_node_create_transaction_e2e_test.py create mode 100644 tests/integration/registered_node_delete_transaction_e2e_test.py create mode 100644 tests/integration/registered_node_update_transaction_e2e_test.py create mode 100644 tests/unit/hip1137_tck_aligned_test.py diff --git a/examples/nodes/registered_node_lifecycle.py b/examples/nodes/registered_node_lifecycle.py new file mode 100644 index 000000000..451a4a103 --- /dev/null +++ b/examples/nodes/registered_node_lifecycle.py @@ -0,0 +1,129 @@ +""" +Demonstrates the full lifecycle of a registered node on the Hedera network (HIP-1137). + +This example shows how to: +1. Create a registered node with BlockNodeServiceEndpoint and multiple endpoint APIs +2. Update the registered node's description and service endpoints +3. Delete the registered node + +NOTE: This is a privileged transaction that requires HIP-1137 support on the network. +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 + +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.mirror_node_service_endpoint import MirrorNodeServiceEndpoint +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, update, 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--- Creating registered node ---") + + 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("Example block node") + .set_service_endpoints([block_endpoint]) + .freeze_with(client) + .sign(admin_key) + .execute(client) + ) + + 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: Update the registered node ────────────────────────── + print("\n--- Updating registered node ---") + + mirror_endpoint = MirrorNodeServiceEndpoint( + domain_name="mirror.example.com", + port=5600, + requires_tls=True, + ) + + receipt = ( + RegisteredNodeUpdateTransaction() + .set_registered_node_id(registered_node_id) + .set_description("Updated block + mirror node") + .set_service_endpoints([block_endpoint, mirror_endpoint]) + .freeze_with(client) + .sign(admin_key) + .execute(client) + ) + + if receipt.status != ResponseCode.SUCCESS: + print(f"Registered node update failed: {ResponseCode(receipt.status).name}") + sys.exit(1) + + print("Registered node updated successfully") + + # ── Step 3: Delete the registered node ────────────────────────── + print("\n--- Deleting registered node ---") + + receipt = ( + RegisteredNodeDeleteTransaction() + .set_registered_node_id(registered_node_id) + .freeze_with(client) + .sign(admin_key) + .execute(client) + ) + + 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/tests/integration/associated_registered_nodes_e2e_test.py b/tests/integration/associated_registered_nodes_e2e_test.py new file mode 100644 index 000000000..029cd2e79 --- /dev/null +++ b/tests/integration/associated_registered_nodes_e2e_test.py @@ -0,0 +1,151 @@ +""" +Integration tests for associated_registered_nodes on NodeCreate/NodeUpdate (HIP-1137). +""" + +from __future__ import annotations + +import pytest + +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 + return receipt.registered_node_id + + +@pytest.mark.skip(reason="HIP-1137 registered node support not yet available on local-node/solo") +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 + + +@pytest.mark.skip(reason="HIP-1137 registered node support not yet available on local-node/solo") +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}" + + +@pytest.mark.skip(reason="HIP-1137 registered node support not yet available on local-node/solo") +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..2f7dfdb5f --- /dev/null +++ b/tests/integration/registered_node_create_transaction_e2e_test.py @@ -0,0 +1,120 @@ +""" +Integration tests for RegisteredNodeCreateTransaction (HIP-1137). +""" + +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.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.response_code import ResponseCode + + +@pytest.mark.skip(reason="HIP-1137 registered node support not yet available on local-node/solo") +def test_registered_node_create_with_block_endpoint(): + """Test creating a registered node with a BlockNodeServiceEndpoint.""" + network = Network(network="solo") + client = Client(network) + + # Account 0.0.2 is a special admin account with privileges for network management operations. + original_operator_key = PrivateKey.from_string_der( + "302e020100300506032b65700422042091132178e72057a1d7528025956fe39b0b847f200ab59b2fdd367017f3087137" + ) + client.set_operator(AccountId(0, 0, 2), original_operator_key) + + 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(client) + .sign(admin_key) + .execute(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" + + +@pytest.mark.skip(reason="HIP-1137 registered node support not yet available on local-node/solo") +def test_registered_node_create_with_mixed_endpoints(): + """Test creating a registered node with multiple endpoint types.""" + 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) + + 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(client) + .sign(admin_key) + .execute(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 + + +@pytest.mark.skip(reason="HIP-1137 registered node support not yet available on local-node/solo") +def test_registered_node_create_fails_without_endpoints(): + """Test that creating a registered node with no endpoints fails.""" + 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) + + admin_key = PrivateKey.generate_ed25519() + + with pytest.raises(ValueError, match="at least 1"): + ( + RegisteredNodeCreateTransaction() + .set_admin_key(admin_key.public_key()) + .set_description("no endpoints") + .freeze_with(client) + .sign(admin_key) + .execute(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..676da97a5 --- /dev/null +++ b/tests/integration/registered_node_delete_transaction_e2e_test.py @@ -0,0 +1,89 @@ +""" +Integration tests for RegisteredNodeDeleteTransaction (HIP-1137). +""" + +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 + + +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 + return receipt.registered_node_id + + +@pytest.mark.skip(reason="HIP-1137 registered node support not yet available on local-node/solo") +def test_registered_node_delete(): + """Test creating then deleting a registered node.""" + 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) + + admin_key = PrivateKey.generate_ed25519() + registered_node_id = _create_registered_node(client, admin_key) + + receipt = ( + RegisteredNodeDeleteTransaction() + .set_registered_node_id(registered_node_id) + .freeze_with(client) + .sign(admin_key) + .execute(client) + ) + + assert receipt.status == ResponseCode.SUCCESS, ( + f"Registered node delete failed with status {ResponseCode(receipt.status).name}" + ) + + +@pytest.mark.skip(reason="HIP-1137 registered node support not yet available on local-node/solo") +def test_registered_node_delete_invalid_id(): + """Test that deleting a nonexistent registered node fails.""" + 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) + + admin_key = PrivateKey.generate_ed25519() + + receipt = ( + RegisteredNodeDeleteTransaction() + .set_registered_node_id(999999999) + .freeze_with(client) + .sign(admin_key) + .execute(client) + ) + + assert receipt.status != ResponseCode.SUCCESS, "Delete of nonexistent node should fail" 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..fd2300175 --- /dev/null +++ b/tests/integration/registered_node_update_transaction_e2e_test.py @@ -0,0 +1,127 @@ +""" +Integration tests for RegisteredNodeUpdateTransaction (HIP-1137). +""" + +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.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_update_transaction import RegisteredNodeUpdateTransaction +from hiero_sdk_python.response_code import ResponseCode + + +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 + return receipt.registered_node_id + + +@pytest.mark.skip(reason="HIP-1137 registered node support not yet available on local-node/solo") +def test_registered_node_update_description(): + """Test updating a registered node's description.""" + 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) + + admin_key = PrivateKey.generate_ed25519() + registered_node_id = _create_registered_node(client, admin_key) + + receipt = ( + RegisteredNodeUpdateTransaction() + .set_registered_node_id(registered_node_id) + .set_description("updated description") + .freeze_with(client) + .sign(admin_key) + .execute(client) + ) + + assert receipt.status == ResponseCode.SUCCESS, ( + f"Registered node update failed with status {ResponseCode(receipt.status).name}" + ) + + +@pytest.mark.skip(reason="HIP-1137 registered node support not yet available on local-node/solo") +def test_registered_node_update_service_endpoints(): + """Test replacing a registered node's service endpoints.""" + 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) + + admin_key = PrivateKey.generate_ed25519() + registered_node_id = _create_registered_node(client, admin_key) + + 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(client) + .sign(admin_key) + .execute(client) + ) + + assert receipt.status == ResponseCode.SUCCESS, ( + f"Registered node update failed with status {ResponseCode(receipt.status).name}" + ) + + +@pytest.mark.skip(reason="HIP-1137 registered node support not yet available on local-node/solo") +def test_registered_node_update_invalid_id(): + """Test that updating a nonexistent registered node fails.""" + 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) + + admin_key = PrivateKey.generate_ed25519() + + # Use a very large ID that is unlikely to exist + receipt = ( + RegisteredNodeUpdateTransaction() + .set_registered_node_id(999999999) + .set_description("should fail") + .freeze_with(client) + .sign(admin_key) + .execute(client) + ) + + assert receipt.status != ResponseCode.SUCCESS, "Update of nonexistent node should fail" diff --git a/tests/unit/hip1137_tck_aligned_test.py b/tests/unit/hip1137_tck_aligned_test.py new file mode 100644 index 000000000..d2bc0a6c3 --- /dev/null +++ b/tests/unit/hip1137_tck_aligned_test.py @@ -0,0 +1,207 @@ +""" +TCK-aligned unit tests for HIP-1137 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 HIP-1137 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_requires_admin_key(self): + """TCK: createRegisteredNode without adminKey should raise.""" + ep = BlockNodeServiceEndpoint( + domain_name="block.tck.example.com", + port=443, + requires_tls=True, + endpoint_apis=[BlockNodeApi.STATUS], + ) + tx = RegisteredNodeCreateTransaction() + tx.set_service_endpoints([ep]) + with pytest.raises(ValueError, match="admin_key is required"): + tx._build_proto_body() + + def test_create_requires_at_least_one_endpoint(self): + """TCK: createRegisteredNode with empty endpoints should raise.""" + admin_key = PrivateKey.generate_ed25519().public_key() + tx = RegisteredNodeCreateTransaction() + tx.set_admin_key(admin_key) + with pytest.raises(ValueError, match="at least 1"): + tx._build_proto_body() + + 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_invalid_id_zero(self): + """TCK: updateRegisteredNode with id=0 should raise on build.""" + tx = RegisteredNodeUpdateTransaction() + tx.set_registered_node_id(0) + with pytest.raises(ValueError): + tx._build_proto_body() + + def test_update_invalid_id_negative(self): + """TCK: updateRegisteredNode with negative id should raise on build.""" + 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() From 5fdbc504afc031855c2178a757808269ce44a663 Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Thu, 7 May 2026 01:32:27 +0300 Subject: [PATCH 08/31] test: add HIP-1137 integration coverage and docs Signed-off-by: Ntege Daniel --- ...stered_node_create_transaction_e2e_test.py | 60 +++----- ...stered_node_delete_transaction_e2e_test.py | 39 ++--- ...stered_node_update_transaction_e2e_test.py | 133 ++++++++---------- 3 files changed, 85 insertions(+), 147 deletions(-) diff --git a/tests/integration/registered_node_create_transaction_e2e_test.py b/tests/integration/registered_node_create_transaction_e2e_test.py index 2f7dfdb5f..b902c7106 100644 --- a/tests/integration/registered_node_create_transaction_e2e_test.py +++ b/tests/integration/registered_node_create_transaction_e2e_test.py @@ -6,29 +6,17 @@ 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.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 -@pytest.mark.skip(reason="HIP-1137 registered node support not yet available on local-node/solo") -def test_registered_node_create_with_block_endpoint(): +def test_registered_node_create_with_block_endpoint(env): """Test creating a registered node with a BlockNodeServiceEndpoint.""" - network = Network(network="solo") - client = Client(network) - - # Account 0.0.2 is a special admin account with privileges for network management operations. - original_operator_key = PrivateKey.from_string_der( - "302e020100300506032b65700422042091132178e72057a1d7528025956fe39b0b847f200ab59b2fdd367017f3087137" - ) - client.set_operator(AccountId(0, 0, 2), original_operator_key) - admin_key = PrivateKey.generate_ed25519() block_endpoint = BlockNodeServiceEndpoint( @@ -43,9 +31,9 @@ def test_registered_node_create_with_block_endpoint(): .set_admin_key(admin_key.public_key()) .set_description("test registered node") .set_service_endpoints([block_endpoint]) - .freeze_with(client) + .freeze_with(env.client) .sign(admin_key) - .execute(client) + .execute(env.client) ) assert receipt.status == ResponseCode.SUCCESS, ( @@ -54,18 +42,14 @@ def test_registered_node_create_with_block_endpoint(): 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(env.client).sign( + admin_key + ).execute(env.client) -@pytest.mark.skip(reason="HIP-1137 registered node support not yet available on local-node/solo") -def test_registered_node_create_with_mixed_endpoints(): - """Test creating a registered node with multiple endpoint types.""" - 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) +def test_registered_node_create_with_mixed_endpoints(env): + """Test creating a registered node with multiple endpoint types.""" admin_key = PrivateKey.generate_ed25519() block_endpoint = BlockNodeServiceEndpoint( @@ -85,9 +69,9 @@ def test_registered_node_create_with_mixed_endpoints(): .set_admin_key(admin_key.public_key()) .set_description("mixed endpoints node") .set_service_endpoints([block_endpoint, mirror_endpoint]) - .freeze_with(client) + .freeze_with(env.client) .sign(admin_key) - .execute(client) + .execute(env.client) ) assert receipt.status == ResponseCode.SUCCESS, ( @@ -95,18 +79,14 @@ def test_registered_node_create_with_mixed_endpoints(): ) assert receipt.registered_node_id is not None + # Cleanup + RegisteredNodeDeleteTransaction().set_registered_node_id(receipt.registered_node_id).freeze_with(env.client).sign( + admin_key + ).execute(env.client) -@pytest.mark.skip(reason="HIP-1137 registered node support not yet available on local-node/solo") -def test_registered_node_create_fails_without_endpoints(): - """Test that creating a registered node with no endpoints fails.""" - 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) +def test_registered_node_create_fails_without_endpoints(env): + """Test that creating a registered node with no endpoints fails (client-side validation).""" admin_key = PrivateKey.generate_ed25519() with pytest.raises(ValueError, match="at least 1"): @@ -114,7 +94,7 @@ def test_registered_node_create_fails_without_endpoints(): RegisteredNodeCreateTransaction() .set_admin_key(admin_key.public_key()) .set_description("no endpoints") - .freeze_with(client) + .freeze_with(env.client) .sign(admin_key) - .execute(client) + .execute(env.client) ) diff --git a/tests/integration/registered_node_delete_transaction_e2e_test.py b/tests/integration/registered_node_delete_transaction_e2e_test.py index 676da97a5..0a1ab65b0 100644 --- a/tests/integration/registered_node_delete_transaction_e2e_test.py +++ b/tests/integration/registered_node_delete_transaction_e2e_test.py @@ -4,13 +4,8 @@ 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 @@ -38,26 +33,17 @@ def _create_registered_node(client, admin_key): return receipt.registered_node_id -@pytest.mark.skip(reason="HIP-1137 registered node support not yet available on local-node/solo") -def test_registered_node_delete(): +def test_registered_node_delete(env): """Test creating then deleting a registered node.""" - 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) - admin_key = PrivateKey.generate_ed25519() - registered_node_id = _create_registered_node(client, admin_key) + registered_node_id = _create_registered_node(env.client, admin_key) receipt = ( RegisteredNodeDeleteTransaction() .set_registered_node_id(registered_node_id) - .freeze_with(client) + .freeze_with(env.client) .sign(admin_key) - .execute(client) + .execute(env.client) ) assert receipt.status == ResponseCode.SUCCESS, ( @@ -65,25 +51,16 @@ def test_registered_node_delete(): ) -@pytest.mark.skip(reason="HIP-1137 registered node support not yet available on local-node/solo") -def test_registered_node_delete_invalid_id(): - """Test that deleting a nonexistent registered node fails.""" - 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) - +def test_registered_node_delete_invalid_id(env): + """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(client) + .freeze_with(env.client) .sign(admin_key) - .execute(client) + .execute(env.client) ) assert receipt.status != ResponseCode.SUCCESS, "Delete of nonexistent node should fail" diff --git a/tests/integration/registered_node_update_transaction_e2e_test.py b/tests/integration/registered_node_update_transaction_e2e_test.py index fd2300175..3c8be5e73 100644 --- a/tests/integration/registered_node_update_transaction_e2e_test.py +++ b/tests/integration/registered_node_update_transaction_e2e_test.py @@ -4,16 +4,12 @@ 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.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.nodes.registered_node_update_transaction import RegisteredNodeUpdateTransaction from hiero_sdk_python.response_code import ResponseCode @@ -39,79 +35,64 @@ def _create_registered_node(client, admin_key): return receipt.registered_node_id -@pytest.mark.skip(reason="HIP-1137 registered node support not yet available on local-node/solo") -def test_registered_node_update_description(): +def test_registered_node_update_description(env): """Test updating a registered node's description.""" - 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) - admin_key = PrivateKey.generate_ed25519() - registered_node_id = _create_registered_node(client, admin_key) - - receipt = ( - RegisteredNodeUpdateTransaction() - .set_registered_node_id(registered_node_id) - .set_description("updated description") - .freeze_with(client) - .sign(admin_key) - .execute(client) - ) - - assert receipt.status == ResponseCode.SUCCESS, ( - f"Registered node update failed with status {ResponseCode(receipt.status).name}" - ) - - -@pytest.mark.skip(reason="HIP-1137 registered node support not yet available on local-node/solo") -def test_registered_node_update_service_endpoints(): + registered_node_id = _create_registered_node(env.client, admin_key) + + try: + receipt = ( + RegisteredNodeUpdateTransaction() + .set_registered_node_id(registered_node_id) + .set_description("updated description") + .freeze_with(env.client) + .sign(admin_key) + .execute(env.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(env.client).sign( + admin_key + ).execute(env.client) + + +def test_registered_node_update_service_endpoints(env): """Test replacing a registered node's service endpoints.""" - 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) - admin_key = PrivateKey.generate_ed25519() - registered_node_id = _create_registered_node(client, admin_key) - - 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(client) - .sign(admin_key) - .execute(client) - ) - - assert receipt.status == ResponseCode.SUCCESS, ( - f"Registered node update failed with status {ResponseCode(receipt.status).name}" - ) - - -@pytest.mark.skip(reason="HIP-1137 registered node support not yet available on local-node/solo") -def test_registered_node_update_invalid_id(): - """Test that updating a nonexistent registered node fails.""" - 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) - + registered_node_id = _create_registered_node(env.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(env.client) + .sign(admin_key) + .execute(env.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(env.client).sign( + admin_key + ).execute(env.client) + + +def test_registered_node_update_invalid_id(env): + """Test that updating a nonexistent registered node fails at the network level.""" admin_key = PrivateKey.generate_ed25519() # Use a very large ID that is unlikely to exist @@ -119,9 +100,9 @@ def test_registered_node_update_invalid_id(): RegisteredNodeUpdateTransaction() .set_registered_node_id(999999999) .set_description("should fail") - .freeze_with(client) + .freeze_with(env.client) .sign(admin_key) - .execute(client) + .execute(env.client) ) assert receipt.status != ResponseCode.SUCCESS, "Update of nonexistent node should fail" From 2b9e6d3f7fbd666f115f80524565a2338e769df4 Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Thu, 7 May 2026 02:55:17 +0300 Subject: [PATCH 09/31] test: add HIP-1137 integration coverage and docs Signed-off-by: Ntege Daniel --- src/hiero_sdk_python/__init__.py | 18 ++++----- .../registered_service_endpoint.py | 29 +++++++++----- .../registered_node_update_transaction.py | 39 +++++++++++-------- 3 files changed, 51 insertions(+), 35 deletions(-) diff --git a/src/hiero_sdk_python/__init__.py b/src/hiero_sdk_python/__init__.py index 44ce92ff1..286f57e43 100644 --- a/src/hiero_sdk_python/__init__.py +++ b/src/hiero_sdk_python/__init__.py @@ -9,17 +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.block_node_api import BlockNodeApi # noqa: F401 +from .address_book.block_node_service_endpoint import BlockNodeServiceEndpoint # noqa: F401 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.general_service_endpoint import GeneralServiceEndpoint # noqa: F401 +from .address_book.mirror_node_service_endpoint import MirrorNodeServiceEndpoint # noqa: F401 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 +from .address_book.registered_service_endpoint import RegisteredServiceEndpoint # noqa: F401 +from .address_book.rpc_relay_service_endpoint import RpcRelayServiceEndpoint # noqa: F401 # Client and Network from .client.client import Client @@ -79,9 +79,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 +from .nodes.registered_node_create_transaction import RegisteredNodeCreateTransaction # noqa: F401 +from .nodes.registered_node_delete_transaction import RegisteredNodeDeleteTransaction # noqa: F401 +from .nodes.registered_node_update_transaction import RegisteredNodeUpdateTransaction # noqa: F401 # PRNG from .prng_transaction import PrngTransaction diff --git a/src/hiero_sdk_python/address_book/registered_service_endpoint.py b/src/hiero_sdk_python/address_book/registered_service_endpoint.py index f44aee37c..198fd4478 100644 --- a/src/hiero_sdk_python/address_book/registered_service_endpoint.py +++ b/src/hiero_sdk_python/address_book/registered_service_endpoint.py @@ -31,17 +31,26 @@ def _validate_address(ip_address: bytes | None, domain_name: str | None) -> None raise ValueError("Exactly one of ip_address or domain_name must be provided, not both") if ip_address is None and domain_name is None: raise ValueError("Exactly one of ip_address or domain_name must be provided") - if ip_address is not None and (not isinstance(ip_address, bytes) or len(ip_address) not in (4, 16)): + if ip_address is not None: + RegisteredServiceEndpoint._validate_ip_address(ip_address) + else: + 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)") - if domain_name is not 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_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: diff --git a/src/hiero_sdk_python/nodes/registered_node_update_transaction.py b/src/hiero_sdk_python/nodes/registered_node_update_transaction.py index 0e2ba9364..bb05620d0 100644 --- a/src/hiero_sdk_python/nodes/registered_node_update_transaction.py +++ b/src/hiero_sdk_python/nodes/registered_node_update_transaction.py @@ -56,24 +56,10 @@ def add_service_endpoint(self, endpoint: RegisteredServiceEndpoint) -> Registere return self def _build_proto_body(self) -> RegisteredNodeUpdateTransactionBody: - 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") + self._validate_registered_node_id() if self.description is not None and len(self.description.encode("utf-8")) > 100: raise ValueError("description must be 100 UTF-8 bytes or fewer") - if self.service_endpoints is not None: - if len(self.service_endpoints) == 0: - raise ValueError("service_endpoints must have at least 1 entry when provided") - if len(self.service_endpoints) > 50: - raise ValueError("service_endpoints must have at most 50 entries") - for ep in self.service_endpoints: - if not isinstance(ep, RegisteredServiceEndpoint): - raise TypeError("service_endpoints must contain RegisteredServiceEndpoint instances") + self._validate_service_endpoints() body = RegisteredNodeUpdateTransactionBody( registered_node_id=self.registered_node_id, @@ -85,6 +71,27 @@ def _build_proto_body(self) -> RegisteredNodeUpdateTransactionBody: body.service_endpoint.append(ep._to_proto()) return body + def _validate_registered_node_id(self) -> None: + 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") + + def _validate_service_endpoints(self) -> None: + if self.service_endpoints is None: + return + if len(self.service_endpoints) == 0: + raise ValueError("service_endpoints must have at least 1 entry when provided") + if len(self.service_endpoints) > 50: + raise ValueError("service_endpoints must have at most 50 entries") + for ep in self.service_endpoints: + if not isinstance(ep, RegisteredServiceEndpoint): + raise TypeError("service_endpoints must contain RegisteredServiceEndpoint instances") + def build_transaction_body(self) -> TransactionBody: body = self._build_proto_body() transaction_body = self.build_base_transaction_body() From b15ebd923b0b14bad623bd40ab2086f2d91f6ed6 Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Thu, 7 May 2026 02:59:25 +0300 Subject: [PATCH 10/31] test: add HIP-1137 integration coverage and docs Signed-off-by: Ntege Daniel --- .../nodes/registered_node_create_transaction.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/hiero_sdk_python/nodes/registered_node_create_transaction.py b/src/hiero_sdk_python/nodes/registered_node_create_transaction.py index 31004621e..1847c6bf2 100644 --- a/src/hiero_sdk_python/nodes/registered_node_create_transaction.py +++ b/src/hiero_sdk_python/nodes/registered_node_create_transaction.py @@ -48,13 +48,7 @@ def add_service_endpoint(self, endpoint: RegisteredServiceEndpoint) -> Registere def _build_proto_body(self) -> RegisteredNodeCreateTransactionBody: if self.admin_key is None: raise ValueError("admin_key is required") - if not self.service_endpoints: - raise ValueError("service_endpoints must have at least 1 entry") - if len(self.service_endpoints) > 50: - raise ValueError("service_endpoints must have at most 50 entries") - for ep in self.service_endpoints: - if not isinstance(ep, RegisteredServiceEndpoint): - raise TypeError("service_endpoints must contain RegisteredServiceEndpoint instances") + self._validate_service_endpoints() if self.description is not None and len(self.description.encode("utf-8")) > 100: raise ValueError("description must be 100 UTF-8 bytes or fewer") @@ -64,6 +58,15 @@ def _build_proto_body(self) -> RegisteredNodeCreateTransactionBody: service_endpoint=[ep._to_proto() for ep in self.service_endpoints], ) + def _validate_service_endpoints(self) -> None: + if not self.service_endpoints: + raise ValueError("service_endpoints must have at least 1 entry") + if len(self.service_endpoints) > 50: + raise ValueError("service_endpoints must have at most 50 entries") + for ep in self.service_endpoints: + if not isinstance(ep, RegisteredServiceEndpoint): + raise TypeError("service_endpoints must contain RegisteredServiceEndpoint instances") + def build_transaction_body(self) -> TransactionBody: body = self._build_proto_body() transaction_body = self.build_base_transaction_body() From dca3d40bdde2d5db82923099e4f5fdffd5009276 Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Thu, 7 May 2026 03:19:00 +0300 Subject: [PATCH 11/31] test: add HIP-1137 integration coverage and docs Signed-off-by: Ntege Daniel --- examples/nodes/registered_node_lifecycle.py | 8 ++++---- src/hiero_sdk_python/address_book/registered_node.py | 2 +- .../address_book/registered_node_address_book.py | 2 +- .../registered_node_address_book_query.py | 5 ++--- .../address_book/registered_service_endpoint.py | 2 +- .../nodes/registered_node_create_transaction.py | 2 +- .../nodes/registered_node_delete_transaction.py | 2 +- .../nodes/registered_node_update_transaction.py | 2 +- src/hiero_sdk_python/response_code.py | 2 +- .../transaction/transaction_receipt.py | 2 +- .../associated_registered_nodes_e2e_test.py | 8 ++++---- .../registered_node_create_transaction_e2e_test.py | 2 +- .../registered_node_delete_transaction_e2e_test.py | 2 +- .../registered_node_update_transaction_e2e_test.py | 2 +- tests/unit/associated_registered_nodes_test.py | 2 +- ...ing_test.py => registered_node_hardening_test.py} | 12 ++++++------ tests/unit/registered_node_model_test.py | 2 +- ...d_test.py => registered_node_tck_aligned_test.py} | 4 ++-- tests/unit/registered_node_transaction_test.py | 2 +- 19 files changed, 32 insertions(+), 33 deletions(-) rename tests/unit/{hip1137_hardening_test.py => registered_node_hardening_test.py} (96%) rename tests/unit/{hip1137_tck_aligned_test.py => registered_node_tck_aligned_test.py} (98%) diff --git a/examples/nodes/registered_node_lifecycle.py b/examples/nodes/registered_node_lifecycle.py index 451a4a103..42ad29340 100644 --- a/examples/nodes/registered_node_lifecycle.py +++ b/examples/nodes/registered_node_lifecycle.py @@ -1,14 +1,14 @@ """ -Demonstrates the full lifecycle of a registered node on the Hedera network (HIP-1137). +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. Update the registered node's description and service endpoints 3. Delete the registered node -NOTE: This is a privileged transaction that requires HIP-1137 support on the network. -Regular developers do not have the required permissions to manage registered nodes -on testnet or mainnet as this operation requires special authorization. +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. diff --git a/src/hiero_sdk_python/address_book/registered_node.py b/src/hiero_sdk_python/address_book/registered_node.py index ff9424db9..db5a07d31 100644 --- a/src/hiero_sdk_python/address_book/registered_node.py +++ b/src/hiero_sdk_python/address_book/registered_node.py @@ -1,4 +1,4 @@ -"""Read-side model for a HIP-1137 registered node.""" +"""Read-side model for a registered node.""" from __future__ import annotations 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 index 707d300f5..331ed6e44 100644 --- a/src/hiero_sdk_python/address_book/registered_node_address_book.py +++ b/src/hiero_sdk_python/address_book/registered_node_address_book.py @@ -1,4 +1,4 @@ -"""Read-side container for a collection of HIP-1137 registered nodes.""" +"""Read-side container for a collection of registered nodes.""" from __future__ import annotations 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 index c3c01e070..610aa4702 100644 --- 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 @@ -1,4 +1,4 @@ -"""Skeleton query for fetching the registered-node address book (HIP-1137).""" +"""Skeleton query for fetching the registered-node address book.""" from __future__ import annotations @@ -40,6 +40,5 @@ def execute(self, client: object = None) -> RegisteredNodeAddressBook: registered-node queries is not yet available in this SDK. """ raise NotImplementedError( - "RegisteredNodeAddressBookQuery requires HIP-1137 mirror node API support, " - "which is not yet available in this SDK." + "RegisteredNodeAddressBookQuery requires mirror node API support, which is not yet available in this SDK." ) diff --git a/src/hiero_sdk_python/address_book/registered_service_endpoint.py b/src/hiero_sdk_python/address_book/registered_service_endpoint.py index 198fd4478..6ce1bb82a 100644 --- a/src/hiero_sdk_python/address_book/registered_service_endpoint.py +++ b/src/hiero_sdk_python/address_book/registered_service_endpoint.py @@ -6,7 +6,7 @@ class RegisteredServiceEndpoint: - """Base SDK model for a HIP-1137 registered service endpoint.""" + """Base SDK model for a registered service endpoint.""" def __init__( self, diff --git a/src/hiero_sdk_python/nodes/registered_node_create_transaction.py b/src/hiero_sdk_python/nodes/registered_node_create_transaction.py index 1847c6bf2..438b92e38 100644 --- a/src/hiero_sdk_python/nodes/registered_node_create_transaction.py +++ b/src/hiero_sdk_python/nodes/registered_node_create_transaction.py @@ -15,7 +15,7 @@ class RegisteredNodeCreateTransaction(Transaction): - """Creates a new registered node on the network (HIP-1137).""" + """Creates a new registered node on the network.""" def __init__(self): super().__init__() diff --git a/src/hiero_sdk_python/nodes/registered_node_delete_transaction.py b/src/hiero_sdk_python/nodes/registered_node_delete_transaction.py index 10fc93203..07f6c6ed0 100644 --- a/src/hiero_sdk_python/nodes/registered_node_delete_transaction.py +++ b/src/hiero_sdk_python/nodes/registered_node_delete_transaction.py @@ -13,7 +13,7 @@ class RegisteredNodeDeleteTransaction(Transaction): - """Deletes an existing registered node from the network (HIP-1137).""" + """Deletes an existing registered node from the network.""" def __init__(self, registered_node_id: int | None = None): super().__init__() diff --git a/src/hiero_sdk_python/nodes/registered_node_update_transaction.py b/src/hiero_sdk_python/nodes/registered_node_update_transaction.py index bb05620d0..1c5cd1c11 100644 --- a/src/hiero_sdk_python/nodes/registered_node_update_transaction.py +++ b/src/hiero_sdk_python/nodes/registered_node_update_transaction.py @@ -17,7 +17,7 @@ class RegisteredNodeUpdateTransaction(Transaction): - """Updates an existing registered node on the network (HIP-1137).""" + """Updates an existing registered node on the network.""" def __init__(self): super().__init__() diff --git a/src/hiero_sdk_python/response_code.py b/src/hiero_sdk_python/response_code.py index 16348e405..b5741eea8 100644 --- a/src/hiero_sdk_python/response_code.py +++ b/src/hiero_sdk_python/response_code.py @@ -398,7 +398,7 @@ class ResponseCode(IntEnum): TRANSFER_TO_FEE_COLLECTION_ACCOUNT_NOT_ALLOWED = 527 TOO_MANY_HOOK_INVOCATIONS = 528 - # HIP-1137: Registered node status codes + # Registered node status codes INVALID_REGISTERED_NODE_ID = 529 INVALID_REGISTERED_ENDPOINT = 530 REGISTERED_ENDPOINTS_EXCEEDED_LIMIT = 531 diff --git a/src/hiero_sdk_python/transaction/transaction_receipt.py b/src/hiero_sdk_python/transaction/transaction_receipt.py index 84ad52afc..0ca69582c 100644 --- a/src/hiero_sdk_python/transaction/transaction_receipt.py +++ b/src/hiero_sdk_python/transaction/transaction_receipt.py @@ -181,7 +181,7 @@ def node_id(self): @property def registered_node_id(self) -> int | None: """ - Returns the registered node ID associated with this receipt (HIP-1137). + Returns the registered node ID associated with this receipt. Returns: int or None: The registered node ID if present; otherwise, None. diff --git a/tests/integration/associated_registered_nodes_e2e_test.py b/tests/integration/associated_registered_nodes_e2e_test.py index 029cd2e79..6166bc493 100644 --- a/tests/integration/associated_registered_nodes_e2e_test.py +++ b/tests/integration/associated_registered_nodes_e2e_test.py @@ -1,5 +1,5 @@ """ -Integration tests for associated_registered_nodes on NodeCreate/NodeUpdate (HIP-1137). +Integration tests for associated_registered_nodes on NodeCreate/NodeUpdate. """ from __future__ import annotations @@ -89,7 +89,7 @@ def _create_registered_node(client, admin_key): return receipt.registered_node_id -@pytest.mark.skip(reason="HIP-1137 registered node support not yet available on local-node/solo") +@pytest.mark.skip(reason="Registered node support not yet available on local-node/solo") def test_node_create_with_associated_registered_nodes(): """Test NodeCreateTransaction with associated_registered_nodes.""" client = _setup_client() @@ -103,7 +103,7 @@ def test_node_create_with_associated_registered_nodes(): assert node_id is not None -@pytest.mark.skip(reason="HIP-1137 registered node support not yet available on local-node/solo") +@pytest.mark.skip(reason="Registered node support not yet available on local-node/solo") def test_node_update_set_associated_registered_nodes(): """Test NodeUpdateTransaction replacing associated_registered_nodes.""" client = _setup_client() @@ -128,7 +128,7 @@ def test_node_update_set_associated_registered_nodes(): assert receipt.status == ResponseCode.SUCCESS, f"Node update failed: {ResponseCode(receipt.status).name}" -@pytest.mark.skip(reason="HIP-1137 registered node support not yet available on local-node/solo") +@pytest.mark.skip(reason="Registered node support not yet available on local-node/solo") def test_node_update_clear_associated_registered_nodes(): """Test NodeUpdateTransaction clearing associated_registered_nodes with empty list.""" client = _setup_client() diff --git a/tests/integration/registered_node_create_transaction_e2e_test.py b/tests/integration/registered_node_create_transaction_e2e_test.py index b902c7106..39f02bca4 100644 --- a/tests/integration/registered_node_create_transaction_e2e_test.py +++ b/tests/integration/registered_node_create_transaction_e2e_test.py @@ -1,5 +1,5 @@ """ -Integration tests for RegisteredNodeCreateTransaction (HIP-1137). +Integration tests for RegisteredNodeCreateTransaction. """ from __future__ import annotations diff --git a/tests/integration/registered_node_delete_transaction_e2e_test.py b/tests/integration/registered_node_delete_transaction_e2e_test.py index 0a1ab65b0..fbdf1fb49 100644 --- a/tests/integration/registered_node_delete_transaction_e2e_test.py +++ b/tests/integration/registered_node_delete_transaction_e2e_test.py @@ -1,5 +1,5 @@ """ -Integration tests for RegisteredNodeDeleteTransaction (HIP-1137). +Integration tests for RegisteredNodeDeleteTransaction. """ from __future__ import annotations diff --git a/tests/integration/registered_node_update_transaction_e2e_test.py b/tests/integration/registered_node_update_transaction_e2e_test.py index 3c8be5e73..0631ebbc9 100644 --- a/tests/integration/registered_node_update_transaction_e2e_test.py +++ b/tests/integration/registered_node_update_transaction_e2e_test.py @@ -1,5 +1,5 @@ """ -Integration tests for RegisteredNodeUpdateTransaction (HIP-1137). +Integration tests for RegisteredNodeUpdateTransaction. """ from __future__ import annotations diff --git a/tests/unit/associated_registered_nodes_test.py b/tests/unit/associated_registered_nodes_test.py index eaac6ec08..113cc5fdc 100644 --- a/tests/unit/associated_registered_nodes_test.py +++ b/tests/unit/associated_registered_nodes_test.py @@ -1,4 +1,4 @@ -"""Tests for HIP-1137 associated registered nodes on NodeCreate/NodeUpdate transactions.""" +"""Tests for associated registered nodes on NodeCreate/NodeUpdate transactions.""" from __future__ import annotations diff --git a/tests/unit/hip1137_hardening_test.py b/tests/unit/registered_node_hardening_test.py similarity index 96% rename from tests/unit/hip1137_hardening_test.py rename to tests/unit/registered_node_hardening_test.py index 7be702a5f..425b3c116 100644 --- a/tests/unit/hip1137_hardening_test.py +++ b/tests/unit/registered_node_hardening_test.py @@ -1,4 +1,4 @@ -"""Phase 6 hardening tests — validation, status codes, retry, exports.""" +"""Hardening tests for registered node validation, status codes, retry, and exports.""" from __future__ import annotations @@ -33,12 +33,12 @@ def _make_key(): # --------------------------------------------------------------------------- -# HIP-1137 status codes in ResponseCode enum +# Registered node status codes in ResponseCode enum # --------------------------------------------------------------------------- -class TestHip1137StatusCodes: - """Verify all HIP-1137 status codes are present in the SDK ResponseCode enum.""" +class TestRegisteredNodeStatusCodes: + """Verify all registered node status codes are present in the SDK ResponseCode enum.""" @pytest.mark.parametrize( "name,value", @@ -63,7 +63,7 @@ def test_status_code_exists(self, name, value): [529, 530, 531, 532, 533, 534, 535], ) def test_status_codes_are_not_retryable(self, value): - """HIP-1137 statuses must NOT be in the retryable set.""" + """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, @@ -264,7 +264,7 @@ def test_node_update_rejects_float_id(self): class TestPublicImports: - """All intended HIP-1137 public classes must be importable from the package.""" + """All registered node public classes must be importable from the package.""" @pytest.mark.parametrize( "name", diff --git a/tests/unit/registered_node_model_test.py b/tests/unit/registered_node_model_test.py index 0b19cac0e..c085f9f88 100644 --- a/tests/unit/registered_node_model_test.py +++ b/tests/unit/registered_node_model_test.py @@ -245,7 +245,7 @@ def test_importable(self): def test_execute_raises_not_implemented(self): q = RegisteredNodeAddressBookQuery() - with pytest.raises(NotImplementedError, match="HIP-1137 mirror node API support"): + with pytest.raises(NotImplementedError, match="mirror node API support"): q.execute() def test_error_message_is_clear(self): diff --git a/tests/unit/hip1137_tck_aligned_test.py b/tests/unit/registered_node_tck_aligned_test.py similarity index 98% rename from tests/unit/hip1137_tck_aligned_test.py rename to tests/unit/registered_node_tck_aligned_test.py index d2bc0a6c3..2f6145cf6 100644 --- a/tests/unit/hip1137_tck_aligned_test.py +++ b/tests/unit/registered_node_tck_aligned_test.py @@ -1,5 +1,5 @@ """ -TCK-aligned unit tests for HIP-1137 registered node transactions. +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 @@ -7,7 +7,7 @@ TODO: Add TCK JSON-RPC handlers (tck/handlers/registered_node.py) and corresponding param/response dataclasses once the TCK specification - for HIP-1137 is finalized. + for registered nodes is finalized. """ from __future__ import annotations diff --git a/tests/unit/registered_node_transaction_test.py b/tests/unit/registered_node_transaction_test.py index 806fe31e7..fee78af29 100644 --- a/tests/unit/registered_node_transaction_test.py +++ b/tests/unit/registered_node_transaction_test.py @@ -1,4 +1,4 @@ -"""Tests for HIP-1137 registered node transactions.""" +"""Tests for registered node transactions.""" from __future__ import annotations From 54c5f31e1264b033d1a06a16b1c5e67ee58ffd16 Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Thu, 7 May 2026 03:51:51 +0300 Subject: [PATCH 12/31] test: add HIP-1137 integration coverage and docs Signed-off-by: Ntege Daniel --- ...stered_node_create_transaction_e2e_test.py | 47 ++++++++++++----- ...stered_node_delete_transaction_e2e_test.py | 35 ++++++++++--- ...stered_node_update_transaction_e2e_test.py | 51 +++++++++++++------ 3 files changed, 98 insertions(+), 35 deletions(-) diff --git a/tests/integration/registered_node_create_transaction_e2e_test.py b/tests/integration/registered_node_create_transaction_e2e_test.py index 39f02bca4..03e9f5b7f 100644 --- a/tests/integration/registered_node_create_transaction_e2e_test.py +++ b/tests/integration/registered_node_create_transaction_e2e_test.py @@ -6,16 +6,37 @@ 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.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 -def test_registered_node_create_with_block_endpoint(env): +# 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() @@ -31,9 +52,9 @@ def test_registered_node_create_with_block_endpoint(env): .set_admin_key(admin_key.public_key()) .set_description("test registered node") .set_service_endpoints([block_endpoint]) - .freeze_with(env.client) + .freeze_with(admin_client) .sign(admin_key) - .execute(env.client) + .execute(admin_client) ) assert receipt.status == ResponseCode.SUCCESS, ( @@ -43,12 +64,12 @@ def test_registered_node_create_with_block_endpoint(env): 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(env.client).sign( + RegisteredNodeDeleteTransaction().set_registered_node_id(receipt.registered_node_id).freeze_with(admin_client).sign( admin_key - ).execute(env.client) + ).execute(admin_client) -def test_registered_node_create_with_mixed_endpoints(env): +def test_registered_node_create_with_mixed_endpoints(admin_client): """Test creating a registered node with multiple endpoint types.""" admin_key = PrivateKey.generate_ed25519() @@ -69,9 +90,9 @@ def test_registered_node_create_with_mixed_endpoints(env): .set_admin_key(admin_key.public_key()) .set_description("mixed endpoints node") .set_service_endpoints([block_endpoint, mirror_endpoint]) - .freeze_with(env.client) + .freeze_with(admin_client) .sign(admin_key) - .execute(env.client) + .execute(admin_client) ) assert receipt.status == ResponseCode.SUCCESS, ( @@ -80,12 +101,12 @@ def test_registered_node_create_with_mixed_endpoints(env): assert receipt.registered_node_id is not None # Cleanup - RegisteredNodeDeleteTransaction().set_registered_node_id(receipt.registered_node_id).freeze_with(env.client).sign( + RegisteredNodeDeleteTransaction().set_registered_node_id(receipt.registered_node_id).freeze_with(admin_client).sign( admin_key - ).execute(env.client) + ).execute(admin_client) -def test_registered_node_create_fails_without_endpoints(env): +def test_registered_node_create_fails_without_endpoints(admin_client): """Test that creating a registered node with no endpoints fails (client-side validation).""" admin_key = PrivateKey.generate_ed25519() @@ -94,7 +115,7 @@ def test_registered_node_create_fails_without_endpoints(env): RegisteredNodeCreateTransaction() .set_admin_key(admin_key.public_key()) .set_description("no endpoints") - .freeze_with(env.client) + .freeze_with(admin_client) .sign(admin_key) - .execute(env.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 index fbdf1fb49..d7a5d92e5 100644 --- a/tests/integration/registered_node_delete_transaction_e2e_test.py +++ b/tests/integration/registered_node_delete_transaction_e2e_test.py @@ -4,14 +4,35 @@ 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( @@ -33,17 +54,17 @@ def _create_registered_node(client, admin_key): return receipt.registered_node_id -def test_registered_node_delete(env): +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(env.client, admin_key) + registered_node_id = _create_registered_node(admin_client, admin_key) receipt = ( RegisteredNodeDeleteTransaction() .set_registered_node_id(registered_node_id) - .freeze_with(env.client) + .freeze_with(admin_client) .sign(admin_key) - .execute(env.client) + .execute(admin_client) ) assert receipt.status == ResponseCode.SUCCESS, ( @@ -51,16 +72,16 @@ def test_registered_node_delete(env): ) -def test_registered_node_delete_invalid_id(env): +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(env.client) + .freeze_with(admin_client) .sign(admin_key) - .execute(env.client) + .execute(admin_client) ) assert receipt.status != ResponseCode.SUCCESS, "Delete of nonexistent node should fail" diff --git a/tests/integration/registered_node_update_transaction_e2e_test.py b/tests/integration/registered_node_update_transaction_e2e_test.py index 0631ebbc9..77b47e557 100644 --- a/tests/integration/registered_node_update_transaction_e2e_test.py +++ b/tests/integration/registered_node_update_transaction_e2e_test.py @@ -4,9 +4,14 @@ 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.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 @@ -14,6 +19,22 @@ 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( @@ -35,19 +56,19 @@ def _create_registered_node(client, admin_key): return receipt.registered_node_id -def test_registered_node_update_description(env): +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(env.client, admin_key) + 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(env.client) + .freeze_with(admin_client) .sign(admin_key) - .execute(env.client) + .execute(admin_client) ) assert receipt.status == ResponseCode.SUCCESS, ( @@ -55,15 +76,15 @@ def test_registered_node_update_description(env): ) finally: # Cleanup - RegisteredNodeDeleteTransaction().set_registered_node_id(registered_node_id).freeze_with(env.client).sign( + RegisteredNodeDeleteTransaction().set_registered_node_id(registered_node_id).freeze_with(admin_client).sign( admin_key - ).execute(env.client) + ).execute(admin_client) -def test_registered_node_update_service_endpoints(env): +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(env.client, admin_key) + registered_node_id = _create_registered_node(admin_client, admin_key) try: new_endpoint = MirrorNodeServiceEndpoint( @@ -76,9 +97,9 @@ def test_registered_node_update_service_endpoints(env): RegisteredNodeUpdateTransaction() .set_registered_node_id(registered_node_id) .set_service_endpoints([new_endpoint]) - .freeze_with(env.client) + .freeze_with(admin_client) .sign(admin_key) - .execute(env.client) + .execute(admin_client) ) assert receipt.status == ResponseCode.SUCCESS, ( @@ -86,12 +107,12 @@ def test_registered_node_update_service_endpoints(env): ) finally: # Cleanup - RegisteredNodeDeleteTransaction().set_registered_node_id(registered_node_id).freeze_with(env.client).sign( + RegisteredNodeDeleteTransaction().set_registered_node_id(registered_node_id).freeze_with(admin_client).sign( admin_key - ).execute(env.client) + ).execute(admin_client) -def test_registered_node_update_invalid_id(env): +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() @@ -100,9 +121,9 @@ def test_registered_node_update_invalid_id(env): RegisteredNodeUpdateTransaction() .set_registered_node_id(999999999) .set_description("should fail") - .freeze_with(env.client) + .freeze_with(admin_client) .sign(admin_key) - .execute(env.client) + .execute(admin_client) ) assert receipt.status != ResponseCode.SUCCESS, "Update of nonexistent node should fail" From 1e862432ab5c3945cb24f62a45e92d3f9319ae02 Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Thu, 7 May 2026 04:40:25 +0300 Subject: [PATCH 13/31] test: add HIP-1137 integration coverage and docs Signed-off-by: Ntege Daniel --- examples/nodes/registered_node_lifecycle.py | 62 ++++++++++------- .../block_node_service_endpoint.py | 5 ++ src/hiero_sdk_python/nodes/_validation.py | 19 ++++++ .../nodes/node_create_transaction.py | 11 +-- .../nodes/node_update_transaction.py | 11 +-- .../unit/registered_node_transaction_test.py | 67 +++++++++++++++++++ 6 files changed, 132 insertions(+), 43 deletions(-) create mode 100644 src/hiero_sdk_python/nodes/_validation.py diff --git a/examples/nodes/registered_node_lifecycle.py b/examples/nodes/registered_node_lifecycle.py index 42ad29340..f92f8d565 100644 --- a/examples/nodes/registered_node_lifecycle.py +++ b/examples/nodes/registered_node_lifecycle.py @@ -64,15 +64,19 @@ def registered_node_lifecycle(): endpoint_apis=[BlockNodeApi.STATUS, BlockNodeApi.SUBSCRIBE_STREAM], ) - receipt = ( - RegisteredNodeCreateTransaction() - .set_admin_key(admin_key.public_key()) - .set_description("Example block node") - .set_service_endpoints([block_endpoint]) - .freeze_with(client) - .sign(admin_key) - .execute(client) - ) + try: + receipt = ( + RegisteredNodeCreateTransaction() + .set_admin_key(admin_key.public_key()) + .set_description("Example block node") + .set_service_endpoints([block_endpoint]) + .freeze_with(client) + .sign(admin_key) + .execute(client) + ) + except Exception 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}") @@ -90,15 +94,19 @@ def registered_node_lifecycle(): requires_tls=True, ) - receipt = ( - RegisteredNodeUpdateTransaction() - .set_registered_node_id(registered_node_id) - .set_description("Updated block + mirror node") - .set_service_endpoints([block_endpoint, mirror_endpoint]) - .freeze_with(client) - .sign(admin_key) - .execute(client) - ) + try: + receipt = ( + RegisteredNodeUpdateTransaction() + .set_registered_node_id(registered_node_id) + .set_description("Updated block + mirror node") + .set_service_endpoints([block_endpoint, mirror_endpoint]) + .freeze_with(client) + .sign(admin_key) + .execute(client) + ) + except Exception 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}") @@ -109,13 +117,17 @@ def registered_node_lifecycle(): # ── Step 3: Delete the registered node ────────────────────────── print("\n--- Deleting registered node ---") - receipt = ( - RegisteredNodeDeleteTransaction() - .set_registered_node_id(registered_node_id) - .freeze_with(client) - .sign(admin_key) - .execute(client) - ) + try: + receipt = ( + RegisteredNodeDeleteTransaction() + .set_registered_node_id(registered_node_id) + .freeze_with(client) + .sign(admin_key) + .execute(client) + ) + except Exception 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}") 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 index 31f36bcef..8c5ac25db 100644 --- a/src/hiero_sdk_python/address_book/block_node_service_endpoint.py +++ b/src/hiero_sdk_python/address_book/block_node_service_endpoint.py @@ -21,6 +21,9 @@ def __init__( super().__init__(ip_address=ip_address, domain_name=domain_name, port=port, requires_tls=requires_tls) if endpoint_apis is None or len(endpoint_apis) == 0: raise ValueError("endpoint_apis must be non-empty") + for api in endpoint_apis: + if isinstance(api, bool): + raise TypeError("endpoint_apis values must be BlockNodeApi, not bool") self.endpoint_apis: list[BlockNodeApi] = [BlockNodeApi(api) for api in endpoint_apis] def _set_endpoint_type(self, proto: RegisteredServiceEndpointProto) -> None: @@ -38,6 +41,8 @@ def _from_proto_inner( requires_tls: bool, ) -> BlockNodeServiceEndpoint: apis = [BlockNodeApi(v) for v in proto.block_node.endpoint_api] + if not apis: + apis = [BlockNodeApi.OTHER] return cls( ip_address=ip_address, domain_name=domain_name, diff --git a/src/hiero_sdk_python/nodes/_validation.py b/src/hiero_sdk_python/nodes/_validation.py new file mode 100644 index 000000000..0a546eb0e --- /dev/null +++ b/src/hiero_sdk_python/nodes/_validation.py @@ -0,0 +1,19 @@ +"""Shared validation utilities for node transactions.""" + +from __future__ import annotations + + +def validate_associated_registered_nodes(ids: list[int]) -> None: + """Validate a list of associated registered node IDs. + + Args: + ids: List of registered node IDs to validate. + + Raises: + ValueError: If any ID is invalid or the list exceeds 20 entries. + """ + if len(ids) > 20: + raise ValueError("associated_registered_nodes must have at most 20 entries") + for node_id in ids: + if not isinstance(node_id, int) or isinstance(node_id, bool) or node_id <= 0: + raise ValueError("Each associated registered node ID must be a positive integer") diff --git a/src/hiero_sdk_python/nodes/node_create_transaction.py b/src/hiero_sdk_python/nodes/node_create_transaction.py index 3f5a144b6..0e72eccac 100644 --- a/src/hiero_sdk_python/nodes/node_create_transaction.py +++ b/src/hiero_sdk_python/nodes/node_create_transaction.py @@ -14,6 +14,7 @@ SchedulableTransactionBody, ) from hiero_sdk_python.hapi.services.transaction_pb2 import TransactionBody +from hiero_sdk_python.nodes._validation import validate_associated_registered_nodes from hiero_sdk_python.transaction.transaction import Transaction @@ -240,14 +241,6 @@ def add_associated_registered_node(self, registered_node_id: int) -> NodeCreateT self.associated_registered_nodes.append(registered_node_id) return self - @staticmethod - def _validate_associated_registered_nodes(ids: list[int]) -> None: - if len(ids) > 20: - raise ValueError("associated_registered_nodes must have at most 20 entries") - for node_id in ids: - if not isinstance(node_id, int) or isinstance(node_id, bool) or node_id <= 0: - raise ValueError("Each associated registered node ID must be a positive integer") - def _build_proto_body(self) -> NodeCreateTransactionBody: """ Returns the protobuf body for the node create transaction. @@ -255,7 +248,7 @@ def _build_proto_body(self) -> NodeCreateTransactionBody: Returns: NodeCreateTransactionBody: The protobuf body for this transaction. """ - self._validate_associated_registered_nodes(self.associated_registered_nodes) + validate_associated_registered_nodes(self.associated_registered_nodes) return NodeCreateTransactionBody( account_id=self.account_id._to_proto() if self.account_id else None, diff --git a/src/hiero_sdk_python/nodes/node_update_transaction.py b/src/hiero_sdk_python/nodes/node_update_transaction.py index 01b073efc..718b89bfe 100644 --- a/src/hiero_sdk_python/nodes/node_update_transaction.py +++ b/src/hiero_sdk_python/nodes/node_update_transaction.py @@ -20,6 +20,7 @@ SchedulableTransactionBody, ) from hiero_sdk_python.hapi.services.transaction_pb2 import TransactionBody +from hiero_sdk_python.nodes._validation import validate_associated_registered_nodes from hiero_sdk_python.transaction.transaction import Transaction @@ -278,14 +279,6 @@ def clear_associated_registered_nodes(self) -> NodeUpdateTransaction: self.associated_registered_nodes = [] return self - @staticmethod - def _validate_associated_registered_nodes(ids: list[int]) -> None: - if len(ids) > 20: - raise ValueError("associated_registered_nodes must have at most 20 entries") - for node_id in ids: - if not isinstance(node_id, int) or isinstance(node_id, bool) or node_id <= 0: - raise ValueError("Each associated registered node ID must be a positive integer") - 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 @@ -302,7 +295,7 @@ def _build_proto_body(self) -> NodeUpdateTransactionBody: NodeUpdateTransactionBody: The protobuf body for this transaction. """ if self.associated_registered_nodes is not None: - self._validate_associated_registered_nodes(self.associated_registered_nodes) + validate_associated_registered_nodes(self.associated_registered_nodes) body = NodeUpdateTransactionBody( node_id=self.node_id, diff --git a/tests/unit/registered_node_transaction_test.py b/tests/unit/registered_node_transaction_test.py index fee78af29..695b49483 100644 --- a/tests/unit/registered_node_transaction_test.py +++ b/tests/unit/registered_node_transaction_test.py @@ -319,6 +319,73 @@ def test_fails_when_registered_node_id_missing(self, mock_account_ids): tx.build_transaction_body() +# --------------------------------------------------------------------------- +# Additional coverage: fluent setters, edge cases, bool rejection +# --------------------------------------------------------------------------- + + +class TestFluentSetterReturnSelf: + """Verify all fluent setters return the transaction instance.""" + + def test_create_setters_return_self(self): + tx = RegisteredNodeCreateTransaction() + key = PrivateKey.generate_ed25519().public_key() + assert tx.set_admin_key(key) is tx + assert tx.set_description("desc") is tx + assert tx.set_service_endpoints([_make_block_endpoint()]) is tx + assert tx.add_service_endpoint(_make_mirror_endpoint()) is tx + + def test_update_setters_return_self(self): + tx = RegisteredNodeUpdateTransaction() + key = PrivateKey.generate_ed25519().public_key() + assert tx.set_registered_node_id(1) is tx + assert tx.set_admin_key(key) is tx + assert tx.set_description("desc") is tx + assert tx.set_service_endpoints([_make_block_endpoint()]) is tx + assert tx.add_service_endpoint(_make_mirror_endpoint()) is tx + + def test_delete_setters_return_self(self): + tx = RegisteredNodeDeleteTransaction() + assert tx.set_registered_node_id(1) is tx + + +class TestCreateMissingAdminKey: + def test_fails_without_admin_key(self, mock_account_ids): + operator_id, _, node_account_id, _, _ = mock_account_ids + tx = RegisteredNodeCreateTransaction() + tx.set_service_endpoints([_make_block_endpoint()]) + tx.operator_account_id = operator_id + tx.node_account_id = node_account_id + + with pytest.raises(ValueError, match="admin_key"): + tx.build_transaction_body() + + +class TestUpdateNoServiceEndpointsRoundTrip: + def test_round_trip_without_service_endpoints(self): + tx = RegisteredNodeUpdateTransaction() + tx.set_registered_node_id(5) + tx.set_description("no endpoints") + + _freeze(tx) + restored = Transaction.from_bytes(tx.to_bytes()) + + assert isinstance(restored, RegisteredNodeUpdateTransaction) + assert restored.registered_node_id == 5 + assert restored.description == "no endpoints" + assert restored.service_endpoints is None + + +class TestBlockEndpointBoolRejection: + def test_rejects_bool_in_endpoint_apis(self): + with pytest.raises(TypeError, match="not bool"): + BlockNodeServiceEndpoint( + ip_address=b"\x7f\x00\x00\x01", + port=8080, + endpoint_apis=[True], + ) + + # --------------------------------------------------------------------------- # TransactionReceipt.registered_node_id # --------------------------------------------------------------------------- From 3b6ad9adc1acc3dbc686fd5eced7bb239d9a591d Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Thu, 7 May 2026 04:44:45 +0300 Subject: [PATCH 14/31] test: add HIP-1137 integration coverage and docs Signed-off-by: Ntege Daniel --- examples/nodes/registered_node_lifecycle.py | 62 +++++++---------- .../block_node_service_endpoint.py | 5 -- src/hiero_sdk_python/nodes/_validation.py | 19 ------ .../nodes/node_create_transaction.py | 11 ++- .../nodes/node_update_transaction.py | 11 ++- .../unit/registered_node_transaction_test.py | 67 ------------------- 6 files changed, 43 insertions(+), 132 deletions(-) delete mode 100644 src/hiero_sdk_python/nodes/_validation.py diff --git a/examples/nodes/registered_node_lifecycle.py b/examples/nodes/registered_node_lifecycle.py index f92f8d565..42ad29340 100644 --- a/examples/nodes/registered_node_lifecycle.py +++ b/examples/nodes/registered_node_lifecycle.py @@ -64,19 +64,15 @@ def registered_node_lifecycle(): endpoint_apis=[BlockNodeApi.STATUS, BlockNodeApi.SUBSCRIBE_STREAM], ) - try: - receipt = ( - RegisteredNodeCreateTransaction() - .set_admin_key(admin_key.public_key()) - .set_description("Example block node") - .set_service_endpoints([block_endpoint]) - .freeze_with(client) - .sign(admin_key) - .execute(client) - ) - except Exception as e: - print(f"Registered node creation failed: {e}") - sys.exit(1) + receipt = ( + RegisteredNodeCreateTransaction() + .set_admin_key(admin_key.public_key()) + .set_description("Example block node") + .set_service_endpoints([block_endpoint]) + .freeze_with(client) + .sign(admin_key) + .execute(client) + ) if receipt.status != ResponseCode.SUCCESS: print(f"Registered node creation failed: {ResponseCode(receipt.status).name}") @@ -94,19 +90,15 @@ def registered_node_lifecycle(): requires_tls=True, ) - try: - receipt = ( - RegisteredNodeUpdateTransaction() - .set_registered_node_id(registered_node_id) - .set_description("Updated block + mirror node") - .set_service_endpoints([block_endpoint, mirror_endpoint]) - .freeze_with(client) - .sign(admin_key) - .execute(client) - ) - except Exception as e: - print(f"Registered node update failed: {e}") - sys.exit(1) + receipt = ( + RegisteredNodeUpdateTransaction() + .set_registered_node_id(registered_node_id) + .set_description("Updated block + mirror node") + .set_service_endpoints([block_endpoint, mirror_endpoint]) + .freeze_with(client) + .sign(admin_key) + .execute(client) + ) if receipt.status != ResponseCode.SUCCESS: print(f"Registered node update failed: {ResponseCode(receipt.status).name}") @@ -117,17 +109,13 @@ def registered_node_lifecycle(): # ── Step 3: Delete the registered node ────────────────────────── print("\n--- Deleting registered node ---") - try: - receipt = ( - RegisteredNodeDeleteTransaction() - .set_registered_node_id(registered_node_id) - .freeze_with(client) - .sign(admin_key) - .execute(client) - ) - except Exception as e: - print(f"Registered node deletion failed: {e}") - sys.exit(1) + receipt = ( + RegisteredNodeDeleteTransaction() + .set_registered_node_id(registered_node_id) + .freeze_with(client) + .sign(admin_key) + .execute(client) + ) if receipt.status != ResponseCode.SUCCESS: print(f"Registered node deletion failed: {ResponseCode(receipt.status).name}") 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 index 8c5ac25db..31f36bcef 100644 --- a/src/hiero_sdk_python/address_book/block_node_service_endpoint.py +++ b/src/hiero_sdk_python/address_book/block_node_service_endpoint.py @@ -21,9 +21,6 @@ def __init__( super().__init__(ip_address=ip_address, domain_name=domain_name, port=port, requires_tls=requires_tls) if endpoint_apis is None or len(endpoint_apis) == 0: raise ValueError("endpoint_apis must be non-empty") - for api in endpoint_apis: - if isinstance(api, bool): - raise TypeError("endpoint_apis values must be BlockNodeApi, not bool") self.endpoint_apis: list[BlockNodeApi] = [BlockNodeApi(api) for api in endpoint_apis] def _set_endpoint_type(self, proto: RegisteredServiceEndpointProto) -> None: @@ -41,8 +38,6 @@ def _from_proto_inner( requires_tls: bool, ) -> BlockNodeServiceEndpoint: apis = [BlockNodeApi(v) for v in proto.block_node.endpoint_api] - if not apis: - apis = [BlockNodeApi.OTHER] return cls( ip_address=ip_address, domain_name=domain_name, diff --git a/src/hiero_sdk_python/nodes/_validation.py b/src/hiero_sdk_python/nodes/_validation.py deleted file mode 100644 index 0a546eb0e..000000000 --- a/src/hiero_sdk_python/nodes/_validation.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Shared validation utilities for node transactions.""" - -from __future__ import annotations - - -def validate_associated_registered_nodes(ids: list[int]) -> None: - """Validate a list of associated registered node IDs. - - Args: - ids: List of registered node IDs to validate. - - Raises: - ValueError: If any ID is invalid or the list exceeds 20 entries. - """ - if len(ids) > 20: - raise ValueError("associated_registered_nodes must have at most 20 entries") - for node_id in ids: - if not isinstance(node_id, int) or isinstance(node_id, bool) or node_id <= 0: - raise ValueError("Each associated registered node ID must be a positive integer") diff --git a/src/hiero_sdk_python/nodes/node_create_transaction.py b/src/hiero_sdk_python/nodes/node_create_transaction.py index 0e72eccac..3f5a144b6 100644 --- a/src/hiero_sdk_python/nodes/node_create_transaction.py +++ b/src/hiero_sdk_python/nodes/node_create_transaction.py @@ -14,7 +14,6 @@ SchedulableTransactionBody, ) from hiero_sdk_python.hapi.services.transaction_pb2 import TransactionBody -from hiero_sdk_python.nodes._validation import validate_associated_registered_nodes from hiero_sdk_python.transaction.transaction import Transaction @@ -241,6 +240,14 @@ def add_associated_registered_node(self, registered_node_id: int) -> NodeCreateT self.associated_registered_nodes.append(registered_node_id) return self + @staticmethod + def _validate_associated_registered_nodes(ids: list[int]) -> None: + if len(ids) > 20: + raise ValueError("associated_registered_nodes must have at most 20 entries") + for node_id in ids: + if not isinstance(node_id, int) or isinstance(node_id, bool) or node_id <= 0: + raise ValueError("Each associated registered node ID must be a positive integer") + def _build_proto_body(self) -> NodeCreateTransactionBody: """ Returns the protobuf body for the node create transaction. @@ -248,7 +255,7 @@ def _build_proto_body(self) -> NodeCreateTransactionBody: Returns: NodeCreateTransactionBody: The protobuf body for this transaction. """ - validate_associated_registered_nodes(self.associated_registered_nodes) + self._validate_associated_registered_nodes(self.associated_registered_nodes) return NodeCreateTransactionBody( account_id=self.account_id._to_proto() if self.account_id else None, diff --git a/src/hiero_sdk_python/nodes/node_update_transaction.py b/src/hiero_sdk_python/nodes/node_update_transaction.py index 718b89bfe..01b073efc 100644 --- a/src/hiero_sdk_python/nodes/node_update_transaction.py +++ b/src/hiero_sdk_python/nodes/node_update_transaction.py @@ -20,7 +20,6 @@ SchedulableTransactionBody, ) from hiero_sdk_python.hapi.services.transaction_pb2 import TransactionBody -from hiero_sdk_python.nodes._validation import validate_associated_registered_nodes from hiero_sdk_python.transaction.transaction import Transaction @@ -279,6 +278,14 @@ def clear_associated_registered_nodes(self) -> NodeUpdateTransaction: self.associated_registered_nodes = [] return self + @staticmethod + def _validate_associated_registered_nodes(ids: list[int]) -> None: + if len(ids) > 20: + raise ValueError("associated_registered_nodes must have at most 20 entries") + for node_id in ids: + if not isinstance(node_id, int) or isinstance(node_id, bool) or node_id <= 0: + raise ValueError("Each associated registered node ID must be a positive integer") + 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 @@ -295,7 +302,7 @@ def _build_proto_body(self) -> NodeUpdateTransactionBody: NodeUpdateTransactionBody: The protobuf body for this transaction. """ if self.associated_registered_nodes is not None: - validate_associated_registered_nodes(self.associated_registered_nodes) + self._validate_associated_registered_nodes(self.associated_registered_nodes) body = NodeUpdateTransactionBody( node_id=self.node_id, diff --git a/tests/unit/registered_node_transaction_test.py b/tests/unit/registered_node_transaction_test.py index 695b49483..fee78af29 100644 --- a/tests/unit/registered_node_transaction_test.py +++ b/tests/unit/registered_node_transaction_test.py @@ -319,73 +319,6 @@ def test_fails_when_registered_node_id_missing(self, mock_account_ids): tx.build_transaction_body() -# --------------------------------------------------------------------------- -# Additional coverage: fluent setters, edge cases, bool rejection -# --------------------------------------------------------------------------- - - -class TestFluentSetterReturnSelf: - """Verify all fluent setters return the transaction instance.""" - - def test_create_setters_return_self(self): - tx = RegisteredNodeCreateTransaction() - key = PrivateKey.generate_ed25519().public_key() - assert tx.set_admin_key(key) is tx - assert tx.set_description("desc") is tx - assert tx.set_service_endpoints([_make_block_endpoint()]) is tx - assert tx.add_service_endpoint(_make_mirror_endpoint()) is tx - - def test_update_setters_return_self(self): - tx = RegisteredNodeUpdateTransaction() - key = PrivateKey.generate_ed25519().public_key() - assert tx.set_registered_node_id(1) is tx - assert tx.set_admin_key(key) is tx - assert tx.set_description("desc") is tx - assert tx.set_service_endpoints([_make_block_endpoint()]) is tx - assert tx.add_service_endpoint(_make_mirror_endpoint()) is tx - - def test_delete_setters_return_self(self): - tx = RegisteredNodeDeleteTransaction() - assert tx.set_registered_node_id(1) is tx - - -class TestCreateMissingAdminKey: - def test_fails_without_admin_key(self, mock_account_ids): - operator_id, _, node_account_id, _, _ = mock_account_ids - tx = RegisteredNodeCreateTransaction() - tx.set_service_endpoints([_make_block_endpoint()]) - tx.operator_account_id = operator_id - tx.node_account_id = node_account_id - - with pytest.raises(ValueError, match="admin_key"): - tx.build_transaction_body() - - -class TestUpdateNoServiceEndpointsRoundTrip: - def test_round_trip_without_service_endpoints(self): - tx = RegisteredNodeUpdateTransaction() - tx.set_registered_node_id(5) - tx.set_description("no endpoints") - - _freeze(tx) - restored = Transaction.from_bytes(tx.to_bytes()) - - assert isinstance(restored, RegisteredNodeUpdateTransaction) - assert restored.registered_node_id == 5 - assert restored.description == "no endpoints" - assert restored.service_endpoints is None - - -class TestBlockEndpointBoolRejection: - def test_rejects_bool_in_endpoint_apis(self): - with pytest.raises(TypeError, match="not bool"): - BlockNodeServiceEndpoint( - ip_address=b"\x7f\x00\x00\x01", - port=8080, - endpoint_apis=[True], - ) - - # --------------------------------------------------------------------------- # TransactionReceipt.registered_node_id # --------------------------------------------------------------------------- From 73405445259291f208c5178b5e66f60ac4daddcb Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Mon, 11 May 2026 15:44:16 +0300 Subject: [PATCH 15/31] feat: refactor registered node transactions and validation logic for improved clarity and performance Signed-off-by: Ntege Daniel --- src/hiero_sdk_python/__init__.py | 18 ++--- .../mirror_node_service_endpoint.py | 16 +++++ .../address_book/registered_node.py | 57 ++++++---------- .../registered_node_address_book.py | 18 ++--- .../registered_service_endpoint.py | 10 +-- .../rpc_relay_service_endpoint.py | 16 +++++ .../nodes/node_create_transaction.py | 10 --- .../nodes/node_update_transaction.py | 11 ---- .../registered_node_create_transaction.py | 15 ----- .../registered_node_update_transaction.py | 66 +++++++++++-------- ...stered_node_create_transaction_e2e_test.py | 21 +++--- .../unit/associated_registered_nodes_test.py | 57 ---------------- tests/unit/registered_node_hardening_test.py | 62 +++++++---------- .../unit/registered_node_tck_aligned_test.py | 28 ++++---- .../unit/registered_node_transaction_test.py | 48 +++++++------- 15 files changed, 187 insertions(+), 266 deletions(-) diff --git a/src/hiero_sdk_python/__init__.py b/src/hiero_sdk_python/__init__.py index 286f57e43..44ce92ff1 100644 --- a/src/hiero_sdk_python/__init__.py +++ b/src/hiero_sdk_python/__init__.py @@ -9,17 +9,17 @@ from .account.account_update_transaction import AccountUpdateTransaction # Address book -from .address_book.block_node_api import BlockNodeApi # noqa: F401 -from .address_book.block_node_service_endpoint import BlockNodeServiceEndpoint # noqa: F401 +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 # noqa: F401 -from .address_book.mirror_node_service_endpoint import MirrorNodeServiceEndpoint # noqa: F401 +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 # noqa: F401 -from .address_book.rpc_relay_service_endpoint import RpcRelayServiceEndpoint # noqa: F401 +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 @@ -79,9 +79,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 # noqa: F401 -from .nodes.registered_node_delete_transaction import RegisteredNodeDeleteTransaction # noqa: F401 -from .nodes.registered_node_update_transaction import RegisteredNodeUpdateTransaction # noqa: F401 +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 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 index 51e741e14..4783ab12e 100644 --- a/src/hiero_sdk_python/address_book/mirror_node_service_endpoint.py +++ b/src/hiero_sdk_python/address_book/mirror_node_service_endpoint.py @@ -12,3 +12,19 @@ class MirrorNodeServiceEndpoint(RegisteredServiceEndpoint): 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, + ) diff --git a/src/hiero_sdk_python/address_book/registered_node.py b/src/hiero_sdk_python/address_book/registered_node.py index db5a07d31..9b868d545 100644 --- a/src/hiero_sdk_python/address_book/registered_node.py +++ b/src/hiero_sdk_python/address_book/registered_node.py @@ -2,6 +2,8 @@ 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 ( @@ -9,41 +11,22 @@ ) +@dataclass(frozen=True) class RegisteredNode: """Immutable model representing a registered node from network/mirror state.""" - def __init__( - self, - registered_node_id: int, - admin_key: PublicKey | None = None, - description: str | None = None, - service_endpoints: tuple[RegisteredServiceEndpoint, ...] = (), - ) -> None: - if not isinstance(registered_node_id, int) or isinstance(registered_node_id, bool): + 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 registered_node_id <= 0: + if self.registered_node_id <= 0: raise ValueError("registered_node_id must be a positive integer") - - self._registered_node_id = registered_node_id - self._admin_key = admin_key - self._description = description - self._service_endpoints = tuple(service_endpoints) - - @property - def registered_node_id(self) -> int: - return self._registered_node_id - - @property - def admin_key(self) -> PublicKey | None: - return self._admin_key - - @property - def description(self) -> str | None: - return self._description - - @property - def service_endpoints(self) -> tuple[RegisteredServiceEndpoint, ...]: - return self._service_endpoints + # Ensure service_endpoints is a tuple + object.__setattr__(self, "service_endpoints", tuple(self.service_endpoints)) @classmethod def _from_proto(cls, proto: RegisteredNodeProto) -> RegisteredNode: @@ -66,15 +49,15 @@ def _from_proto(cls, proto: RegisteredNodeProto) -> RegisteredNode: def _to_proto(self) -> RegisteredNodeProto: """Convert this RegisteredNode to a protobuf RegisteredNode.""" proto = RegisteredNodeProto( - registered_node_id=self._registered_node_id, + 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: + 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})" + return f"RegisteredNode(registered_node_id={self.registered_node_id}, description={self.description!r})" 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 index 331ed6e44..c031f0d59 100644 --- a/src/hiero_sdk_python/address_book/registered_node_address_book.py +++ b/src/hiero_sdk_python/address_book/registered_node_address_book.py @@ -3,10 +3,12 @@ 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. @@ -15,21 +17,19 @@ class RegisteredNodeAddressBook: the role of the legacy ``NodeAddressBook`` model. """ - def __init__(self, nodes: tuple[RegisteredNode, ...] = ()) -> None: - self._nodes = tuple(nodes) + nodes: tuple[RegisteredNode, ...] = field(default_factory=tuple) - @property - def nodes(self) -> tuple[RegisteredNode, ...]: - return self._nodes + def __post_init__(self): + object.__setattr__(self, "nodes", tuple(self.nodes)) def __len__(self) -> int: - return len(self._nodes) + return len(self.nodes) def __iter__(self) -> Iterator[RegisteredNode]: - return iter(self._nodes) + return iter(self.nodes) def __getitem__(self, index: int) -> RegisteredNode: - return self._nodes[index] + return self.nodes[index] def __repr__(self) -> str: - return f"RegisteredNodeAddressBook(nodes={len(self._nodes)})" + return f"RegisteredNodeAddressBook(nodes={len(self.nodes)})" diff --git a/src/hiero_sdk_python/address_book/registered_service_endpoint.py b/src/hiero_sdk_python/address_book/registered_service_endpoint.py index 6ce1bb82a..a118798dc 100644 --- a/src/hiero_sdk_python/address_book/registered_service_endpoint.py +++ b/src/hiero_sdk_python/address_book/registered_service_endpoint.py @@ -98,13 +98,9 @@ def _from_proto(cls, proto: RegisteredServiceEndpointProto) -> RegisteredService 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( - ip_address=ip_address, domain_name=domain_name, port=port, requires_tls=requires_tls - ) + return MirrorNodeServiceEndpoint._from_proto_inner(proto, ip_address, domain_name, port, requires_tls) if endpoint_type == "rpc_relay": - return RpcRelayServiceEndpoint( - ip_address=ip_address, domain_name=domain_name, port=port, requires_tls=requires_tls - ) + 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) - return cls(ip_address=ip_address, domain_name=domain_name, port=port, requires_tls=requires_tls) + raise ValueError(f"Unknown endpoint_type: {endpoint_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 index c99b227ee..c4a64bcbc 100644 --- a/src/hiero_sdk_python/address_book/rpc_relay_service_endpoint.py +++ b/src/hiero_sdk_python/address_book/rpc_relay_service_endpoint.py @@ -11,3 +11,19 @@ class RpcRelayServiceEndpoint(RegisteredServiceEndpoint): 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, + ) diff --git a/src/hiero_sdk_python/nodes/node_create_transaction.py b/src/hiero_sdk_python/nodes/node_create_transaction.py index 3f5a144b6..73a944bcf 100644 --- a/src/hiero_sdk_python/nodes/node_create_transaction.py +++ b/src/hiero_sdk_python/nodes/node_create_transaction.py @@ -240,14 +240,6 @@ def add_associated_registered_node(self, registered_node_id: int) -> NodeCreateT self.associated_registered_nodes.append(registered_node_id) return self - @staticmethod - def _validate_associated_registered_nodes(ids: list[int]) -> None: - if len(ids) > 20: - raise ValueError("associated_registered_nodes must have at most 20 entries") - for node_id in ids: - if not isinstance(node_id, int) or isinstance(node_id, bool) or node_id <= 0: - raise ValueError("Each associated registered node ID must be a positive integer") - def _build_proto_body(self) -> NodeCreateTransactionBody: """ Returns the protobuf body for the node create transaction. @@ -255,8 +247,6 @@ def _build_proto_body(self) -> NodeCreateTransactionBody: Returns: NodeCreateTransactionBody: The protobuf body for this transaction. """ - self._validate_associated_registered_nodes(self.associated_registered_nodes) - return NodeCreateTransactionBody( account_id=self.account_id._to_proto() if self.account_id else None, description=self.description, diff --git a/src/hiero_sdk_python/nodes/node_update_transaction.py b/src/hiero_sdk_python/nodes/node_update_transaction.py index 01b073efc..2f095df7d 100644 --- a/src/hiero_sdk_python/nodes/node_update_transaction.py +++ b/src/hiero_sdk_python/nodes/node_update_transaction.py @@ -278,14 +278,6 @@ def clear_associated_registered_nodes(self) -> NodeUpdateTransaction: self.associated_registered_nodes = [] return self - @staticmethod - def _validate_associated_registered_nodes(ids: list[int]) -> None: - if len(ids) > 20: - raise ValueError("associated_registered_nodes must have at most 20 entries") - for node_id in ids: - if not isinstance(node_id, int) or isinstance(node_id, bool) or node_id <= 0: - raise ValueError("Each associated registered node ID must be a positive integer") - 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 @@ -301,9 +293,6 @@ def _build_proto_body(self) -> NodeUpdateTransactionBody: Returns: NodeUpdateTransactionBody: The protobuf body for this transaction. """ - if self.associated_registered_nodes is not None: - self._validate_associated_registered_nodes(self.associated_registered_nodes) - body = NodeUpdateTransactionBody( node_id=self.node_id, account_id=self._convert_to_proto(self.account_id), diff --git a/src/hiero_sdk_python/nodes/registered_node_create_transaction.py b/src/hiero_sdk_python/nodes/registered_node_create_transaction.py index 438b92e38..1777e392f 100644 --- a/src/hiero_sdk_python/nodes/registered_node_create_transaction.py +++ b/src/hiero_sdk_python/nodes/registered_node_create_transaction.py @@ -46,27 +46,12 @@ def add_service_endpoint(self, endpoint: RegisteredServiceEndpoint) -> Registere return self def _build_proto_body(self) -> RegisteredNodeCreateTransactionBody: - if self.admin_key is None: - raise ValueError("admin_key is required") - self._validate_service_endpoints() - if self.description is not None and len(self.description.encode("utf-8")) > 100: - raise ValueError("description must be 100 UTF-8 bytes or fewer") - return RegisteredNodeCreateTransactionBody( admin_key=self.admin_key._to_proto() if self.admin_key else None, description=self.description or "", service_endpoint=[ep._to_proto() for ep in self.service_endpoints], ) - def _validate_service_endpoints(self) -> None: - if not self.service_endpoints: - raise ValueError("service_endpoints must have at least 1 entry") - if len(self.service_endpoints) > 50: - raise ValueError("service_endpoints must have at most 50 entries") - for ep in self.service_endpoints: - if not isinstance(ep, RegisteredServiceEndpoint): - raise TypeError("service_endpoints must contain RegisteredServiceEndpoint instances") - def build_transaction_body(self) -> TransactionBody: body = self._build_proto_body() transaction_body = self.build_base_transaction_body() diff --git a/src/hiero_sdk_python/nodes/registered_node_update_transaction.py b/src/hiero_sdk_python/nodes/registered_node_update_transaction.py index 1c5cd1c11..497c82c53 100644 --- a/src/hiero_sdk_python/nodes/registered_node_update_transaction.py +++ b/src/hiero_sdk_python/nodes/registered_node_update_transaction.py @@ -27,16 +27,40 @@ def __init__(self): 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: PublicKey | 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 @@ -44,11 +68,27 @@ def set_description(self, description: str | None) -> RegisteredNodeUpdateTransa 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 = [] @@ -56,11 +96,6 @@ def add_service_endpoint(self, endpoint: RegisteredServiceEndpoint) -> Registere return self def _build_proto_body(self) -> RegisteredNodeUpdateTransactionBody: - self._validate_registered_node_id() - if self.description is not None and len(self.description.encode("utf-8")) > 100: - raise ValueError("description must be 100 UTF-8 bytes or fewer") - self._validate_service_endpoints() - body = RegisteredNodeUpdateTransactionBody( registered_node_id=self.registered_node_id, admin_key=self.admin_key._to_proto() if self.admin_key else None, @@ -71,27 +106,6 @@ def _build_proto_body(self) -> RegisteredNodeUpdateTransactionBody: body.service_endpoint.append(ep._to_proto()) return body - def _validate_registered_node_id(self) -> None: - 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") - - def _validate_service_endpoints(self) -> None: - if self.service_endpoints is None: - return - if len(self.service_endpoints) == 0: - raise ValueError("service_endpoints must have at least 1 entry when provided") - if len(self.service_endpoints) > 50: - raise ValueError("service_endpoints must have at most 50 entries") - for ep in self.service_endpoints: - if not isinstance(ep, RegisteredServiceEndpoint): - raise TypeError("service_endpoints must contain RegisteredServiceEndpoint instances") - def build_transaction_body(self) -> TransactionBody: body = self._build_proto_body() transaction_body = self.build_base_transaction_body() diff --git a/tests/integration/registered_node_create_transaction_e2e_test.py b/tests/integration/registered_node_create_transaction_e2e_test.py index 03e9f5b7f..d06bf735c 100644 --- a/tests/integration/registered_node_create_transaction_e2e_test.py +++ b/tests/integration/registered_node_create_transaction_e2e_test.py @@ -107,15 +107,16 @@ def test_registered_node_create_with_mixed_endpoints(admin_client): def test_registered_node_create_fails_without_endpoints(admin_client): - """Test that creating a registered node with no endpoints fails (client-side validation).""" + """Test that creating a registered node with no endpoints fails at the network level.""" admin_key = PrivateKey.generate_ed25519() - with pytest.raises(ValueError, match="at least 1"): - ( - RegisteredNodeCreateTransaction() - .set_admin_key(admin_key.public_key()) - .set_description("no endpoints") - .freeze_with(admin_client) - .sign(admin_key) - .execute(admin_client) - ) + receipt = ( + RegisteredNodeCreateTransaction() + .set_admin_key(admin_key.public_key()) + .set_description("no endpoints") + .freeze_with(admin_client) + .sign(admin_key) + .execute(admin_client) + ) + + assert receipt.status != ResponseCode.SUCCESS, "Create without endpoints should fail" diff --git a/tests/unit/associated_registered_nodes_test.py b/tests/unit/associated_registered_nodes_test.py index 113cc5fdc..478652e4f 100644 --- a/tests/unit/associated_registered_nodes_test.py +++ b/tests/unit/associated_registered_nodes_test.py @@ -70,33 +70,6 @@ def test_from_bytes_round_trip(self): assert isinstance(restored, NodeCreateTransaction) assert list(restored.associated_registered_nodes) == [7, 8, 9] - def test_fails_more_than_20_ids(self, mock_account_ids): - operator_id, _, node_account_id, _, _ = mock_account_ids - tx = NodeCreateTransaction() - tx.set_associated_registered_nodes(list(range(1, 22))) - tx.operator_account_id = operator_id - tx.node_account_id = node_account_id - with pytest.raises(ValueError, match="at most 20"): - tx.build_transaction_body() - - def test_fails_id_zero(self, mock_account_ids): - operator_id, _, node_account_id, _, _ = mock_account_ids - tx = NodeCreateTransaction() - tx.set_associated_registered_nodes([0]) - tx.operator_account_id = operator_id - tx.node_account_id = node_account_id - with pytest.raises(ValueError, match="positive integer"): - tx.build_transaction_body() - - def test_fails_id_negative(self, mock_account_ids): - operator_id, _, node_account_id, _, _ = mock_account_ids - tx = NodeCreateTransaction() - tx.set_associated_registered_nodes([-1]) - tx.operator_account_id = operator_id - tx.node_account_id = node_account_id - with pytest.raises(ValueError, match="positive integer"): - tx.build_transaction_body() - def test_preserves_behavior_when_unset(self, mock_account_ids): operator_id, _, node_account_id, _, _ = mock_account_ids tx = NodeCreateTransaction() @@ -197,36 +170,6 @@ def test_from_bytes_round_trip_unset(self): assert isinstance(restored, NodeUpdateTransaction) assert restored.associated_registered_nodes is None - def test_fails_more_than_20_ids(self, mock_account_ids): - operator_id, _, node_account_id, _, _ = mock_account_ids - tx = NodeUpdateTransaction() - tx.node_id = 1 - tx.set_associated_registered_nodes(list(range(1, 22))) - tx.operator_account_id = operator_id - tx.node_account_id = node_account_id - with pytest.raises(ValueError, match="at most 20"): - tx.build_transaction_body() - - def test_fails_id_zero(self, mock_account_ids): - operator_id, _, node_account_id, _, _ = mock_account_ids - tx = NodeUpdateTransaction() - tx.node_id = 1 - tx.set_associated_registered_nodes([0]) - tx.operator_account_id = operator_id - tx.node_account_id = node_account_id - with pytest.raises(ValueError, match="positive integer"): - tx.build_transaction_body() - - def test_fails_id_negative(self, mock_account_ids): - operator_id, _, node_account_id, _, _ = mock_account_ids - tx = NodeUpdateTransaction() - tx.node_id = 1 - tx.set_associated_registered_nodes([-5]) - tx.operator_account_id = operator_id - tx.node_account_id = node_account_id - with pytest.raises(ValueError, match="positive integer"): - tx.build_transaction_body() - 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 diff --git a/tests/unit/registered_node_hardening_test.py b/tests/unit/registered_node_hardening_test.py index 425b3c116..df1b6e368 100644 --- a/tests/unit/registered_node_hardening_test.py +++ b/tests/unit/registered_node_hardening_test.py @@ -156,25 +156,26 @@ def test_general_endpoint_description_exactly_100_bytes(self): class TestRegisteredNodeCreateTransactionValidation: - def test_missing_admin_key_fails(self): + def test_valid_build_succeeds(self): tx = RegisteredNodeCreateTransaction() + tx.admin_key = _make_key() tx.set_service_endpoints([_mirror_ep()]) - with pytest.raises(ValueError, match="admin_key is required"): - tx._build_proto_body() + body = tx._build_proto_body() + assert body.HasField("admin_key") - def test_invalid_endpoint_object_fails(self): + def test_build_without_admin_key_succeeds(self): + """admin_key is no longer validated client-side; the node handles it.""" tx = RegisteredNodeCreateTransaction() - tx.admin_key = _make_key() - tx.service_endpoints = ["not an endpoint"] - with pytest.raises(TypeError, match="RegisteredServiceEndpoint"): - tx._build_proto_body() + tx.set_service_endpoints([_mirror_ep()]) + body = tx._build_proto_body() + assert not body.HasField("admin_key") - def test_valid_build_succeeds(self): + 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() - tx.set_service_endpoints([_mirror_ep()]) body = tx._build_proto_body() - assert body.HasField("admin_key") + assert len(body.service_endpoint) == 0 # --------------------------------------------------------------------------- @@ -183,31 +184,18 @@ def test_valid_build_succeeds(self): class TestRegisteredNodeUpdateTransactionValidation: - def test_registered_node_id_zero_fails(self): - tx = RegisteredNodeUpdateTransaction() - tx.registered_node_id = 0 - with pytest.raises(ValueError, match="positive integer"): - tx._build_proto_body() - - def test_registered_node_id_negative_fails(self): - tx = RegisteredNodeUpdateTransaction() - tx.registered_node_id = -5 - with pytest.raises(ValueError, match="positive integer"): - tx._build_proto_body() - - def test_empty_service_endpoints_fails(self): + def test_build_with_valid_id_succeeds(self): tx = RegisteredNodeUpdateTransaction() tx.registered_node_id = 1 - tx.service_endpoints = [] - with pytest.raises(ValueError, match="at least 1 entry"): - tx._build_proto_body() + body = tx._build_proto_body() + assert body.registered_node_id == 1 - def test_invalid_endpoint_object_fails(self): + def test_build_with_endpoints_succeeds(self): tx = RegisteredNodeUpdateTransaction() tx.registered_node_id = 1 - tx.service_endpoints = [42] - with pytest.raises(TypeError, match="RegisteredServiceEndpoint"): - tx._build_proto_body() + tx.service_endpoints = [_mirror_ep()] + body = tx._build_proto_body() + assert len(body.service_endpoint) == 1 # --------------------------------------------------------------------------- @@ -241,21 +229,21 @@ def test_registered_node_id_bool_fails(self): class TestAssociatedRegisteredNodesNonInt: - def test_node_create_rejects_string_id(self): + 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"]) - with pytest.raises(ValueError, match="positive integer"): - tx._validate_associated_registered_nodes(tx.associated_registered_nodes) + assert tx.associated_registered_nodes == ["abc"] - def test_node_update_rejects_float_id(self): + 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]) - with pytest.raises(ValueError, match="positive integer"): - tx._validate_associated_registered_nodes(tx.associated_registered_nodes) + assert tx.associated_registered_nodes == [1.5] # --------------------------------------------------------------------------- diff --git a/tests/unit/registered_node_tck_aligned_test.py b/tests/unit/registered_node_tck_aligned_test.py index 2f6145cf6..be8459faa 100644 --- a/tests/unit/registered_node_tck_aligned_test.py +++ b/tests/unit/registered_node_tck_aligned_test.py @@ -87,8 +87,8 @@ def test_create_with_all_endpoint_types(self): body = tx._build_proto_body() assert len(body.service_endpoint) == 4 - def test_create_requires_admin_key(self): - """TCK: createRegisteredNode without adminKey should raise.""" + 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, @@ -97,16 +97,16 @@ def test_create_requires_admin_key(self): ) tx = RegisteredNodeCreateTransaction() tx.set_service_endpoints([ep]) - with pytest.raises(ValueError, match="admin_key is required"): - tx._build_proto_body() + body = tx._build_proto_body() + assert not body.HasField("admin_key") - def test_create_requires_at_least_one_endpoint(self): - """TCK: createRegisteredNode with empty endpoints should raise.""" + 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) - with pytest.raises(ValueError, match="at least 1"): - tx._build_proto_body() + body = tx._build_proto_body() + assert len(body.service_endpoint) == 0 def test_create_description_optional(self): """TCK: createRegisteredNode without description sets empty string.""" @@ -162,15 +162,15 @@ def test_update_admin_key(self): body = tx._build_proto_body() assert body.admin_key is not None - def test_update_invalid_id_zero(self): - """TCK: updateRegisteredNode with id=0 should raise on build.""" + def test_update_id_zero_builds(self): + """TCK: updateRegisteredNode with id=0 should build (node validates).""" tx = RegisteredNodeUpdateTransaction() tx.set_registered_node_id(0) - with pytest.raises(ValueError): - tx._build_proto_body() + body = tx._build_proto_body() + assert body.registered_node_id == 0 - def test_update_invalid_id_negative(self): - """TCK: updateRegisteredNode with negative id should raise on build.""" + 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): diff --git a/tests/unit/registered_node_transaction_test.py b/tests/unit/registered_node_transaction_test.py index fee78af29..4f5744bbd 100644 --- a/tests/unit/registered_node_transaction_test.py +++ b/tests/unit/registered_node_transaction_test.py @@ -123,17 +123,18 @@ def test_from_bytes_round_trip(self): assert isinstance(restored.service_endpoints[0], BlockNodeServiceEndpoint) assert isinstance(restored.service_endpoints[1], MirrorNodeServiceEndpoint) - def test_fails_with_zero_endpoints(self, mock_account_ids): + 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 - with pytest.raises(ValueError, match="at least 1"): - tx.build_transaction_body() - - def test_fails_with_more_than_50_endpoints(self, mock_account_ids): + 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() @@ -141,11 +142,11 @@ def test_fails_with_more_than_50_endpoints(self, mock_account_ids): 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 - with pytest.raises(ValueError, match="at most 50"): - tx.build_transaction_body() - - def test_fails_with_long_description(self, mock_account_ids): + 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()) @@ -153,9 +154,8 @@ def test_fails_with_long_description(self, mock_account_ids): tx.set_service_endpoints([_make_block_endpoint()]) tx.operator_account_id = operator_id tx.node_account_id = node_account_id - - with pytest.raises(ValueError, match="100 UTF-8 bytes"): - tx.build_transaction_body() + body = tx.build_transaction_body() + assert body.registeredNodeCreate.description == "x" * 101 def test_add_service_endpoint(self): tx = RegisteredNodeCreateTransaction() @@ -244,36 +244,36 @@ def test_from_bytes_round_trip(self): assert len(restored.service_endpoints) == 1 assert isinstance(restored.service_endpoints[0], MirrorNodeServiceEndpoint) - def test_fails_when_registered_node_id_missing(self, mock_account_ids): + 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 - with pytest.raises(ValueError, match="registered_node_id"): - tx.build_transaction_body() - - def test_fails_when_endpoints_empty(self, mock_account_ids): + 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 - with pytest.raises(ValueError, match="at least 1"): - tx.build_transaction_body() - - def test_fails_when_endpoints_exceed_50(self, mock_account_ids): + 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 - - with pytest.raises(ValueError, match="at most 50"): - tx.build_transaction_body() + body = tx.build_transaction_body() + assert len(body.registeredNodeUpdate.service_endpoint) == 51 # --------------------------------------------------------------------------- From c928195ed6b348ae9eaba46cc821c7b72e9d0594 Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Mon, 11 May 2026 21:36:26 +0300 Subject: [PATCH 16/31] feat: refactor registered node transactions and validation logic for improved clarity and performance Signed-off-by: Ntege Daniel --- ...stered_node_create_transaction_e2e_test.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/integration/registered_node_create_transaction_e2e_test.py b/tests/integration/registered_node_create_transaction_e2e_test.py index d06bf735c..d6e04cc03 100644 --- a/tests/integration/registered_node_create_transaction_e2e_test.py +++ b/tests/integration/registered_node_create_transaction_e2e_test.py @@ -13,6 +13,7 @@ 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 @@ -110,13 +111,12 @@ 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() - receipt = ( - RegisteredNodeCreateTransaction() - .set_admin_key(admin_key.public_key()) - .set_description("no endpoints") - .freeze_with(admin_client) - .sign(admin_key) - .execute(admin_client) - ) - - assert receipt.status != ResponseCode.SUCCESS, "Create without endpoints should fail" + 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) + ) From 0be6eec6bf908d702ac307b8d4de91e0099a1075 Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Mon, 11 May 2026 21:49:11 +0300 Subject: [PATCH 17/31] feat: refactor registered node transactions and validation logic for improved clarity and performance Signed-off-by: Ntege Daniel --- examples/nodes/registered_node_lifecycle.py | 62 +++++++++++-------- .../block_node_service_endpoint.py | 2 + .../associated_registered_nodes_e2e_test.py | 4 +- ...stered_node_delete_transaction_e2e_test.py | 27 +++++--- ...stered_node_update_transaction_e2e_test.py | 30 +++++---- 5 files changed, 77 insertions(+), 48 deletions(-) diff --git a/examples/nodes/registered_node_lifecycle.py b/examples/nodes/registered_node_lifecycle.py index 42ad29340..f92f8d565 100644 --- a/examples/nodes/registered_node_lifecycle.py +++ b/examples/nodes/registered_node_lifecycle.py @@ -64,15 +64,19 @@ def registered_node_lifecycle(): endpoint_apis=[BlockNodeApi.STATUS, BlockNodeApi.SUBSCRIBE_STREAM], ) - receipt = ( - RegisteredNodeCreateTransaction() - .set_admin_key(admin_key.public_key()) - .set_description("Example block node") - .set_service_endpoints([block_endpoint]) - .freeze_with(client) - .sign(admin_key) - .execute(client) - ) + try: + receipt = ( + RegisteredNodeCreateTransaction() + .set_admin_key(admin_key.public_key()) + .set_description("Example block node") + .set_service_endpoints([block_endpoint]) + .freeze_with(client) + .sign(admin_key) + .execute(client) + ) + except Exception 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}") @@ -90,15 +94,19 @@ def registered_node_lifecycle(): requires_tls=True, ) - receipt = ( - RegisteredNodeUpdateTransaction() - .set_registered_node_id(registered_node_id) - .set_description("Updated block + mirror node") - .set_service_endpoints([block_endpoint, mirror_endpoint]) - .freeze_with(client) - .sign(admin_key) - .execute(client) - ) + try: + receipt = ( + RegisteredNodeUpdateTransaction() + .set_registered_node_id(registered_node_id) + .set_description("Updated block + mirror node") + .set_service_endpoints([block_endpoint, mirror_endpoint]) + .freeze_with(client) + .sign(admin_key) + .execute(client) + ) + except Exception 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}") @@ -109,13 +117,17 @@ def registered_node_lifecycle(): # ── Step 3: Delete the registered node ────────────────────────── print("\n--- Deleting registered node ---") - receipt = ( - RegisteredNodeDeleteTransaction() - .set_registered_node_id(registered_node_id) - .freeze_with(client) - .sign(admin_key) - .execute(client) - ) + try: + receipt = ( + RegisteredNodeDeleteTransaction() + .set_registered_node_id(registered_node_id) + .freeze_with(client) + .sign(admin_key) + .execute(client) + ) + except Exception 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}") 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 index 31f36bcef..cb89e5ab1 100644 --- a/src/hiero_sdk_python/address_book/block_node_service_endpoint.py +++ b/src/hiero_sdk_python/address_book/block_node_service_endpoint.py @@ -38,6 +38,8 @@ def _from_proto_inner( requires_tls: bool, ) -> BlockNodeServiceEndpoint: apis = [BlockNodeApi(v) for v in proto.block_node.endpoint_api] + if not apis: + apis = [BlockNodeApi.OTHER] return cls( ip_address=ip_address, domain_name=domain_name, diff --git a/tests/integration/associated_registered_nodes_e2e_test.py b/tests/integration/associated_registered_nodes_e2e_test.py index 6166bc493..efe2ca218 100644 --- a/tests/integration/associated_registered_nodes_e2e_test.py +++ b/tests/integration/associated_registered_nodes_e2e_test.py @@ -85,7 +85,9 @@ def _create_registered_node(client, admin_key): .sign(admin_key) .execute(client) ) - assert receipt.status == ResponseCode.SUCCESS + assert receipt.status == ResponseCode.SUCCESS, ( + f"Helper: registered node creation failed: {ResponseCode(receipt.status).name}" + ) return receipt.registered_node_id diff --git a/tests/integration/registered_node_delete_transaction_e2e_test.py b/tests/integration/registered_node_delete_transaction_e2e_test.py index d7a5d92e5..eeac0c7a6 100644 --- a/tests/integration/registered_node_delete_transaction_e2e_test.py +++ b/tests/integration/registered_node_delete_transaction_e2e_test.py @@ -12,6 +12,7 @@ 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 @@ -50,7 +51,9 @@ def _create_registered_node(client, admin_key): .sign(admin_key) .execute(client) ) - assert receipt.status == ResponseCode.SUCCESS + assert receipt.status == ResponseCode.SUCCESS, ( + f"Helper: registered node creation failed: {ResponseCode(receipt.status).name}" + ) return receipt.registered_node_id @@ -76,12 +79,16 @@ 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.SUCCESS, "Delete of nonexistent node should fail" + try: + 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}" + ) + except PrecheckError: + pass # Also acceptable: network rejects at precheck diff --git a/tests/integration/registered_node_update_transaction_e2e_test.py b/tests/integration/registered_node_update_transaction_e2e_test.py index 77b47e557..1a82b0dea 100644 --- a/tests/integration/registered_node_update_transaction_e2e_test.py +++ b/tests/integration/registered_node_update_transaction_e2e_test.py @@ -13,6 +13,7 @@ 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 @@ -52,7 +53,9 @@ def _create_registered_node(client, admin_key): .sign(admin_key) .execute(client) ) - assert receipt.status == ResponseCode.SUCCESS + assert receipt.status == ResponseCode.SUCCESS, ( + f"Helper: registered node creation failed: {ResponseCode(receipt.status).name}" + ) return receipt.registered_node_id @@ -116,14 +119,17 @@ 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() - # Use a very large ID that is unlikely to exist - 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.SUCCESS, "Update of nonexistent node should fail" + 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 From d4f0fa0ee11a2b586b82641e0a6cda724e216f7d Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Mon, 11 May 2026 21:55:59 +0300 Subject: [PATCH 18/31] feat: refactor registered node transactions and validation logic for improved clarity and performance Signed-off-by: Ntege Daniel --- examples/nodes/registered_node_lifecycle.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/examples/nodes/registered_node_lifecycle.py b/examples/nodes/registered_node_lifecycle.py index f92f8d565..374dff3b8 100644 --- a/examples/nodes/registered_node_lifecycle.py +++ b/examples/nodes/registered_node_lifecycle.py @@ -22,6 +22,7 @@ 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.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 @@ -74,7 +75,7 @@ def registered_node_lifecycle(): .sign(admin_key) .execute(client) ) - except Exception as e: + except (PrecheckError, ValueError) as e: print(f"Registered node creation failed: {e}") sys.exit(1) @@ -104,7 +105,7 @@ def registered_node_lifecycle(): .sign(admin_key) .execute(client) ) - except Exception as e: + except (PrecheckError, ValueError) as e: print(f"Registered node update failed: {e}") sys.exit(1) @@ -125,7 +126,7 @@ def registered_node_lifecycle(): .sign(admin_key) .execute(client) ) - except Exception as e: + except (PrecheckError, ValueError) as e: print(f"Registered node deletion failed: {e}") sys.exit(1) From b922cc9245fed380440fb5ee63e12438be1ec06e Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Wed, 13 May 2026 17:30:54 +0300 Subject: [PATCH 19/31] test: Enhance NodeCreate and NodeUpdate tests for associated registered nodes Signed-off-by: Ntege Daniel --- tests/unit/associated_registered_nodes_test.py | 6 ++++-- tests/unit/registered_node_model_test.py | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/tests/unit/associated_registered_nodes_test.py b/tests/unit/associated_registered_nodes_test.py index 478652e4f..13b23138d 100644 --- a/tests/unit/associated_registered_nodes_test.py +++ b/tests/unit/associated_registered_nodes_test.py @@ -58,7 +58,8 @@ def test_add_associated_registered_node_chains(self, mock_account_ids): def test_set_replaces_list(self): tx = NodeCreateTransaction() tx.add_associated_registered_node(1) - tx.set_associated_registered_nodes([5, 6]) + 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): @@ -138,7 +139,8 @@ def test_add_initializes_and_appends(self): def test_set_replaces_list(self): tx = NodeUpdateTransaction() tx.add_associated_registered_node(1) - tx.set_associated_registered_nodes([100, 200]) + 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): diff --git a/tests/unit/registered_node_model_test.py b/tests/unit/registered_node_model_test.py index c085f9f88..6d9946e6d 100644 --- a/tests/unit/registered_node_model_test.py +++ b/tests/unit/registered_node_model_test.py @@ -270,3 +270,18 @@ 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") From 58ccff7edd51dd3950c97652a0f0a7eba7ec5516 Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Wed, 13 May 2026 23:06:35 +0300 Subject: [PATCH 20/31] feat: Enhance registered node lifecycle example and add query functionality for mirror node Signed-off-by: Ntege Daniel --- examples/nodes/registered_node_lifecycle.py | 100 ++++++++-- .../block_node_service_endpoint.py | 9 + .../address_book/general_service_endpoint.py | 6 + .../address_book/registered_node.py | 26 +++ .../registered_node_address_book_query.py | 188 ++++++++++++++++-- .../registered_service_endpoint.py | 34 ++++ tests/unit/registered_node_model_test.py | 172 +++++++++++++++- 7 files changed, 491 insertions(+), 44 deletions(-) diff --git a/examples/nodes/registered_node_lifecycle.py b/examples/nodes/registered_node_lifecycle.py index 374dff3b8..6a9eb55e5 100644 --- a/examples/nodes/registered_node_lifecycle.py +++ b/examples/nodes/registered_node_lifecycle.py @@ -3,8 +3,10 @@ This example shows how to: 1. Create a registered node with BlockNodeServiceEndpoint and multiple endpoint APIs -2. Update the registered node's description and service endpoints -3. Delete the registered node +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 @@ -15,14 +17,16 @@ """ 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.mirror_node_service_endpoint import MirrorNodeServiceEndpoint +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 @@ -49,17 +53,17 @@ def setup_client(): def registered_node_lifecycle(): - """Demonstrates create, update, and delete of a registered node.""" + """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--- Creating registered node ---") + print("\n--- Step 1: Creating registered node ---") block_endpoint = BlockNodeServiceEndpoint( - domain_name="block.example.com", + ip_address=bytes([127, 0, 0, 1]), port=443, requires_tls=True, endpoint_apis=[BlockNodeApi.STATUS, BlockNodeApi.SUBSCRIBE_STREAM], @@ -69,7 +73,7 @@ def registered_node_lifecycle(): receipt = ( RegisteredNodeCreateTransaction() .set_admin_key(admin_key.public_key()) - .set_description("Example block node") + .set_description("My Block Node") .set_service_endpoints([block_endpoint]) .freeze_with(client) .sign(admin_key) @@ -86,21 +90,41 @@ def registered_node_lifecycle(): registered_node_id = receipt.registered_node_id print(f"Registered node created with ID: {registered_node_id}") - # ── Step 2: Update the registered node ────────────────────────── - print("\n--- Updating registered node ---") + # ── Step 2: Query the registered node via mirror node ─────────── + print("\n--- Step 2: Querying registered node via mirror node ---") - mirror_endpoint = MirrorNodeServiceEndpoint( - domain_name="mirror.example.com", - port=5600, + # 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("Updated block + mirror node") - .set_service_endpoints([block_endpoint, mirror_endpoint]) + .set_description("My Updated Block Node") + .set_service_endpoints([block_endpoint, update_endpoint]) .freeze_with(client) .sign(admin_key) .execute(client) @@ -115,8 +139,50 @@ def registered_node_lifecycle(): print("Registered node updated successfully") - # ── Step 3: Delete the registered node ────────────────────────── - print("\n--- Deleting registered node ---") + # ── 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 = ( @@ -135,7 +201,7 @@ def registered_node_lifecycle(): sys.exit(1) print("Registered node deleted successfully") - print("\n--- Registered node lifecycle complete ---") + print("\n--- Registered node lifecycle complete! ---") if __name__ == "__main__": 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 index cb89e5ab1..a22dd2712 100644 --- a/src/hiero_sdk_python/address_book/block_node_service_endpoint.py +++ b/src/hiero_sdk_python/address_book/block_node_service_endpoint.py @@ -47,3 +47,12 @@ def _from_proto_inner( 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] + if not apis: + apis = [BlockNodeApi.OTHER] + 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 index ad35e59a1..e19bc59ff 100644 --- a/src/hiero_sdk_python/address_book/general_service_endpoint.py +++ b/src/hiero_sdk_python/address_book/general_service_endpoint.py @@ -45,3 +45,9 @@ def _from_proto_inner( 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/registered_node.py b/src/hiero_sdk_python/address_book/registered_node.py index 9b868d545..a6dde0d55 100644 --- a/src/hiero_sdk_python/address_book/registered_node.py +++ b/src/hiero_sdk_python/address_book/registered_node.py @@ -61,3 +61,29 @@ def _to_proto(self) -> RegisteredNodeProto: 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_query.py b/src/hiero_sdk_python/address_book/registered_node_address_book_query.py index 610aa4702..37a8e33e6 100644 --- 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 @@ -1,25 +1,44 @@ -"""Skeleton query for fetching the registered-node address book.""" +"""Query for fetching the registered-node address book from the mirror node.""" from __future__ import annotations -from hiero_sdk_python.address_book.registered_node_address_book import RegisteredNodeAddressBook +import logging +import time +from urllib.parse import urlencode +import requests -class RegisteredNodeAddressBookQuery: - """Query to retrieve the registered-node address book from the network. +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} - .. warning:: - Query execution is **not yet available**. The mirror-node API endpoint - required by this query has not been implemented in this SDK release. - Calling :meth:`execute` will raise :class:`NotImplementedError`. +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 number of registered nodes returned. + """Limit the total number of registered nodes returned. Args: count: Maximum number of nodes. Must be positive. @@ -32,13 +51,150 @@ def set_max_registered_node_count(self, count: int) -> RegisteredNodeAddressBook self._max_registered_node_count = count return self - def execute(self, client: object = None) -> RegisteredNodeAddressBook: - """Execute the query against the network. + 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: - NotImplementedError: Always. Mirror-node API support for - registered-node queries is not yet available in this SDK. + RuntimeError: If a page fetch fails after all retry attempts. """ - raise NotImplementedError( - "RegisteredNodeAddressBookQuery requires mirror node API support, which is not yet available in this SDK." - ) + 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 index a118798dc..4f0ed97f2 100644 --- a/src/hiero_sdk_python/address_book/registered_service_endpoint.py +++ b/src/hiero_sdk_python/address_book/registered_service_endpoint.py @@ -104,3 +104,37 @@ def _from_proto(cls, proto: RegisteredServiceEndpointProto) -> RegisteredService 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(**base_kwargs) + if ep_type == "RPC_RELAY": + return RpcRelayServiceEndpoint(**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/tests/unit/registered_node_model_test.py b/tests/unit/registered_node_model_test.py index 6d9946e6d..2a1d54f2d 100644 --- a/tests/unit/registered_node_model_test.py +++ b/tests/unit/registered_node_model_test.py @@ -186,6 +186,95 @@ def test_repr(self): 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 @@ -243,18 +332,10 @@ def test_importable(self): q = RegisteredNodeAddressBookQuery() assert q is not None - def test_execute_raises_not_implemented(self): + def test_execute_rejects_none_client(self): q = RegisteredNodeAddressBookQuery() - with pytest.raises(NotImplementedError, match="mirror node API support"): - q.execute() - - def test_error_message_is_clear(self): - q = RegisteredNodeAddressBookQuery() - with pytest.raises(NotImplementedError) as exc_info: - q.execute() - msg = str(exc_info.value) - assert "RegisteredNodeAddressBookQuery" in msg - assert "not yet available" in msg + with pytest.raises(ValueError, match="client must not be None"): + q.execute(None) def test_set_max_registered_node_count(self): q = RegisteredNodeAddressBookQuery() @@ -285,3 +366,72 @@ 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 From c0dba0e53c3088b76df99bd2948d04af1f3dbf08 Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Thu, 14 May 2026 16:22:36 +0300 Subject: [PATCH 21/31] feat: Implement from_dict methods for service endpoints and enhance transaction classes with additional fields Signed-off-by: Ntege Daniel --- .../block_node_service_endpoint.py | 4 +- .../mirror_node_service_endpoint.py | 5 +++ .../registered_service_endpoint.py | 4 +- .../rpc_relay_service_endpoint.py | 5 +++ .../nodes/node_create_transaction.py | 16 +++++++ .../nodes/node_update_transaction.py | 17 ++++++++ .../registered_node_create_transaction.py | 42 ++++++++++++++++--- .../registered_node_delete_transaction.py | 8 ++++ .../registered_node_update_transaction.py | 10 ++--- .../associated_registered_nodes_e2e_test.py | 5 --- ...stered_node_create_transaction_e2e_test.py | 19 +++++++++ ...stered_node_delete_transaction_e2e_test.py | 24 +++++------ ...stered_node_update_transaction_e2e_test.py | 13 ++++++ 13 files changed, 139 insertions(+), 33 deletions(-) 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 index a22dd2712..8efe75a94 100644 --- a/src/hiero_sdk_python/address_book/block_node_service_endpoint.py +++ b/src/hiero_sdk_python/address_book/block_node_service_endpoint.py @@ -49,10 +49,10 @@ def _from_proto_inner( ) @classmethod - def _from_dict_inner(cls, type_data: dict, **base_kwargs) -> BlockNodeServiceEndpoint: + def _from_dict_inner(cls, type_data: dict, **base_kwargs) -> BlockNodeServiceEndpoint | None: """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] if not apis: - apis = [BlockNodeApi.OTHER] + return None return cls(endpoint_apis=apis, **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 index 4783ab12e..db1e1df89 100644 --- a/src/hiero_sdk_python/address_book/mirror_node_service_endpoint.py +++ b/src/hiero_sdk_python/address_book/mirror_node_service_endpoint.py @@ -28,3 +28,8 @@ def _from_proto_inner( 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_service_endpoint.py b/src/hiero_sdk_python/address_book/registered_service_endpoint.py index 4f0ed97f2..7816789d3 100644 --- a/src/hiero_sdk_python/address_book/registered_service_endpoint.py +++ b/src/hiero_sdk_python/address_book/registered_service_endpoint.py @@ -132,9 +132,9 @@ def _from_dict(cls, data: dict) -> RegisteredServiceEndpoint: if ep_type == "BLOCK_NODE": return BlockNodeServiceEndpoint._from_dict_inner(data.get("block_node", {}), **base_kwargs) if ep_type == "MIRROR_NODE": - return MirrorNodeServiceEndpoint(**base_kwargs) + return MirrorNodeServiceEndpoint._from_dict_inner(data.get("mirror_node", {}), **base_kwargs) if ep_type == "RPC_RELAY": - return RpcRelayServiceEndpoint(**base_kwargs) + 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 index c4a64bcbc..4b66a73ca 100644 --- a/src/hiero_sdk_python/address_book/rpc_relay_service_endpoint.py +++ b/src/hiero_sdk_python/address_book/rpc_relay_service_endpoint.py @@ -27,3 +27,8 @@ def _from_proto_inner( 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 73a944bcf..c368e58ea 100644 --- a/src/hiero_sdk_python/nodes/node_create_transaction.py +++ b/src/hiero_sdk_python/nodes/node_create_transaction.py @@ -305,6 +305,22 @@ def _from_protobuf(cls, transaction_body, body_bytes: 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.HasField("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 2f095df7d..d1667ef3a 100644 --- a/src/hiero_sdk_python/nodes/node_update_transaction.py +++ b/src/hiero_sdk_python/nodes/node_update_transaction.py @@ -364,6 +364,23 @@ def _from_protobuf(cls, transaction_body, body_bytes: 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 diff --git a/src/hiero_sdk_python/nodes/registered_node_create_transaction.py b/src/hiero_sdk_python/nodes/registered_node_create_transaction.py index 1777e392f..c11e329f2 100644 --- a/src/hiero_sdk_python/nodes/registered_node_create_transaction.py +++ b/src/hiero_sdk_python/nodes/registered_node_create_transaction.py @@ -4,7 +4,7 @@ from hiero_sdk_python.address_book.registered_service_endpoint import RegisteredServiceEndpoint from hiero_sdk_python.channels import _Channel -from hiero_sdk_python.crypto.public_key import PublicKey +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 ( @@ -19,16 +19,32 @@ class RegisteredNodeCreateTransaction(Transaction): def __init__(self): super().__init__() - self.admin_key: PublicKey | None = None + self.admin_key: Key | None = None self.description: str | None = None self.service_endpoints: list[RegisteredServiceEndpoint] = [] - def set_admin_key(self, admin_key: PublicKey | None) -> RegisteredNodeCreateTransaction: + 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 @@ -36,18 +52,34 @@ def set_description(self, description: str | None) -> RegisteredNodeCreateTransa 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() if self.admin_key else None, + 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], ) @@ -74,7 +106,7 @@ def _from_protobuf(cls, transaction_body, body_bytes: bytes, sig_map): if transaction_body.HasField("registeredNodeCreate"): pb = transaction_body.registeredNodeCreate if pb.HasField("admin_key"): - transaction.admin_key = PublicKey._from_proto(pb.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] diff --git a/src/hiero_sdk_python/nodes/registered_node_delete_transaction.py b/src/hiero_sdk_python/nodes/registered_node_delete_transaction.py index 07f6c6ed0..6fdd65488 100644 --- a/src/hiero_sdk_python/nodes/registered_node_delete_transaction.py +++ b/src/hiero_sdk_python/nodes/registered_node_delete_transaction.py @@ -20,6 +20,14 @@ def __init__(self, registered_node_id: int | None = None): 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 diff --git a/src/hiero_sdk_python/nodes/registered_node_update_transaction.py b/src/hiero_sdk_python/nodes/registered_node_update_transaction.py index 497c82c53..37520a219 100644 --- a/src/hiero_sdk_python/nodes/registered_node_update_transaction.py +++ b/src/hiero_sdk_python/nodes/registered_node_update_transaction.py @@ -6,7 +6,7 @@ from hiero_sdk_python.address_book.registered_service_endpoint import RegisteredServiceEndpoint from hiero_sdk_python.channels import _Channel -from hiero_sdk_python.crypto.public_key import PublicKey +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 ( @@ -22,7 +22,7 @@ class RegisteredNodeUpdateTransaction(Transaction): def __init__(self): super().__init__() self.registered_node_id: int | None = None - self.admin_key: PublicKey | None = None + self.admin_key: Key | None = None self.description: str | None = None self.service_endpoints: list[RegisteredServiceEndpoint] | None = None @@ -39,7 +39,7 @@ def set_registered_node_id(self, registered_node_id: int | None) -> RegisteredNo self.registered_node_id = registered_node_id return self - def set_admin_key(self, admin_key: PublicKey | None) -> RegisteredNodeUpdateTransaction: + def set_admin_key(self, admin_key: Key | None) -> RegisteredNodeUpdateTransaction: """Sets the new admin key for the registered node. Args: @@ -98,7 +98,7 @@ def add_service_endpoint(self, endpoint: RegisteredServiceEndpoint) -> Registere def _build_proto_body(self) -> RegisteredNodeUpdateTransactionBody: body = RegisteredNodeUpdateTransactionBody( registered_node_id=self.registered_node_id, - admin_key=self.admin_key._to_proto() if self.admin_key else None, + 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: @@ -129,7 +129,7 @@ def _from_protobuf(cls, transaction_body, body_bytes: bytes, sig_map): pb = transaction_body.registeredNodeUpdate transaction.registered_node_id = pb.registered_node_id if pb.HasField("admin_key"): - transaction.admin_key = PublicKey._from_proto(pb.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: diff --git a/tests/integration/associated_registered_nodes_e2e_test.py b/tests/integration/associated_registered_nodes_e2e_test.py index efe2ca218..c1893ab3f 100644 --- a/tests/integration/associated_registered_nodes_e2e_test.py +++ b/tests/integration/associated_registered_nodes_e2e_test.py @@ -4,8 +4,6 @@ from __future__ import annotations -import pytest - 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 @@ -91,7 +89,6 @@ def _create_registered_node(client, admin_key): return receipt.registered_node_id -@pytest.mark.skip(reason="Registered node support not yet available on local-node/solo") def test_node_create_with_associated_registered_nodes(): """Test NodeCreateTransaction with associated_registered_nodes.""" client = _setup_client() @@ -105,7 +102,6 @@ def test_node_create_with_associated_registered_nodes(): assert node_id is not None -@pytest.mark.skip(reason="Registered node support not yet available on local-node/solo") def test_node_update_set_associated_registered_nodes(): """Test NodeUpdateTransaction replacing associated_registered_nodes.""" client = _setup_client() @@ -130,7 +126,6 @@ def test_node_update_set_associated_registered_nodes(): assert receipt.status == ResponseCode.SUCCESS, f"Node update failed: {ResponseCode(receipt.status).name}" -@pytest.mark.skip(reason="Registered node support not yet available on local-node/solo") def test_node_update_clear_associated_registered_nodes(): """Test NodeUpdateTransaction clearing associated_registered_nodes with empty list.""" client = _setup_client() diff --git a/tests/integration/registered_node_create_transaction_e2e_test.py b/tests/integration/registered_node_create_transaction_e2e_test.py index d6e04cc03..6e07e6c19 100644 --- a/tests/integration/registered_node_create_transaction_e2e_test.py +++ b/tests/integration/registered_node_create_transaction_e2e_test.py @@ -120,3 +120,22 @@ def test_registered_node_create_fails_without_endpoints(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 index eeac0c7a6..cce831f10 100644 --- a/tests/integration/registered_node_delete_transaction_e2e_test.py +++ b/tests/integration/registered_node_delete_transaction_e2e_test.py @@ -12,7 +12,6 @@ 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 @@ -79,16 +78,13 @@ 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() - try: - 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}" - ) - except PrecheckError: - pass # Also acceptable: network rejects at precheck + 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}" + ) diff --git a/tests/integration/registered_node_update_transaction_e2e_test.py b/tests/integration/registered_node_update_transaction_e2e_test.py index 1a82b0dea..2a129d0f5 100644 --- a/tests/integration/registered_node_update_transaction_e2e_test.py +++ b/tests/integration/registered_node_update_transaction_e2e_test.py @@ -4,12 +4,15 @@ 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 @@ -77,6 +80,16 @@ def test_registered_node_update_description(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(2) + + # 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( From dced5fd5627aec371137e508c54e4b3eccea6546 Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Thu, 14 May 2026 16:44:49 +0300 Subject: [PATCH 22/31] feat: Implement from_dict methods for service endpoints and enhance transaction classes with additional fields Signed-off-by: Ntege Daniel --- src/hiero_sdk_python/nodes/node_create_transaction.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hiero_sdk_python/nodes/node_create_transaction.py b/src/hiero_sdk_python/nodes/node_create_transaction.py index c368e58ea..b890a21f9 100644 --- a/src/hiero_sdk_python/nodes/node_create_transaction.py +++ b/src/hiero_sdk_python/nodes/node_create_transaction.py @@ -317,7 +317,7 @@ def _from_protobuf(cls, transaction_body, body_bytes: bytes, sig_map): transaction.grpc_certificate_hash = pb.grpc_certificate_hash if pb.HasField("admin_key"): transaction.admin_key = PublicKey._from_proto(pb.admin_key) - if pb.HasField("decline_reward"): + 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) From 7144c0e19f22c4631e6d67e99082a95d52c0ef7c Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Thu, 14 May 2026 16:53:35 +0300 Subject: [PATCH 23/31] feat: Implement from_dict methods for service endpoints and enhance transaction classes with additional fields Signed-off-by: Ntege Daniel --- .../block_node_service_endpoint.py | 8 ++---- .../unit/registered_service_endpoint_test.py | 28 +++++++++---------- 2 files changed, 16 insertions(+), 20 deletions(-) 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 index 8efe75a94..e1f733d6b 100644 --- a/src/hiero_sdk_python/address_book/block_node_service_endpoint.py +++ b/src/hiero_sdk_python/address_book/block_node_service_endpoint.py @@ -19,9 +19,7 @@ def __init__( endpoint_apis: list[BlockNodeApi] | None = None, ) -> None: super().__init__(ip_address=ip_address, domain_name=domain_name, port=port, requires_tls=requires_tls) - if endpoint_apis is None or len(endpoint_apis) == 0: - raise ValueError("endpoint_apis must be non-empty") - self.endpoint_apis: list[BlockNodeApi] = [BlockNodeApi(api) for api in endpoint_apis] + self.endpoint_apis: list[BlockNodeApi] = [BlockNodeApi(api) for api in (endpoint_apis or [])] def _set_endpoint_type(self, proto: RegisteredServiceEndpointProto) -> None: block_node = proto.block_node @@ -49,10 +47,8 @@ def _from_proto_inner( ) @classmethod - def _from_dict_inner(cls, type_data: dict, **base_kwargs) -> BlockNodeServiceEndpoint | None: + 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] - if not apis: - return None return cls(endpoint_apis=apis, **base_kwargs) diff --git a/tests/unit/registered_service_endpoint_test.py b/tests/unit/registered_service_endpoint_test.py index 1eca3ec00..b308ff144 100644 --- a/tests/unit/registered_service_endpoint_test.py +++ b/tests/unit/registered_service_endpoint_test.py @@ -78,21 +78,21 @@ def test_multiple_endpoint_apis(self): assert isinstance(restored, BlockNodeServiceEndpoint) assert restored.endpoint_apis == apis - def test_empty_endpoint_apis_raises(self): - with pytest.raises(ValueError, match="endpoint_apis must be non-empty"): - BlockNodeServiceEndpoint( - ip_address=b"\x7f\x00\x00\x01", - port=80, - endpoint_apis=[], - ) + def test_empty_endpoint_apis_allowed(self): + ep = BlockNodeServiceEndpoint( + ip_address=b"\x7f\x00\x00\x01", + port=80, + endpoint_apis=[], + ) + assert ep.endpoint_apis == [] - def test_none_endpoint_apis_raises(self): - with pytest.raises(ValueError, match="endpoint_apis must be non-empty"): - BlockNodeServiceEndpoint( - ip_address=b"\x7f\x00\x00\x01", - port=80, - endpoint_apis=None, - ) + def test_none_endpoint_apis_defaults_to_empty(self): + ep = BlockNodeServiceEndpoint( + ip_address=b"\x7f\x00\x00\x01", + port=80, + endpoint_apis=None, + ) + assert ep.endpoint_apis == [] # --- MirrorNodeServiceEndpoint --- From 1206a8699a74822e29ae2344c690af572deb0087 Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Thu, 14 May 2026 22:35:23 +0300 Subject: [PATCH 24/31] feat: Implement from_dict methods for service endpoints and enhance transaction classes with additional fields Signed-off-by: Ntege Daniel --- ...stered_node_create_transaction_e2e_test.py | 99 ++++++++++++++++ ...stered_node_delete_transaction_e2e_test.py | 30 +++++ ...stered_node_update_transaction_e2e_test.py | 106 +++++++++++++++++- 3 files changed, 234 insertions(+), 1 deletion(-) diff --git a/tests/integration/registered_node_create_transaction_e2e_test.py b/tests/integration/registered_node_create_transaction_e2e_test.py index 6e07e6c19..62124ea4b 100644 --- a/tests/integration/registered_node_create_transaction_e2e_test.py +++ b/tests/integration/registered_node_create_transaction_e2e_test.py @@ -9,7 +9,9 @@ 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 @@ -107,6 +109,103 @@ def test_registered_node_create_with_mixed_endpoints(admin_client): ).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 + 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() diff --git a/tests/integration/registered_node_delete_transaction_e2e_test.py b/tests/integration/registered_node_delete_transaction_e2e_test.py index cce831f10..32e3e82b8 100644 --- a/tests/integration/registered_node_delete_transaction_e2e_test.py +++ b/tests/integration/registered_node_delete_transaction_e2e_test.py @@ -88,3 +88,33 @@ def test_registered_node_delete_invalid_id(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 index 2a129d0f5..7fc85aa52 100644 --- a/tests/integration/registered_node_update_transaction_e2e_test.py +++ b/tests/integration/registered_node_update_transaction_e2e_test.py @@ -82,7 +82,7 @@ def test_registered_node_update_description(admin_client): ) # Allow mirror node to sync - time.sleep(2) + 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) @@ -146,3 +146,107 @@ def test_registered_node_update_invalid_id(admin_client): ) 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) From 51287f536a364a7d2e1f84d88642716694eec30a Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Thu, 14 May 2026 23:17:40 +0300 Subject: [PATCH 25/31] feat: Implement from_dict methods for service endpoints and enhance transaction classes with additional fields Signed-off-by: Ntege Daniel --- .../integration/registered_node_create_transaction_e2e_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/registered_node_create_transaction_e2e_test.py b/tests/integration/registered_node_create_transaction_e2e_test.py index 62124ea4b..a4bfd9dc3 100644 --- a/tests/integration/registered_node_create_transaction_e2e_test.py +++ b/tests/integration/registered_node_create_transaction_e2e_test.py @@ -200,7 +200,7 @@ def test_registered_node_create_with_general_endpoint(admin_client): 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 + # 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) From ad2a72ef3ee96efadaa2dfd2074bfc6d82ff3e50 Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Fri, 15 May 2026 22:10:06 +0300 Subject: [PATCH 26/31] feat: Add setter methods for service endpoint attributes and enhance validation in tests Signed-off-by: Ntege Daniel --- .../block_node_service_endpoint.py | 5 +++ .../address_book/general_service_endpoint.py | 7 ++++ .../address_book/registered_node.py | 2 +- .../registered_service_endpoint.py | 27 ++++++++++++++ .../unit/associated_registered_nodes_test.py | 20 ++++++++++ tests/unit/registered_node_hardening_test.py | 37 +++++++++++++++++++ .../unit/registered_node_transaction_test.py | 32 ++++++++++++++++ .../unit/registered_service_endpoint_test.py | 25 +++++++++++++ 8 files changed, 154 insertions(+), 1 deletion(-) 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 index e1f733d6b..546c0f316 100644 --- a/src/hiero_sdk_python/address_book/block_node_service_endpoint.py +++ b/src/hiero_sdk_python/address_book/block_node_service_endpoint.py @@ -21,6 +21,11 @@ def __init__( 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: diff --git a/src/hiero_sdk_python/address_book/general_service_endpoint.py b/src/hiero_sdk_python/address_book/general_service_endpoint.py index e19bc59ff..3a89b8d0a 100644 --- a/src/hiero_sdk_python/address_book/general_service_endpoint.py +++ b/src/hiero_sdk_python/address_book/general_service_endpoint.py @@ -22,6 +22,13 @@ def __init__( 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 diff --git a/src/hiero_sdk_python/address_book/registered_node.py b/src/hiero_sdk_python/address_book/registered_node.py index a6dde0d55..fa1a2efdb 100644 --- a/src/hiero_sdk_python/address_book/registered_node.py +++ b/src/hiero_sdk_python/address_book/registered_node.py @@ -35,7 +35,7 @@ def _from_proto(cls, proto: RegisteredNodeProto) -> RegisteredNode: if proto.HasField("admin_key"): admin_key = PublicKey._from_proto(proto.admin_key) - description: str | None = proto.description if proto.description else None + description: str | None = proto.description or None endpoints = tuple(RegisteredServiceEndpoint._from_proto(ep) for ep in proto.service_endpoint) diff --git a/src/hiero_sdk_python/address_book/registered_service_endpoint.py b/src/hiero_sdk_python/address_book/registered_service_endpoint.py index 7816789d3..e3dd7d9bf 100644 --- a/src/hiero_sdk_python/address_book/registered_service_endpoint.py +++ b/src/hiero_sdk_python/address_book/registered_service_endpoint.py @@ -25,6 +25,33 @@ def __init__( 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(ip_address: bytes | None, domain_name: str | None) -> None: if ip_address is not None and domain_name is not None: diff --git a/tests/unit/associated_registered_nodes_test.py b/tests/unit/associated_registered_nodes_test.py index 13b23138d..eba6ee749 100644 --- a/tests/unit/associated_registered_nodes_test.py +++ b/tests/unit/associated_registered_nodes_test.py @@ -33,7 +33,10 @@ def _freeze(tx): 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]) @@ -43,6 +46,7 @@ def test_serializes_associated_registered_nodes(self, mock_account_ids): 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) @@ -56,6 +60,7 @@ def test_add_associated_registered_node_chains(self, mock_account_ids): 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]) @@ -63,6 +68,7 @@ def test_set_replaces_list(self): 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) @@ -72,6 +78,7 @@ def test_from_bytes_round_trip(self): 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 @@ -80,10 +87,12 @@ def test_preserves_behavior_when_unset(self, mock_account_ids): 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) @@ -96,7 +105,10 @@ def test_constructor_compat_no_break(self): 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 @@ -106,6 +118,7 @@ def test_does_not_serialize_when_none(self, mock_account_ids): 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 @@ -117,6 +130,7 @@ def test_serializes_empty_when_cleared(self, mock_account_ids): 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 @@ -128,6 +142,7 @@ def test_serializes_non_empty(self, mock_account_ids): 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) @@ -137,6 +152,7 @@ def test_add_initializes_and_appends(self): 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]) @@ -144,6 +160,7 @@ def test_set_replaces_list(self): 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]) @@ -154,6 +171,7 @@ def test_from_bytes_round_trip_non_empty(self): 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() @@ -164,6 +182,7 @@ def test_from_bytes_round_trip_cleared(self): 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) @@ -185,6 +204,7 @@ def test_preserves_existing_behavior(self, mock_account_ids): 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 diff --git a/tests/unit/registered_node_hardening_test.py b/tests/unit/registered_node_hardening_test.py index df1b6e368..843f878cf 100644 --- a/tests/unit/registered_node_hardening_test.py +++ b/tests/unit/registered_node_hardening_test.py @@ -98,27 +98,35 @@ def test_status_codes_match_protobuf(self): 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", @@ -136,6 +144,7 @@ def test_block_node_endpoint_apis_accepts_int_enum_values(self): 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 @@ -143,6 +152,7 @@ def test_general_endpoint_description_multibyte_utf8(self): 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 @@ -156,13 +166,29 @@ def test_general_endpoint_description_exactly_100_bytes(self): 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() @@ -184,13 +210,17 @@ def test_build_without_endpoints_succeeds(self): 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()] @@ -204,19 +234,24 @@ def test_build_with_endpoints_succeeds(self): 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"): @@ -229,6 +264,8 @@ def test_registered_node_id_bool_fails(self): 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 diff --git a/tests/unit/registered_node_transaction_test.py b/tests/unit/registered_node_transaction_test.py index 4f5744bbd..48006941b 100644 --- a/tests/unit/registered_node_transaction_test.py +++ b/tests/unit/registered_node_transaction_test.py @@ -52,7 +52,10 @@ def _freeze(tx): 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() @@ -72,6 +75,7 @@ def test_builds_proto_with_all_fields(self, mock_account_ids): 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() @@ -84,6 +88,7 @@ def test_multiple_service_endpoints(self, mock_account_ids): 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", @@ -100,6 +105,7 @@ def test_block_endpoint_with_multiple_apis(self, mock_account_ids): 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()]) @@ -107,6 +113,7 @@ def test_builds_schedulable_body(self): 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) @@ -158,6 +165,7 @@ def test_builds_with_long_description(self, mock_account_ids): 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()) @@ -170,7 +178,10 @@ def test_add_service_endpoint(self): 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) @@ -181,6 +192,7 @@ def test_builds_proto_with_registered_node_id(self, mock_account_ids): 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() @@ -192,6 +204,7 @@ def test_updates_admin_key(self, mock_account_ids): 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) @@ -202,6 +215,7 @@ def test_updates_description(self, mock_account_ids): 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) @@ -212,6 +226,7 @@ def test_replaces_endpoints_when_provided(self, mock_account_ids): 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) @@ -221,12 +236,14 @@ def test_does_not_serialize_endpoints_when_unset(self, mock_account_ids): 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) @@ -282,7 +299,10 @@ def test_builds_with_many_endpoints(self, mock_account_ids): 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) @@ -293,6 +313,7 @@ def test_builds_proto_with_registered_node_id(self, mock_account_ids): 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() @@ -300,6 +321,7 @@ def test_builds_schedulable_body(self): 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) @@ -310,6 +332,7 @@ def test_from_bytes_round_trip(self): 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 @@ -325,12 +348,16 @@ def test_fails_when_registered_node_id_missing(self, mock_account_ids): 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 @@ -342,14 +369,19 @@ def test_none_when_absent(self): 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 index b308ff144..98c1a6550 100644 --- a/tests/unit/registered_service_endpoint_test.py +++ b/tests/unit/registered_service_endpoint_test.py @@ -20,6 +20,8 @@ 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 @@ -34,7 +36,10 @@ def test_enum_values_match_protobuf(self): 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, @@ -51,6 +56,7 @@ def test_round_trip_ip_address(self): 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, @@ -66,6 +72,7 @@ def test_round_trip_domain_name(self): 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", @@ -79,6 +86,7 @@ def test_multiple_endpoint_apis(self): 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, @@ -87,6 +95,7 @@ def test_empty_endpoint_apis_allowed(self): 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, @@ -99,7 +108,10 @@ def test_none_endpoint_apis_defaults_to_empty(self): 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, @@ -117,7 +129,10 @@ def test_round_trip(self): 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, @@ -135,7 +150,10 @@ def test_round_trip(self): 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, @@ -150,6 +168,7 @@ def test_round_trip_with_description(self): 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, @@ -162,6 +181,7 @@ def test_round_trip_without_description(self): 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"): @@ -172,6 +192,7 @@ def test_description_too_long_raises(self): ) 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"): @@ -186,7 +207,10 @@ def test_multibyte_description_utf8_limit(self): 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) @@ -194,6 +218,7 @@ def test_ip_address_round_trip(self): 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) From ad9ffe231aa0831c966b574b34587f1bced04f67 Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Fri, 15 May 2026 22:25:53 +0300 Subject: [PATCH 27/31] feat: Add setter methods for service endpoint attributes and enhance validation in tests Signed-off-by: Ntege Daniel --- tests/unit/registered_node_model_test.py | 200 ++++++++++++++++++ .../unit/registered_service_endpoint_test.py | 153 ++++++++++++++ 2 files changed, 353 insertions(+) diff --git a/tests/unit/registered_node_model_test.py b/tests/unit/registered_node_model_test.py index 2a1d54f2d..bbe696177 100644 --- a/tests/unit/registered_node_model_test.py +++ b/tests/unit/registered_node_model_test.py @@ -1,5 +1,7 @@ from __future__ import annotations +from unittest.mock import MagicMock, patch + import pytest from hiero_sdk_python.address_book.block_node_api import BlockNodeApi @@ -435,3 +437,201 @@ def test_next_page_path_without_link(self): 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_service_endpoint_test.py b/tests/unit/registered_service_endpoint_test.py index 98c1a6550..4af873bc4 100644 --- a/tests/unit/registered_service_endpoint_test.py +++ b/tests/unit/registered_service_endpoint_test.py @@ -263,3 +263,156 @@ def test_port_below_zero_raises(self): 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" From fc186b9a2c511425bebd1febfaa7ed2cc2c42d48 Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Sat, 16 May 2026 20:15:33 +0300 Subject: [PATCH 28/31] feat: Add setter methods for service endpoint attributes and enhance validation in tests Signed-off-by: Ntege Daniel --- .../registered_service_endpoint.py | 18 +++++++++++++----- tests/unit/registered_service_endpoint_test.py | 10 +++++++--- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/hiero_sdk_python/address_book/registered_service_endpoint.py b/src/hiero_sdk_python/address_book/registered_service_endpoint.py index e3dd7d9bf..2bb5f718e 100644 --- a/src/hiero_sdk_python/address_book/registered_service_endpoint.py +++ b/src/hiero_sdk_python/address_book/registered_service_endpoint.py @@ -15,7 +15,7 @@ def __init__( port: int = 0, requires_tls: bool = False, ) -> None: - self._validate_address(ip_address, domain_name) + 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") @@ -53,14 +53,20 @@ def set_requires_tls(self, requires_tls: bool) -> RegisteredServiceEndpoint: return self @staticmethod - def _validate_address(ip_address: bytes | None, domain_name: str | None) -> None: + 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 None and domain_name is None: - raise ValueError("Exactly one of ip_address or domain_name must be provided") if ip_address is not None: RegisteredServiceEndpoint._validate_ip_address(ip_address) - else: + if domain_name is not None: RegisteredServiceEndpoint._validate_domain_name(domain_name) @staticmethod @@ -87,6 +93,8 @@ def _validate_port(port: int) -> None: 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, diff --git a/tests/unit/registered_service_endpoint_test.py b/tests/unit/registered_service_endpoint_test.py index 4af873bc4..25fce3226 100644 --- a/tests/unit/registered_service_endpoint_test.py +++ b/tests/unit/registered_service_endpoint_test.py @@ -240,9 +240,13 @@ def test_both_ip_and_domain_raises(self): port=80, ) - def test_neither_ip_nor_domain_raises(self): - with pytest.raises(ValueError, match="Exactly one"): - MirrorNodeServiceEndpoint(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"): From bcfae4722cba859b36f658064cd6d33593a95770 Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Sat, 16 May 2026 22:10:42 +0300 Subject: [PATCH 29/31] feat: Add setter methods for service endpoint attributes and enhance validation in tests Signed-off-by: Ntege Daniel --- .../address_book/block_node_service_endpoint.py | 2 -- src/hiero_sdk_python/nodes/node_create_transaction.py | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) 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 index 546c0f316..6cfad7381 100644 --- a/src/hiero_sdk_python/address_book/block_node_service_endpoint.py +++ b/src/hiero_sdk_python/address_book/block_node_service_endpoint.py @@ -41,8 +41,6 @@ def _from_proto_inner( requires_tls: bool, ) -> BlockNodeServiceEndpoint: apis = [BlockNodeApi(v) for v in proto.block_node.endpoint_api] - if not apis: - apis = [BlockNodeApi.OTHER] return cls( ip_address=ip_address, domain_name=domain_name, diff --git a/src/hiero_sdk_python/nodes/node_create_transaction.py b/src/hiero_sdk_python/nodes/node_create_transaction.py index b890a21f9..1aa12efcb 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): From 0fbd5786c89f9e8535ce4fbc9626c71f3168dcf3 Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Sat, 16 May 2026 22:14:42 +0300 Subject: [PATCH 30/31] feat: Add setter methods for service endpoint attributes and enhance validation in tests Signed-off-by: Ntege Daniel --- src/hiero_sdk_python/nodes/node_create_transaction.py | 2 +- src/hiero_sdk_python/nodes/node_update_transaction.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/hiero_sdk_python/nodes/node_create_transaction.py b/src/hiero_sdk_python/nodes/node_create_transaction.py index 1aa12efcb..472d08c25 100644 --- a/src/hiero_sdk_python/nodes/node_create_transaction.py +++ b/src/hiero_sdk_python/nodes/node_create_transaction.py @@ -76,7 +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] = [] + self.associated_registered_nodes: list[int] = node_create_params.associated_registered_nodes def set_account_id(self, account_id: AccountId | None) -> NodeCreateTransaction: """ diff --git a/src/hiero_sdk_python/nodes/node_update_transaction.py b/src/hiero_sdk_python/nodes/node_update_transaction.py index d1667ef3a..92d2d81ac 100644 --- a/src/hiero_sdk_python/nodes/node_update_transaction.py +++ b/src/hiero_sdk_python/nodes/node_update_transaction.py @@ -51,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): @@ -84,7 +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 = None + self.associated_registered_nodes: list[int] | None = node_update_params.associated_registered_nodes def set_node_id(self, node_id: int | None) -> NodeUpdateTransaction: """ From 366d56f5c4f063d4d1ff4d97b85acdbd597c79bc Mon Sep 17 00:00:00 2001 From: Ntege Daniel Date: Sat, 16 May 2026 22:36:09 +0300 Subject: [PATCH 31/31] feat: Add setter methods for service endpoint attributes and enhance validation in tests Signed-off-by: Ntege Daniel --- src/hiero_sdk_python/nodes/registered_node_delete_transaction.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/hiero_sdk_python/nodes/registered_node_delete_transaction.py b/src/hiero_sdk_python/nodes/registered_node_delete_transaction.py index 6fdd65488..3ce37b124 100644 --- a/src/hiero_sdk_python/nodes/registered_node_delete_transaction.py +++ b/src/hiero_sdk_python/nodes/registered_node_delete_transaction.py @@ -65,6 +65,7 @@ def _get_method(self, channel: _Channel) -> _Method: 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