Skip to content

Commit e6e6a16

Browse files
committed
feat: implement GeneralServiceEndpoint and add lifecycle example for HIP-1137
Signed-off-by: Ntege Daniel <danientege785@gmail.com>
1 parent 15d6b7d commit e6e6a16

5 files changed

Lines changed: 303 additions & 0 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# HIP-1137 Coverage in hiero-sdk-python
2+
3+
This document maps HIP-1137 concepts to current SDK implementation status.
4+
5+
## Covered Concepts
6+
7+
- `BlockNodeApi` enum: [src/hiero_sdk_python/address_book/block_node_api.py](../../src/hiero_sdk_python/address_book/block_node_api.py)
8+
- `RegisteredServiceEndpoint` base type with IP/FQDN one-of validation:
9+
[src/hiero_sdk_python/address_book/registered_service_endpoint.py](../../src/hiero_sdk_python/address_book/registered_service_endpoint.py)
10+
- Endpoint subtypes:
11+
- Block: [src/hiero_sdk_python/address_book/block_node_service_endpoint.py](../../src/hiero_sdk_python/address_book/block_node_service_endpoint.py)
12+
- Mirror: [src/hiero_sdk_python/address_book/mirror_node_service_endpoint.py](../../src/hiero_sdk_python/address_book/mirror_node_service_endpoint.py)
13+
- RPC relay: [src/hiero_sdk_python/address_book/rpc_relay_service_endpoint.py](../../src/hiero_sdk_python/address_book/rpc_relay_service_endpoint.py)
14+
- General service (deferred/public API not exposed yet): [src/hiero_sdk_python/address_book/general_service_endpoint.py](../../src/hiero_sdk_python/address_book/general_service_endpoint.py)
15+
- Registered node transactions:
16+
- Create: [src/hiero_sdk_python/nodes/registered_node_create_transaction.py](../../src/hiero_sdk_python/nodes/registered_node_create_transaction.py)
17+
- Update: [src/hiero_sdk_python/nodes/registered_node_update_transaction.py](../../src/hiero_sdk_python/nodes/registered_node_update_transaction.py)
18+
- Delete: [src/hiero_sdk_python/nodes/registered_node_delete_transaction.py](../../src/hiero_sdk_python/nodes/registered_node_delete_transaction.py)
19+
- `TransactionReceipt.registered_node_id`:
20+
[src/hiero_sdk_python/transaction/transaction_receipt.py](../../src/hiero_sdk_python/transaction/transaction_receipt.py)
21+
- Consensus node association fields:
22+
- Create: [src/hiero_sdk_python/nodes/node_create_transaction.py](../../src/hiero_sdk_python/nodes/node_create_transaction.py)
23+
- Update (including clear semantics): [src/hiero_sdk_python/nodes/node_update_transaction.py](../../src/hiero_sdk_python/nodes/node_update_transaction.py)
24+
- Registered node read models:
25+
- [src/hiero_sdk_python/address_book/registered_node.py](../../src/hiero_sdk_python/address_book/registered_node.py)
26+
- [src/hiero_sdk_python/address_book/registered_node_address_book.py](../../src/hiero_sdk_python/address_book/registered_node_address_book.py)
27+
28+
## Deferred Concept (Intentional)
29+
30+
- `RegisteredNodeAddressBookQuery` execution is intentionally deferred until mirror-node API support is defined, matching HIP guidance:
31+
[src/hiero_sdk_python/address_book/registered_node_address_book_query.py](../../src/hiero_sdk_python/address_book/registered_node_address_book_query.py)
32+
- `GeneralServiceEndpoint` protobuf subtype is not present in the currently generated schema for this repository. The SDK includes internal forward-compatible handling, but it is not a stable public top-level API until protobuf support lands.
33+
34+
## Tests
35+
36+
- Endpoint tests (including general subtype when protobuf supports it):
37+
[tests/unit/registered_service_endpoint_test.py](../../tests/unit/registered_service_endpoint_test.py)
38+
- Registered-node transaction tests:
39+
- [tests/unit/registered_node_create_transaction_test.py](../../tests/unit/registered_node_create_transaction_test.py)
40+
- [tests/unit/registered_node_update_transaction_test.py](../../tests/unit/registered_node_update_transaction_test.py)
41+
- [tests/unit/registered_node_delete_transaction_test.py](../../tests/unit/registered_node_delete_transaction_test.py)
42+
- Node association tests:
43+
- [tests/unit/node_create_transaction_test.py](../../tests/unit/node_create_transaction_test.py)
44+
- [tests/unit/node_update_transaction_test.py](../../tests/unit/node_update_transaction_test.py)
45+
- Receipt field test:
46+
[tests/unit/test_transaction_receipt.py](../../tests/unit/test_transaction_receipt.py)
47+
48+
## Example
49+
50+
- Registered node lifecycle example:
51+
[examples/nodes/registered_node_lifecycle.py](../../examples/nodes/registered_node_lifecycle.py)
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""
2+
Demonstrates a HIP-1137 registered-node lifecycle.
3+
4+
This example shows:
5+
1. Create a registered node with one or more service endpoints.
6+
2. Read the registered_node_id from the transaction receipt.
7+
3. Update the registered node metadata.
8+
4. Associate the registered node with an existing consensus node.
9+
5. Delete the registered node.
10+
11+
Notes:
12+
- This flow needs network support for HIP-1137 features.
13+
- GeneralServiceEndpoint is deferred until protobuf support is available.
14+
- RegisteredNodeAddressBookQuery remains unavailable until mirror-node APIs are defined.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import os
20+
21+
from dotenv import load_dotenv
22+
23+
from hiero_sdk_python import (
24+
AccountId,
25+
BlockNodeApi,
26+
BlockNodeServiceEndpoint,
27+
Client,
28+
NodeUpdateTransaction,
29+
PrivateKey,
30+
RegisteredNodeCreateTransaction,
31+
RegisteredNodeDeleteTransaction,
32+
RegisteredNodeUpdateTransaction,
33+
ResponseCode,
34+
)
35+
from hiero_sdk_python.address_book.general_service_endpoint import (
36+
GeneralServiceEndpoint,
37+
)
38+
39+
40+
def _must_env(key: str) -> str:
41+
value = os.getenv(key)
42+
if not value:
43+
raise ValueError(f"Missing environment variable: {key}")
44+
return value
45+
46+
47+
def registered_node_lifecycle() -> None:
48+
load_dotenv()
49+
50+
operator_id = AccountId.from_string(_must_env("OPERATOR_ID"))
51+
operator_key = PrivateKey.from_string(_must_env("OPERATOR_KEY"))
52+
node_id_to_update = int(_must_env("CONSENSUS_NODE_ID"))
53+
54+
client = Client.for_testnet()
55+
client.set_operator(operator_id, operator_key)
56+
57+
registered_node_admin_key = PrivateKey.generate_ed25519()
58+
59+
block_endpoint = BlockNodeServiceEndpoint(
60+
domain_name="block.example.com",
61+
port=443,
62+
requires_tls=True,
63+
endpoint_api=BlockNodeApi.SUBSCRIBE_STREAM,
64+
)
65+
66+
create_tx = (
67+
RegisteredNodeCreateTransaction()
68+
.set_admin_key(registered_node_admin_key.public_key())
69+
.set_description("Example block node")
70+
.add_service_endpoint(block_endpoint)
71+
.freeze_with(client)
72+
.sign(registered_node_admin_key)
73+
)
74+
75+
create_receipt = create_tx.execute(client)
76+
if create_receipt.status != ResponseCode.SUCCESS:
77+
raise RuntimeError(f"Create failed with status: {ResponseCode(create_receipt.status).name}")
78+
79+
registered_node_id = create_receipt.registered_node_id
80+
if registered_node_id is None:
81+
raise RuntimeError("Create succeeded but no registered_node_id was returned.")
82+
83+
update_tx = (
84+
RegisteredNodeUpdateTransaction()
85+
.set_registered_node_id(registered_node_id)
86+
.set_description("Updated block node")
87+
.add_service_endpoint(block_endpoint)
88+
)
89+
90+
try:
91+
update_tx = update_tx.add_service_endpoint(
92+
GeneralServiceEndpoint(
93+
domain_name="general.example.com",
94+
port=443,
95+
requires_tls=True,
96+
description="General endpoint",
97+
)
98+
)
99+
except NotImplementedError:
100+
# Current protobuf/network does not support this subtype yet.
101+
print("Skipping GeneralServiceEndpoint: unavailable in current protobuf schema.")
102+
103+
update_receipt = update_tx.freeze_with(client).sign(registered_node_admin_key).execute(client)
104+
if update_receipt.status != ResponseCode.SUCCESS:
105+
raise RuntimeError(f"Update failed with status: {ResponseCode(update_receipt.status).name}")
106+
107+
associate_receipt = (
108+
NodeUpdateTransaction()
109+
.set_node_id(node_id_to_update)
110+
.add_associated_registered_node(registered_node_id)
111+
.execute(client)
112+
)
113+
if associate_receipt.status != ResponseCode.SUCCESS:
114+
raise RuntimeError(f"Node association failed with status: {ResponseCode(associate_receipt.status).name}")
115+
116+
delete_receipt = (
117+
RegisteredNodeDeleteTransaction()
118+
.set_registered_node_id(registered_node_id)
119+
.freeze_with(client)
120+
.sign(registered_node_admin_key)
121+
.execute(client)
122+
)
123+
if delete_receipt.status != ResponseCode.SUCCESS:
124+
raise RuntimeError(f"Delete failed with status: {ResponseCode(delete_receipt.status).name}")
125+
126+
print(f"Lifecycle complete for registered node id: {registered_node_id}")
127+
128+
129+
if __name__ == "__main__":
130+
registered_node_lifecycle()
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Registered general service endpoint."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
from typing import Any
7+
8+
from hiero_sdk_python.address_book.registered_service_endpoint import (
9+
RegisteredServiceEndpoint,
10+
)
11+
from hiero_sdk_python.hapi.services.registered_service_endpoint_pb2 import (
12+
RegisteredServiceEndpoint as RegisteredServiceEndpointProto,
13+
)
14+
15+
16+
@dataclass(eq=True)
17+
class GeneralServiceEndpoint(RegisteredServiceEndpoint):
18+
"""A registered endpoint for a general-purpose service."""
19+
20+
description: str | None = None
21+
MAX_DESCRIPTION_BYTES = 100
22+
23+
def __post_init__(self) -> None:
24+
"""Run base endpoint validation and validate optional description."""
25+
super().__post_init__()
26+
if self.description is not None and len(self.description.encode("utf-8")) > self.MAX_DESCRIPTION_BYTES:
27+
raise ValueError("description must not exceed 100 UTF-8 bytes.")
28+
29+
@classmethod
30+
def _resolve_general_endpoint_descriptor(
31+
cls,
32+
) -> tuple[str, type[Any]]:
33+
"""Resolve the endpoint_type oneof entry and nested protobuf message for general endpoints."""
34+
endpoint_type_oneof = RegisteredServiceEndpointProto.DESCRIPTOR.oneofs_by_name.get("endpoint_type")
35+
if endpoint_type_oneof is None:
36+
raise NotImplementedError("RegisteredServiceEndpoint endpoint_type oneof is unavailable in this protobuf.")
37+
38+
for field in endpoint_type_oneof.fields:
39+
message_type = field.message_type
40+
if message_type is not None and "General" in message_type.name:
41+
nested_type = getattr(RegisteredServiceEndpointProto, message_type.name, None)
42+
if nested_type is None:
43+
raise NotImplementedError(
44+
"General service endpoint nested protobuf type is unavailable in this protobuf."
45+
)
46+
return field.name, nested_type
47+
48+
raise NotImplementedError("General service endpoints are unavailable in this protobuf version.")
49+
50+
def _endpoint_type_proto(self) -> dict[str, Any]:
51+
"""Return the protobuf general-service subtype."""
52+
field_name, nested_type = self._resolve_general_endpoint_descriptor()
53+
endpoint_proto = nested_type()
54+
55+
if self.description is not None:
56+
if not hasattr(endpoint_proto, "description"):
57+
raise NotImplementedError(
58+
"General service endpoint descriptions are unavailable in this protobuf version."
59+
)
60+
endpoint_proto.description = self.description
61+
62+
return {field_name: endpoint_proto}
63+
64+
@classmethod
65+
def _from_proto(cls, proto: RegisteredServiceEndpointProto) -> GeneralServiceEndpoint:
66+
"""Build a general service endpoint from protobuf."""
67+
field_name, _ = cls._resolve_general_endpoint_descriptor()
68+
endpoint_proto = getattr(proto, field_name)
69+
description = endpoint_proto.description if hasattr(endpoint_proto, "description") else None
70+
return cls(
71+
description=description or None,
72+
**cls._base_kwargs_from_proto(proto),
73+
)

src/hiero_sdk_python/address_book/registered_service_endpoint.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,4 +159,17 @@ def _from_proto(cls, proto: RegisteredServiceEndpointProto) -> RegisteredService
159159

160160
return RpcRelayServiceEndpoint._from_proto(proto)
161161

162+
if endpoint_type is not None:
163+
field_descriptor = proto.DESCRIPTOR.fields_by_name.get(endpoint_type)
164+
if (
165+
field_descriptor is not None
166+
and field_descriptor.message_type is not None
167+
and "General" in field_descriptor.message_type.name
168+
):
169+
from hiero_sdk_python.address_book.general_service_endpoint import (
170+
GeneralServiceEndpoint,
171+
)
172+
173+
return GeneralServiceEndpoint._from_proto(proto)
174+
162175
raise ValueError("Registered service endpoint type is required.")

tests/unit/registered_service_endpoint_test.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
from hiero_sdk_python.address_book.block_node_service_endpoint import (
77
BlockNodeServiceEndpoint,
88
)
9+
from hiero_sdk_python.address_book.general_service_endpoint import (
10+
GeneralServiceEndpoint,
11+
)
912
from hiero_sdk_python.address_book.mirror_node_service_endpoint import (
1013
MirrorNodeServiceEndpoint,
1114
)
@@ -15,10 +18,24 @@
1518
from hiero_sdk_python.address_book.rpc_relay_service_endpoint import (
1619
RpcRelayServiceEndpoint,
1720
)
21+
from hiero_sdk_python.hapi.services.registered_service_endpoint_pb2 import (
22+
RegisteredServiceEndpoint as RegisteredServiceEndpointProto,
23+
)
1824

1925
pytestmark = pytest.mark.unit
2026

2127

28+
def _supports_general_service_endpoint() -> bool:
29+
"""Return whether this protobuf build includes the general endpoint subtype."""
30+
endpoint_type_oneof = RegisteredServiceEndpointProto.DESCRIPTOR.oneofs_by_name.get("endpoint_type")
31+
if endpoint_type_oneof is None:
32+
return False
33+
34+
return any(
35+
field.message_type is not None and "General" in field.message_type.name for field in endpoint_type_oneof.fields
36+
)
37+
38+
2239
def test_block_node_service_endpoint_roundtrip():
2340
"""Block node endpoints should round-trip through protobuf."""
2441
endpoint = BlockNodeServiceEndpoint(
@@ -57,6 +74,25 @@ def test_rpc_relay_service_endpoint_roundtrip():
5774
assert roundtrip == endpoint
5875

5976

77+
def test_general_service_endpoint_roundtrip():
78+
"""General service endpoints should round-trip when supported by protobuf."""
79+
if not _supports_general_service_endpoint():
80+
pytest.skip("General service endpoint is unavailable in this protobuf version.")
81+
82+
endpoint = GeneralServiceEndpoint(
83+
domain_name="general.example.com",
84+
port=443,
85+
requires_tls=True,
86+
description="General node service",
87+
)
88+
89+
proto = endpoint._to_proto()
90+
roundtrip = RegisteredServiceEndpoint._from_proto(proto)
91+
92+
assert isinstance(roundtrip, GeneralServiceEndpoint)
93+
assert roundtrip == endpoint
94+
95+
6096
def test_registered_service_endpoint_requires_exactly_one_address():
6197
"""Endpoints must use either an IP address or a domain name."""
6298
with pytest.raises(ValueError, match="Exactly one of ip_address or domain_name must be set."):

0 commit comments

Comments
 (0)