diff --git a/tck/handlers/__init__.py b/tck/handlers/__init__.py index 5b76afd46..e0469fbc0 100644 --- a/tck/handlers/__init__.py +++ b/tck/handlers/__init__.py @@ -6,6 +6,7 @@ account, key, sdk, # setup, reset + topic, ) from .registry import ( get_all_handlers, @@ -18,7 +19,4 @@ "get_handler", "get_all_handlers", "safe_dispatch", - "account", - "key", - "sdk", ] diff --git a/tck/handlers/key.py b/tck/handlers/key.py index c198110b0..e67aa85f8 100644 --- a/tck/handlers/key.py +++ b/tck/handlers/key.py @@ -10,6 +10,13 @@ from tck.util.key_utils import KeyType, get_key_from_string +def _format_generated_public_key(public_key: PublicKey) -> str: + """Return DER-hex output expected by TCK for generated public keys.""" + if public_key.is_ecdsa(): + return public_key.to_string_der_ecdsa_compressed() + return public_key.to_string_der() + + @rpc_method("generateKey") def generate_key(params: KeyGenerationParams) -> KeyGenerationResponse: if params.fromKey and params.type not in { @@ -62,7 +69,7 @@ def _handle_private_key(params: KeyGenerationParams, response: KeyGenerationResp def _handle_public_key(params: KeyGenerationParams, response: KeyGenerationResponse, is_list: bool) -> str: if params.fromKey: - return PrivateKey.from_string(params.fromKey).public_key().to_string_der() + return _format_generated_public_key(PrivateKey.from_string(params.fromKey).public_key()) if params.type == KeyType.ED25519_PUBLIC_KEY: private_key = PrivateKey.generate_ed25519() @@ -72,7 +79,7 @@ def _handle_public_key(params: KeyGenerationParams, response: KeyGenerationRespo if is_list: response.privateKeys.append(private_key.to_string_der()) - return private_key.public_key().to_string_der() + return _format_generated_public_key(private_key.public_key()) def _handle_key_list(params: KeyGenerationParams, response: KeyGenerationResponse) -> str: diff --git a/tck/handlers/topic.py b/tck/handlers/topic.py new file mode 100644 index 000000000..77aca2742 --- /dev/null +++ b/tck/handlers/topic.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from hiero_sdk_python.account.account_id import AccountId +from hiero_sdk_python.consensus.topic_create_transaction import TopicCreateTransaction +from hiero_sdk_python.response_code import ResponseCode +from hiero_sdk_python.tokens.custom_fixed_fee import CustomFixedFee +from hiero_sdk_python.tokens.token_id import TokenId +from hiero_sdk_python.transaction.transaction_receipt import TransactionReceipt +from tck.handlers.registry import rpc_method +from tck.param.topic import CreateTopicCustomFeeParams, CreateTopicParams +from tck.response.topic import CreateTopicResponse +from tck.util.client_utils import get_client +from tck.util.constants import DEFAULT_GRPC_TIMEOUT +from tck.util.key_utils import get_key_from_string + + +def _build_custom_fee(custom_fee_params: CreateTopicCustomFeeParams) -> CustomFixedFee: + custom_fee = CustomFixedFee() + + if custom_fee_params.feeCollectorAccountId == "": + # Keep TCK behavior: explicitly empty collector should surface as internal error. + raise ValueError("feeCollectorAccountId cannot be empty") + + if custom_fee_params.feeCollectorAccountId is not None: + custom_fee.set_fee_collector_account_id(AccountId.from_string(custom_fee_params.feeCollectorAccountId)) + + if custom_fee_params.feeCollectorsExempt: + custom_fee.set_all_collectors_are_exempt(custom_fee_params.feeCollectorsExempt) + + if custom_fee_params.fixedFee is not None: + if custom_fee_params.fixedFee.amount is not None: + custom_fee.amount = custom_fee_params.fixedFee.amount + + if custom_fee_params.fixedFee.denominatingTokenId: + custom_fee.set_denominating_token_id(TokenId.from_string(custom_fee_params.fixedFee.denominatingTokenId)) + + return custom_fee + + +def _build_create_topic_transaction(params: CreateTopicParams) -> TopicCreateTransaction: + transaction = TopicCreateTransaction().set_grpc_deadline(DEFAULT_GRPC_TIMEOUT) + + if params.memo is not None: + transaction.set_memo(params.memo) + + if params.adminKey is not None: + transaction.set_admin_key(get_key_from_string(params.adminKey)) + + if params.submitKey is not None: + transaction.set_submit_key(get_key_from_string(params.submitKey)) + + if params.autoRenewPeriod is not None: + transaction.set_auto_renew_period(params.autoRenewPeriod) + + if params.autoRenewAccountId is not None: + transaction.set_auto_renew_account(AccountId.from_string(params.autoRenewAccountId)) + + if params.feeScheduleKey is not None: + transaction.set_fee_schedule_key(get_key_from_string(params.feeScheduleKey)) + + if params.feeExemptKeys is not None: + transaction.set_fee_exempt_keys([get_key_from_string(key) for key in params.feeExemptKeys]) + + if params.customFees is not None: + transaction.set_custom_fees([_build_custom_fee(custom_fee_params) for custom_fee_params in params.customFees]) + + return transaction + + +@rpc_method("createTopic") +def create_topic(params: CreateTopicParams) -> CreateTopicResponse: + client = get_client(params.sessionId) + + transaction = _build_create_topic_transaction(params) + + if params.autoRenewAccountId is None and client is not None and client.operator_account_id is not None: + transaction.set_auto_renew_account(client.operator_account_id) + + if params.commonTransactionParams is not None: + params.commonTransactionParams.apply_common_params(transaction, client) + + response = transaction.execute(client, wait_for_receipt=False) + receipt: TransactionReceipt = response.get_receipt(client, validate_status=True) + + topic_id = "" + if receipt.status == ResponseCode.SUCCESS and receipt.topic_id is not None: + topic_id = str(receipt.topic_id) + + return CreateTopicResponse(topic_id, ResponseCode(receipt.status).name) diff --git a/tck/param/common.py b/tck/param/common.py index 7e90038d5..31423b865 100644 --- a/tck/param/common.py +++ b/tck/param/common.py @@ -38,7 +38,8 @@ def apply_common_params(self, transaction: Transaction, client: Client) -> None: except Exception: transaction.set_transaction_id(TransactionId.generate(AccountId.from_string(self.transactionId))) - # TODO add a max_transaction_fee sdk missing func + if self.maxTransactionFee is not None: + transaction.transaction_fee = int(self.maxTransactionFee) if self.validTransactionDuration is not None: transaction.set_transaction_valid_duration(self.validTransactionDuration) diff --git a/tck/param/topic.py b/tck/param/topic.py new file mode 100644 index 000000000..c708d65e0 --- /dev/null +++ b/tck/param/topic.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from tck.param.base import BaseTransactionParams +from tck.util.param_utils import ( + non_empty_string_list, + non_empty_string_or_none, + parse_common_transaction_params, + parse_session_id, + to_bool, + to_int, +) + + +@dataclass +class CreateTopicFixedFeeParams: + """Parameters for a fixed fee custom fee in topic creation.""" + + amount: int | None = None + denominatingTokenId: str | None = None + + @classmethod + def parse_json_params(cls, params: dict) -> CreateTopicFixedFeeParams: + return cls( + amount=to_int(params.get("amount")), + denominatingTokenId=non_empty_string_or_none(params.get("denominatingTokenId")), + ) + + +@dataclass +class CreateTopicCustomFeeParams: + """Parameters for a custom fee in topic creation.""" + + feeCollectorAccountId: str | None = None + feeCollectorsExempt: bool | None = None + fixedFee: CreateTopicFixedFeeParams | None = None + + @classmethod + def parse_json_params(cls, params: dict) -> CreateTopicCustomFeeParams: + fixed_fee = params.get("fixedFee") + + fee_collector_account_id = params.get("feeCollectorAccountId") + if isinstance(fee_collector_account_id, str): + fee_collector_account_id = fee_collector_account_id.strip() + + return cls( + feeCollectorAccountId=fee_collector_account_id, + feeCollectorsExempt=to_bool(params.get("feeCollectorsExempt")), + fixedFee=(CreateTopicFixedFeeParams.parse_json_params(fixed_fee) if isinstance(fixed_fee, dict) else None), + ) + + +@dataclass +class CreateTopicParams(BaseTransactionParams): + """Parameters for creating a topic. Extends BaseTransactionParams to include common transaction parameters.""" + + memo: str | None = None + adminKey: str | None = None + submitKey: str | None = None + autoRenewPeriod: int | None = None + autoRenewAccountId: str | None = None + feeScheduleKey: str | None = None + feeExemptKeys: list[str] | None = None + customFees: list[CreateTopicCustomFeeParams] | None = None + + @classmethod + def parse_json_params(cls, params: dict) -> CreateTopicParams: + fee_exempt_keys = params.get("feeExemptKeys") + if fee_exempt_keys is not None and not isinstance(fee_exempt_keys, list): + raise ValueError("feeExemptKeys must be a list") + + custom_fees = params.get("customFees") + if custom_fees is not None and not isinstance(custom_fees, list): + raise ValueError("customFees must be a list") + if custom_fees is not None and any(not isinstance(custom_fee, dict) for custom_fee in custom_fees): + raise ValueError("each customFees item must be an object") + return cls( + memo=params.get("memo"), + adminKey=non_empty_string_or_none(params.get("adminKey")), + submitKey=non_empty_string_or_none(params.get("submitKey")), + autoRenewPeriod=to_int(params.get("autoRenewPeriod")), + autoRenewAccountId=non_empty_string_or_none(params.get("autoRenewAccountId")), + feeScheduleKey=non_empty_string_or_none(params.get("feeScheduleKey")), + feeExemptKeys=non_empty_string_list(fee_exempt_keys), + customFees=( + [CreateTopicCustomFeeParams.parse_json_params(custom_fee) for custom_fee in custom_fees] + if custom_fees is not None + else None + ), + sessionId=parse_session_id(params), + commonTransactionParams=parse_common_transaction_params(params), + ) diff --git a/tck/response/topic.py b/tck/response/topic.py new file mode 100644 index 000000000..015a8ba74 --- /dev/null +++ b/tck/response/topic.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class CreateTopicResponse: + topicId: str | None = None + status: str | None = None diff --git a/tck/util/param_utils.py b/tck/util/param_utils.py index 49a12b4db..4e687ab6d 100644 --- a/tck/util/param_utils.py +++ b/tck/util/param_utils.py @@ -30,6 +30,28 @@ def to_int(value) -> int | None: return None +def non_empty_string_or_none(value: str | None) -> str | None: + """Trim string values; convert blank strings to None.""" + if not isinstance(value, str): + return value + cleaned = value.strip() + return cleaned if cleaned else None + + +def non_empty_string_list(values) -> list[str] | None: + """Trim list entries and remove empty-string items.""" + if values is None: + return None + + cleaned_values: list[str] = [] + for value in values: + cleaned = non_empty_string_or_none(value) + if cleaned is not None: + cleaned_values.append(cleaned) + + return cleaned_values + + def to_bool(value) -> bool | None: """Helper to convert value to bool.""" if isinstance(value, str):