diff --git a/src/hiero_sdk_python/query/account_info_query.py b/src/hiero_sdk_python/query/account_info_query.py index a1198f99e..9731282d9 100644 --- a/src/hiero_sdk_python/query/account_info_query.py +++ b/src/hiero_sdk_python/query/account_info_query.py @@ -52,18 +52,15 @@ def _make_request(self): Query: The protobuf query message. Raises: - ValueError: If the account ID is not set. Exception: If any other error occurs during request construction. """ try: - if not self.account_id: - raise ValueError("Account ID must be set before making the request.") - query_header = self._make_request_header() crypto_info_query = crypto_get_info_pb2.CryptoGetInfoQuery() crypto_info_query.header.CopyFrom(query_header) - crypto_info_query.accountID.CopyFrom(self.account_id._to_proto()) + if self.account_id is not None: + crypto_info_query.accountID.CopyFrom(self.account_id._to_proto()) query = query_pb2.Query() query.cryptoGetInfo.CopyFrom(crypto_info_query) diff --git a/tck/handlers/account.py b/tck/handlers/account.py index 1b11a0372..f20be2b67 100644 --- a/tck/handlers/account.py +++ b/tck/handlers/account.py @@ -1,13 +1,22 @@ +"""TCK RPC handlers for account-related endpoints.""" + from __future__ import annotations from hiero_sdk_python.account.account_create_transaction import AccountCreateTransaction from hiero_sdk_python.account.account_id import AccountId +from hiero_sdk_python.account.account_info import AccountInfo from hiero_sdk_python.hbar import Hbar +from hiero_sdk_python.query.account_info_query import AccountInfoQuery from hiero_sdk_python.response_code import ResponseCode from hiero_sdk_python.transaction.transaction_receipt import TransactionReceipt from tck.handlers.registry import rpc_method -from tck.param.account import CreateAccountParams -from tck.response.account import CreateAccountResponse +from tck.param.account import CreateAccountParams, GetAccountInfoParams +from tck.response.account import ( + CreateAccountResponse, + GetAccountInfoResponse, + StakingInfoResponse, + TokenRelationshipResponse, +) 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 @@ -51,6 +60,7 @@ def _build_create_account_transaction(params: CreateAccountParams) -> AccountCre @rpc_method("createAccount") def create_account(params: CreateAccountParams) -> CreateAccountResponse: + """Create a new account using TCK createAccount parameters.""" client = get_client(params.sessionId) transaction = _build_create_account_transaction(params) @@ -66,3 +76,100 @@ def create_account(params: CreateAccountParams) -> CreateAccountResponse: account_id = str(receipt.account_id) return CreateAccountResponse(account_id, ResponseCode(receipt.status).name) + + +def _enum_name(value) -> str | None: + if value is None: + return None + return getattr(value, "name", str(value)) + + +def _serialize_key(key) -> str | None: + if key is None: + return None + + to_string_der = getattr(key, "to_string_der", None) + if callable(to_string_der): + return to_string_der() + + return key.to_bytes().hex() + + +def _to_staking_info_response(info: AccountInfo) -> StakingInfoResponse | None: + if info.staking_info is None: + return None + + staking_info = info.staking_info + return StakingInfoResponse( + declineStakingReward=staking_info.decline_reward, + stakePeriodStart=str(staking_info.stake_period_start) if staking_info.stake_period_start is not None else None, + pendingReward=str(staking_info.pending_reward.to_tinybars()) + if staking_info.pending_reward is not None + else "0", + stakedToMe=str(staking_info.staked_to_me.to_tinybars()) if staking_info.staked_to_me is not None else "0", + stakedAccountId=str(staking_info.staked_account_id) if staking_info.staked_account_id is not None else None, + stakedNodeId=str(staking_info.staked_node_id) if staking_info.staked_node_id is not None else None, + ) + + +def _to_token_relationships_response(info: AccountInfo) -> dict[str, TokenRelationshipResponse]: + token_relationships_response: dict[str, TokenRelationshipResponse] = {} + + for relationship in info.token_relationships: + token_id = str(relationship.token_id) if relationship.token_id is not None else None + if token_id is None: + continue + + token_relationships_response[token_id] = TokenRelationshipResponse( + tokenId=token_id, + symbol=relationship.symbol, + balance=str(relationship.balance) if relationship.balance is not None else "0", + kycStatus=_enum_name(relationship.kyc_status), + freezeStatus=_enum_name(relationship.freeze_status), + decimals=str(relationship.decimals) if relationship.decimals is not None else None, + automaticAssociation=relationship.automatic_association, + ) + + return token_relationships_response + + +def _build_account_info_response(info: AccountInfo) -> GetAccountInfoResponse: + auto_renew_period_seconds = str(info.auto_renew_period.seconds) if info.auto_renew_period is not None else "0" + + return GetAccountInfoResponse( + accountId=str(info.account_id) if info.account_id is not None else None, + contractAccountId=info.contract_account_id or "", + isDeleted=bool(info.is_deleted), + proxyAccountId="", + proxyReceived=str(info.proxy_received.to_tinybars()) if info.proxy_received is not None else "0", + key=_serialize_key(info.key), + balance=str(info.balance.to_tinybars()) if info.balance is not None else "0", + sendRecordThreshold="0", + receiveRecordThreshold="0", + isReceiverSignatureRequired=bool(info.receiver_signature_required), + expirationTime=str(info.expiration_time) if info.expiration_time is not None else None, + autoRenewPeriod=auto_renew_period_seconds, + tokenRelationships=_to_token_relationships_response(info), + accountMemo=info.account_memo or "", + ownedNfts=str(info.owned_nfts) if info.owned_nfts is not None else "0", + maxAutomaticTokenAssociations=str(info.max_automatic_token_associations) + if info.max_automatic_token_associations is not None + else "0", + aliasKey="", + ledgerId="", + ethereumNonce="0", + stakingInfo=_to_staking_info_response(info), + ) + + +@rpc_method("getAccountInfo") +def get_account_info(params: GetAccountInfoParams) -> GetAccountInfoResponse: + """Query account info and map SDK fields to the TCK response contract.""" + client = get_client(params.sessionId) + + query = AccountInfoQuery().set_grpc_deadline(DEFAULT_GRPC_TIMEOUT) + if params.accountId is not None: + query.set_account_id(AccountId.from_string(params.accountId)) + + info = query.execute(client) + return _build_account_info_response(info) diff --git a/tck/param/account.py b/tck/param/account.py index 58087aece..de8047b23 100644 --- a/tck/param/account.py +++ b/tck/param/account.py @@ -1,8 +1,10 @@ +"""TCK request parameter models for account endpoints.""" + from __future__ import annotations from dataclasses import dataclass -from tck.param.base import BaseTransactionParams +from tck.param.base import BaseParams, BaseTransactionParams from tck.util.param_utils import ( parse_common_transaction_params, parse_session_id, @@ -13,6 +15,8 @@ @dataclass class CreateAccountParams(BaseTransactionParams): + """Request parameters for the createAccount endpoint.""" + key: str | None = None initialBalance: int | None = None receiverSignatureRequired: bool | None = None @@ -26,6 +30,7 @@ class CreateAccountParams(BaseTransactionParams): @classmethod def parse_json_params(cls, params: dict) -> CreateAccountParams: + """Parse JSON-RPC params into a CreateAccountParams instance.""" return cls( key=params.get("key"), initialBalance=to_int(params.get("initialBalance")), @@ -40,3 +45,15 @@ def parse_json_params(cls, params: dict) -> CreateAccountParams: sessionId=parse_session_id(params), commonTransactionParams=parse_common_transaction_params(params), ) + + +@dataclass +class GetAccountInfoParams(BaseParams): + """Request parameters for the getAccountInfo endpoint.""" + + accountId: str | None = None + + @classmethod + def parse_json_params(cls, params: dict) -> GetAccountInfoParams: + """Parse JSON-RPC params into a GetAccountInfoParams instance.""" + return cls(accountId=params.get("accountId"), sessionId=parse_session_id(params)) diff --git a/tck/response/account.py b/tck/response/account.py index 73d4c72b3..f316ab1c5 100644 --- a/tck/response/account.py +++ b/tck/response/account.py @@ -1,9 +1,68 @@ +"""TCK response models for account endpoints.""" + from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field @dataclass class CreateAccountResponse: + """Response payload for createAccount.""" + accountId: str | None = None status: str | None = None + + +@dataclass +class StakingInfoResponse: + """Nested staking fields in the getAccountInfo response.""" + + declineStakingReward: bool | None = None + stakePeriodStart: str | None = None + pendingReward: str | None = None + stakedToMe: str | None = None + stakedAccountId: str | None = None + stakedNodeId: str | None = None + + +@dataclass +class TokenRelationshipResponse: + """Nested token relationship details for getAccountInfo.""" + + tokenId: str | None = None + symbol: str | None = None + balance: str | None = None + kycStatus: str | None = None + freezeStatus: str | None = None + decimals: str | None = None + automaticAssociation: bool | None = None + + +@dataclass +class GetAccountInfoResponse: + """Response payload for getAccountInfo.""" + + accountId: str | None = None + contractAccountId: str | None = None + isDeleted: bool | None = None + proxyAccountId: str | None = None + proxyReceived: str | None = None + key: str | None = None + balance: str | None = None + sendRecordThreshold: str | None = None + receiveRecordThreshold: str | None = None + isReceiverSignatureRequired: bool | None = None + expirationTime: str | None = None + autoRenewPeriod: str | None = None + liveHashes: list[dict] = field(default_factory=list) + tokenRelationships: dict[str, TokenRelationshipResponse] = field(default_factory=dict) + accountMemo: str | None = None + ownedNfts: str | None = None + maxAutomaticTokenAssociations: str | None = None + aliasKey: str | None = None + ledgerId: str | None = None + ethereumNonce: str | None = None + hbarAllowances: list[dict] = field(default_factory=list) + tokenAllowances: list[dict] = field(default_factory=list) + nftAllowances: list[dict] = field(default_factory=list) + stakingInfo: StakingInfoResponse | None = None diff --git a/tests/unit/account_info_query_test.py b/tests/unit/account_info_query_test.py index 204733083..a23d984a6 100644 --- a/tests/unit/account_info_query_test.py +++ b/tests/unit/account_info_query_test.py @@ -34,12 +34,29 @@ def test_constructor(): assert query.account_id == account_id -def test_execute_fails_with_missing_account_id(mock_client): - """Test request creation with missing Account ID.""" +def test_execute_fails_with_missing_account_id(): + """Test request execution with missing Account ID throws PrecheckError.""" + from hiero_sdk_python.exceptions import PrecheckError + query = AccountInfoQuery() - with pytest.raises(ValueError, match=r"Account ID must be set before making the request\."): - query.execute(mock_client) + response_sequences = [ + [ + response_pb2.Response( + cryptoGetInfo=crypto_get_info_pb2.CryptoGetInfoResponse( + header=response_header_pb2.ResponseHeader( + nodeTransactionPrecheckCode=ResponseCode.INVALID_ACCOUNT_ID, + responseType=ResponseType.ANSWER_ONLY, + cost=0, + ) + ) + ) + ] + ] + with mock_hedera_servers(response_sequences) as client: + with pytest.raises(PrecheckError) as exc_info: + query.execute(client) + assert exc_info.value.status == ResponseCode.INVALID_ACCOUNT_ID def test_get_method():