Skip to content

Commit 1de57e3

Browse files
authored
feat(token): Add getTokenInfo request and response models, including custom fee serialization (#2400)
Signed-off-by: Ntege Daniel <danientege785@gmail.com>
1 parent fd3c405 commit 1de57e3

7 files changed

Lines changed: 325 additions & 15 deletions

File tree

src/hiero_sdk_python/query/token_info_query.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,12 @@ def _make_request(self) -> query_pb2.Query:
5757
Exception: If any other error occurs during request construction.
5858
"""
5959
try:
60-
if not self.token_id:
61-
raise ValueError("Token ID must be set before making the request.")
62-
6360
query_header = self._make_request_header()
6461

6562
token_info_query = token_get_info_pb2.TokenGetInfoQuery()
6663
token_info_query.header.CopyFrom(query_header)
67-
token_info_query.token.CopyFrom(self.token_id._to_proto())
64+
if self.token_id:
65+
token_info_query.token.CopyFrom(self.token_id._to_proto())
6866

6967
query = query_pb2.Query()
7068
query.tokenGetInfo.CopyFrom(token_info_query)

tck/handlers/registry.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import inspect
88
from collections.abc import Callable
9-
from dataclasses import asdict
9+
from dataclasses import asdict, fields as dc_fields
1010
from typing import Any, get_type_hints
1111

1212
from tck.errors import JsonRpcError, handle_sdk_errors
@@ -81,6 +81,29 @@ def safe_dispatch(method_name: str, params: Any, request_id: str | int | None) -
8181
return build_json_rpc_error_response(error, request_id)
8282

8383

84+
def _strip_none(obj: Any, nullable_keys: set[str] | None = None) -> Any:
85+
"""Recursively strip None values from nested dicts and lists."""
86+
if isinstance(obj, dict):
87+
return {
88+
k: _strip_none(v)
89+
for k, v in obj.items()
90+
if v is not None or (nullable_keys is not None and k in nullable_keys)
91+
}
92+
if isinstance(obj, list):
93+
return [_strip_none(item) for item in obj]
94+
return obj
95+
96+
8497
def parse_result(result: Any) -> dict:
85-
"""Parse the result from the methods to dict containing non none key:values"""
86-
return {k: v for k, v in asdict(result).items() if v is not None}
98+
"""Parse the result from the methods to dict containing non none key:values.
99+
100+
Fields with metadata={"nullable": True} are preserved even when None.
101+
Recursively strips None from nested structures.
102+
"""
103+
nullable_fields: set[str] = set()
104+
for f in dc_fields(result):
105+
if f.metadata.get("nullable"):
106+
nullable_fields.add(f.name)
107+
108+
raw = asdict(result)
109+
return _strip_none(raw, nullable_keys=nullable_fields)

tck/handlers/token.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
from hiero_sdk_python.account.account_id import AccountId
66
from hiero_sdk_python.Duration import Duration
7+
from hiero_sdk_python.hbar import Hbar
8+
from hiero_sdk_python.query.token_info_query import TokenInfoQuery
79
from hiero_sdk_python.response_code import ResponseCode
810
from hiero_sdk_python.timestamp import Timestamp
911
from hiero_sdk_python.tokens.custom_fee import CustomFee
@@ -19,9 +21,13 @@
1921
from hiero_sdk_python.tokens.token_associate_transaction import TokenAssociateTransaction
2022
from hiero_sdk_python.tokens.token_create_transaction import TokenCreateTransaction
2123
from hiero_sdk_python.tokens.token_delete_transaction import TokenDeleteTransaction
24+
from hiero_sdk_python.tokens.token_freeze_status import TokenFreezeStatus
2225
from hiero_sdk_python.tokens.token_freeze_transaction import TokenFreezeTransaction
2326
from hiero_sdk_python.tokens.token_id import TokenId
27+
from hiero_sdk_python.tokens.token_info import TokenInfo
28+
from hiero_sdk_python.tokens.token_kyc_status import TokenKycStatus
2429
from hiero_sdk_python.tokens.token_mint_transaction import TokenMintTransaction
30+
from hiero_sdk_python.tokens.token_pause_status import TokenPauseStatus
2531
from hiero_sdk_python.tokens.token_pause_transaction import TokenPauseTransaction
2632
from hiero_sdk_python.tokens.token_type import TokenType
2733
from hiero_sdk_python.transaction.transaction_receipt import TransactionReceipt
@@ -34,6 +40,7 @@
3440
CreateTokenParams,
3541
DeleteTokenParams,
3642
FreezeTokenParams,
43+
GetTokenInfoParams,
3744
MintTokenParams,
3845
PauseTokenParams,
3946
)
@@ -42,8 +49,10 @@
4249
AssociateTokenResponse,
4350
ClaimTokenResponse,
4451
CreateTokenResponse,
52+
CustomFeeResponse,
4553
DeleteTokenResponse,
4654
FreezeTokenResponse,
55+
GetTokenInfoResponse,
4756
MintTokenResponse,
4857
PauseTokenResponse,
4958
)
@@ -478,3 +487,157 @@ def claim_token(params: ClaimTokenParams) -> ClaimTokenResponse:
478487
receipt: TransactionReceipt = response.get_receipt(client, validate_status=True)
479488

480489
return ClaimTokenResponse(status=ResponseCode(receipt.status).name)
490+
491+
492+
def _serialize_key(key) -> str | None:
493+
"""Serialize a key to its DER-encoded hex string representation."""
494+
if key is None:
495+
return ""
496+
return key.to_string_der()
497+
498+
499+
def _serialize_custom_fee(fee: CustomFee) -> CustomFeeResponse:
500+
"""Serialize a CustomFee to a CustomFeeResponse."""
501+
response = CustomFeeResponse()
502+
503+
if fee.fee_collector_account_id is not None:
504+
aid = fee.fee_collector_account_id
505+
response.feeCollectorAccountId = {
506+
"realm": str(aid.realm),
507+
"shard": str(aid.shard),
508+
"num": str(aid.num),
509+
}
510+
response.feeCollectorsExempt = fee.all_collectors_are_exempt
511+
512+
if isinstance(fee, CustomFixedFee):
513+
fixed_fee_dict: dict = {"amount": str(fee.amount)}
514+
if fee.denominating_token_id is not None:
515+
fixed_fee_dict["denominatingTokenId"] = str(fee.denominating_token_id)
516+
response.fixedFee = fixed_fee_dict
517+
elif isinstance(fee, CustomFractionalFee):
518+
response.fractionalFee = {
519+
"numerator": str(fee.numerator),
520+
"denominator": str(fee.denominator),
521+
"minimumAmount": str(fee.min_amount),
522+
"maximumAmount": str(fee.max_amount),
523+
"assessmentMethod": "inclusive" if fee.assessment_method == FeeAssessmentMethod.INCLUSIVE else "exclusive",
524+
}
525+
elif isinstance(fee, CustomRoyaltyFee):
526+
royalty_fee_dict: dict = {
527+
"numerator": str(fee.numerator),
528+
"denominator": str(fee.denominator),
529+
}
530+
if fee.fallback_fee is not None:
531+
fallback: dict = {"amount": str(fee.fallback_fee.amount)}
532+
if fee.fallback_fee.denominating_token_id is not None:
533+
fallback["denominatingTokenId"] = str(fee.fallback_fee.denominating_token_id)
534+
royalty_fee_dict["fallbackFee"] = fallback
535+
response.royaltyFee = royalty_fee_dict
536+
537+
return response
538+
539+
540+
def _map_pause_status(pause_status: TokenPauseStatus) -> bool | None:
541+
"""Map TokenPauseStatus enum to TCK boolean representation."""
542+
if pause_status == TokenPauseStatus.PAUSED:
543+
return True
544+
if pause_status == TokenPauseStatus.UNPAUSED:
545+
return False
546+
return None
547+
548+
549+
def _map_token_type(token_type: TokenType | None) -> str | None:
550+
"""Map TokenType enum to TCK string representation."""
551+
if token_type is None:
552+
return None
553+
mapping = {
554+
TokenType.FUNGIBLE_COMMON: "FUNGIBLE_COMMON",
555+
TokenType.NON_FUNGIBLE_UNIQUE: "NON_FUNGIBLE_UNIQUE",
556+
}
557+
return mapping.get(token_type)
558+
559+
560+
def _map_supply_type(supply_type: SupplyType | None) -> str | None:
561+
"""Map SupplyType enum to TCK string representation."""
562+
if supply_type is None:
563+
return None
564+
mapping = {
565+
SupplyType.INFINITE: "INFINITE",
566+
SupplyType.FINITE: "FINITE",
567+
}
568+
return mapping.get(supply_type)
569+
570+
571+
def _map_freeze_status(freeze_status: TokenFreezeStatus) -> bool | None:
572+
"""Map TokenFreezeStatus enum to TCK boolean representation."""
573+
if freeze_status == TokenFreezeStatus.FROZEN:
574+
return True
575+
if freeze_status == TokenFreezeStatus.UNFROZEN:
576+
return False
577+
return None
578+
579+
580+
def _map_kyc_status(kyc_status: TokenKycStatus) -> bool | None:
581+
"""Map TokenKycStatus enum to TCK boolean representation."""
582+
if kyc_status == TokenKycStatus.GRANTED:
583+
return True
584+
if kyc_status == TokenKycStatus.REVOKED:
585+
return False
586+
return None
587+
588+
589+
def _build_token_info_response(info: TokenInfo) -> GetTokenInfoResponse:
590+
"""Build a GetTokenInfoResponse from a TokenInfo object."""
591+
# Serialize custom fees
592+
custom_fees = [_serialize_custom_fee(fee) for fee in info.custom_fees] if info.custom_fees else []
593+
594+
return GetTokenInfoResponse(
595+
tokenId=str(info.token_id) if info.token_id else None,
596+
name=info.name or "",
597+
symbol=info.symbol or "",
598+
decimals=info.decimals,
599+
totalSupply=str(info.total_supply) if info.total_supply is not None else "0",
600+
treasuryAccountId=str(info.treasury) if info.treasury else None,
601+
adminKey=_serialize_key(info.admin_key),
602+
kycKey=_serialize_key(info.kyc_key),
603+
freezeKey=_serialize_key(info.freeze_key),
604+
pauseKey=_serialize_key(info.pause_key),
605+
wipeKey=_serialize_key(info.wipe_key),
606+
supplyKey=_serialize_key(info.supply_key),
607+
feeScheduleKey=_serialize_key(info.fee_schedule_key),
608+
metadataKey=_serialize_key(info.metadata_key),
609+
defaultFreezeStatus=_map_freeze_status(info.default_freeze_status),
610+
defaultKycStatus=_map_kyc_status(info.default_kyc_status),
611+
pauseStatus=_map_pause_status(info.pause_status),
612+
isDeleted=info.is_deleted,
613+
autoRenewAccountId=str(info.auto_renew_account) if info.auto_renew_account else None,
614+
autoRenewPeriod=str(info.auto_renew_period.seconds) if info.auto_renew_period else None,
615+
expirationTime=str(info.expiry.seconds) if info.expiry else None,
616+
tokenMemo=info.memo if info.memo is not None else "",
617+
customFees=custom_fees,
618+
tokenType=_map_token_type(info.token_type),
619+
supplyType=_map_supply_type(info.supply_type),
620+
maxSupply=str(info.max_supply) if info.max_supply is not None else "0",
621+
metadata=info.metadata.hex() if info.metadata else "",
622+
ledgerId=info.ledger_id.hex() if info.ledger_id else "",
623+
)
624+
625+
626+
@rpc_method("getTokenInfo")
627+
def get_token_info(params: GetTokenInfoParams) -> GetTokenInfoResponse:
628+
"""Query token info using TCK getTokenInfo parameters."""
629+
client = get_client(params.sessionId)
630+
631+
query = TokenInfoQuery().set_grpc_deadline(DEFAULT_GRPC_TIMEOUT)
632+
633+
if params.tokenId is not None:
634+
query.set_token_id(TokenId.from_string(params.tokenId))
635+
636+
if params.queryPayment is not None:
637+
query.set_query_payment(Hbar.from_tinybars(int(params.queryPayment)))
638+
639+
if params.maxQueryPayment is not None:
640+
query.set_max_query_payment(Hbar.from_tinybars(int(params.maxQueryPayment)))
641+
642+
info = query.execute(client)
643+
return _build_token_info_response(info)

tck/param/token.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from dataclasses import dataclass
66

7-
from tck.param.base import BaseTransactionParams
7+
from tck.param.base import BaseParams, BaseTransactionParams
88
from tck.param.custom_fee import CustomFeeParams
99
from tck.util.param_utils import (
1010
parse_common_transaction_params,
@@ -232,3 +232,22 @@ def parse_json_params(cls, params: dict) -> ClaimTokenParams:
232232
sessionId=parse_session_id(params),
233233
commonTransactionParams=parse_common_transaction_params(params),
234234
)
235+
236+
237+
@dataclass
238+
class GetTokenInfoParams(BaseParams):
239+
"""Request parameters for the getTokenInfo endpoint."""
240+
241+
tokenId: str | None = None
242+
queryPayment: str | None = None
243+
maxQueryPayment: str | None = None
244+
245+
@classmethod
246+
def parse_json_params(cls, params: dict) -> GetTokenInfoParams:
247+
"""Parse JSON-RPC params into a GetTokenInfoParams instance."""
248+
return cls(
249+
tokenId=params.get("tokenId"),
250+
queryPayment=params.get("queryPayment"),
251+
maxQueryPayment=params.get("maxQueryPayment"),
252+
sessionId=parse_session_id(params),
253+
)

tck/response/token.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from __future__ import annotations
44

5-
from dataclasses import dataclass
5+
from dataclasses import dataclass, field
66

77
from tck.response.base import StatusOnlyResponse
88

@@ -52,3 +52,48 @@ class AirdropTokenResponse(StatusOnlyResponse):
5252
@dataclass
5353
class ClaimTokenResponse(StatusOnlyResponse):
5454
"""Response payload for claimToken."""
55+
56+
57+
@dataclass
58+
class CustomFeeResponse:
59+
"""Nested custom fee details for getTokenInfo."""
60+
61+
fixedFee: dict | None = None
62+
fractionalFee: dict | None = None
63+
royaltyFee: dict | None = None
64+
feeCollectorAccountId: dict | None = None
65+
feeCollectorsExempt: bool | None = None
66+
67+
68+
@dataclass
69+
class GetTokenInfoResponse:
70+
"""Response payload for getTokenInfo."""
71+
72+
tokenId: str | None = None
73+
name: str | None = None
74+
symbol: str | None = None
75+
decimals: int | None = None
76+
totalSupply: str | None = None
77+
treasuryAccountId: str | None = None
78+
adminKey: str | None = None
79+
kycKey: str | None = None
80+
freezeKey: str | None = None
81+
pauseKey: str | None = None
82+
wipeKey: str | None = None
83+
supplyKey: str | None = None
84+
feeScheduleKey: str | None = None
85+
metadataKey: str | None = None
86+
defaultFreezeStatus: bool | None = field(metadata={"nullable": True}, default=None)
87+
defaultKycStatus: bool | None = field(metadata={"nullable": True}, default=None)
88+
pauseStatus: bool | None = field(metadata={"nullable": True}, default=None)
89+
isDeleted: bool | None = None
90+
autoRenewAccountId: str | None = None
91+
autoRenewPeriod: str | None = None
92+
expirationTime: str | None = None
93+
tokenMemo: str | None = None
94+
customFees: list[CustomFeeResponse] = field(default_factory=list)
95+
tokenType: str | None = None
96+
supplyType: str | None = None
97+
maxSupply: str | None = None
98+
metadata: str | None = None
99+
ledgerId: str | None = None

0 commit comments

Comments
 (0)