diff --git a/bittensor/core/async_subtensor.py b/bittensor/core/async_subtensor.py index 3207e908e3..cd4439e65f 100644 --- a/bittensor/core/async_subtensor.py +++ b/bittensor/core/async_subtensor.py @@ -96,6 +96,7 @@ claim_root_extrinsic, root_register_extrinsic, set_root_claim_type_extrinsic, + set_validator_claim_type_extrinsic, ) from bittensor.core.extrinsics.asyncex.serving import ( publish_metadata_extrinsic, @@ -3824,7 +3825,9 @@ async def get_root_claim_type( The root claim type controls how dividends from staking to the Root Subnet (subnet 0) are processed when they are claimed: - - `Swap` (default): Alpha dividends are swapped to TAO at claim time and restaked on the root subnet. + - `Delegated` (default): Delegate the choice to the validator. Stakers with this setting will inherit + the validator's claim type (Swap or Keep) for each subnet. + - `Swap`: Alpha dividends are swapped to TAO at claim time and restaked on the root subnet. - `Keep`: Alpha dividends remain as Alpha on the originating subnets. Parameters: @@ -3836,12 +3839,14 @@ async def get_root_claim_type( Returns: - The root claim type as a string, either `Swap` or `Keep`, + The root claim type as a string, either `Swap`, `Keep`, or `Delegated`, or dict for "KeepSubnets" in format {"KeepSubnets": {"subnets": [1, 2, 3]}}. + If not set, returns `"Delegated"` (the default). Notes: - The claim type applies to both automatic and manual root claims; it does not affect the original TAO stake on subnet 0, only how Alpha dividends are treated. + - Stakers with `Delegated` will inherit the validator's claim type for each subnet when claiming. - See: - See also: """ @@ -3853,11 +3858,14 @@ async def get_root_claim_type( block_hash=block_hash, reuse_block_hash=reuse_block, ) - # Query returns enum as dict: {"Swap": ()} or {"Keep": ()} or {"KeepSubnets": {"subnets": [1, 2, 3]}} + + # Query returns enum as dict: + # - {"Swap": ()}, {"Keep": ()}, {"Delegated": ()}, or + # - {"KeepSubnets": {"subnets": [1, 2, 3]}} variant_name = next(iter(query.keys())) variant_value = query[variant_name] - # For simple variants (Swap, Keep), value is empty tuple, return string + # For simple variants (Swap, Keep, Delegated), value is empty tuple, return string. if not variant_value or variant_value == (): return variant_name @@ -4839,6 +4847,61 @@ async def get_unstake_fee( ) return sim_swap_result.alpha_fee.set_unit(netuid=netuid) + async def get_validator_claim_type( + self, + hotkey_ss58: str, + netuid: int, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> Union[str, dict]: + """Retrieves the validator claim type for a given hotkey and netuid. + + This returns the claim type that validators set for their hotkey on a specific subnet. + Stakers with "Delegated" root claim type will inherit this value when claiming root emissions. + + Parameters: + hotkey_ss58: The ss58 address of the hotkey. + netuid: The netuid of the subnet. + block: The block number to query. Do not specify if using block_hash or reuse_block. + block_hash: The block hash at which to check the parameter. Do not set if using block or reuse_block. + reuse_block: Whether to reuse the last-used block hash. Do not set if using block_hash or block. + + Returns: + Union[str, dict]: ValidatorClaimType value. Returns string for "Swap" or "Keep", + or dict for "KeepSubnets" in format {"KeepSubnets": {"subnets": [1, 2, 3]}}. + If not set, returns "Keep" (the default). + """ + block_hash = await self.determine_block_hash(block, block_hash, reuse_block) + query = await self.substrate.query( + module="SubtensorModule", + storage_function="ValidatorClaimType", + params=[hotkey_ss58, netuid], + block_hash=block_hash, + reuse_block_hash=reuse_block, + ) + + # If query returns None or empty, return default "Keep" + if not query: + return "Keep" + + # Query returns enum as dict: {"Swap": ()}, {"Keep": ()}, or {"KeepSubnets": {"subnets": [1, 2, 3]}} + variant_name = next(iter(query.keys())) + variant_value = query[variant_name] + + # For simple variants (Swap, Keep), value is empty tuple, return string + if not variant_value or variant_value == (): + return variant_name + + # For KeepSubnets, value contains the data, return full dict structure + if isinstance(variant_value, dict) and "subnets" in variant_value: + subnets_raw = variant_value["subnets"] + subnets = list(subnets_raw[0]) + + return {variant_name: {"subnets": subnets}} + + return {variant_name: variant_value} + async def get_vote_data( self, proposal_hash: str, @@ -8343,6 +8406,63 @@ async def set_root_claim_type( wait_for_revealed_execution=wait_for_revealed_execution, ) + async def set_validator_claim_type( + self, + wallet: "Wallet", + hotkey_ss58: str, + netuid: int, + new_claim_type: "Literal['Swap', 'Keep'] | RootClaimType | dict", + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = DEFAULT_PERIOD, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, + ) -> ExtrinsicResponse: + """Sets the validator claim type for a hotkey on a specific subnet. + + This allows validators to set a default claim type that will be inherited by stakers + who have set their root claim type to "Delegated" (the default). + + Parameters: + wallet: Bittensor Wallet instance (must own the hotkey). + hotkey_ss58: The SS58 address of the hotkey to set claim type for. + netuid: The netuid of the subnet. + new_claim_type: The new validator claim type. Can be: + - String: "Swap" or "Keep" + - RootClaimType: RootClaimType.Swap, RootClaimType.Keep + - Dict: {"KeepSubnets": {"subnets": [1, 2, 3]}} + - Callable: RootClaimType.KeepSubnets([1, 2, 3]) + Note: "Delegated" is not allowed for validators. + mev_protection: If True, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If False, submits the transaction directly without encryption. + period: The number of blocks during which the transaction will remain valid after it's submitted. If the + transaction is not included in a block within that number of blocks, it will expire and be rejected. You + can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the inclusion of the transaction. + wait_for_finalization: Whether to wait for the finalization of the transaction. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + return await set_validator_claim_type_extrinsic( + subtensor=self, + wallet=wallet, + hotkey_ss58=hotkey_ss58, + netuid=netuid, + new_claim_type=new_claim_type, + mev_protection=mev_protection, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + async def set_subnet_identity( self, wallet: "Wallet", diff --git a/bittensor/core/chain_data/root_claim.py b/bittensor/core/chain_data/root_claim.py index 8e57d84901..4b672a53db 100644 --- a/bittensor/core/chain_data/root_claim.py +++ b/bittensor/core/chain_data/root_claim.py @@ -40,26 +40,28 @@ class RootClaimType(str, Enum): Enumeration of root claim types in the Bittensor network. This enum defines how coldkeys manage their root alpha emissions: - - Swap: Swap any alpha emission for TAO - - Keep: Keep all alpha emission - - KeepSubnets: Keep alpha emission for specified subnets, swap everything else + - Delegated: Delegate the choice to the validator (inherit validator's claim type). + - Keep: Keep all alpha emission. + - KeepSubnets: Keep alpha emission for specified subnets, swap everything else. + - Swap: Swap any alpha emission for TAO. The values match exactly with the RootClaimTypeEnum defined in the Subtensor runtime. """ - Swap = "Swap" + Delegated = "Delegated" Keep = "Keep" KeepSubnets = KeepSubnetsDescriptor + Swap = "Swap" @classmethod def normalize( - cls, value: "Literal['Swap', 'Keep'] | RootClaimType | dict" + cls, value: "Literal['Swap', 'Keep', 'Delegated'] | RootClaimType | dict" ) -> str | dict: """ Normalizes a root claim type to a format suitable for Substrate calls. This method handles various input formats: - - String values ("Swap", "Keep") → returns string + - String values ("Swap", "Keep", "Delegated") → returns string - Enum values (RootClaimType.Swap) → returns string - Dict values ({"KeepSubnets": {"subnets": [1, 2, 3]}}) → returns dict as-is - Callable KeepSubnets([1, 2, 3]) → returns dict @@ -68,7 +70,7 @@ def normalize( value: The root claim type in any supported format. Returns: - Normalized value - string for Swap/Keep or dict for KeepSubnets. + Normalized value - string for Swap/Keep/Delegated or dict for KeepSubnets. Raises: ValueError: If the value is not a valid root claim type or KeepSubnets has no subnets. @@ -90,7 +92,7 @@ def normalize( # Handle string values if isinstance(value, str): - if value in ("Swap", "Keep"): + if value in ("Swap", "Keep", "Delegated"): return value elif value == "KeepSubnets": raise ValueError( @@ -99,7 +101,7 @@ def normalize( else: raise ValueError( f"Invalid root claim type: {value}. " - f"Valid types are: 'Swap', 'Keep', or KeepSubnets dict/callable" + f"Valid types are: 'Swap', 'Keep', 'Delegated', or KeepSubnets dict/callable" ) # Handle dict values (for KeepSubnets) diff --git a/bittensor/core/extrinsics/asyncex/root.py b/bittensor/core/extrinsics/asyncex/root.py index 782569e983..ea83f7a9ca 100644 --- a/bittensor/core/extrinsics/asyncex/root.py +++ b/bittensor/core/extrinsics/asyncex/root.py @@ -169,7 +169,7 @@ async def root_register_extrinsic( async def set_root_claim_type_extrinsic( subtensor: "AsyncSubtensor", wallet: "Wallet", - new_root_claim_type: "Literal['Swap', 'Keep'] | RootClaimType | dict", + new_root_claim_type: "Literal['Swap', 'Keep', 'Delegated'] | RootClaimType | dict", *, mev_protection: bool = DEFAULT_MEV_PROTECTION, period: Optional[int] = None, @@ -184,8 +184,8 @@ async def set_root_claim_type_extrinsic( subtensor: Subtensor instance to interact with the blockchain. wallet: Bittensor Wallet instance. new_root_claim_type: The new root claim type to set. Can be: - - String: "Swap" or "Keep" - - RootClaimType: RootClaimType.Swap, RootClaimType.Keep + - String: "Swap", "Keep", "Delegated" + - RootClaimType: RootClaimType.Swap, RootClaimType.Keep, RootClaimType.Delegated - Dict: {"KeepSubnets": {"subnets": [1, 2, 3]}} - Callable: RootClaimType.KeepSubnets([1, 2, 3]) mev_protection: If True, encrypts and submits the transaction through the MEV Shield pallet to protect @@ -302,3 +302,95 @@ async def claim_root_extrinsic( except Exception as error: return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + + +async def set_validator_claim_type_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + hotkey_ss58: str, + netuid: int, + new_claim_type: "Literal['Swap', 'Keep'] | RootClaimType | dict", + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, +) -> ExtrinsicResponse: + """Sets the validator claim type for a hotkey on a specific subnet. + + This allows validators to set a default claim type that will be inherited by stakers who have set their root claim + type to "Delegated" (the default). + + Parameters: + subtensor: AsyncSubtensor instance to interact with the blockchain. + wallet: Bittensor Wallet instance (must own the hotkey). + hotkey_ss58: The SS58 address of the hotkey to set claim type for. + netuid: The netuid of the subnet. + new_claim_type: The new validator claim type. Can be: + - String: "Swap" or "Keep" + - RootClaimType: RootClaimType.Swap, RootClaimType.Keep + - Dict: {"KeepSubnets": {"subnets": [1, 2, 3]}} + - Callable: RootClaimType.KeepSubnets([1, 2, 3]) + Note: "Delegated" is not allowed for validators. + mev_protection: If True, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If False, submits the transaction directly without encryption. + period: The number of blocks during which the transaction will remain valid after it's submitted. If the + transaction is not included in a block within that number of blocks, it will expire and be rejected. You can + think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the inclusion of the transaction. + wait_for_finalization: Whether to wait for the finalization of the transaction. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Raises: + ValueError: If new_claim_type is "Delegated" (not allowed for validators). + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + normalized_type = RootClaimType.normalize(new_claim_type) + + # Validators cannot set Delegated claim type + if normalized_type == "Delegated": + raise ValueError( + "Delegated claim type cannot be set for validators. Validators must use Swap, Keep, or KeepSubnets." + ) + + call = await SubtensorModule(subtensor).set_validator_claim_type( + hotkey=hotkey_ss58, + netuid=netuid, + new_claim_type=normalized_type, + ) + + if mev_protection: + return await submit_encrypted_extrinsic( + subtensor=subtensor, + wallet=wallet, + call=call, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + else: + return await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) diff --git a/bittensor/core/extrinsics/pallets/subtensor_module.py b/bittensor/core/extrinsics/pallets/subtensor_module.py index 8a3344be55..6d3ff421be 100644 --- a/bittensor/core/extrinsics/pallets/subtensor_module.py +++ b/bittensor/core/extrinsics/pallets/subtensor_module.py @@ -565,6 +565,31 @@ def set_subnet_identity( additional=additional, ) + def set_validator_claim_type( + self, + hotkey: str, + netuid: int, + new_claim_type: Literal["Swap", "Keep"] | dict, + ) -> Call: + """Returns GenericCall instance for Subtensor function SubtensorModule.set_validator_claim_type. + + Parameters: + hotkey: The hotkey SS58 address associated with the validator. + netuid: The netuid of the subnet. + new_claim_type: The new validator claim type. Can be: + - String: "Swap" or "Keep" + - Dict: {"KeepSubnets": {"subnets": [1, 2, 3]}} + Note: "Delegated" is not allowed for validators. + + Returns: + GenericCall instance. + """ + return self.create_composed_call( + hotkey=hotkey, + netuid=netuid, + new_claim_type=new_claim_type, + ) + def start_call(self, netuid: int) -> Call: """Returns GenericCall instance for Subtensor function SubtensorModule.start_call. diff --git a/bittensor/core/extrinsics/root.py b/bittensor/core/extrinsics/root.py index 996136b23f..859616f6c9 100644 --- a/bittensor/core/extrinsics/root.py +++ b/bittensor/core/extrinsics/root.py @@ -1,11 +1,11 @@ import time -from typing import Literal, Optional, TYPE_CHECKING +from typing import TYPE_CHECKING, Literal, Optional from bittensor.core.chain_data import RootClaimType from bittensor.core.extrinsics.mev_shield import submit_encrypted_extrinsic from bittensor.core.extrinsics.pallets import SubtensorModule from bittensor.core.settings import DEFAULT_MEV_PROTECTION -from bittensor.core.types import ExtrinsicResponse, UIDs +from bittensor.core.types import ExtrinsicResponse from bittensor.utils import u16_normalized_float from bittensor.utils.balance import Balance from bittensor.utils.btlogging import logging @@ -13,6 +13,7 @@ if TYPE_CHECKING: from bittensor_wallet import Wallet from bittensor.core.subtensor import Subtensor + from bittensor.core.types import UIDs def _get_limits(subtensor: "Subtensor") -> tuple[int, float]: @@ -164,7 +165,7 @@ def root_register_extrinsic( def set_root_claim_type_extrinsic( subtensor: "Subtensor", wallet: "Wallet", - new_root_claim_type: "Literal['Swap', 'Keep'] | RootClaimType | dict", + new_root_claim_type: "Literal['Swap', 'Keep', 'Delegated'] | RootClaimType | dict", *, mev_protection: bool = DEFAULT_MEV_PROTECTION, period: Optional[int] = None, @@ -179,8 +180,8 @@ def set_root_claim_type_extrinsic( subtensor: Subtensor instance to interact with the blockchain. wallet: Bittensor Wallet instance. new_root_claim_type: The new root claim type to set. Can be: - - String: "Swap" or "Keep" - - RootClaimType: RootClaimType.Swap, RootClaimType.Keep + - String: "Swap", "Keep", "Delegated" + - RootClaimType: RootClaimType.Swap, RootClaimType.Keep, RootClaimType.Delegated - Dict: {"KeepSubnets": {"subnets": [1, 2, 3]}} - Callable: RootClaimType.KeepSubnets([1, 2, 3]) mev_protection: If True, encrypts and submits the transaction through the MEV Shield pallet to protect @@ -297,3 +298,95 @@ def claim_root_extrinsic( except Exception as error: return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + + +def set_validator_claim_type_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + hotkey_ss58: str, + netuid: int, + new_claim_type: "Literal['Swap', 'Keep'] | RootClaimType | dict", + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, +) -> ExtrinsicResponse: + """Sets the validator claim type for a hotkey on a specific subnet. + + This allows validators to set a default claim type that will be inherited by stakers who have set their root claim + type to "Delegated" (the default). + + Parameters: + subtensor: Subtensor instance to interact with the blockchain. + wallet: Bittensor Wallet instance (must own the hotkey). + hotkey_ss58: The SS58 address of the hotkey to set claim type for. + netuid: The netuid of the subnet. + new_claim_type: The new validator claim type. Can be: + - String: "Swap" or "Keep" + - RootClaimType: RootClaimType.Swap, RootClaimType.Keep + - Dict: {"KeepSubnets": {"subnets": [1, 2, 3]}} + - Callable: RootClaimType.KeepSubnets([1, 2, 3]) + Note: "Delegated" is not allowed for validators. + mev_protection: If True, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If False, submits the transaction directly without encryption. + period: The number of blocks during which the transaction will remain valid after it's submitted. If the + transaction is not included in a block within that number of blocks, it will expire and be rejected. You can + think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the inclusion of the transaction. + wait_for_finalization: Whether to wait for the finalization of the transaction. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + + Raises: + ValueError: If new_claim_type is "Delegated" (not allowed for validators). + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + normalized_type = RootClaimType.normalize(new_claim_type) + + # Validators cannot set Delegated claim type + if normalized_type == "Delegated": + raise ValueError( + "Delegated claim type cannot be set for validators. Validators must use Swap, Keep, or KeepSubnets." + ) + + call = SubtensorModule(subtensor).set_validator_claim_type( + hotkey=hotkey_ss58, + netuid=netuid, + new_claim_type=normalized_type, + ) + + if mev_protection: + return submit_encrypted_extrinsic( + subtensor=subtensor, + wallet=wallet, + call=call, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + else: + return subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) diff --git a/bittensor/core/subtensor.py b/bittensor/core/subtensor.py index 804d160bce..3701f9e8fa 100644 --- a/bittensor/core/subtensor.py +++ b/bittensor/core/subtensor.py @@ -96,6 +96,7 @@ claim_root_extrinsic, root_register_extrinsic, set_root_claim_type_extrinsic, + set_validator_claim_type_extrinsic, ) from bittensor.core.extrinsics.serving import ( publish_metadata_extrinsic, @@ -3175,7 +3176,9 @@ def get_root_claim_type( The root claim type controls how dividends from staking to the Root Subnet (subnet 0) are processed when they are claimed: - - `Swap` (default): Alpha dividends are swapped to TAO at claim time and restaked on the root subnet. + - `Delegated` (default): Delegate the choice to the validator. Stakers with this setting will inherit + the validator's claim type (Swap or Keep) for each subnet. + - `Swap`: Alpha dividends are swapped to TAO at claim time and restaked on the root subnet. - `Keep`: Alpha dividends remain as Alpha on the originating subnets. Parameters: @@ -3184,12 +3187,14 @@ def get_root_claim_type( Returns: - The root claim type as a string, either `Swap` or `Keep`, + The root claim type as a string, either `Swap`, `Keep`, or `Delegated`, or dict for "KeepSubnets" in format {"KeepSubnets": {"subnets": [1, 2, 3]}}. + If not set, returns `"Delegated"` (the default). Notes: - The claim type applies to both automatic and manual root claims; it does not affect the original TAO stake on subnet 0, only how Alpha dividends are treated. + - Stakers with `Delegated` will inherit the validator's claim type for each subnet when claiming. - See: - See also: """ @@ -3199,11 +3204,14 @@ def get_root_claim_type( params=[coldkey_ss58], block_hash=self.determine_block_hash(block), ) - # Query returns enum as dict: {"Swap": ()} or {"Keep": ()} or {"KeepSubnets": {"subnets": [1, 2, 3]}} + + # Query returns enum as dict: + # - {"Swap": ()}, {"Keep": ()}, {"Delegated": ()}, or + # - {"KeepSubnets": {"subnets": [1, 2, 3]}} variant_name = next(iter(query.keys())) variant_value = query[variant_name] - # For simple variants (Swap, Keep), value is empty tuple, return string + # For simple variants (Swap, Keep, Delegated), value is empty tuple, return string. if not variant_value or variant_value == (): return variant_name @@ -3978,6 +3986,55 @@ def get_unstake_fee( ) return sim_swap_result.alpha_fee.set_unit(netuid=netuid) + def get_validator_claim_type( + self, + hotkey_ss58: str, + netuid: int, + block: Optional[int] = None, + ) -> Union[str, dict]: + """Retrieves the validator claim type for a given hotkey and netuid. + + This returns the claim type that validators set for their hotkey on a specific subnet. + Stakers with "Delegated" root claim type will inherit this value when claiming root emissions. + + Parameters: + hotkey_ss58: The ss58 address of the hotkey. + netuid: The netuid of the subnet. + block: The block number to query. + + Returns: + Union[str, dict]: ValidatorClaimType value. Returns string for "Swap" or "Keep", + or dict for "KeepSubnets" in format {"KeepSubnets": {"subnets": [1, 2, 3]}}. + If not set, returns "Keep" (the default). + """ + query = self.substrate.query( + module="SubtensorModule", + storage_function="ValidatorClaimType", + params=[hotkey_ss58, netuid], + block_hash=self.determine_block_hash(block), + ) + + # If query returns None or empty, return default "Keep" + if not query: + return "Keep" + + # Query returns enum as dict: {"Swap": ()}, {"Keep": ()}, or {"KeepSubnets": {"subnets": [1, 2, 3]}} + variant_name = next(iter(query.keys())) + variant_value = query[variant_name] + + # For simple variants (Swap, Keep), value is empty tuple, return string + if not variant_value or variant_value == (): + return variant_name + + # For KeepSubnets, value contains the data, return full dict structure + if isinstance(variant_value, dict) and "subnets" in variant_value: + subnets_raw = variant_value["subnets"] + subnets = list(subnets_raw[0]) + + return {variant_name: {"subnets": subnets}} + + return {variant_name: variant_value} + def get_vote_data( self, proposal_hash: str, block: Optional[int] = None ) -> Optional["ProposalVoteData"]: @@ -7166,6 +7223,63 @@ def set_root_claim_type( wait_for_revealed_execution=wait_for_revealed_execution, ) + def set_validator_claim_type( + self, + wallet: "Wallet", + hotkey_ss58: str, + netuid: int, + new_claim_type: "Literal['Swap', 'Keep'] | RootClaimType | dict", + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = DEFAULT_PERIOD, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, + ) -> ExtrinsicResponse: + """Sets the validator claim type for a hotkey on a specific subnet. + + This allows validators to set a default claim type that will be inherited by stakers + who have set their root claim type to "Delegated" (the default). + + Parameters: + wallet: Bittensor Wallet instance (must own the hotkey). + hotkey_ss58: The SS58 address of the hotkey to set claim type for. + netuid: The netuid of the subnet. + new_claim_type: The new validator claim type. Can be: + - String: "Swap" or "Keep" + - RootClaimType: RootClaimType.Swap, RootClaimType.Keep + - Dict: {"KeepSubnets": {"subnets": [1, 2, 3]}} + - Callable: RootClaimType.KeepSubnets([1, 2, 3]) + Note: "Delegated" is not allowed for validators. + mev_protection: If True, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If False, submits the transaction directly without encryption. + period: The number of blocks during which the transaction will remain valid after it's submitted. If the + transaction is not included in a block within that number of blocks, it will expire and be rejected. You + can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Whether to wait for the inclusion of the transaction. + wait_for_finalization: Whether to wait for the finalization of the transaction. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + return set_validator_claim_type_extrinsic( + subtensor=self, + wallet=wallet, + hotkey_ss58=hotkey_ss58, + netuid=netuid, + new_claim_type=new_claim_type, + mev_protection=mev_protection, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + def set_subnet_identity( self, wallet: "Wallet", diff --git a/bittensor/extras/subtensor_api/staking.py b/bittensor/extras/subtensor_api/staking.py index 16d7b6cf5a..b453196f14 100644 --- a/bittensor/extras/subtensor_api/staking.py +++ b/bittensor/extras/subtensor_api/staking.py @@ -31,9 +31,11 @@ def __init__(self, subtensor: Union["_Subtensor", "_AsyncSubtensor"]): self.get_stake_movement_fee = subtensor.get_stake_movement_fee self.get_stake_weight = subtensor.get_stake_weight self.get_unstake_fee = subtensor.get_unstake_fee + self.get_validator_claim_type = subtensor.get_validator_claim_type self.move_stake = subtensor.move_stake self.set_auto_stake = subtensor.set_auto_stake self.set_root_claim_type = subtensor.set_root_claim_type + self.set_validator_claim_type = subtensor.set_validator_claim_type self.sim_swap = subtensor.sim_swap self.swap_stake = subtensor.swap_stake self.transfer_stake = subtensor.transfer_stake diff --git a/tests/e2e_tests/test_root_claim.py b/tests/e2e_tests/test_root_claim.py index 08a928ce02..d2ef091efb 100644 --- a/tests/e2e_tests/test_root_claim.py +++ b/tests/e2e_tests/test_root_claim.py @@ -1683,3 +1683,404 @@ async def test_root_claim_keep_subnets_validation_and_formats_async( assert isinstance(root_claim_type, dict) assert "KeepSubnets" in root_claim_type assert sn2.netuid in root_claim_type["KeepSubnets"]["subnets"] + + +def test_validator_claim_type_set_and_get( + subtensor, alice_wallet, bob_wallet, charlie_wallet +): + """Tests validator claim type set and get functionality, and Delegated inheritance. + + Steps: + - Activate ROOT net to stake on Alice + - Register SN2 and the same validator (Alice) on that subnet to ROOT has an emissions + - Set validator claim type = Keep for Alice on SN2 + - Verify that get_validator_claim_type(alice_hotkey, sn2_netuid) returns "Keep" + - Set validator claim type = Swap for Alice on SN2 + - Verify that get_validator_claim_type returns "Swap" + - Verify default: for a new validator (Bob) on SN2 should return "Keep" + - Set validator claim type back to Keep for Alice + - Verify Delegated inheritance: Charlie with default Delegated claim type inherits Keep from validator Alice when + claiming root emissions (alpha stays on SN2, not swapped to TAO) + """ + TEMPO_TO_SET = 20 if subtensor.chain.is_fast_blocks() else 10 + + # Activate ROOT net to stake on Alice + root_sn = TestSubnet(subtensor, 0) + root_sn.execute_steps( + [ + SUDO_SET_ADMIN_FREEZE_WINDOW(alice_wallet, AdminUtils, True, 0), + SUDO_SET_TEMPO(alice_wallet, AdminUtils, True, NETUID, TEMPO_TO_SET), + ACTIVATE_SUBNET(alice_wallet), + ] + ) + + # Register SN2 and the same validator (Alice) on that subnet to ROOT has an emissions + sn2 = TestSubnet(subtensor) + sn2.execute_steps( + [ + SUDO_SET_ADMIN_FREEZE_WINDOW(alice_wallet, AdminUtils, True, 0), + REGISTER_SUBNET(bob_wallet), + SUDO_SET_TEMPO(alice_wallet, AdminUtils, True, NETUID, TEMPO_TO_SET), + ACTIVATE_SUBNET(bob_wallet), + REGISTER_NEURON(alice_wallet), + REGISTER_NEURON(charlie_wallet), + ] + ) + + # Test 1: Set validator claim type = Keep for Alice on SN2 + # Verify that get_validator_claim_type returns "Keep" + assert subtensor.staking.set_validator_claim_type( + wallet=alice_wallet, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + netuid=sn2.netuid, + new_claim_type=RootClaimType.Keep, + ).success + + # Verify that get_validator_claim_type returns "Keep" + validator_claim_type = subtensor.staking.get_validator_claim_type( + hotkey_ss58=alice_wallet.hotkey.ss58_address, + netuid=sn2.netuid, + ) + assert validator_claim_type == "Keep", ( + f"Expected 'Keep', got {validator_claim_type}" + ) + + # Test 2: Set validator claim type = Swap for Alice on SN2 + assert subtensor.staking.set_validator_claim_type( + wallet=alice_wallet, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + netuid=sn2.netuid, + new_claim_type=RootClaimType.Swap, + ).success + + # Verify that get_validator_claim_type returns "Swap" + validator_claim_type = subtensor.staking.get_validator_claim_type( + hotkey_ss58=alice_wallet.hotkey.ss58_address, + netuid=sn2.netuid, + ) + assert validator_claim_type == "Swap", ( + f"Expected 'Swap', got {validator_claim_type}" + ) + + # Bob hasn't set validator claim type, so should return default "Keep" + bob_validator_claim_type = subtensor.staking.get_validator_claim_type( + hotkey_ss58=bob_wallet.hotkey.ss58_address, + netuid=sn2.netuid, + ) + assert bob_validator_claim_type == "Keep", ( + f"Expected default 'Keep' for new validator, got {bob_validator_claim_type}" + ) + + # Test 4: Set validator claim type back to Keep for Alice + assert subtensor.staking.set_validator_claim_type( + wallet=alice_wallet, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + netuid=sn2.netuid, + new_claim_type=RootClaimType.Keep, + ).success + + # Verify it's back to Keep + validator_claim_type = subtensor.staking.get_validator_claim_type( + hotkey_ss58=alice_wallet.hotkey.ss58_address, + netuid=sn2.netuid, + ) + assert validator_claim_type == "Keep", ( + f"Expected 'Keep', got {validator_claim_type}" + ) + + # Test 5: Verify Delegated staker inherits validator claim type + # Charlie has default Delegated claim type (not explicitly set) + charlie_default_claim_type = subtensor.staking.get_root_claim_type( + coldkey_ss58=charlie_wallet.coldkey.ss58_address + ) + assert charlie_default_claim_type == "Delegated", ( + f"New staker should have default 'Delegated', got {charlie_default_claim_type}" + ) + + # Setup EMA for root emissions (must be before staking) + assert increase_subnet_ema(subtensor=subtensor, sudo_wallet=alice_wallet) + + # Set NumRootClaim to 0 to avoid auto claims (for controlled testing) + # Must be set BEFORE staking + root_sn.execute_one( + SUDO_SET_NUM_ROOT_CLAIMS(alice_wallet, "SubtensorModule", True, 0) + ) + + stake_balance = Balance.from_tao(100) + + # Stake from Charlie to Alice in ROOT + response = subtensor.staking.add_stake( + wallet=charlie_wallet, + netuid=root_sn.netuid, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + amount=stake_balance, + ) + assert response.success, response.message + + # Skip the epoch in which the stake was installed, since the emission doesn't occur + logging.console.info("Skipping stake epoch") + next_epoch_start_block = subtensor.subnets.get_next_epoch_start_block( + netuid=root_sn.netuid + ) + subtensor.wait_for_block(block=next_epoch_start_block) + + # Wait for multiple epochs to accumulate root alpha dividends + logging.console.info("Waiting for epochs to accumulate root alpha") + proof_counter = PROOF_COUNTER + while proof_counter > 0: + next_epoch_start_block = subtensor.subnets.get_next_epoch_start_block( + netuid=root_sn.netuid + ) + subtensor.wait_for_block(block=next_epoch_start_block) + proof_counter -= 1 + + # Check that Charlie has claimable stake + claimable_stake_charlie = subtensor.staking.get_root_claimable_stake( + coldkey_ss58=charlie_wallet.coldkey.ss58_address, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + netuid=sn2.netuid, + ) + assert claimable_stake_charlie > Balance.from_tao(0).set_unit(sn2.netuid), ( + f"Charlie should have claimable root alpha, got {claimable_stake_charlie}" + ) + + # Get stake on SN2 before claim (should be 0) + stake_before_claim = subtensor.staking.get_stake( + coldkey_ss58=charlie_wallet.coldkey.ss58_address, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + netuid=sn2.netuid, + ) + assert stake_before_claim == Balance.from_tao(0).set_unit(sn2.netuid) + + # Manual claim_root - Charlie with Delegated should inherit Keep from validator + response = subtensor.staking.claim_root(wallet=charlie_wallet, netuids=[sn2.netuid]) + assert response.success, response.message + + # Verify that stake increased on SN2 (Keep behavior - alpha stays on subnet) + stake_after_claim = subtensor.staking.get_stake( + coldkey_ss58=charlie_wallet.coldkey.ss58_address, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + netuid=sn2.netuid, + ) + assert stake_after_claim > stake_before_claim, ( + f"Stake should increase on SN2 with Keep behavior. " + f"Before: {stake_before_claim}, After: {stake_after_claim}" + ) + + # Verify root claimed was updated + root_claimed = subtensor.staking.get_root_claimed( + coldkey_ss58=charlie_wallet.coldkey.ss58_address, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + netuid=sn2.netuid, + ) + assert root_claimed >= claimable_stake_charlie, ( + f"Root claimed should be at least equal to claimable stake. " + f"Claimed: {root_claimed}, Claimable: {claimable_stake_charlie}" + ) + + +@pytest.mark.asyncio +async def test_validator_claim_type_set_and_get_async( + async_subtensor, alice_wallet, bob_wallet, charlie_wallet +): + """Tests validator claim type set and get functionality, and Delegated inheritance. + + Steps: + - Activate ROOT net to stake on Alice + - Register SN2 and the same validator (Alice) on that subnet to ROOT has an emissions + - Set validator claim type = Keep for Alice on SN2 + - Verify that get_validator_claim_type(alice_hotkey, sn2_netuid) returns "Keep" + - Set validator claim type = Swap for Alice on SN2 + - Verify that get_validator_claim_type returns "Swap" + - Verify default: for a new validator (Bob) on SN2 should return "Keep" + - Set validator claim type back to Keep for Alice + - Verify Delegated inheritance: Charlie with default Delegated claim type inherits Keep from validator Alice when + claiming root emissions (alpha stays on SN2, not swapped to TAO) + """ + TEMPO_TO_SET = 20 if await async_subtensor.chain.is_fast_blocks() else 10 + + # Activate ROOT net to stake on Alice + root_sn = TestSubnet(async_subtensor, 0) + await root_sn.async_execute_steps( + [ + SUDO_SET_ADMIN_FREEZE_WINDOW(alice_wallet, AdminUtils, True, 0), + SUDO_SET_TEMPO(alice_wallet, AdminUtils, True, NETUID, TEMPO_TO_SET), + ACTIVATE_SUBNET(alice_wallet), + ] + ) + + # Register SN2 and the same validator (Alice) on that subnet to ROOT has an emissions + sn2 = TestSubnet(async_subtensor) + await sn2.async_execute_steps( + [ + SUDO_SET_ADMIN_FREEZE_WINDOW(alice_wallet, AdminUtils, True, 0), + REGISTER_SUBNET(bob_wallet), + SUDO_SET_TEMPO(alice_wallet, AdminUtils, True, NETUID, TEMPO_TO_SET), + ACTIVATE_SUBNET(bob_wallet), + REGISTER_NEURON(alice_wallet), + REGISTER_NEURON(charlie_wallet), + ] + ) + + # Test 1: Set validator claim type = Keep for Alice on SN2 + # Verify that get_validator_claim_type returns "Keep" + assert ( + await async_subtensor.staking.set_validator_claim_type( + wallet=alice_wallet, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + netuid=sn2.netuid, + new_claim_type=RootClaimType.Keep, + ) + ).success + + # Verify that get_validator_claim_type returns "Keep" + validator_claim_type = await async_subtensor.staking.get_validator_claim_type( + hotkey_ss58=alice_wallet.hotkey.ss58_address, + netuid=sn2.netuid, + ) + assert validator_claim_type == "Keep", ( + f"Expected 'Keep', got {validator_claim_type}" + ) + + # Test 2: Set validator claim type = Swap for Alice on SN2 + assert ( + await async_subtensor.staking.set_validator_claim_type( + wallet=alice_wallet, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + netuid=sn2.netuid, + new_claim_type=RootClaimType.Swap, + ) + ).success + + # Verify that get_validator_claim_type returns "Swap" + validator_claim_type = await async_subtensor.staking.get_validator_claim_type( + hotkey_ss58=alice_wallet.hotkey.ss58_address, + netuid=sn2.netuid, + ) + assert validator_claim_type == "Swap", ( + f"Expected 'Swap', got {validator_claim_type}" + ) + + # Bob hasn't set validator claim type, so should return default "Keep" + bob_validator_claim_type = await async_subtensor.staking.get_validator_claim_type( + hotkey_ss58=bob_wallet.hotkey.ss58_address, + netuid=sn2.netuid, + ) + assert bob_validator_claim_type == "Keep", ( + f"Expected default 'Keep' for new validator, got {bob_validator_claim_type}" + ) + + # Test 4: Set validator claim type back to Keep for Alice + assert ( + await async_subtensor.staking.set_validator_claim_type( + wallet=alice_wallet, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + netuid=sn2.netuid, + new_claim_type=RootClaimType.Keep, + ) + ).success + + # Verify it's back to Keep + validator_claim_type = await async_subtensor.staking.get_validator_claim_type( + hotkey_ss58=alice_wallet.hotkey.ss58_address, + netuid=sn2.netuid, + ) + assert validator_claim_type == "Keep", ( + f"Expected 'Keep', got {validator_claim_type}" + ) + + # Test 5: Verify Delegated staker inherits validator claim type + # Charlie has default Delegated claim type (not explicitly set) + charlie_default_claim_type = await async_subtensor.staking.get_root_claim_type( + coldkey_ss58=charlie_wallet.coldkey.ss58_address + ) + assert charlie_default_claim_type == "Delegated", ( + f"New staker should have default 'Delegated', got {charlie_default_claim_type}" + ) + + # Setup EMA for root emissions (must be before staking) + assert await async_increase_subnet_ema( + subtensor=async_subtensor, sudo_wallet=alice_wallet + ) + + # Set NumRootClaim to 0 to avoid auto claims (for controlled testing) + # Must be set BEFORE staking + await root_sn.async_execute_one( + SUDO_SET_NUM_ROOT_CLAIMS(alice_wallet, "SubtensorModule", True, 0) + ) + + stake_balance = Balance.from_tao(100) + + # Stake from Charlie to Alice in ROOT + response = await async_subtensor.staking.add_stake( + wallet=charlie_wallet, + netuid=root_sn.netuid, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + amount=stake_balance, + ) + assert response.success, response.message + + # Skip the epoch in which the stake was installed, since the emission doesn't occur + logging.console.info("Skipping stake epoch") + next_epoch_start_block = await async_subtensor.subnets.get_next_epoch_start_block( + netuid=root_sn.netuid + ) + await async_subtensor.wait_for_block(block=next_epoch_start_block) + + # Wait for multiple epochs to accumulate root alpha dividends + logging.console.info("Waiting for epochs to accumulate root alpha") + proof_counter = PROOF_COUNTER + while proof_counter > 0: + next_epoch_start_block = ( + await async_subtensor.subnets.get_next_epoch_start_block( + netuid=root_sn.netuid + ) + ) + await async_subtensor.wait_for_block(block=next_epoch_start_block) + proof_counter -= 1 + + # Check that Charlie has claimable stake + claimable_stake_charlie = await async_subtensor.staking.get_root_claimable_stake( + coldkey_ss58=charlie_wallet.coldkey.ss58_address, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + netuid=sn2.netuid, + ) + assert claimable_stake_charlie > Balance.from_tao(0).set_unit(sn2.netuid), ( + f"Charlie should have claimable root alpha, got {claimable_stake_charlie}" + ) + + # Get stake on SN2 before claim (should be 0) + stake_before_claim = await async_subtensor.staking.get_stake( + coldkey_ss58=charlie_wallet.coldkey.ss58_address, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + netuid=sn2.netuid, + ) + assert stake_before_claim == Balance.from_tao(0).set_unit(sn2.netuid) + + # Manual claim_root - Charlie with Delegated should inherit Keep from validator + response = await async_subtensor.staking.claim_root( + wallet=charlie_wallet, netuids=[sn2.netuid] + ) + assert response.success, response.message + + # Verify that stake increased on SN2 (Keep behavior - alpha stays on subnet) + stake_after_claim = await async_subtensor.staking.get_stake( + coldkey_ss58=charlie_wallet.coldkey.ss58_address, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + netuid=sn2.netuid, + ) + assert stake_after_claim > stake_before_claim, ( + f"Stake should increase on SN2 with Keep behavior. " + f"Before: {stake_before_claim}, After: {stake_after_claim}" + ) + + # Verify root claimed was updated + root_claimed = await async_subtensor.staking.get_root_claimed( + coldkey_ss58=charlie_wallet.coldkey.ss58_address, + hotkey_ss58=alice_wallet.hotkey.ss58_address, + netuid=sn2.netuid, + ) + assert root_claimed >= claimable_stake_charlie, ( + f"Root claimed should be at least equal to claimable stake. " + f"Claimed: {root_claimed}, Claimable: {claimable_stake_charlie}" + ) diff --git a/tests/unit_tests/extrinsics/asyncex/test_root.py b/tests/unit_tests/extrinsics/asyncex/test_root.py index 87b95f0776..852a033d0c 100644 --- a/tests/unit_tests/extrinsics/asyncex/test_root.py +++ b/tests/unit_tests/extrinsics/asyncex/test_root.py @@ -519,3 +519,240 @@ async def test_claim_root_extrinsic(subtensor, fake_wallet, mocker): wait_for_finalization=True, ) assert response == mocked_sign_and_send_extrinsic.return_value + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "new_claim_type, expected_normalized, hotkey_ss58, netuid", + [ + ("Swap", "Swap", "fake_hotkey_address", 1), + ("Keep", "Keep", "fake_hotkey_address", 2), + (RootClaimType.Swap, "Swap", "fake_hotkey_address", 1), + (RootClaimType.Keep, "Keep", "fake_hotkey_address", 2), + ( + {"KeepSubnets": {"subnets": [1, 2, 3]}}, + {"KeepSubnets": {"subnets": [1, 2, 3]}}, + "fake_hotkey_address", + 1, + ), + ( + RootClaimType.KeepSubnets([1, 2, 3]), + {"KeepSubnets": {"subnets": [1, 2, 3]}}, + "fake_hotkey_address", + 2, + ), + ], + ids=[ + "string-swap", + "string-keep", + "enum-swap", + "enum-keep", + "dict-keep-subnets", + "callable-keep-subnets", + ], +) +async def test_set_validator_claim_type_extrinsic( + subtensor, + fake_wallet, + mocker, + new_claim_type, + expected_normalized, + hotkey_ss58, + netuid, +): + """Tests `set_validator_claim_type_extrinsic` extrinsic function with various input formats.""" + # Preps + mocked_normalize = mocker.patch.object( + RootClaimType, "normalize", return_value=expected_normalized + ) + mocked_pallet_compose_call = mocker.patch.object( + async_root.SubtensorModule, "set_validator_claim_type", new=mocker.AsyncMock() + ) + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, "sign_and_send_extrinsic" + ) + + # call + response = await async_root.set_validator_claim_type_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + hotkey_ss58=hotkey_ss58, + netuid=netuid, + new_claim_type=new_claim_type, + ) + + # asserts + mocked_normalize.assert_called_once_with(new_claim_type) + mocked_pallet_compose_call.assert_awaited_once_with( + hotkey=hotkey_ss58, + netuid=netuid, + new_claim_type=expected_normalized, + ) + mocked_sign_and_send_extrinsic.assert_awaited_once_with( + call=mocked_pallet_compose_call.return_value, + wallet=fake_wallet, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert response == mocked_sign_and_send_extrinsic.return_value + + +@pytest.mark.asyncio +async def test_set_validator_claim_type_extrinsic_delegated_not_allowed( + subtensor, fake_wallet, mocker +): + """Tests that `set_validator_claim_type_extrinsic` raises ValueError for Delegated claim type.""" + # Preps + mocked_normalize = mocker.patch.object( + RootClaimType, "normalize", return_value="Delegated" + ) + mocked_pallet_compose_call = mocker.patch.object( + async_root.SubtensorModule, "set_validator_claim_type", new=mocker.AsyncMock() + ) + + # call and assert + with pytest.raises( + ValueError, match="Delegated claim type cannot be set for validators" + ): + await async_root.set_validator_claim_type_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + hotkey_ss58="fake_hotkey_address", + netuid=1, + new_claim_type="Delegated", + raise_error=True, + ) + + mocked_normalize.assert_called_once_with("Delegated") + mocked_pallet_compose_call.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_set_validator_claim_type_extrinsic_delegated_not_allowed_no_raise( + subtensor, fake_wallet, mocker +): + """Tests that `set_validator_claim_type_extrinsic` returns error response for Delegated claim type when raise_error=False.""" + # Preps + mocked_normalize = mocker.patch.object( + RootClaimType, "normalize", return_value="Delegated" + ) + mocked_pallet_compose_call = mocker.patch.object( + async_root.SubtensorModule, "set_validator_claim_type", new=mocker.AsyncMock() + ) + mocked_from_exception = mocker.patch.object(ExtrinsicResponse, "from_exception") + + # call + response = await async_root.set_validator_claim_type_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + hotkey_ss58="fake_hotkey_address", + netuid=1, + new_claim_type="Delegated", + raise_error=False, + ) + + # assert + mocked_normalize.assert_called_once_with("Delegated") + mocked_pallet_compose_call.assert_not_awaited() + mocked_from_exception.assert_called_once() + assert response == mocked_from_exception.return_value + + +@pytest.mark.parametrize( + "invalid_input, expected_error", + [ + ("InvalidType", ValueError), + ({"InvalidKey": {}}, ValueError), + ({"KeepSubnets": {}}, ValueError), # Empty subnets + ({"KeepSubnets": {"subnets": []}}, ValueError), # Empty subnets list + ( + {"KeepSubnets": {"subnets": ["not", "integers"]}}, + ValueError, + ), # Non-integer subnets + (123, TypeError), # Wrong type + ], + ids=[ + "invalid-string", + "invalid-dict-key", + "empty-subnets-dict", + "empty-subnets-list", + "non-integer-subnets", + "wrong-type", + ], +) +@pytest.mark.asyncio +async def test_set_validator_claim_type_extrinsic_validation_with_raise_error( + subtensor, fake_wallet, mocker, invalid_input, expected_error +): + """Tests `set_validator_claim_type_extrinsic` validation for invalid inputs with raise_error=True.""" + # Preps + test_error = expected_error("Test error") + mocked_normalize = mocker.patch.object( + RootClaimType, "normalize", side_effect=test_error + ) + mocked_pallet_compose_call = mocker.patch.object( + async_root.SubtensorModule, "set_validator_claim_type", new=mocker.AsyncMock() + ) + + # call and assert + with pytest.raises(expected_error): + await async_root.set_validator_claim_type_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + hotkey_ss58="fake_hotkey_address", + netuid=1, + new_claim_type=invalid_input, + raise_error=True, + ) + + mocked_normalize.assert_called_once_with(invalid_input) + mocked_pallet_compose_call.assert_not_awaited() + + +@pytest.mark.parametrize( + "invalid_input, expected_error", + [ + ("InvalidType", ValueError), + ({"InvalidKey": {}}, ValueError), + ({"KeepSubnets": {"subnets": []}}, ValueError), # Empty subnets list + (123, TypeError), # Wrong type + ], + ids=[ + "invalid-string-no-raise", + "invalid-dict-key-no-raise", + "empty-subnets-list-no-raise", + "wrong-type-no-raise", + ], +) +@pytest.mark.asyncio +async def test_set_validator_claim_type_extrinsic_validation_without_raise_error( + subtensor, fake_wallet, mocker, invalid_input, expected_error +): + """Tests `set_validator_claim_type_extrinsic` validation for invalid inputs with raise_error=False.""" + # Preps + test_error = expected_error("Test error") + mocked_normalize = mocker.patch.object( + RootClaimType, "normalize", side_effect=test_error + ) + mocked_pallet_compose_call = mocker.patch.object( + async_root.SubtensorModule, "set_validator_claim_type", new=mocker.AsyncMock() + ) + mocked_from_exception = mocker.patch.object(ExtrinsicResponse, "from_exception") + + # call + response = await async_root.set_validator_claim_type_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + hotkey_ss58="fake_hotkey_address", + netuid=1, + new_claim_type=invalid_input, + raise_error=False, + ) + + # assert + mocked_normalize.assert_called_once_with(invalid_input) + mocked_pallet_compose_call.assert_not_awaited() + mocked_from_exception.assert_called_once_with(raise_error=False, error=test_error) + assert response == mocked_from_exception.return_value diff --git a/tests/unit_tests/extrinsics/test_root.py b/tests/unit_tests/extrinsics/test_root.py index 413f6155e4..ae932d887e 100644 --- a/tests/unit_tests/extrinsics/test_root.py +++ b/tests/unit_tests/extrinsics/test_root.py @@ -322,3 +322,235 @@ def test_claim_root_extrinsic(subtensor, fake_wallet, mocker): wait_for_finalization=True, ) assert response == mocked_sign_and_send_extrinsic.return_value + + +@pytest.mark.parametrize( + "new_claim_type, expected_normalized, hotkey_ss58, netuid", + [ + ("Swap", "Swap", "fake_hotkey_address", 1), + ("Keep", "Keep", "fake_hotkey_address", 2), + (RootClaimType.Swap, "Swap", "fake_hotkey_address", 1), + (RootClaimType.Keep, "Keep", "fake_hotkey_address", 2), + ( + {"KeepSubnets": {"subnets": [1, 2, 3]}}, + {"KeepSubnets": {"subnets": [1, 2, 3]}}, + "fake_hotkey_address", + 1, + ), + ( + RootClaimType.KeepSubnets([1, 2, 3]), + {"KeepSubnets": {"subnets": [1, 2, 3]}}, + "fake_hotkey_address", + 2, + ), + ], + ids=[ + "string-swap", + "string-keep", + "enum-swap", + "enum-keep", + "dict-keep-subnets", + "callable-keep-subnets", + ], +) +def test_set_validator_claim_type_extrinsic( + subtensor, + fake_wallet, + mocker, + new_claim_type, + expected_normalized, + hotkey_ss58, + netuid, +): + """Tests `set_validator_claim_type_extrinsic` extrinsic function with various input formats.""" + # Preps + mocked_normalize = mocker.patch.object( + RootClaimType, "normalize", return_value=expected_normalized + ) + mocked_pallet_compose_call = mocker.patch.object( + root.SubtensorModule, "set_validator_claim_type" + ) + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, "sign_and_send_extrinsic" + ) + + # call + response = root.set_validator_claim_type_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + hotkey_ss58=hotkey_ss58, + netuid=netuid, + new_claim_type=new_claim_type, + ) + + # asserts + mocked_normalize.assert_called_once_with(new_claim_type) + mocked_pallet_compose_call.assert_called_once_with( + hotkey=hotkey_ss58, + netuid=netuid, + new_claim_type=expected_normalized, + ) + mocked_sign_and_send_extrinsic.assert_called_once_with( + call=mocked_pallet_compose_call.return_value, + wallet=fake_wallet, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert response == mocked_sign_and_send_extrinsic.return_value + + +def test_set_validator_claim_type_extrinsic_delegated_not_allowed( + subtensor, fake_wallet, mocker +): + """Tests that `set_validator_claim_type_extrinsic` raises ValueError for Delegated claim type.""" + # Preps + mocked_normalize = mocker.patch.object( + RootClaimType, "normalize", return_value="Delegated" + ) + mocked_pallet_compose_call = mocker.patch.object( + root.SubtensorModule, "set_validator_claim_type" + ) + + # call and assert + with pytest.raises( + ValueError, match="Delegated claim type cannot be set for validators" + ): + root.set_validator_claim_type_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + hotkey_ss58="fake_hotkey_address", + netuid=1, + new_claim_type="Delegated", + raise_error=True, + ) + + mocked_normalize.assert_called_once_with("Delegated") + mocked_pallet_compose_call.assert_not_called() + + +def test_set_validator_claim_type_extrinsic_delegated_not_allowed_no_raise( + subtensor, fake_wallet, mocker +): + """Tests that `set_validator_claim_type_extrinsic` returns error response for Delegated claim type when raise_error=False.""" + # Preps + mocked_normalize = mocker.patch.object( + RootClaimType, "normalize", return_value="Delegated" + ) + mocked_pallet_compose_call = mocker.patch.object( + root.SubtensorModule, "set_validator_claim_type" + ) + mocked_from_exception = mocker.patch.object(ExtrinsicResponse, "from_exception") + + # call + response = root.set_validator_claim_type_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + hotkey_ss58="fake_hotkey_address", + netuid=1, + new_claim_type="Delegated", + raise_error=False, + ) + + # assert + mocked_normalize.assert_called_once_with("Delegated") + mocked_pallet_compose_call.assert_not_called() + mocked_from_exception.assert_called_once() + assert response == mocked_from_exception.return_value + + +@pytest.mark.parametrize( + "invalid_input, expected_error", + [ + ("InvalidType", ValueError), + ({"InvalidKey": {}}, ValueError), + ({"KeepSubnets": {}}, ValueError), # Empty subnets + ({"KeepSubnets": {"subnets": []}}, ValueError), # Empty subnets list + ( + {"KeepSubnets": {"subnets": ["not", "integers"]}}, + ValueError, + ), # Non-integer subnets + (123, TypeError), # Wrong type + ], + ids=[ + "invalid-string", + "invalid-dict-key", + "empty-subnets-dict", + "empty-subnets-list", + "non-integer-subnets", + "wrong-type", + ], +) +def test_set_validator_claim_type_extrinsic_validation_with_raise_error( + subtensor, fake_wallet, mocker, invalid_input, expected_error +): + """Tests `set_validator_claim_type_extrinsic` validation for invalid inputs with raise_error=True.""" + # Preps + test_error = expected_error("Test error") + mocked_normalize = mocker.patch.object( + RootClaimType, "normalize", side_effect=test_error + ) + mocked_pallet_compose_call = mocker.patch.object( + root.SubtensorModule, "set_validator_claim_type" + ) + + # call and assert + with pytest.raises(expected_error): + root.set_validator_claim_type_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + hotkey_ss58="fake_hotkey_address", + netuid=1, + new_claim_type=invalid_input, + raise_error=True, + ) + + mocked_normalize.assert_called_once_with(invalid_input) + mocked_pallet_compose_call.assert_not_called() + + +@pytest.mark.parametrize( + "invalid_input, expected_error", + [ + ("InvalidType", ValueError), + ({"InvalidKey": {}}, ValueError), + ({"KeepSubnets": {"subnets": []}}, ValueError), # Empty subnets list + (123, TypeError), # Wrong type + ], + ids=[ + "invalid-string-no-raise", + "invalid-dict-key-no-raise", + "empty-subnets-list-no-raise", + "wrong-type-no-raise", + ], +) +def test_set_validator_claim_type_extrinsic_validation_without_raise_error( + subtensor, fake_wallet, mocker, invalid_input, expected_error +): + """Tests `set_validator_claim_type_extrinsic` validation for invalid inputs with raise_error=False.""" + # Preps + test_error = expected_error("Test error") + mocked_normalize = mocker.patch.object( + RootClaimType, "normalize", side_effect=test_error + ) + mocked_pallet_compose_call = mocker.patch.object( + root.SubtensorModule, "set_validator_claim_type" + ) + mocked_from_exception = mocker.patch.object(ExtrinsicResponse, "from_exception") + + # call + response = root.set_validator_claim_type_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + hotkey_ss58="fake_hotkey_address", + netuid=1, + new_claim_type=invalid_input, + raise_error=False, + ) + + # assert + mocked_normalize.assert_called_once_with(invalid_input) + mocked_pallet_compose_call.assert_not_called() + mocked_from_exception.assert_called_once_with(raise_error=False, error=test_error) + assert response == mocked_from_exception.return_value diff --git a/tests/unit_tests/test_async_subtensor.py b/tests/unit_tests/test_async_subtensor.py index 0f83110729..2aa3c35cce 100644 --- a/tests/unit_tests/test_async_subtensor.py +++ b/tests/unit_tests/test_async_subtensor.py @@ -6315,3 +6315,100 @@ async def test_mev_submit_encrypted_default_params(subtensor, fake_wallet, mocke blocks_for_revealed_execution=3, ) assert result == mocked_submit_encrypted_extrinsic.return_value + + +@pytest.mark.parametrize( + "fake_result, expected_result", + [ + ({"Swap": ()}, "Swap"), + ({"Keep": ()}, "Keep"), + ( + { + "KeepSubnets": { + "subnets": ( + ( + 2, + 3, + ), + ) + } + }, + {"KeepSubnets": {"subnets": [2, 3]}}, + ), + ( + {"KeepSubnets": {"subnets": ((2,),)}}, + { + "KeepSubnets": { + "subnets": [ + 2, + ] + } + }, + ), + (None, "Keep"), # Default when query returns None + ({}, "Keep"), # Default when query returns empty dict + ], +) +@pytest.mark.asyncio +async def test_get_validator_claim_type( + mocker, subtensor, fake_result, expected_result +): + """Tests that `get_validator_claim_type` calls proper methods and returns the correct value.""" + # Preps + fake_hotkey_ss58 = mocker.Mock(spec=str) + fake_netuid = mocker.Mock(spec=int) + mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") + mocked_query = mocker.patch.object( + subtensor.substrate, "query", return_value=fake_result + ) + + # call + result = await subtensor.get_validator_claim_type(fake_hotkey_ss58, fake_netuid) + + # asserts + mocked_determine_block_hash.assert_awaited_once() + mocked_query.assert_awaited_once_with( + module="SubtensorModule", + storage_function="ValidatorClaimType", + params=[fake_hotkey_ss58, fake_netuid], + block_hash=mocked_determine_block_hash.return_value, + reuse_block_hash=False, + ) + assert result == expected_result + + +@pytest.mark.asyncio +async def test_set_validator_claim_type(mocker, subtensor): + """Tests that `set_validator_claim_type` calls proper methods and returns the correct value.""" + # Preps + faked_wallet = mocker.Mock(spec=Wallet) + fake_hotkey_ss58 = mocker.Mock(spec=str) + fake_netuid = mocker.Mock(spec=int) + fake_new_claim_type = mocker.Mock(spec=str) + mocked_set_validator_claim_type_extrinsic = mocker.patch.object( + async_subtensor, "set_validator_claim_type_extrinsic" + ) + + # call + response = await subtensor.set_validator_claim_type( + wallet=faked_wallet, + hotkey_ss58=fake_hotkey_ss58, + netuid=fake_netuid, + new_claim_type=fake_new_claim_type, + ) + + # asserts + mocked_set_validator_claim_type_extrinsic.assert_awaited_once_with( + subtensor=subtensor, + wallet=faked_wallet, + hotkey_ss58=fake_hotkey_ss58, + netuid=fake_netuid, + new_claim_type=fake_new_claim_type, + mev_protection=DEFAULT_MEV_PROTECTION, + period=DEFAULT_PERIOD, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + assert response == mocked_set_validator_claim_type_extrinsic.return_value diff --git a/tests/unit_tests/test_subtensor.py b/tests/unit_tests/test_subtensor.py index 0d70415874..05632e4efd 100644 --- a/tests/unit_tests/test_subtensor.py +++ b/tests/unit_tests/test_subtensor.py @@ -6430,3 +6430,95 @@ def test_mev_submit_encrypted_default_params(subtensor, fake_wallet, mocker): blocks_for_revealed_execution=3, ) assert result == mocked_submit_encrypted_extrinsic.return_value + + +@pytest.mark.parametrize( + "fake_result, expected_result", + [ + ({"Swap": ()}, "Swap"), + ({"Keep": ()}, "Keep"), + ( + { + "KeepSubnets": { + "subnets": ( + ( + 2, + 3, + ), + ) + } + }, + {"KeepSubnets": {"subnets": [2, 3]}}, + ), + ( + {"KeepSubnets": {"subnets": ((2,),)}}, + { + "KeepSubnets": { + "subnets": [ + 2, + ] + } + }, + ), + (None, "Keep"), # Default when query returns None + ({}, "Keep"), # Default when query returns empty dict + ], +) +def test_get_validator_claim_type(mocker, subtensor, fake_result, expected_result): + """Tests that `get_validator_claim_type` calls proper methods and returns the correct value.""" + # Preps + fake_hotkey_ss58 = mocker.Mock(spec=str) + fake_netuid = mocker.Mock(spec=int) + mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") + mocked_query = mocker.patch.object( + subtensor.substrate, "query", return_value=fake_result + ) + + # call + result = subtensor.get_validator_claim_type(fake_hotkey_ss58, fake_netuid) + + # asserts + mocked_determine_block_hash.assert_called_once() + mocked_query.assert_called_once_with( + module="SubtensorModule", + storage_function="ValidatorClaimType", + params=[fake_hotkey_ss58, fake_netuid], + block_hash=mocked_determine_block_hash.return_value, + ) + assert result == expected_result + + +def test_set_validator_claim_type(mocker, subtensor): + """Tests that `set_validator_claim_type` calls proper methods and returns the correct value.""" + # Preps + faked_wallet = mocker.Mock(spec=Wallet) + fake_hotkey_ss58 = mocker.Mock(spec=str) + fake_netuid = mocker.Mock(spec=int) + fake_new_claim_type = mocker.Mock(spec=str) + mocked_set_validator_claim_type_extrinsic = mocker.patch.object( + subtensor_module, "set_validator_claim_type_extrinsic" + ) + + # call + response = subtensor.set_validator_claim_type( + wallet=faked_wallet, + hotkey_ss58=fake_hotkey_ss58, + netuid=fake_netuid, + new_claim_type=fake_new_claim_type, + ) + + # asserts + mocked_set_validator_claim_type_extrinsic.assert_called_once_with( + subtensor=subtensor, + wallet=faked_wallet, + hotkey_ss58=fake_hotkey_ss58, + netuid=fake_netuid, + new_claim_type=fake_new_claim_type, + mev_protection=DEFAULT_MEV_PROTECTION, + period=DEFAULT_PERIOD, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + assert response == mocked_set_validator_claim_type_extrinsic.return_value