Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.
- Fixed duplication in GitHub bot next issue recommendations by parsing actual issue descriptions instead of blind truncation (#1658)

### Src
- Add `staking_info` field to `ContractInfo` class to expose staking metadata using the `StakingInfo` wrapper. (#1365)
- Fix `TopicInfo.__str__()` to format `expiration_time` in UTC so unit tests pass in non-UTC environments. (#1800)
- Resolve CodeQL `reflected-XSS` warning in TCK JSON-RPC endpoint
- Improve `keccak256` docstring formatting for better readability and consistency (#1624)
Expand Down
16 changes: 14 additions & 2 deletions src/hiero_sdk_python/contract/contract_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from hiero_sdk_python.crypto.public_key import PublicKey
from hiero_sdk_python.Duration import Duration
from hiero_sdk_python.hapi.services.contract_get_info_pb2 import ContractGetInfoResponse
from hiero_sdk_python.staking_info import StakingInfo
from hiero_sdk_python.timestamp import Timestamp
from hiero_sdk_python.tokens.token_relationship import TokenRelationship

Expand All @@ -38,6 +39,7 @@ class ContractInfo:
max_automatic_token_associations (Optional[int]):
The maximum number of token associations that can be automatically renewed
token_relationships (list[TokenRelationship]): The token relationships of the contract
staking_info (Optional[StakingInfo]): The staking information for this contract
"""

contract_id: Optional[ContractId] = None
Expand All @@ -54,6 +56,7 @@ class ContractInfo:
ledger_id: Optional[bytes] = None
max_automatic_token_associations: Optional[int] = None
token_relationships: list[TokenRelationship] = field(default_factory=list)
staking_info: Optional[StakingInfo] = None
Comment thread
manishdait marked this conversation as resolved.

Comment thread
manishdait marked this conversation as resolved.
@classmethod
def _from_proto(cls, proto: ContractGetInfoResponse.ContractInfo) -> "ContractInfo":
Expand All @@ -69,7 +72,7 @@ def _from_proto(cls, proto: ContractGetInfoResponse.ContractInfo) -> "ContractIn
if proto is None:
raise ValueError("Contract info proto is None")

return cls(
contract_info = cls(
contract_id=(
cls._from_proto_field(proto, "contractID", ContractId._from_proto)
),
Expand Down Expand Up @@ -99,8 +102,15 @@ def _from_proto(cls, proto: ContractGetInfoResponse.ContractInfo) -> "ContractIn
TokenRelationship._from_proto(relationship)
for relationship in proto.tokenRelationships
],
staking_info=(
StakingInfo._from_proto(proto.staking_info)
if proto.HasField('staking_info')
else None
),
)

return contract_info

def _to_proto(self) -> ContractGetInfoResponse.ContractInfo:
"""
Converts this ContractInfo instance to its protobuf representation.
Expand Down Expand Up @@ -133,6 +143,7 @@ def _to_proto(self) -> ContractGetInfoResponse.ContractInfo:
else None
),
max_automatic_token_associations=self.max_automatic_token_associations,
staking_info=self.staking_info._to_proto() if self.staking_info else None,
)

def __repr__(self) -> str:
Expand Down Expand Up @@ -184,7 +195,8 @@ def __str__(self) -> str:
f" is_deleted={self.is_deleted},\n"
f" token_relationships={token_relationships_str},\n"
f" ledger_id={ledger_id_display},\n"
f" max_automatic_token_associations={self.max_automatic_token_associations}\n"
f" max_automatic_token_associations={self.max_automatic_token_associations},\n"
f" staking_info={self.staking_info}\n"
")"
)

Expand Down
6 changes: 6 additions & 0 deletions tests/integration/contract_info_query_e2e_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ def test_integration_contract_info_query_can_execute(env):
assert info.auto_renew_account_id == env.operator_id, "Auto renew account ID mismatch"
assert info.auto_renew_period == auto_renew_period, "Auto renew period mismatch"

# Verify staking_info is populated (contracts default to no staking)
assert info.staking_info is not None, "staking_info should not be None"
assert info.staking_info.staked_account_id is None, "staked_account_id should be None by default"
assert info.staking_info.staked_node_id is None, "staked_node_id should be None by default"
assert info.staking_info.decline_reward is False, "decline_reward should be False by default"


@pytest.mark.integration
def test_integration_contract_info_query_get_cost(env):
Expand Down
1 change: 1 addition & 0 deletions tests/unit/account_info_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,3 +210,4 @@ def test_str_and_repr(account_info):
assert "account_id=AccountId(shard=0, realm=0, num=100" in info_repr
assert "contract_account_id='0.0.100'" in info_repr
assert "account_memo='Test account memo'" in info_repr

182 changes: 182 additions & 0 deletions tests/unit/contract_info_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
from hiero_sdk_python.contract.contract_info import ContractInfo
from hiero_sdk_python.crypto.private_key import PrivateKey
from hiero_sdk_python.Duration import Duration
from hiero_sdk_python.hapi.services.basic_types_pb2 import StakingInfo as StakingInfoProto
from hiero_sdk_python.hapi.services.contract_get_info_pb2 import ContractGetInfoResponse
from hiero_sdk_python.staking_info import StakingInfo
from hiero_sdk_python.timestamp import Timestamp
from hiero_sdk_python.tokens.token_freeze_status import TokenFreezeStatus
from hiero_sdk_python.tokens.token_id import TokenId
Expand Down Expand Up @@ -76,6 +78,10 @@ def contract_info(token_relationship):
ledger_id=b"test_ledger_id",
max_automatic_token_associations=10,
token_relationships=[token_relationship],
staking_info=StakingInfo(
staked_account_id=AccountId(0, 0, 500),
decline_reward=False,
),
)


Expand All @@ -99,6 +105,10 @@ def proto_contract_info(token_relationship):
ledger_id=b"test_ledger_id",
max_automatic_token_associations=10,
tokenRelationships=[token_relationship._to_proto()],
staking_info=StakingInfoProto(
staked_account_id=AccountId(0, 0, 500)._to_proto(),
decline_reward=False,
),
)
return proto

Expand All @@ -120,6 +130,9 @@ def test_contract_info_initialization(contract_info):
assert contract_info.max_automatic_token_associations == 10
assert len(contract_info.token_relationships) == 1
assert contract_info.token_relationships[0].token_id == TokenId(0, 0, 500)
assert contract_info.staking_info.staked_account_id == AccountId(0, 0, 500)
assert contract_info.staking_info.staked_node_id is None
assert contract_info.staking_info.decline_reward is False


def test_contract_info_default_initialization():
Expand All @@ -139,6 +152,7 @@ def test_contract_info_default_initialization():
assert contract_info.ledger_id is None
assert contract_info.max_automatic_token_associations is None
assert not contract_info.token_relationships
assert contract_info.staking_info is None


def test_from_proto(proto_contract_info):
Expand All @@ -160,6 +174,10 @@ def test_from_proto(proto_contract_info):
assert contract_info.max_automatic_token_associations == 10
assert len(contract_info.token_relationships) == 1
assert contract_info.token_relationships[0].token_id == TokenId(0, 0, 500)
assert contract_info.staking_info is not None
assert contract_info.staking_info.staked_account_id == AccountId(0, 0, 500)
assert contract_info.staking_info.staked_node_id is None
assert contract_info.staking_info.decline_reward is False


def test_from_proto_with_empty_token_relationships():
Expand Down Expand Up @@ -272,6 +290,9 @@ def test_proto_conversion_full_object(contract_info):
== contract_info.max_automatic_token_associations
)
assert len(converted.token_relationships) == len(contract_info.token_relationships)
assert converted.staking_info.staked_account_id == contract_info.staking_info.staked_account_id
assert converted.staking_info.staked_node_id == contract_info.staking_info.staked_node_id
assert converted.staking_info.decline_reward == contract_info.staking_info.decline_reward


def test_proto_conversion_multiple_token_relationships(multiple_token_relationships):
Expand Down Expand Up @@ -307,3 +328,164 @@ def test_proto_conversion_minimal_fields():
assert converted.balance == contract_info.balance
assert converted.admin_key is None
assert not converted.token_relationships


def test_from_proto_with_no_staking_info():
"""Test from_proto with no staking info"""
public_key = PrivateKey.generate_ed25519().public_key()
proto = ContractGetInfoResponse.ContractInfo(
contractID=ContractId(0, 0, 200)._to_proto(),
accountID=AccountId(0, 0, 300)._to_proto(),
contractAccountID="0.0.300",
adminKey=public_key._to_proto(),
storage=1024,
balance=5000000,
)

contract_info = ContractInfo._from_proto(proto)

assert contract_info.contract_id == ContractId(0, 0, 200)
assert contract_info.staking_info is None


def test_from_proto_with_staked_node_id():
"""Test from_proto with staked_node_id (staked to node)"""
proto = ContractGetInfoResponse.ContractInfo(
contractID=ContractId(0, 0, 200)._to_proto(),
accountID=AccountId(0, 0, 300)._to_proto(),
storage=1024,
balance=5000000,
staking_info=StakingInfoProto(
staked_node_id=3,
decline_reward=True,
),
)

contract_info = ContractInfo._from_proto(proto)

assert contract_info.staking_info is not None
assert contract_info.staking_info.staked_account_id is None
assert contract_info.staking_info.staked_node_id == 3
assert contract_info.staking_info.decline_reward is True


def test_to_proto_with_staked_account_id():
"""Test to_proto with staked_account_id"""
contract_info = ContractInfo(
contract_id=ContractId(0, 0, 200),
account_id=AccountId(0, 0, 300),
balance=5000000,
staking_info=StakingInfo(
staked_account_id=AccountId(0, 0, 500),
decline_reward=False,
),
)

proto = contract_info._to_proto()

assert proto.HasField('staking_info')
assert proto.staking_info.HasField('staked_account_id')
assert proto.staking_info.staked_account_id == AccountId(0, 0, 500)._to_proto()
assert proto.staking_info.decline_reward is False


def test_to_proto_with_staked_node_id():
"""Test to_proto with staked_node_id"""
contract_info = ContractInfo(
contract_id=ContractId(0, 0, 200),
account_id=AccountId(0, 0, 300),
balance=5000000,
staking_info=StakingInfo(
staked_node_id=5,
decline_reward=True,
),
)

proto = contract_info._to_proto()

assert proto.HasField('staking_info')
assert proto.staking_info.staked_node_id == 5
assert proto.staking_info.decline_reward is True

Comment thread
Mounil2005 marked this conversation as resolved.

def test_proto_conversion_staking_node_round_trip():
"""Test proto conversion round trip with staked_node_id"""
contract_info = ContractInfo(
contract_id=ContractId(0, 0, 200),
account_id=AccountId(0, 0, 300),
balance=5000000,
staking_info=StakingInfo(
staked_node_id=7,
decline_reward=False,
),
)

converted = ContractInfo._from_proto(contract_info._to_proto())

assert converted.contract_id == contract_info.contract_id
assert converted.account_id == contract_info.account_id
assert converted.balance == contract_info.balance
assert converted.staking_info.staked_account_id is None
assert converted.staking_info.staked_node_id == 7
assert isinstance(converted.staking_info.staked_node_id, int)
assert converted.staking_info.decline_reward is False


def test_proto_conversion_staking_account_round_trip():
"""Test proto conversion round trip with staked_account_id"""
contract_info = ContractInfo(
contract_id=ContractId(0, 0, 200),
account_id=AccountId(0, 0, 300),
balance=5000000,
staking_info=StakingInfo(
staked_account_id=AccountId(0, 0, 600),
decline_reward=True,
),
)

converted = ContractInfo._from_proto(contract_info._to_proto())

assert converted.contract_id == contract_info.contract_id
assert converted.account_id == contract_info.account_id
assert converted.balance == contract_info.balance
assert converted.staking_info.staked_account_id == AccountId(0, 0, 600)
assert isinstance(converted.staking_info.staked_account_id, AccountId)
assert converted.staking_info.staked_node_id is None
assert converted.staking_info.decline_reward is True


def test_proto_conversion_with_staked_node_zero():
"""Test proto conversion with staked_node_id set to 0 (valid edge case)"""
contract_info = ContractInfo(
contract_id=ContractId(0, 0, 200),
account_id=AccountId(0, 0, 300),
balance=5000000,
staking_info=StakingInfo(
staked_node_id=0,
decline_reward=True,
),
)

converted = ContractInfo._from_proto(contract_info._to_proto())

assert converted.staking_info is not None
assert converted.staking_info.staked_node_id == 0
assert isinstance(converted.staking_info.staked_node_id, int)
assert converted.staking_info.decline_reward is True


def test_proto_conversion_no_staking_info_round_trip():
"""Test proto conversion round trip with no staking info (None should stay None)"""
contract_info = ContractInfo(
contract_id=ContractId(0, 0, 200),
account_id=AccountId(0, 0, 300),
balance=5000000,
staking_info=None,
)

converted = ContractInfo._from_proto(contract_info._to_proto())

assert converted.contract_id == contract_info.contract_id
assert converted.account_id == contract_info.account_id
assert converted.balance == contract_info.balance
assert converted.staking_info is None