diff --git a/bittensor/core/async_subtensor.py b/bittensor/core/async_subtensor.py index d0228509c7..e1f1d1b51d 100644 --- a/bittensor/core/async_subtensor.py +++ b/bittensor/core/async_subtensor.py @@ -82,12 +82,12 @@ remove_liquidity_extrinsic, toggle_user_liquidity_extrinsic, ) -from bittensor.core.extrinsics.asyncex.mev_shield import submit_encrypted_extrinsic from bittensor.core.extrinsics.asyncex.lock import ( lock_stake_extrinsic, move_lock_extrinsic, set_perpetual_lock_extrinsic, ) +from bittensor.core.extrinsics.asyncex.mev_shield import submit_encrypted_extrinsic from bittensor.core.extrinsics.asyncex.move_stake import ( move_stake_extrinsic, swap_stake_extrinsic, @@ -129,6 +129,12 @@ ) from bittensor.core.extrinsics.asyncex.start_call import start_call_extrinsic from bittensor.core.extrinsics.asyncex.take import set_take_extrinsic +from bittensor.core.extrinsics.asyncex.tempo_control import ( + root_set_activity_cutoff_factor_extrinsic, + set_activity_cutoff_factor_extrinsic, + set_tempo_extrinsic, + trigger_epoch_extrinsic, +) from bittensor.core.extrinsics.asyncex.transfer import transfer_extrinsic from bittensor.core.extrinsics.asyncex.unstaking import ( unstake_all_extrinsic, @@ -153,6 +159,7 @@ ) from bittensor.core.types import ( BlockInfo, + EpochScheduleState, ExtrinsicResponse, LockState, Salt, @@ -177,6 +184,7 @@ u64_normalized_float, validate_max_attempts, ) +from bittensor.utils import epoch_schedule from bittensor.utils.balance import ( Balance, check_balance_amount, @@ -1115,36 +1123,35 @@ async def blocks_since_last_update( async def blocks_until_next_epoch( self, netuid: int, - tempo: Optional[int] = None, + *, block: Optional[int] = None, block_hash: Optional[str] = None, reuse_block: bool = False, ) -> Optional[int]: - """Returns the number of blocks until the next epoch of subnet with provided netuid. + """Returns the number of blocks until the next epoch for the given subnet. + + Derives the answer from the ``get_next_epoch_start_block`` runtime API. Parameters: netuid: The unique identifier of the subnetwork. - tempo: The tempo 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. + 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. Do not set if using ``block`` or ``reuse_block``. + reuse_block: Whether to reuse the last-used block hash. Returns: - The number of blocks until the next epoch of the subnet with provided netuid. + The number of blocks remaining, or ``None`` if the subnet has + tempo 0 (no epochs). """ block_hash = await self.determine_block_hash(block, block_hash, reuse_block) - block = block or await self.substrate.get_block_number(block_hash=block_hash) - tempo = tempo or await self.tempo(netuid=netuid, block_hash=block_hash) - - if not tempo: + block_number = block or await self.substrate.get_block_number( + block_hash=block_hash + ) + next_start = await self.get_next_epoch_start_block( + netuid, block_hash=block_hash + ) + if next_start is None: return None - - # the logic is the same as in SubtensorModule:blocks_until_next_epoch - netuid_plus_one = int(netuid) + 1 - tempo_plus_one = tempo + 1 - adjusted_block = (block + netuid_plus_one) % (2**64) - remainder = adjusted_block % tempo_plus_one - return tempo - remainder + return max(0, next_start - block_number) async def bonds( self, @@ -1311,6 +1318,33 @@ async def does_hotkey_exist( ) return result.value != "5C4hrfjw9DjXZTzV3MwzrrAr9P1MJhSrvWGWqi1eSuyUpnhM" + async def get_activity_cutoff_factor_milli( + self, + netuid: int, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> int: + """Returns the activity cutoff factor (per-mille) for the given subnet. + + Parameters: + netuid: The unique identifier of the subnetwork. + 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. Do not set if using `block` or `reuse_block`. + reuse_block: Whether to reuse the last-used block hash. + + Returns: + The activity cutoff factor in per-mille units. + """ + query = await self.query_subtensor( + name="ActivityCutoffFactorMilli", + block=block, + block_hash=block_hash, + reuse_block=reuse_block, + params=[netuid], + ) + return query.value + async def get_admin_freeze_window( self, block: Optional[int] = None, @@ -2833,6 +2867,59 @@ async def get_ema_tao_inflow( # TODO verify this from rao, seems like we're just rounding down return block_updated, Balance.from_rao(ema_value) + async def get_epoch_schedule_state( + self, + netuid: int, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> "EpochScheduleState": + """Returns a snapshot of all epoch-related storage for the given subnet. + + All fields are read at the same block to ensure consistency. + Used by `bittensor-drand` v2 for commit/reveal prediction. + + Parameters: + netuid: The unique identifier of the subnetwork. + 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. Do not set if using `block` or `reuse_block`. + reuse_block: Whether to reuse the last-used block hash. + + Returns: + An `EpochScheduleState` populated from on-chain storage. + """ + block_hash = ( + await self.determine_block_hash(block, block_hash, reuse_block) + or await self.substrate.get_chain_head() + ) + + block_number = block or await self.substrate.get_block_number( + block_hash=block_hash + ) + + ( + last_epoch_block, + pending_epoch_at, + subnet_epoch_index, + tempo, + blocks_since_last_step, + ) = await asyncio.gather( + self.get_last_epoch_block(netuid, block_hash=block_hash), + self.get_pending_epoch_at(netuid, block_hash=block_hash), + self.get_subnet_epoch_index(netuid, block_hash=block_hash), + self.tempo(netuid, block_hash=block_hash), + self.blocks_since_last_step(netuid, block_hash=block_hash), + ) + + return EpochScheduleState( + last_epoch_block=last_epoch_block, + pending_epoch_at=pending_epoch_at, + subnet_epoch_index=subnet_epoch_index, + tempo=tempo, + blocks_since_last_step=blocks_since_last_step, + current_block=block_number, + ) + async def get_hotkey_conviction( self, hotkey_ss58: str, @@ -2975,6 +3062,33 @@ async def get_last_commitment_bonds_reset_block( ) return block_data.value + async def get_last_epoch_block( + self, + netuid: int, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> int: + """Returns the block number at which the last epoch ran for the given subnet. + + Parameters: + netuid: The unique identifier of the subnetwork. + 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. Do not set if using `block` or `reuse_block`. + reuse_block: Whether to reuse the last-used block hash. + + Returns: + The block number of the most recent epoch. + """ + query = await self.query_subtensor( + name="LastEpochBlock", + block=block, + block_hash=block_hash, + reuse_block=reuse_block, + params=[netuid], + ) + return query.value + async def get_liquidity_list( self, wallet: "Wallet", @@ -3005,6 +3119,99 @@ async def get_liquidity_list( ) return [] + async def get_max_activity_cutoff_factor_milli( + self, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> int: + """Returns the upper bound for the activity-cutoff factor (per-mille). + + This chain constant defines the maximum value a subnet owner can set for the activity-cutoff factor via + ``set_activity_cutoff_factor``. The factor is expressed in per-mille units relative to the subnet's tempo. + + Parameters: + block: The blockchain block number for the query. + block_hash: The block hash at which to query. + reuse_block: Whether to reuse the last-used blockchain block hash. + + Returns: + The upper bound for the activity-cutoff factor in per-mille. + """ + block_hash = await self.determine_block_hash(block, block_hash, reuse_block) + result = await self.substrate.get_constant( + module_name="SubtensorModule", + constant_name="MaxActivityCutoffFactorMilli", + block_hash=block_hash, + ) + + if result is None: + raise Exception("Unable to retrieve MaxActivityCutoffFactorMilli constant.") + + return result.value + + async def get_max_epochs_per_block( + self, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> int: + """Returns the per-block cap on the number of subnet epochs that may execute in a single block step. + + When more subnets are due for an epoch than this cap allows, excess epochs are deferred to the next block via + ``PendingEpochAt``. + + Parameters: + block: The blockchain block number for the query. + block_hash: The block hash at which to query. + reuse_block: Whether to reuse the last-used blockchain block hash. + + Returns: + The maximum number of subnet epochs allowed per block. + """ + block_hash = await self.determine_block_hash(block, block_hash, reuse_block) + result = await self.substrate.get_constant( + module_name="SubtensorModule", + constant_name="MaxEpochsPerBlock", + block_hash=block_hash, + ) + + if result is None: + raise Exception("Unable to retrieve MaxEpochsPerBlock constant.") + + return result.value + + async def get_max_tempo( + self, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> int: + """Returns the upper bound for owner-set tempo. + + This chain constant defines the maximum epoch period (in blocks) that a subnet owner can configure via + ``set_tempo``. + + Parameters: + block: The blockchain block number for the query. + block_hash: The block hash at which to query. + reuse_block: Whether to reuse the last-used blockchain block hash. + + Returns: + The maximum allowed tempo value in blocks. + """ + block_hash = await self.determine_block_hash(block, block_hash, reuse_block) + result = await self.substrate.get_constant( + module_name="SubtensorModule", + constant_name="MaxTempo", + block_hash=block_hash, + ) + + if result is None: + raise Exception("Unable to retrieve MaxTempo constant.") + + return result.value + async def get_mechanism_emission_split( self, netuid: int, @@ -3264,6 +3471,68 @@ async def get_mev_shield_next_key( return public_key_bytes + async def get_min_activity_cutoff_factor_milli( + self, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> int: + """Returns the lower bound for the activity-cutoff factor (per-mille). + + This chain constant defines the minimum value a subnet owner can set for the activity-cutoff factor via + ``set_activity_cutoff_factor``. The factor is expressed in per-mille units relative to the subnet's tempo. + + Parameters: + block: The blockchain block number for the query. + block_hash: The block hash at which to query. + reuse_block: Whether to reuse the last-used blockchain block hash. + + Returns: + The lower bound for the activity-cutoff factor in per-mille. + """ + block_hash = await self.determine_block_hash(block, block_hash, reuse_block) + result = await self.substrate.get_constant( + module_name="SubtensorModule", + constant_name="MinActivityCutoffFactorMilli", + block_hash=block_hash, + ) + + if result is None: + raise Exception("Unable to retrieve MinActivityCutoffFactorMilli constant.") + + return result.value + + async def get_min_tempo( + self, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> int: + """Returns the lower bound for owner-set tempo. + + This chain constant defines the minimum epoch period (in blocks) that a subnet owner can configure via + ``set_tempo``. Also serves as the fixed cooldown between consecutive ``set_tempo`` calls. + + Parameters: + block: The blockchain block number for the query. + block_hash: The block hash at which to query. + reuse_block: Whether to reuse the last-used blockchain block hash. + + Returns: + The minimum allowed tempo value in blocks. + """ + block_hash = await self.determine_block_hash(block, block_hash, reuse_block) + result = await self.substrate.get_constant( + module_name="SubtensorModule", + constant_name="MinTempo", + block_hash=block_hash, + ) + + if result is None: + raise Exception("Unable to retrieve MinTempo constant.") + + return result.value + async def get_minimum_required_stake(self) -> Balance: """Returns the minimum required stake threshold for nominator cleanup operations. @@ -3444,39 +3713,34 @@ async def get_next_epoch_start_block( block_hash: Optional[str] = None, reuse_block: bool = False, ) -> Optional[int]: - """ - Calculates the first block number of the next epoch for the given subnet. + """Returns the block at which the next epoch will fire for the given subnet. - If `block` is not provided, the current chain block will be used. Epochs are determined based on the subnet's - tempo (i.e., blocks per epoch). The result is the block number at which the next epoch will begin. + Delegates to the ``SubnetInfoRuntimeApi.get_next_epoch_start_block`` + runtime API, which accounts for both the auto-timer + (``last_epoch_block + tempo``) and any pending owner-triggered epoch. Parameters: netuid: The unique identifier of the subnet. - block: The reference block to calculate from. If `None`, uses the current chain block height. - block_hash: The blockchain block number at which to perform the query. + block: The reference block to query. If ``None``, uses the current chain head. + block_hash: The blockchain block hash at which to perform the query. reuse_block: Whether to reuse the last-used blockchain block hash. Returns: - int: The block number at which the next epoch will start. + The block number at which the next epoch will start, or ``None`` + if tempo is 0 (subnet does not run epochs). Notes: - """ - block_hash = await self.determine_block_hash(block, block_hash, reuse_block) - tempo = await self.tempo(netuid=netuid, block_hash=block_hash) - current_block = block or await self.block - - if not tempo: - return None - - blocks_until = await self.blocks_until_next_epoch( - netuid=netuid, tempo=tempo, block_hash=block_hash + result = await self.query_runtime_api( + runtime_api="SubnetInfoRuntimeApi", + method="get_next_epoch_start_block", + params=[netuid], + block=block, + block_hash=block_hash, + reuse_block=reuse_block, ) - - if blocks_until is None: - return None - - return current_block + blocks_until + 1 + return None if result is None else int(result) async def get_owned_hotkeys( self, @@ -3507,6 +3771,30 @@ async def get_owned_hotkeys( return owned_hotkeys.value or [] + async def get_owner_hyperparam_rate_limit( + self, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> int: + """Returns the owner hyperparameter rate limit (in tempos). + + Parameters: + 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. Do not set if using `block` or `reuse_block`. + reuse_block: Whether to reuse the last-used block hash. + + Returns: + The rate limit in tempos. + """ + block_hash = await self.determine_block_hash(block, block_hash, reuse_block) + query = await self.substrate.query( + module="SubtensorModule", + storage_function="OwnerHyperparamRateLimit", + block_hash=block_hash, + ) + return query.value + async def get_parents( self, hotkey_ss58: str, @@ -3550,6 +3838,33 @@ async def get_parents( return [] + async def get_pending_epoch_at( + self, + netuid: int, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> int: + """Returns the pending (owner-triggered) epoch block, or 0 if none is scheduled. + + Parameters: + netuid: The unique identifier of the subnetwork. + 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. Do not set if using `block` or `reuse_block`. + reuse_block: Whether to reuse the last-used block hash. + + Returns: + The block at which the triggered epoch will fire, or 0. + """ + query = await self.query_subtensor( + name="PendingEpochAt", + block=block, + block_hash=block_hash, + reuse_block=reuse_block, + params=[netuid], + ) + return query.value + async def get_proxies( self, block: Optional[int] = None, @@ -4650,6 +4965,33 @@ async def get_subnet_burn_cost( else: return lock_cost + async def get_subnet_epoch_index( + self, + netuid: int, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> int: + """Returns the monotonic epoch counter for the given subnet. + + Parameters: + netuid: The unique identifier of the subnetwork. + 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. Do not set if using `block` or `reuse_block`. + reuse_block: Whether to reuse the last-used block hash. + + Returns: + The current epoch index. + """ + query = await self.query_subtensor( + name="SubnetEpochIndex", + block=block, + block_hash=block_hash, + reuse_block=reuse_block, + params=[netuid], + ) + return query.value + async def get_subnet_hyperparameters( self, netuid: int, @@ -4657,9 +4999,10 @@ async def get_subnet_hyperparameters( block_hash: Optional[str] = None, reuse_block: bool = False, ) -> Optional["SubnetHyperparameters"]: - """ - Retrieves the hyperparameters for a specific subnet within the Bittensor network. These hyperparameters define - the operational settings and rules governing the subnet's behavior. + """Retrieves the hyperparameters for a specific subnet. + + Tries the v3 ``Vec`` runtime API first, then falls + back to v2 and v1 struct APIs at the requested block. Parameters: netuid: The network UID of the subnet to query. @@ -4668,24 +5011,21 @@ async def get_subnet_hyperparameters( reuse_block: Whether to reuse the last-used blockchain hash. Returns: - The subnet's hyperparameters, or `None` if not available. - - Understanding the hyperparameters is crucial for comprehending how subnets are configured and managed, and how - they interact with the network's consensus and incentive mechanisms. + The subnet's hyperparameters, or ``None`` if not available. """ - result = await self.query_runtime_api( - runtime_api="SubnetInfoRuntimeApi", - method="get_subnet_hyperparams_v2", - params=[netuid], + block_hash = await self.determine_block_hash( block=block, block_hash=block_hash, reuse_block=reuse_block, ) - - if not result: - return None - - return SubnetHyperparameters.from_dict(result) + result = await self._runtime_call_with_fallback( + ("SubnetInfoRuntimeApi", "get_subnet_hyperparams_v3", [netuid]), + ("SubnetInfoRuntimeApi", "get_subnet_hyperparams_v2", [netuid]), + ("SubnetInfoRuntimeApi", "get_subnet_hyperparams", [netuid]), + block_hash=block_hash, + default_value=None, + ) + return SubnetHyperparameters.from_any(result) if result else None async def get_subnet_info( self, @@ -5269,10 +5609,11 @@ async def is_in_admin_freeze_window( block_hash: Optional[str] = None, reuse_block: bool = False, ) -> bool: - """ - Returns True if the current block is within the terminal freeze window of the tempo - for the given subnet. During this window, admin ops are prohibited to avoid interference - with validator weight submissions. + """Returns whether owner operations are currently blocked for the subnet. + + Matches the chain's ``is_in_admin_freeze_window`` logic: a pending + triggered epoch in the future **or** fewer than ``admin_freeze_window`` + blocks remaining until the next auto epoch. Parameters: netuid: The unique identifier of the subnet. @@ -5281,28 +5622,35 @@ async def is_in_admin_freeze_window( reuse_block: Whether to reuse the last-used blockchain block hash. Returns: - bool: True if in freeze window, else False. + ``True`` if in freeze window, ``False`` otherwise. """ - # SN0 doesn't have admin_freeze_window if netuid == 0: return False - next_epoch_start_block, window = await asyncio.gather( - self.get_next_epoch_start_block( - netuid=netuid, - block=block, - block_hash=block_hash, - reuse_block=reuse_block, - ), - self.get_admin_freeze_window( - block=block, block_hash=block_hash, reuse_block=reuse_block - ), + block_hash = await self.determine_block_hash(block, block_hash, reuse_block) + block_number = block or await self.substrate.get_block_number( + block_hash=block_hash ) - if next_epoch_start_block is not None: - remaining = next_epoch_start_block - await self.block - return remaining < window - return False + ( + tempo, + pending_epoch_at, + last_epoch_block, + admin_freeze_window, + ) = await asyncio.gather( + self.tempo(netuid, block_hash=block_hash), + self.get_pending_epoch_at(netuid, block_hash=block_hash), + self.get_last_epoch_block(netuid, block_hash=block_hash), + self.get_admin_freeze_window(block_hash=block_hash), + ) + + return epoch_schedule.is_in_admin_freeze_window( + tempo=tempo or 0, + pending_epoch_at=pending_epoch_at, + last_epoch_block=last_epoch_block, + block_number=block_number, + admin_freeze_window=admin_freeze_window, + ) async def is_fast_blocks(self) -> bool: """Checks if the node is running with fast blocks enabled. @@ -8648,6 +8996,53 @@ async def root_register( wait_for_revealed_execution=wait_for_revealed_execution, ) + async def root_set_activity_cutoff_factor( + self, + wallet: "Wallet", + netuid: int, + factor_milli: int, + *, + 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 activity cutoff factor via sudo (root only). + + Parameters: + wallet: The wallet used to sign the extrinsic. + netuid: The unique identifier of the subnet. + factor_milli: Activity cutoff factor in per-mille units. + 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: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + 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 root_set_activity_cutoff_factor_extrinsic( + subtensor=self, + wallet=wallet, + netuid=netuid, + factor_milli=factor_milli, + 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 root_set_pending_childkey_cooldown( self, wallet: "Wallet", @@ -8694,6 +9089,53 @@ async def root_set_pending_childkey_cooldown( wait_for_revealed_execution=wait_for_revealed_execution, ) + async def set_activity_cutoff_factor( + self, + wallet: "Wallet", + netuid: int, + factor_milli: int, + *, + 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 activity cutoff factor for a subnet. Owner (coldkey) only. + + Parameters: + wallet: The wallet used to sign the extrinsic (coldkey must be the subnet owner). + netuid: The unique identifier of the subnet. + factor_milli: Activity cutoff factor in per-mille units. + 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: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + 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_activity_cutoff_factor_extrinsic( + subtensor=self, + wallet=wallet, + netuid=netuid, + factor_milli=factor_milli, + 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_auto_stake( self, wallet: "Wallet", @@ -9050,6 +9492,53 @@ async def set_subnet_identity( wait_for_revealed_execution=wait_for_revealed_execution, ) + async def set_tempo( + self, + wallet: "Wallet", + netuid: int, + tempo: int, + *, + 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 epoch tempo for a subnet. Owner (coldkey) only. + + Parameters: + wallet: The wallet used to sign the extrinsic (coldkey must be the subnet owner). + netuid: The unique identifier of the subnet. + tempo: New tempo value (blocks per epoch). + 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: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + 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_tempo_extrinsic( + subtensor=self, + wallet=wallet, + netuid=netuid, + tempo=tempo, + 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_weights( self, wallet: "Wallet", @@ -9798,6 +10287,50 @@ async def transfer_stake( wait_for_revealed_execution=wait_for_revealed_execution, ) + async def trigger_epoch( + self, + wallet: "Wallet", + netuid: int, + *, + 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: + """ + Schedules an owner-triggered epoch to fire after the admin freeze window elapses. Owner (coldkey) only. + + Parameters: + wallet: The wallet used to sign the extrinsic (coldkey must be the subnet owner). + netuid: The unique identifier of the subnet. + 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: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + 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 trigger_epoch_extrinsic( + subtensor=self, + wallet=wallet, + netuid=netuid, + 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 unstake( self, wallet: "Wallet", diff --git a/bittensor/core/chain_data/subnet_hyperparameters.py b/bittensor/core/chain_data/subnet_hyperparameters.py index 5317bad9e3..c856902971 100644 --- a/bittensor/core/chain_data/subnet_hyperparameters.py +++ b/bittensor/core/chain_data/subnet_hyperparameters.py @@ -1,120 +1,163 @@ -from dataclasses import dataclass -from bittensor.utils.balance import fixed_to_float +import re +from dataclasses import dataclass, field, fields +from typing import Any, Optional + from bittensor.core.chain_data.info_base import InfoBase +from bittensor.utils import Self +from bittensor.utils.balance import fixed_to_float + +_FIXED_POINT_TAG = re.compile(r"^[UI]\d+F(\d+)$") + +# Chain field name -> dataclass attribute (v2/v1 use ``max_weights_limit``). +_FIELD_ALIASES = {"max_weights_limit": "max_weight_limit"} @dataclass class SubnetHyperparameters(InfoBase): """ - This class represents the hyperparameters for a subnet. - - Attributes: - rho: The rate of decay of some value. - kappa: A constant multiplier used in calculations. - immunity_period: The period during which immunity is active. - min_allowed_weights: Minimum allowed weights. - max_weight_limit: Maximum weight limit. - tempo: The tempo or rate of operation. - min_difficulty: Minimum difficulty for some operations. - max_difficulty: Maximum difficulty for some operations. - weights_version: The version number of the weights used. - weights_rate_limit: Rate limit for processing weights. - adjustment_interval: Interval at which adjustments are made. - activity_cutoff: Activity cutoff threshold. - registration_allowed: Indicates if registration is allowed. - target_regs_per_interval: Target number of registrations per interval. - min_burn: Minimum burn value. - max_burn: Maximum burn value. - bonds_moving_avg: Moving average of bonds. - max_regs_per_block: Maximum number of registrations per block. - serving_rate_limit: Limit on the rate of service. - max_validators: Maximum number of validators. - adjustment_alpha: Alpha value for adjustments. - difficulty: Difficulty level. - commit_reveal_period: Interval for commit-reveal weights. - commit_reveal_weights_enabled: Flag indicating if commit-reveal weights are enabled. - alpha_high: High value of alpha. - alpha_low: Low value of alpha. - liquid_alpha_enabled: Flag indicating if liquid alpha is enabled. - alpha_sigmoid_steepness: Sigmoid steepness parameter for converting miner-validator alignment into alpha. - yuma_version: Version of yuma. - subnet_is_active: Indicates if subnet is active after START CALL. - transfers_enabled: Flag indicating if transfers are enabled. - bonds_reset_enabled: Flag indicating if bonds are reset enabled. - user_liquidity_enabled: Flag indicating if user liquidity is enabled. + Hyperparameters for a subnet. + + Known fields are explicit typed attributes for IDE support. Values returned + by the chain under other names are stored in ``hyperparameters`` and are + accessible via attribute, item, or mapping-style access. """ - rho: int - kappa: int - immunity_period: int - min_allowed_weights: int - max_weight_limit: float - tempo: int - min_difficulty: int - max_difficulty: int - weights_version: int - weights_rate_limit: int - adjustment_interval: int - activity_cutoff: int - registration_allowed: bool - target_regs_per_interval: int - min_burn: int - max_burn: int - bonds_moving_avg: int - max_regs_per_block: int - serving_rate_limit: int - max_validators: int - adjustment_alpha: int - difficulty: int - commit_reveal_period: int - commit_reveal_weights_enabled: bool - alpha_high: int - alpha_low: int - liquid_alpha_enabled: bool - alpha_sigmoid_steepness: float - yuma_version: int - subnet_is_active: bool - transfers_enabled: bool - bonds_reset_enabled: bool - user_liquidity_enabled: bool + rho: Optional[int] = None + kappa: Optional[int] = None + immunity_period: Optional[int] = None + min_allowed_weights: Optional[int] = None + max_weight_limit: Optional[float] = None + tempo: Optional[int] = None + min_difficulty: Optional[int] = None + max_difficulty: Optional[int] = None + weights_version: Optional[int] = None + weights_rate_limit: Optional[int] = None + adjustment_interval: Optional[int] = None + activity_cutoff: Optional[int] = None + registration_allowed: Optional[bool] = None + target_regs_per_interval: Optional[int] = None + min_burn: Optional[int] = None + max_burn: Optional[int] = None + bonds_moving_avg: Optional[int] = None + max_regs_per_block: Optional[int] = None + serving_rate_limit: Optional[int] = None + max_validators: Optional[int] = None + adjustment_alpha: Optional[int] = None + difficulty: Optional[int] = None + commit_reveal_period: Optional[int] = None + commit_reveal_weights_enabled: Optional[bool] = None + alpha_high: Optional[int] = None + alpha_low: Optional[int] = None + liquid_alpha_enabled: Optional[bool] = None + alpha_sigmoid_steepness: Optional[float] = None + yuma_version: Optional[int] = None + subnet_is_active: Optional[bool] = None + transfers_enabled: Optional[bool] = None + bonds_reset_enabled: Optional[bool] = None + user_liquidity_enabled: Optional[bool] = None + activity_cutoff_factor: Optional[int] = None + hyperparameters: dict[str, Any] = field(default_factory=dict, repr=False) - @classmethod - def _from_dict(cls, decoded: dict) -> "SubnetHyperparameters": - """Returns a SubnetHyperparameters object from decoded chain data.""" - return SubnetHyperparameters( - activity_cutoff=decoded["activity_cutoff"], - adjustment_alpha=decoded["adjustment_alpha"], - adjustment_interval=decoded["adjustment_interval"], - alpha_high=decoded["alpha_high"], - alpha_low=decoded["alpha_low"], - alpha_sigmoid_steepness=fixed_to_float( - decoded["alpha_sigmoid_steepness"], frac_bits=32 - ), - bonds_moving_avg=decoded["bonds_moving_avg"], - bonds_reset_enabled=decoded["bonds_reset_enabled"], - commit_reveal_weights_enabled=decoded["commit_reveal_weights_enabled"], - commit_reveal_period=decoded["commit_reveal_period"], - difficulty=decoded["difficulty"], - immunity_period=decoded["immunity_period"], - kappa=decoded["kappa"], - liquid_alpha_enabled=decoded["liquid_alpha_enabled"], - max_burn=decoded["max_burn"], - max_difficulty=decoded["max_difficulty"], - max_regs_per_block=decoded["max_regs_per_block"], - max_validators=decoded["max_validators"], - max_weight_limit=decoded["max_weights_limit"], - min_allowed_weights=decoded["min_allowed_weights"], - min_burn=decoded["min_burn"], - min_difficulty=decoded["min_difficulty"], - registration_allowed=decoded["registration_allowed"], - rho=decoded["rho"], - serving_rate_limit=decoded["serving_rate_limit"], - subnet_is_active=decoded["subnet_is_active"], - target_regs_per_interval=decoded["target_regs_per_interval"], - tempo=decoded["tempo"], - transfers_enabled=decoded["transfers_enabled"], - user_liquidity_enabled=decoded["user_liquidity_enabled"], - weights_rate_limit=decoded["weights_rate_limit"], - weights_version=decoded["weights_version"], - yuma_version=decoded["yuma_version"], + @staticmethod + def _typed_field_names() -> frozenset[str]: + return frozenset( + f.name for f in fields(SubnetHyperparameters) if f.name != "hyperparameters" ) + + @staticmethod + def _decode_value(value: Any) -> Any: + """Decode a single ``{: }`` hyperparameter value.""" + if isinstance(value, dict) and set(value.keys()) == {"bits"}: + # V2 struct encodes fixed-point fields as {"bits": N} without a type tag. All V2 fixed-point fields are + # I32F32. V3 entries carry the enum variant tag and are handled below. + return fixed_to_float(value["bits"], frac_bits=32) + if not isinstance(value, dict) or len(value) != 1: + return value + ((type_tag, payload),) = value.items() + if type_tag == "Bool": + return bool(payload) + if match := _FIXED_POINT_TAG.match(type_tag): + if isinstance(payload, dict) and "bits" in payload: + payload = payload["bits"] + return fixed_to_float(payload, frac_bits=int(match.group(1))) + try: + return int(payload) + except (TypeError, ValueError): + return payload + + @classmethod + def _fix_decoded(cls, decoded: list | dict | Self) -> Self: + if isinstance(decoded, SubnetHyperparameters): + return decoded + + if isinstance(decoded, dict): + entries = decoded.items() + else: + entries = ((record["name"], record["value"]) for record in decoded) + + flat: dict[str, Any] = {} + for name, value in entries: + if isinstance(name, (bytes, bytearray)): + name = name.decode("utf-8", errors="replace") + if not isinstance(name, str): + continue + flat[name] = cls._decode_value(value) + + for chain_name, attr_name in _FIELD_ALIASES.items(): + if chain_name in flat and attr_name not in flat: + flat[attr_name] = flat[chain_name] + + typed_names = cls._typed_field_names() + typed_kwargs = {name: flat[name] for name in typed_names if name in flat} + spillover = { + name: value for name, value in flat.items() if name not in typed_names + } + return cls(hyperparameters=spillover, **typed_kwargs) + + @classmethod + def from_any(cls, data: Any) -> Self: + return cls._fix_decoded(data) + + @classmethod + def from_dict(cls, decoded: dict) -> Self: + return cls.from_any(decoded) + + def __getattr__(self, item: str) -> Any: + try: + return self.__dict__["hyperparameters"][item] + except KeyError: + raise AttributeError( + f"{type(self).__name__!r} object has no hyperparameter {item!r}" + ) + + def __getitem__(self, item: str) -> Any: + if item in self._typed_field_names(): + return getattr(self, item) + return self.hyperparameters[item] + + def __iter__(self): + return self.keys() + + def __contains__(self, item: str) -> bool: + if item in self._typed_field_names(): + return getattr(self, item) is not None + return item in self.hyperparameters + + def get(self, item: str, default: Any = None) -> Any: + if item in self._typed_field_names(): + value = getattr(self, item) + return default if value is None else value + return self.hyperparameters.get(item, default) + + def items(self): + for name in sorted(self._typed_field_names()): + value = getattr(self, name) + if value is not None: + yield name, value + yield from self.hyperparameters.items() + + def keys(self): + return (name for name, _ in self.items()) + + def values(self): + return (value for _, value in self.items()) diff --git a/bittensor/core/extrinsics/asyncex/tempo_control.py b/bittensor/core/extrinsics/asyncex/tempo_control.py new file mode 100644 index 0000000000..9510370375 --- /dev/null +++ b/bittensor/core/extrinsics/asyncex/tempo_control.py @@ -0,0 +1,283 @@ +"""Async extrinsics for tempo-control operations (subnet owner and root sudo).""" + +from typing import TYPE_CHECKING, Optional + +from bittensor.core.extrinsics.asyncex.mev_shield import submit_encrypted_extrinsic +from bittensor.core.extrinsics.pallets import SubtensorModule, Sudo +from bittensor.core.settings import DEFAULT_MEV_PROTECTION +from bittensor.core.types import ExtrinsicResponse + +if TYPE_CHECKING: + from bittensor_wallet import Wallet + from bittensor.core.async_subtensor import AsyncSubtensor + + +async def root_set_activity_cutoff_factor_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + netuid: int, + factor_milli: int, + *, + 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 activity cutoff factor via sudo (root only). + + Parameters: + subtensor: The AsyncSubtensor client instance used for blockchain interaction. + wallet: The wallet used to sign the extrinsic (must be unlocked). + netuid: The unique identifier of the subnet. + factor_milli: Activity cutoff factor in per-mille units. + 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: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + 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. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + inner = await SubtensorModule(subtensor).set_activity_cutoff_factor( + netuid=netuid, factor_milli=factor_milli + ) + call = await Sudo(subtensor).sudo(call=inner) + + 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) + + +async def set_activity_cutoff_factor_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + netuid: int, + factor_milli: int, + *, + 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 activity cutoff factor for a subnet. Owner (coldkey) only. + + Parameters: + subtensor: The AsyncSubtensor client instance used for blockchain interaction. + wallet: The wallet used to sign the extrinsic (coldkey must be the subnet owner). + netuid: The unique identifier of the subnet. + factor_milli: Activity cutoff factor in per-mille units. + 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: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + 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. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + call = await SubtensorModule(subtensor).set_activity_cutoff_factor( + netuid=netuid, factor_milli=factor_milli + ) + + 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) + + +async def set_tempo_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + netuid: int, + tempo: int, + *, + 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 epoch tempo for a subnet. Owner (coldkey) only. + + Parameters: + subtensor: The AsyncSubtensor client instance used for blockchain interaction. + wallet: The wallet used to sign the extrinsic (coldkey must be the subnet owner). + netuid: The unique identifier of the subnet. + tempo: New tempo value (blocks per epoch). + 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: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + 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. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + call = await SubtensorModule(subtensor).set_tempo(netuid=netuid, tempo=tempo) + + 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) + + +async def trigger_epoch_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + netuid: int, + *, + 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: + """ + Schedules an owner-triggered epoch to fire after the admin freeze window elapses. Owner (coldkey) only. + + Parameters: + subtensor: The AsyncSubtensor client instance used for blockchain interaction. + wallet: The wallet used to sign the extrinsic (coldkey must be the subnet owner). + netuid: The unique identifier of the subnet. + 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: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + 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. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + call = await SubtensorModule(subtensor).trigger_epoch(netuid=netuid) + + 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/asyncex/weights.py b/bittensor/core/extrinsics/asyncex/weights.py index bfed04bc8a..a08bd3b382 100644 --- a/bittensor/core/extrinsics/asyncex/weights.py +++ b/bittensor/core/extrinsics/asyncex/weights.py @@ -2,7 +2,7 @@ from typing import Optional, Union, TYPE_CHECKING -from bittensor_drand import get_encrypted_commit +from bittensor_drand import get_encrypted_commit_v2 from bittensor.core.extrinsics.asyncex.mev_shield import submit_encrypted_extrinsic from bittensor.core.extrinsics.pallets import SubtensorModule @@ -81,19 +81,19 @@ async def commit_timelocked_weights_extrinsic( subnet_hyperparameters = await subtensor.get_subnet_hyperparameters( netuid, block=current_block ) - tempo = subnet_hyperparameters.tempo subnet_reveal_period_epochs = subnet_hyperparameters.commit_reveal_period - storage_index = get_mechid_storage_index(netuid=netuid, mechid=mechid) - - # Encrypt `commit_hash` with t-lock and `get reveal_round` - commit_for_reveal, reveal_round = get_encrypted_commit( - uids=uids, - weights=weights, + schedule = await subtensor.get_epoch_schedule_state(netuid, block=current_block) + commit_for_reveal, reveal_round = get_encrypted_commit_v2( + uids=list(uids), + weights=list(weights), version_key=version_key, - tempo=tempo, - current_block=current_block, - netuid=storage_index, + last_epoch_block=schedule.last_epoch_block, + pending_epoch_at=schedule.pending_epoch_at, + subnet_epoch_index=schedule.subnet_epoch_index, + tempo=schedule.tempo, + blocks_since_last_step=schedule.blocks_since_last_step, + current_block=schedule.current_block, subnet_reveal_period_epochs=subnet_reveal_period_epochs, block_time=block_time, hotkey=wallet.hotkey.public_key, diff --git a/bittensor/core/extrinsics/pallets/subtensor_module.py b/bittensor/core/extrinsics/pallets/subtensor_module.py index 9f6daf5f17..ace42e02f2 100644 --- a/bittensor/core/extrinsics/pallets/subtensor_module.py +++ b/bittensor/core/extrinsics/pallets/subtensor_module.py @@ -848,3 +848,38 @@ def transfer_stake( destination_netuid=destination_netuid, alpha_amount=alpha_amount, ) + + def set_tempo(self, netuid: int, tempo: int) -> Call: + """Returns GenericCall instance for SubtensorModule.set_tempo. + + Parameters: + netuid: The unique identifier of the subnet. + tempo: New tempo value (blocks per epoch). + + Returns: + GenericCall instance. + """ + return self.create_composed_call(netuid=netuid, tempo=tempo) + + def set_activity_cutoff_factor(self, netuid: int, factor_milli: int) -> Call: + """Returns GenericCall instance for SubtensorModule.set_activity_cutoff_factor. + + Parameters: + netuid: The unique identifier of the subnet. + factor_milli: Activity cutoff factor in per-mille units. + + Returns: + GenericCall instance. + """ + return self.create_composed_call(netuid=netuid, factor_milli=factor_milli) + + def trigger_epoch(self, netuid: int) -> Call: + """Returns GenericCall instance for SubtensorModule.trigger_epoch. + + Parameters: + netuid: The unique identifier of the subnet. + + Returns: + GenericCall instance. + """ + return self.create_composed_call(netuid=netuid) diff --git a/bittensor/core/extrinsics/tempo_control.py b/bittensor/core/extrinsics/tempo_control.py new file mode 100644 index 0000000000..62e14b0b95 --- /dev/null +++ b/bittensor/core/extrinsics/tempo_control.py @@ -0,0 +1,283 @@ +"""Sync extrinsics for tempo-control operations (subnet owner and root sudo).""" + +from typing import TYPE_CHECKING, Optional + +from bittensor.core.extrinsics.mev_shield import submit_encrypted_extrinsic +from bittensor.core.extrinsics.pallets import SubtensorModule, Sudo +from bittensor.core.settings import DEFAULT_MEV_PROTECTION +from bittensor.core.types import ExtrinsicResponse + +if TYPE_CHECKING: + from bittensor_wallet import Wallet + from bittensor.core.subtensor import Subtensor + + +def root_set_activity_cutoff_factor_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + netuid: int, + factor_milli: int, + *, + 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 activity cutoff factor via sudo (root only). + + Parameters: + subtensor: The Subtensor client instance used for blockchain interaction. + wallet: The wallet used to sign the extrinsic (must be unlocked). + netuid: The unique identifier of the subnet. + factor_milli: Activity cutoff factor in per-mille units. + 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: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + 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. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + inner = SubtensorModule(subtensor).set_activity_cutoff_factor( + netuid=netuid, factor_milli=factor_milli + ) + call = Sudo(subtensor).sudo(call=inner) + + 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) + + +def set_activity_cutoff_factor_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + netuid: int, + factor_milli: int, + *, + 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 activity cutoff factor for a subnet. Owner (coldkey) only. + + Parameters: + subtensor: The Subtensor client instance used for blockchain interaction. + wallet: The wallet used to sign the extrinsic (coldkey must be the subnet owner). + netuid: The unique identifier of the subnet. + factor_milli: Activity cutoff factor in per-mille units. + 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: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + 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. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + call = SubtensorModule(subtensor).set_activity_cutoff_factor( + netuid=netuid, factor_milli=factor_milli + ) + + 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) + + +def set_tempo_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + netuid: int, + tempo: int, + *, + 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 epoch tempo for a subnet. Owner (coldkey) only. + + Parameters: + subtensor: The Subtensor client instance used for blockchain interaction. + wallet: The wallet used to sign the extrinsic (coldkey must be the subnet owner). + netuid: The unique identifier of the subnet. + tempo: New tempo value (blocks per epoch). + 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: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + 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. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + call = SubtensorModule(subtensor).set_tempo(netuid=netuid, tempo=tempo) + + 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) + + +def trigger_epoch_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + netuid: int, + *, + 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: + """ + Schedules an owner-triggered epoch to fire after the admin freeze window elapses. Owner (coldkey) only. + + Parameters: + subtensor: The Subtensor client instance used for blockchain interaction. + wallet: The wallet used to sign the extrinsic (coldkey must be the subnet owner). + netuid: The unique identifier of the subnet. + 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: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + 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. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + call = SubtensorModule(subtensor).trigger_epoch(netuid=netuid) + + 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/extrinsics/weights.py b/bittensor/core/extrinsics/weights.py index 0f64c4aa89..618f8e807e 100644 --- a/bittensor/core/extrinsics/weights.py +++ b/bittensor/core/extrinsics/weights.py @@ -2,7 +2,7 @@ from typing import Optional, Union, TYPE_CHECKING -from bittensor_drand import get_encrypted_commit +from bittensor_drand import get_encrypted_commit_v2 from bittensor.core.extrinsics.mev_shield import submit_encrypted_extrinsic from bittensor.core.extrinsics.pallets import SubtensorModule @@ -82,19 +82,19 @@ def commit_timelocked_weights_extrinsic( subnet_hyperparameters = subtensor.get_subnet_hyperparameters( netuid, block=current_block ) - tempo = subnet_hyperparameters.tempo subnet_reveal_period_epochs = subnet_hyperparameters.commit_reveal_period - storage_index = get_mechid_storage_index(netuid=netuid, mechid=mechid) - - # Encrypt `commit_hash` with t-lock and `get reveal_round` - commit_for_reveal, reveal_round = get_encrypted_commit( - uids=uids, - weights=weights, + schedule = subtensor.get_epoch_schedule_state(netuid, block=current_block) + commit_for_reveal, reveal_round = get_encrypted_commit_v2( + uids=list(uids), + weights=list(weights), version_key=version_key, - tempo=tempo, - current_block=current_block, - netuid=storage_index, + last_epoch_block=schedule.last_epoch_block, + pending_epoch_at=schedule.pending_epoch_at, + subnet_epoch_index=schedule.subnet_epoch_index, + tempo=schedule.tempo, + blocks_since_last_step=schedule.blocks_since_last_step, + current_block=schedule.current_block, subnet_reveal_period_epochs=subnet_reveal_period_epochs, block_time=block_time, hotkey=wallet.hotkey.public_key, diff --git a/bittensor/core/subtensor.py b/bittensor/core/subtensor.py index 8e0db75d43..17951b9c15 100644 --- a/bittensor/core/subtensor.py +++ b/bittensor/core/subtensor.py @@ -49,6 +49,7 @@ decode_revealed_commitment, decode_revealed_commitment_with_hotkey, ) +from bittensor.utils import epoch_schedule from bittensor.core.config import Config from bittensor.core.errors import ChainError, chain_error_from_substrate_exception from bittensor.core.extrinsics.children import ( @@ -125,6 +126,12 @@ ) from bittensor.core.extrinsics.start_call import start_call_extrinsic from bittensor.core.extrinsics.take import set_take_extrinsic +from bittensor.core.extrinsics.tempo_control import ( + root_set_activity_cutoff_factor_extrinsic, + set_activity_cutoff_factor_extrinsic, + set_tempo_extrinsic, + trigger_epoch_extrinsic, +) from bittensor.core.extrinsics.transfer import transfer_extrinsic from bittensor.core.extrinsics.unstaking import ( unstake_all_extrinsic, @@ -149,6 +156,7 @@ ) from bittensor.core.types import ( BlockInfo, + EpochScheduleState, ExtrinsicResponse, LockState, Salt, @@ -896,30 +904,25 @@ def blocks_since_last_update( return None if len(call) == 0 else (block - int(call[uid])) def blocks_until_next_epoch( - self, netuid: int, tempo: Optional[int] = None, block: Optional[int] = None + self, netuid: int, *, block: Optional[int] = None ) -> Optional[int]: - """Returns the number of blocks until the next epoch of subnet with provided netuid. + """Returns the number of blocks until the next epoch for the given subnet. + + Derives the answer from the ``get_next_epoch_start_block`` runtime API. Parameters: netuid: The unique identifier of the subnetwork. - tempo: The tempo of the subnet. - block: the block number for this query. + block: The block number to query. If ``None``, queries the current chain head. Returns: - The number of blocks until the next epoch of the subnet with provided netuid. + The number of blocks remaining, or ``None`` if the subnet has + tempo 0 (no epochs). """ - block = block or self.block - - tempo = tempo or self.tempo(netuid=netuid) - if not tempo: + block_number = block or self.block + next_start = self.get_next_epoch_start_block(netuid, block=block_number) + if next_start is None: return None - - # the logic is the same as in SubtensorModule:blocks_until_next_epoch - netuid_plus_one = int(netuid) + 1 - tempo_plus_one = tempo + 1 - adjusted_block = (block + netuid_plus_one) % (2**64) - remainder = adjusted_block % tempo_plus_one - return tempo - remainder + return max(0, next_start - block_number) def bonds( self, @@ -1043,6 +1046,24 @@ def does_hotkey_exist(self, hotkey_ss58: str, block: Optional[int] = None) -> bo ) return result.value != "5C4hrfjw9DjXZTzV3MwzrrAr9P1MJhSrvWGWqi1eSuyUpnhM" + def get_activity_cutoff_factor_milli( + self, netuid: int, block: Optional[int] = None + ) -> int: + """Returns the activity cutoff factor (per-mille) for the given subnet. + + Parameters: + netuid: The unique identifier of the subnetwork. + block: The block number to query. If `None`, queries the current chain head. + + Returns: + The activity cutoff factor in per-mille units. + """ + query = self.query_subtensor( + name="ActivityCutoffFactorMilli", block=block, params=[netuid] + ) + + return cast(int, query.value) + def get_admin_freeze_window(self, block: Optional[int] = None) -> int: """Returns the duration, in blocks, of the administrative freeze window at the end of each epoch. @@ -2308,6 +2329,33 @@ def get_ema_tao_inflow( # TODO verify this from rao, seems like we're just rounding down return block_updated, Balance.from_rao(ema_value) + def get_epoch_schedule_state( + self, netuid: int, block: Optional[int] = None + ) -> "EpochScheduleState": + """Returns a snapshot of all epoch-related storage for the given subnet. + + All fields are read at the same block to ensure consistency. + Used by `bittensor-drand` v2 for commit/reveal prediction. + + Parameters: + netuid: The unique identifier of the subnetwork. + block: The block number to query. If `None`, queries the current chain head. + + Returns: + An `EpochScheduleState` populated from on-chain storage. + """ + block_number = block or self.block + return EpochScheduleState( + last_epoch_block=self.get_last_epoch_block(netuid, block=block_number), + pending_epoch_at=self.get_pending_epoch_at(netuid, block=block_number), + subnet_epoch_index=self.get_subnet_epoch_index(netuid, block=block_number), + tempo=cast(int, self.tempo(netuid, block=block_number)), + blocks_since_last_step=cast( + int, self.blocks_since_last_step(netuid, block=block_number) + ), + current_block=block_number, + ) + def get_hotkey_conviction( self, hotkey_ss58: str, @@ -2418,6 +2466,21 @@ def get_last_commitment_bonds_reset_block( block_data = self.get_last_bonds_reset(netuid, hotkey_ss58, block) return block_data.value + def get_last_epoch_block(self, netuid: int, block: Optional[int] = None) -> int: + """Returns the block number at which the last epoch ran for the given subnet. + + Parameters: + netuid: The unique identifier of the subnetwork. + block: The block number to query. If `None`, queries the current chain head. + + Returns: + The block number of the most recent epoch. + """ + query = self.query_subtensor( + name="LastEpochBlock", block=block, params=[netuid] + ) + return cast(int, query.value) + def get_liquidity_list( self, wallet: "Wallet", @@ -2445,6 +2508,75 @@ def get_liquidity_list( ) return [] + def get_max_activity_cutoff_factor_milli(self, block: Optional[int] = None) -> int: + """Returns the upper bound for the activity-cutoff factor (per-mille). + + This chain constant defines the maximum value a subnet owner can set for the activity-cutoff factor via + ``set_activity_cutoff_factor``. The factor is expressed in per-mille units relative to the subnet's tempo. + + Parameters: + block: The blockchain block number for the query. + + Returns: + The upper bound for the activity-cutoff factor in per-mille. + """ + result = self.substrate.get_constant( + module_name="SubtensorModule", + constant_name="MaxActivityCutoffFactorMilli", + block_hash=self.determine_block_hash(block), + ) + + if result is None: + raise Exception("Unable to retrieve MaxActivityCutoffFactorMilli constant.") + + return cast(int, result.value) + + def get_max_epochs_per_block(self, block: Optional[int] = None) -> int: + """Returns the per-block cap on the number of subnet epochs that may execute in a single block step. + + When more subnets are due for an epoch than this cap allows, excess epochs are deferred to the next block via + ``PendingEpochAt``. + + Parameters: + block: The blockchain block number for the query. + + Returns: + The maximum number of subnet epochs allowed per block. + """ + result = self.substrate.get_constant( + module_name="SubtensorModule", + constant_name="MaxEpochsPerBlock", + block_hash=self.determine_block_hash(block), + ) + + if result is None: + raise Exception("Unable to retrieve MaxEpochsPerBlock constant.") + + return cast(int, result.value) + + def get_max_tempo(self, block: Optional[int] = None) -> int: + """Returns the upper bound for owner-set tempo. + + This chain constant defines the maximum epoch period (in blocks) that a subnet owner can configure via + ``set_tempo``. + + Parameters: + block: The blockchain block number for the query. + + Returns: + The maximum allowed tempo value in blocks. + """ + result = self.substrate.get_constant( + module_name="SubtensorModule", + constant_name="MaxTempo", + block_hash=self.determine_block_hash(block), + ) + + if result is None: + raise Exception("Unable to retrieve MaxTempo constant.") + + return cast(int, result.value) + def get_mechanism_emission_split( self, netuid: int, block: Optional[int] = None ) -> Optional[list[int]]: @@ -2685,6 +2817,52 @@ def get_mev_shield_next_key(self, block: Optional[int] = None) -> Optional[bytes return public_key_bytes + def get_min_activity_cutoff_factor_milli(self, block: Optional[int] = None) -> int: + """Returns the lower bound for the activity-cutoff factor (per-mille). + + This chain constant defines the minimum value a subnet owner can set for the activity-cutoff factor via + ``set_activity_cutoff_factor``. The factor is expressed in per-mille units relative to the subnet's tempo. + + Parameters: + block: The blockchain block number for the query. + + Returns: + The lower bound for the activity-cutoff factor in per-mille. + """ + result = self.substrate.get_constant( + module_name="SubtensorModule", + constant_name="MinActivityCutoffFactorMilli", + block_hash=self.determine_block_hash(block), + ) + + if result is None: + raise Exception("Unable to retrieve MinActivityCutoffFactorMilli constant.") + + return cast(int, result.value) + + def get_min_tempo(self, block: Optional[int] = None) -> int: + """Returns the lower bound for owner-set tempo. + + This chain constant defines the minimum epoch period (in blocks) that a subnet owner can configure via + ``set_tempo``. Also serves as the fixed cooldown between consecutive ``set_tempo`` calls. + + Parameters: + block: The blockchain block number for the query. + + Returns: + The minimum allowed tempo value in blocks. + """ + result = self.substrate.get_constant( + module_name="SubtensorModule", + constant_name="MinTempo", + block_hash=self.determine_block_hash(block), + ) + + if result is None: + raise Exception("Unable to retrieve MinTempo constant.") + + return cast(int, result.value) + def get_minimum_required_stake(self) -> Balance: """Returns the minimum required stake threshold for nominator cleanup operations. @@ -2830,36 +3008,30 @@ def get_neuron_for_pubkey_and_subnet( def get_next_epoch_start_block( self, netuid: int, block: Optional[int] = None ) -> Optional[int]: - """ - Calculates the first block number of the next epoch for the given subnet. + """Returns the block at which the next epoch will fire for the given subnet. - If `block` is not provided, the current chain block will be used. Epochs are determined based on the subnet's - tempo (i.e., blocks per epoch). The result is the block number at which the next epoch will begin. + Delegates to the ``SubnetInfoRuntimeApi.get_next_epoch_start_block`` + runtime API, which accounts for both the auto-timer + (``last_epoch_block + tempo``) and any pending owner-triggered epoch. Parameters: netuid: The unique identifier of the subnet. - block: The reference block to calculate from. If None, uses the current chain block height. + block: The reference block to query. If ``None``, uses the current chain head. Returns: - int: The block number at which the next epoch will start, or None if tempo is 0 or invalid. + The block number at which the next epoch will start, or ``None`` + if tempo is 0 (subnet does not run epochs). Notes: - """ - tempo = self.tempo(netuid=netuid, block=block) - current_block = block or self.block - - if not tempo: - return None - - blocks_until = self.blocks_until_next_epoch( - netuid=netuid, tempo=tempo, block=current_block + result = self.query_runtime_api( + runtime_api="SubnetInfoRuntimeApi", + method="get_next_epoch_start_block", + params=[netuid], + block=block, ) - - if blocks_until is None: - return None - - return current_block + blocks_until + 1 + return None if result is None else int(result) def get_owned_hotkeys( self, @@ -2885,6 +3057,22 @@ def get_owned_hotkeys( ) return owned_hotkeys.value or [] + def get_owner_hyperparam_rate_limit(self, block: Optional[int] = None) -> int: + """Returns the owner hyperparameter rate limit (in tempos). + + Parameters: + block: The block number to query. If `None`, queries the current chain head. + + Returns: + The rate limit in tempos. + """ + query: ScaleType[int] = self.substrate.query( + module="SubtensorModule", + storage_function="OwnerHyperparamRateLimit", + block_hash=self.determine_block_hash(block), + ) + return query.value + def get_parents( self, hotkey_ss58: str, netuid: int, block: Optional[int] = None ) -> list[tuple[float, str]]: @@ -2920,6 +3108,21 @@ def get_parents( return [] + def get_pending_epoch_at(self, netuid: int, block: Optional[int] = None) -> int: + """Returns the pending (owner-triggered) epoch block, or 0 if none is scheduled. + + Parameters: + netuid: The unique identifier of the subnetwork. + block: The block number to query. If `None`, queries the current chain head. + + Returns: + The block at which the triggered epoch will fire, or 0. + """ + query = self.query_subtensor( + name="PendingEpochAt", block=block, params=[netuid] + ) + return cast(int, query.value) + def get_proxies(self, block: Optional[int] = None) -> dict[str, list[ProxyInfo]]: """ Retrieves all proxy relationships from the chain. @@ -3819,34 +4022,45 @@ def get_subnet_burn_cost(self, block: Optional[int] = None) -> Optional[Balance] else: return lock_cost + def get_subnet_epoch_index(self, netuid: int, block: Optional[int] = None) -> int: + """Returns the monotonic epoch counter for the given subnet. + + Parameters: + netuid: The unique identifier of the subnetwork. + block: The block number to query. If `None`, queries the current chain head. + + Returns: + The current epoch index. + """ + query = self.query_subtensor( + name="SubnetEpochIndex", block=block, params=[netuid] + ) + return cast(int, query.value) + def get_subnet_hyperparameters( self, netuid: int, block: Optional[int] = None - ) -> Optional[Union[list, "SubnetHyperparameters"]]: - """ - Retrieves the hyperparameters for a specific subnet within the Bittensor network. These hyperparameters define - the operational settings and rules governing the subnet's behavior. + ) -> Optional["SubnetHyperparameters"]: + """Retrieves the hyperparameters for a specific subnet. + + Tries the v3 ``Vec`` runtime API first, then falls + back to v2 and v1 struct APIs at the requested block. Parameters: netuid: The network UID of the subnet to query. block: The blockchain block number for the query. Returns: - The subnet's hyperparameters, or `None` if not available. - - Understanding the hyperparameters is crucial for comprehending how subnets are configured and managed, and how - they interact with the network's consensus and incentive mechanisms. + The subnet's hyperparameters, or ``None`` if not available. """ - result = self.query_runtime_api( - runtime_api="SubnetInfoRuntimeApi", - method="get_subnet_hyperparams_v2", - params=[netuid], - block=block, + block_hash = self.determine_block_hash(block) + result = self._runtime_call_with_fallback( + ("SubnetInfoRuntimeApi", "get_subnet_hyperparams_v3", [netuid]), + ("SubnetInfoRuntimeApi", "get_subnet_hyperparams_v2", [netuid]), + ("SubnetInfoRuntimeApi", "get_subnet_hyperparams", [netuid]), + block_hash=block_hash, + default_value=None, ) - - if not result: - return None - - return SubnetHyperparameters.from_dict(result) + return SubnetHyperparameters.from_any(result) if result else None def get_subnet_info( self, netuid: int, block: Optional[int] = None @@ -4305,32 +4519,31 @@ def is_in_admin_freeze_window( netuid: int, block: Optional[int] = None, ) -> bool: - """ - Returns True if the current block is within the terminal freeze window of the tempo - for the given subnet. During this window, admin ops are prohibited to avoid interference - with validator weight submissions. + """Returns whether owner operations are currently blocked for the subnet. + + Matches the chain's ``is_in_admin_freeze_window`` logic: a pending + triggered epoch in the future **or** fewer than ``admin_freeze_window`` + blocks remaining until the next auto epoch. Parameters: netuid: The unique identifier of the subnet. block: The blockchain block number for the query. Returns: - bool: True if in freeze window, else False. + ``True`` if in freeze window, ``False`` otherwise. """ - # SN0 doesn't have admin_freeze_window if netuid == 0: return False - next_epoch_start_block = self.get_next_epoch_start_block( - netuid=netuid, block=block + block_number = block or self.block + return epoch_schedule.is_in_admin_freeze_window( + tempo=self.tempo(netuid, block=block_number) or 0, + pending_epoch_at=self.get_pending_epoch_at(netuid, block=block_number), + last_epoch_block=self.get_last_epoch_block(netuid, block=block_number), + block_number=block_number, + admin_freeze_window=self.get_admin_freeze_window(block=block_number), ) - if next_epoch_start_block is not None: - remaining = next_epoch_start_block - self.block - window = self.get_admin_freeze_window(block=block) - return remaining < window - return False - def is_fast_blocks(self) -> bool: """Checks if the node is running with fast blocks enabled. @@ -7416,6 +7629,53 @@ def root_register( wait_for_revealed_execution=wait_for_revealed_execution, ) + def root_set_activity_cutoff_factor( + self, + wallet: "Wallet", + netuid: int, + factor_milli: int, + *, + 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 activity cutoff factor via sudo (root only). + + Parameters: + wallet: The wallet used to sign the extrinsic. + netuid: The unique identifier of the subnet. + factor_milli: Activity cutoff factor in per-mille units. + 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: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + 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 root_set_activity_cutoff_factor_extrinsic( + subtensor=self, + wallet=wallet, + netuid=netuid, + factor_milli=factor_milli, + 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 root_set_pending_childkey_cooldown( self, wallet: "Wallet", @@ -7461,6 +7721,53 @@ def root_set_pending_childkey_cooldown( wait_for_revealed_execution=wait_for_revealed_execution, ) + def set_activity_cutoff_factor( + self, + wallet: "Wallet", + netuid: int, + factor_milli: int, + *, + 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 activity cutoff factor for a subnet. Owner (coldkey) only. + + Parameters: + wallet: The wallet used to sign the extrinsic (coldkey must be the subnet owner). + netuid: The unique identifier of the subnet. + factor_milli: Activity cutoff factor in per-mille units. + 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: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + 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_activity_cutoff_factor_extrinsic( + subtensor=self, + wallet=wallet, + netuid=netuid, + factor_milli=factor_milli, + 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_auto_stake( self, wallet: "Wallet", @@ -7797,6 +8104,53 @@ def set_subnet_identity( wait_for_revealed_execution=wait_for_revealed_execution, ) + def set_tempo( + self, + wallet: "Wallet", + netuid: int, + tempo: int, + *, + 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 epoch tempo for a subnet. Owner (coldkey) only. + + Parameters: + wallet: The wallet used to sign the extrinsic (coldkey must be the subnet owner). + netuid: The unique identifier of the subnet. + tempo: New tempo value (blocks per epoch). + 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: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + 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_tempo_extrinsic( + subtensor=self, + wallet=wallet, + netuid=netuid, + tempo=tempo, + 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_weights( self, wallet: "Wallet", @@ -8467,6 +8821,50 @@ def transfer_stake( wait_for_revealed_execution=wait_for_revealed_execution, ) + def trigger_epoch( + self, + wallet: "Wallet", + netuid: int, + *, + 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: + """ + Schedules an owner-triggered epoch to fire after the admin freeze window elapses. Owner (coldkey) only. + + Parameters: + wallet: The wallet used to sign the extrinsic (coldkey must be the subnet owner). + netuid: The unique identifier of the subnet. + 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: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + 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 trigger_epoch_extrinsic( + subtensor=self, + wallet=wallet, + netuid=netuid, + 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 unstake( self, wallet: "Wallet", diff --git a/bittensor/core/types.py b/bittensor/core/types.py index 03d47d58ac..2ef1bdf77b 100644 --- a/bittensor/core/types.py +++ b/bittensor/core/types.py @@ -673,3 +673,27 @@ class LockState(TypedDict): locked_mass: "Balance" conviction: float last_update: int + + +@dataclass(frozen=True) +class EpochScheduleState: + """Snapshot of on-chain epoch schedule state at a given block. + + Aggregates storage fields needed by the SDK and ``bittensor-drand`` to compute epoch predictions. + All fields are queried at the same ``block_hash`` to ensure consistency. + + Attributes: + last_epoch_block: The block at which the last epoch fired for this subnet. + pending_epoch_at: Block at which an owner-triggered epoch is scheduled (``0`` = none). + subnet_epoch_index: Monotonically increasing epoch counter for this subnet. + tempo: The subnet's tempo (epoch period in blocks). + blocks_since_last_step: Blocks elapsed since the last epoch. + current_block: The reference block at which the snapshot was taken. + """ + + last_epoch_block: int + pending_epoch_at: int + subnet_epoch_index: int + tempo: int + blocks_since_last_step: int + current_block: int diff --git a/bittensor/extras/dev_framework/calls/non_sudo_calls.py b/bittensor/extras/dev_framework/calls/non_sudo_calls.py index a7fbbafd91..31d6cca92d 100644 --- a/bittensor/extras/dev_framework/calls/non_sudo_calls.py +++ b/bittensor/extras/dev_framework/calls/non_sudo_calls.py @@ -11,7 +11,7 @@ Note: Any manual changes will be overwritten the next time the generator is run. - Subtensor spec version: 408 + Subtensor spec version: 417 """ from collections import namedtuple @@ -162,6 +162,9 @@ CANCEL_NAMED = namedtuple( "CANCEL_NAMED", ["wallet", "pallet", "id"] ) # args: [id: TaskName] | Pallet: Scheduler +CANCEL_ORDER = namedtuple( + "CANCEL_ORDER", ["wallet", "pallet", "order"] +) # args: [order: VersionedOrder] | Pallet: LimitOrders CANCEL_RETRY = namedtuple( "CANCEL_RETRY", ["wallet", "pallet", "task"] ) # args: [task: TaskAddress>] | Pallet: Scheduler @@ -307,6 +310,12 @@ "pallet", ], ) # args: [] | Pallet: SafeMode +EXECUTE_BATCHED_ORDERS = namedtuple( + "EXECUTE_BATCHED_ORDERS", ["wallet", "pallet", "netuid", "orders"] +) # args: [netuid: NetUid, orders: BoundedVec, T::MaxOrdersPerBatch>] | Pallet: LimitOrders +EXECUTE_ORDERS = namedtuple( + "EXECUTE_ORDERS", ["wallet", "pallet", "orders", "should_fail"] +) # args: [orders: BoundedVec, T::MaxOrdersPerBatch>, should_fail: bool] | Pallet: LimitOrders EXTEND = namedtuple( "EXTEND", [ @@ -652,6 +661,9 @@ SET = namedtuple( "SET", ["wallet", "pallet", "now"] ) # args: [now: T::Moment] | Pallet: Timestamp +SET_ACTIVITY_CUTOFF_FACTOR = namedtuple( + "SET_ACTIVITY_CUTOFF_FACTOR", ["wallet", "pallet", "netuid", "factor_milli"] +) # args: [netuid: NetUid, factor_milli: u32] | Pallet: SubtensorModule SET_AUTO_PARENT_DELEGATION_ENABLED = namedtuple( "SET_AUTO_PARENT_DELEGATION_ENABLED", ["wallet", "pallet", "hotkey", "enabled"] ) # args: [hotkey: T::AccountId, enabled: bool] | Pallet: SubtensorModule @@ -711,6 +723,9 @@ SET_KEY = namedtuple( "SET_KEY", ["wallet", "pallet", "new"] ) # args: [new: AccountIdLookupOf] | Pallet: Sudo +SET_MAX_CONTRIBUTION = namedtuple( + "SET_MAX_CONTRIBUTION", ["wallet", "pallet", "crowdloan_id", "new_max_contribution"] +) # args: [crowdloan_id: CrowdloanId, new_max_contribution: Option>] | Pallet: Crowdloan SET_MAX_EXTRINSIC_WEIGHT = namedtuple( "SET_MAX_EXTRINSIC_WEIGHT", ["wallet", "pallet", "value"] ) # args: [value: u64] | Pallet: MevShield @@ -730,6 +745,9 @@ SET_ON_INITIALIZE_WEIGHT = namedtuple( "SET_ON_INITIALIZE_WEIGHT", ["wallet", "pallet", "value"] ) # args: [value: u64] | Pallet: MevShield +SET_PALLET_STATUS = namedtuple( + "SET_PALLET_STATUS", ["wallet", "pallet", "enabled"] +) # args: [enabled: bool] | Pallet: LimitOrders SET_PENDING_CHILDKEY_COOLDOWN = namedtuple( "SET_PENDING_CHILDKEY_COOLDOWN", ["wallet", "pallet", "cooldown"] ) # args: [cooldown: u64] | Pallet: SubtensorModule @@ -770,6 +788,9 @@ "additional", ], ) # args: [netuid: NetUid, subnet_name: Vec, github_repo: Vec, subnet_contact: Vec, subnet_url: Vec, discord: Vec, description: Vec, logo_url: Vec, additional: Vec] | Pallet: SubtensorModule +SET_TEMPO = namedtuple( + "SET_TEMPO", ["wallet", "pallet", "netuid", "tempo"] +) # args: [netuid: NetUid, tempo: u16] | Pallet: SubtensorModule SET_WEIGHTS = namedtuple( "SET_WEIGHTS", ["wallet", "pallet", "netuid", "dests", "weights", "version_key"] ) # args: [netuid: NetUid, dests: Vec, weights: Vec, version_key: u64] | Pallet: SubtensorModule @@ -858,6 +879,9 @@ "alpha_amount", ], ) # args: [destination_coldkey: T::AccountId, hotkey: T::AccountId, origin_netuid: NetUid, destination_netuid: NetUid, alpha_amount: AlphaBalance] | Pallet: SubtensorModule +TRIGGER_EPOCH = namedtuple( + "TRIGGER_EPOCH", ["wallet", "pallet", "netuid"] +) # args: [netuid: NetUid] | Pallet: SubtensorModule TRY_ASSOCIATE_HOTKEY = namedtuple( "TRY_ASSOCIATE_HOTKEY", ["wallet", "pallet", "hotkey"] ) # args: [hotkey: T::AccountId] | Pallet: SubtensorModule diff --git a/bittensor/extras/dev_framework/calls/pallets.py b/bittensor/extras/dev_framework/calls/pallets.py index d35ca501e2..942be57eb7 100644 --- a/bittensor/extras/dev_framework/calls/pallets.py +++ b/bittensor/extras/dev_framework/calls/pallets.py @@ -1,5 +1,5 @@ """ " -Subtensor spec version: 408 +Subtensor spec version: 417 """ System = "System" @@ -25,3 +25,4 @@ Swap = "Swap" Contracts = "Contracts" MevShield = "MevShield" +LimitOrders = "LimitOrders" diff --git a/bittensor/extras/dev_framework/calls/sudo_calls.py b/bittensor/extras/dev_framework/calls/sudo_calls.py index 3d3be533ee..8556060366 100644 --- a/bittensor/extras/dev_framework/calls/sudo_calls.py +++ b/bittensor/extras/dev_framework/calls/sudo_calls.py @@ -11,7 +11,7 @@ Note: Any manual changes will be overwritten the next time the generator is run. - Subtensor spec version: 408 + Subtensor spec version: 417 """ from collections import namedtuple @@ -196,6 +196,10 @@ SUDO_SET_NUM_ROOT_CLAIMS = namedtuple( "SUDO_SET_NUM_ROOT_CLAIMS", ["wallet", "pallet", "sudo", "new_value"] ) # args: [new_value: u64] | Pallet: SubtensorModule +SUDO_SET_OWNER_CUT_AUTO_LOCK_ENABLED = namedtuple( + "SUDO_SET_OWNER_CUT_AUTO_LOCK_ENABLED", + ["wallet", "pallet", "sudo", "netuid", "enabled"], +) # args: [netuid: NetUid, enabled: bool] | Pallet: AdminUtils SUDO_SET_OWNER_CUT_ENABLED = namedtuple( "SUDO_SET_OWNER_CUT_ENABLED", ["wallet", "pallet", "sudo", "netuid", "enabled"] ) # args: [netuid: NetUid, enabled: bool] | Pallet: AdminUtils diff --git a/bittensor/extras/dev_framework/subnet.py b/bittensor/extras/dev_framework/subnet.py index 13b15366cc..206a719f17 100644 --- a/bittensor/extras/dev_framework/subnet.py +++ b/bittensor/extras/dev_framework/subnet.py @@ -1,5 +1,4 @@ from typing import Optional, Union -from collections import namedtuple from bittensor_wallet import Wallet from bittensor.core.extrinsics.asyncex.utils import ( diff --git a/bittensor/extras/subtensor_api/chain.py b/bittensor/extras/subtensor_api/chain.py index 4cca40bfd1..ebf60a5702 100644 --- a/bittensor/extras/subtensor_api/chain.py +++ b/bittensor/extras/subtensor_api/chain.py @@ -13,7 +13,17 @@ def __init__(self, subtensor: Union["_Subtensor", "_AsyncSubtensor"]): self.get_current_block = subtensor.get_current_block self.get_delegate_identities = subtensor.get_delegate_identities self.get_existential_deposit = subtensor.get_existential_deposit + self.get_max_activity_cutoff_factor_milli = ( + subtensor.get_max_activity_cutoff_factor_milli + ) + self.get_max_epochs_per_block = subtensor.get_max_epochs_per_block + self.get_max_tempo = subtensor.get_max_tempo + self.get_min_activity_cutoff_factor_milli = ( + subtensor.get_min_activity_cutoff_factor_milli + ) + self.get_min_tempo = subtensor.get_min_tempo self.get_minimum_required_stake = subtensor.get_minimum_required_stake + self.get_owner_hyperparam_rate_limit = subtensor.get_owner_hyperparam_rate_limit self.get_start_call_delay = subtensor.get_start_call_delay self.get_timestamp = subtensor.get_timestamp self.get_vote_data = subtensor.get_vote_data diff --git a/bittensor/extras/subtensor_api/extrinsics.py b/bittensor/extras/subtensor_api/extrinsics.py index 438ae94978..fb2d0b6bc7 100644 --- a/bittensor/extras/subtensor_api/extrinsics.py +++ b/bittensor/extras/subtensor_api/extrinsics.py @@ -46,6 +46,10 @@ def __init__(self, subtensor: Union["_Subtensor", "_AsyncSubtensor"]): self.set_reveal_commitment = subtensor.set_reveal_commitment self.set_root_claim_type = subtensor.set_root_claim_type self.set_subnet_identity = subtensor.set_subnet_identity + self.set_tempo = subtensor.set_tempo + self.set_activity_cutoff_factor = subtensor.set_activity_cutoff_factor + self.root_set_activity_cutoff_factor = subtensor.root_set_activity_cutoff_factor + self.trigger_epoch = subtensor.trigger_epoch self.set_weights = subtensor.set_weights self.start_call = subtensor.start_call self.swap_coldkey_announced = subtensor.swap_coldkey_announced diff --git a/bittensor/extras/subtensor_api/subnets.py b/bittensor/extras/subtensor_api/subnets.py index 37cf2ccece..8ed394bd69 100644 --- a/bittensor/extras/subtensor_api/subnets.py +++ b/bittensor/extras/subtensor_api/subnets.py @@ -16,6 +16,10 @@ def __init__(self, subtensor: Union["_Subtensor", "_AsyncSubtensor"]): self.burned_register = subtensor.burned_register self.commit_reveal_enabled = subtensor.commit_reveal_enabled self.difficulty = subtensor.difficulty + self.get_activity_cutoff_factor_milli = ( + subtensor.get_activity_cutoff_factor_milli + ) + self.get_epoch_schedule_state = subtensor.get_epoch_schedule_state self.get_all_ema_tao_inflow = subtensor.get_all_ema_tao_inflow self.get_all_subnets_info = subtensor.get_all_subnets_info self.get_all_subnets_netuid = subtensor.get_all_subnets_netuid @@ -28,7 +32,10 @@ def __init__(self, subtensor: Union["_Subtensor", "_AsyncSubtensor"]): self.get_neuron_for_pubkey_and_subnet = ( subtensor.get_neuron_for_pubkey_and_subnet ) + self.get_last_epoch_block = subtensor.get_last_epoch_block self.get_next_epoch_start_block = subtensor.get_next_epoch_start_block + self.get_pending_epoch_at = subtensor.get_pending_epoch_at + self.get_subnet_epoch_index = subtensor.get_subnet_epoch_index self.get_mechanism_emission_split = subtensor.get_mechanism_emission_split self.get_mechanism_count = subtensor.get_mechanism_count self.get_subnet_burn_cost = subtensor.get_subnet_burn_cost diff --git a/bittensor/utils/__init__.py b/bittensor/utils/__init__.py index e1ef7a0bd8..89dcc99dfe 100644 --- a/bittensor/utils/__init__.py +++ b/bittensor/utils/__init__.py @@ -25,6 +25,11 @@ from .registration import torch, use_torch from .version import check_version, VersionCheckError +try: + from typing import Self +except ImportError: + from typing_extensions import Self + if TYPE_CHECKING: from bittensor_wallet import Wallet from bittensor.core.types import ExtrinsicResponse, NeuronCertificateResponse diff --git a/bittensor/utils/epoch_schedule.py b/bittensor/utils/epoch_schedule.py new file mode 100644 index 0000000000..725c3b36cf --- /dev/null +++ b/bittensor/utils/epoch_schedule.py @@ -0,0 +1,53 @@ +"""Pure-function ports of subtensor's epoch scheduling logic.""" + + +def blocks_until_next_auto_epoch( + last_epoch_block: int, tempo: int, block_number: int +) -> int: + """Returns the number of blocks remaining before the next automatic epoch. + + Port of ``run_coinbase.rs::blocks_until_next_auto_epoch``. Does not account for ``PendingEpochAt``, the + ``BlocksSinceLastStep > MaxTempo`` safety-net, or the per-block-cap deferral. Caller must guard against + ``tempo == 0`` upstream. + + Parameters: + last_epoch_block: The block at which the last epoch fired for this subnet. + tempo: The subnet's tempo (epoch period in blocks). + block_number: The current (or reference) block number. + + Returns: + blocks_remaining: Non-negative number of blocks until ``last_epoch_block + tempo``. + """ + next_auto = last_epoch_block + tempo + return max(0, next_auto - block_number) + + +def is_in_admin_freeze_window( + *, + tempo: int, + pending_epoch_at: int, + last_epoch_block: int, + block_number: int, + admin_freeze_window: int, +) -> bool: + """Returns whether owner operations are blocked because an epoch is imminent. + + Returns ``True`` when the current block is within the terminal ``admin_freeze_window`` blocks before the next auto + epoch, or a pending manual trigger is armed (``pending_epoch_at > block_number``). + + Parameters: + tempo: The subnet's tempo (epoch period in blocks). Returns ``False`` when zero. + pending_epoch_at: Block at which an owner-triggered epoch is scheduled (``0`` = none). + last_epoch_block: The block at which the last epoch fired for this subnet. + block_number: The current (or reference) block number. + admin_freeze_window: How many blocks before an epoch owner operations are frozen. + + Returns: + is_frozen: ``True`` if owner operations should be blocked. + """ + if tempo == 0: + return False + if pending_epoch_at > 0 and pending_epoch_at > block_number: + return True + remaining = blocks_until_next_auto_epoch(last_epoch_block, tempo, block_number) + return remaining < admin_freeze_window diff --git a/pyproject.toml b/pyproject.toml index 99441ecffc..fea33ca61a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "bittensor" -version = "10.4.1" +version = "10.5.0" description = "Bittensor SDK" readme = "README.md" authors = [ @@ -29,7 +29,7 @@ dependencies = [ "pydantic>=2.3,<3", "cyscale==0.5.0", "uvicorn", - "bittensor-drand>=1.3.0,<2.0.0", + "bittensor-drand>=2.0.0,<3.0.0", "bittensor-wallet>=4.1.0", "async-substrate-interface>=2.0.4,<3.0.0", ] diff --git a/tests/e2e_tests/test_dynamic_tempo.py b/tests/e2e_tests/test_dynamic_tempo.py new file mode 100644 index 0000000000..94e6de3a05 --- /dev/null +++ b/tests/e2e_tests/test_dynamic_tempo.py @@ -0,0 +1,792 @@ +"""E2E tests for configurable tempo and owner-triggered epochs (Subtensor PR #2638).""" + +import time + +import numpy as np +import pytest + +from bittensor.utils.btlogging import logging +from bittensor.utils.weight_utils import convert_weights_and_uids_for_emit +from tests.e2e_tests.utils import ( + AdminUtils, + TestSubnet, + ACTIVATE_SUBNET, + REGISTER_SUBNET, + SUDO_SET_ADMIN_FREEZE_WINDOW, + SUDO_SET_COMMIT_REVEAL_WEIGHTS_ENABLED, + SUDO_SET_LOCK_REDUCTION_INTERVAL, + SUDO_SET_NETWORK_RATE_LIMIT, + SUDO_SET_TEMPO, + SUDO_SET_WEIGHTS_SET_RATE_LIMIT, + NETUID, +) + + +def _setup_subnet(subtensor, wallet, tempo=None, admin_freeze_window=0): + """Register and activate a subnet with the given tempo and freeze window.""" + if tempo is None: + tempo = subtensor.chain.get_min_tempo() + sn = TestSubnet(subtensor) + sn.execute_steps( + [ + SUDO_SET_ADMIN_FREEZE_WINDOW(wallet, AdminUtils, True, admin_freeze_window), + SUDO_SET_NETWORK_RATE_LIMIT(wallet, AdminUtils, True, 0), + SUDO_SET_LOCK_REDUCTION_INTERVAL(wallet, AdminUtils, True, 1), + ] + ) + sn.execute_steps( + [ + REGISTER_SUBNET(wallet), + SUDO_SET_TEMPO(wallet, AdminUtils, True, NETUID, tempo), + ACTIVATE_SUBNET(wallet), + ] + ) + return sn + + +async def _setup_subnet_async( + async_subtensor, wallet, tempo=None, admin_freeze_window=0 +): + """Register and activate a subnet with the given tempo and freeze window.""" + if tempo is None: + tempo = await async_subtensor.chain.get_min_tempo() + sn = TestSubnet(async_subtensor) + await sn.async_execute_steps( + [ + SUDO_SET_ADMIN_FREEZE_WINDOW(wallet, AdminUtils, True, admin_freeze_window), + SUDO_SET_NETWORK_RATE_LIMIT(wallet, AdminUtils, True, 0), + SUDO_SET_LOCK_REDUCTION_INTERVAL(wallet, AdminUtils, True, 1), + ] + ) + await sn.async_execute_steps( + [ + REGISTER_SUBNET(wallet), + SUDO_SET_TEMPO(wallet, AdminUtils, True, NETUID, tempo), + ACTIVATE_SUBNET(wallet), + ] + ) + return sn + + +def test_set_tempo(subtensor, alice_wallet): + """ + Verify owner set_tempo resets LastEpochBlock and matches get_next_epoch_start_block. + + Steps: + 1. Register and activate a subnet. + 2. Call owner set_tempo. + 3. Verify runtime epoch schedule and hyperparameters tempo. + """ + sn = _setup_subnet(subtensor, alice_wallet) + netuid = sn.netuid + + new_tempo = subtensor.chain.get_min_tempo() + 10 + result = subtensor.extrinsics.set_tempo( + wallet=alice_wallet, + netuid=netuid, + tempo=new_tempo, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert result.success, result.message + + block_at_set = subtensor.subnets.get_last_epoch_block(netuid) + assert ( + subtensor.subnets.get_next_epoch_start_block(netuid) == block_at_set + new_tempo + ) + + hp = subtensor.subnets.get_subnet_hyperparameters(netuid) + assert hp.tempo == new_tempo + + epoch_index = subtensor.subnets.get_subnet_epoch_index(netuid) + assert isinstance(epoch_index, int) and epoch_index >= 0 + + rate_limit = subtensor.chain.get_owner_hyperparam_rate_limit() + assert isinstance(rate_limit, int) and rate_limit > 0 + + +@pytest.mark.asyncio +async def test_set_tempo_async(async_subtensor, alice_wallet): + """ + Verify async owner set_tempo resets LastEpochBlock and matches get_next_epoch_start_block. + + Steps: + 1. Register and activate a subnet. + 2. Call owner set_tempo. + 3. Verify runtime epoch schedule and hyperparameters tempo. + """ + sn = await _setup_subnet_async(async_subtensor, alice_wallet) + netuid = sn.netuid + + new_tempo = await async_subtensor.chain.get_min_tempo() + 10 + result = await async_subtensor.extrinsics.set_tempo( + wallet=alice_wallet, + netuid=netuid, + tempo=new_tempo, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert result.success, result.message + + block_at_set = await async_subtensor.subnets.get_last_epoch_block(netuid) + next_start = await async_subtensor.subnets.get_next_epoch_start_block(netuid) + assert next_start == block_at_set + new_tempo + + hp = await async_subtensor.subnets.get_subnet_hyperparameters(netuid) + assert hp.tempo == new_tempo + + epoch_index = await async_subtensor.subnets.get_subnet_epoch_index(netuid) + assert isinstance(epoch_index, int) and epoch_index >= 0 + + rate_limit = await async_subtensor.chain.get_owner_hyperparam_rate_limit() + assert isinstance(rate_limit, int) and rate_limit > 0 + + +def test_trigger_epoch(subtensor, alice_wallet): + """ + Verify owner trigger_epoch is blocked by commit-reveal and succeeds after disabling it. + + Steps: + 1. Register and activate a subnet with a long tempo (CR enabled by default). + 2. Set admin freeze window. + 3. Attempt trigger_epoch — expect DynamicTempoBlockedByCommitReveal rejection. + 4. Disable commit-reveal. + 5. Call trigger_epoch again — expect success. + 6. Verify pending epoch and freeze window state. + """ + max_tempo = subtensor.chain.get_max_tempo() + sn = _setup_subnet(subtensor, alice_wallet, tempo=max_tempo, admin_freeze_window=0) + netuid = sn.netuid + + sn.execute_steps([SUDO_SET_ADMIN_FREEZE_WINDOW(alice_wallet, AdminUtils, True, 10)]) + freeze_window = subtensor.chain.get_admin_freeze_window() + + result = subtensor.extrinsics.trigger_epoch( + wallet=alice_wallet, + netuid=netuid, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert result.success is False + + sn.execute_steps( + [ + SUDO_SET_COMMIT_REVEAL_WEIGHTS_ENABLED( + alice_wallet, AdminUtils, True, NETUID, False + ) + ] + ) + + result = subtensor.extrinsics.trigger_epoch( + wallet=alice_wallet, + netuid=netuid, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert result.success, result.message + + pending = subtensor.subnets.get_pending_epoch_at(netuid) + assert pending > 0 + trigger_block = pending - freeze_window + assert pending == trigger_block + freeze_window + assert pending > subtensor.block + assert subtensor.chain.is_in_admin_freeze_window(netuid) is True + + +@pytest.mark.asyncio +async def test_trigger_epoch_async(async_subtensor, alice_wallet): + """ + Verify async owner trigger_epoch is blocked by commit-reveal and succeeds after disabling it. + + Steps: + 1. Register and activate a subnet with a long tempo (CR enabled by default). + 2. Set admin freeze window. + 3. Attempt trigger_epoch — expect DynamicTempoBlockedByCommitReveal rejection. + 4. Disable commit-reveal. + 5. Call trigger_epoch again — expect success. + 6. Verify pending epoch and freeze window state. + """ + max_tempo = await async_subtensor.chain.get_max_tempo() + sn = await _setup_subnet_async( + async_subtensor, alice_wallet, tempo=max_tempo, admin_freeze_window=0 + ) + netuid = sn.netuid + + await sn.async_execute_steps( + [SUDO_SET_ADMIN_FREEZE_WINDOW(alice_wallet, AdminUtils, True, 10)] + ) + freeze_window = await async_subtensor.chain.get_admin_freeze_window() + + result = await async_subtensor.extrinsics.trigger_epoch( + wallet=alice_wallet, + netuid=netuid, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert result.success is False + + await sn.async_execute_steps( + [ + SUDO_SET_COMMIT_REVEAL_WEIGHTS_ENABLED( + alice_wallet, AdminUtils, True, NETUID, False + ) + ] + ) + + result = await async_subtensor.extrinsics.trigger_epoch( + wallet=alice_wallet, + netuid=netuid, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert result.success, result.message + + pending = await async_subtensor.subnets.get_pending_epoch_at(netuid) + assert pending > 0 + trigger_block = pending - freeze_window + assert pending == trigger_block + freeze_window + assert pending > await async_subtensor.block + assert await async_subtensor.chain.is_in_admin_freeze_window(netuid) is True + + +def test_set_activity_cutoff_factor(subtensor, alice_wallet): + """ + Verify owner set_activity_cutoff_factor updates hyperparameters. + + Steps: + 1. Register and activate a subnet. + 2. Call owner set_activity_cutoff_factor. + 3. Verify activity_cutoff_factor in hyperparameters. + """ + sn = _setup_subnet(subtensor, alice_wallet) + netuid = sn.netuid + + new_factor = 5_000 + result = subtensor.extrinsics.set_activity_cutoff_factor( + wallet=alice_wallet, + netuid=netuid, + factor_milli=new_factor, + ) + assert result.success, result.message + + hp = subtensor.subnets.get_subnet_hyperparameters(netuid) + assert hp.activity_cutoff_factor == new_factor + + factor = subtensor.subnets.get_activity_cutoff_factor_milli(netuid) + assert factor == new_factor + + +@pytest.mark.asyncio +async def test_set_activity_cutoff_factor_async(async_subtensor, alice_wallet): + """ + Verify async owner set_activity_cutoff_factor updates hyperparameters. + + Steps: + 1. Register and activate a subnet. + 2. Call owner set_activity_cutoff_factor. + 3. Verify activity_cutoff_factor in hyperparameters. + """ + sn = await _setup_subnet_async(async_subtensor, alice_wallet) + netuid = sn.netuid + + new_factor = 5_000 + result = await async_subtensor.extrinsics.set_activity_cutoff_factor( + wallet=alice_wallet, + netuid=netuid, + factor_milli=new_factor, + ) + assert result.success, result.message + + hp = await async_subtensor.subnets.get_subnet_hyperparameters(netuid) + assert hp.activity_cutoff_factor == new_factor + + factor = await async_subtensor.subnets.get_activity_cutoff_factor_milli(netuid) + assert factor == new_factor + + +def test_commit_reveal_after_owner_set_tempo(subtensor, alice_wallet): + """ + Verify commit-reveal weights after owner set_tempo (not sudo tempo). + + Steps: + 1. Register and activate a subnet with commit-reveal enabled. + 2. Call owner set_tempo. + 3. Commit and reveal weights using CRv4 schedule. + """ + BLOCK_TIME = 0.25 if subtensor.chain.is_fast_blocks() else 12.0 + logging.console.info(f"Using block time: {BLOCK_TIME}") + + max_tempo = subtensor.chain.get_max_tempo() + min_tempo = subtensor.chain.get_min_tempo() + + sn = TestSubnet(subtensor) + sn.execute_steps( + [ + SUDO_SET_ADMIN_FREEZE_WINDOW(alice_wallet, AdminUtils, True, 0), + SUDO_SET_NETWORK_RATE_LIMIT(alice_wallet, AdminUtils, True, 0), + REGISTER_SUBNET(alice_wallet), + SUDO_SET_TEMPO(alice_wallet, AdminUtils, True, NETUID, max_tempo), + ACTIVATE_SUBNET(alice_wallet), + SUDO_SET_COMMIT_REVEAL_WEIGHTS_ENABLED( + alice_wallet, AdminUtils, True, NETUID, True + ), + SUDO_SET_WEIGHTS_SET_RATE_LIMIT(alice_wallet, AdminUtils, True, NETUID, 0), + ] + ) + netuid = sn.netuid + + owner_tempo = min_tempo + tempo_result = subtensor.extrinsics.set_tempo( + wallet=alice_wallet, + netuid=netuid, + tempo=owner_tempo, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert tempo_result.success, tempo_result.message + assert subtensor.subnets.get_subnet_hyperparameters(netuid).tempo == owner_tempo + + uids = np.array([0], dtype=np.int64) + weights = np.array([0.1], dtype=np.float32) + weight_uids, weight_vals = convert_weights_and_uids_for_emit( + uids=uids, weights=weights + ) + + current_block = subtensor.chain.get_current_block() + upcoming_tempo = subtensor.subnets.get_next_epoch_start_block(netuid) + if upcoming_tempo - current_block < 6: + sn.wait_next_epoch() + current_block = subtensor.chain.get_current_block() + upcoming_tempo = subtensor.subnets.get_next_epoch_start_block(netuid) + logging.console.info( + f"Current block: {current_block}, next epoch: {upcoming_tempo}" + ) + + expected_commit_block = subtensor.block + 2 + response = subtensor.extrinsics.set_weights( + wallet=alice_wallet, + netuid=netuid, + mechid=0, + uids=weight_uids, + weights=weight_vals, + wait_for_inclusion=True, + wait_for_finalization=True, + block_time=BLOCK_TIME, + period=16, + raise_error=True, + ) + assert response.success is True, response.message + expected_reveal_round = response.data.get("reveal_round") + assert expected_reveal_round is not None + + subtensor.wait_for_block(subtensor.block + 1) + + commits_on_chain = subtensor.commitments.get_timelocked_weight_commits( + netuid=netuid, mechid=0 + ) + address, commit_block, _commit, reveal_round = commits_on_chain[0] + assert expected_reveal_round == reveal_round + assert address == alice_wallet.hotkey.ss58_address + assert expected_commit_block in list(range(commit_block - 2, commit_block + 2)) + assert subtensor.subnets.weights(netuid=netuid, mechid=0) == [] + + expected_reveal_block = subtensor.subnets.get_next_epoch_start_block(netuid) + 5 + subtensor.wait_for_block(expected_reveal_block) + + latest_drand_round = 0 + while latest_drand_round <= expected_reveal_round: + latest_drand_round = subtensor.chain.last_drand_round() + time.sleep(3) + + subnet_weights = subtensor.subnets.weights(netuid=netuid, mechid=0) + assert subnet_weights != [] + revealed_weights = subnet_weights[0][1] + assert weight_uids[0] == revealed_weights[0][0] + assert weight_vals[0] == revealed_weights[0][1] + assert ( + subtensor.commitments.get_timelocked_weight_commits(netuid=netuid, mechid=0) + == [] + ) + + +@pytest.mark.asyncio +async def test_commit_reveal_after_owner_set_tempo_async(async_subtensor, alice_wallet): + """ + Verify async commit-reveal weights after owner set_tempo (not sudo tempo). + + Steps: + 1. Register and activate a subnet with commit-reveal enabled. + 2. Call owner set_tempo. + 3. Commit and reveal weights using CRv4 schedule. + """ + BLOCK_TIME = 0.25 if await async_subtensor.chain.is_fast_blocks() else 12.0 + logging.console.info(f"Using block time: {BLOCK_TIME}") + + max_tempo = await async_subtensor.chain.get_max_tempo() + min_tempo = await async_subtensor.chain.get_min_tempo() + + sn = TestSubnet(async_subtensor) + await sn.async_execute_steps( + [ + SUDO_SET_ADMIN_FREEZE_WINDOW(alice_wallet, AdminUtils, True, 0), + SUDO_SET_NETWORK_RATE_LIMIT(alice_wallet, AdminUtils, True, 0), + REGISTER_SUBNET(alice_wallet), + SUDO_SET_TEMPO(alice_wallet, AdminUtils, True, NETUID, max_tempo), + ACTIVATE_SUBNET(alice_wallet), + SUDO_SET_COMMIT_REVEAL_WEIGHTS_ENABLED( + alice_wallet, AdminUtils, True, NETUID, True + ), + SUDO_SET_WEIGHTS_SET_RATE_LIMIT(alice_wallet, AdminUtils, True, NETUID, 0), + ] + ) + netuid = sn.netuid + + owner_tempo = min_tempo + tempo_result = await async_subtensor.extrinsics.set_tempo( + wallet=alice_wallet, + netuid=netuid, + tempo=owner_tempo, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert tempo_result.success, tempo_result.message + hp = await async_subtensor.subnets.get_subnet_hyperparameters(netuid) + assert hp.tempo == owner_tempo + + uids = np.array([0], dtype=np.int64) + weights = np.array([0.1], dtype=np.float32) + weight_uids, weight_vals = convert_weights_and_uids_for_emit( + uids=uids, weights=weights + ) + + current_block = await async_subtensor.chain.get_current_block() + upcoming_tempo = await async_subtensor.subnets.get_next_epoch_start_block(netuid) + if upcoming_tempo - current_block < 6: + await sn.wait_next_epoch() + current_block = await async_subtensor.chain.get_current_block() + upcoming_tempo = await async_subtensor.subnets.get_next_epoch_start_block(netuid) + logging.console.info( + f"Current block: {current_block}, next epoch: {upcoming_tempo}" + ) + + expected_commit_block = await async_subtensor.block + 2 + response = await async_subtensor.extrinsics.set_weights( + wallet=alice_wallet, + netuid=netuid, + mechid=0, + uids=weight_uids, + weights=weight_vals, + wait_for_inclusion=True, + wait_for_finalization=True, + block_time=BLOCK_TIME, + period=16, + raise_error=True, + ) + assert response.success is True, response.message + expected_reveal_round = response.data.get("reveal_round") + assert expected_reveal_round is not None + + await async_subtensor.wait_for_block(await async_subtensor.block + 1) + + commits_on_chain = await async_subtensor.commitments.get_timelocked_weight_commits( + netuid=netuid, mechid=0 + ) + address, commit_block, _commit, reveal_round = commits_on_chain[0] + assert expected_reveal_round == reveal_round + assert address == alice_wallet.hotkey.ss58_address + assert expected_commit_block in list(range(commit_block - 2, commit_block + 2)) + + assert await async_subtensor.subnets.weights(netuid=netuid, mechid=0) == [] + + expected_reveal_block = ( + await async_subtensor.subnets.get_next_epoch_start_block(netuid) + 5 + ) + await async_subtensor.wait_for_block(expected_reveal_block) + + latest_drand_round = 0 + while latest_drand_round <= expected_reveal_round: + latest_drand_round = await async_subtensor.chain.last_drand_round() + time.sleep(3) + + subnet_weights = await async_subtensor.subnets.weights(netuid=netuid, mechid=0) + assert subnet_weights != [] + revealed_weights = subnet_weights[0][1] + assert weight_uids[0] == revealed_weights[0][0] + assert weight_vals[0] == revealed_weights[0][1] + assert ( + await async_subtensor.commitments.get_timelocked_weight_commits( + netuid=netuid, mechid=0 + ) + == [] + ) + + +def test_root_set_activity_cutoff_factor(subtensor, alice_wallet): + """ + Verify sudo root_set_activity_cutoff_factor overrides the owner-level value. + + Steps: + 1. Register and activate a subnet. + 2. Call root_set_activity_cutoff_factor via sudo. + 3. Verify the factor is updated in hyperparameters and direct query. + """ + sn = _setup_subnet(subtensor, alice_wallet) + netuid = sn.netuid + + new_factor = subtensor.chain.get_min_activity_cutoff_factor_milli() + 500 + result = subtensor.extrinsics.root_set_activity_cutoff_factor( + wallet=alice_wallet, + netuid=netuid, + factor_milli=new_factor, + ) + assert result.success, result.message + + hp = subtensor.subnets.get_subnet_hyperparameters(netuid) + assert hp.activity_cutoff_factor == new_factor + + factor = subtensor.subnets.get_activity_cutoff_factor_milli(netuid) + assert factor == new_factor + + +@pytest.mark.asyncio +async def test_root_set_activity_cutoff_factor_async(async_subtensor, alice_wallet): + """ + Verify async sudo root_set_activity_cutoff_factor overrides the owner-level value. + + Steps: + 1. Register and activate a subnet. + 2. Call root_set_activity_cutoff_factor via sudo. + 3. Verify the factor is updated in hyperparameters and direct query. + """ + sn = await _setup_subnet_async(async_subtensor, alice_wallet) + netuid = sn.netuid + + new_factor = ( + await async_subtensor.chain.get_min_activity_cutoff_factor_milli() + 500 + ) + result = await async_subtensor.extrinsics.root_set_activity_cutoff_factor( + wallet=alice_wallet, + netuid=netuid, + factor_milli=new_factor, + ) + assert result.success, result.message + + hp = await async_subtensor.subnets.get_subnet_hyperparameters(netuid) + assert hp.activity_cutoff_factor == new_factor + + factor = await async_subtensor.subnets.get_activity_cutoff_factor_milli(netuid) + assert factor == new_factor + + +def test_tempo_control_negative_cases(subtensor, alice_wallet, bob_wallet): + """ + Verify negative scenarios for tempo control extrinsics. + + Steps: + 1. Register and activate a subnet (owner = alice). + 2. Non-owner (bob) attempts set_tempo — expect failure. + 3. Owner sets tempo below chain minimum — expect failure. + 4. Owner sets tempo above chain maximum — expect failure. + 5. Owner sets activity cutoff factor above chain maximum — expect failure. + 6. Owner sets activity cutoff factor below chain minimum — expect failure. + """ + sn = _setup_subnet(subtensor, alice_wallet) + netuid = sn.netuid + + min_tempo = subtensor.chain.get_min_tempo() + max_tempo = subtensor.chain.get_max_tempo() + min_cutoff = subtensor.chain.get_min_activity_cutoff_factor_milli() + max_cutoff = subtensor.chain.get_max_activity_cutoff_factor_milli() + + result = subtensor.extrinsics.set_tempo( + wallet=bob_wallet, + netuid=netuid, + tempo=min_tempo + 5, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert result.success is False + + result = subtensor.extrinsics.set_tempo( + wallet=alice_wallet, + netuid=netuid, + tempo=min_tempo - 1, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert result.success is False + + result = subtensor.extrinsics.set_tempo( + wallet=alice_wallet, + netuid=netuid, + tempo=max_tempo + 1, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert result.success is False + + result = subtensor.extrinsics.set_activity_cutoff_factor( + wallet=alice_wallet, + netuid=netuid, + factor_milli=max_cutoff + 1, + ) + assert result.success is False + + result = subtensor.extrinsics.set_activity_cutoff_factor( + wallet=alice_wallet, + netuid=netuid, + factor_milli=min_cutoff - 1, + ) + assert result.success is False + + +@pytest.mark.asyncio +async def test_tempo_control_negative_cases_async( + async_subtensor, alice_wallet, bob_wallet +): + """ + Verify async negative scenarios for tempo control extrinsics. + + Steps: + 1. Register and activate a subnet (owner = alice). + 2. Non-owner (bob) attempts set_tempo — expect failure. + 3. Owner sets tempo below chain minimum — expect failure. + 4. Owner sets tempo above chain maximum — expect failure. + 5. Owner sets activity cutoff factor above chain maximum — expect failure. + 6. Owner sets activity cutoff factor below chain minimum — expect failure. + """ + sn = await _setup_subnet_async(async_subtensor, alice_wallet) + netuid = sn.netuid + + min_tempo = await async_subtensor.chain.get_min_tempo() + max_tempo = await async_subtensor.chain.get_max_tempo() + min_cutoff = await async_subtensor.chain.get_min_activity_cutoff_factor_milli() + max_cutoff = await async_subtensor.chain.get_max_activity_cutoff_factor_milli() + + result = await async_subtensor.extrinsics.set_tempo( + wallet=bob_wallet, + netuid=netuid, + tempo=min_tempo + 5, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert result.success is False + + result = await async_subtensor.extrinsics.set_tempo( + wallet=alice_wallet, + netuid=netuid, + tempo=min_tempo - 1, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert result.success is False + + result = await async_subtensor.extrinsics.set_tempo( + wallet=alice_wallet, + netuid=netuid, + tempo=max_tempo + 1, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert result.success is False + + result = await async_subtensor.extrinsics.set_activity_cutoff_factor( + wallet=alice_wallet, + netuid=netuid, + factor_milli=max_cutoff + 1, + ) + assert result.success is False + + result = await async_subtensor.extrinsics.set_activity_cutoff_factor( + wallet=alice_wallet, + netuid=netuid, + factor_milli=min_cutoff - 1, + ) + assert result.success is False + + +def test_set_tempo_rejected_in_freeze_window(subtensor, alice_wallet): + """ + Verify set_tempo is rejected when the subnet is in admin freeze window. + + Steps: + 1. Register and activate a subnet with long tempo and no freeze window. + 2. Set admin freeze window, trigger epoch to enter freeze state. + 3. Attempt set_tempo while frozen — expect failure. + """ + max_tempo = subtensor.chain.get_max_tempo() + min_tempo = subtensor.chain.get_min_tempo() + sn = _setup_subnet(subtensor, alice_wallet, tempo=max_tempo, admin_freeze_window=0) + netuid = sn.netuid + + sn.execute_steps( + [ + SUDO_SET_ADMIN_FREEZE_WINDOW(alice_wallet, AdminUtils, True, 10), + SUDO_SET_COMMIT_REVEAL_WEIGHTS_ENABLED( + alice_wallet, AdminUtils, True, NETUID, False + ), + ] + ) + + result = subtensor.extrinsics.trigger_epoch( + wallet=alice_wallet, + netuid=netuid, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert result.success, result.message + assert subtensor.chain.is_in_admin_freeze_window(netuid) is True + + result = subtensor.extrinsics.set_tempo( + wallet=alice_wallet, + netuid=netuid, + tempo=min_tempo, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert result.success is False + + +@pytest.mark.asyncio +async def test_set_tempo_rejected_in_freeze_window_async(async_subtensor, alice_wallet): + """ + Verify async set_tempo is rejected when the subnet is in admin freeze window. + + Steps: + 1. Register and activate a subnet with long tempo and no freeze window. + 2. Set admin freeze window, trigger epoch to enter freeze state. + 3. Attempt set_tempo while frozen — expect failure. + """ + max_tempo = await async_subtensor.chain.get_max_tempo() + min_tempo = await async_subtensor.chain.get_min_tempo() + sn = await _setup_subnet_async( + async_subtensor, alice_wallet, tempo=max_tempo, admin_freeze_window=0 + ) + netuid = sn.netuid + + await sn.async_execute_steps( + [ + SUDO_SET_ADMIN_FREEZE_WINDOW(alice_wallet, AdminUtils, True, 10), + SUDO_SET_COMMIT_REVEAL_WEIGHTS_ENABLED( + alice_wallet, AdminUtils, True, NETUID, False + ), + ] + ) + + result = await async_subtensor.extrinsics.trigger_epoch( + wallet=alice_wallet, + netuid=netuid, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert result.success, result.message + assert await async_subtensor.chain.is_in_admin_freeze_window(netuid) is True + + result = await async_subtensor.extrinsics.set_tempo( + wallet=alice_wallet, + netuid=netuid, + tempo=min_tempo, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert result.success is False diff --git a/tests/e2e_tests/test_metagraph.py b/tests/e2e_tests/test_metagraph.py index b5d58bb37c..c6f7f6b1d3 100644 --- a/tests/e2e_tests/test_metagraph.py +++ b/tests/e2e_tests/test_metagraph.py @@ -614,6 +614,12 @@ def test_metagraph_info(subtensor, alice_wallet, bob_wallet): metagraph_info = subtensor.metagraphs.get_metagraph_info(netuid=1, block=1) + expected_activity_cutoff = max( + 1, + subtensor.subnets.get_activity_cutoff_factor_milli(1) + * 100 # tempo + // 1000, + ) expected_metagraph_info = MetagraphInfo( netuid=1, mechid=0, @@ -644,7 +650,7 @@ def test_metagraph_info(subtensor, alice_wallet, bob_wallet): max_weights_limit=1.0, weights_version=0, weights_rate_limit=100, - activity_cutoff=5000, + activity_cutoff=expected_activity_cutoff, max_validators=64, num_uids=1, max_uids=256, @@ -711,6 +717,12 @@ def test_metagraph_info(subtensor, alice_wallet, bob_wallet): metagraph_infos = subtensor.metagraphs.get_all_metagraphs_info(block=1) + root_activity_cutoff = max( + 1, + subtensor.subnets.get_activity_cutoff_factor_milli(0) + * 100 # tempo + // 1000, + ) expected_metagraph_infos = [ MetagraphInfo( netuid=0, @@ -742,7 +754,7 @@ def test_metagraph_info(subtensor, alice_wallet, bob_wallet): max_weights_limit=1.0, weights_version=0, weights_rate_limit=100, - activity_cutoff=5000, + activity_cutoff=root_activity_cutoff, max_validators=64, num_uids=0, max_uids=64, @@ -865,6 +877,12 @@ async def test_metagraph_info_async(async_subtensor, alice_wallet, bob_wallet): netuid=1, block=1 ) + expected_activity_cutoff = max( + 1, + await async_subtensor.subnets.get_activity_cutoff_factor_milli(1) + * 100 # tempo + // 1000, + ) expected_metagraph_info = MetagraphInfo( netuid=1, mechid=0, @@ -895,7 +913,7 @@ async def test_metagraph_info_async(async_subtensor, alice_wallet, bob_wallet): max_weights_limit=1.0, weights_version=0, weights_rate_limit=100, - activity_cutoff=5000, + activity_cutoff=expected_activity_cutoff, max_validators=64, num_uids=1, max_uids=256, @@ -962,6 +980,12 @@ async def test_metagraph_info_async(async_subtensor, alice_wallet, bob_wallet): metagraph_infos = await async_subtensor.metagraphs.get_all_metagraphs_info(block=1) + root_activity_cutoff = max( + 1, + await async_subtensor.subnets.get_activity_cutoff_factor_milli(0) + * 100 # tempo + // 1000, + ) expected_metagraph_infos = [ MetagraphInfo( netuid=0, @@ -993,7 +1017,7 @@ async def test_metagraph_info_async(async_subtensor, alice_wallet, bob_wallet): max_weights_limit=1.0, weights_version=0, weights_rate_limit=100, - activity_cutoff=5000, + activity_cutoff=root_activity_cutoff, max_validators=64, num_uids=0, max_uids=64, diff --git a/tests/e2e_tests/test_root_claim.py b/tests/e2e_tests/test_root_claim.py index 1fadb2ca9d..15fa140565 100644 --- a/tests/e2e_tests/test_root_claim.py +++ b/tests/e2e_tests/test_root_claim.py @@ -97,7 +97,7 @@ def test_root_claim_swap(subtensor, alice_wallet, bob_wallet, charlie_wallet): # We skip the era in which the stake was installed, since the emission doesn't occur (Subtensor implementation) logging.console.info(f"Skipping stake epoch") next_epoch_start_block = subtensor.subnets.get_next_epoch_start_block( - netuid=root_sn.netuid + netuid=sn2.netuid ) subtensor.wait_for_block(block=next_epoch_start_block) @@ -116,7 +116,7 @@ def test_root_claim_swap(subtensor, alice_wallet, bob_wallet, charlie_wallet): epochs_left = MAX_EPOCHS_FOR_INCREASE while charlie_root_stake <= prev_root_stake and epochs_left > 0: next_epoch_start_block = subtensor.subnets.get_next_epoch_start_block( - netuid=root_sn.netuid + netuid=sn2.netuid ) subtensor.wait_for_block(block=next_epoch_start_block + 4) charlie_root_stake = subtensor.staking.get_stake( @@ -210,7 +210,7 @@ async def test_root_claim_swap_async( # We skip the era in which the stake was installed, since the emission doesn't occur (Subtensor implementation) logging.console.info(f"Skipping stake epoch") next_epoch_start_block = await async_subtensor.subnets.get_next_epoch_start_block( - netuid=root_sn.netuid + netuid=sn2.netuid ) await async_subtensor.wait_for_block(block=next_epoch_start_block) @@ -230,7 +230,7 @@ async def test_root_claim_swap_async( while charlie_root_stake <= prev_root_stake and epochs_left > 0: next_epoch_start_block = ( await async_subtensor.subnets.get_next_epoch_start_block( - netuid=root_sn.netuid + netuid=sn2.netuid ) ) await async_subtensor.wait_for_block(block=next_epoch_start_block + 4) @@ -334,7 +334,7 @@ def test_root_claim_keep_with_zero_num_root_auto_claims( # proof that ROOT stake isn't changes until it's claimed manually while proof_counter > 0: next_epoch_start_block = subtensor.subnets.get_next_epoch_start_block( - root_sn.netuid + sn2.netuid ) subtensor.wait_for_block(next_epoch_start_block) @@ -505,7 +505,7 @@ async def test_root_claim_keep_with_zero_num_root_auto_claims_async( # proof that ROOT stake isn't changes until it's claimed manually while proof_counter > 0: next_epoch_start_block = ( - await async_subtensor.subnets.get_next_epoch_start_block(root_sn.netuid) + await async_subtensor.subnets.get_next_epoch_start_block(sn2.netuid) ) await async_subtensor.wait_for_block(next_epoch_start_block) @@ -674,7 +674,7 @@ def test_root_claim_keep_with_random_auto_claims( # Skip the epoch in which stake was installed. Emission doesn't occur in the same epoch as stake installation logging.console.info("Skipping stake epoch") next_epoch_start_block = subtensor.subnets.get_next_epoch_start_block( - netuid=root_sn.netuid + netuid=sn2.netuid ) subtensor.wait_for_block(next_epoch_start_block) @@ -702,7 +702,7 @@ def test_root_claim_keep_with_random_auto_claims( epochs_left = MAX_EPOCHS_FOR_INCREASE while claimed_stake_charlie <= prev_claimed_stake_charlie and epochs_left > 0: next_epoch_start_block = subtensor.subnets.get_next_epoch_start_block( - root_sn.netuid + sn2.netuid ) subtensor.wait_for_block(next_epoch_start_block + 4) @@ -817,7 +817,7 @@ async def test_root_claim_keep_with_random_auto_claims_async( # Skip the epoch in which stake was installed. Emission doesn't occur in the same epoch as stake installation logging.console.info("Skipping stake epoch") next_epoch_start_block = await async_subtensor.subnets.get_next_epoch_start_block( - netuid=root_sn.netuid + netuid=sn2.netuid ) await async_subtensor.wait_for_block(next_epoch_start_block) @@ -845,7 +845,7 @@ async def test_root_claim_keep_with_random_auto_claims_async( epochs_left = MAX_EPOCHS_FOR_INCREASE while claimed_stake_charlie <= prev_claimed_stake_charlie and epochs_left > 0: next_epoch_start_block = ( - await async_subtensor.subnets.get_next_epoch_start_block(root_sn.netuid) + await async_subtensor.subnets.get_next_epoch_start_block(sn2.netuid) ) await async_subtensor.wait_for_block(next_epoch_start_block + 4) @@ -946,13 +946,13 @@ def test_root_claim_keep_subnets_basic( # Skip the epoch in which stake was installed logging.console.info("Skipping stake epoch") next_epoch_start_block = subtensor.subnets.get_next_epoch_start_block( - netuid=root_sn.netuid + netuid=sn2.netuid ) subtensor.wait_for_block(next_epoch_start_block) # Wait for one epoch and check that get_root_alpha_dividends_per_subnet increases for SN2 next_epoch_start_block = subtensor.subnets.get_next_epoch_start_block( - netuid=root_sn.netuid + netuid=sn2.netuid ) subtensor.wait_for_block(next_epoch_start_block) @@ -1041,13 +1041,13 @@ async def test_root_claim_keep_subnets_basic_async( # Skip the epoch in which stake was installed logging.console.info("Skipping stake epoch") next_epoch_start_block = await async_subtensor.subnets.get_next_epoch_start_block( - netuid=root_sn.netuid + netuid=sn2.netuid ) await async_subtensor.wait_for_block(next_epoch_start_block) # Wait for one epoch and check that get_root_alpha_dividends_per_subnet increases for SN2 next_epoch_start_block = await async_subtensor.subnets.get_next_epoch_start_block( - netuid=root_sn.netuid + netuid=sn2.netuid ) await async_subtensor.wait_for_block(next_epoch_start_block) @@ -1136,7 +1136,7 @@ def test_root_claim_keep_subnets_with_auto_claims( # Skip the epoch in which stake was installed logging.console.info("Skipping stake epoch") next_epoch_start_block = subtensor.subnets.get_next_epoch_start_block( - netuid=root_sn.netuid + netuid=sn2.netuid ) subtensor.wait_for_block(next_epoch_start_block) @@ -1151,7 +1151,7 @@ def test_root_claim_keep_subnets_with_auto_claims( # Wait for epochs and check that stake increases on SN2 due to auto claims while proof_counter > 0: next_epoch_start_block = subtensor.subnets.get_next_epoch_start_block( - root_sn.netuid + sn2.netuid ) subtensor.wait_for_block(next_epoch_start_block) @@ -1247,7 +1247,7 @@ async def test_root_claim_keep_subnets_with_auto_claims_async( # Skip the epoch in which stake was installed logging.console.info("Skipping stake epoch") next_epoch_start_block = await async_subtensor.subnets.get_next_epoch_start_block( - netuid=root_sn.netuid + netuid=sn2.netuid ) await async_subtensor.wait_for_block(next_epoch_start_block) @@ -1262,7 +1262,7 @@ async def test_root_claim_keep_subnets_with_auto_claims_async( # Wait for epochs and check that stake increases on SN2 due to auto claims while proof_counter > 0: next_epoch_start_block = ( - await async_subtensor.subnets.get_next_epoch_start_block(root_sn.netuid) + await async_subtensor.subnets.get_next_epoch_start_block(sn2.netuid) ) await async_subtensor.wait_for_block(next_epoch_start_block) @@ -1377,13 +1377,13 @@ def test_root_claim_keep_subnets_multiple_subnets( # Skip the epoch in which stake was installed logging.console.info("Skipping stake epoch") next_epoch_start_block = subtensor.subnets.get_next_epoch_start_block( - netuid=root_sn.netuid + netuid=sn2.netuid ) subtensor.wait_for_block(next_epoch_start_block) # Wait for one epoch next_epoch_start_block = subtensor.subnets.get_next_epoch_start_block( - netuid=root_sn.netuid + netuid=sn2.netuid ) subtensor.wait_for_block(next_epoch_start_block) @@ -1516,13 +1516,13 @@ async def test_root_claim_keep_subnets_multiple_subnets_async( # Skip the epoch in which stake was installed logging.console.info("Skipping stake epoch") next_epoch_start_block = await async_subtensor.subnets.get_next_epoch_start_block( - netuid=root_sn.netuid + netuid=sn2.netuid ) await async_subtensor.wait_for_block(next_epoch_start_block) # Wait for one epoch next_epoch_start_block = await async_subtensor.subnets.get_next_epoch_start_block( - netuid=root_sn.netuid + netuid=sn2.netuid ) await async_subtensor.wait_for_block(next_epoch_start_block) diff --git a/tests/unit_tests/chain_data/test_subnet_hyperparameters.py b/tests/unit_tests/chain_data/test_subnet_hyperparameters.py new file mode 100644 index 0000000000..2ca0f08e54 --- /dev/null +++ b/tests/unit_tests/chain_data/test_subnet_hyperparameters.py @@ -0,0 +1,93 @@ +import pytest + +from bittensor.core.chain_data.subnet_hyperparameters import SubnetHyperparameters + +_HYPERPARAMS_V3 = [ + {"name": "kappa", "value": {"U16": 32767}}, + {"name": "tempo", "value": {"U16": 100}}, + {"name": "max_weights_limit", "value": {"U16": 65535}}, + {"name": "weights_rate_limit", "value": {"U64": 100}}, + {"name": "registration_allowed", "value": {"Bool": True}}, + {"name": "liquid_alpha_enabled", "value": {"Bool": False}}, + {"name": "min_burn", "value": {"TaoBalance": 500000}}, + {"name": "alpha_sigmoid_steepness", "value": {"I32F32": {"bits": 4294967296000}}}, + {"name": "activity_cutoff_factor", "value": {"U32": 13889}}, +] + + +@pytest.mark.parametrize( + "tag", + ["U8", "U16", "U32", "U64", "U128", "I8", "I16", "I32", "I64"], +) +def test_decode_value_integer_tags(tag): + """Verify integer SCALE tags decode to plain ints.""" + result = SubnetHyperparameters._decode_value({tag: 42}) + assert result == 42 + assert type(result) is int + + +def test_decode_value_bool_tag(): + """Verify Bool SCALE tag decodes to bool.""" + assert SubnetHyperparameters._decode_value({"Bool": True}) is True + assert SubnetHyperparameters._decode_value({"Bool": False}) is False + + +def test_from_any_decodes_v3_list(): + """Verify v3 Vec decoding into typed fields.""" + params = SubnetHyperparameters.from_any(_HYPERPARAMS_V3) + assert params.kappa == 32767 + assert params.tempo == 100 + assert params.max_weight_limit == 65535 + assert params.weights_rate_limit == 100 + assert params.registration_allowed is True + assert params.liquid_alpha_enabled is False + assert params.min_burn == 500000 + assert params.activity_cutoff_factor == 13889 + assert isinstance(params.alpha_sigmoid_steepness, float) + + +def test_from_any_decodes_v2_dict(): + """Verify v2 struct dict decoding into typed fields.""" + params = SubnetHyperparameters.from_any( + { + "rho": 10, + "tempo": 360, + "max_weights_limit": 49151, + "registration_allowed": True, + "alpha_sigmoid_steepness": {"bits": 4294967296000}, + } + ) + assert params.rho == 10 + assert params.tempo == 360 + assert params.max_weight_limit == 49151 + assert params.registration_allowed is True + assert isinstance(params.alpha_sigmoid_steepness, float) + + +def test_from_any_unknown_field_goes_to_spillover(): + """Verify unknown v3 entries are accessible via spillover mapping.""" + params = SubnetHyperparameters.from_any( + _HYPERPARAMS_V3 + [{"name": "future_param", "value": {"U128": 7}}] + ) + assert params.future_param == 7 + assert "future_param" in params + + +def test_from_any_bytes_name(): + """Verify byte-string entry names are decoded.""" + params = SubnetHyperparameters.from_any([{"name": b"tempo", "value": {"U16": 360}}]) + assert params.tempo == 360 + + +def test_subnet_hyperparameters_access(): + """Verify attribute, item, and get access patterns.""" + params = SubnetHyperparameters.from_any(_HYPERPARAMS_V3) + assert params.tempo == 100 + assert params["tempo"] == 100 + assert params.get("missing", "default") == "default" + + +def test_from_any_passthrough_existing_instance(): + """Verify from_any returns the same instance when already decoded.""" + original = SubnetHyperparameters.from_any(_HYPERPARAMS_V3) + assert SubnetHyperparameters.from_any(original) is original diff --git a/tests/unit_tests/extrinsics/asyncex/test_tempo_control.py b/tests/unit_tests/extrinsics/asyncex/test_tempo_control.py new file mode 100644 index 0000000000..464d2b4a54 --- /dev/null +++ b/tests/unit_tests/extrinsics/asyncex/test_tempo_control.py @@ -0,0 +1,367 @@ +import pytest + +from bittensor.core.extrinsics.asyncex import tempo_control +from bittensor.core.types import ExtrinsicResponse + + +@pytest.mark.asyncio +async def test_set_tempo_extrinsic(subtensor, mocker, fake_wallet): + """Verify that async set_tempo_extrinsic composes correct call and submits it.""" + # Preps + netuid = 1 + tempo = 500 + mocked_set_tempo = mocker.patch.object( + tempo_control.SubtensorModule, + "set_tempo", + new=mocker.AsyncMock(), + ) + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + success, message = await tempo_control.set_tempo_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + tempo=tempo, + mev_protection=False, + ) + + # Asserts + mocked_set_tempo.assert_awaited_once_with(netuid=netuid, tempo=tempo) + mocked_sign_and_send_extrinsic.assert_awaited_once_with( + call=mocked_set_tempo.return_value, + wallet=fake_wallet, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert success is True + assert "Success" in message + + +@pytest.mark.asyncio +async def test_set_tempo_extrinsic_mev_protection(subtensor, mocker, fake_wallet): + """Verify that async set_tempo_extrinsic uses submit_encrypted_extrinsic when mev_protection=True.""" + # Preps + netuid = 1 + tempo = 500 + mocked_set_tempo = mocker.patch.object( + tempo_control.SubtensorModule, + "set_tempo", + new=mocker.AsyncMock(), + ) + mock_submit = mocker.patch( + "bittensor.core.extrinsics.asyncex.tempo_control.submit_encrypted_extrinsic", + new_callable=mocker.AsyncMock, + return_value=ExtrinsicResponse(True, "Success"), + ) + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, "sign_and_send_extrinsic" + ) + + # Call + success, message = await tempo_control.set_tempo_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + tempo=tempo, + mev_protection=True, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + + # Asserts + assert success is True + assert "Success" in message + mock_submit.assert_awaited_once_with( + subtensor=subtensor, + wallet=fake_wallet, + call=mocked_set_tempo.return_value, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + mocked_sign_and_send_extrinsic.assert_not_called() + + +@pytest.mark.asyncio +async def test_set_activity_cutoff_factor_extrinsic(subtensor, mocker, fake_wallet): + """Verify that async set_activity_cutoff_factor_extrinsic composes correct call and submits it.""" + # Preps + netuid = 1 + factor_milli = 5000 + mocked_set_factor = mocker.patch.object( + tempo_control.SubtensorModule, + "set_activity_cutoff_factor", + new=mocker.AsyncMock(), + ) + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + success, message = await tempo_control.set_activity_cutoff_factor_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + factor_milli=factor_milli, + mev_protection=False, + ) + + # Asserts + mocked_set_factor.assert_awaited_once_with(netuid=netuid, factor_milli=factor_milli) + mocked_sign_and_send_extrinsic.assert_awaited_once_with( + call=mocked_set_factor.return_value, + wallet=fake_wallet, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert success is True + assert "Success" in message + + +@pytest.mark.asyncio +async def test_set_activity_cutoff_factor_extrinsic_mev_protection( + subtensor, mocker, fake_wallet +): + """Verify that async set_activity_cutoff_factor_extrinsic uses submit_encrypted_extrinsic when mev_protection=True.""" + # Preps + netuid = 1 + factor_milli = 5000 + mocked_set_factor = mocker.patch.object( + tempo_control.SubtensorModule, + "set_activity_cutoff_factor", + new=mocker.AsyncMock(), + ) + mock_submit = mocker.patch( + "bittensor.core.extrinsics.asyncex.tempo_control.submit_encrypted_extrinsic", + new_callable=mocker.AsyncMock, + return_value=ExtrinsicResponse(True, "Success"), + ) + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, "sign_and_send_extrinsic" + ) + + # Call + success, message = await tempo_control.set_activity_cutoff_factor_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + factor_milli=factor_milli, + mev_protection=True, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + + # Asserts + assert success is True + assert "Success" in message + mock_submit.assert_awaited_once_with( + subtensor=subtensor, + wallet=fake_wallet, + call=mocked_set_factor.return_value, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + mocked_sign_and_send_extrinsic.assert_not_called() + + +@pytest.mark.asyncio +async def test_trigger_epoch_extrinsic(subtensor, mocker, fake_wallet): + """Verify that async trigger_epoch_extrinsic composes correct call and submits it.""" + # Preps + netuid = 1 + mocked_trigger_epoch = mocker.patch.object( + tempo_control.SubtensorModule, + "trigger_epoch", + new=mocker.AsyncMock(), + ) + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + success, message = await tempo_control.trigger_epoch_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + mev_protection=False, + ) + + # Asserts + mocked_trigger_epoch.assert_awaited_once_with(netuid=netuid) + mocked_sign_and_send_extrinsic.assert_awaited_once_with( + call=mocked_trigger_epoch.return_value, + wallet=fake_wallet, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert success is True + assert "Success" in message + + +@pytest.mark.asyncio +async def test_trigger_epoch_extrinsic_mev_protection(subtensor, mocker, fake_wallet): + """Verify that async trigger_epoch_extrinsic uses submit_encrypted_extrinsic when mev_protection=True.""" + # Preps + netuid = 1 + mocked_trigger_epoch = mocker.patch.object( + tempo_control.SubtensorModule, + "trigger_epoch", + new=mocker.AsyncMock(), + ) + mock_submit = mocker.patch( + "bittensor.core.extrinsics.asyncex.tempo_control.submit_encrypted_extrinsic", + new_callable=mocker.AsyncMock, + return_value=ExtrinsicResponse(True, "Success"), + ) + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, "sign_and_send_extrinsic" + ) + + # Call + success, message = await tempo_control.trigger_epoch_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + mev_protection=True, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + + # Asserts + assert success is True + assert "Success" in message + mock_submit.assert_awaited_once_with( + subtensor=subtensor, + wallet=fake_wallet, + call=mocked_trigger_epoch.return_value, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + mocked_sign_and_send_extrinsic.assert_not_called() + + +@pytest.mark.asyncio +async def test_root_set_activity_cutoff_factor_extrinsic( + subtensor, mocker, fake_wallet +): + """Verify async root_set_activity_cutoff_factor_extrinsic extrinsic.""" + # Preps + netuid = 1 + factor_milli = 5000 + mocked_set_factor = mocker.patch.object( + tempo_control.SubtensorModule, + "set_activity_cutoff_factor", + new=mocker.AsyncMock(), + ) + mocked_sudo = mocker.patch.object( + tempo_control.Sudo, "sudo", new=mocker.AsyncMock() + ) + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + success, message = await tempo_control.root_set_activity_cutoff_factor_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + factor_milli=factor_milli, + mev_protection=False, + ) + + # Asserts + mocked_set_factor.assert_awaited_once_with(netuid=netuid, factor_milli=factor_milli) + mocked_sudo.assert_awaited_once_with(call=mocked_set_factor.return_value) + mocked_sign_and_send_extrinsic.assert_awaited_once_with( + call=mocked_sudo.return_value, + wallet=fake_wallet, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert success is True + assert "Success" in message + + +@pytest.mark.asyncio +async def test_root_set_activity_cutoff_factor_extrinsic_mev_protection( + subtensor, mocker, fake_wallet +): + """Verify that async root_set_activity_cutoff_factor_extrinsic uses submit_encrypted_extrinsic when mev_protection=True.""" + # Preps + netuid = 1 + factor_milli = 5000 + mocked_set_factor = mocker.patch.object( + tempo_control.SubtensorModule, + "set_activity_cutoff_factor", + new=mocker.AsyncMock(), + ) + mocked_sudo = mocker.patch.object( + tempo_control.Sudo, "sudo", new=mocker.AsyncMock() + ) + mock_submit = mocker.patch( + "bittensor.core.extrinsics.asyncex.tempo_control.submit_encrypted_extrinsic", + new_callable=mocker.AsyncMock, + return_value=ExtrinsicResponse(True, "Success"), + ) + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, "sign_and_send_extrinsic" + ) + + # Call + success, message = await tempo_control.root_set_activity_cutoff_factor_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + factor_milli=factor_milli, + mev_protection=True, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + + # Asserts + assert success is True + assert "Success" in message + mocked_set_factor.assert_awaited_once_with(netuid=netuid, factor_milli=factor_milli) + mocked_sudo.assert_awaited_once_with(call=mocked_set_factor.return_value) + mock_submit.assert_awaited_once_with( + subtensor=subtensor, + wallet=fake_wallet, + call=mocked_sudo.return_value, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + mocked_sign_and_send_extrinsic.assert_not_called() diff --git a/tests/unit_tests/extrinsics/asyncex/test_weights.py b/tests/unit_tests/extrinsics/asyncex/test_weights.py index c365bf4ed5..18820a82c7 100644 --- a/tests/unit_tests/extrinsics/asyncex/test_weights.py +++ b/tests/unit_tests/extrinsics/asyncex/test_weights.py @@ -94,12 +94,13 @@ async def test_commit_timelocked_weights_extrinsic(mocker, subtensor, fake_walle mocked_get_subnet_hyperparameters = mocker.patch.object( subtensor, "get_subnet_hyperparameters" ) - mocked_get_mechanism_storage_index = mocker.patch.object( - weights_module, "get_mechid_storage_index" + fake_schedule = mocker.Mock() + mocker.patch.object( + subtensor, "get_epoch_schedule_state", return_value=fake_schedule ) - mocked_get_encrypted_commit = mocker.patch.object( + mocked_get_encrypted_commit_v2 = mocker.patch.object( weights_module, - "get_encrypted_commit", + "get_encrypted_commit_v2", return_value=(mocker.Mock(), mocker.Mock()), ) mocked_compose_call = mocker.patch.object(subtensor, "compose_call") @@ -108,7 +109,7 @@ async def test_commit_timelocked_weights_extrinsic(mocker, subtensor, fake_walle "sign_and_send_extrinsic", return_value=ExtrinsicResponse( True, - f"reveal_round:{mocked_get_encrypted_commit.return_value[1]}", + f"reveal_round:{mocked_get_encrypted_commit_v2.return_value[1]}", ), ) @@ -125,17 +126,17 @@ async def test_commit_timelocked_weights_extrinsic(mocker, subtensor, fake_walle # Asserts mocked_convert_and_normalize_weights_and_uids.assert_called_once_with(uids, weights) - mocked_get_mechanism_storage_index.assert_called_once_with( - netuid=netuid, mechid=mechid - ) - mocked_get_encrypted_commit.assert_called_once_with( - uids=uids, - weights=weights, - subnet_reveal_period_epochs=mocked_get_subnet_hyperparameters.return_value.commit_reveal_period, + mocked_get_encrypted_commit_v2.assert_called_once_with( + uids=list(uids), + weights=list(weights), version_key=weights_module.version_as_int, - tempo=mocked_get_subnet_hyperparameters.return_value.tempo, - netuid=mocked_get_mechanism_storage_index.return_value, - current_block=mocked_get_current_block.return_value, + last_epoch_block=fake_schedule.last_epoch_block, + pending_epoch_at=fake_schedule.pending_epoch_at, + subnet_epoch_index=fake_schedule.subnet_epoch_index, + tempo=fake_schedule.tempo, + blocks_since_last_step=fake_schedule.blocks_since_last_step, + current_block=fake_schedule.current_block, + subnet_reveal_period_epochs=mocked_get_subnet_hyperparameters.return_value.commit_reveal_period, block_time=block_time, hotkey=fake_wallet.hotkey.public_key, ) @@ -145,8 +146,8 @@ async def test_commit_timelocked_weights_extrinsic(mocker, subtensor, fake_walle call_params={ "netuid": netuid, "mecid": mechid, - "commit": mocked_get_encrypted_commit.return_value[0], - "reveal_round": mocked_get_encrypted_commit.return_value[1], + "commit": mocked_get_encrypted_commit_v2.return_value[0], + "reveal_round": mocked_get_encrypted_commit_v2.return_value[1], "commit_reveal_version": 4, }, ) diff --git a/tests/unit_tests/extrinsics/test_tempo_control.py b/tests/unit_tests/extrinsics/test_tempo_control.py new file mode 100644 index 0000000000..838a13b963 --- /dev/null +++ b/tests/unit_tests/extrinsics/test_tempo_control.py @@ -0,0 +1,327 @@ +from bittensor.core.extrinsics import tempo_control +from bittensor.core.types import ExtrinsicResponse + + +def test_set_tempo_extrinsic(subtensor, mocker, fake_wallet): + """Verify that set_tempo_extrinsic composes correct call and submits it.""" + # Preps + netuid = 1 + tempo = 500 + mocked_set_tempo = mocker.patch.object(tempo_control.SubtensorModule, "set_tempo") + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + success, message = tempo_control.set_tempo_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + tempo=tempo, + mev_protection=False, + ) + + # Asserts + mocked_set_tempo.assert_called_once_with(netuid=netuid, tempo=tempo) + mocked_sign_and_send_extrinsic.assert_called_once_with( + call=mocked_set_tempo.return_value, + wallet=fake_wallet, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert success is True + assert "Success" in message + + +def test_set_tempo_extrinsic_mev_protection(subtensor, mocker, fake_wallet): + """Verify that set_tempo_extrinsic uses submit_encrypted_extrinsic when mev_protection=True.""" + # Preps + netuid = 1 + tempo = 500 + mocked_set_tempo = mocker.patch.object(tempo_control.SubtensorModule, "set_tempo") + mock_submit = mocker.patch( + "bittensor.core.extrinsics.tempo_control.submit_encrypted_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, "sign_and_send_extrinsic" + ) + + # Call + success, message = tempo_control.set_tempo_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + tempo=tempo, + mev_protection=True, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + + # Asserts + assert success is True + assert "Success" in message + mock_submit.assert_called_once_with( + subtensor=subtensor, + wallet=fake_wallet, + call=mocked_set_tempo.return_value, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + mocked_sign_and_send_extrinsic.assert_not_called() + + +def test_set_activity_cutoff_factor_extrinsic(subtensor, mocker, fake_wallet): + """Verify that set_activity_cutoff_factor_extrinsic composes correct call and submits it.""" + # Preps + netuid = 1 + factor_milli = 5000 + mocked_set_factor = mocker.patch.object( + tempo_control.SubtensorModule, "set_activity_cutoff_factor" + ) + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + success, message = tempo_control.set_activity_cutoff_factor_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + factor_milli=factor_milli, + mev_protection=False, + ) + + # Asserts + mocked_set_factor.assert_called_once_with(netuid=netuid, factor_milli=factor_milli) + mocked_sign_and_send_extrinsic.assert_called_once_with( + call=mocked_set_factor.return_value, + wallet=fake_wallet, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert success is True + assert "Success" in message + + +def test_set_activity_cutoff_factor_extrinsic_mev_protection( + subtensor, mocker, fake_wallet +): + """Verify that set_activity_cutoff_factor_extrinsic uses submit_encrypted_extrinsic when mev_protection=True.""" + # Preps + netuid = 1 + factor_milli = 5000 + mocked_set_factor = mocker.patch.object( + tempo_control.SubtensorModule, "set_activity_cutoff_factor" + ) + mock_submit = mocker.patch( + "bittensor.core.extrinsics.tempo_control.submit_encrypted_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, "sign_and_send_extrinsic" + ) + + # Call + success, message = tempo_control.set_activity_cutoff_factor_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + factor_milli=factor_milli, + mev_protection=True, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + + # Asserts + assert success is True + assert "Success" in message + mock_submit.assert_called_once_with( + subtensor=subtensor, + wallet=fake_wallet, + call=mocked_set_factor.return_value, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + mocked_sign_and_send_extrinsic.assert_not_called() + + +def test_trigger_epoch_extrinsic(subtensor, mocker, fake_wallet): + """Verify that trigger_epoch_extrinsic composes correct call and submits it.""" + # Preps + netuid = 1 + mocked_trigger_epoch = mocker.patch.object( + tempo_control.SubtensorModule, "trigger_epoch" + ) + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + success, message = tempo_control.trigger_epoch_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + mev_protection=False, + ) + + # Asserts + mocked_trigger_epoch.assert_called_once_with(netuid=netuid) + mocked_sign_and_send_extrinsic.assert_called_once_with( + call=mocked_trigger_epoch.return_value, + wallet=fake_wallet, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert success is True + assert "Success" in message + + +def test_trigger_epoch_extrinsic_mev_protection(subtensor, mocker, fake_wallet): + """Verify that trigger_epoch_extrinsic uses submit_encrypted_extrinsic when mev_protection=True.""" + # Preps + netuid = 1 + mocked_trigger_epoch = mocker.patch.object( + tempo_control.SubtensorModule, "trigger_epoch" + ) + mock_submit = mocker.patch( + "bittensor.core.extrinsics.tempo_control.submit_encrypted_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, "sign_and_send_extrinsic" + ) + + # Call + success, message = tempo_control.trigger_epoch_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + mev_protection=True, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + + # Asserts + assert success is True + assert "Success" in message + mock_submit.assert_called_once_with( + subtensor=subtensor, + wallet=fake_wallet, + call=mocked_trigger_epoch.return_value, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + mocked_sign_and_send_extrinsic.assert_not_called() + + +def test_root_set_activity_cutoff_factor_extrinsic(subtensor, mocker, fake_wallet): + """Verify root_set_activity_cutoff_factor_extrinsic extrinsic.""" + # Preps + netuid = 1 + factor_milli = 5000 + mocked_set_factor = mocker.patch.object( + tempo_control.SubtensorModule, "set_activity_cutoff_factor" + ) + mocked_sudo = mocker.patch.object(tempo_control.Sudo, "sudo") + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + success, message = tempo_control.root_set_activity_cutoff_factor_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + factor_milli=factor_milli, + mev_protection=False, + ) + + # Asserts + mocked_set_factor.assert_called_once_with(netuid=netuid, factor_milli=factor_milli) + mocked_sudo.assert_called_once_with(call=mocked_set_factor.return_value) + mocked_sign_and_send_extrinsic.assert_called_once_with( + call=mocked_sudo.return_value, + wallet=fake_wallet, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert success is True + assert "Success" in message + + +def test_root_set_activity_cutoff_factor_extrinsic_mev_protection( + subtensor, mocker, fake_wallet +): + """Verify that root_set_activity_cutoff_factor_extrinsic uses submit_encrypted_extrinsic when mev_protection=True.""" + # Preps + netuid = 1 + factor_milli = 5000 + mocked_set_factor = mocker.patch.object( + tempo_control.SubtensorModule, "set_activity_cutoff_factor" + ) + mocked_sudo = mocker.patch.object(tempo_control.Sudo, "sudo") + mock_submit = mocker.patch( + "bittensor.core.extrinsics.tempo_control.submit_encrypted_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, "sign_and_send_extrinsic" + ) + + # Call + success, message = tempo_control.root_set_activity_cutoff_factor_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + factor_milli=factor_milli, + mev_protection=True, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + + # Asserts + assert success is True + assert "Success" in message + mocked_set_factor.assert_called_once_with(netuid=netuid, factor_milli=factor_milli) + mocked_sudo.assert_called_once_with(call=mocked_set_factor.return_value) + mock_submit.assert_called_once_with( + subtensor=subtensor, + wallet=fake_wallet, + call=mocked_sudo.return_value, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + mocked_sign_and_send_extrinsic.assert_not_called() diff --git a/tests/unit_tests/extrinsics/test_weights.py b/tests/unit_tests/extrinsics/test_weights.py index 99c0b233b3..31cfa16911 100644 --- a/tests/unit_tests/extrinsics/test_weights.py +++ b/tests/unit_tests/extrinsics/test_weights.py @@ -22,12 +22,13 @@ def test_commit_timelocked_weights_extrinsic(mocker, subtensor, fake_wallet): mocked_get_subnet_hyperparameters = mocker.patch.object( subtensor, "get_subnet_hyperparameters" ) - mocked_get_sub_subnet_storage_index = mocker.patch.object( - weights_module, "get_mechid_storage_index" + fake_schedule = mocker.Mock() + mocker.patch.object( + subtensor, "get_epoch_schedule_state", return_value=fake_schedule ) - mocked_get_encrypted_commit = mocker.patch.object( + mocked_get_encrypted_commit_v2 = mocker.patch.object( weights_module, - "get_encrypted_commit", + "get_encrypted_commit_v2", return_value=(mocker.Mock(), mocker.Mock()), ) mocked_compose_call = mocker.patch.object(subtensor, "compose_call") @@ -36,7 +37,7 @@ def test_commit_timelocked_weights_extrinsic(mocker, subtensor, fake_wallet): "sign_and_send_extrinsic", return_value=ExtrinsicResponse( True, - f"reveal_round:{mocked_get_encrypted_commit.return_value[1]}", + f"reveal_round:{mocked_get_encrypted_commit_v2.return_value[1]}", ), ) @@ -53,17 +54,17 @@ def test_commit_timelocked_weights_extrinsic(mocker, subtensor, fake_wallet): # Asserts mocked_convert_and_normalize_weights_and_uids.assert_called_once_with(uids, weights) - mocked_get_sub_subnet_storage_index.assert_called_once_with( - netuid=netuid, mechid=mechid - ) - mocked_get_encrypted_commit.assert_called_once_with( - uids=uids, - weights=weights, - subnet_reveal_period_epochs=mocked_get_subnet_hyperparameters.return_value.commit_reveal_period, + mocked_get_encrypted_commit_v2.assert_called_once_with( + uids=list(uids), + weights=list(weights), version_key=weights_module.version_as_int, - tempo=mocked_get_subnet_hyperparameters.return_value.tempo, - netuid=mocked_get_sub_subnet_storage_index.return_value, - current_block=mocked_get_current_block.return_value, + last_epoch_block=fake_schedule.last_epoch_block, + pending_epoch_at=fake_schedule.pending_epoch_at, + subnet_epoch_index=fake_schedule.subnet_epoch_index, + tempo=fake_schedule.tempo, + blocks_since_last_step=fake_schedule.blocks_since_last_step, + current_block=fake_schedule.current_block, + subnet_reveal_period_epochs=mocked_get_subnet_hyperparameters.return_value.commit_reveal_period, block_time=block_time, hotkey=fake_wallet.hotkey.public_key, ) @@ -73,8 +74,8 @@ def test_commit_timelocked_weights_extrinsic(mocker, subtensor, fake_wallet): call_params={ "netuid": netuid, "mecid": mechid, - "commit": mocked_get_encrypted_commit.return_value[0], - "reveal_round": mocked_get_encrypted_commit.return_value[1], + "commit": mocked_get_encrypted_commit_v2.return_value[0], + "reveal_round": mocked_get_encrypted_commit_v2.return_value[1], "commit_reveal_version": 4, }, ) diff --git a/tests/unit_tests/test_async_subtensor.py b/tests/unit_tests/test_async_subtensor.py index bdc29af8a2..94029b20ab 100644 --- a/tests/unit_tests/test_async_subtensor.py +++ b/tests/unit_tests/test_async_subtensor.py @@ -915,6 +915,183 @@ async def test_get_existential_deposit_raise_exception(subtensor, mocker): spy_balance_from_rao.assert_not_called() +@pytest.mark.asyncio +async def test_get_max_activity_cutoff_factor_milli(subtensor, mocker): + """Tests get_max_activity_cutoff_factor_milli method.""" + # Preps + fake_block_hash = "block_hash" + mocked_substrate_get_constant = mocker.AsyncMock( + return_value=mocker.Mock(value=50_000) + ) + subtensor.substrate.get_constant = mocked_substrate_get_constant + + # Call + result = await subtensor.get_max_activity_cutoff_factor_milli( + block_hash=fake_block_hash + ) + + # Asserts + mocked_substrate_get_constant.assert_awaited_once() + mocked_substrate_get_constant.assert_called_once_with( + module_name="SubtensorModule", + constant_name="MaxActivityCutoffFactorMilli", + block_hash=fake_block_hash, + ) + assert result == 50_000 + + +@pytest.mark.asyncio +async def test_get_max_activity_cutoff_factor_milli_none(subtensor, mocker): + """Tests get_max_activity_cutoff_factor_milli raises when constant is missing.""" + # Preps + mocked_substrate_get_constant = mocker.AsyncMock(return_value=None) + subtensor.substrate.get_constant = mocked_substrate_get_constant + + # Call + with pytest.raises(Exception): + await subtensor.get_max_activity_cutoff_factor_milli(block_hash="block_hash") + + +@pytest.mark.asyncio +async def test_get_max_epochs_per_block(subtensor, mocker): + """Tests get_max_epochs_per_block method.""" + # Preps + fake_block_hash = "block_hash" + mocked_substrate_get_constant = mocker.AsyncMock(return_value=mocker.Mock(value=32)) + subtensor.substrate.get_constant = mocked_substrate_get_constant + + # Call + result = await subtensor.get_max_epochs_per_block(block_hash=fake_block_hash) + + # Asserts + mocked_substrate_get_constant.assert_awaited_once() + mocked_substrate_get_constant.assert_called_once_with( + module_name="SubtensorModule", + constant_name="MaxEpochsPerBlock", + block_hash=fake_block_hash, + ) + assert result == 32 + + +@pytest.mark.asyncio +async def test_get_max_epochs_per_block_none(subtensor, mocker): + """Tests get_max_epochs_per_block raises when constant is missing.""" + # Preps + mocked_substrate_get_constant = mocker.AsyncMock(return_value=None) + subtensor.substrate.get_constant = mocked_substrate_get_constant + + # Call + with pytest.raises(Exception): + await subtensor.get_max_epochs_per_block(block_hash="block_hash") + + +@pytest.mark.asyncio +async def test_get_max_tempo(subtensor, mocker): + """Tests get_max_tempo method.""" + # Preps + fake_block_hash = "block_hash" + mocked_substrate_get_constant = mocker.AsyncMock( + return_value=mocker.Mock(value=50_400) + ) + subtensor.substrate.get_constant = mocked_substrate_get_constant + + # Call + result = await subtensor.get_max_tempo(block_hash=fake_block_hash) + + # Asserts + mocked_substrate_get_constant.assert_awaited_once() + mocked_substrate_get_constant.assert_called_once_with( + module_name="SubtensorModule", + constant_name="MaxTempo", + block_hash=fake_block_hash, + ) + assert result == 50_400 + + +@pytest.mark.asyncio +async def test_get_max_tempo_none(subtensor, mocker): + """Tests get_max_tempo raises when constant is missing.""" + # Preps + mocked_substrate_get_constant = mocker.AsyncMock(return_value=None) + subtensor.substrate.get_constant = mocked_substrate_get_constant + + # Call + with pytest.raises(Exception): + await subtensor.get_max_tempo(block_hash="block_hash") + + +@pytest.mark.asyncio +async def test_get_min_activity_cutoff_factor_milli(subtensor, mocker): + """Tests get_min_activity_cutoff_factor_milli method.""" + # Preps + fake_block_hash = "block_hash" + mocked_substrate_get_constant = mocker.AsyncMock( + return_value=mocker.Mock(value=1_000) + ) + subtensor.substrate.get_constant = mocked_substrate_get_constant + + # Call + result = await subtensor.get_min_activity_cutoff_factor_milli( + block_hash=fake_block_hash + ) + + # Asserts + mocked_substrate_get_constant.assert_awaited_once() + mocked_substrate_get_constant.assert_called_once_with( + module_name="SubtensorModule", + constant_name="MinActivityCutoffFactorMilli", + block_hash=fake_block_hash, + ) + assert result == 1_000 + + +@pytest.mark.asyncio +async def test_get_min_activity_cutoff_factor_milli_none(subtensor, mocker): + """Tests get_min_activity_cutoff_factor_milli raises when constant is missing.""" + # Preps + mocked_substrate_get_constant = mocker.AsyncMock(return_value=None) + subtensor.substrate.get_constant = mocked_substrate_get_constant + + # Call + with pytest.raises(Exception): + await subtensor.get_min_activity_cutoff_factor_milli(block_hash="block_hash") + + +@pytest.mark.asyncio +async def test_get_min_tempo(subtensor, mocker): + """Tests get_min_tempo method.""" + # Preps + fake_block_hash = "block_hash" + mocked_substrate_get_constant = mocker.AsyncMock( + return_value=mocker.Mock(value=360) + ) + subtensor.substrate.get_constant = mocked_substrate_get_constant + + # Call + result = await subtensor.get_min_tempo(block_hash=fake_block_hash) + + # Asserts + mocked_substrate_get_constant.assert_awaited_once() + mocked_substrate_get_constant.assert_called_once_with( + module_name="SubtensorModule", + constant_name="MinTempo", + block_hash=fake_block_hash, + ) + assert result == 360 + + +@pytest.mark.asyncio +async def test_get_min_tempo_none(subtensor, mocker): + """Tests get_min_tempo raises when constant is missing.""" + # Preps + mocked_substrate_get_constant = mocker.AsyncMock(return_value=None) + subtensor.substrate.get_constant = mocked_substrate_get_constant + + # Call + with pytest.raises(Exception): + await subtensor.get_min_tempo(block_hash="block_hash") + + @pytest.mark.asyncio async def test_neurons(subtensor, mocker): """Tests neurons method.""" @@ -1989,92 +2166,49 @@ async def test_get_children_pending(mock_substrate, subtensor): @pytest.mark.asyncio async def test_get_subnet_hyperparameters_success(subtensor, mocker): - """Tests get_subnet_hyperparameters with successful hyperparameter retrieval.""" - # Preps + """Tests get_subnet_hyperparameters uses runtime fallback and from_any.""" fake_netuid = 1 fake_block_hash = "block_hash" - fake_result = object() - - mocked_query_runtime_api = mocker.AsyncMock(return_value=fake_result) - subtensor.query_runtime_api = mocked_query_runtime_api + fake_v3_entries = [ + {"name": "tempo", "value": {"U16": 360}}, + {"name": "kappa", "value": {"U16": 32767}}, + ] - mocked_from_dict = mocker.Mock() + mocker.patch.object(subtensor, "determine_block_hash", return_value=fake_block_hash) mocker.patch.object( - async_subtensor.SubnetHyperparameters, "from_dict", mocked_from_dict + subtensor, "_runtime_call_with_fallback", return_value=fake_v3_entries ) + mocker.patch.object(async_subtensor.SubnetHyperparameters, "from_any") - # Call - result = await subtensor.get_subnet_hyperparameters( + await subtensor.get_subnet_hyperparameters( netuid=fake_netuid, block_hash=fake_block_hash ) - # Asserts - mocked_query_runtime_api.assert_called_once_with( - runtime_api="SubnetInfoRuntimeApi", - method="get_subnet_hyperparams_v2", - params=[fake_netuid], - block=None, + subtensor._runtime_call_with_fallback.assert_awaited_once_with( + ("SubnetInfoRuntimeApi", "get_subnet_hyperparams_v3", [fake_netuid]), + ("SubnetInfoRuntimeApi", "get_subnet_hyperparams_v2", [fake_netuid]), + ("SubnetInfoRuntimeApi", "get_subnet_hyperparams", [fake_netuid]), block_hash=fake_block_hash, - reuse_block=False, + default_value=None, + ) + async_subtensor.SubnetHyperparameters.from_any.assert_called_once_with( + fake_v3_entries ) - assert result == mocked_from_dict.return_value @pytest.mark.asyncio async def test_get_subnet_hyperparameters_no_data(subtensor, mocker): - """Tests get_subnet_hyperparameters when no hyperparameters data is returned.""" - # Preps + """Tests get_subnet_hyperparameters returns None when runtime call is empty.""" fake_netuid = 1 - mocked_query_runtime_api = mocker.AsyncMock(return_value=None) - subtensor.query_runtime_api = mocked_query_runtime_api + mocker.patch.object(subtensor, "determine_block_hash", return_value=None) + mocker.patch.object(subtensor, "_runtime_call_with_fallback", return_value=None) - # Call result = await subtensor.get_subnet_hyperparameters(netuid=fake_netuid) - # Asserts - mocked_query_runtime_api.assert_called_once_with( - runtime_api="SubnetInfoRuntimeApi", - method="get_subnet_hyperparams_v2", - params=[fake_netuid], - block=None, - block_hash=None, - reuse_block=False, - ) assert result is None -@pytest.mark.asyncio -async def test_get_subnet_hyperparameters_without_0x_prefix(subtensor, mocker): - """Tests get_subnet_hyperparameters when hex_bytes_result is without 0x prefix.""" - # Preps - fake_netuid = 1 - fake_result = object() - - mocked_query_runtime_api = mocker.AsyncMock(return_value=fake_result) - subtensor.query_runtime_api = mocked_query_runtime_api - - mocked_from_dict = mocker.Mock() - mocker.patch.object( - async_subtensor.SubnetHyperparameters, "from_dict", mocked_from_dict - ) - - # Call - result = await subtensor.get_subnet_hyperparameters(netuid=fake_netuid) - - # Asserts - mocked_query_runtime_api.assert_called_once_with( - runtime_api="SubnetInfoRuntimeApi", - method="get_subnet_hyperparams_v2", - params=[fake_netuid], - block=None, - block_hash=None, - reuse_block=False, - ) - mocked_from_dict.assert_called_once_with(fake_result) - assert result == mocked_from_dict.return_value - - @pytest.mark.asyncio async def test_get_vote_data_success(subtensor, mocker): """Tests get_vote_data when voting data is successfully retrieved.""" @@ -3304,35 +3438,32 @@ async def test_get_subnet_info_no_data(mocker, subtensor): @pytest.mark.asyncio async def test_get_next_epoch_start_block(mocker, subtensor): - """Check that get_next_epoch_start_block returns the correct value.""" - # Prep + """Check that get_next_epoch_start_block delegates to runtime API.""" netuid = 14 block = 20 - fake_block_hash = mocker.MagicMock() - mocker.patch.object(subtensor, "get_block_hash", return_value=fake_block_hash) - mocked_tempo = mocker.patch.object(subtensor, "tempo", return_value=100) - mocked_get_block_number = mocker.patch.object( - subtensor.substrate, "get_block_number" - ) + mocker.patch.object(subtensor, "query_runtime_api", return_value=150) - # Call result = await subtensor.get_next_epoch_start_block(netuid=netuid, block=block) - # Asserts - mocked_tempo.assert_awaited_once_with( - netuid=netuid, - block_hash=fake_block_hash, - ) - assert ( - result - == mocked_get_block_number.return_value.__add__() - .__mod__() - .__mod__() - .__rsub__() - .__radd__() - .__add__() + subtensor.query_runtime_api.assert_awaited_once_with( + runtime_api="SubnetInfoRuntimeApi", + method="get_next_epoch_start_block", + params=[netuid], + block=block, + block_hash=None, + reuse_block=False, ) + assert result == 150 + + +@pytest.mark.asyncio +async def test_get_next_epoch_start_block_none(mocker, subtensor): + """Check that get_next_epoch_start_block returns None when API returns None.""" + mocker.patch.object(subtensor, "query_runtime_api", return_value=None) + + result = await subtensor.get_next_epoch_start_block(netuid=1, block=20) + assert result is None @pytest.mark.asyncio @@ -4181,45 +4312,45 @@ async def test_get_mechanism_count(subtensor, mocker): @pytest.mark.asyncio async def test_is_in_admin_freeze_window_root_net(subtensor, mocker): """Verify that root net has no admin freeze window.""" - # Preps netuid = 0 - mocked_get_next_epoch_start_block = mocker.patch.object( - subtensor, "get_next_epoch_start_block" - ) + mocked_tempo = mocker.patch.object(subtensor, "tempo") - # Call result = await subtensor.is_in_admin_freeze_window(netuid=netuid) - # Asserts - mocked_get_next_epoch_start_block.assert_not_called() + mocked_tempo.assert_not_called() assert result is False @pytest.mark.parametrize( - "block, next_esb, expected_result", - ( - [89, 100, False], - [90, 100, False], - [91, 100, True], - ), + "block_number, pending, last, tempo, window, expected", + [ + (91, 200, 80, 20, 10, True), + (91, 0, 80, 20, 10, True), + (90, 0, 80, 20, 10, False), + (89, 0, 80, 20, 10, False), + ], ) @pytest.mark.asyncio async def test_is_in_admin_freeze_window( - subtensor, mocker, block, next_esb, expected_result + subtensor, mocker, block_number, pending, last, tempo, window, expected ): - """Verify that `is_in_admin_freeze_window` method processed the data correctly.""" - # Preps + """Verify is_in_admin_freeze_window matches chain logic with storage getters.""" netuid = 14 - mocker.patch.object(subtensor, "get_current_block", return_value=block) - mocker.patch.object(subtensor, "get_next_epoch_start_block", return_value=next_esb) - mocker.patch.object(subtensor, "get_admin_freeze_window", return_value=10) - - # Call + fake_block_hash = mocker.MagicMock() + mocker.patch.object(subtensor, "determine_block_hash", return_value=fake_block_hash) + mocker.patch.object( + subtensor.substrate, "get_block_number", return_value=block_number + ) + mocker.patch.object(subtensor, "tempo", return_value=tempo) + mocker.patch.object(subtensor, "get_pending_epoch_at", return_value=pending) + mocker.patch.object(subtensor, "get_last_epoch_block", return_value=last) + mocker.patch.object(subtensor, "get_admin_freeze_window", return_value=window) - result = await subtensor.is_in_admin_freeze_window(netuid=netuid) + result = await subtensor.is_in_admin_freeze_window( + netuid=netuid, block=block_number + ) - # Asserts - assert result is expected_result + assert result is expected @pytest.mark.asyncio @@ -5933,28 +6064,31 @@ async def test_remove_proxy(mocker, subtensor): @pytest.mark.asyncio -async def test_blocks_until_next_epoch_uses_default_tempo(subtensor, mocker): - """Test blocks_until_next_epoch uses self.tempo when tempo is None.""" - # Prep - netuid = 0 +async def test_blocks_until_next_epoch_runtime_api(subtensor, mocker): + """Test blocks_until_next_epoch derives from get_next_epoch_start_block runtime API.""" + netuid = 1 block = 20 - tempo = 100 - mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") - spy_get_current_block = mocker.spy(subtensor, "get_current_block") - spy_tempo = mocker.spy(subtensor, "tempo") + fake_block_hash = mocker.MagicMock() + mocker.patch.object(subtensor, "determine_block_hash", return_value=fake_block_hash) + mocker.patch.object(subtensor.substrate, "get_block_number", return_value=block) + mocker.patch.object(subtensor, "get_next_epoch_start_block", return_value=150) - # Call - result = await subtensor.blocks_until_next_epoch( - netuid=netuid, tempo=tempo, block=block - ) + result = await subtensor.blocks_until_next_epoch(netuid=netuid, block=block) - # Assert - mocked_determine_block_hash.assert_awaited_once_with(block, None, False) - spy_get_current_block.assert_not_awaited() - spy_tempo.assert_not_awaited() - assert result is not None - assert isinstance(result, int) + assert result == 130 + + +@pytest.mark.asyncio +async def test_blocks_until_next_epoch_none_when_no_tempo(subtensor, mocker): + """Test blocks_until_next_epoch returns None when runtime API returns None.""" + fake_block_hash = mocker.MagicMock() + mocker.patch.object(subtensor, "determine_block_hash", return_value=fake_block_hash) + mocker.patch.object(subtensor.substrate, "get_block_number", return_value=20) + mocker.patch.object(subtensor, "get_next_epoch_start_block", return_value=None) + + result = await subtensor.blocks_until_next_epoch(netuid=1, block=20) + assert result is None @pytest.mark.asyncio @@ -6634,3 +6768,270 @@ async def test_dispute_coldkey_swap(mocker, subtensor): wait_for_revealed_execution=True, ) assert response == mocked_dispute_coldkey_swap_extrinsic.return_value + + +@pytest.mark.asyncio +async def test_get_last_epoch_block(subtensor, mocker): + """Verify that `get_last_epoch_block` queries LastEpochBlock storage.""" + # Preps + netuid = 7 + mocked_query = mocker.patch.object(subtensor, "query_subtensor") + + # Call + result = await subtensor.get_last_epoch_block(netuid=netuid) + + # Asserts + mocked_query.assert_awaited_once_with( + name="LastEpochBlock", + block=None, + block_hash=None, + reuse_block=False, + params=[netuid], + ) + assert result == mocked_query.return_value.value + + +@pytest.mark.asyncio +async def test_get_pending_epoch_at(subtensor, mocker): + """Verify that `get_pending_epoch_at` queries PendingEpochAt storage.""" + # Preps + netuid = 7 + mocked_query = mocker.patch.object(subtensor, "query_subtensor") + + # Call + result = await subtensor.get_pending_epoch_at(netuid=netuid) + + # Asserts + mocked_query.assert_awaited_once_with( + name="PendingEpochAt", + block=None, + block_hash=None, + reuse_block=False, + params=[netuid], + ) + assert result == mocked_query.return_value.value + + +@pytest.mark.asyncio +async def test_get_subnet_epoch_index(subtensor, mocker): + """Verify that `get_subnet_epoch_index` queries SubnetEpochIndex storage.""" + # Preps + netuid = 7 + mocked_query = mocker.patch.object(subtensor, "query_subtensor") + + # Call + result = await subtensor.get_subnet_epoch_index(netuid=netuid) + + # Asserts + mocked_query.assert_awaited_once_with( + name="SubnetEpochIndex", + block=None, + block_hash=None, + reuse_block=False, + params=[netuid], + ) + assert result == mocked_query.return_value.value + + +@pytest.mark.asyncio +async def test_get_activity_cutoff_factor_milli(subtensor, mocker): + """Verify that `get_activity_cutoff_factor_milli` queries ActivityCutoffFactorMilli storage.""" + # Preps + netuid = 7 + mocked_query = mocker.patch.object(subtensor, "query_subtensor") + + # Call + result = await subtensor.get_activity_cutoff_factor_milli(netuid=netuid) + + # Asserts + mocked_query.assert_awaited_once_with( + name="ActivityCutoffFactorMilli", + block=None, + block_hash=None, + reuse_block=False, + params=[netuid], + ) + assert result == mocked_query.return_value.value + + +@pytest.mark.asyncio +async def test_get_owner_hyperparam_rate_limit(subtensor, mocker): + """Verify that `get_owner_hyperparam_rate_limit` queries OwnerHyperparamRateLimit storage.""" + # Preps + mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") + mocked_query = mocker.patch.object(subtensor.substrate, "query") + + # Call + result = await subtensor.get_owner_hyperparam_rate_limit() + + # Asserts + mocked_query.assert_awaited_once_with( + module="SubtensorModule", + storage_function="OwnerHyperparamRateLimit", + block_hash=mocked_determine_block_hash.return_value, + ) + assert result == mocked_query.return_value.value + + +@pytest.mark.asyncio +async def test_get_epoch_schedule_state(subtensor, mocker): + """Verify that `get_epoch_schedule_state` gathers sub-queries into EpochScheduleState.""" + # Preps + netuid = 7 + fake_block_hash = mocker.MagicMock() + fake_block_number = 500 + mocker.patch.object(subtensor, "determine_block_hash", return_value=fake_block_hash) + mocker.patch.object( + subtensor.substrate, "get_block_number", return_value=fake_block_number + ) + mocker.patch.object(subtensor, "get_last_epoch_block", return_value=100) + mocker.patch.object(subtensor, "get_pending_epoch_at", return_value=0) + mocker.patch.object(subtensor, "get_subnet_epoch_index", return_value=42) + mocker.patch.object(subtensor, "tempo", return_value=360) + mocker.patch.object(subtensor, "blocks_since_last_step", return_value=50) + + # Call + result = await subtensor.get_epoch_schedule_state(netuid=netuid) + + # Asserts + subtensor.get_last_epoch_block.assert_awaited_once_with( + netuid, block_hash=fake_block_hash + ) + subtensor.get_pending_epoch_at.assert_awaited_once_with( + netuid, block_hash=fake_block_hash + ) + subtensor.get_subnet_epoch_index.assert_awaited_once_with( + netuid, block_hash=fake_block_hash + ) + subtensor.tempo.assert_awaited_once_with(netuid, block_hash=fake_block_hash) + subtensor.blocks_since_last_step.assert_awaited_once_with( + netuid, block_hash=fake_block_hash + ) + assert result.last_epoch_block == 100 + assert result.pending_epoch_at == 0 + assert result.subnet_epoch_index == 42 + assert result.tempo == 360 + assert result.blocks_since_last_step == 50 + assert result.current_block == fake_block_number + + +@pytest.mark.asyncio +async def test_set_tempo(subtensor, mocker): + """Verify that `set_tempo` delegates to set_tempo_extrinsic with correct parameters.""" + # Preps + wallet = mocker.Mock(spec=Wallet) + mocked_extrinsic = mocker.patch.object(async_subtensor, "set_tempo_extrinsic") + + # Call + result = await subtensor.set_tempo( + wallet=wallet, + netuid=7, + tempo=500, + ) + + # Asserts + mocked_extrinsic.assert_awaited_once_with( + subtensor=subtensor, + wallet=wallet, + netuid=7, + tempo=500, + 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 result == mocked_extrinsic.return_value + + +@pytest.mark.asyncio +async def test_set_activity_cutoff_factor(subtensor, mocker): + """Verify that `set_activity_cutoff_factor` delegates to set_activity_cutoff_factor_extrinsic.""" + # Preps + wallet = mocker.Mock(spec=Wallet) + mocked_extrinsic = mocker.patch.object( + async_subtensor, "set_activity_cutoff_factor_extrinsic" + ) + + # Call + result = await subtensor.set_activity_cutoff_factor( + wallet=wallet, + netuid=7, + factor_milli=5000, + ) + + # Asserts + mocked_extrinsic.assert_awaited_once_with( + subtensor=subtensor, + wallet=wallet, + netuid=7, + factor_milli=5000, + 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 result == mocked_extrinsic.return_value + + +@pytest.mark.asyncio +async def test_root_set_activity_cutoff_factor(subtensor, mocker): + """Verify that `root_set_activity_cutoff_factor` delegates to root_set_activity_cutoff_factor_extrinsic.""" + # Preps + wallet = mocker.Mock(spec=Wallet) + mocked_extrinsic = mocker.patch.object( + async_subtensor, "root_set_activity_cutoff_factor_extrinsic" + ) + + # Call + result = await subtensor.root_set_activity_cutoff_factor( + wallet=wallet, + netuid=7, + factor_milli=5000, + ) + + # Asserts + mocked_extrinsic.assert_awaited_once_with( + subtensor=subtensor, + wallet=wallet, + netuid=7, + factor_milli=5000, + 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 result == mocked_extrinsic.return_value + + +@pytest.mark.asyncio +async def test_trigger_epoch(subtensor, mocker): + """Verify that `trigger_epoch` delegates to trigger_epoch_extrinsic.""" + # Preps + wallet = mocker.Mock(spec=Wallet) + mocked_extrinsic = mocker.patch.object(async_subtensor, "trigger_epoch_extrinsic") + + # Call + result = await subtensor.trigger_epoch( + wallet=wallet, + netuid=7, + ) + + # Asserts + mocked_extrinsic.assert_awaited_once_with( + subtensor=subtensor, + wallet=wallet, + netuid=7, + 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 result == mocked_extrinsic.return_value diff --git a/tests/unit_tests/test_subtensor.py b/tests/unit_tests/test_subtensor.py index f615de6b39..4407381deb 100644 --- a/tests/unit_tests/test_subtensor.py +++ b/tests/unit_tests/test_subtensor.py @@ -845,55 +845,45 @@ def test_get_subnets_no_block_specified(mocker, subtensor): # `get_subnet_hyperparameters` tests def test_get_subnet_hyperparameters_success(mocker, subtensor): - """Test get_subnet_hyperparameters returns correct data when hyperparameters are found.""" - # Prep + """Test get_subnet_hyperparameters uses runtime fallback and from_any.""" netuid = 1 block = 123 + block_hash = "0xabc" + fake_v3_entries = [ + {"name": "tempo", "value": {"U16": 360}}, + {"name": "kappa", "value": {"U16": 32767}}, + ] + mocker.patch.object(subtensor, "determine_block_hash", return_value=block_hash) mocker.patch.object( - subtensor, - "query_runtime_api", - ) - mocker.patch.object( - subtensor_module.SubnetHyperparameters, - "from_dict", + subtensor, "_runtime_call_with_fallback", return_value=fake_v3_entries ) + mocker.patch.object(subtensor_module.SubnetHyperparameters, "from_any") - # Call subtensor.get_subnet_hyperparameters(netuid, block) - # Asserts - subtensor.query_runtime_api.assert_called_once_with( - runtime_api="SubnetInfoRuntimeApi", - method="get_subnet_hyperparams_v2", - params=[netuid], - block=block, + subtensor._runtime_call_with_fallback.assert_called_once_with( + ("SubnetInfoRuntimeApi", "get_subnet_hyperparams_v3", [netuid]), + ("SubnetInfoRuntimeApi", "get_subnet_hyperparams_v2", [netuid]), + ("SubnetInfoRuntimeApi", "get_subnet_hyperparams", [netuid]), + block_hash=block_hash, + default_value=None, ) - subtensor_module.SubnetHyperparameters.from_dict.assert_called_once_with( - subtensor.query_runtime_api.return_value, + subtensor_module.SubnetHyperparameters.from_any.assert_called_once_with( + fake_v3_entries ) def test_get_subnet_hyperparameters_no_data(mocker, subtensor): - """Test get_subnet_hyperparameters returns empty list when no data is found.""" - # Prep + """Test get_subnet_hyperparameters returns None when runtime call is empty.""" netuid = 1 block = 123 - mocker.patch.object(subtensor, "query_runtime_api", return_value=None) - mocker.patch.object(subtensor_module.SubnetHyperparameters, "from_dict") + mocker.patch.object(subtensor, "determine_block_hash", return_value="0xabc") + mocker.patch.object(subtensor, "_runtime_call_with_fallback", return_value=None) - # Call result = subtensor.get_subnet_hyperparameters(netuid, block) - # Asserts assert result is None - subtensor.query_runtime_api.assert_called_once_with( - runtime_api="SubnetInfoRuntimeApi", - method="get_subnet_hyperparams_v2", - params=[netuid], - block=block, - ) - subtensor_module.SubnetHyperparameters.from_dict.assert_not_called() def test_query_subtensor(subtensor, mocker): @@ -1666,6 +1656,111 @@ def test_get_existential_deposit(subtensor, mocker): assert result == Balance.from_rao(value) +def test_get_max_activity_cutoff_factor_milli(subtensor, mocker): + """Successful get_max_activity_cutoff_factor_milli call.""" + # Prep + block = 123 + mocked_query_constant = mocker.MagicMock() + mocked_query_constant.return_value.value = 50_000 + subtensor.substrate.get_constant = mocked_query_constant + + # Call + result = subtensor.get_max_activity_cutoff_factor_milli(block=block) + + # Assertions + mocked_query_constant.assert_called_once_with( + module_name="SubtensorModule", + constant_name="MaxActivityCutoffFactorMilli", + block_hash=subtensor.substrate.get_block_hash.return_value, + ) + subtensor.substrate.get_block_hash.assert_called_once_with(block) + assert result == 50_000 + + +def test_get_max_epochs_per_block(subtensor, mocker): + """Successful get_max_epochs_per_block call.""" + # Prep + block = 123 + mocked_query_constant = mocker.MagicMock() + mocked_query_constant.return_value.value = 32 + subtensor.substrate.get_constant = mocked_query_constant + + # Call + result = subtensor.get_max_epochs_per_block(block=block) + + # Assertions + mocked_query_constant.assert_called_once_with( + module_name="SubtensorModule", + constant_name="MaxEpochsPerBlock", + block_hash=subtensor.substrate.get_block_hash.return_value, + ) + subtensor.substrate.get_block_hash.assert_called_once_with(block) + assert result == 32 + + +def test_get_max_tempo(subtensor, mocker): + """Successful get_max_tempo call.""" + # Prep + block = 123 + mocked_query_constant = mocker.MagicMock() + mocked_query_constant.return_value.value = 50_400 + subtensor.substrate.get_constant = mocked_query_constant + + # Call + result = subtensor.get_max_tempo(block=block) + + # Assertions + mocked_query_constant.assert_called_once_with( + module_name="SubtensorModule", + constant_name="MaxTempo", + block_hash=subtensor.substrate.get_block_hash.return_value, + ) + subtensor.substrate.get_block_hash.assert_called_once_with(block) + assert result == 50_400 + + +def test_get_min_activity_cutoff_factor_milli(subtensor, mocker): + """Successful get_min_activity_cutoff_factor_milli call.""" + # Prep + block = 123 + mocked_query_constant = mocker.MagicMock() + mocked_query_constant.return_value.value = 1_000 + subtensor.substrate.get_constant = mocked_query_constant + + # Call + result = subtensor.get_min_activity_cutoff_factor_milli(block=block) + + # Assertions + mocked_query_constant.assert_called_once_with( + module_name="SubtensorModule", + constant_name="MinActivityCutoffFactorMilli", + block_hash=subtensor.substrate.get_block_hash.return_value, + ) + subtensor.substrate.get_block_hash.assert_called_once_with(block) + assert result == 1_000 + + +def test_get_min_tempo(subtensor, mocker): + """Successful get_min_tempo call.""" + # Prep + block = 123 + mocked_query_constant = mocker.MagicMock() + mocked_query_constant.return_value.value = 360 + subtensor.substrate.get_constant = mocked_query_constant + + # Call + result = subtensor.get_min_tempo(block=block) + + # Assertions + mocked_query_constant.assert_called_once_with( + module_name="SubtensorModule", + constant_name="MinTempo", + block_hash=subtensor.substrate.get_block_hash.return_value, + ) + subtensor.substrate.get_block_hash.assert_called_once_with(block) + assert result == 360 + + def test_reveal_weights(subtensor, fake_wallet, mocker): """Successful test_reveal_weights call.""" # Preps @@ -3534,26 +3629,29 @@ def test_get_subnet_info_no_data(mocker, subtensor): def test_get_next_epoch_start_block(mocker, subtensor): - """Check that get_next_epoch_start_block returns the correct value.""" - # Prep + """Check that get_next_epoch_start_block delegates to runtime API.""" netuid = 14 block = 20 - mocked_tempo = mocker.patch.object(subtensor, "tempo", return_value=100) - mocked_blocks_until_next_epoch = mocker.patch.object( - subtensor, - "blocks_until_next_epoch", - ) + mocker.patch.object(subtensor, "query_runtime_api", return_value=150) - # Call result = subtensor.get_next_epoch_start_block(netuid=netuid, block=block) - # Asserts - mocked_tempo.assert_called_once_with( - netuid=netuid, + subtensor.query_runtime_api.assert_called_once_with( + runtime_api="SubnetInfoRuntimeApi", + method="get_next_epoch_start_block", + params=[netuid], block=block, ) - assert result == mocked_blocks_until_next_epoch.return_value.__radd__().__add__() + assert result == 150 + + +def test_get_next_epoch_start_block_none(mocker, subtensor): + """Check that get_next_epoch_start_block returns None when API returns None.""" + mocker.patch.object(subtensor, "query_runtime_api", return_value=None) + + result = subtensor.get_next_epoch_start_block(netuid=1, block=20) + assert result is None def test_get_parents_success(subtensor, mocker): @@ -4419,42 +4517,37 @@ def test_get_mechanism_count(subtensor, mocker): def test_is_in_admin_freeze_window_root_net(subtensor, mocker): """Verify that root net has no admin freeze window.""" - # Preps netuid = 0 - mocked_get_next_epoch_start_block = mocker.patch.object( - subtensor, "get_next_epoch_start_block" - ) + mocked_tempo = mocker.patch.object(subtensor, "tempo") - # Call result = subtensor.is_in_admin_freeze_window(netuid=netuid) - # Asserts - mocked_get_next_epoch_start_block.assert_not_called() + mocked_tempo.assert_not_called() assert result is False @pytest.mark.parametrize( - "block, next_esb, expected_result", - ( - [89, 100, False], - [90, 100, False], - [91, 100, True], - ), + "block_number, pending, last, tempo, window, expected", + [ + (91, 200, 80, 20, 10, True), + (91, 0, 80, 20, 10, True), + (90, 0, 80, 20, 10, False), + (89, 0, 80, 20, 10, False), + ], ) -def test_is_in_admin_freeze_window(subtensor, mocker, block, next_esb, expected_result): - """Verify that `is_in_admin_freeze_window` method processed the data correctly.""" - # Preps +def test_is_in_admin_freeze_window( + subtensor, mocker, block_number, pending, last, tempo, window, expected +): + """Verify is_in_admin_freeze_window matches chain logic with storage getters.""" netuid = 14 - mocker.patch.object(subtensor, "get_current_block", return_value=block) - mocker.patch.object(subtensor, "get_next_epoch_start_block", return_value=next_esb) - mocker.patch.object(subtensor, "get_admin_freeze_window", return_value=10) + mocker.patch.object(subtensor, "tempo", return_value=tempo) + mocker.patch.object(subtensor, "get_pending_epoch_at", return_value=pending) + mocker.patch.object(subtensor, "get_last_epoch_block", return_value=last) + mocker.patch.object(subtensor, "get_admin_freeze_window", return_value=window) - # Call - - result = subtensor.is_in_admin_freeze_window(netuid=netuid) + result = subtensor.is_in_admin_freeze_window(netuid=netuid, block=block_number) - # Asserts - assert result is expected_result + assert result is expected def test_get_admin_freeze_window(subtensor, mocker): @@ -5952,24 +6045,25 @@ def test_remove_proxy(mocker, subtensor): assert response == mocked_remove_proxy_extrinsic.return_value -def test_blocks_until_next_epoch_uses_default_tempo(subtensor, mocker): - """Test blocks_until_next_epoch uses self.tempo when tempo is None.""" - # Prep - netuid = 0 +def test_blocks_until_next_epoch_runtime_api(subtensor, mocker): + """Test blocks_until_next_epoch derives from get_next_epoch_start_block runtime API.""" + netuid = 1 block = 20 - tempo = 100 - spy_get_current_block = mocker.spy(subtensor, "get_current_block") - spy_tempo = mocker.spy(subtensor, "tempo") + mocker.patch.object(subtensor, "get_next_epoch_start_block", return_value=150) - # Call - result = subtensor.blocks_until_next_epoch(netuid=netuid, tempo=tempo, block=block) + result = subtensor.blocks_until_next_epoch(netuid=netuid, block=block) - # Assert - spy_get_current_block.assert_not_called() - spy_tempo.assert_not_called() - assert result is not None - assert isinstance(result, int) + subtensor.get_next_epoch_start_block.assert_called_once_with(netuid, block=block) + assert result == 130 + + +def test_blocks_until_next_epoch_none_when_no_tempo(subtensor, mocker): + """Test blocks_until_next_epoch returns None when runtime API returns None.""" + mocker.patch.object(subtensor, "get_next_epoch_start_block", return_value=None) + + result = subtensor.blocks_until_next_epoch(netuid=1, block=20) + assert result is None def test_get_stake_info_for_coldkeys_none(subtensor, mocker): @@ -6642,3 +6736,233 @@ def test_register_on_root(mock_substrate, subtensor, fake_wallet, mocker): ) assert response == mocked_root_register_extrinsic.return_value + + +def test_get_last_epoch_block(subtensor, mocker): + """Verify that `get_last_epoch_block` queries LastEpochBlock storage.""" + # Preps + netuid = 7 + mocked_query = mocker.patch.object(subtensor, "query_subtensor") + + # Call + result = subtensor.get_last_epoch_block(netuid=netuid) + + # Asserts + mocked_query.assert_called_once_with( + name="LastEpochBlock", block=None, params=[netuid] + ) + assert result == mocked_query.return_value.value + + +def test_get_pending_epoch_at(subtensor, mocker): + """Verify that `get_pending_epoch_at` queries PendingEpochAt storage.""" + # Preps + netuid = 7 + mocked_query = mocker.patch.object(subtensor, "query_subtensor") + + # Call + result = subtensor.get_pending_epoch_at(netuid=netuid) + + # Asserts + mocked_query.assert_called_once_with( + name="PendingEpochAt", block=None, params=[netuid] + ) + assert result == mocked_query.return_value.value + + +def test_get_subnet_epoch_index(subtensor, mocker): + """Verify that `get_subnet_epoch_index` queries SubnetEpochIndex storage.""" + # Preps + netuid = 7 + mocked_query = mocker.patch.object(subtensor, "query_subtensor") + + # Call + result = subtensor.get_subnet_epoch_index(netuid=netuid) + + # Asserts + mocked_query.assert_called_once_with( + name="SubnetEpochIndex", block=None, params=[netuid] + ) + assert result == mocked_query.return_value.value + + +def test_get_activity_cutoff_factor_milli(subtensor, mocker): + """Verify that `get_activity_cutoff_factor_milli` queries ActivityCutoffFactorMilli storage.""" + # Preps + netuid = 7 + mocked_query = mocker.patch.object(subtensor, "query_subtensor") + + # Call + result = subtensor.get_activity_cutoff_factor_milli(netuid=netuid) + + # Asserts + mocked_query.assert_called_once_with( + name="ActivityCutoffFactorMilli", block=None, params=[netuid] + ) + assert result == mocked_query.return_value.value + + +def test_get_owner_hyperparam_rate_limit(subtensor, mocker): + """Verify that `get_owner_hyperparam_rate_limit` queries OwnerHyperparamRateLimit storage.""" + # Preps + mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") + mocked_query = mocker.patch.object(subtensor.substrate, "query") + + # Call + result = subtensor.get_owner_hyperparam_rate_limit() + + # Asserts + mocked_query.assert_called_once_with( + module="SubtensorModule", + storage_function="OwnerHyperparamRateLimit", + block_hash=mocked_determine_block_hash.return_value, + ) + assert result == mocked_query.return_value.value + + +def test_get_epoch_schedule_state(subtensor, mocker): + """Verify that `get_epoch_schedule_state` composes sub-queries into EpochScheduleState.""" + # Preps + netuid = 7 + fake_block = 500 + mocker.patch.object( + type(subtensor), + "block", + new_callable=mocker.PropertyMock, + return_value=fake_block, + ) + mocker.patch.object(subtensor, "get_last_epoch_block", return_value=100) + mocker.patch.object(subtensor, "get_pending_epoch_at", return_value=0) + mocker.patch.object(subtensor, "get_subnet_epoch_index", return_value=42) + mocker.patch.object(subtensor, "tempo", return_value=360) + mocker.patch.object(subtensor, "blocks_since_last_step", return_value=50) + + # Call + result = subtensor.get_epoch_schedule_state(netuid=netuid) + + # Asserts + subtensor.get_last_epoch_block.assert_called_once_with(netuid, block=fake_block) + subtensor.get_pending_epoch_at.assert_called_once_with(netuid, block=fake_block) + subtensor.get_subnet_epoch_index.assert_called_once_with(netuid, block=fake_block) + subtensor.tempo.assert_called_once_with(netuid, block=fake_block) + subtensor.blocks_since_last_step.assert_called_once_with(netuid, block=fake_block) + assert result.last_epoch_block == 100 + assert result.pending_epoch_at == 0 + assert result.subnet_epoch_index == 42 + assert result.tempo == 360 + assert result.blocks_since_last_step == 50 + assert result.current_block == fake_block + + +def test_set_tempo(subtensor, fake_wallet, mocker): + """Verify that `set_tempo` delegates to set_tempo_extrinsic with correct parameters.""" + # Preps + mocked_extrinsic = mocker.patch.object(subtensor_module, "set_tempo_extrinsic") + + # Call + result = subtensor.set_tempo( + wallet=fake_wallet, + netuid=7, + tempo=500, + ) + + # Asserts + mocked_extrinsic.assert_called_once_with( + subtensor=subtensor, + wallet=fake_wallet, + netuid=7, + tempo=500, + 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 result == mocked_extrinsic.return_value + + +def test_set_activity_cutoff_factor(subtensor, fake_wallet, mocker): + """Verify that `set_activity_cutoff_factor` delegates to set_activity_cutoff_factor_extrinsic.""" + # Preps + mocked_extrinsic = mocker.patch.object( + subtensor_module, "set_activity_cutoff_factor_extrinsic" + ) + + # Call + result = subtensor.set_activity_cutoff_factor( + wallet=fake_wallet, + netuid=7, + factor_milli=5000, + ) + + # Asserts + mocked_extrinsic.assert_called_once_with( + subtensor=subtensor, + wallet=fake_wallet, + netuid=7, + factor_milli=5000, + 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 result == mocked_extrinsic.return_value + + +def test_root_set_activity_cutoff_factor(subtensor, fake_wallet, mocker): + """Verify that `root_set_activity_cutoff_factor` delegates to root_set_activity_cutoff_factor_extrinsic.""" + # Preps + mocked_extrinsic = mocker.patch.object( + subtensor_module, "root_set_activity_cutoff_factor_extrinsic" + ) + + # Call + result = subtensor.root_set_activity_cutoff_factor( + wallet=fake_wallet, + netuid=7, + factor_milli=5000, + ) + + # Asserts + mocked_extrinsic.assert_called_once_with( + subtensor=subtensor, + wallet=fake_wallet, + netuid=7, + factor_milli=5000, + 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 result == mocked_extrinsic.return_value + + +def test_trigger_epoch(subtensor, fake_wallet, mocker): + """Verify that `trigger_epoch` delegates to trigger_epoch_extrinsic.""" + # Preps + mocked_extrinsic = mocker.patch.object(subtensor_module, "trigger_epoch_extrinsic") + + # Call + result = subtensor.trigger_epoch( + wallet=fake_wallet, + netuid=7, + ) + + # Asserts + mocked_extrinsic.assert_called_once_with( + subtensor=subtensor, + wallet=fake_wallet, + netuid=7, + 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 result == mocked_extrinsic.return_value diff --git a/tests/unit_tests/utils/test_epoch_schedule.py b/tests/unit_tests/utils/test_epoch_schedule.py new file mode 100644 index 0000000000..d8d1528d1e --- /dev/null +++ b/tests/unit_tests/utils/test_epoch_schedule.py @@ -0,0 +1,101 @@ +import pytest + +from bittensor.utils.epoch_schedule import ( + blocks_until_next_auto_epoch, + is_in_admin_freeze_window, +) + + +def test_blocks_until_next_auto_epoch_before_next_epoch(): + """Verify blocks remaining before the next auto epoch.""" + assert blocks_until_next_auto_epoch(100, 50, 120) == 30 + + +def test_blocks_until_next_auto_epoch_at_next_epoch(): + """Verify zero remaining at the next auto epoch boundary.""" + assert blocks_until_next_auto_epoch(100, 50, 150) == 0 + + +def test_blocks_until_next_auto_epoch_after_next_epoch(): + """Verify zero remaining after the next auto epoch boundary.""" + assert blocks_until_next_auto_epoch(100, 50, 200) == 0 + + +def test_is_in_admin_freeze_window_tempo_zero(): + """Verify tempo zero disables the admin freeze window.""" + assert ( + is_in_admin_freeze_window( + tempo=0, + pending_epoch_at=0, + last_epoch_block=100, + block_number=100, + admin_freeze_window=10, + ) + is False + ) + + +def test_is_in_admin_freeze_window_pending_epoch_in_future(): + """Verify pending triggered epoch in the future blocks owner operations.""" + assert ( + is_in_admin_freeze_window( + tempo=20, + pending_epoch_at=200, + last_epoch_block=80, + block_number=91, + admin_freeze_window=10, + ) + is True + ) + + +def test_is_in_admin_freeze_window_auto_epoch_inside_window(): + """Verify auto epoch inside the freeze window blocks owner operations.""" + assert ( + is_in_admin_freeze_window( + tempo=20, + pending_epoch_at=0, + last_epoch_block=80, + block_number=91, + admin_freeze_window=10, + ) + is True + ) + + +def test_is_in_admin_freeze_window_auto_epoch_at_boundary(): + """Verify remaining == window is not frozen (strict less-than).""" + assert ( + is_in_admin_freeze_window( + tempo=20, + pending_epoch_at=0, + last_epoch_block=80, + block_number=90, + admin_freeze_window=10, + ) + is False + ) + + +@pytest.mark.parametrize( + "block_number, expected", + [ + (89, False), + (90, False), + (91, True), + (99, True), + (100, True), + ], +) +def test_is_in_admin_freeze_window_edge_cases(block_number, expected): + """Sweep around the freeze boundary for last=80, tempo=20, window=10.""" + assert ( + is_in_admin_freeze_window( + tempo=20, + pending_epoch_at=0, + last_epoch_block=80, + block_number=block_number, + admin_freeze_window=10, + ) + is expected + )