From 061de5e3e0b89c3b9e60a94b2c85d1820e47e644 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Tue, 26 May 2026 20:14:51 -0700 Subject: [PATCH 01/59] update `get_subnet_prices` --- bittensor/core/async_subtensor.py | 233 ++++++------------------------ bittensor/core/subtensor.py | 214 ++++----------------------- 2 files changed, 75 insertions(+), 372 deletions(-) diff --git a/bittensor/core/async_subtensor.py b/bittensor/core/async_subtensor.py index 00eb1a8e9c..a1265d7c07 100644 --- a/bittensor/core/async_subtensor.py +++ b/bittensor/core/async_subtensor.py @@ -13,7 +13,7 @@ from bittensor_wallet.utils import SS58_FORMAT from scalecodec import GenericCall, ScaleValue from scalecodec.base import ScaleType -from scalecodec.utils.math import FixedPoint, fixed_to_decimal +from scalecodec.utils.math import FixedPoint from bittensor.core.chain_data import ( ColdkeySwapAnnouncementInfo, @@ -151,7 +151,6 @@ SubtensorMixin, UIDs, Weights, - PositionResponse, NeuronCertificateResponse, CommitmentOfResponse, CrowdloansResponse, @@ -159,7 +158,9 @@ ) from bittensor.utils import ( Certificate, + ChainFeatureDisabledWarning, decode_hex_identity_dict, + deprecated_message, format_error_message, get_caller_name, get_mechid_storage_index, @@ -174,13 +175,6 @@ fixed_to_float, ) from bittensor.utils.btlogging import logging -from bittensor.utils.liquidity import ( - LiquidityPosition, - calculate_fees, - get_fees, - price_to_tick, - tick_to_price, -) if TYPE_CHECKING: from async_substrate_interface import AsyncQueryMapResult @@ -2897,9 +2891,8 @@ async def get_liquidity_list( block: Optional[int] = None, block_hash: Optional[str] = None, reuse_block: bool = False, - ) -> Optional[list[LiquidityPosition]]: + ) -> list: """Retrieves all liquidity positions for the given wallet on a specified subnet (netuid). - Calculates associated fee rewards based on current global and tick-level fee data. Parameters: wallet: Wallet instance to fetch positions for. @@ -2909,173 +2902,17 @@ async def get_liquidity_list( reuse_block: Whether to reuse the last-used block hash. Returns: - List of liquidity positions, or None if subnet does not exist. - - Notes: - - + Always returns an empty list. User liquidity positions (Uniswap v3) have been permanently removed + from the chain and replaced by the Balancer swap mechanism. """ - if not await self.subnet_exists(netuid=netuid): - logging.debug(f"Subnet {netuid} does not exist.") - return None - - if not await self.is_subnet_active(netuid=netuid): - logging.debug(f"Subnet {netuid} is not active.") - return None - - positions_response = await self.query_map( - module="Swap", - name="Positions", - params=[netuid, wallet.coldkeypub.ss58_address], - block=block, - block_hash=block_hash, - reuse_block=reuse_block, - ) - if len(positions_response.records) == 0: - return [] - - block_hash = await self.determine_block_hash( - block=block, block_hash=block_hash, reuse_block=reuse_block - ) - - # Fetch global fees and current price - fee_global_tao_query_sk = await self.substrate.create_storage_key( - pallet="Swap", - storage_function="FeeGlobalTao", - params=[netuid], - block_hash=block_hash, - ) - fee_global_alpha_query_sk = await self.substrate.create_storage_key( - pallet="Swap", - storage_function="FeeGlobalAlpha", - params=[netuid], - block_hash=block_hash, - ) - sqrt_price_query_sk = await self.substrate.create_storage_key( - pallet="Swap", - storage_function="AlphaSqrtPrice", - params=[netuid], - block_hash=block_hash, - ) - - ( - fee_global_tao_query, - fee_global_alpha_query, - sqrt_price_query, - ) = await self.substrate.query_multi( - storage_keys=[ - fee_global_tao_query_sk, - fee_global_alpha_query_sk, - sqrt_price_query_sk, - ], - block_hash=block_hash, + deprecated_message( + message="User liquidity positions have been permanently removed from the chain. " + "The Uniswap v3 swap mechanism has been replaced by the Balancer swap. " + "This method will always return an empty list.", + category=ChainFeatureDisabledWarning, + stacklevel=2, ) - - # convert to floats - fee_global_tao = fixed_to_float(fee_global_tao_query[1]) - fee_global_alpha = fixed_to_float(fee_global_alpha_query[1]) - sqrt_price = fixed_to_float(sqrt_price_query[1]) - - # Fetch global fees and current price - current_tick = price_to_tick(sqrt_price**2) - - # Fetch positions - positions_values: list[tuple[PositionResponse, int, int]] = [] - positions_storage_keys: list[StorageKey] = [] - position: PositionResponse - async for _, position in positions_response: - tick_low_idx = position.get("tick_low") - tick_high_idx = position.get("tick_high") - positions_values.append((position, tick_low_idx, tick_high_idx)) - tick_low_sk = await self.substrate.create_storage_key( - pallet="Swap", - storage_function="Ticks", - params=[netuid, tick_low_idx], - block_hash=block_hash, - ) - tick_high_sk = await self.substrate.create_storage_key( - pallet="Swap", - storage_function="Ticks", - params=[netuid, tick_high_idx], - block_hash=block_hash, - ) - positions_storage_keys.extend([tick_low_sk, tick_high_sk]) - - # query all our ticks at once - ticks_query = await self.substrate.query_multi( - positions_storage_keys, block_hash=block_hash - ) - # iterator with just the values - ticks = iter([x[1] for x in ticks_query]) - positions: list[LiquidityPosition] = [] - for position, tick_low_idx, tick_high_idx in positions_values: - tick_low = next(ticks) - tick_high = next(ticks) - # Calculate fees above/below range for both tokens - tao_below = get_fees( - current_tick=current_tick, - tick=tick_low, - tick_index=tick_low_idx, - quote=True, - global_fees_tao=fee_global_tao, - global_fees_alpha=fee_global_alpha, - above=False, - ) - tao_above = get_fees( - current_tick=current_tick, - tick=tick_high, - tick_index=tick_high_idx, - quote=True, - global_fees_tao=fee_global_tao, - global_fees_alpha=fee_global_alpha, - above=True, - ) - alpha_below = get_fees( - current_tick=current_tick, - tick=tick_low, - tick_index=tick_low_idx, - quote=False, - global_fees_tao=fee_global_tao, - global_fees_alpha=fee_global_alpha, - above=False, - ) - alpha_above = get_fees( - current_tick=current_tick, - tick=tick_high, - tick_index=tick_high_idx, - quote=False, - global_fees_tao=fee_global_tao, - global_fees_alpha=fee_global_alpha, - above=True, - ) - - # Calculate fees earned by position - fees_tao, fees_alpha = calculate_fees( - position=position, - global_fees_tao=fee_global_tao, - global_fees_alpha=fee_global_alpha, - tao_fees_below_low=tao_below, - tao_fees_above_high=tao_above, - alpha_fees_below_low=alpha_below, - alpha_fees_above_high=alpha_above, - netuid=netuid, - ) - - positions.append( - LiquidityPosition( - id=position.get("id"), - price_low=Balance.from_tao(tick_to_price(position.get("tick_low"))), - price_high=Balance.from_tao( - tick_to_price(position.get("tick_high")) - ), - liquidity=Balance.from_rao(position.get("liquidity")), - fees_tao=fees_tao, - fees_alpha=fees_alpha, - netuid=position.get("netuid"), - ) - ) - - return positions + return [] async def get_mechanism_emission_split( self, @@ -4656,23 +4493,39 @@ async def get_subnet_prices( block_hash = await self.determine_block_hash( block=block, block_hash=block_hash, reuse_block=reuse_block ) + if block_hash is None: + block_hash = await self.substrate.get_chain_head() - current_sqrt_prices = await self.substrate.query_map( - module="Swap", - storage_function="AlphaSqrtPrice", + if await self._runtime_method_exists( + api="SwapRuntimeApi", + method="current_alpha_price_all", block_hash=block_hash, - page_size=129, # total number of subnets - ) - - prices = {} - async for id_, current_sqrt_price_bits in current_sqrt_prices: - current_sqrt_price = fixed_to_decimal(current_sqrt_price_bits) - current_price = current_sqrt_price * current_sqrt_price - current_price_in_tao = Balance.from_tao(float(current_price)) - prices.update({id_: current_price_in_tao}) + ): + prices_rao = cast( + dict, + await self.substrate.runtime_call( + api="SwapRuntimeApi", + method="current_alpha_price_all", + block_hash=block_hash, + ), + ) + return { + int(p["netuid"]): Balance.from_rao(int(p["price"])) for p in prices_rao + } - # SN0 price is always 1 TAO - prices.update({0: Balance.from_tao(1)}) + netuids = await self.get_all_subnets_netuid( + block=block, block_hash=block_hash, reuse_block=reuse_block + ) + prices_list = await asyncio.gather( + *[ + self.get_subnet_price( + netuid, block=block, block_hash=block_hash, reuse_block=reuse_block + ) + for netuid in netuids + ] + ) + prices = dict(zip(netuids, prices_list)) + prices[0] = Balance.from_tao(1) return prices async def get_subnet_reveal_period_epochs( diff --git a/bittensor/core/subtensor.py b/bittensor/core/subtensor.py index ce51579431..e4c962579c 100644 --- a/bittensor/core/subtensor.py +++ b/bittensor/core/subtensor.py @@ -12,7 +12,7 @@ from bittensor_wallet.utils import SS58_FORMAT from scalecodec import ScaleValue from scalecodec.base import ScaleType -from scalecodec.utils.math import FixedPoint, fixed_to_decimal +from scalecodec.utils.math import FixedPoint from bittensor.core.axon import Axon from bittensor.core.chain_data import ( @@ -147,7 +147,6 @@ SubtensorMixin, UIDs, Weights, - PositionResponse, NeuronCertificateResponse, CommitmentOfResponse, CrowdloansResponse, @@ -155,7 +154,9 @@ ) from bittensor.utils import ( Certificate, + ChainFeatureDisabledWarning, decode_hex_identity_dict, + deprecated_message, format_error_message, get_caller_name, get_mechid_storage_index, @@ -170,13 +171,6 @@ fixed_to_float, ) from bittensor.utils.btlogging import logging -from bittensor.utils.liquidity import ( - LiquidityPosition, - calculate_fees, - get_fees, - price_to_tick, - tick_to_price, -) if TYPE_CHECKING: from async_substrate_interface.sync_substrate import QueryMapResult @@ -2359,10 +2353,9 @@ def get_liquidity_list( wallet: "Wallet", netuid: int, block: Optional[int] = None, - ) -> Optional[list[LiquidityPosition]]: + ) -> list: """ Retrieves all liquidity positions for the given wallet on a specified subnet (netuid). - Calculates associated fee rewards based on current global and tick-level fee data. Parameters: wallet: Wallet instance to fetch positions for. @@ -2370,164 +2363,17 @@ def get_liquidity_list( block: The blockchain block number for the query. Returns: - List of liquidity positions, or None if subnet does not exist. + Always returns an empty list. User liquidity positions (Uniswap v3) have been permanently removed + from the chain and replaced by the Balancer swap mechanism. """ - if not self.subnet_exists(netuid=netuid): - logging.debug(f"Subnet {netuid} does not exist.") - return None - - if not self.is_subnet_active(netuid=netuid): - logging.debug(f"Subnet {netuid} is not active.") - return None - - # Fetch positions - positions_response = self.query_map( - module="Swap", - name="Positions", - block=block, - params=[netuid, wallet.coldkeypub.ss58_address], - ) - if len(positions_response.records) == 0: - return [] - - block_hash = self.determine_block_hash(block) - - # Fetch global fees and current price - fee_global_tao_query_sk = self.substrate.create_storage_key( - pallet="Swap", - storage_function="FeeGlobalTao", - params=[netuid], - block_hash=block_hash, + deprecated_message( + message="User liquidity positions have been permanently removed from the chain. " + "The Uniswap v3 swap mechanism has been replaced by the Balancer swap. " + "This method will always return an empty list.", + category=ChainFeatureDisabledWarning, + stacklevel=2, ) - fee_global_alpha_query_sk = self.substrate.create_storage_key( - pallet="Swap", - storage_function="FeeGlobalAlpha", - params=[netuid], - block_hash=block_hash, - ) - sqrt_price_query_sk = self.substrate.create_storage_key( - pallet="Swap", - storage_function="AlphaSqrtPrice", - params=[netuid], - block_hash=block_hash, - ) - fee_global_tao_query, fee_global_alpha_query, sqrt_price_query = ( - self.substrate.query_multi( - storage_keys=[ - fee_global_tao_query_sk, - fee_global_alpha_query_sk, - sqrt_price_query_sk, - ], - block_hash=block_hash, - ) - ) - - fee_global_tao_raw: FixedPoint = fee_global_tao_query[1] # type: ignore[assignment] - fee_global_alpha_raw: FixedPoint = fee_global_alpha_query[1] # type: ignore[assignment] - sqrt_price_raw: FixedPoint = sqrt_price_query[1] # type: ignore[assignment] - fee_global_tao = fixed_to_float(fee_global_tao_raw) - fee_global_alpha = fixed_to_float(fee_global_alpha_raw) - sqrt_price = fixed_to_float(sqrt_price_raw) - current_tick = price_to_tick(sqrt_price**2) - - positions_values: list[tuple[PositionResponse, int, int]] = [] - positions_storage_keys: list[StorageKey] = [] - position: PositionResponse - for _, position in positions_response: - tick_low_idx = position["tick_low"] - tick_high_idx = position["tick_high"] - - tick_low_sk = self.substrate.create_storage_key( - pallet="Swap", - storage_function="Ticks", - params=[netuid, tick_low_idx], - block_hash=block_hash, - ) - tick_high_sk = self.substrate.create_storage_key( - pallet="Swap", - storage_function="Ticks", - params=[netuid, tick_high_idx], - block_hash=block_hash, - ) - positions_values.append((position, tick_low_idx, tick_high_idx)) - positions_storage_keys.extend([tick_low_sk, tick_high_sk]) - # query all our ticks at once - ticks_query = self.substrate.query_multi( - positions_storage_keys, block_hash=block_hash - ) - # iterator with just the values - tick_values: list[dict] = [x[1] for x in ticks_query] # type: ignore - ticks = iter(tick_values) - positions: list[LiquidityPosition] = [] - for position, tick_low_idx, tick_high_idx in positions_values: - tick_low = next(ticks) - tick_high = next(ticks) - - # Calculate fees above/below range for both tokens - tao_below = get_fees( - current_tick=current_tick, - tick=tick_low, - tick_index=tick_low_idx, - quote=True, - global_fees_tao=fee_global_tao, - global_fees_alpha=fee_global_alpha, - above=False, - ) - tao_above = get_fees( - current_tick=current_tick, - tick=tick_high, - tick_index=tick_high_idx, - quote=True, - global_fees_tao=fee_global_tao, - global_fees_alpha=fee_global_alpha, - above=True, - ) - alpha_below = get_fees( - current_tick=current_tick, - tick=tick_low, - tick_index=tick_low_idx, - quote=False, - global_fees_tao=fee_global_tao, - global_fees_alpha=fee_global_alpha, - above=False, - ) - alpha_above = get_fees( - current_tick=current_tick, - tick=tick_high, - tick_index=tick_high_idx, - quote=False, - global_fees_tao=fee_global_tao, - global_fees_alpha=fee_global_alpha, - above=True, - ) - - # Calculate fees earned by position - fees_tao, fees_alpha = calculate_fees( - position=position, - global_fees_tao=fee_global_tao, - global_fees_alpha=fee_global_alpha, - tao_fees_below_low=tao_below, - tao_fees_above_high=tao_above, - alpha_fees_below_low=alpha_below, - alpha_fees_above_high=alpha_above, - netuid=netuid, - ) - - positions.append( - LiquidityPosition( - id=position.get("id"), - price_low=Balance.from_tao(tick_to_price(position.get("tick_low"))), - price_high=Balance.from_tao( - tick_to_price(position.get("tick_high")) - ), - liquidity=Balance.from_rao(position.get("liquidity")), - fees_tao=fees_tao, - fees_alpha=fees_alpha, - netuid=position.get("netuid"), - ) - ) - - return positions + return [] def get_mechanism_emission_split( self, netuid: int, block: Optional[int] = None @@ -3821,7 +3667,7 @@ def get_subnet_prices( """Gets the current Alpha price in TAO for all subnets. Parameters: - block: The blockchain block number for the query. If `None`, queries the current chain head. + block: The blockchain block number for the query. If ``None``, queries the current chain head. Returns: A dictionary mapping subnet unique ID (netuid) to the current Alpha price in TAO units. @@ -3830,23 +3676,27 @@ def get_subnet_prices( Subnet 0 (root network) always has a price of 1 TAO since it uses TAO directly rather than Alpha. """ block_hash = self.determine_block_hash(block=block) + if block_hash is None: + block_hash = self.substrate.get_chain_head() - current_sqrt_prices = self.substrate.query_map( - module="Swap", - storage_function="AlphaSqrtPrice", + if self._runtime_method_exists( + api="SwapRuntimeApi", + method="current_alpha_price_all", block_hash=block_hash, - page_size=129, # total number of subnets - ) - - prices = {} - for id_, current_sqrt_price_bits in current_sqrt_prices: - current_sqrt_price = fixed_to_decimal(current_sqrt_price_bits) - current_price = current_sqrt_price * current_sqrt_price - current_price_in_tao = Balance.from_tao(float(current_price)) - prices.update({id_: current_price_in_tao}) + ): + prices_rao = cast( + dict, + self.substrate.runtime_call( + api="SwapRuntimeApi", + method="current_alpha_price_all", + block_hash=block_hash, + ), + ) + return {p["netuid"]: Balance.from_rao(p["price"]) for p in prices_rao} - # SN0 price is always 1 TAO - prices.update({0: Balance.from_tao(1)}) + prices = {0: Balance.from_tao(1)} + for netuid in self.get_all_subnets_netuid(block=block): + prices[netuid] = self.get_subnet_price(netuid, block=block) return prices def get_subnet_reveal_period_epochs( From 262d9323b595066e16140eff34e7b61fd2af0212 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Tue, 26 May 2026 20:17:30 -0700 Subject: [PATCH 02/59] liquidity deprecation + remove tests --- .../core/extrinsics/asyncex/liquidity.py | 322 +--------- bittensor/core/extrinsics/liquidity.py | 322 +--------- bittensor/utils/liquidity.py | 155 ++--- tests/e2e_tests/test_liquidity.py | 569 ------------------ .../extrinsics/asyncex/test_liquidity.py | 173 ------ tests/unit_tests/extrinsics/test_liquidity.py | 166 ----- .../unit_tests/utils/test_liquidity_utils.py | 124 ---- 7 files changed, 83 insertions(+), 1748 deletions(-) diff --git a/bittensor/core/extrinsics/asyncex/liquidity.py b/bittensor/core/extrinsics/asyncex/liquidity.py index 5f6a3fc78a..54cb6b4a45 100644 --- a/bittensor/core/extrinsics/asyncex/liquidity.py +++ b/bittensor/core/extrinsics/asyncex/liquidity.py @@ -1,350 +1,74 @@ -from typing import Optional, TYPE_CHECKING +# TODO: remove this module in the next major release (include all references) +from typing import TYPE_CHECKING -from bittensor.core.extrinsics.asyncex.mev_shield import submit_encrypted_extrinsic -from bittensor.core.extrinsics.pallets import Swap -from bittensor.core.settings import DEFAULT_MEV_PROTECTION from bittensor.core.types import ExtrinsicResponse from bittensor.utils import ChainFeatureDisabledWarning, deprecated_message -from bittensor.utils.balance import Balance -from bittensor.utils.liquidity import price_to_tick if TYPE_CHECKING: from bittensor_wallet import Wallet from bittensor.core.async_subtensor import AsyncSubtensor +_DEPRECATED_MSG = ( + "User liquidity (Uniswap v3) has been permanently removed from the chain. The swap mechanism has been replaced by " + "the Balancer swap. This extrinsic is deprecated and will return an error." +) + async def add_liquidity_extrinsic( subtensor: "AsyncSubtensor", wallet: "Wallet", netuid: int, - liquidity: Balance, - price_low: Balance, - price_high: Balance, - hotkey_ss58: Optional[str] = None, - *, - 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, + **kwargs, ) -> ExtrinsicResponse: - """ - Adds liquidity to the specified price range. - - Parameters: - subtensor: The Subtensor client instance used for blockchain interaction. - wallet: The wallet used to sign the extrinsic (must be unlocked). - netuid: The UID of the target subnet for which the call is being initiated. - liquidity: The amount of liquidity to be added. - price_low: The lower bound of the price tick range. - price_high: The upper bound of the price tick range. - hotkey_ss58: The hotkey with staked TAO in Alpha. If not passed then the wallet hotkey is used. - mev_protection: If True, encrypts and submits the transaction through the MEV Shield pallet to protect - against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators - decrypt and execute it. If False, submits the transaction directly without encryption. - period: The number of blocks during which the transaction will remain valid after it's submitted. If the - transaction is not included in a block within that number of blocks, it will expire and be rejected. You can - think of it as an expiration date for the transaction. - raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. - wait_for_inclusion: Whether to wait for the inclusion of the transaction. - wait_for_finalization: Whether to wait for the finalization of the transaction. - wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. - - Returns: - ExtrinsicResponse: The result object of the extrinsic execution. - - Note: Adding is allowed even when user liquidity is enabled in specified subnet. Call - `toggle_user_liquidity_extrinsic` to enable/disable user liquidity. - """ + """Deprecated. User liquidity has been permanently removed from the chain.""" deprecated_message( - message="User liquidity is currently disabled on the chain. " - "Calling this method will result in a 'UserLiquidityDisabled' error.", + message=_DEPRECATED_MSG, category=ChainFeatureDisabledWarning, stacklevel=3, ) - try: - unlock_type = "coldkey" if hotkey_ss58 else "both" - if not ( - unlocked := ExtrinsicResponse.unlock_wallet( - wallet, raise_error, unlock_type=unlock_type - ) - ).success: - return unlocked - - call = await Swap(subtensor).add_liquidity( - netuid=netuid, - liquidity=liquidity.rao, - tick_low=price_to_tick(price_low.tao), - tick_high=price_to_tick(price_high.tao), - hotkey=hotkey_ss58 or wallet.hotkey.ss58_address, - ) - - 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, - wait_for_inclusion=wait_for_inclusion, - wait_for_finalization=wait_for_finalization, - period=period, - raise_error=raise_error, - ) - except Exception as error: - return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + return ExtrinsicResponse(success=False, message=_DEPRECATED_MSG) async def modify_liquidity_extrinsic( subtensor: "AsyncSubtensor", wallet: "Wallet", netuid: int, - position_id: int, - liquidity_delta: Balance, - hotkey_ss58: Optional[str] = None, - *, - 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, + **kwargs, ) -> ExtrinsicResponse: - """Modifies liquidity in liquidity position by adding or removing liquidity from it. - - Parameters: - subtensor: The Subtensor client instance used for blockchain interaction. - wallet: The wallet used to sign the extrinsic (must be unlocked). - netuid: The UID of the target subnet for which the call is being initiated. - position_id: The id of the position record in the pool. - liquidity_delta: The amount of liquidity to be added or removed (add if positive or remove if negative). - hotkey_ss58: The hotkey with staked TAO in Alpha. If not passed then the wallet hotkey is used. - mev_protection: If True, encrypts and submits the transaction through the MEV Shield pallet to protect - against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators - decrypt and execute it. If False, submits the transaction directly without encryption. - period: The number of blocks during which the transaction will remain valid after it's submitted. If the - transaction is not included in a block within that number of blocks, it will expire and be rejected. You can - think of it as an expiration date for the transaction. - raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. - wait_for_inclusion: Whether to wait for the inclusion of the transaction. - wait_for_finalization: Whether to wait for the finalization of the transaction. - wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. - - Returns: - ExtrinsicResponse: The result object of the extrinsic execution. - - Note: Modifying is allowed even when user liquidity is enabled in specified subnet. - Call `toggle_user_liquidity_extrinsic` to enable/disable user liquidity. - """ + """Deprecated. User liquidity has been permanently removed from the chain.""" deprecated_message( - message="User liquidity is currently disabled on the chain. " - "Calling this method will result in a 'UserLiquidityDisabled' error.", + message=_DEPRECATED_MSG, category=ChainFeatureDisabledWarning, stacklevel=3, ) - try: - unlock_type = "coldkey" if hotkey_ss58 else "both" - if not ( - unlocked := ExtrinsicResponse.unlock_wallet( - wallet, raise_error, unlock_type - ) - ).success: - return unlocked - - call = await Swap(subtensor).modify_position( - netuid=netuid, - hotkey=hotkey_ss58 or wallet.hotkey.ss58_address, - position_id=position_id, - liquidity_delta=liquidity_delta.rao, - ) - - 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, - wait_for_inclusion=wait_for_inclusion, - wait_for_finalization=wait_for_finalization, - period=period, - raise_error=raise_error, - ) - except Exception as error: - return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + return ExtrinsicResponse(success=False, message=_DEPRECATED_MSG) async def remove_liquidity_extrinsic( subtensor: "AsyncSubtensor", wallet: "Wallet", netuid: int, - position_id: int, - hotkey_ss58: Optional[str] = None, - *, - 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, + **kwargs, ) -> ExtrinsicResponse: - """Remove liquidity and credit balances back to wallet's hotkey stake. - - Parameters: - subtensor: The Subtensor client instance used for blockchain interaction. - wallet: The wallet used to sign the extrinsic (must be unlocked). - netuid: The UID of the target subnet for which the call is being initiated. - position_id: The id of the position record in the pool. - hotkey_ss58: The hotkey with staked TAO in Alpha. If not passed then the wallet hotkey is used. - mev_protection: If True, encrypts and submits the transaction through the MEV Shield pallet to protect - against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators - decrypt and execute it. If False, submits the transaction directly without encryption. - period: The number of blocks during which the transaction will remain valid after it's submitted. If the - transaction is not included in a block within that number of blocks, it will expire and be rejected. You can - think of it as an expiration date for the transaction. - raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. - wait_for_inclusion: Whether to wait for the inclusion of the transaction. - wait_for_finalization: Whether to wait for the finalization of the transaction. - wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. - - Returns: - ExtrinsicResponse: The result object of the extrinsic execution. - - Note: Adding is allowed even when user liquidity is enabled in specified subnet. Call - `toggle_user_liquidity_extrinsic` to enable/disable user liquidity. - """ + """Deprecated. User liquidity has been permanently removed from the chain.""" deprecated_message( - message="User liquidity is currently disabled on the chain. " - "Calling this method will result in a 'UserLiquidityDisabled' error.", + message=_DEPRECATED_MSG, category=ChainFeatureDisabledWarning, stacklevel=3, ) - try: - unlock_type = "coldkey" if hotkey_ss58 else "both" - if not ( - unlocked := ExtrinsicResponse.unlock_wallet( - wallet, raise_error, unlock_type - ) - ).success: - return unlocked - - call = await Swap(subtensor).remove_liquidity( - netuid=netuid, - hotkey=hotkey_ss58 or wallet.hotkey.ss58_address, - position_id=position_id, - ) - - 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, - wait_for_inclusion=wait_for_inclusion, - wait_for_finalization=wait_for_finalization, - period=period, - raise_error=raise_error, - ) - except Exception as error: - return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + return ExtrinsicResponse(success=False, message=_DEPRECATED_MSG) async def toggle_user_liquidity_extrinsic( subtensor: "AsyncSubtensor", wallet: "Wallet", netuid: int, - enable: bool, - *, - 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, + **kwargs, ) -> ExtrinsicResponse: - """Allow to toggle user liquidity for specified subnet. - - Parameters: - subtensor: The Subtensor client instance used for blockchain interaction. - wallet: The wallet used to sign the extrinsic (must be unlocked). - netuid: The UID of the target subnet for which the call is being initiated. - enable: Boolean indicating whether to enable user liquidity. - mev_protection: If True, encrypts and submits the transaction through the MEV Shield pallet to protect - against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators - decrypt and execute it. If False, submits the transaction directly without encryption. - period: The number of blocks during which the transaction will remain valid after it's submitted. If the - transaction is not included in a block within that number of blocks, it will expire and be rejected. You can - think of it as an expiration date for the transaction. - raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. - wait_for_inclusion: Whether to wait for the inclusion of the transaction. - wait_for_finalization: Whether to wait for the finalization of the transaction. - wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. - - Returns: - ExtrinsicResponse: The result object of the extrinsic execution. - """ + """Deprecated. User liquidity has been permanently removed from the chain.""" deprecated_message( - message="User liquidity is currently disabled on the chain. " - "Calling this method will result in a 'UserLiquidityDisabled' error.", + message=_DEPRECATED_MSG, category=ChainFeatureDisabledWarning, stacklevel=3, ) - try: - if not ( - unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) - ).success: - return unlocked - - call = await Swap(subtensor).toggle_user_liquidity( - netuid=netuid, - enable=enable, - ) - - 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, - wait_for_inclusion=wait_for_inclusion, - wait_for_finalization=wait_for_finalization, - period=period, - raise_error=raise_error, - ) - except Exception as error: - return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + return ExtrinsicResponse(success=False, message=_DEPRECATED_MSG) diff --git a/bittensor/core/extrinsics/liquidity.py b/bittensor/core/extrinsics/liquidity.py index bff99f1f18..7c6dcb4734 100644 --- a/bittensor/core/extrinsics/liquidity.py +++ b/bittensor/core/extrinsics/liquidity.py @@ -1,350 +1,74 @@ -from typing import Optional, TYPE_CHECKING +# TODO: remove this module in the next major release (include all references) +from typing import TYPE_CHECKING -from bittensor.core.extrinsics.mev_shield import submit_encrypted_extrinsic -from bittensor.core.extrinsics.pallets import Swap -from bittensor.core.settings import DEFAULT_MEV_PROTECTION from bittensor.core.types import ExtrinsicResponse from bittensor.utils import ChainFeatureDisabledWarning, deprecated_message -from bittensor.utils.balance import Balance -from bittensor.utils.liquidity import price_to_tick if TYPE_CHECKING: from bittensor_wallet import Wallet from bittensor.core.subtensor import Subtensor +_DEPRECATED_MSG = ( + "User liquidity (Uniswap v3) has been permanently removed from the chain. The swap mechanism has been replaced by " + "the Balancer swap. This extrinsic is deprecated and will return an error." +) + def add_liquidity_extrinsic( subtensor: "Subtensor", wallet: "Wallet", netuid: int, - liquidity: Balance, - price_low: Balance, - price_high: Balance, - hotkey_ss58: Optional[str] = None, - *, - 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, + **kwargs, ) -> ExtrinsicResponse: - """ - Adds liquidity to the specified price range. - - Parameters: - subtensor: The Subtensor client instance used for blockchain interaction. - wallet: The wallet used to sign the extrinsic (must be unlocked). - netuid: The UID of the target subnet for which the call is being initiated. - liquidity: The amount of liquidity to be added. - price_low: The lower bound of the price tick range. - price_high: The upper bound of the price tick range. - hotkey_ss58: The hotkey with staked TAO in Alpha. If not passed then the wallet hotkey is used. - mev_protection: If True, encrypts and submits the transaction through the MEV Shield pallet to protect - against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators - decrypt and execute it. If False, submits the transaction directly without encryption. - period: The number of blocks during which the transaction will remain valid after it's submitted. If the - transaction is not included in a block within that number of blocks, it will expire and be rejected. You can - think of it as an expiration date for the transaction. - raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. - wait_for_inclusion: Whether to wait for the inclusion of the transaction. - wait_for_finalization: Whether to wait for the finalization of the transaction. - wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. - - Returns: - ExtrinsicResponse: The result object of the extrinsic execution. - - Note: Adding is allowed even when user liquidity is enabled in specified subnet. Call - `toggle_user_liquidity_extrinsic` to enable/disable user liquidity. - """ + """Deprecated. User liquidity has been permanently removed from the chain.""" deprecated_message( - message="User liquidity is currently disabled on the chain. " - "Calling this method will result in a 'UserLiquidityDisabled' error.", + message=_DEPRECATED_MSG, category=ChainFeatureDisabledWarning, stacklevel=3, ) - try: - unlock_type = "coldkey" if hotkey_ss58 else "both" - if not ( - unlocked := ExtrinsicResponse.unlock_wallet( - wallet, raise_error, unlock_type - ) - ).success: - return unlocked - - call = Swap(subtensor).add_liquidity( - netuid=netuid, - liquidity=liquidity.rao, - tick_low=price_to_tick(price_low.tao), - tick_high=price_to_tick(price_high.tao), - hotkey=hotkey_ss58 or wallet.hotkey.ss58_address, - ) - - 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, - wait_for_inclusion=wait_for_inclusion, - wait_for_finalization=wait_for_finalization, - period=period, - raise_error=raise_error, - ) - except Exception as error: - return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + return ExtrinsicResponse(success=False, message=_DEPRECATED_MSG) def modify_liquidity_extrinsic( subtensor: "Subtensor", wallet: "Wallet", netuid: int, - position_id: int, - liquidity_delta: Balance, - hotkey_ss58: Optional[str] = None, - *, - 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, + **kwargs, ) -> ExtrinsicResponse: - """Modifies liquidity in liquidity position by adding or removing liquidity from it. - - Parameters: - subtensor: The Subtensor client instance used for blockchain interaction. - wallet: The wallet used to sign the extrinsic (must be unlocked). - netuid: The UID of the target subnet for which the call is being initiated. - position_id: The id of the position record in the pool. - liquidity_delta: The amount of liquidity to be added or removed (add if positive or remove if negative). - hotkey_ss58: The hotkey with staked TAO in Alpha. If not passed then the wallet hotkey is used. - mev_protection: If True, encrypts and submits the transaction through the MEV Shield pallet to protect - against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators - decrypt and execute it. If False, submits the transaction directly without encryption. - period: The number of blocks during which the transaction will remain valid after it's submitted. If the - transaction is not included in a block within that number of blocks, it will expire and be rejected. You can - think of it as an expiration date for the transaction. - raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. - wait_for_inclusion: Whether to wait for the inclusion of the transaction. - wait_for_finalization: Whether to wait for the finalization of the transaction. - wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. - - Returns: - ExtrinsicResponse: The result object of the extrinsic execution. - - Note: Modifying is allowed even when user liquidity is enabled in specified subnet. Call - `toggle_user_liquidity_extrinsic` to enable/disable user liquidity. - """ + """Deprecated. User liquidity has been permanently removed from the chain.""" deprecated_message( - message="User liquidity is currently disabled on the chain. " - "Calling this method will result in a 'UserLiquidityDisabled' error.", + message=_DEPRECATED_MSG, category=ChainFeatureDisabledWarning, stacklevel=3, ) - try: - unlock_type = "coldkey" if hotkey_ss58 else "both" - if not ( - unlocked := ExtrinsicResponse.unlock_wallet( - wallet, raise_error, unlock_type - ) - ).success: - return unlocked - - call = Swap(subtensor).modify_position( - netuid=netuid, - hotkey=hotkey_ss58 or wallet.hotkey.ss58_address, - position_id=position_id, - liquidity_delta=liquidity_delta.rao, - ) - - 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, - wait_for_inclusion=wait_for_inclusion, - wait_for_finalization=wait_for_finalization, - period=period, - raise_error=raise_error, - ) - except Exception as error: - return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + return ExtrinsicResponse(success=False, message=_DEPRECATED_MSG) def remove_liquidity_extrinsic( subtensor: "Subtensor", wallet: "Wallet", netuid: int, - position_id: int, - hotkey_ss58: Optional[str] = None, - *, - 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, + **kwargs, ) -> ExtrinsicResponse: - """Remove liquidity and credit balances back to wallet's hotkey stake. - - Parameters: - subtensor: The Subtensor client instance used for blockchain interaction. - wallet: The wallet used to sign the extrinsic (must be unlocked). - netuid: The UID of the target subnet for which the call is being initiated. - position_id: The id of the position record in the pool. - hotkey_ss58: The hotkey with staked TAO in Alpha. If not passed then the wallet hotkey is used. - mev_protection: If True, encrypts and submits the transaction through the MEV Shield pallet to protect - against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators - decrypt and execute it. If False, submits the transaction directly without encryption. - period: The number of blocks during which the transaction will remain valid after it's submitted. If the - transaction is not included in a block within that number of blocks, it will expire and be rejected. You can - think of it as an expiration date for the transaction. - raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. - wait_for_inclusion: Whether to wait for the inclusion of the transaction. - wait_for_finalization: Whether to wait for the finalization of the transaction. - wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. - - Returns: - ExtrinsicResponse: The result object of the extrinsic execution. - - Note: Adding is allowed even when user liquidity is enabled in specified subnet. Call - `toggle_user_liquidity_extrinsic` to enable/disable user liquidity. - """ + """Deprecated. User liquidity has been permanently removed from the chain.""" deprecated_message( - message="User liquidity is currently disabled on the chain. " - "Calling this method will result in a 'UserLiquidityDisabled' error.", + message=_DEPRECATED_MSG, category=ChainFeatureDisabledWarning, stacklevel=3, ) - try: - unlock_type = "coldkey" if hotkey_ss58 else "both" - if not ( - unlocked := ExtrinsicResponse.unlock_wallet( - wallet, raise_error, unlock_type - ) - ).success: - return unlocked - - call = Swap(subtensor).remove_liquidity( - netuid=netuid, - hotkey=hotkey_ss58 or wallet.hotkey.ss58_address, - position_id=position_id, - ) - - 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, - wait_for_inclusion=wait_for_inclusion, - wait_for_finalization=wait_for_finalization, - period=period, - raise_error=raise_error, - ) - except Exception as error: - return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + return ExtrinsicResponse(success=False, message=_DEPRECATED_MSG) def toggle_user_liquidity_extrinsic( subtensor: "Subtensor", wallet: "Wallet", netuid: int, - enable: bool, - *, - 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, + **kwargs, ) -> ExtrinsicResponse: - """Allow to toggle user liquidity for specified subnet. - - Parameters: - subtensor: The Subtensor client instance used for blockchain interaction. - wallet: The wallet used to sign the extrinsic (must be unlocked). - netuid: The UID of the target subnet for which the call is being initiated. - enable: Boolean indicating whether to enable user liquidity. - mev_protection: If True, encrypts and submits the transaction through the MEV Shield pallet to protect - against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators - decrypt and execute it. If False, submits the transaction directly without encryption. - period: The number of blocks during which the transaction will remain valid after it's submitted. If the - transaction is not included in a block within that number of blocks, it will expire and be rejected. You can - think of it as an expiration date for the transaction. - raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. - wait_for_inclusion: Whether to wait for the inclusion of the transaction. - wait_for_finalization: Whether to wait for the finalization of the transaction. - wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. - - Returns: - ExtrinsicResponse: The result object of the extrinsic execution. - """ + """Deprecated. User liquidity has been permanently removed from the chain.""" deprecated_message( - message="User liquidity is currently disabled on the chain. " - "Calling this method will result in a 'UserLiquidityDisabled' error.", + message=_DEPRECATED_MSG, category=ChainFeatureDisabledWarning, stacklevel=3, ) - try: - if not ( - unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) - ).success: - return unlocked - - call = Swap(subtensor).toggle_user_liquidity( - netuid=netuid, - enable=enable, - ) - - 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, - wait_for_inclusion=wait_for_inclusion, - wait_for_finalization=wait_for_finalization, - period=period, - raise_error=raise_error, - ) - except Exception as error: - return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + return ExtrinsicResponse(success=False, message=_DEPRECATED_MSG) diff --git a/bittensor/utils/liquidity.py b/bittensor/utils/liquidity.py index 3cc253c01d..64bafc0090 100644 --- a/bittensor/utils/liquidity.py +++ b/bittensor/utils/liquidity.py @@ -1,36 +1,32 @@ -""" -This module provides utilities for managing liquidity positions and price conversions in the Bittensor network. The -module handles conversions between TAO and Alpha tokens while maintaining precise calculations for liquidity -provisioning and fee distribution. -""" +"""Deprecated. User liquidity (Uniswap V3) has been permanently removed from the chain.""" -import math +# TODO: remove this module in the next major release (include all references) from dataclasses import dataclass -from bittensor.core.types import PositionResponse from bittensor.utils import ChainFeatureDisabledWarning, deprecated_message -from bittensor.utils.balance import Balance, fixed_to_float +from bittensor.utils.balance import Balance -# These three constants are unchangeable at the level of Uniswap math -MIN_TICK = -887272 -MAX_TICK = 887272 -PRICE_STEP = 1.0001 +_DEPRECATED_MSG = ( + "User liquidity (Uniswap V3) has been permanently removed from the chain. " + "The swap mechanism has been replaced by the Balancer swap." +) @dataclass class LiquidityPosition: + """Deprecated. User liquidity positions no longer exist on-chain.""" + id: int - price_low: Balance # RAO - price_high: Balance # RAO - liquidity: Balance # TAO + ALPHA (sqrt by TAO balance * Alpha Balance -> math under the hood) - fees_tao: Balance # RAO - fees_alpha: Balance # RAO + price_low: Balance + price_high: Balance + liquidity: Balance + fees_tao: Balance + fees_alpha: Balance netuid: int def __post_init__(self): deprecated_message( - message="LiquidityPosition is deprecated. User liquidity functionality has been " - "disabled on the chain after migration from Uniswap V3 to PalSwap.", + message=_DEPRECATED_MSG, category=ChainFeatureDisabledWarning, stacklevel=3, ) @@ -38,69 +34,33 @@ def __post_init__(self): def to_token_amounts( self, current_subnet_price: Balance ) -> tuple[Balance, Balance]: - """Convert a position to token amounts. - - Parameters: - current_subnet_price: current subnet price in Alpha. - - Returns: - tuple[int, int]: - Amount of Alpha in liquidity - Amount of TAO in liquidity - - Liquidity is a combination of TAO and Alpha depending on the price of the subnet at the moment. - """ - sqrt_price_low = math.sqrt(self.price_low) - sqrt_price_high = math.sqrt(self.price_high) - sqrt_current_subnet_price = math.sqrt(current_subnet_price) - - if sqrt_current_subnet_price < sqrt_price_low: - amount_alpha = self.liquidity * (1 / sqrt_price_low - 1 / sqrt_price_high) - amount_tao = 0 - elif sqrt_current_subnet_price > sqrt_price_high: - amount_alpha = 0 - amount_tao = self.liquidity * (sqrt_price_high - sqrt_price_low) - else: - amount_alpha = self.liquidity * ( - 1 / sqrt_current_subnet_price - 1 / sqrt_price_high - ) - amount_tao = self.liquidity * (sqrt_current_subnet_price - sqrt_price_low) - return Balance.from_rao(int(amount_alpha), self.netuid), Balance.from_rao( - int(amount_tao) + """Deprecated.""" + deprecated_message( + message=_DEPRECATED_MSG, + category=ChainFeatureDisabledWarning, + stacklevel=2, ) + return Balance.from_rao(0, self.netuid), Balance.from_rao(0) def price_to_tick(price: float) -> int: - """Converts a float price to the nearest Uniswap V3 tick index.""" + """Deprecated.""" deprecated_message( - message="price_to_tick() is deprecated. The chain has migrated from Uniswap V3 " - "to PalSwap which does not use tick-based pricing.", + message=_DEPRECATED_MSG, category=ChainFeatureDisabledWarning, stacklevel=3, ) - if price <= 0: - raise ValueError(f"Price must be positive, got `{price}`.") - - tick = int(math.log(price) / math.log(PRICE_STEP)) - - if not (MIN_TICK <= tick <= MAX_TICK): - raise ValueError( - f"Resulting tick {tick} is out of allowed range ({MIN_TICK} to {MAX_TICK})" - ) - return tick + return 0 def tick_to_price(tick: int) -> float: - """Convert an integer Uniswap V3 tick index to float price.""" + """Deprecated.""" deprecated_message( - message="tick_to_price() is deprecated. The chain has migrated from Uniswap V3 " - "to PalSwap which does not use tick-based pricing.", + message=_DEPRECATED_MSG, category=ChainFeatureDisabledWarning, stacklevel=3, ) - if not (MIN_TICK <= tick <= MAX_TICK): - raise ValueError("Tick is out of allowed range") - return PRICE_STEP**tick + return 0.0 def get_fees( @@ -112,28 +72,13 @@ def get_fees( global_fees_alpha: float, above: bool, ) -> float: - """Returns the liquidity fee.""" + """Deprecated.""" deprecated_message( - message="get_fees() is deprecated. The chain has migrated from Uniswap V3 " - "to PalSwap which uses a different fee calculation mechanism.", + message=_DEPRECATED_MSG, category=ChainFeatureDisabledWarning, stacklevel=3, ) - tick_fee_key = "fees_out_tao" if quote else "fees_out_alpha" - tick_fee_value = fixed_to_float(tick.get(tick_fee_key)) - global_fee_value = global_fees_tao if quote else global_fees_alpha - - if above: - return ( - global_fee_value - tick_fee_value - if tick_index <= current_tick - else tick_fee_value - ) - return ( - tick_fee_value - if tick_index <= current_tick - else global_fee_value - tick_fee_value - ) + return 0.0 def get_fees_in_range( @@ -143,19 +88,17 @@ def get_fees_in_range( fees_below_low: float, fees_above_high: float, ) -> float: - """Returns the liquidity fee value in a range.""" + """Deprecated.""" deprecated_message( - message="get_fees_in_range() is deprecated. The chain has migrated from Uniswap V3 " - "to PalSwap which uses a different fee calculation mechanism.", + message=_DEPRECATED_MSG, category=ChainFeatureDisabledWarning, stacklevel=3, ) - global_fees = global_fees_tao if quote else global_fees_alpha - return global_fees - fees_below_low - fees_above_high + return 0.0 def calculate_fees( - position: PositionResponse, + position: dict, global_fees_tao: float, global_fees_alpha: float, tao_fees_below_low: float, @@ -164,34 +107,10 @@ def calculate_fees( alpha_fees_above_high: float, netuid: int, ) -> tuple[Balance, Balance]: - """Calculate fees for a position.""" + """Deprecated.""" deprecated_message( - message="calculate_fees() is deprecated. The chain has migrated from Uniswap V3 " - "to PalSwap which uses a different fee calculation mechanism.", + message=_DEPRECATED_MSG, category=ChainFeatureDisabledWarning, stacklevel=3, ) - fee_tao_agg = get_fees_in_range( - quote=True, - global_fees_tao=global_fees_tao, - global_fees_alpha=global_fees_alpha, - fees_below_low=tao_fees_below_low, - fees_above_high=tao_fees_above_high, - ) - - fee_alpha_agg = get_fees_in_range( - quote=False, - global_fees_tao=global_fees_tao, - global_fees_alpha=global_fees_alpha, - fees_below_low=alpha_fees_below_low, - fees_above_high=alpha_fees_above_high, - ) - - fee_tao = fee_tao_agg - fixed_to_float(position["fees_tao"]) - fee_alpha = fee_alpha_agg - fixed_to_float(position["fees_alpha"]) - liquidity_frac = position["liquidity"] - - fee_tao = liquidity_frac * fee_tao - fee_alpha = liquidity_frac * fee_alpha - - return Balance.from_rao(int(fee_tao)), Balance.from_rao(int(fee_alpha), netuid) + return Balance.from_rao(0), Balance.from_rao(0, netuid) diff --git a/tests/e2e_tests/test_liquidity.py b/tests/e2e_tests/test_liquidity.py index 674278ef14..e69de29bb2 100644 --- a/tests/e2e_tests/test_liquidity.py +++ b/tests/e2e_tests/test_liquidity.py @@ -1,569 +0,0 @@ -import pytest - -from bittensor import Balance, logging -from bittensor.utils.liquidity import LiquidityPosition -from tests.e2e_tests.utils import ( - TestSubnet, - ACTIVATE_SUBNET, - REGISTER_SUBNET, -) - - -@pytest.mark.skip("Skips user liquidity e2e test pending the rework") -def test_liquidity(subtensor, alice_wallet, bob_wallet): - """ - Tests the liquidity mechanism - - Steps: - 1. Check `get_liquidity_list` return None if SN doesn't exist. - 2. Register a subnet through Alice. - 3. Make sure `get_liquidity_list` return None without activ SN. - 4. Wait until start call availability and do this call. - 5. Add liquidity to the subnet and check `get_liquidity_list` return liquidity positions. - 6. Modify liquidity and check `get_liquidity_list` return new liquidity positions with modified liquidity value. - 7. Add second liquidity position and check `get_liquidity_list` return new liquidity positions with 0 index. - 8. Add stake from Bob to Alice and check `get_liquidity_list` return new liquidity positions with fees_tao. - 9. Remove all stake from Alice and check `get_liquidity_list` return new liquidity positions with 0 fees_tao. - 10. Remove all liquidity positions and check `get_liquidity_list` return empty list. - """ - alice_sn = TestSubnet(subtensor) - alice_sn.execute_one(REGISTER_SUBNET(alice_wallet)) - - # Make sure `get_liquidity_list` return None if SN doesn't exist - assert ( - subtensor.subnets.get_liquidity_list(wallet=alice_wallet, netuid=14) is None - ), "❌ `get_liquidity_list` is not None for unexisting subnet." - - # Make sure `get_liquidity_list` return None without activ SN - assert ( - subtensor.subnets.get_liquidity_list( - wallet=alice_wallet, netuid=alice_sn.netuid - ) - is None - ), "❌ `get_liquidity_list` is not None when no activ subnet." - - # Wait until start call availability and do this call - alice_sn.execute_one(ACTIVATE_SUBNET(alice_wallet)) - - # Make sure `get_liquidity_list` return None without activ SN - assert ( - subtensor.subnets.get_liquidity_list( - wallet=alice_wallet, netuid=alice_sn.netuid - ) - == [] - ), "❌ `get_liquidity_list` is not empty list before fist liquidity add." - - # enable user liquidity in SN - success, message = subtensor.extrinsics.toggle_user_liquidity( - wallet=alice_wallet, - netuid=alice_sn.netuid, - enable=True, - ) - assert success, message - assert message == "Success", "❌ Cannot enable user liquidity." - - # Add steak to call add_liquidity - assert subtensor.staking.add_stake( - wallet=alice_wallet, - hotkey_ss58=alice_wallet.hotkey.ss58_address, - netuid=alice_sn.netuid, - amount=Balance.from_tao(1), - ).success, "❌ Cannot cannot add stake to Alice from Alice." - - # wait for the next block to give the chain time to update the stake - subtensor.wait_for_block() - - current_balance = subtensor.wallets.get_balance(alice_wallet.hotkey.ss58_address) - current_sn_stake = subtensor.staking.get_stake_info_for_coldkey( - coldkey_ss58=alice_wallet.coldkey.ss58_address - ) - logging.console.info( - f"Alice balance: {current_balance} and stake: {current_sn_stake}" - ) - - # Add liquidity - success, message = subtensor.extrinsics.add_liquidity( - wallet=alice_wallet, - netuid=alice_sn.netuid, - liquidity=Balance.from_tao(1), - price_low=Balance.from_tao(1.7), - price_high=Balance.from_tao(1.8), - wait_for_inclusion=True, - wait_for_finalization=True, - ) - assert success, message - assert message == "Success", "❌ Cannot add liquidity." - - # Get liquidity - liquidity_positions = subtensor.subnets.get_liquidity_list( - wallet=alice_wallet, netuid=alice_sn.netuid - ) - - assert len(liquidity_positions) == 1, ( - "❌ liquidity_positions has more than one element." - ) - - # Check if liquidity is correct - liquidity_position = liquidity_positions[0] - assert liquidity_position == LiquidityPosition( - id=2, - price_low=liquidity_position.price_low, - price_high=liquidity_position.price_high, - liquidity=Balance.from_tao(1), - fees_tao=Balance.from_tao(0), - fees_alpha=Balance.from_tao(0, netuid=alice_sn.netuid), - netuid=alice_sn.netuid, - ), "❌ `get_liquidity_list` still empty list after liquidity add." - - # Modify liquidity position with positive value - success, message = subtensor.extrinsics.modify_liquidity( - wallet=alice_wallet, - netuid=alice_sn.netuid, - position_id=liquidity_position.id, - liquidity_delta=Balance.from_tao(20), - wait_for_inclusion=True, - wait_for_finalization=True, - ) - assert success, message - assert message == "Success", "❌ cannot modify liquidity position." - - liquidity_positions = subtensor.subnets.get_liquidity_list( - wallet=alice_wallet, netuid=alice_sn.netuid - ) - - assert len(liquidity_positions) == 1, ( - "❌ liquidity_positions has more than one element." - ) - liquidity_position = liquidity_positions[0] - - assert liquidity_position == LiquidityPosition( - id=2, - price_low=liquidity_position.price_low, - price_high=liquidity_position.price_high, - liquidity=Balance.from_tao(21), - fees_tao=Balance.from_tao(0), - fees_alpha=Balance.from_tao(0, netuid=alice_sn.netuid), - netuid=alice_sn.netuid, - ) - - # Modify liquidity position with negative value - success, message = subtensor.extrinsics.modify_liquidity( - wallet=alice_wallet, - netuid=alice_sn.netuid, - position_id=liquidity_position.id, - liquidity_delta=-Balance.from_tao(11), - wait_for_inclusion=True, - wait_for_finalization=True, - ) - assert success, message - assert message == "Success", "❌ cannot modify liquidity position." - - liquidity_positions = subtensor.subnets.get_liquidity_list( - wallet=alice_wallet, netuid=alice_sn.netuid - ) - - assert len(liquidity_positions) == 1, ( - "❌ liquidity_positions has more than one element." - ) - liquidity_position = liquidity_positions[0] - - assert liquidity_position == LiquidityPosition( - id=2, - price_low=liquidity_position.price_low, - price_high=liquidity_position.price_high, - liquidity=Balance.from_tao(10), - fees_tao=Balance.from_tao(0), - fees_alpha=Balance.from_tao(0, netuid=alice_sn.netuid), - netuid=alice_sn.netuid, - ) - - # Add stake from Bob to Alice - assert subtensor.staking.add_stake( - wallet=bob_wallet, - hotkey_ss58=alice_wallet.hotkey.ss58_address, - netuid=alice_sn.netuid, - amount=Balance.from_tao(1000), - ).success, "❌ Cannot add stake from Bob to Alice." - - # wait for the next block to give the chain time to update the stake - subtensor.wait_for_block() - - current_balance = subtensor.wallets.get_balance(alice_wallet.hotkey.ss58_address) - current_sn_stake = subtensor.staking.get_stake_info_for_coldkey( - coldkey_ss58=alice_wallet.coldkey.ss58_address - ) - logging.console.info( - f"Alice balance: {current_balance} and stake: {current_sn_stake}" - ) - - # Add second liquidity position - success, message = subtensor.extrinsics.add_liquidity( - wallet=alice_wallet, - netuid=alice_sn.netuid, - liquidity=Balance.from_tao(150), - price_low=Balance.from_tao(0.8), - price_high=Balance.from_tao(1.2), - wait_for_inclusion=True, - wait_for_finalization=True, - ) - assert success, message - assert message == "Success", "❌ Cannot add liquidity." - - liquidity_positions = subtensor.subnets.get_liquidity_list( - wallet=alice_wallet, netuid=alice_sn.netuid - ) - - assert len(liquidity_positions) == 2, ( - f"❌ liquidity_positions should have 2 elements, but has only {len(liquidity_positions)} element." - ) - - # All new liquidity inserts on the 0 index - liquidity_position_second = liquidity_positions[0] - assert liquidity_position_second == LiquidityPosition( - id=3, - price_low=liquidity_position_second.price_low, - price_high=liquidity_position_second.price_high, - liquidity=Balance.from_tao(150), - fees_tao=Balance.from_tao(0), - fees_alpha=Balance.from_tao(0, netuid=alice_sn.netuid), - netuid=alice_sn.netuid, - ) - - liquidity_position_first = liquidity_positions[1] - assert liquidity_position_first == LiquidityPosition( - id=2, - price_low=liquidity_position_first.price_low, - price_high=liquidity_position_first.price_high, - liquidity=Balance.from_tao(10), - fees_tao=liquidity_position_first.fees_tao, - fees_alpha=Balance.from_tao(0, netuid=alice_sn.netuid), - netuid=alice_sn.netuid, - ) - # After adding stake alice liquidity position has a fees_tao bc of high price - assert liquidity_position_first.fees_tao > Balance.from_tao(0) - - # Bob remove all stake from alice - assert subtensor.extrinsics.unstake_all( - wallet=bob_wallet, - netuid=alice_sn.netuid, - hotkey_ss58=alice_wallet.hotkey.ss58_address, - rate_tolerance=0.9, # keep high rate tolerance to avoid flaky behavior - wait_for_inclusion=True, - wait_for_finalization=True, - ).success - - # Check that fees_alpha comes too after all unstake - liquidity_position_first = subtensor.subnets.get_liquidity_list( - wallet=alice_wallet, netuid=alice_sn.netuid - )[1] - assert liquidity_position_first.fees_tao > Balance.from_tao(0) - assert liquidity_position_first.fees_alpha > Balance.from_tao(0, alice_sn.netuid) - - # Remove all liquidity positions - for p in liquidity_positions: - success, message = subtensor.extrinsics.remove_liquidity( - wallet=alice_wallet, - netuid=alice_sn.netuid, - position_id=p.id, - wait_for_inclusion=True, - wait_for_finalization=True, - ) - assert success, message - assert message == "Success", "❌ Cannot remove liquidity." - - # Make sure all liquidity positions removed - assert ( - subtensor.subnets.get_liquidity_list( - wallet=alice_wallet, netuid=alice_sn.netuid - ) - == [] - ), "❌ Not all liquidity positions removed." - - logging.console.info("✅ Passed [blue]test_liquidity[/blue]") - - -@pytest.mark.skip("Skips user liquidity e2e test pending the rework") -@pytest.mark.asyncio -async def test_liquidity_async(async_subtensor, alice_wallet, bob_wallet): - """ - ASync tests the liquidity mechanism - - Steps: - 1. Check `get_liquidity_list` return None if SN doesn't exist. - 2. Register a subnet through Alice. - 3. Make sure `get_liquidity_list` return None without activ SN. - 4. Wait until start call availability and do this call. - 5. Add liquidity to the subnet and check `get_liquidity_list` return liquidity positions. - 6. Modify liquidity and check `get_liquidity_list` return new liquidity positions with modified liquidity value. - 7. Add second liquidity position and check `get_liquidity_list` return new liquidity positions with 0 index. - 8. Add stake from Bob to Alice and check `get_liquidity_list` return new liquidity positions with fees_tao. - 9. Remove all stake from Alice and check `get_liquidity_list` return new liquidity positions with 0 fees_tao. - 10. Remove all liquidity positions and check `get_liquidity_list` return empty list. - """ - alice_sn = TestSubnet(async_subtensor) - await alice_sn.async_execute_one(REGISTER_SUBNET(alice_wallet)) - - # Make sure `get_liquidity_list` return None if SN doesn't exist - assert ( - await async_subtensor.subnets.get_liquidity_list(wallet=alice_wallet, netuid=14) - is None - ), "❌ `get_liquidity_list` is not None for unexisting subnet." - - # Make sure `get_liquidity_list` return None without activ SN - assert ( - await async_subtensor.subnets.get_liquidity_list( - wallet=alice_wallet, netuid=alice_sn.netuid - ) - is None - ), "❌ `get_liquidity_list` is not None when no activ subnet." - - # Wait until start call availability and do this call - await alice_sn.async_execute_one(ACTIVATE_SUBNET(alice_wallet)) - - # Make sure `get_liquidity_list` return None without activ SN - assert ( - await async_subtensor.subnets.get_liquidity_list( - wallet=alice_wallet, netuid=alice_sn.netuid - ) - == [] - ), "❌ `get_liquidity_list` is not empty list before fist liquidity add." - - # enable user liquidity in SN - success, message = await async_subtensor.extrinsics.toggle_user_liquidity( - wallet=alice_wallet, - netuid=alice_sn.netuid, - enable=True, - ) - assert success, message - assert message == "Success", "❌ Cannot enable user liquidity." - - # Add steak to call add_liquidity - assert ( - await async_subtensor.staking.add_stake( - wallet=alice_wallet, - hotkey_ss58=alice_wallet.hotkey.ss58_address, - netuid=alice_sn.netuid, - amount=Balance.from_tao(1), - ) - ).success, "❌ Cannot cannot add stake to Alice from Alice." - - # wait for the next block to give the chain time to update the stake - await async_subtensor.wait_for_block() - - current_balance = await async_subtensor.wallets.get_balance( - alice_wallet.hotkey.ss58_address - ) - current_sn_stake = await async_subtensor.staking.get_stake_info_for_coldkey( - coldkey_ss58=alice_wallet.coldkey.ss58_address - ) - logging.console.info( - f"Alice balance: {current_balance} and stake: {current_sn_stake}" - ) - - # Add liquidity - success, message = await async_subtensor.extrinsics.add_liquidity( - wallet=alice_wallet, - netuid=alice_sn.netuid, - liquidity=Balance.from_tao(1), - price_low=Balance.from_tao(1.7), - price_high=Balance.from_tao(1.8), - wait_for_inclusion=True, - wait_for_finalization=True, - ) - assert success, message - assert message == "Success", "❌ Cannot add liquidity." - - # Get liquidity - liquidity_positions = await async_subtensor.subnets.get_liquidity_list( - wallet=alice_wallet, netuid=alice_sn.netuid - ) - - assert len(liquidity_positions) == 1, ( - "❌ liquidity_positions has more than one element." - ) - - # Check if liquidity is correct - liquidity_position = liquidity_positions[0] - assert liquidity_position == LiquidityPosition( - id=2, - price_low=liquidity_position.price_low, - price_high=liquidity_position.price_high, - liquidity=Balance.from_tao(1), - fees_tao=Balance.from_tao(0), - fees_alpha=Balance.from_tao(0, netuid=alice_sn.netuid), - netuid=alice_sn.netuid, - ), "❌ `get_liquidity_list` still empty list after liquidity add." - - # Modify liquidity position with positive value - success, message = await async_subtensor.extrinsics.modify_liquidity( - wallet=alice_wallet, - netuid=alice_sn.netuid, - position_id=liquidity_position.id, - liquidity_delta=Balance.from_tao(20), - wait_for_inclusion=True, - wait_for_finalization=True, - ) - assert success, message - assert message == "Success", "❌ cannot modify liquidity position." - - liquidity_positions = await async_subtensor.subnets.get_liquidity_list( - wallet=alice_wallet, netuid=alice_sn.netuid - ) - - assert len(liquidity_positions) == 1, ( - "❌ liquidity_positions has more than one element." - ) - liquidity_position = liquidity_positions[0] - - assert liquidity_position == LiquidityPosition( - id=2, - price_low=liquidity_position.price_low, - price_high=liquidity_position.price_high, - liquidity=Balance.from_tao(21), - fees_tao=Balance.from_tao(0), - fees_alpha=Balance.from_tao(0, netuid=alice_sn.netuid), - netuid=alice_sn.netuid, - ) - - # Modify liquidity position with negative value - success, message = await async_subtensor.extrinsics.modify_liquidity( - wallet=alice_wallet, - netuid=alice_sn.netuid, - position_id=liquidity_position.id, - liquidity_delta=-Balance.from_tao(11), - wait_for_inclusion=True, - wait_for_finalization=True, - ) - assert success, message - assert message == "Success", "❌ cannot modify liquidity position." - - liquidity_positions = await async_subtensor.subnets.get_liquidity_list( - wallet=alice_wallet, netuid=alice_sn.netuid - ) - - assert len(liquidity_positions) == 1, ( - "❌ liquidity_positions has more than one element." - ) - liquidity_position = liquidity_positions[0] - - assert liquidity_position == LiquidityPosition( - id=2, - price_low=liquidity_position.price_low, - price_high=liquidity_position.price_high, - liquidity=Balance.from_tao(10), - fees_tao=Balance.from_tao(0), - fees_alpha=Balance.from_tao(0, netuid=alice_sn.netuid), - netuid=alice_sn.netuid, - ) - - # Add stake from Bob to Alice - assert ( - await async_subtensor.staking.add_stake( - wallet=bob_wallet, - hotkey_ss58=alice_wallet.hotkey.ss58_address, - netuid=alice_sn.netuid, - amount=Balance.from_tao(1000), - ) - ).success, "❌ Cannot add stake from Bob to Alice." - - # wait for the next block to give the chain time to update the stake - await async_subtensor.wait_for_block() - - current_balance = await async_subtensor.wallets.get_balance( - alice_wallet.hotkey.ss58_address - ) - current_sn_stake = await async_subtensor.staking.get_stake_info_for_coldkey( - coldkey_ss58=alice_wallet.coldkey.ss58_address - ) - logging.console.info( - f"Alice balance: {current_balance} and stake: {current_sn_stake}" - ) - - # Add second liquidity position - success, message = await async_subtensor.extrinsics.add_liquidity( - wallet=alice_wallet, - netuid=alice_sn.netuid, - liquidity=Balance.from_tao(150), - price_low=Balance.from_tao(0.8), - price_high=Balance.from_tao(1.2), - wait_for_inclusion=True, - wait_for_finalization=True, - ) - assert success, message - assert message == "Success", "❌ Cannot add liquidity." - - liquidity_positions = await async_subtensor.subnets.get_liquidity_list( - wallet=alice_wallet, netuid=alice_sn.netuid - ) - - assert len(liquidity_positions) == 2, ( - f"❌ liquidity_positions should have 2 elements, but has only {len(liquidity_positions)} element." - ) - - # All new liquidity inserts on the 0 index - liquidity_position_second = liquidity_positions[0] - assert liquidity_position_second == LiquidityPosition( - id=3, - price_low=liquidity_position_second.price_low, - price_high=liquidity_position_second.price_high, - liquidity=Balance.from_tao(150), - fees_tao=Balance.from_tao(0), - fees_alpha=Balance.from_tao(0, netuid=alice_sn.netuid), - netuid=alice_sn.netuid, - ) - - liquidity_position_first = liquidity_positions[1] - assert liquidity_position_first == LiquidityPosition( - id=2, - price_low=liquidity_position_first.price_low, - price_high=liquidity_position_first.price_high, - liquidity=Balance.from_tao(10), - fees_tao=liquidity_position_first.fees_tao, - fees_alpha=Balance.from_tao(0, netuid=alice_sn.netuid), - netuid=alice_sn.netuid, - ) - # After adding stake alice liquidity position has a fees_tao bc of high price - assert liquidity_position_first.fees_tao > Balance.from_tao(0) - - # Bob remove all stake from alice - assert ( - await async_subtensor.extrinsics.unstake_all( - wallet=bob_wallet, - netuid=alice_sn.netuid, - hotkey_ss58=alice_wallet.hotkey.ss58_address, - rate_tolerance=0.9, # keep high rate tolerance to avoid flaky behavior - wait_for_inclusion=True, - wait_for_finalization=True, - ) - ).success - - # Check that fees_alpha comes too after all unstake - liquidity_position_first = ( - await async_subtensor.subnets.get_liquidity_list( - wallet=alice_wallet, netuid=alice_sn.netuid - ) - )[1] - assert liquidity_position_first.fees_tao > Balance.from_tao(0) - assert liquidity_position_first.fees_alpha > Balance.from_tao(0, alice_sn.netuid) - - # Remove all liquidity positions - for p in liquidity_positions: - success, message = await async_subtensor.extrinsics.remove_liquidity( - wallet=alice_wallet, - netuid=alice_sn.netuid, - position_id=p.id, - wait_for_inclusion=True, - wait_for_finalization=True, - ) - assert success, message - assert message == "Success", "❌ Cannot remove liquidity." - - # Make sure all liquidity positions removed - assert ( - await async_subtensor.subnets.get_liquidity_list( - wallet=alice_wallet, netuid=alice_sn.netuid - ) - == [] - ), "❌ Not all liquidity positions removed." - - logging.console.info("✅ Passed [blue]test_liquidity_async[/blue]") diff --git a/tests/unit_tests/extrinsics/asyncex/test_liquidity.py b/tests/unit_tests/extrinsics/asyncex/test_liquidity.py index 9e9d7cf2bb..e69de29bb2 100644 --- a/tests/unit_tests/extrinsics/asyncex/test_liquidity.py +++ b/tests/unit_tests/extrinsics/asyncex/test_liquidity.py @@ -1,173 +0,0 @@ -import pytest -from bittensor.core.extrinsics.asyncex import liquidity -from bittensor.utils.balance import Balance - - -@pytest.mark.asyncio -async def test_add_liquidity_extrinsic(subtensor, fake_wallet, mocker): - """Test that the add `add_liquidity_extrinsic` executes correct calls.""" - # Preps - fake_netuid = mocker.Mock() - fake_liquidity = mocker.MagicMock(spec=Balance, rao=1000_000) - fake_price_low = mocker.MagicMock(spec=Balance, tao=1.1) - fake_price_high = mocker.MagicMock(spec=Balance, tao=1.5) - - mocked_pallet_compose_call = mocker.patch.object( - liquidity.Swap, "add_liquidity", new=mocker.AsyncMock() - ) - mocked_sign_and_send_extrinsic = mocker.patch.object( - subtensor, "sign_and_send_extrinsic" - ) - - # Call - result = await liquidity.add_liquidity_extrinsic( - subtensor=subtensor, - wallet=fake_wallet, - netuid=fake_netuid, - liquidity=fake_liquidity, - price_low=fake_price_low, - price_high=fake_price_high, - ) - - # Asserts - mocked_pallet_compose_call.assert_awaited_once_with( - netuid=fake_netuid, - hotkey=fake_wallet.hotkey.ss58_address, - liquidity=1000000, - tick_low=953, - tick_high=4054, - ) - mocked_sign_and_send_extrinsic.assert_awaited_once_with( - call=mocked_pallet_compose_call.return_value, - wallet=fake_wallet, - wait_for_inclusion=True, - wait_for_finalization=True, - period=None, - raise_error=False, - ) - assert result == mocked_sign_and_send_extrinsic.return_value - - -@pytest.mark.asyncio -async def test_modify_liquidity_extrinsic(subtensor, fake_wallet, mocker): - """Test that the add `modify_liquidity_extrinsic` executes correct calls.""" - # Preps - fake_netuid = 1 - fake_position_id = 2 - fake_liquidity_delta = mocker.Mock() - - mocked_compose_call = mocker.patch.object(subtensor, "compose_call") - mocked_sign_and_send_extrinsic = mocker.patch.object( - subtensor, "sign_and_send_extrinsic" - ) - - # Call - result = await liquidity.modify_liquidity_extrinsic( - subtensor=subtensor, - wallet=fake_wallet, - netuid=fake_netuid, - position_id=fake_position_id, - liquidity_delta=fake_liquidity_delta, - ) - - # Asserts - mocked_compose_call.assert_awaited_once_with( - call_module="Swap", - call_function="modify_position", - call_params={ - "hotkey": fake_wallet.hotkey.ss58_address, - "netuid": fake_netuid, - "position_id": fake_position_id, - "liquidity_delta": fake_liquidity_delta.rao, - }, - ) - mocked_sign_and_send_extrinsic.assert_awaited_once_with( - call=mocked_compose_call.return_value, - wallet=fake_wallet, - wait_for_inclusion=True, - wait_for_finalization=True, - period=None, - raise_error=False, - ) - assert result == mocked_sign_and_send_extrinsic.return_value - - -@pytest.mark.asyncio -async def test_remove_liquidity_extrinsic(subtensor, fake_wallet, mocker): - """Test that the add `remove_liquidity_extrinsic` executes correct calls.""" - # Preps - fake_netuid = 1 - fake_position_id = 2 - - mocked_compose_call = mocker.patch.object(subtensor, "compose_call") - mocked_sign_and_send_extrinsic = mocker.patch.object( - subtensor, "sign_and_send_extrinsic" - ) - - # Call - result = await liquidity.remove_liquidity_extrinsic( - subtensor=subtensor, - wallet=fake_wallet, - netuid=fake_netuid, - position_id=fake_position_id, - ) - - # Asserts - mocked_compose_call.assert_awaited_once_with( - call_module="Swap", - call_function="remove_liquidity", - call_params={ - "hotkey": fake_wallet.hotkey.ss58_address, - "netuid": fake_netuid, - "position_id": fake_position_id, - }, - ) - mocked_sign_and_send_extrinsic.assert_awaited_once_with( - call=mocked_compose_call.return_value, - wallet=fake_wallet, - wait_for_inclusion=True, - wait_for_finalization=True, - period=None, - raise_error=False, - ) - assert result == mocked_sign_and_send_extrinsic.return_value - - -@pytest.mark.asyncio -async def test_toggle_user_liquidity_extrinsic(subtensor, fake_wallet, mocker): - """Test that the add `toggle_user_liquidity_extrinsic` executes correct calls.""" - # Preps - fake_netuid = 1 - fake_enable = mocker.Mock() - - mocked_compose_call = mocker.patch.object(subtensor, "compose_call") - mocked_sign_and_send_extrinsic = mocker.patch.object( - subtensor, "sign_and_send_extrinsic" - ) - - # Call - result = await liquidity.toggle_user_liquidity_extrinsic( - subtensor=subtensor, - wallet=fake_wallet, - netuid=fake_netuid, - enable=fake_enable, - ) - - # Asserts - mocked_compose_call.assert_awaited_once_with( - call_module="Swap", - call_function="toggle_user_liquidity", - call_params={ - "netuid": fake_netuid, - "enable": fake_enable, - }, - ) - mocked_sign_and_send_extrinsic.assert_awaited_once_with( - call=mocked_compose_call.return_value, - wallet=fake_wallet, - wait_for_inclusion=True, - wait_for_finalization=True, - period=None, - raise_error=False, - ) - assert result == mocked_sign_and_send_extrinsic.return_value diff --git a/tests/unit_tests/extrinsics/test_liquidity.py b/tests/unit_tests/extrinsics/test_liquidity.py index 1ad11b3dd4..e69de29bb2 100644 --- a/tests/unit_tests/extrinsics/test_liquidity.py +++ b/tests/unit_tests/extrinsics/test_liquidity.py @@ -1,166 +0,0 @@ -from bittensor.core.extrinsics import liquidity -from bittensor.utils.balance import Balance - - -def test_add_liquidity_extrinsic(subtensor, fake_wallet, mocker): - """Test that the add `add_liquidity_extrinsic` executes correct calls.""" - # Preps - fake_netuid = 1 - fake_liquidity = mocker.MagicMock(spec=Balance, rao=1000_000) - fake_price_low = mocker.MagicMock(spec=Balance, tao=1.1) - fake_price_high = mocker.MagicMock(spec=Balance, tao=1.5) - - mocked_sign_and_send_extrinsic = mocker.patch.object( - subtensor, "sign_and_send_extrinsic" - ) - mocked_pallet_compose_call = mocker.patch.object(liquidity.Swap, "add_liquidity") - - # Call - result = liquidity.add_liquidity_extrinsic( - subtensor=subtensor, - wallet=fake_wallet, - netuid=fake_netuid, - liquidity=fake_liquidity, - price_low=fake_price_low, - price_high=fake_price_high, - ) - - # Asserts - mocked_pallet_compose_call.assert_called_once_with( - netuid=fake_netuid, - hotkey=fake_wallet.hotkey.ss58_address, - liquidity=1000000, - tick_low=953, - tick_high=4054, - ) - mocked_sign_and_send_extrinsic.assert_called_once_with( - call=mocked_pallet_compose_call.return_value, - wallet=fake_wallet, - wait_for_inclusion=True, - wait_for_finalization=True, - period=None, - raise_error=False, - ) - assert result == mocked_sign_and_send_extrinsic.return_value - - -def test_modify_liquidity_extrinsic(subtensor, fake_wallet, mocker): - """Test that the add `modify_liquidity_extrinsic` executes correct calls.""" - # Preps - fake_netuid = 1 - fake_position_id = 2 - fake_liquidity_delta = mocker.Mock() - - mocked_compose_call = mocker.patch.object(subtensor, "compose_call") - mocked_sign_and_send_extrinsic = mocker.patch.object( - subtensor, "sign_and_send_extrinsic" - ) - - # Call - result = liquidity.modify_liquidity_extrinsic( - subtensor=subtensor, - wallet=fake_wallet, - netuid=fake_netuid, - position_id=fake_position_id, - liquidity_delta=fake_liquidity_delta, - ) - - # Asserts - mocked_compose_call.assert_called_once_with( - call_module="Swap", - call_function="modify_position", - call_params={ - "hotkey": fake_wallet.hotkey.ss58_address, - "netuid": fake_netuid, - "position_id": fake_position_id, - "liquidity_delta": fake_liquidity_delta.rao, - }, - ) - mocked_sign_and_send_extrinsic.assert_called_once_with( - call=mocked_compose_call.return_value, - wallet=fake_wallet, - wait_for_inclusion=True, - wait_for_finalization=True, - period=None, - raise_error=False, - ) - assert result == mocked_sign_and_send_extrinsic.return_value - - -def test_remove_liquidity_extrinsic(subtensor, fake_wallet, mocker): - """Test that the add `remove_liquidity_extrinsic` executes correct calls.""" - # Preps - fake_netuid = 1 - fake_position_id = 2 - - mocked_compose_call = mocker.patch.object(subtensor, "compose_call") - mocked_sign_and_send_extrinsic = mocker.patch.object( - subtensor, "sign_and_send_extrinsic" - ) - - # Call - result = liquidity.remove_liquidity_extrinsic( - subtensor=subtensor, - wallet=fake_wallet, - netuid=fake_netuid, - position_id=fake_position_id, - ) - - # Asserts - mocked_compose_call.assert_called_once_with( - call_module="Swap", - call_function="remove_liquidity", - call_params={ - "hotkey": fake_wallet.hotkey.ss58_address, - "netuid": fake_netuid, - "position_id": fake_position_id, - }, - ) - mocked_sign_and_send_extrinsic.assert_called_once_with( - call=mocked_compose_call.return_value, - wallet=fake_wallet, - wait_for_inclusion=True, - wait_for_finalization=True, - period=None, - raise_error=False, - ) - assert result == mocked_sign_and_send_extrinsic.return_value - - -def test_toggle_user_liquidity_extrinsic(subtensor, fake_wallet, mocker): - """Test that the add `toggle_user_liquidity_extrinsic` executes correct calls.""" - # Preps - fake_netuid = 1 - fake_enable = mocker.Mock() - - mocked_compose_call = mocker.patch.object(subtensor, "compose_call") - mocked_sign_and_send_extrinsic = mocker.patch.object( - subtensor, "sign_and_send_extrinsic" - ) - - # Call - result = liquidity.toggle_user_liquidity_extrinsic( - subtensor=subtensor, - wallet=fake_wallet, - netuid=fake_netuid, - enable=fake_enable, - ) - - # Asserts - mocked_compose_call.assert_called_once_with( - call_module="Swap", - call_function="toggle_user_liquidity", - call_params={ - "netuid": fake_netuid, - "enable": fake_enable, - }, - ) - mocked_sign_and_send_extrinsic.assert_called_once_with( - call=mocked_compose_call.return_value, - wallet=fake_wallet, - wait_for_inclusion=True, - wait_for_finalization=True, - period=None, - raise_error=False, - ) - assert result == mocked_sign_and_send_extrinsic.return_value diff --git a/tests/unit_tests/utils/test_liquidity_utils.py b/tests/unit_tests/utils/test_liquidity_utils.py index 5a761cc6a7..e69de29bb2 100644 --- a/tests/unit_tests/utils/test_liquidity_utils.py +++ b/tests/unit_tests/utils/test_liquidity_utils.py @@ -1,124 +0,0 @@ -import math - -import pytest - -from bittensor.utils.balance import Balance -from bittensor.utils.liquidity import ( - LiquidityPosition, - price_to_tick, - tick_to_price, - get_fees, - get_fees_in_range, - calculate_fees, -) - - -def test_liquidity_position_to_token_amounts(): - """Test conversion of liquidity position to token amounts.""" - # Preps - pos = LiquidityPosition( - id=1, - price_low=Balance.from_tao(10000), - price_high=Balance.from_tao(40000), - liquidity=Balance.from_tao(25000), - fees_tao=Balance.from_tao(0), - fees_alpha=Balance.from_tao(0), - netuid=1, - ) - current_price = Balance.from_tao(20000) - # Call - alpha, tao = pos.to_token_amounts(current_price) - # Asserts - assert isinstance(alpha, Balance) - assert isinstance(tao, Balance) - assert alpha.rao >= 0 and tao.rao >= 0 - - -def test_price_to_tick_and_back(): - """Test price to tick conversion and back.""" - # Preps - price = 1.25 - # Call - tick = price_to_tick(price) - restored_price = tick_to_price(tick) - # Asserts - assert math.isclose(restored_price, price, rel_tol=1e-3) - - -def test_price_to_tick_invalid(): - """Test price to tick conversion with invalid input.""" - with pytest.raises(ValueError): - price_to_tick(0) - - -def test_tick_to_price_invalid(): - """Test tick to price conversion with invalid input.""" - with pytest.raises(ValueError): - tick_to_price(1_000_000) - - -def test_get_fees_above_true(): - """Test fee calculation for above position.""" - # Preps - tick = { - "liquidity_net": 1000000000000, - "liquidity_gross": 1000000000000, - "fees_out_tao": {"bits": 0}, - "fees_out_alpha": {"bits": 0}, - } - # Call - result = get_fees( - current_tick=100, - tick=tick, - tick_index=90, - quote=True, - global_fees_tao=8000, - global_fees_alpha=6000, - above=True, - ) - # Asserts - assert result == 8000 - - -def test_get_fees_in_range(): - """Test fee calculation within a range.""" - # Call - value = get_fees_in_range( - quote=True, - global_fees_tao=10000, - global_fees_alpha=5000, - fees_below_low=2000, - fees_above_high=1000, - ) - # Asserts - assert value == 7000 - - -def test_calculate_fees(): - """Test calculation of fees for a position.""" - # Preps - position = { - "id": (2,), - "netuid": 2, - "tick_low": (206189,), - "tick_high": (208196,), - "liquidity": 1000000000000, - "fees_tao": {"bits": 0}, - "fees_alpha": {"bits": 0}, - } - # Call - result = calculate_fees( - position=position, - global_fees_tao=5000, - global_fees_alpha=8000, - tao_fees_below_low=1000, - tao_fees_above_high=1000, - alpha_fees_below_low=2000, - alpha_fees_above_high=1000, - netuid=1, - ) - # Asserts - assert isinstance(result[0], Balance) - assert isinstance(result[1], Balance) - assert result[0].rao > 0 - assert result[1].rao > 0 From d984d3ba5f0773df36d5a0253875fb96d3f38875 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Tue, 26 May 2026 20:17:42 -0700 Subject: [PATCH 03/59] update unit tests --- tests/unit_tests/test_async_subtensor.py | 259 +++++------------------ tests/unit_tests/test_subtensor.py | 182 +++++----------- 2 files changed, 105 insertions(+), 336 deletions(-) diff --git a/tests/unit_tests/test_async_subtensor.py b/tests/unit_tests/test_async_subtensor.py index aba3085fc2..82482973f5 100644 --- a/tests/unit_tests/test_async_subtensor.py +++ b/tests/unit_tests/test_async_subtensor.py @@ -3366,193 +3366,6 @@ async def test_unstake_all(subtensor, fake_wallet, mocker): assert result == fake_unstake_all_extrinsic.return_value -@pytest.mark.asyncio -async def test_get_liquidity_list_subnet_does_not_exits(subtensor, mocker): - """Test get_liquidity_list returns None when subnet doesn't exist.""" - # Preps - mocker.patch.object(subtensor, "subnet_exists", return_value=False) - - # Call - result = await subtensor.get_liquidity_list(wallet=mocker.Mock(), netuid=1) - - # Asserts - subtensor.subnet_exists.assert_awaited_once_with(netuid=1) - assert result is None - - -@pytest.mark.asyncio -async def test_get_liquidity_list_subnet_is_not_active(subtensor, mocker): - """Test get_liquidity_list returns None when subnet is not active.""" - # Preps - mocker.patch.object(subtensor, "subnet_exists", return_value=True) - mocker.patch.object(subtensor, "is_subnet_active", return_value=False) - - # Call - result = await subtensor.get_liquidity_list(wallet=mocker.Mock(), netuid=1) - - # Asserts - subtensor.subnet_exists.assert_awaited_once_with(netuid=1) - subtensor.is_subnet_active.assert_awaited_once_with(netuid=1) - assert result is None - - -@pytest.mark.asyncio -async def test_get_liquidity_list_happy_path(subtensor, fake_wallet, mocker): - """Tests `get_liquidity_list` returns the correct value.""" - # Preps - netuid = 2 - - mocker.patch.object(subtensor, "subnet_exists", return_value=True) - mocker.patch.object(subtensor, "is_subnet_active", return_value=True) - mocker.patch.object(subtensor, "determine_block_hash") - - mocker.patch.object( - async_subtensor, "price_to_tick", return_value=Balance.from_tao(1.0, netuid) - ) - mocker.patch.object( - async_subtensor, - "calculate_fees", - return_value=(Balance.from_tao(0.0), Balance.from_tao(0.0, netuid)), - ) - - mocked_substrate_query_multi = mocker.AsyncMock( - side_effect=[ - [ - (None, {"bits": 0}), - (None, {"bits": 0}), - (None, {"bits": 18446744073709551616}), - ], - [ - ( - None, - { - "liquidity_net": 1000000000000, - "liquidity_gross": 1000000000000, - "fees_out_tao": {"bits": 0}, - "fees_out_alpha": {"bits": 0}, - }, - ), - ( - None, - { - "liquidity_net": -1000000000000, - "liquidity_gross": 1000000000000, - "fees_out_tao": {"bits": 0}, - "fees_out_alpha": {"bits": 0}, - }, - ), - ( - None, - { - "liquidity_net": 1000000000000, - "liquidity_gross": 1000000000000, - "fees_out_tao": {"bits": 0}, - "fees_out_alpha": {"bits": 0}, - }, - ), - ( - None, - { - "liquidity_net": -1000000000000, - "liquidity_gross": 1000000000000, - "fees_out_tao": {"bits": 0}, - "fees_out_alpha": {"bits": 0}, - }, - ), - ( - None, - { - "liquidity_net": 1000000000000, - "liquidity_gross": 1000000000000, - "fees_out_tao": {"bits": 0}, - "fees_out_alpha": {"bits": 0}, - }, - ), - ( - None, - { - "liquidity_net": -1000000000000, - "liquidity_gross": 1000000000000, - "fees_out_tao": {"bits": 0}, - "fees_out_alpha": {"bits": 0}, - }, - ), - ], - ] - ) - - mocker.patch.object( - subtensor.substrate, "query_multi", mocked_substrate_query_multi - ) - - fake_positions = [ - [ - (2,), - { - "id": 2, - "netuid": 2, - "tick_low": 206189, - "tick_high": 208196, - "liquidity": 1000000000000, - "fees_tao": {"bits": 0}, - "fees_alpha": {"bits": 0}, - }, - ], - [ - 2, - { - "id": 2, - "netuid": 2, - "tick_low": 216189, - "tick_high": 198196, - "liquidity": 2000000000000, - "fees_tao": {"bits": 0}, - "fees_alpha": {"bits": 0}, - }, - ], - [ - 2, - { - "id": 2, - "netuid": 2, - "tick_low": 226189, - "tick_high": 188196, - "liquidity": 3000000000000, - "fees_tao": {"bits": 0}, - "fees_alpha": {"bits": 0}, - }, - ], - ] - - fake_result = mocker.AsyncMock(records=fake_positions, autospec=list) - fake_result.__aiter__.return_value = iter(fake_positions) - - mocked_query_map = mocker.AsyncMock(return_value=fake_result) - mocker.patch.object(subtensor, "query_map", new=mocked_query_map) - - # Call - - result = await subtensor.get_liquidity_list(wallet=fake_wallet, netuid=netuid) - - # Asserts - subtensor.determine_block_hash.assert_awaited_once_with( - block=None, block_hash=None, reuse_block=False - ) - assert async_subtensor.price_to_tick.call_count == 1 - assert async_subtensor.calculate_fees.call_count == len(fake_positions) - - mocked_query_map.assert_awaited_once_with( - module="Swap", - name="Positions", - params=[netuid, fake_wallet.coldkeypub.ss58_address], - block=None, - block_hash=None, - reuse_block=False, - ) - assert len(result) == len(fake_positions) - assert all([isinstance(p, async_subtensor.LiquidityPosition) for p in result]) - - @pytest.mark.asyncio async def test_add_liquidity(subtensor, fake_wallet, mocker): """Test add_liquidity extrinsic calls properly.""" @@ -3722,36 +3535,70 @@ async def test_get_subnet_price(subtensor, mocker): @pytest.mark.asyncio async def test_get_subnet_prices(subtensor, mocker): - """Test get_subnet_prices returns the correct value.""" - # preps - mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") - - async def fake_current_sqrt_prices(): - yield [0, {"bits": 0}] - yield [1, {"bits": 3155343338053956962}] - - expected_prices = {0: Balance.from_tao(1), 1: Balance.from_tao(0.029258617)} - mocked_query_map = mocker.patch.object( - subtensor.substrate, "query_map", return_value=fake_current_sqrt_prices() + """Test get_subnet_prices returns the correct value via runtime API.""" + # Preps + fake_block_hash = "0xabc" + mocker.patch.object(subtensor, "determine_block_hash", return_value=fake_block_hash) + mocker.patch.object(subtensor, "_runtime_method_exists", return_value=True) + fake_prices_rao = [ + {"netuid": 0, "price": 1_000_000_000}, + {"netuid": 1, "price": 29_258_617}, + ] + expected_prices = { + 0: Balance.from_rao(1_000_000_000), + 1: Balance.from_rao(29_258_617), + } + mocked_runtime_call = mocker.patch.object( + subtensor.substrate, "runtime_call", return_value=fake_prices_rao ) # Call result = await subtensor.get_subnet_prices() # Asserts - mocked_determine_block_hash.assert_awaited_once_with( - block=None, block_hash=None, reuse_block=False + subtensor._runtime_method_exists.assert_awaited_once_with( + api="SwapRuntimeApi", + method="current_alpha_price_all", + block_hash=fake_block_hash, ) - mocked_query_map.assert_awaited_once_with( - module="Swap", - storage_function="AlphaSqrtPrice", - block_hash=mocked_determine_block_hash.return_value, - page_size=129, # total number of subnets + mocked_runtime_call.assert_awaited_once_with( + api="SwapRuntimeApi", + method="current_alpha_price_all", + block_hash=fake_block_hash, ) - assert result == expected_prices +@pytest.mark.asyncio +async def test_get_subnet_prices_fallback(subtensor, mocker): + """Test get_subnet_prices falls back to per-subnet calls when runtime API is missing.""" + # Preps + fake_block_hash = "0xabc" + mocker.patch.object(subtensor, "determine_block_hash", return_value=fake_block_hash) + mocker.patch.object(subtensor, "_runtime_method_exists", return_value=False) + mocker.patch.object(subtensor, "get_all_subnets_netuid", return_value=[0, 1, 2]) + mocker.patch.object( + subtensor, + "get_subnet_price", + side_effect=[ + Balance.from_tao(1), + Balance.from_rao(29_258_617), + Balance.from_rao(50_000_000), + ], + ) + + # Call + result = await subtensor.get_subnet_prices() + + # Asserts + assert result == { + 0: Balance.from_tao(1), + 1: Balance.from_rao(29_258_617), + 2: Balance.from_rao(50_000_000), + } + assert subtensor.get_subnet_price.await_count == 3 + + @pytest.mark.asyncio async def test_all_subnets(subtensor, mocker): """Verify that `all_subnets` calls proper methods and returns the correct value.""" diff --git a/tests/unit_tests/test_subtensor.py b/tests/unit_tests/test_subtensor.py index d2324d435d..81fad905aa 100644 --- a/tests/unit_tests/test_subtensor.py +++ b/tests/unit_tests/test_subtensor.py @@ -3590,121 +3590,6 @@ def test_unstake_all(subtensor, fake_wallet, mocker): assert result == fake_unstake_all_extrinsic.return_value -def test_get_liquidity_list_subnet_does_not_exits(subtensor, mocker): - """Test get_liquidity_list returns None when subnet doesn't exist.""" - # Preps - mocker.patch.object(subtensor, "subnet_exists", return_value=False) - - # Call - result = subtensor.get_liquidity_list(wallet=mocker.Mock(), netuid=1) - - # Asserts - subtensor.subnet_exists.assert_called_once_with(netuid=1) - assert result is None - - -def test_get_liquidity_list_subnet_is_not_active(subtensor, mocker): - """Test get_liquidity_list returns None when subnet is not active.""" - # Preps - mocker.patch.object(subtensor, "subnet_exists", return_value=True) - mocker.patch.object(subtensor, "is_subnet_active", return_value=False) - - # Call - result = subtensor.get_liquidity_list(wallet=mocker.Mock(), netuid=1) - - # Asserts - subtensor.subnet_exists.assert_called_once_with(netuid=1) - subtensor.is_subnet_active.assert_called_once_with(netuid=1) - assert result is None - - -def test_get_liquidity_list_happy_path(subtensor, fake_wallet, mocker): - """Tests `get_liquidity_list` returns the correct value.""" - netuid = 2 - - # Mock network state - mocker.patch.object(subtensor, "subnet_exists", return_value=True) - mocker.patch.object(subtensor, "is_subnet_active", return_value=True) - mocker.patch.object(subtensor, "determine_block_hash", return_value="0x1234") - - # Mock price and fee calculation - mocker.patch.object(subtensor_module, "price_to_tick", return_value=100) - mocker.patch.object( - subtensor_module, - "calculate_fees", - return_value=(Balance.from_tao(0.0), Balance.from_tao(0.0, netuid)), - ) - - # Fake positions to return from query_map - fake_positions = [ - [ - 2, - { - "id": 2, - "netuid": 2, - "tick_low": 206189, - "tick_high": 208196, - "liquidity": 1000000000000, - "fees_tao": {"bits": 0}, - "fees_alpha": {"bits": 0}, - }, - ], - ] - fake_result = mocker.MagicMock(records=fake_positions, autospec=list) - fake_result.__iter__.return_value = iter(fake_positions) - - mocked_query_map = mocker.Mock(return_value=fake_result) - mocker.patch.object(subtensor, "query_map", new=mocked_query_map) - - # Mock storage key creation - mocker.patch.object( - subtensor.substrate, - "create_storage_key", - side_effect=lambda pallet, storage_function, params, block_hash=None: ( - f"{pallet}:{storage_function}:{params}" - ), - ) - - # Mock query_multi for fee + sqrt_price + tick data - mock_query_multi = mocker.MagicMock( - side_effect=[ - [ - ("key1", {"bits": 0}), # fee_global_tao - ("key2", {"bits": 0}), # fee_global_alpha - ("key3", {"bits": 1072693248}), - ], - [ - ( - "tick_low", - {"fees_out_tao": {"bits": 0}, "fees_out_alpha": {"bits": 0}}, - ), - ( - "tick_high", - {"fees_out_tao": {"bits": 0}, "fees_out_alpha": {"bits": 0}}, - ), - ], - ] - ) - mocker.patch.object(subtensor.substrate, "query_multi", new=mock_query_multi) - - # Call - result = subtensor.get_liquidity_list(wallet=fake_wallet, netuid=netuid) - - # Asserts - assert subtensor.determine_block_hash.call_count == 1 - assert subtensor_module.price_to_tick.call_count == 1 - assert subtensor_module.calculate_fees.call_count == len(fake_positions) - mocked_query_map.assert_called_once_with( - module="Swap", - name="Positions", - params=[netuid, fake_wallet.coldkeypub.ss58_address], - block=None, - ) - assert mock_query_multi.call_count == 2 # one for fees, one for ticks - assert len(result) == len(fake_positions) - assert all(isinstance(p, subtensor_module.LiquidityPosition) for p in result) - - def test_add_liquidity(subtensor, fake_wallet, mocker): """Test add_liquidity extrinsic calls properly.""" # preps @@ -3868,32 +3753,69 @@ def test_get_subnet_price(subtensor, mocker): def test_get_subnet_prices(subtensor, mocker): - """Test get_subnet_prices returns the correct value.""" - # preps - mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") - fake_prices = [ - [0, {"bits": 0}], - [1, {"bits": 3155343338053956962}], + """Test get_subnet_prices returns the correct value via runtime API.""" + # Preps + fake_block_hash = "0xabc" + mocker.patch.object(subtensor, "determine_block_hash", return_value=fake_block_hash) + mocker.patch.object(subtensor, "_runtime_method_exists", return_value=True) + fake_prices_rao = [ + {"netuid": 0, "price": 1_000_000_000}, + {"netuid": 1, "price": 29_258_617}, ] - expected_prices = {0: Balance.from_tao(1), 1: Balance.from_tao(0.029258617)} - mocked_query_map = mocker.patch.object( - subtensor.substrate, "query_map", return_value=fake_prices + expected_prices = { + 0: Balance.from_rao(1_000_000_000), + 1: Balance.from_rao(29_258_617), + } + mocked_runtime_call = mocker.patch.object( + subtensor.substrate, "runtime_call", return_value=fake_prices_rao ) # Call result = subtensor.get_subnet_prices() # Asserts - mocked_determine_block_hash.assert_called_once_with(block=None) - mocked_query_map.assert_called_once_with( - module="Swap", - storage_function="AlphaSqrtPrice", - block_hash=mocked_determine_block_hash.return_value, - page_size=129, # total number of subnets + subtensor._runtime_method_exists.assert_called_once_with( + api="SwapRuntimeApi", + method="current_alpha_price_all", + block_hash=fake_block_hash, + ) + mocked_runtime_call.assert_called_once_with( + api="SwapRuntimeApi", + method="current_alpha_price_all", + block_hash=fake_block_hash, ) assert result == expected_prices +def test_get_subnet_prices_fallback(subtensor, mocker): + """Test get_subnet_prices falls back to per-subnet calls when runtime API is missing.""" + # Preps + fake_block_hash = "0xabc" + mocker.patch.object(subtensor, "determine_block_hash", return_value=fake_block_hash) + mocker.patch.object(subtensor, "_runtime_method_exists", return_value=False) + mocker.patch.object(subtensor, "get_all_subnets_netuid", return_value=[0, 1, 2]) + mocker.patch.object( + subtensor, + "get_subnet_price", + side_effect=[ + Balance.from_tao(1), + Balance.from_rao(29_258_617), + Balance.from_rao(50_000_000), + ], + ) + + # Call + result = subtensor.get_subnet_prices() + + # Asserts + assert result == { + 0: Balance.from_tao(1), + 1: Balance.from_rao(29_258_617), + 2: Balance.from_rao(50_000_000), + } + assert subtensor.get_subnet_price.call_count == 3 + + def test_all_subnets(subtensor, mocker): """Verify that `all_subnets` calls proper methods and returns the correct value.""" # Preps From b86d31e65ca5fc679004410bf504ab9d80ee0a16 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Tue, 26 May 2026 20:18:28 -0700 Subject: [PATCH 04/59] update `bittensor/extras/dev_framework/calls` --- .../extras/dev_framework/calls/non_sudo_calls.py | 11 ++++++++++- bittensor/extras/dev_framework/calls/pallets.py | 2 +- .../extras/dev_framework/calls/sudo_calls.py | 16 +++++++++++++++- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/bittensor/extras/dev_framework/calls/non_sudo_calls.py b/bittensor/extras/dev_framework/calls/non_sudo_calls.py index 537351c5c7..a7fbbafd91 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: 397 + Subtensor spec version: 408 """ from collections import namedtuple @@ -430,6 +430,9 @@ KILL_STORAGE = namedtuple( "KILL_STORAGE", ["wallet", "pallet", "keys"] ) # args: [keys: Vec] | Pallet: System +LOCK_STAKE = namedtuple( + "LOCK_STAKE", ["wallet", "pallet", "hotkey", "netuid", "amount"] +) # args: [hotkey: T::AccountId, netuid: NetUid, amount: AlphaBalance] | Pallet: SubtensorModule MIGRATE = namedtuple( "MIGRATE", ["wallet", "pallet", "weight_limit"] ) # args: [weight_limit: Weight] | Pallet: Contracts @@ -437,6 +440,9 @@ "MODIFY_POSITION", ["wallet", "pallet", "hotkey", "netuid", "position_id", "liquidity_delta"], ) # args: [hotkey: T::AccountId, netuid: NetUid, position_id: PositionId, liquidity_delta: i64] | Pallet: Swap +MOVE_LOCK = namedtuple( + "MOVE_LOCK", ["wallet", "pallet", "destination_hotkey", "netuid"] +) # args: [destination_hotkey: T::AccountId, netuid: NetUid] | Pallet: SubtensorModule MOVE_STAKE = namedtuple( "MOVE_STAKE", [ @@ -727,6 +733,9 @@ SET_PENDING_CHILDKEY_COOLDOWN = namedtuple( "SET_PENDING_CHILDKEY_COOLDOWN", ["wallet", "pallet", "cooldown"] ) # args: [cooldown: u64] | Pallet: SubtensorModule +SET_PERPETUAL_LOCK = namedtuple( + "SET_PERPETUAL_LOCK", ["wallet", "pallet", "netuid", "enabled"] +) # args: [netuid: NetUid, enabled: bool] | Pallet: SubtensorModule SET_REAL_PAYS_FEE = namedtuple( "SET_REAL_PAYS_FEE", ["wallet", "pallet", "delegate", "pays_fee"] ) # args: [delegate: AccountIdLookupOf, pays_fee: bool] | Pallet: Proxy diff --git a/bittensor/extras/dev_framework/calls/pallets.py b/bittensor/extras/dev_framework/calls/pallets.py index 1ffef1da5d..d35ca501e2 100644 --- a/bittensor/extras/dev_framework/calls/pallets.py +++ b/bittensor/extras/dev_framework/calls/pallets.py @@ -1,5 +1,5 @@ """ " -Subtensor spec version: 397 +Subtensor spec version: 408 """ System = "System" diff --git a/bittensor/extras/dev_framework/calls/sudo_calls.py b/bittensor/extras/dev_framework/calls/sudo_calls.py index d7b5a0cdc4..3d3be533ee 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: 397 + Subtensor spec version: 408 """ from collections import namedtuple @@ -157,6 +157,10 @@ SUDO_SET_MIN_CHILDKEY_TAKE = namedtuple( "SUDO_SET_MIN_CHILDKEY_TAKE", ["wallet", "pallet", "sudo", "take"] ) # args: [take: u16] | Pallet: SubtensorModule +SUDO_SET_MIN_CHILDKEY_TAKE_PER_SUBNET = namedtuple( + "SUDO_SET_MIN_CHILDKEY_TAKE_PER_SUBNET", + ["wallet", "pallet", "sudo", "netuid", "take"], +) # args: [netuid: NetUid, take: u16] | Pallet: AdminUtils SUDO_SET_MIN_DELEGATE_TAKE = namedtuple( "SUDO_SET_MIN_DELEGATE_TAKE", ["wallet", "pallet", "sudo", "take"] ) # args: [take: u16] | Pallet: AdminUtils @@ -183,12 +187,18 @@ "SUDO_SET_NETWORK_REGISTRATION_ALLOWED", ["wallet", "pallet", "sudo", "netuid", "registration_allowed"], ) # args: [netuid: NetUid, registration_allowed: bool] | Pallet: AdminUtils +SUDO_SET_NET_TAO_FLOW_ENABLED = namedtuple( + "SUDO_SET_NET_TAO_FLOW_ENABLED", ["wallet", "pallet", "sudo", "enabled"] +) # args: [enabled: bool] | Pallet: AdminUtils SUDO_SET_NOMINATOR_MIN_REQUIRED_STAKE = namedtuple( "SUDO_SET_NOMINATOR_MIN_REQUIRED_STAKE", ["wallet", "pallet", "sudo", "min_stake"] ) # args: [min_stake: u64] | Pallet: AdminUtils 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_ENABLED = namedtuple( + "SUDO_SET_OWNER_CUT_ENABLED", ["wallet", "pallet", "sudo", "netuid", "enabled"] +) # args: [netuid: NetUid, enabled: bool] | Pallet: AdminUtils SUDO_SET_OWNER_HPARAM_RATE_LIMIT = namedtuple( "SUDO_SET_OWNER_HPARAM_RATE_LIMIT", ["wallet", "pallet", "sudo", "epochs"] ) # args: [epochs: u16] | Pallet: AdminUtils @@ -222,6 +232,10 @@ SUDO_SET_START_CALL_DELAY = namedtuple( "SUDO_SET_START_CALL_DELAY", ["wallet", "pallet", "sudo", "delay"] ) # args: [delay: u64] | Pallet: AdminUtils +SUDO_SET_SUBNET_EMISSION_ENABLED = namedtuple( + "SUDO_SET_SUBNET_EMISSION_ENABLED", + ["wallet", "pallet", "sudo", "netuid", "enabled"], +) # args: [netuid: NetUid, enabled: bool] | Pallet: AdminUtils SUDO_SET_SUBNET_LIMIT = namedtuple( "SUDO_SET_SUBNET_LIMIT", ["wallet", "pallet", "sudo", "max_subnets"] ) # args: [max_subnets: u16] | Pallet: AdminUtils From 7bf2cdb27f4275bb15e138f72c2b4c09799ea453 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Thu, 28 May 2026 13:41:09 -0700 Subject: [PATCH 05/59] fix wrong casting --- bittensor/core/async_subtensor.py | 2 +- bittensor/core/subtensor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bittensor/core/async_subtensor.py b/bittensor/core/async_subtensor.py index d23295bfeb..aa7899ffa2 100644 --- a/bittensor/core/async_subtensor.py +++ b/bittensor/core/async_subtensor.py @@ -4692,7 +4692,7 @@ async def get_subnet_prices( block_hash=block_hash, ): prices_rao = cast( - dict, + list[dict], await self.substrate.runtime_call( api="SwapRuntimeApi", method="current_alpha_price_all", diff --git a/bittensor/core/subtensor.py b/bittensor/core/subtensor.py index e58cdb9edf..f1ce368cfb 100644 --- a/bittensor/core/subtensor.py +++ b/bittensor/core/subtensor.py @@ -3841,7 +3841,7 @@ def get_subnet_prices( block_hash=block_hash, ): prices_rao = cast( - dict, + list[dict], self.substrate.runtime_call( api="SwapRuntimeApi", method="current_alpha_price_all", From 0d988f158eb9ed3d3da6b0447e09765b6f139f8e Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Thu, 28 May 2026 14:09:47 -0700 Subject: [PATCH 06/59] fixes --- bittensor/core/extrinsics/asyncex/liquidity.py | 13 ++++++++++++- bittensor/core/extrinsics/liquidity.py | 13 ++++++++++++- bittensor/core/extrinsics/pallets/swap.py | 1 + 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/bittensor/core/extrinsics/asyncex/liquidity.py b/bittensor/core/extrinsics/asyncex/liquidity.py index 54cb6b4a45..a035e90252 100644 --- a/bittensor/core/extrinsics/asyncex/liquidity.py +++ b/bittensor/core/extrinsics/asyncex/liquidity.py @@ -1,8 +1,9 @@ # TODO: remove this module in the next major release (include all references) -from typing import TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from bittensor.core.types import ExtrinsicResponse from bittensor.utils import ChainFeatureDisabledWarning, deprecated_message +from bittensor.utils.balance import Balance if TYPE_CHECKING: from bittensor_wallet import Wallet @@ -18,6 +19,10 @@ async def add_liquidity_extrinsic( subtensor: "AsyncSubtensor", wallet: "Wallet", netuid: int, + liquidity: Optional[Balance] = None, + price_low: Optional[Balance] = None, + price_high: Optional[Balance] = None, + hotkey_ss58: Optional[str] = None, **kwargs, ) -> ExtrinsicResponse: """Deprecated. User liquidity has been permanently removed from the chain.""" @@ -33,6 +38,9 @@ async def modify_liquidity_extrinsic( subtensor: "AsyncSubtensor", wallet: "Wallet", netuid: int, + position_id: Optional[int] = None, + liquidity_delta: Optional[Balance] = None, + hotkey_ss58: Optional[str] = None, **kwargs, ) -> ExtrinsicResponse: """Deprecated. User liquidity has been permanently removed from the chain.""" @@ -48,6 +56,8 @@ async def remove_liquidity_extrinsic( subtensor: "AsyncSubtensor", wallet: "Wallet", netuid: int, + position_id: Optional[int] = None, + hotkey_ss58: Optional[str] = None, **kwargs, ) -> ExtrinsicResponse: """Deprecated. User liquidity has been permanently removed from the chain.""" @@ -63,6 +73,7 @@ async def toggle_user_liquidity_extrinsic( subtensor: "AsyncSubtensor", wallet: "Wallet", netuid: int, + enable: Optional[bool] = None, **kwargs, ) -> ExtrinsicResponse: """Deprecated. User liquidity has been permanently removed from the chain.""" diff --git a/bittensor/core/extrinsics/liquidity.py b/bittensor/core/extrinsics/liquidity.py index 7c6dcb4734..b55c9c90ea 100644 --- a/bittensor/core/extrinsics/liquidity.py +++ b/bittensor/core/extrinsics/liquidity.py @@ -1,8 +1,9 @@ # TODO: remove this module in the next major release (include all references) -from typing import TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from bittensor.core.types import ExtrinsicResponse from bittensor.utils import ChainFeatureDisabledWarning, deprecated_message +from bittensor.utils.balance import Balance if TYPE_CHECKING: from bittensor_wallet import Wallet @@ -18,6 +19,10 @@ def add_liquidity_extrinsic( subtensor: "Subtensor", wallet: "Wallet", netuid: int, + liquidity: Optional[Balance] = None, + price_low: Optional[Balance] = None, + price_high: Optional[Balance] = None, + hotkey_ss58: Optional[str] = None, **kwargs, ) -> ExtrinsicResponse: """Deprecated. User liquidity has been permanently removed from the chain.""" @@ -33,6 +38,9 @@ def modify_liquidity_extrinsic( subtensor: "Subtensor", wallet: "Wallet", netuid: int, + position_id: Optional[int] = None, + liquidity_delta: Optional[Balance] = None, + hotkey_ss58: Optional[str] = None, **kwargs, ) -> ExtrinsicResponse: """Deprecated. User liquidity has been permanently removed from the chain.""" @@ -48,6 +56,8 @@ def remove_liquidity_extrinsic( subtensor: "Subtensor", wallet: "Wallet", netuid: int, + position_id: Optional[int] = None, + hotkey_ss58: Optional[str] = None, **kwargs, ) -> ExtrinsicResponse: """Deprecated. User liquidity has been permanently removed from the chain.""" @@ -63,6 +73,7 @@ def toggle_user_liquidity_extrinsic( subtensor: "Subtensor", wallet: "Wallet", netuid: int, + enable: Optional[bool] = None, **kwargs, ) -> ExtrinsicResponse: """Deprecated. User liquidity has been permanently removed from the chain.""" diff --git a/bittensor/core/extrinsics/pallets/swap.py b/bittensor/core/extrinsics/pallets/swap.py index 766452c48c..61f0dbe1d5 100644 --- a/bittensor/core/extrinsics/pallets/swap.py +++ b/bittensor/core/extrinsics/pallets/swap.py @@ -1,3 +1,4 @@ +# TODO: remove this module in the next major release (include all references) from dataclasses import dataclass from typing import Optional From 6adab5373ccf0a5c891c35f8d6904d552f733be5 Mon Sep 17 00:00:00 2001 From: Yupsecous Date: Wed, 3 Jun 2026 07:32:20 +1000 Subject: [PATCH 07/59] Forward version_key from commit_weights Subtensor.commit_weights and AsyncSubtensor.commit_weights accepted a version_key but never passed it to commit_weights_extrinsic, so the commit hash was built with the default version_key. A reveal made with the intended version_key then mismatched the committed hash and was rejected on-chain. Forward version_key=version_key in both wrappers, matching how reveal_weights already does. Behavior-preserving for the default (version_as_int); adds sync+async forwarding tests and sync+async symptom-level commit/reveal hash-equality tests. --- bittensor/core/async_subtensor.py | 1 + bittensor/core/subtensor.py | 1 + tests/unit_tests/test_async_subtensor.py | 98 ++++++++++++++++++++++++ tests/unit_tests/test_subtensor.py | 93 ++++++++++++++++++++++ 4 files changed, 193 insertions(+) diff --git a/bittensor/core/async_subtensor.py b/bittensor/core/async_subtensor.py index c83cd30a80..99758d30d6 100644 --- a/bittensor/core/async_subtensor.py +++ b/bittensor/core/async_subtensor.py @@ -7041,6 +7041,7 @@ async def commit_weights( uids=uids, weights=weights, salt=salt, + version_key=version_key, mev_protection=mev_protection, period=period, raise_error=raise_error, diff --git a/bittensor/core/subtensor.py b/bittensor/core/subtensor.py index 59f6207cd5..48fe5dd3c4 100644 --- a/bittensor/core/subtensor.py +++ b/bittensor/core/subtensor.py @@ -5846,6 +5846,7 @@ def commit_weights( uids=uids, weights=weights, salt=salt, + version_key=version_key, mev_protection=mev_protection, period=period, raise_error=raise_error, diff --git a/tests/unit_tests/test_async_subtensor.py b/tests/unit_tests/test_async_subtensor.py index 9a6e6d867d..b4194f269e 100644 --- a/tests/unit_tests/test_async_subtensor.py +++ b/tests/unit_tests/test_async_subtensor.py @@ -4885,6 +4885,104 @@ async def test_get_crowdloans(mocker, subtensor): assert result == [mocked_decode_crowdloan_entry.return_value] +@pytest.mark.asyncio +async def test_commit_weights_forwards_version_key(subtensor, fake_wallet, mocker): + """Regression: async commit_weights must forward the caller's version_key to the + extrinsic. If dropped, the commit hash is built with the default version_key and + a reveal made with the intended version_key mismatches on-chain.""" + # Preps + netuid = 1 + uids = [1, 2, 3, 4] + weights = [10, 20, 30, 40] + salt = [4, 2, 2, 1] + custom_version_key = settings.version_as_int + 1 + mocked_extrinsic = mocker.AsyncMock(return_value=ExtrinsicResponse(True, None)) + mocker.patch.object(async_subtensor, "commit_weights_extrinsic", mocked_extrinsic) + + # Call + await subtensor.commit_weights( + wallet=fake_wallet, + netuid=netuid, + uids=uids, + weights=weights, + salt=salt, + version_key=custom_version_key, + ) + + # Assertions + assert mocked_extrinsic.await_count == 1 + assert mocked_extrinsic.call_args.kwargs["version_key"] == custom_version_key + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "version_key", + [settings.version_as_int, settings.version_as_int + 7], + ids=["default_version_key", "custom_version_key"], +) +async def test_commit_weights_hash_matches_reveal( + subtensor, fake_wallet, mocker, version_key +): + """Symptom-level (async): the commit hash built by AsyncSubtensor.commit_weights + (version_key=X) must equal the hash the chain recomputes at reveal for the same + inputs and X (reveal_weights forwards version_key). Pre-fix the async wrapper + dropped X and hashed with the default, so a custom X produced a mismatching commit + hash that a reveal would be rejected against. Drives the REAL async extrinsic + (commit_weights_extrinsic is NOT mocked) so it guards asyncex/weights.py's hashing.""" + from bittensor.utils.weight_utils import generate_weight_hash as real_ghash + + fake_wallet.hotkey.ss58_address = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + uids = [1, 2, 3] + weights = [10, 20, 30] + salt = [4, 2, 2, 1] + + captured = {} + + def spy(**kwargs): + result = real_ghash(**kwargs) + captured.update(kwargs) + captured["result"] = result + return result + + mocker.patch( + "bittensor.core.extrinsics.asyncex.weights.generate_weight_hash", + side_effect=spy, + ) + # Neutralize wallet unlock + chain submission so the extrinsic runs through hashing. + mocker.patch.object( + ExtrinsicResponse, "unlock_wallet", return_value=mocker.Mock(success=True) + ) + mock_sm = mocker.patch("bittensor.core.extrinsics.asyncex.weights.SubtensorModule") + mock_sm.return_value.commit_mechanism_weights = mocker.AsyncMock() + mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + mocker.AsyncMock(return_value=ExtrinsicResponse(True, None)), + ) + + # Call (force the non-MEV path). + await subtensor.commit_weights( + wallet=fake_wallet, + netuid=1, + uids=uids, + weights=weights, + salt=salt, + version_key=version_key, + mev_protection=False, + ) + + # Hash actually committed by commit_weights ... + commit_hash = captured["result"] + # ... vs the hash the chain recomputes at reveal for the same inputs + intended + # version_key (reusing the exact non-version_key args the commit path passed). + reveal_args = { + k: v for k, v in captured.items() if k not in ("version_key", "result") + } + reveal_hash = real_ghash(**reveal_args, version_key=version_key) + + assert commit_hash == reveal_hash + + @pytest.mark.parametrize( "method, add_salt", [ diff --git a/tests/unit_tests/test_subtensor.py b/tests/unit_tests/test_subtensor.py index 550a59732d..565fce121e 100644 --- a/tests/unit_tests/test_subtensor.py +++ b/tests/unit_tests/test_subtensor.py @@ -1711,6 +1711,99 @@ def test_reveal_weights(subtensor, fake_wallet, mocker): ) +def test_commit_weights_forwards_version_key(subtensor, fake_wallet, mocker): + """Regression: commit_weights must forward the caller's version_key to the + extrinsic. If dropped, the commit hash is built with the default version_key + and a reveal made with the intended version_key mismatches on-chain.""" + # Preps + netuid = 1 + uids = [1, 2, 3, 4] + weights = [10, 20, 30, 40] + salt = [4, 2, 2, 1] + custom_version_key = version_as_int + 1 + mocked_extrinsic = mocker.patch.object( + subtensor_module, + "commit_weights_extrinsic", + return_value=ExtrinsicResponse(True, None), + ) + + # Call + subtensor.commit_weights( + wallet=fake_wallet, + netuid=netuid, + uids=uids, + weights=weights, + salt=salt, + version_key=custom_version_key, + ) + + # Assertions + assert mocked_extrinsic.call_count == 1 + assert mocked_extrinsic.call_args.kwargs["version_key"] == custom_version_key + + +@pytest.mark.parametrize( + "version_key", + [version_as_int, version_as_int + 7], + ids=["default_version_key", "custom_version_key"], +) +def test_commit_weights_hash_matches_reveal( + subtensor, fake_wallet, mocker, version_key +): + """Symptom-level: the commit hash built by commit_weights(version_key=X) must equal + the hash the chain recomputes at reveal for the same inputs and X (reveal_weights + forwards version_key). Pre-fix, commit dropped X and hashed with the default, so a + custom X produced a mismatching commit hash that a reveal would be rejected against.""" + from bittensor.utils.weight_utils import generate_weight_hash as real_ghash + + fake_wallet.hotkey.ss58_address = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + uids = [1, 2, 3] + weights = [10, 20, 30] + salt = [4, 2, 2, 1] + + captured = {} + + def spy(**kwargs): + result = real_ghash(**kwargs) + captured.update(kwargs) + captured["result"] = result + return result + + mocker.patch( + "bittensor.core.extrinsics.weights.generate_weight_hash", side_effect=spy + ) + # Neutralize wallet unlock + chain submission so the extrinsic runs through hashing. + mocker.patch.object( + ExtrinsicResponse, "unlock_wallet", return_value=mocker.Mock(success=True) + ) + mocker.patch("bittensor.core.extrinsics.weights.SubtensorModule") + mocker.patch.object( + subtensor, "sign_and_send_extrinsic", return_value=ExtrinsicResponse(True, None) + ) + + # Call (force the non-MEV path). + subtensor.commit_weights( + wallet=fake_wallet, + netuid=1, + uids=uids, + weights=weights, + salt=salt, + version_key=version_key, + mev_protection=False, + ) + + # Hash actually committed by commit_weights ... + commit_hash = captured["result"] + # ... vs the hash the chain recomputes at reveal for the same inputs + intended + # version_key (reusing the exact non-version_key args the commit path passed). + reveal_args = { + k: v for k, v in captured.items() if k not in ("version_key", "result") + } + reveal_hash = real_ghash(**reveal_args, version_key=version_key) + + assert commit_hash == reveal_hash + + def test_reveal_weights_false(subtensor, fake_wallet, mocker): """Failed test_reveal_weights call.""" # Preps From 606e3006d4f461414ac1f664c1c290c63a03d702 Mon Sep 17 00:00:00 2001 From: Dera Okeke Date: Mon, 8 Jun 2026 16:34:38 +0100 Subject: [PATCH 08/59] docs: updated kill pure docs --- bittensor/core/async_subtensor.py | 5 ++--- bittensor/core/extrinsics/asyncex/proxy.py | 5 ++--- bittensor/core/extrinsics/proxy.py | 5 ++--- bittensor/core/subtensor.py | 5 ++--- 4 files changed, 8 insertions(+), 12 deletions(-) diff --git a/bittensor/core/async_subtensor.py b/bittensor/core/async_subtensor.py index 99758d30d6..d790d5661a 100644 --- a/bittensor/core/async_subtensor.py +++ b/bittensor/core/async_subtensor.py @@ -7491,9 +7491,8 @@ async def kill_pure_proxy( method automatically handles this by executing the call via :meth:`proxy`. Parameters: - wallet: Bittensor wallet object. The wallet.coldkey.ss58_address must be the spawner of the pure proxy (the - account that created it via :meth:`create_pure_proxy`). The spawner must have an "Any" proxy relationship - with the pure proxy. + wallet: Bittensor wallet object. The wallet.coldkey.ss58_address can either be the spawner or an account + with an "Any" proxy relationship to the pure proxy. pure_proxy_ss58: The SS58 address of the pure proxy account to be killed. This is the address that was returned in the :meth:`create_pure_proxy` response. spawner: The SS58 address of the spawner account (the account that originally created the pure proxy via diff --git a/bittensor/core/extrinsics/asyncex/proxy.py b/bittensor/core/extrinsics/asyncex/proxy.py index bb6848b0ea..0b08fab944 100644 --- a/bittensor/core/extrinsics/asyncex/proxy.py +++ b/bittensor/core/extrinsics/asyncex/proxy.py @@ -466,9 +466,8 @@ async def kill_pure_proxy_extrinsic( Parameters: subtensor: Subtensor instance with the connection to the chain. - wallet: Bittensor wallet object. The `wallet.coldkey.ss58_address` must be the spawner of the pure proxy (the - account that created it via :meth:`create_pure_proxy_extrinsic`). The spawner must have an `Any` proxy - relationship with the pure proxy. + wallet: Bittensor wallet object. The `wallet.coldkey.ss58_address` The wallet.coldkey.ss58_address can either be the spawner or an account + with an "Any" proxy relationship to the pure proxy. pure_proxy_ss58: The SS58 address of the pure proxy account to be killed. This is the address that was returned in the :meth:`create_pure_proxy_extrinsic` response. spawner: The SS58 address of the spawner account (the account that originally created the pure proxy via diff --git a/bittensor/core/extrinsics/proxy.py b/bittensor/core/extrinsics/proxy.py index 42766eb0e3..580366e466 100644 --- a/bittensor/core/extrinsics/proxy.py +++ b/bittensor/core/extrinsics/proxy.py @@ -463,9 +463,8 @@ def kill_pure_proxy_extrinsic( Parameters: subtensor: Subtensor instance with the connection to the chain. - wallet: Bittensor wallet object. The `wallet.coldkey.ss58_address` must be the spawner of the pure proxy (the - account that created it via :meth:`create_pure_proxy_extrinsic`). The spawner must have an `Any` proxy - relationship with the pure proxy. + wallet: Bittensor wallet object. The wallet.coldkey.ss58_address can either be the spawner or an account + with an "Any" proxy relationship to the pure proxy. pure_proxy_ss58: The SS58 address of the pure proxy account to be killed. This is the address that was returned in the :meth:`create_pure_proxy_extrinsic` response. spawner: The SS58 address of the spawner account (the account that originally created the pure proxy via diff --git a/bittensor/core/subtensor.py b/bittensor/core/subtensor.py index 48fe5dd3c4..2e068d9a76 100644 --- a/bittensor/core/subtensor.py +++ b/bittensor/core/subtensor.py @@ -6289,9 +6289,8 @@ def kill_pure_proxy( method automatically handles this by executing the call via :meth:`proxy`. Parameters: - wallet: Bittensor wallet object. The wallet.coldkey.ss58_address must be the spawner of the pure proxy (the - account that created it via :meth:`create_pure_proxy`). The spawner must have an "Any" proxy relationship - with the pure proxy. + wallet: Bittensor wallet object. The wallet.coldkey.ss58_address can either be the spawner or an account + with an "Any" proxy relationship to the pure proxy. pure_proxy_ss58: The SS58 address of the pure proxy account to be killed. This is the address that was returned in the :meth:`create_pure_proxy` response. spawner: The SS58 address of the spawner account (the account that originally created the pure proxy via From 608722f77cab909a8ac6e72c485059c14fc130fb Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Mon, 8 Jun 2026 12:10:06 -0700 Subject: [PATCH 09/59] improve price calculation in DynamicInfo --- bittensor/core/chain_data/dynamic_info.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bittensor/core/chain_data/dynamic_info.py b/bittensor/core/chain_data/dynamic_info.py index 92373ecf1b..b10e0d55ff 100644 --- a/bittensor/core/chain_data/dynamic_info.py +++ b/bittensor/core/chain_data/dynamic_info.py @@ -113,6 +113,8 @@ def _from_dict(cls, decoded: dict) -> "DynamicInfo": price if price is not None else Balance.from_tao(tao_in.tao / alpha_in.tao).set_unit(netuid) + if alpha_in.tao + else Balance.from_tao(1 if netuid == 0 else 0).set_unit(netuid) ), alpha_out_emission=alpha_out_emission, alpha_in_emission=alpha_in_emission, From f793a405d099d2a682793a299da6345ab3b3bcf7 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Mon, 8 Jun 2026 12:14:11 -0700 Subject: [PATCH 10/59] remove debug wrapper for mypy check --- bittensor/core/async_subtensor.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bittensor/core/async_subtensor.py b/bittensor/core/async_subtensor.py index 3a3a80c06e..90a3a4ff8d 100644 --- a/bittensor/core/async_subtensor.py +++ b/bittensor/core/async_subtensor.py @@ -4699,9 +4699,7 @@ async def get_subnet_prices( block_hash=block_hash, ), ) - return { - int(p["netuid"]): Balance.from_rao(int(p["price"])) for p in prices_rao - } + return {p["netuid"]: Balance.from_rao(p["price"]) for p in prices_rao} netuids = await self.get_all_subnets_netuid( block=block, block_hash=block_hash, reuse_block=reuse_block From 2a2aaa53754c55ba950831e34dbd46bb57a188f4 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Mon, 8 Jun 2026 12:30:47 -0700 Subject: [PATCH 11/59] improve async `determine_block_hash` logic --- bittensor/core/async_subtensor.py | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/bittensor/core/async_subtensor.py b/bittensor/core/async_subtensor.py index d790d5661a..b3e3ca192a 100644 --- a/bittensor/core/async_subtensor.py +++ b/bittensor/core/async_subtensor.py @@ -462,11 +462,12 @@ async def determine_block_hash( for blockchain queries. Parameter precedence (in order): - 1. If `reuse_block=True` and `block` or `block_hash` is set → raises ValueError - 2. If both `block` and `block_hash` are set → validates they match, raises ValueError if not - 3. If only `block_hash` is set → returns it directly - 4. If only `block` is set → fetches and returns its hash - 5. If none are set → returns `None` + 1. ``block`` + ``block_hash`` → validate they agree, return hash + 2. ``block_hash`` only → return it (``reuse_block`` is ignored if also set) + 3. ``block`` + ``reuse_block`` → raises ValueError + 4. ``block`` only → fetch hash for the block number + 5. ``reuse_block`` only → return ``substrate.last_block_hash`` (may be None) + 6. none set → return None (chain tip) Parameters: block: The block number to get the hash for. If specifying along with `block_hash`, the hash of `block` @@ -482,9 +483,8 @@ async def determine_block_hash( Notes: - """ - if reuse_block and any([block, block_hash]): - raise ValueError("Cannot specify both reuse_block and block_hash/block") - if block and block_hash: + # 1. Explicit pair: strongest signal, validate consistency + if block is not None and block_hash is not None: retrieved_block_hash = await self.get_block_hash(block) if retrieved_block_hash != block_hash: raise ValueError( @@ -492,16 +492,20 @@ async def determine_block_hash( f"the one you supplied. You supplied `block_hash={block_hash}` for `block={block}`, but this block" f"maps to the block hash {retrieved_block_hash}." ) - else: - return retrieved_block_hash - - # Return the appropriate value. - if block_hash: + return retrieved_block_hash + # 2. Explicit hash wins; reuse_block is redundant after parent resolution + if block_hash is not None: return block_hash + # 3. Real ambiguity: block number vs reuse flag, no hash to disambiguate + if block is not None and reuse_block: + raise ValueError("Cannot specify both reuse_block and block") + # 4. Explicit block number if block is not None: return await self.get_block_hash(block) + # 5. Reuse last queried block if reuse_block: return self.substrate.last_block_hash + # 6. Chain tip return None async def _runtime_method_exists( From ae02ad2c3c50b6d440e32a8cdbb44966ed9ab32c Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Mon, 8 Jun 2026 12:31:12 -0700 Subject: [PATCH 12/59] update\extend unit tests --- tests/unit_tests/test_async_subtensor.py | 54 +++++++++++++++++------- 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/tests/unit_tests/test_async_subtensor.py b/tests/unit_tests/test_async_subtensor.py index b4194f269e..e021e4f7e3 100644 --- a/tests/unit_tests/test_async_subtensor.py +++ b/tests/unit_tests/test_async_subtensor.py @@ -4410,34 +4410,58 @@ async def test_set_auto_stake(subtensor, mocker): @pytest.mark.asyncio -async def test_determine_block_hash(subtensor, mocker): - """Tests that `determine_block_hash` calls proper methods and returns the correct value.""" +async def test_determine_block_hash(subtensor): + """Tests determine_block_hash precedence and validation rules.""" async def fake_get_block_hash(block: int) -> str: - d = { - 1: "0xfake1", - 2: "0xfake2", - } - return d[block] + return {1: "0xfake1", 2: "0xfake2"}[block] subtensor.get_block_hash = fake_get_block_hash + subtensor.substrate.last_block_hash = "0xOTHER" - # Call mocked_hash = await subtensor.get_block_hash(block=1) - expected_hash_1 = await subtensor.determine_block_hash(block_hash=mocked_hash) - assert mocked_hash == expected_hash_1 + # block_hash only + assert await subtensor.determine_block_hash(block_hash=mocked_hash) == mocked_hash - expected_hash_2 = await subtensor.determine_block_hash( - block=1, block_hash=mocked_hash + # block only + assert await subtensor.determine_block_hash(block=1) == mocked_hash + + # block + block_hash agree + assert ( + await subtensor.determine_block_hash(block=1, block_hash=mocked_hash) + == mocked_hash ) - assert expected_hash_1 == expected_hash_2 - with pytest.raises(ValueError): + # block_hash wins over reuse_block and last_block_hash (internal composition path) + assert ( + await subtensor.determine_block_hash(block_hash=mocked_hash, reuse_block=True) + == mocked_hash + ) + + # block + block_hash + reuse_block: validate pair, ignore reuse_block + assert ( await subtensor.determine_block_hash( - block_hash=mocked_hash, block=1, reuse_block=True + block=1, block_hash=mocked_hash, reuse_block=True ) + == mocked_hash + ) + + # reuse_block only + assert await subtensor.determine_block_hash(reuse_block=True) == "0xOTHER" + + # reuse_block with no cached block + subtensor.substrate.last_block_hash = None + assert await subtensor.determine_block_hash(reuse_block=True) is None + + # chain tip + assert await subtensor.determine_block_hash() is None + + # reuse_block + block: real conflict, no block_hash to disambiguate + with pytest.raises(ValueError, match="reuse_block and block"): + await subtensor.determine_block_hash(block=1, reuse_block=True) + # block + block_hash mismatch with pytest.raises(ValueError): await subtensor.determine_block_hash(block=2, block_hash=mocked_hash) From 842dc043419f04f5ca879d6e5154b64b886692dd Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Mon, 8 Jun 2026 12:40:38 -0700 Subject: [PATCH 13/59] formating --- bittensor/core/async_subtensor.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/bittensor/core/async_subtensor.py b/bittensor/core/async_subtensor.py index b3e3ca192a..1d3d199518 100644 --- a/bittensor/core/async_subtensor.py +++ b/bittensor/core/async_subtensor.py @@ -483,7 +483,7 @@ async def determine_block_hash( Notes: - """ - # 1. Explicit pair: strongest signal, validate consistency + # 1. explicit pair: strongest signal, validate consistency if block is not None and block_hash is not None: retrieved_block_hash = await self.get_block_hash(block) if retrieved_block_hash != block_hash: @@ -493,19 +493,24 @@ async def determine_block_hash( f"maps to the block hash {retrieved_block_hash}." ) return retrieved_block_hash - # 2. Explicit hash wins; reuse_block is redundant after parent resolution + + # 2. explicit hash wins; reuse_block is redundant after parent resolution if block_hash is not None: return block_hash - # 3. Real ambiguity: block number vs reuse flag, no hash to disambiguate + + # 3. real ambiguity: block number vs reuse flag, no hash to disambiguate if block is not None and reuse_block: raise ValueError("Cannot specify both reuse_block and block") - # 4. Explicit block number + + # 4. explicit block number if block is not None: return await self.get_block_hash(block) - # 5. Reuse last queried block + + # 5. reuse last queried block if reuse_block: return self.substrate.last_block_hash - # 6. Chain tip + + # 6. chain tip return None async def _runtime_method_exists( From 6b02225760034497d1a8371e0ac7bdf3fe596056 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Tue, 9 Jun 2026 03:51:21 -0700 Subject: [PATCH 14/59] remove empty test module --- tests/e2e_tests/test_liquidity.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 tests/e2e_tests/test_liquidity.py diff --git a/tests/e2e_tests/test_liquidity.py b/tests/e2e_tests/test_liquidity.py deleted file mode 100644 index e69de29bb2..0000000000 From 6f9b66d4ad0a6536778d6374c90fcc2e6abfc7b2 Mon Sep 17 00:00:00 2001 From: kilyanni Date: Wed, 10 Jun 2026 20:14:45 +0200 Subject: [PATCH 15/59] fix(pyproject): de-dupe build-system deps --- pyproject.toml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bd054e8ce2..29011145eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=70.0.0", "wheel"] +requires = ["setuptools>=78.1.1"] build-backend = "setuptools.build_meta" [project] @@ -13,8 +13,6 @@ authors = [ license = { file = "LICENSE" } requires-python = ">=3.10,<3.15" dependencies = [ - "wheel>0.46.1", - "setuptools>=78.1.1", "aiohttp>=3.13.4,<4.0", "asyncstdlib~=3.13.0", "colorama~=0.4.6", From 2bf2a904d59706752072d7f786cf6185d3dcde0f Mon Sep 17 00:00:00 2001 From: BD Himes Date: Thu, 11 Jun 2026 10:41:28 +0200 Subject: [PATCH 16/59] Specify cyscale==0.5.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bd054e8ce2..ef6202cd35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ "retry==0.9.2", "requests>=2.33.0,<3.0", "pydantic>=2.3,<3", - "cyscale>=0.3.3,<1.0.0", + "cyscale==0.5.0", "uvicorn", "bittensor-drand>=1.3.0,<2.0.0", "bittensor-wallet>=4.1.0", From 34aea0fef33f697160e3098e13d7fb3b61aa11aa Mon Sep 17 00:00:00 2001 From: BD Himes Date: Thu, 11 Jun 2026 10:41:47 +0200 Subject: [PATCH 17/59] Update RootClaimable for new BMap return type --- bittensor/core/async_subtensor.py | 5 +++-- bittensor/core/subtensor.py | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/bittensor/core/async_subtensor.py b/bittensor/core/async_subtensor.py index 6ed48f71df..911a20ea63 100644 --- a/bittensor/core/async_subtensor.py +++ b/bittensor/core/async_subtensor.py @@ -3972,14 +3972,15 @@ async def get_root_claimable_all_rates( - See: """ block_hash = await self.determine_block_hash(block, block_hash, reuse_block) - query: ScaleType[list[tuple[int, FixedPoint]]] = await self.substrate.query( + query: ScaleType[dict[int, FixedPoint]] = await self.substrate.query( module="SubtensorModule", storage_function="RootClaimable", params=[hotkey_ss58], block_hash=block_hash, ) return { - netuid: fixed_to_float(bits, frac_bits=32) for (netuid, bits) in query.value + netuid: fixed_to_float(bits, frac_bits=32) + for (netuid, bits) in query.value.items() } async def get_root_claimable_stake( diff --git a/bittensor/core/subtensor.py b/bittensor/core/subtensor.py index 16598cf68d..c3b76ca91d 100644 --- a/bittensor/core/subtensor.py +++ b/bittensor/core/subtensor.py @@ -3286,14 +3286,14 @@ def get_root_claimable_all_rates( Notes: - See: """ - query: ScaleType[list[tuple[int, FixedPoint]]] = self.substrate.query( + query: ScaleType[dict[int, FixedPoint]] = self.substrate.query( module="SubtensorModule", storage_function="RootClaimable", params=[hotkey_ss58], block_hash=self.determine_block_hash(block), ) return { - netuid: fixed_to_float(bits, frac_bits=32) for (netuid, bits) in query.value + netuid: fixed_to_float(bits, frac_bits=32) for (netuid, bits) in query.value.items() } def get_root_claimable_stake( From e32d5f1951f5a4fb0d71f11975c28f8e7bdba826 Mon Sep 17 00:00:00 2001 From: BD Himes Date: Thu, 11 Jun 2026 10:51:28 +0200 Subject: [PATCH 18/59] Ruff --- bittensor/core/subtensor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bittensor/core/subtensor.py b/bittensor/core/subtensor.py index c3b76ca91d..dad1f2ed22 100644 --- a/bittensor/core/subtensor.py +++ b/bittensor/core/subtensor.py @@ -3293,7 +3293,8 @@ def get_root_claimable_all_rates( block_hash=self.determine_block_hash(block), ) return { - netuid: fixed_to_float(bits, frac_bits=32) for (netuid, bits) in query.value.items() + netuid: fixed_to_float(bits, frac_bits=32) + for (netuid, bits) in query.value.items() } def get_root_claimable_stake( From 6aa948b91b3c02e8867067e26524ebfcf21cc37a Mon Sep 17 00:00:00 2001 From: BD Himes Date: Thu, 11 Jun 2026 10:52:32 +0200 Subject: [PATCH 19/59] Unit Test fix --- tests/unit_tests/test_subtensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit_tests/test_subtensor.py b/tests/unit_tests/test_subtensor.py index 9dc8926535..4d406587ef 100644 --- a/tests/unit_tests/test_subtensor.py +++ b/tests/unit_tests/test_subtensor.py @@ -5036,7 +5036,7 @@ def test_get_root_claimable_all_rates(mocker, subtensor): # Preps hotkey_ss58 = mocker.Mock(spec=str) mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") - fake_value = [(14, {"bits": 6520190})] + fake_value = {14: {"bits": 6520190}} fake_result = mocker.MagicMock(value=fake_value) fake_result.__iter__ = fake_value mocked_query = mocker.patch.object( From 8a5d1c0c7bb852eb24b2f87c5635dbc137a6c55f Mon Sep 17 00:00:00 2001 From: BD Himes Date: Thu, 11 Jun 2026 10:53:44 +0200 Subject: [PATCH 20/59] async Unit test fix --- tests/unit_tests/test_async_subtensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit_tests/test_async_subtensor.py b/tests/unit_tests/test_async_subtensor.py index 3ec2231bdb..7ada0cd09e 100644 --- a/tests/unit_tests/test_async_subtensor.py +++ b/tests/unit_tests/test_async_subtensor.py @@ -4988,7 +4988,7 @@ async def test_get_root_claimable_all_rates(mocker, subtensor): # Preps hotkey_ss58 = mocker.Mock(spec=str) mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") - fake_value = [(14, {"bits": 6520190})] + fake_value = {14: {"bits": 6520190}} fake_result = mocker.MagicMock(spec=ScaleType, value=fake_value) fake_result.__iter__ = fake_value mocked_query = mocker.patch.object( From c590a6e17cb2a7062ce5c991102ad56601ff2854 Mon Sep 17 00:00:00 2001 From: BD Himes Date: Thu, 11 Jun 2026 11:12:15 +0200 Subject: [PATCH 21/59] Fixes tests test_owner_lock_lifecycle/test_owner_lock_lifecycle_async e2e test --- tests/e2e_tests/test_lock_stake.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/e2e_tests/test_lock_stake.py b/tests/e2e_tests/test_lock_stake.py index c26df229d9..d57fe8329c 100644 --- a/tests/e2e_tests/test_lock_stake.py +++ b/tests/e2e_tests/test_lock_stake.py @@ -132,13 +132,13 @@ def test_owner_lock_lifecycle(subtensor, alice_wallet, bob_wallet): logging.console.info(f"Unstake small amount response: {response}") assert response.success - # Unstake must not reduce locked_mass + # Unstake must not slash locked_mass (decaying locks lose a tiny amount per block) lock_unchanged = subtensor.staking.get_stake_lock( coldkey_ss58=alice_wallet.coldkey.ss58_address, netuid=alice_sn.netuid, hotkey_ss58=alice_wallet.hotkey.ss58_address, ) - assert lock_unchanged["locked_mass"].rao >= lock_after["locked_mass"].rao + assert lock_unchanged["locked_mass"].rao > lock_after["locked_mass"].rao * 0.99 @pytest.mark.asyncio @@ -266,13 +266,13 @@ async def test_owner_lock_lifecycle_async(async_subtensor, alice_wallet, bob_wal logging.console.info(f"Unstake small amount response: {response}") assert response.success - # Unstake must not reduce locked_mass + # Unstake must not slash locked_mass (decaying locks lose a tiny amount per block) lock_unchanged = await async_subtensor.staking.get_stake_lock( coldkey_ss58=alice_wallet.coldkey.ss58_address, netuid=alice_sn.netuid, hotkey_ss58=alice_wallet.hotkey.ss58_address, ) - assert lock_unchanged["locked_mass"].rao >= lock_after["locked_mass"].rao + assert lock_unchanged["locked_mass"].rao > lock_after["locked_mass"].rao * 0.99 def test_non_owner_lock_lifecycle(subtensor, alice_wallet, bob_wallet, charlie_wallet): From c1056a33012a36b5ce6c56474ab2f2c6f93f45bc Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Thu, 11 Jun 2026 11:10:57 -0700 Subject: [PATCH 22/59] update pallets.py --- .../extrinsics/pallets/subtensor_module.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) 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) From 9ca17f903612809e3ff3d7f040143c83eee281f1 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Thu, 11 Jun 2026 11:13:05 -0700 Subject: [PATCH 23/59] add new extrinsics --- .../core/extrinsics/asyncex/tempo_control.py | 283 ++++++++++++++++++ bittensor/core/extrinsics/tempo_control.py | 283 ++++++++++++++++++ 2 files changed, 566 insertions(+) create mode 100644 bittensor/core/extrinsics/asyncex/tempo_control.py create mode 100644 bittensor/core/extrinsics/tempo_control.py diff --git a/bittensor/core/extrinsics/asyncex/tempo_control.py b/bittensor/core/extrinsics/asyncex/tempo_control.py new file mode 100644 index 0000000000..fcb694abab --- /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: + """ + Triggers an immediate epoch on 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. + 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/tempo_control.py b/bittensor/core/extrinsics/tempo_control.py new file mode 100644 index 0000000000..8b11866ccd --- /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: + """ + Triggers an immediate epoch on 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. + 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) From fb347144789ebaa41c55b0f08d7b105fadc38c38 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Thu, 11 Jun 2026 11:13:30 -0700 Subject: [PATCH 24/59] add epoch schedule utils --- bittensor/utils/epoch_schedule.py | 53 +++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 bittensor/utils/epoch_schedule.py diff --git a/bittensor/utils/epoch_schedule.py b/bittensor/utils/epoch_schedule.py new file mode 100644 index 0000000000..38ec24575f --- /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 > MAX_TEMPO`` safety-net, or 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 From 3a3667a5cc8820e43ca1526fc293f32b332e751a Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Thu, 11 Jun 2026 11:14:11 -0700 Subject: [PATCH 25/59] add constants with TODO (will be revealed from the chain later) --- bittensor/core/settings.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bittensor/core/settings.py b/bittensor/core/settings.py index debb9a647a..d1a44981fe 100644 --- a/bittensor/core/settings.py +++ b/bittensor/core/settings.py @@ -84,6 +84,14 @@ # Public_key size for ML-KEM-768 (must be exactly 1184 bytes) MLKEM768_PUBLIC_KEY_SIZE = 1184 +# TODO: should be available via pallet.constant call (after subtensor update) - need to replace +# Chain bounds for owner-set tempo. +MIN_TEMPO = 360 +MAX_TEMPO = 50_400 +# Chain bounds for activity-cutoff factor in per-mille. +MIN_ACTIVITY_CUTOFF_FACTOR_MILLI = 1_000 +MAX_ACTIVITY_CUTOFF_FACTOR_MILLI = 50_000 + # Block Explorers map network to explorer url # Must all be polkadotjs explorer urls NETWORK_EXPLORER_MAP = { From b96cbd7f5bb9d8b306252defb82fd4719b4d5d5b Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Thu, 11 Jun 2026 11:26:26 -0700 Subject: [PATCH 26/59] improve subnet_hyperparameters.py module + .utils.Self --- .../core/chain_data/subnet_hyperparameters.py | 261 ++++++++++-------- bittensor/utils/__init__.py | 5 + 2 files changed, 156 insertions(+), 110 deletions(-) diff --git a/bittensor/core/chain_data/subnet_hyperparameters.py b/bittensor/core/chain_data/subnet_hyperparameters.py index 5317bad9e3..41618662b1 100644 --- a/bittensor/core/chain_data/subnet_hyperparameters.py +++ b/bittensor/core/chain_data/subnet_hyperparameters.py @@ -1,120 +1,161 @@ -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"}: + 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/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 From 8a06cc592e49cdc4f38391af4102307eb2a4ae41 Mon Sep 17 00:00:00 2001 From: BD Himes Date: Thu, 11 Jun 2026 20:26:46 +0200 Subject: [PATCH 27/59] Bump version + changelog --- CHANGELOG.md | 20 ++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c49a34c9e9..4c23dae701 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 10.5.0 /2026-06-11 + +## What's Changed +* Fix for flaky test_staking tests by @thewhaleking in https://github.com/latent-to/bittensor/pull/3367 +* Fix for flaky root claims tests by @thewhaleking in https://github.com/latent-to/bittensor/pull/3366 +* Fix after aiohttp latest release by @basfroman in https://github.com/latent-to/bittensor/pull/3369 +* Forward version_key from commit_weights by @Yupsecous in https://github.com/latent-to/bittensor/pull/3368 +* Update docstrings for kill_pure_proxy_extrinsic by @chideraao in https://github.com/latent-to/bittensor/pull/3374 +* New balancer (attempt 3) by @basfroman in https://github.com/latent-to/bittensor/pull/3362 +* Improvement for async `determine_block_hash` method by @basfroman in https://github.com/latent-to/bittensor/pull/3375 +* fix: test_owner_lock_lifecycle e2e by @thewhaleking in https://github.com/latent-to/bittensor/pull/3379 +* Handle cyscale 0.5.0 by @thewhaleking in https://github.com/latent-to/bittensor/pull/3378 +* fix(pyproject): de-dupe build-system deps by @kilyanni in https://github.com/latent-to/bittensor/pull/3377 + +## New Contributors +* @Yupsecous made their first contribution in https://github.com/latent-to/bittensor/pull/3368 +* @kilyanni made their first contribution in https://github.com/latent-to/bittensor/pull/3377 + +**Full Changelog**: https://github.com/latent-to/bittensor/compare/v10.4.0...v10.5.0 + ## 10.4.0 /2026-05-28 ## What's Changed diff --git a/pyproject.toml b/pyproject.toml index a557c61e80..bd0fc8596e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "bittensor" -version = "10.4.0" +version = "10.5.0" description = "Bittensor SDK" readme = "README.md" authors = [ From 524e59981ce13b5e9ea2944e70c6958489b47627 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Thu, 11 Jun 2026 11:27:06 -0700 Subject: [PATCH 28/59] add `.types.EpochScheduleState` class --- bittensor/core/types.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/bittensor/core/types.py b/bittensor/core/types.py index 03d47d58ac..7190f3a2c0 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: Optional[int] + blocks_since_last_step: Optional[int] + current_block: int From 69ef3ce89593838e17a151ccdbc145d95ac2214b Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Thu, 11 Jun 2026 11:38:27 -0700 Subject: [PATCH 29/59] use `get_encrypted_commit_v2` in `commit_timelocked_weights_extrinsic` --- bittensor/core/extrinsics/asyncex/weights.py | 22 ++++++++++---------- bittensor/core/extrinsics/weights.py | 22 ++++++++++---------- 2 files changed, 22 insertions(+), 22 deletions(-) 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/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, From 408aea3eaa742384ad90c82d400cda6924875876 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Thu, 11 Jun 2026 11:55:46 -0700 Subject: [PATCH 30/59] update Async/Subtensor classes --- bittensor/core/async_subtensor.py | 525 +++++++++++++++++++++++++----- bittensor/core/subtensor.py | 417 ++++++++++++++++++++---- 2 files changed, 799 insertions(+), 143 deletions(-) diff --git a/bittensor/core/async_subtensor.py b/bittensor/core/async_subtensor.py index 911a20ea63..6fb6f58c55 100644 --- a/bittensor/core/async_subtensor.py +++ b/bittensor/core/async_subtensor.py @@ -80,12 +80,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, @@ -127,6 +127,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, @@ -151,6 +157,7 @@ ) from bittensor.core.types import ( BlockInfo, + EpochScheduleState, ExtrinsicResponse, LockState, Salt, @@ -175,6 +182,7 @@ u64_normalized_float, validate_max_attempts, ) +from bittensor.utils import epoch_schedule from bittensor.utils.balance import ( Balance, check_balance_amount, @@ -1113,36 +1121,34 @@ 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, @@ -1309,6 +1315,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, @@ -2831,6 +2864,55 @@ 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) + 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, @@ -2973,6 +3055,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", @@ -3442,39 +3551,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, @@ -3505,6 +3609,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, @@ -3548,6 +3676,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, @@ -4533,6 +4688,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, @@ -4540,9 +4722,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. @@ -4551,24 +4734,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, @@ -5152,10 +5332,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. @@ -5164,28 +5345,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. @@ -8531,6 +8719,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", @@ -8577,6 +8812,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", @@ -8933,6 +9215,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", @@ -9681,6 +10010,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: + """ + Triggers an immediate epoch on 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. + 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/subtensor.py b/bittensor/core/subtensor.py index dad1f2ed22..3cbbeeb66b 100644 --- a/bittensor/core/subtensor.py +++ b/bittensor/core/subtensor.py @@ -47,6 +47,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 ( @@ -123,6 +124,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, @@ -147,6 +154,7 @@ ) from bittensor.core.types import ( BlockInfo, + EpochScheduleState, ExtrinsicResponse, LockState, Salt, @@ -894,30 +902,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, @@ -1041,6 +1044,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. @@ -2306,6 +2327,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=self.tempo(netuid, block=block_number), + blocks_since_last_step=self.blocks_since_last_step( + netuid, block=block_number + ), + current_block=block_number, + ) + def get_hotkey_conviction( self, hotkey_ss58: str, @@ -2416,6 +2464,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", @@ -2828,36 +2891,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, @@ -2883,6 +2940,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]]: @@ -2918,6 +2991,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. @@ -3710,34 +3798,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 @@ -4196,32 +4295,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. @@ -7307,6 +7405,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", @@ -7352,6 +7497,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", @@ -7688,6 +7880,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", @@ -8358,6 +8597,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: + """ + Triggers an immediate epoch on 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. + 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", From 4d4565e2f1be30f3714146565b90bf983d436600 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Thu, 11 Jun 2026 11:56:15 -0700 Subject: [PATCH 31/59] update extras --- .../dev_framework/calls/non_sudo_calls.py | 26 ++++++++++++++++++- .../extras/dev_framework/calls/pallets.py | 3 ++- .../extras/dev_framework/calls/sudo_calls.py | 6 ++++- bittensor/extras/dev_framework/subnet.py | 1 - bittensor/extras/subtensor_api/chain.py | 1 + bittensor/extras/subtensor_api/extrinsics.py | 4 +++ bittensor/extras/subtensor_api/subnets.py | 7 +++++ 7 files changed, 44 insertions(+), 4 deletions(-) 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..b641b34d75 100644 --- a/bittensor/extras/subtensor_api/chain.py +++ b/bittensor/extras/subtensor_api/chain.py @@ -14,6 +14,7 @@ def __init__(self, subtensor: Union["_Subtensor", "_AsyncSubtensor"]): self.get_delegate_identities = subtensor.get_delegate_identities self.get_existential_deposit = subtensor.get_existential_deposit 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 From c798dbbbd7b6866224ff35ee230a3fe2a533c25d Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Thu, 11 Jun 2026 11:56:37 -0700 Subject: [PATCH 32/59] add new unit tests --- .../chain_data/test_subnet_hyperparameters.py | 93 ++++ .../extrinsics/asyncex/test_tempo_control.py | 367 ++++++++++++++ .../extrinsics/test_tempo_control.py | 327 ++++++++++++ tests/unit_tests/test_async_subtensor.py | 478 +++++++++++++----- tests/unit_tests/test_subtensor.py | 379 +++++++++++--- tests/unit_tests/utils/test_epoch_schedule.py | 101 ++++ 6 files changed, 1538 insertions(+), 207 deletions(-) create mode 100644 tests/unit_tests/chain_data/test_subnet_hyperparameters.py create mode 100644 tests/unit_tests/extrinsics/asyncex/test_tempo_control.py create mode 100644 tests/unit_tests/extrinsics/test_tempo_control.py create mode 100644 tests/unit_tests/utils/test_epoch_schedule.py 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/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/test_async_subtensor.py b/tests/unit_tests/test_async_subtensor.py index 7ada0cd09e..409d76f79e 100644 --- a/tests/unit_tests/test_async_subtensor.py +++ b/tests/unit_tests/test_async_subtensor.py @@ -1989,92 +1989,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 +3261,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 @@ -4128,45 +4082,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 @@ -5835,28 +5789,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 @@ -6536,3 +6493,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 4d406587ef..19690bf1ae 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): @@ -3534,26 +3524,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): @@ -4372,42 +4365,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, block=block_number) - result = subtensor.is_in_admin_freeze_window(netuid=netuid) - - # Asserts - assert result is expected_result + assert result is expected def test_get_admin_freeze_window(subtensor, mocker): @@ -5860,24 +5848,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): @@ -6550,3 +6539,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 + ) From 12e19bb042d3f21f654a0a0c36c6a64d5dd96417 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Thu, 11 Jun 2026 12:03:48 -0700 Subject: [PATCH 33/59] add e2e tests for dynamic tempo extrinsics --- tests/e2e_tests/test_dynamic_tempo.py | 777 ++++++++++++++++++++++++++ 1 file changed, 777 insertions(+) create mode 100644 tests/e2e_tests/test_dynamic_tempo.py diff --git a/tests/e2e_tests/test_dynamic_tempo.py b/tests/e2e_tests/test_dynamic_tempo.py new file mode 100644 index 0000000000..c395a6df19 --- /dev/null +++ b/tests/e2e_tests/test_dynamic_tempo.py @@ -0,0 +1,777 @@ +"""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 bittensor.core.settings import ( + MAX_ACTIVITY_CUTOFF_FACTOR_MILLI, + MAX_TEMPO, + MIN_ACTIVITY_CUTOFF_FACTOR_MILLI, + MIN_TEMPO, +) +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=MIN_TEMPO, admin_freeze_window=0): + """Register and activate a subnet with the given tempo and freeze window.""" + 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=MIN_TEMPO, admin_freeze_window=0 +): + """Register and activate a subnet with the given tempo and freeze window.""" + 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 = 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 = 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. + """ + 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. + """ + 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}") + + 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 + 1 + 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 [ + commit_block - 1, + commit_block, + commit_block + 1, + ] + 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}") + + 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 + 1 + 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 [ + commit_block - 1, + commit_block, + commit_block + 1, + ] + 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 = 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 = 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 MIN_TEMPO — expect failure. + 4. Owner sets tempo above MAX_TEMPO — expect failure. + 5. Owner sets activity cutoff factor above MAX — expect failure. + 6. Owner sets activity cutoff factor below MIN — expect failure. + """ + sn = _setup_subnet(subtensor, alice_wallet) + netuid = sn.netuid + + 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_ACTIVITY_CUTOFF_FACTOR_MILLI + 1, + ) + assert result.success is False + + result = subtensor.extrinsics.set_activity_cutoff_factor( + wallet=alice_wallet, + netuid=netuid, + factor_milli=MIN_ACTIVITY_CUTOFF_FACTOR_MILLI - 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 MIN_TEMPO — expect failure. + 4. Owner sets tempo above MAX_TEMPO — expect failure. + 5. Owner sets activity cutoff factor above MAX — expect failure. + 6. Owner sets activity cutoff factor below MIN — expect failure. + """ + sn = await _setup_subnet_async(async_subtensor, alice_wallet) + netuid = sn.netuid + + 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_ACTIVITY_CUTOFF_FACTOR_MILLI + 1, + ) + assert result.success is False + + result = await async_subtensor.extrinsics.set_activity_cutoff_factor( + wallet=alice_wallet, + netuid=netuid, + factor_milli=MIN_ACTIVITY_CUTOFF_FACTOR_MILLI - 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. + """ + 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. + """ + 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 From b02774199192c78ac7baf72cbe03916e4b7098dc Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Thu, 11 Jun 2026 12:24:22 -0700 Subject: [PATCH 34/59] ooops, update `test_commit_timelocked_weights_extrinsic` e2e tests --- .../extrinsics/asyncex/test_weights.py | 35 ++++++++++--------- tests/unit_tests/extrinsics/test_weights.py | 35 ++++++++++--------- 2 files changed, 36 insertions(+), 34 deletions(-) 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_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, }, ) From dce211ffb53f8971d3c81efaa1768c85da9be810 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Thu, 11 Jun 2026 12:34:03 -0700 Subject: [PATCH 35/59] fix `test_metagraph_info_async` --- tests/e2e_tests/test_metagraph.py | 32 +++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) 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, From 327d1dabb4947ab6d2dc39b5d6ae863f4a8d383d Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Thu, 11 Jun 2026 12:40:55 -0700 Subject: [PATCH 36/59] bumping version --- pyproject.toml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a557c61e80..d5c6f93a2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "bittensor" -version = "10.4.0" +version = "10.5.0" description = "Bittensor SDK" readme = "README.md" authors = [ @@ -29,7 +29,8 @@ 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-drand @ git+https://github.com/latent-to/bittensor-drand.git@feat/basfroman/dynamic-tempo-support", ### temporarelly "bittensor-wallet>=4.1.0", "async-substrate-interface>=2.0.4,<3.0.0", ] From f3ae276caa52aa806ccdcfcf8f32164cdde75b8c Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Thu, 11 Jun 2026 13:55:18 -0700 Subject: [PATCH 37/59] fix for root_claim e2e tests --- tests/e2e_tests/test_root_claim.py | 44 +++++++++++++++--------------- 1 file changed, 22 insertions(+), 22 deletions(-) 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) From 031f2ae4c09256420c905dc52fea7bd75a82e78c Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Thu, 11 Jun 2026 15:36:33 -0700 Subject: [PATCH 38/59] remove constants from settings.py --- bittensor/core/settings.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/bittensor/core/settings.py b/bittensor/core/settings.py index d1a44981fe..debb9a647a 100644 --- a/bittensor/core/settings.py +++ b/bittensor/core/settings.py @@ -84,14 +84,6 @@ # Public_key size for ML-KEM-768 (must be exactly 1184 bytes) MLKEM768_PUBLIC_KEY_SIZE = 1184 -# TODO: should be available via pallet.constant call (after subtensor update) - need to replace -# Chain bounds for owner-set tempo. -MIN_TEMPO = 360 -MAX_TEMPO = 50_400 -# Chain bounds for activity-cutoff factor in per-mille. -MIN_ACTIVITY_CUTOFF_FACTOR_MILLI = 1_000 -MAX_ACTIVITY_CUTOFF_FACTOR_MILLI = 50_000 - # Block Explorers map network to explorer url # Must all be polkadotjs explorer urls NETWORK_EXPLORER_MAP = { From 8e4ec1a6924b81d45af2233141905dda8002a567 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Thu, 11 Jun 2026 15:58:17 -0700 Subject: [PATCH 39/59] use constants from the chain + tests --- bittensor/core/async_subtensor.py | 155 ++++++++++++++++++++ bittensor/core/subtensor.py | 115 +++++++++++++++ bittensor/extras/subtensor_api/chain.py | 9 ++ bittensor/utils/epoch_schedule.py | 4 +- tests/e2e_tests/test_dynamic_tempo.py | 102 ++++++++----- tests/unit_tests/test_async_subtensor.py | 177 +++++++++++++++++++++++ tests/unit_tests/test_subtensor.py | 105 ++++++++++++++ 7 files changed, 625 insertions(+), 42 deletions(-) diff --git a/bittensor/core/async_subtensor.py b/bittensor/core/async_subtensor.py index 6fb6f58c55..631f3841a7 100644 --- a/bittensor/core/async_subtensor.py +++ b/bittensor/core/async_subtensor.py @@ -3112,6 +3112,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, @@ -3371,6 +3464,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. diff --git a/bittensor/core/subtensor.py b/bittensor/core/subtensor.py index 3cbbeeb66b..16305b8a21 100644 --- a/bittensor/core/subtensor.py +++ b/bittensor/core/subtensor.py @@ -2506,6 +2506,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]]: @@ -2746,6 +2815,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. diff --git a/bittensor/extras/subtensor_api/chain.py b/bittensor/extras/subtensor_api/chain.py index b641b34d75..ebf60a5702 100644 --- a/bittensor/extras/subtensor_api/chain.py +++ b/bittensor/extras/subtensor_api/chain.py @@ -13,6 +13,15 @@ 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 diff --git a/bittensor/utils/epoch_schedule.py b/bittensor/utils/epoch_schedule.py index 38ec24575f..725c3b36cf 100644 --- a/bittensor/utils/epoch_schedule.py +++ b/bittensor/utils/epoch_schedule.py @@ -7,8 +7,8 @@ def blocks_until_next_auto_epoch( """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 > MAX_TEMPO`` safety-net, or per-block-cap deferral. Caller must guard against ``tempo == 0`` - upstream. + ``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. diff --git a/tests/e2e_tests/test_dynamic_tempo.py b/tests/e2e_tests/test_dynamic_tempo.py index c395a6df19..7b19c20c2a 100644 --- a/tests/e2e_tests/test_dynamic_tempo.py +++ b/tests/e2e_tests/test_dynamic_tempo.py @@ -7,12 +7,6 @@ from bittensor.utils.btlogging import logging from bittensor.utils.weight_utils import convert_weights_and_uids_for_emit -from bittensor.core.settings import ( - MAX_ACTIVITY_CUTOFF_FACTOR_MILLI, - MAX_TEMPO, - MIN_ACTIVITY_CUTOFF_FACTOR_MILLI, - MIN_TEMPO, -) from tests.e2e_tests.utils import ( AdminUtils, TestSubnet, @@ -28,8 +22,10 @@ ) -def _setup_subnet(subtensor, wallet, tempo=MIN_TEMPO, admin_freeze_window=0): +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( [ @@ -49,9 +45,11 @@ def _setup_subnet(subtensor, wallet, tempo=MIN_TEMPO, admin_freeze_window=0): async def _setup_subnet_async( - async_subtensor, wallet, tempo=MIN_TEMPO, admin_freeze_window=0 + 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( [ @@ -82,7 +80,7 @@ def test_set_tempo(subtensor, alice_wallet): sn = _setup_subnet(subtensor, alice_wallet) netuid = sn.netuid - new_tempo = MIN_TEMPO + 10 + new_tempo = subtensor.chain.get_min_tempo() + 10 result = subtensor.extrinsics.set_tempo( wallet=alice_wallet, netuid=netuid, @@ -120,7 +118,7 @@ async def test_set_tempo_async(async_subtensor, alice_wallet): sn = await _setup_subnet_async(async_subtensor, alice_wallet) netuid = sn.netuid - new_tempo = MIN_TEMPO + 10 + new_tempo = await async_subtensor.chain.get_min_tempo() + 10 result = await async_subtensor.extrinsics.set_tempo( wallet=alice_wallet, netuid=netuid, @@ -156,7 +154,8 @@ def test_trigger_epoch(subtensor, alice_wallet): 5. Call trigger_epoch again — expect success. 6. Verify pending epoch and freeze window state. """ - sn = _setup_subnet(subtensor, alice_wallet, tempo=MAX_TEMPO, admin_freeze_window=0) + 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)]) @@ -207,8 +206,9 @@ async def test_trigger_epoch_async(async_subtensor, alice_wallet): 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 + async_subtensor, alice_wallet, tempo=max_tempo, admin_freeze_window=0 ) netuid = sn.netuid @@ -316,13 +316,16 @@ def test_commit_reveal_after_owner_set_tempo(subtensor, alice_wallet): 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), + 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 @@ -332,7 +335,7 @@ def test_commit_reveal_after_owner_set_tempo(subtensor, alice_wallet): ) netuid = sn.netuid - owner_tempo = MIN_TEMPO + owner_tempo = min_tempo tempo_result = subtensor.extrinsics.set_tempo( wallet=alice_wallet, netuid=netuid, @@ -423,13 +426,16 @@ async def test_commit_reveal_after_owner_set_tempo_async(async_subtensor, alice_ 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), + 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 @@ -439,7 +445,7 @@ async def test_commit_reveal_after_owner_set_tempo_async(async_subtensor, alice_ ) netuid = sn.netuid - owner_tempo = MIN_TEMPO + owner_tempo = min_tempo tempo_result = await async_subtensor.extrinsics.set_tempo( wallet=alice_wallet, netuid=netuid, @@ -534,7 +540,7 @@ def test_root_set_activity_cutoff_factor(subtensor, alice_wallet): sn = _setup_subnet(subtensor, alice_wallet) netuid = sn.netuid - new_factor = MIN_ACTIVITY_CUTOFF_FACTOR_MILLI + 500 + new_factor = subtensor.chain.get_min_activity_cutoff_factor_milli() + 500 result = subtensor.extrinsics.root_set_activity_cutoff_factor( wallet=alice_wallet, netuid=netuid, @@ -562,7 +568,9 @@ async def test_root_set_activity_cutoff_factor_async(async_subtensor, alice_wall sn = await _setup_subnet_async(async_subtensor, alice_wallet) netuid = sn.netuid - new_factor = MIN_ACTIVITY_CUTOFF_FACTOR_MILLI + 500 + 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, @@ -584,18 +592,23 @@ def test_tempo_control_negative_cases(subtensor, alice_wallet, bob_wallet): Steps: 1. Register and activate a subnet (owner = alice). 2. Non-owner (bob) attempts set_tempo — expect failure. - 3. Owner sets tempo below MIN_TEMPO — expect failure. - 4. Owner sets tempo above MAX_TEMPO — expect failure. - 5. Owner sets activity cutoff factor above MAX — expect failure. - 6. Owner sets activity cutoff factor below MIN — 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, + tempo=min_tempo + 5, wait_for_inclusion=True, wait_for_finalization=True, ) @@ -604,7 +617,7 @@ def test_tempo_control_negative_cases(subtensor, alice_wallet, bob_wallet): result = subtensor.extrinsics.set_tempo( wallet=alice_wallet, netuid=netuid, - tempo=MIN_TEMPO - 1, + tempo=min_tempo - 1, wait_for_inclusion=True, wait_for_finalization=True, ) @@ -613,7 +626,7 @@ def test_tempo_control_negative_cases(subtensor, alice_wallet, bob_wallet): result = subtensor.extrinsics.set_tempo( wallet=alice_wallet, netuid=netuid, - tempo=MAX_TEMPO + 1, + tempo=max_tempo + 1, wait_for_inclusion=True, wait_for_finalization=True, ) @@ -622,14 +635,14 @@ def test_tempo_control_negative_cases(subtensor, alice_wallet, bob_wallet): result = subtensor.extrinsics.set_activity_cutoff_factor( wallet=alice_wallet, netuid=netuid, - factor_milli=MAX_ACTIVITY_CUTOFF_FACTOR_MILLI + 1, + 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_ACTIVITY_CUTOFF_FACTOR_MILLI - 1, + factor_milli=min_cutoff - 1, ) assert result.success is False @@ -644,18 +657,23 @@ async def test_tempo_control_negative_cases_async( Steps: 1. Register and activate a subnet (owner = alice). 2. Non-owner (bob) attempts set_tempo — expect failure. - 3. Owner sets tempo below MIN_TEMPO — expect failure. - 4. Owner sets tempo above MAX_TEMPO — expect failure. - 5. Owner sets activity cutoff factor above MAX — expect failure. - 6. Owner sets activity cutoff factor below MIN — 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, + tempo=min_tempo + 5, wait_for_inclusion=True, wait_for_finalization=True, ) @@ -664,7 +682,7 @@ async def test_tempo_control_negative_cases_async( result = await async_subtensor.extrinsics.set_tempo( wallet=alice_wallet, netuid=netuid, - tempo=MIN_TEMPO - 1, + tempo=min_tempo - 1, wait_for_inclusion=True, wait_for_finalization=True, ) @@ -673,7 +691,7 @@ async def test_tempo_control_negative_cases_async( result = await async_subtensor.extrinsics.set_tempo( wallet=alice_wallet, netuid=netuid, - tempo=MAX_TEMPO + 1, + tempo=max_tempo + 1, wait_for_inclusion=True, wait_for_finalization=True, ) @@ -682,14 +700,14 @@ async def test_tempo_control_negative_cases_async( result = await async_subtensor.extrinsics.set_activity_cutoff_factor( wallet=alice_wallet, netuid=netuid, - factor_milli=MAX_ACTIVITY_CUTOFF_FACTOR_MILLI + 1, + 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_ACTIVITY_CUTOFF_FACTOR_MILLI - 1, + factor_milli=min_cutoff - 1, ) assert result.success is False @@ -703,7 +721,9 @@ def test_set_tempo_rejected_in_freeze_window(subtensor, alice_wallet): 2. Set admin freeze window, trigger epoch to enter freeze state. 3. Attempt set_tempo while frozen — expect failure. """ - sn = _setup_subnet(subtensor, alice_wallet, tempo=MAX_TEMPO, admin_freeze_window=0) + 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( @@ -727,7 +747,7 @@ def test_set_tempo_rejected_in_freeze_window(subtensor, alice_wallet): result = subtensor.extrinsics.set_tempo( wallet=alice_wallet, netuid=netuid, - tempo=MIN_TEMPO, + tempo=min_tempo, wait_for_inclusion=True, wait_for_finalization=True, ) @@ -744,8 +764,10 @@ async def test_set_tempo_rejected_in_freeze_window_async(async_subtensor, alice_ 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 + async_subtensor, alice_wallet, tempo=max_tempo, admin_freeze_window=0 ) netuid = sn.netuid @@ -770,7 +792,7 @@ async def test_set_tempo_rejected_in_freeze_window_async(async_subtensor, alice_ result = await async_subtensor.extrinsics.set_tempo( wallet=alice_wallet, netuid=netuid, - tempo=MIN_TEMPO, + tempo=min_tempo, wait_for_inclusion=True, wait_for_finalization=True, ) diff --git a/tests/unit_tests/test_async_subtensor.py b/tests/unit_tests/test_async_subtensor.py index 409d76f79e..923f7934ce 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.""" diff --git a/tests/unit_tests/test_subtensor.py b/tests/unit_tests/test_subtensor.py index 19690bf1ae..0726b117cc 100644 --- a/tests/unit_tests/test_subtensor.py +++ b/tests/unit_tests/test_subtensor.py @@ -1656,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 From dd7d2e6418786f98cd9dec1304184edd4c29c9ec Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Fri, 12 Jun 2026 10:49:57 -0700 Subject: [PATCH 40/59] review fixes --- bittensor/core/async_subtensor.py | 9 +++++++-- bittensor/core/chain_data/subnet_hyperparameters.py | 2 ++ bittensor/core/extrinsics/asyncex/tempo_control.py | 2 +- bittensor/core/extrinsics/tempo_control.py | 2 +- bittensor/core/subtensor.py | 10 +++++----- bittensor/core/types.py | 4 ++-- 6 files changed, 18 insertions(+), 11 deletions(-) diff --git a/bittensor/core/async_subtensor.py b/bittensor/core/async_subtensor.py index 631f3841a7..786b1b68f4 100644 --- a/bittensor/core/async_subtensor.py +++ b/bittensor/core/async_subtensor.py @@ -1121,6 +1121,7 @@ async def blocks_since_last_update( async def blocks_until_next_epoch( self, netuid: int, + *, block: Optional[int] = None, block_hash: Optional[str] = None, reuse_block: bool = False, @@ -2885,7 +2886,11 @@ async def get_epoch_schedule_state( Returns: An `EpochScheduleState` populated from on-chain storage. """ - block_hash = await self.determine_block_hash(block, block_hash, reuse_block) + 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 ) @@ -10178,7 +10183,7 @@ async def trigger_epoch( wait_for_revealed_execution: bool = True, ) -> ExtrinsicResponse: """ - Triggers an immediate epoch on a subnet. Owner (coldkey) only. + 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). diff --git a/bittensor/core/chain_data/subnet_hyperparameters.py b/bittensor/core/chain_data/subnet_hyperparameters.py index 41618662b1..c856902971 100644 --- a/bittensor/core/chain_data/subnet_hyperparameters.py +++ b/bittensor/core/chain_data/subnet_hyperparameters.py @@ -68,6 +68,8 @@ def _typed_field_names() -> frozenset[str]: 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 diff --git a/bittensor/core/extrinsics/asyncex/tempo_control.py b/bittensor/core/extrinsics/asyncex/tempo_control.py index fcb694abab..9510370375 100644 --- a/bittensor/core/extrinsics/asyncex/tempo_control.py +++ b/bittensor/core/extrinsics/asyncex/tempo_control.py @@ -231,7 +231,7 @@ async def trigger_epoch_extrinsic( wait_for_revealed_execution: bool = True, ) -> ExtrinsicResponse: """ - Triggers an immediate epoch on a subnet. Owner (coldkey) only. + 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. diff --git a/bittensor/core/extrinsics/tempo_control.py b/bittensor/core/extrinsics/tempo_control.py index 8b11866ccd..62e14b0b95 100644 --- a/bittensor/core/extrinsics/tempo_control.py +++ b/bittensor/core/extrinsics/tempo_control.py @@ -231,7 +231,7 @@ def trigger_epoch_extrinsic( wait_for_revealed_execution: bool = True, ) -> ExtrinsicResponse: """ - Triggers an immediate epoch on a subnet. Owner (coldkey) only. + 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. diff --git a/bittensor/core/subtensor.py b/bittensor/core/subtensor.py index 16305b8a21..db33fcdeac 100644 --- a/bittensor/core/subtensor.py +++ b/bittensor/core/subtensor.py @@ -902,7 +902,7 @@ def blocks_since_last_update( return None if len(call) == 0 else (block - int(call[uid])) def blocks_until_next_epoch( - self, netuid: int, block: Optional[int] = None + self, netuid: int, *, block: Optional[int] = None ) -> Optional[int]: """Returns the number of blocks until the next epoch for the given subnet. @@ -2347,9 +2347,9 @@ def get_epoch_schedule_state( 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=self.tempo(netuid, block=block_number), - blocks_since_last_step=self.blocks_since_last_step( - 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, ) @@ -8725,7 +8725,7 @@ def trigger_epoch( wait_for_revealed_execution: bool = True, ) -> ExtrinsicResponse: """ - Triggers an immediate epoch on a subnet. Owner (coldkey) only. + 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). diff --git a/bittensor/core/types.py b/bittensor/core/types.py index 7190f3a2c0..2ef1bdf77b 100644 --- a/bittensor/core/types.py +++ b/bittensor/core/types.py @@ -694,6 +694,6 @@ class EpochScheduleState: last_epoch_block: int pending_epoch_at: int subnet_epoch_index: int - tempo: Optional[int] - blocks_since_last_step: Optional[int] + tempo: int + blocks_since_last_step: int current_block: int From d3019dc560f645b8d92427f5896a4ef3bde45851 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Mon, 15 Jun 2026 15:19:16 -0700 Subject: [PATCH 41/59] separate find-tests for 2 jobs --- .../nightly-e2e-tests-subtensor-main.yml | 72 ++++++++++++++++--- 1 file changed, 64 insertions(+), 8 deletions(-) diff --git a/.github/workflows/nightly-e2e-tests-subtensor-main.yml b/.github/workflows/nightly-e2e-tests-subtensor-main.yml index 1cbd66e528..bc0dbcce8e 100644 --- a/.github/workflows/nightly-e2e-tests-subtensor-main.yml +++ b/.github/workflows/nightly-e2e-tests-subtensor-main.yml @@ -25,8 +25,8 @@ env: # job to run tests in parallel jobs: - # Looking for e2e tests - find-tests: + # Looking for e2e tests on master + find-tests-master: runs-on: ubuntu-latest if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} outputs: @@ -34,6 +34,62 @@ jobs: steps: - name: Check-out repository under $GITHUB_WORKSPACE uses: actions/checkout@v6 + with: + ref: master + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.10' + + - name: Install uv + uses: astral-sh/setup-uv@v8.0.0 + with: + enable-cache: false + cache-dependency-glob: '**/pyproject.toml' + ignore-nothing-to-cache: true + + - name: Cache uv and venv + uses: actions/cache@v5 + with: + path: | + ~/.cache/uv + .venv + key: uv-${{ runner.os }}-py3.10-master-${{ hashFiles('pyproject.toml') }} + restore-keys: uv-${{ runner.os }}-py3.10-master- + + - name: Install dependencies (faster if cache hit) + run: uv sync --extra dev --dev + + - name: Find test files + id: get-tests + shell: bash + run: | + set -euo pipefail + test_matrix=$( + uv run pytest -q --collect-only tests/e2e_tests \ + | sed -n '/^e2e_tests\//p' \ + | sed 's|^|tests/|' \ + | jq -R -s -c ' + split("\n") + | map(select(. != "")) + | map({nodeid: ., label: (sub("^tests/e2e_tests/"; ""))}) + ' + ) + echo "Found tests: $test_matrix" + echo "test-files=$test_matrix" >> "$GITHUB_OUTPUT" + + # Looking for e2e tests on staging + find-tests-staging: + runs-on: ubuntu-latest + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} + outputs: + test-files: ${{ steps.get-tests.outputs.test-files }} + steps: + - name: Check-out repository under $GITHUB_WORKSPACE + uses: actions/checkout@v6 + with: + ref: staging - name: Set up Python uses: actions/setup-python@v6 @@ -53,8 +109,8 @@ jobs: path: | ~/.cache/uv .venv - key: uv-${{ runner.os }}-py3.10-${{ hashFiles('pyproject.toml') }} - restore-keys: uv-${{ runner.os }}-py3.10- + key: uv-${{ runner.os }}-py3.10-staging-${{ hashFiles('pyproject.toml') }} + restore-keys: uv-${{ runner.os }}-py3.10-staging- - name: Install dependencies (faster if cache hit) run: uv sync --extra dev --dev @@ -125,14 +181,14 @@ jobs: run-fast-blocks-e2e-test-master: name: "master: ${{ matrix.label }}" needs: - - find-tests + - find-tests-master - pull-docker-images - read-python-versions strategy: fail-fast: false max-parallel: 64 matrix: - include: ${{ fromJson(needs.find-tests.outputs.test-files) }} + include: ${{ fromJson(needs.find-tests-master.outputs.test-files) }} uses: ./.github/workflows/_run-e2e-single.yaml with: nodeid: ${{ matrix.nodeid }} @@ -146,14 +202,14 @@ jobs: run-fast-blocks-e2e-test-staging: name: "staging: ${{ matrix.label }}" needs: - - find-tests + - find-tests-staging - pull-docker-images - read-python-versions strategy: fail-fast: false max-parallel: 64 matrix: - include: ${{ fromJson(needs.find-tests.outputs.test-files) }} + include: ${{ fromJson(needs.find-tests-staging.outputs.test-files) }} uses: ./.github/workflows/_run-e2e-single.yaml with: nodeid: ${{ matrix.nodeid }} From cc556d6dd0c52bf2038ed7ce1789920b7863b3c8 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Mon, 15 Jun 2026 17:36:49 -0700 Subject: [PATCH 42/59] update chain data --- bittensor/core/chain_data/__init__.py | 11 +- bittensor/core/chain_data/proxy.py | 186 ++++++++++++++++---------- bittensor/utils/easy_imports.py | 4 + 3 files changed, 126 insertions(+), 75 deletions(-) diff --git a/bittensor/core/chain_data/__init__.py b/bittensor/core/chain_data/__init__.py index f66c3bd0a6..afaa0a4120 100644 --- a/bittensor/core/chain_data/__init__.py +++ b/bittensor/core/chain_data/__init__.py @@ -28,7 +28,14 @@ from .neuron_info_lite import NeuronInfoLite from .prometheus_info import PrometheusInfo from .proposal_vote_data import ProposalVoteData -from .proxy import ProxyAnnouncementInfo, ProxyConstants, ProxyInfo, ProxyType +from .proxy import ( + ProxyAnnouncementInfo, + ProxyConstants, + ProxyFilterInfo, + ProxyInfo, + ProxyType, + ProxyTypeInfo, +) from .root_claim import RootClaimType from .scheduled_coldkey_swap_info import ScheduledColdkeySwapInfo from .sim_swap import SimSwapResult @@ -66,8 +73,10 @@ "ProposalVoteData", "ProxyConstants", "ProxyAnnouncementInfo", + "ProxyFilterInfo", "ProxyInfo", "ProxyType", + "ProxyTypeInfo", "RootClaimType", "ScheduledColdkeySwapInfo", "SelectiveMetagraphIndex", diff --git a/bittensor/core/chain_data/proxy.py b/bittensor/core/chain_data/proxy.py index 662aad1e6b..0249d94667 100644 --- a/bittensor/core/chain_data/proxy.py +++ b/bittensor/core/chain_data/proxy.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from enum import Enum -from typing import Any, Optional, Union, Sequence +from typing import Any, Optional, Sequence, Union from bittensor.utils.balance import Balance @@ -11,84 +11,28 @@ class ProxyType(str, Enum): These types define the permissions that a proxy account has when acting on behalf of the real account. Each type restricts what operations the proxy can perform. - Proxy Type Descriptions: - - Any: Allows the proxy to execute any call on behalf of the real account. This is the most permissive but least - secure proxy type. Use with caution. - - Owner: Allows the proxy to manage subnet identity and settings. Permitted operations include: - - AdminUtils calls (except sudo_set_sn_owner_hotkey) - - set_subnet_identity - - update_symbol - - NonCritical: Allows all operations except critical ones that could harm the account. Prohibited operations: - - dissolve_network - - root_register - - burned_register - - Sudo calls - - NonTransfer: Allows all operations except those involving token transfers. Prohibited operations: - - All Balances module calls - - transfer_stake - - NonFungible: Allows all operations except token-related operations and registrations. Prohibited operations: - - All Balances module calls - - All staking operations (add_stake, remove_stake, unstake_all, swap_stake, move_stake, transfer_stake) - - Registration operations (burned_register, root_register) - - Key swap operations (announce_coldkey_swap, swap_coldkey_announced, swap_hotkey) - - Staking: Allows only staking-related operations. Permitted operations: - - add_stake, add_stake_limit - - remove_stake, remove_stake_limit, remove_stake_full_limit - - unstake_all, unstake_all_alpha - - swap_stake, swap_stake_limit - - move_stake - - Registration: Allows only neuron registration operations. Permitted operations: - - burned_register - - register - - Transfer: Allows only token transfer operations. Permitted operations: - - transfer_keep_alive - - transfer_allow_death - - transfer_all - - transfer_stake - - SmallTransfer: Allows only small token transfers below a specific limit. Permitted operations: - - transfer_keep_alive (if value < SMALL_TRANSFER_LIMIT) - - transfer_allow_death (if value < SMALL_TRANSFER_LIMIT) - - transfer_stake (if alpha_amount < SMALL_TRANSFER_LIMIT) - - ChildKeys: Allows only child key management operations. Permitted operations: - - set_children - - set_childkey_take - - SudoUncheckedSetCode: Allows only runtime code updates. Permitted operations: - - sudo_unchecked_weight with inner call System::set_code - - SwapHotkey: Allows only hotkey swap operations. Permitted operations: - - swap_hotkey - - SubnetLeaseBeneficiary: Allows subnet management and configuration operations. Permitted operations: - - start_call - - Multiple AdminUtils.sudo_set_* calls for subnet parameters, network settings, weights, alpha values, etc. - - RootClaim: Allows only root claim operations. Permitted operations: - - claim_root - - set_root_claim_type + Proxy Types: + Any: Full permissions — allows all calls. Use with extreme caution. + ChildKeys: Only child key management operations. + NonCritical: All operations except critical/destructive ones. + NonTransfer: All operations except token transfers. + NonFungible: All operations except token/staking/registration/key-swap operations. + Owner: Subnet identity and settings management. + Registration: Only neuron registration operations. + RootClaim: Only root claim operations. + SmallTransfer: Only token transfers below the on-chain limit. + Staking: Only staking-related operations. + SubnetLeaseBeneficiary: Subnet management for lease beneficiaries. + SudoUncheckedSetCode: Only runtime code updates via sudo. + SwapHotkey: Only hotkey swap operations. + Transfer: Only token transfer operations. Notes: - - The permissions described above may change over time as the Subtensor runtime evolves. For the most up-to-date - and authoritative information about proxy type permissions, refer to the Subtensor source code at: - - Specifically, look for the `impl InstanceFilter for ProxyType` implementation which defines the - exact filtering logic for each proxy type. - - The values match exactly with the ProxyType enum defined in the Subtensor runtime. Any changes to the - runtime enum must be reflected here. + - To retrieve the exact, up-to-date filter rules (which extrinsics each type permits or denies), use + :meth:`~bittensor.core.async_/subtensor.Async/Subtensor.get_proxy_filter`. - Proxy overview: - Creating and managing proxies: - Pure proxies: - """ Any = "Any" @@ -413,3 +357,97 @@ def to_dict(self) -> dict: from dataclasses import asdict return asdict(self) + + +@dataclass +class ProxyTypeInfo: + """Runtime information about a proxy type variant. + + This data is returned by the ``ProxyFilterRuntimeApi.getProxyTypes`` runtime API and represents the authoritative + source of truth for which proxy types exist in the current runtime. + + Attributes: + name: The proxy type name (e.g., ``"Staking"``, ``"NonTransfer"``). + index: The numeric index of this proxy type in the runtime enum. + deprecated: Whether this proxy type is deprecated and no longer functional. + + Notes: + - See: + """ + + name: str + index: int + deprecated: bool + + @classmethod + def from_list(cls, data: list[dict]) -> list["ProxyTypeInfo"]: + """Creates a list of ProxyTypeInfo from the ``ProxyFilterRuntimeApi.getProxyTypes`` runtime API response. + + Parameters: + data: List of dictionaries from the runtime API response. + + Returns: + List of ProxyTypeInfo objects. + """ + return [ + cls(name=item["name"], index=item["index"], deprecated=item["deprecated"]) + for item in data + ] + + +@dataclass +class ProxyFilterInfo: + """Describes how a specific proxy type filters incoming runtime calls. + + This data is returned by the ``ProxyFilterRuntimeApi.getProxyFilter`` runtime API and represents the authoritative + source of truth for proxy permissions. It describes which extrinsics each proxy type is allowed or denied to + execute on behalf of the real account. + + Attributes: + proxy_type: The numeric index of the proxy type. + name: Human-readable name of the proxy type (e.g., ``"Staking"``, ``"NonTransfer"``). + deprecated: Whether this proxy type is deprecated. + filter_mode: How filtering works. One of: + + - ``"AllowAll"``: All calls are permitted (e.g., ``ProxyType.Any``). + - ``"DenyAll"``: No calls are permitted (e.g., deprecated types). + - ``"Allow"``: Only calls listed in ``calls`` are permitted (minus ``exceptions``). + - ``"Deny"``: All calls are permitted EXCEPT those listed in ``calls``. + calls: List of call descriptors that the filter applies to. Each is a dict with keys: ``pallet_name``, + ``pallet_index``, ``call_name`` (``None`` means all calls in the pallet), ``call_index``, ``condition`` + (``None`` or a dict describing the condition — e.g., ``{"ParamLessThan": {"param_name": ..., "limit": ...}}`` + or ``{"NestedCallMustBe": {"pallet_name": ..., "call_name": ...}}``). + exceptions: List of call descriptors excluded from the filter rule (same structure as ``calls``). + + Notes: + - See: + """ + + proxy_type: int + name: str + deprecated: bool + filter_mode: str + calls: list[dict] + exceptions: list[dict] + + @classmethod + def from_list(cls, data: list[dict]) -> list["ProxyFilterInfo"]: + """Creates a list of ProxyFilterInfo from the ``ProxyFilterRuntimeApi.getProxyFilter`` runtime API response. + + Parameters: + data: List of dictionaries from the runtime API response. + + Returns: + List of ProxyFilterInfo objects. + """ + return [ + cls( + proxy_type=item["proxy_type"], + name=item["name"], + deprecated=item["deprecated"], + filter_mode=item["filter_mode"], + calls=item.get("calls", []), + exceptions=item.get("exceptions", []), + ) + for item in data + ] diff --git a/bittensor/utils/easy_imports.py b/bittensor/utils/easy_imports.py index f31d968a79..20fc5e7521 100644 --- a/bittensor/utils/easy_imports.py +++ b/bittensor/utils/easy_imports.py @@ -49,8 +49,10 @@ NeuronInfoLite, ProxyAnnouncementInfo, ProxyConstants, + ProxyFilterInfo, ProxyInfo, ProxyType, + ProxyTypeInfo, PrometheusInfo, ProposalCallData, ProposalVoteData, @@ -206,8 +208,10 @@ "ProportionOverflow", "ProxyAnnouncementInfo", "ProxyConstants", + "ProxyFilterInfo", "ProxyInfo", "ProxyType", + "ProxyTypeInfo", "RegistrationError", "RegistrationNotPermittedOnRootSubnet", "RunException", From bd019ee369c7f6b790711a827dc89d6799615a71 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Mon, 15 Jun 2026 17:37:14 -0700 Subject: [PATCH 43/59] add runtime api calls methods --- bittensor/core/async_subtensor.py | 71 +++++++++++++++++++++++++++++++ bittensor/core/subtensor.py | 69 ++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) diff --git a/bittensor/core/async_subtensor.py b/bittensor/core/async_subtensor.py index 911a20ea63..3bb57bee60 100644 --- a/bittensor/core/async_subtensor.py +++ b/bittensor/core/async_subtensor.py @@ -29,8 +29,10 @@ ProposalVoteData, ProxyAnnouncementInfo, ProxyConstants, + ProxyFilterInfo, ProxyInfo, ProxyType, + ProxyTypeInfo, RootClaimType, SelectiveMetagraphIndex, SimSwapResult, @@ -3755,6 +3757,75 @@ async def get_proxy_constants( return proxy_constants.to_dict() if as_dict else proxy_constants + async def get_proxy_filter( + self, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> list[ProxyFilterInfo]: + """Retrieves proxy filter rules from the runtime. + + Queries the ``ProxyFilterRuntimeApi.getProxyFilter`` runtime API to get detailed information about which + extrinsics are allowed or denied for each proxy type. This is the authoritative source of truth for proxy + permissions. + + Parameters: + block: The blockchain block number for the query. If ``None``, queries the latest block. + block_hash: The hash of the block at which to query. Do not set if using ``block`` or ``reuse_block``. + reuse_block: Whether to reuse the last-used block hash. Do not set if using ``block_hash`` or ``block``. + + Returns: + List of ProxyFilterInfo objects describing the filter rules for all proxy types. + + Notes: + - Filter modes: + - ``"AllowAll"``: All calls are permitted (e.g., ``ProxyType.Any``). + - ``"DenyAll"``: No calls are permitted (e.g., deprecated types). + - ``"Allow"``: Only calls listed in ``calls`` are permitted (minus ``exceptions``). + - ``"Deny"``: All calls are permitted EXCEPT those listed in ``calls``. + - A call entry with ``call_name=None`` means the rule applies to ALL calls in that pallet. + - See: + """ + block_hash = await self.determine_block_hash(block, block_hash, reuse_block) + result = await self.substrate.runtime_call( + api="ProxyFilterRuntimeApi", + method="get_proxy_filter", + params=[None], + block_hash=block_hash, + ) + return ProxyFilterInfo.from_list(result) + + async def get_proxy_types( + self, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> list[ProxyTypeInfo]: + """Retrieves all proxy type variants defined in the runtime. + + Queries the ``ProxyFilterRuntimeApi.getProxyTypes`` runtime API to get the complete list of proxy types with + their indices and deprecation status. This is the authoritative source of truth for which proxy types exist in + the current runtime. + + Parameters: + block: The blockchain block number for the query. If ``None``, queries the latest block. + block_hash: The hash of the block at which to query. Do not set if using ``block`` or ``reuse_block``. + reuse_block: Whether to reuse the last-used block hash. Do not set if using ``block_hash`` or ``block``. + + Returns: + List of ProxyTypeInfo objects representing all proxy type variants in the runtime. + + Notes: + - See: + """ + block_hash = await self.determine_block_hash(block, block_hash, reuse_block) + result = await self.substrate.runtime_call( + api="ProxyFilterRuntimeApi", + method="get_proxy_types", + block_hash=block_hash, + ) + return ProxyTypeInfo.from_list(result) + async def get_revealed_commitment( self, netuid: int, diff --git a/bittensor/core/subtensor.py b/bittensor/core/subtensor.py index dad1f2ed22..7c3aac624b 100644 --- a/bittensor/core/subtensor.py +++ b/bittensor/core/subtensor.py @@ -30,8 +30,10 @@ ProposalVoteData, ProxyAnnouncementInfo, ProxyConstants, + ProxyFilterInfo, ProxyInfo, ProxyType, + ProxyTypeInfo, RootClaimType, SelectiveMetagraphIndex, SimSwapResult, @@ -3099,6 +3101,73 @@ def get_proxy_constants( return proxy_constants.to_dict() if as_dict else proxy_constants + def get_proxy_filter( + self, + block: Optional[int] = None, + ) -> list[ProxyFilterInfo]: + """Retrieves proxy filter rules from the runtime. + + Queries the ``ProxyFilterRuntimeApi.getProxyFilter`` runtime API to get detailed information about which + extrinsics are allowed or denied for each proxy type. This is the authoritative source of truth for proxy + permissions. + + Parameters: + block: The blockchain block number for the query. If ``None``, queries the latest block. + + Returns: + List of ProxyFilterInfo objects describing the filter rules for all proxy types. + + Notes: + - Filter modes: + - ``"AllowAll"``: All calls are permitted (e.g., ``ProxyType.Any``). + - ``"DenyAll"``: No calls are permitted (e.g., deprecated types). + - ``"Allow"``: Only calls listed in ``calls`` are permitted (minus ``exceptions``). + - ``"Deny"``: All calls are permitted EXCEPT those listed in ``calls``. + - A call entry with ``call_name=None`` means the rule applies to ALL calls in that pallet. + - See: + """ + block_hash = self.determine_block_hash(block) + result = cast( + list[dict], + self.substrate.runtime_call( + api="ProxyFilterRuntimeApi", + method="get_proxy_filter", + params=[None], + block_hash=block_hash, + ), + ) + return ProxyFilterInfo.from_list(result) + + def get_proxy_types( + self, + block: Optional[int] = None, + ) -> list[ProxyTypeInfo]: + """Retrieves all proxy type variants defined in the runtime. + + Queries the ``ProxyFilterRuntimeApi.getProxyTypes`` runtime API to get the complete list of proxy types with + their indices and deprecation status. This is the authoritative source of truth for which proxy types exist in + the current runtime. + + Parameters: + block: The blockchain block number for the query. If ``None``, queries the latest block. + + Returns: + List of ProxyTypeInfo objects representing all proxy type variants in the runtime. + + Notes: + - See: + """ + block_hash = self.determine_block_hash(block) + result = cast( + list[dict], + self.substrate.runtime_call( + api="ProxyFilterRuntimeApi", + method="get_proxy_types", + block_hash=block_hash, + ), + ) + return ProxyTypeInfo.from_list(result) + def get_revealed_commitment( self, netuid: int, From 7b10e35dc39ce751d133e0679caa74b59d5fb180 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Mon, 15 Jun 2026 17:37:23 -0700 Subject: [PATCH 44/59] update SubtensorApi --- bittensor/extras/subtensor_api/proxy.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bittensor/extras/subtensor_api/proxy.py b/bittensor/extras/subtensor_api/proxy.py index 11ba7c4353..33d686879c 100644 --- a/bittensor/extras/subtensor_api/proxy.py +++ b/bittensor/extras/subtensor_api/proxy.py @@ -33,6 +33,8 @@ def __init__(self, subtensor: Union["_Subtensor", "_AsyncSubtensor"]): self.kill_pure_proxy = subtensor.kill_pure_proxy self.poke_deposit = subtensor.poke_deposit self.proxy_announced = subtensor.proxy_announced + self.get_proxy_filter = subtensor.get_proxy_filter + self.get_proxy_types = subtensor.get_proxy_types self.proxy = subtensor.proxy self.reject_proxy_announcement = subtensor.reject_proxy_announcement self.remove_proxies = subtensor.remove_proxies From 513e0bf9b0fcdd9911508aa050faf986a403739b Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Mon, 15 Jun 2026 17:37:31 -0700 Subject: [PATCH 45/59] add unit tests --- tests/unit_tests/test_async_subtensor.py | 45 ++++++++++++++++++++++++ tests/unit_tests/test_subtensor.py | 45 ++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/tests/unit_tests/test_async_subtensor.py b/tests/unit_tests/test_async_subtensor.py index 7ada0cd09e..0d680c5f8e 100644 --- a/tests/unit_tests/test_async_subtensor.py +++ b/tests/unit_tests/test_async_subtensor.py @@ -5447,6 +5447,51 @@ async def test_get_proxy_constants_as_dict(subtensor, mocker): assert result == fake_constants +@pytest.mark.asyncio +async def test_get_proxy_filter(subtensor, mocker): + """Test get_proxy_filter calls runtime API correctly.""" + # Prep + mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") + mocked_runtime_call = mocker.patch.object(subtensor.substrate, "runtime_call") + mocked_from_list = mocker.patch.object(async_subtensor.ProxyFilterInfo, "from_list") + + # Call + result = await subtensor.get_proxy_filter() + + # Asserts + mocked_determine_block_hash.assert_awaited_once_with(None, None, False) + mocked_runtime_call.assert_awaited_once_with( + api="ProxyFilterRuntimeApi", + method="get_proxy_filter", + params=[None], + block_hash=mocked_determine_block_hash.return_value, + ) + mocked_from_list.assert_called_once_with(mocked_runtime_call.return_value) + assert result == mocked_from_list.return_value + + +@pytest.mark.asyncio +async def test_get_proxy_types(subtensor, mocker): + """Test get_proxy_types calls runtime API correctly.""" + # Prep + mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") + mocked_runtime_call = mocker.patch.object(subtensor.substrate, "runtime_call") + mocked_from_list = mocker.patch.object(async_subtensor.ProxyTypeInfo, "from_list") + + # Call + result = await subtensor.get_proxy_types() + + # Asserts + mocked_determine_block_hash.assert_awaited_once_with(None, None, False) + mocked_runtime_call.assert_awaited_once_with( + api="ProxyFilterRuntimeApi", + method="get_proxy_types", + block_hash=mocked_determine_block_hash.return_value, + ) + mocked_from_list.assert_called_once_with(mocked_runtime_call.return_value) + assert result == mocked_from_list.return_value + + @pytest.mark.asyncio async def test_add_proxy(mocker, subtensor): """Tests `add_proxy` extrinsic call method.""" diff --git a/tests/unit_tests/test_subtensor.py b/tests/unit_tests/test_subtensor.py index 4d406587ef..25fb362044 100644 --- a/tests/unit_tests/test_subtensor.py +++ b/tests/unit_tests/test_subtensor.py @@ -5484,6 +5484,51 @@ def test_get_proxy_constants_as_dict(subtensor, mocker): assert result == fake_constants +def test_get_proxy_filter(subtensor, mocker): + """Test get_proxy_filter calls runtime API correctly.""" + # Prep + mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") + mocked_runtime_call = mocker.patch.object(subtensor.substrate, "runtime_call") + mocked_from_list = mocker.patch.object( + subtensor_module.ProxyFilterInfo, "from_list" + ) + + # Call + result = subtensor.get_proxy_filter() + + # Asserts + mocked_determine_block_hash.assert_called_once_with(None) + mocked_runtime_call.assert_called_once_with( + api="ProxyFilterRuntimeApi", + method="get_proxy_filter", + params=[None], + block_hash=mocked_determine_block_hash.return_value, + ) + mocked_from_list.assert_called_once_with(mocked_runtime_call.return_value) + assert result == mocked_from_list.return_value + + +def test_get_proxy_types(subtensor, mocker): + """Test get_proxy_types calls runtime API correctly.""" + # Prep + mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") + mocked_runtime_call = mocker.patch.object(subtensor.substrate, "runtime_call") + mocked_from_list = mocker.patch.object(subtensor_module.ProxyTypeInfo, "from_list") + + # Call + result = subtensor.get_proxy_types() + + # Asserts + mocked_determine_block_hash.assert_called_once_with(None) + mocked_runtime_call.assert_called_once_with( + api="ProxyFilterRuntimeApi", + method="get_proxy_types", + block_hash=mocked_determine_block_hash.return_value, + ) + mocked_from_list.assert_called_once_with(mocked_runtime_call.return_value) + assert result == mocked_from_list.return_value + + def test_add_proxy(mocker, subtensor): """Tests `add_proxy` extrinsic call method.""" # preps From 450f6c185d16e1393af82d5375bd5c2ca1954625 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Mon, 15 Jun 2026 17:37:44 -0700 Subject: [PATCH 46/59] improve consistency tests --- tests/consistency/test_proxy_types.py | 36 +++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/consistency/test_proxy_types.py b/tests/consistency/test_proxy_types.py index 5bb3e8be6b..c23809ce85 100644 --- a/tests/consistency/test_proxy_types.py +++ b/tests/consistency/test_proxy_types.py @@ -24,3 +24,39 @@ def test_make_sure_proxy_type_has_all_fields(subtensor, alice_wallet): assert len(chain_proxy_type_fields) == len(ProxyType) assert set(chain_proxy_type_fields) == set(ProxyType.all_types()) + + +def test_proxy_types_match_runtime_api(subtensor, alice_wallet): + """Tests that SDK ProxyType enum matches ProxyFilterRuntimeApi.getProxyTypes.""" + runtime_types = subtensor.proxies.get_proxy_types() + + runtime_names = {rt.name for rt in runtime_types} + + for rt in runtime_types: + assert ProxyType.is_valid(rt.name), ( + f"Runtime proxy type '{rt.name}' (index={rt.index}) not in SDK ProxyType enum" + ) + + for pt in ProxyType: + assert pt.value in runtime_names, ( + f"SDK ProxyType.{pt.value} not found in runtime getProxyTypes response" + ) + + +def test_proxy_filter_returns_valid_data(subtensor, alice_wallet): + """Tests that get_proxy_filter() returns valid filter data for all types.""" + filters = subtensor.proxies.get_proxy_filter() + + assert len(filters) > 0 + valid_modes = {"AllowAll", "DenyAll", "Allow", "Deny"} + + for f in filters: + assert f.filter_mode in valid_modes, ( + f"Invalid filter_mode '{f.filter_mode}' for {f.name}" + ) + if f.filter_mode in ("AllowAll", "DenyAll"): + assert f.calls == [], f"{f.name}: {f.filter_mode} should have empty calls" + else: + assert len(f.calls) > 0, ( + f"{f.name}: {f.filter_mode} should have non-empty calls" + ) From 31035830f21cc17325fa37fcaff27138d4595e98 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Mon, 15 Jun 2026 17:52:21 -0700 Subject: [PATCH 47/59] fix flaky assert --- tests/e2e_tests/test_axon.py | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/tests/e2e_tests/test_axon.py b/tests/e2e_tests/test_axon.py index e1574383cc..60817b3035 100644 --- a/tests/e2e_tests/test_axon.py +++ b/tests/e2e_tests/test_axon.py @@ -1,6 +1,7 @@ import asyncio import time +import netaddr import pytest from bittensor.utils import networking @@ -52,7 +53,6 @@ def test_axon(subtensor, templates, alice_wallet): # Refresh the metagraph metagraph = subtensor.metagraphs.metagraph(alice_sn.netuid) updated_axon = metagraph.axons[0] - external_ip = networking.get_external_ip() # Assert updated attributes assert len(metagraph.axons) == 1, ( @@ -63,13 +63,9 @@ def test_axon(subtensor, templates, alice_wallet): f"Expected 1 neuron, but got {len(metagraph.neurons)}" ) - assert updated_axon.ip == external_ip, ( - f"Expected IP {external_ip}, but got {updated_axon.ip}" - ) - - assert updated_axon.ip_type == networking.ip_version(external_ip), ( - f"Expected IP type {networking.ip_version(external_ip)}, but got {updated_axon.ip_type}" - ) + assert updated_axon.ip != "0.0.0.0", f"Axon IP was not updated: {updated_axon.ip}" + assert netaddr.IPAddress(updated_axon.ip).is_global() + assert updated_axon.ip_type == networking.ip_version(updated_axon.ip) assert updated_axon.port == 8091, f"Expected port 8091, but got {updated_axon.port}" @@ -124,7 +120,6 @@ async def test_axon_async(async_subtensor, templates, alice_wallet): # Refresh the metagraph metagraph = await async_subtensor.metagraphs.metagraph(alice_sn.netuid) updated_axon = metagraph.axons[0] - external_ip = networking.get_external_ip() # Assert updated attributes assert len(metagraph.axons) == 1, ( @@ -135,13 +130,9 @@ async def test_axon_async(async_subtensor, templates, alice_wallet): f"Expected 1 neuron, but got {len(metagraph.neurons)}" ) - assert updated_axon.ip == external_ip, ( - f"Expected IP {external_ip}, but got {updated_axon.ip}" - ) - - assert updated_axon.ip_type == networking.ip_version(external_ip), ( - f"Expected IP type {networking.ip_version(external_ip)}, but got {updated_axon.ip_type}" - ) + assert updated_axon.ip != "0.0.0.0", f"Axon IP was not updated: {updated_axon.ip}" + assert netaddr.IPAddress(updated_axon.ip).is_global() + assert updated_axon.ip_type == networking.ip_version(updated_axon.ip) assert updated_axon.port == 8091, f"Expected port 8091, but got {updated_axon.port}" From b11412470d2e29d42af1b1d2923ca3bb87e7373a Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Tue, 16 Jun 2026 09:44:19 -0700 Subject: [PATCH 48/59] add `get_stake_availability_for_coldkeys` method --- bittensor/core/async_subtensor.py | 46 +++++++++++++++++++++++++++++++ bittensor/core/subtensor.py | 40 +++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/bittensor/core/async_subtensor.py b/bittensor/core/async_subtensor.py index 3bb57bee60..d0228509c7 100644 --- a/bittensor/core/async_subtensor.py +++ b/bittensor/core/async_subtensor.py @@ -4220,6 +4220,52 @@ async def get_stake_add_fee( ) return sim_swap_result.tao_fee + async def get_stake_availability_for_coldkeys( + self, + coldkey_ss58s: list[str], + netuids: Optional[list[int]] = None, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> dict: + """ + Batch query of stake availability per coldkey and subnet. + + Returns how much alpha each coldkey has staked on each subnet, how much is locked by conviction, and how much + is free to unstake right now. All values are in rao (raw integer units). + + Parameters: + coldkey_ss58s: SS58 addresses of the coldkeys to query. + netuids: Subnet UIDs to limit the scan to. ``None`` scans all subnets. + block: The block number to query. Do not specify if using ``block_hash`` or ``reuse_block``. + block_hash: The block hash at which to query. + reuse_block: Whether to reuse the last-used block hash. + + Returns: + A nested dict ``{coldkey_ss58: {netuid: {total, locked, available}}}``. + Each inner dict contains: + + - ``total`` - all alpha staked on the subnet across all hotkeys (rao). + - ``locked`` - current locked mass after decay (rao). + - ``available`` - alpha that can be unstaked now, i.e. ``total - locked`` (rao). + + Subnets with zero stake and zero lock are omitted. + Coldkeys from the request are always present (inner dict may be empty). + + Example:: + + result = await subtensor.get_stake_availability_for_coldkeys(["5HGj..."]) + available_rao = result["5HGj..."][2]["available"] + """ + return await self.query_runtime_api( + runtime_api="StakeInfoRuntimeApi", + method="get_stake_availability_for_coldkeys", + params=[coldkey_ss58s, netuids], + block=block, + block_hash=block_hash, + reuse_block=reuse_block, + ) + async def get_stake_lock( self, coldkey_ss58: str, diff --git a/bittensor/core/subtensor.py b/bittensor/core/subtensor.py index 7c3aac624b..8e0db75d43 100644 --- a/bittensor/core/subtensor.py +++ b/bittensor/core/subtensor.py @@ -3602,6 +3602,46 @@ def get_stake_add_fee( ) return sim_swap_result.tao_fee + def get_stake_availability_for_coldkeys( + self, + coldkey_ss58s: list[str], + netuids: Optional[list[int]] = None, + block: Optional[int] = None, + ) -> dict: + """ + Batch query of stake availability per coldkey and subnet. + + Returns how much alpha each coldkey has staked on each subnet, how much is locked by conviction, and how much + is free to unstake right now. All values are in rao (raw integer units). + + Parameters: + coldkey_ss58s: SS58 addresses of the coldkeys to query. + netuids: Subnet UIDs to limit the scan to. ``None`` scans all subnets. + block: The block number to query. If ``None``, queries the current block. + + Returns: + A nested dict ``{coldkey_ss58: {netuid: {total, locked, available}}}``. + Each inner dict contains: + + - ``total`` - all alpha staked on the subnet across all hotkeys (rao). + - ``locked`` - current locked mass after decay (rao). + - ``available`` - alpha that can be unstaked now, i.e. ``total - locked`` (rao). + + Subnets with zero stake and zero lock are omitted. + Coldkeys from the request are always present (inner dict may be empty). + + Example:: + + result = subtensor.get_stake_availability_for_coldkeys(["5HGj..."]) + available_rao = result["5HGj..."][2]["available"] + """ + return self.query_runtime_api( + runtime_api="StakeInfoRuntimeApi", + method="get_stake_availability_for_coldkeys", + params=[coldkey_ss58s, netuids], + block=block, + ) + def get_stake_lock( self, coldkey_ss58: str, From ab6a3b6ee5ec8c678376ac0f4ca3e5b2f47865d6 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Tue, 16 Jun 2026 09:44:34 -0700 Subject: [PATCH 49/59] update SubtensorApi --- bittensor/extras/subtensor_api/staking.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bittensor/extras/subtensor_api/staking.py b/bittensor/extras/subtensor_api/staking.py index d488f694f0..1b33009630 100644 --- a/bittensor/extras/subtensor_api/staking.py +++ b/bittensor/extras/subtensor_api/staking.py @@ -29,6 +29,9 @@ def __init__(self, subtensor: Union["_Subtensor", "_AsyncSubtensor"]): self.get_root_claimed = subtensor.get_root_claimed self.get_stake = subtensor.get_stake self.get_stake_add_fee = subtensor.get_stake_add_fee + self.get_stake_availability_for_coldkeys = ( + subtensor.get_stake_availability_for_coldkeys + ) self.get_stake_for_coldkey_and_hotkey = ( subtensor.get_stake_for_coldkey_and_hotkey ) From 5972375e0854c9c14e8f46446e5c9f521800ada0 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Tue, 16 Jun 2026 09:45:15 -0700 Subject: [PATCH 50/59] add unit tests --- tests/unit_tests/test_async_subtensor.py | 53 ++++++++++++++++++++++++ tests/unit_tests/test_subtensor.py | 47 +++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/tests/unit_tests/test_async_subtensor.py b/tests/unit_tests/test_async_subtensor.py index 0d680c5f8e..bdc29af8a2 100644 --- a/tests/unit_tests/test_async_subtensor.py +++ b/tests/unit_tests/test_async_subtensor.py @@ -3701,6 +3701,59 @@ async def test_get_stake_add_fee(subtensor, mocker): assert result == mocked_sim_swap.return_value.tao_fee +@pytest.mark.asyncio +async def test_get_stake_availability_for_coldkeys(subtensor, mocker): + """Tests get_stake_availability_for_coldkeys returns raw dict from runtime API.""" + # Preps + fake_coldkeys = ["ck1", "ck2"] + fake_result = { + "ck1": { + 2: {"total": 1_500_000_000, "locked": 0, "available": 1_500_000_000}, + 3: {"total": 800_000_000, "locked": 200_000_000, "available": 600_000_000}, + }, + "ck2": {}, + } + subtensor.query_runtime_api = mocker.AsyncMock(return_value=fake_result) + + # Call + result = await subtensor.get_stake_availability_for_coldkeys( + coldkey_ss58s=fake_coldkeys, netuids=[2, 3], block=100 + ) + + # Asserts + subtensor.query_runtime_api.assert_awaited_once_with( + runtime_api="StakeInfoRuntimeApi", + method="get_stake_availability_for_coldkeys", + params=[fake_coldkeys, [2, 3]], + block=100, + block_hash=None, + reuse_block=False, + ) + assert result == fake_result + + +@pytest.mark.asyncio +async def test_get_stake_availability_for_coldkeys_netuids_none(subtensor, mocker): + """Tests get_stake_availability_for_coldkeys passes None netuids correctly.""" + # Preps + fake_result = {"ck1": {1: {"total": 100, "locked": 0, "available": 100}}} + subtensor.query_runtime_api = mocker.AsyncMock(return_value=fake_result) + + # Call + result = await subtensor.get_stake_availability_for_coldkeys(coldkey_ss58s=["ck1"]) + + # Asserts + subtensor.query_runtime_api.assert_awaited_once_with( + runtime_api="StakeInfoRuntimeApi", + method="get_stake_availability_for_coldkeys", + params=[["ck1"], None], + block=None, + block_hash=None, + reuse_block=False, + ) + assert result == fake_result + + @pytest.mark.asyncio async def test_get_unstake_fee(subtensor, mocker): """Verify that `get_unstake_fee` calls proper methods and returns the correct value.""" diff --git a/tests/unit_tests/test_subtensor.py b/tests/unit_tests/test_subtensor.py index 25fb362044..f615de6b39 100644 --- a/tests/unit_tests/test_subtensor.py +++ b/tests/unit_tests/test_subtensor.py @@ -4000,6 +4000,53 @@ def test_get_stake_add_fee(subtensor, mocker): assert result == mocked_sim_swap.return_value.tao_fee +def test_get_stake_availability_for_coldkeys(subtensor, mocker): + """Tests get_stake_availability_for_coldkeys returns raw dict from runtime API.""" + # Preps + fake_coldkeys = ["ck1", "ck2"] + fake_result = { + "ck1": { + 2: {"total": 1_500_000_000, "locked": 0, "available": 1_500_000_000}, + 3: {"total": 800_000_000, "locked": 200_000_000, "available": 600_000_000}, + }, + "ck2": {}, + } + mocker.patch.object(subtensor, "query_runtime_api", return_value=fake_result) + + # Call + result = subtensor.get_stake_availability_for_coldkeys( + coldkey_ss58s=fake_coldkeys, netuids=[2, 3], block=100 + ) + + # Asserts + subtensor.query_runtime_api.assert_called_once_with( + runtime_api="StakeInfoRuntimeApi", + method="get_stake_availability_for_coldkeys", + params=[fake_coldkeys, [2, 3]], + block=100, + ) + assert result == fake_result + + +def test_get_stake_availability_for_coldkeys_netuids_none(subtensor, mocker): + """Tests get_stake_availability_for_coldkeys passes None netuids correctly.""" + # Preps + fake_result = {"ck1": {1: {"total": 100, "locked": 0, "available": 100}}} + mocker.patch.object(subtensor, "query_runtime_api", return_value=fake_result) + + # Call + result = subtensor.get_stake_availability_for_coldkeys(coldkey_ss58s=["ck1"]) + + # Asserts + subtensor.query_runtime_api.assert_called_once_with( + runtime_api="StakeInfoRuntimeApi", + method="get_stake_availability_for_coldkeys", + params=[["ck1"], None], + block=None, + ) + assert result == fake_result + + def test_get_unstake_fee(subtensor, mocker): """Verify that `get_unstake_fee` calls proper methods and returns the correct value.""" # Preps From 7d8f1f35cb382b82c12b79e8367b47f4f4a3fe9e Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Tue, 16 Jun 2026 09:45:29 -0700 Subject: [PATCH 51/59] update e2e test --- tests/e2e_tests/test_lock_stake.py | 72 ++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/tests/e2e_tests/test_lock_stake.py b/tests/e2e_tests/test_lock_stake.py index d57fe8329c..7fc900208e 100644 --- a/tests/e2e_tests/test_lock_stake.py +++ b/tests/e2e_tests/test_lock_stake.py @@ -346,6 +346,40 @@ def test_non_owner_lock_lifecycle(subtensor, alice_wallet, bob_wallet, charlie_w assert lock_info["conviction"] < 1.0 assert lock_info["last_update"] > 0 + # Verify stake availability batch query: Bob has stake+lock, Alice (subnet owner) has no lock + availability = subtensor.staking.get_stake_availability_for_coldkeys( + coldkey_ss58s=[ + bob_wallet.coldkey.ss58_address, + alice_wallet.coldkey.ss58_address, + ], + netuids=[alice_sn.netuid], + ) + logging.console.info(f"Stake availability: {availability}") + bob_avail = availability[bob_wallet.coldkey.ss58_address] + assert alice_sn.netuid in bob_avail + bob_subnet = bob_avail[alice_sn.netuid] + assert bob_subnet["total"] > 0 + assert bob_subnet["locked"] <= lock_info["locked_mass"].rao + assert bob_subnet["locked"] > lock_info["locked_mass"].rao * 0.99 + assert bob_subnet["available"] == bob_subnet["total"] - bob_subnet["locked"] + # Alice (subnet owner) has stake but no lock — locked should be 0, available == total + alice_subnet = availability[alice_wallet.coldkey.ss58_address][alice_sn.netuid] + assert alice_subnet["locked"] == 0 + assert alice_subnet["available"] == alice_subnet["total"] + + # netuids=None should also include Bob's subnet + availability_all = subtensor.staking.get_stake_availability_for_coldkeys( + coldkey_ss58s=[bob_wallet.coldkey.ss58_address], + ) + assert alice_sn.netuid in availability_all[bob_wallet.coldkey.ss58_address] + + # Non-existent netuid should yield empty inner dicts + availability_empty = subtensor.staking.get_stake_availability_for_coldkeys( + coldkey_ss58s=[bob_wallet.coldkey.ss58_address], + netuids=[999], + ) + assert availability_empty[bob_wallet.coldkey.ss58_address] == {} + # Hotkey conviction should be near zero for a fresh non-owner lock conviction = subtensor.staking.get_hotkey_conviction( hotkey_ss58=bob_wallet.hotkey.ss58_address, @@ -491,6 +525,44 @@ async def test_non_owner_lock_lifecycle_async( assert lock_info["conviction"] < 1.0 assert lock_info["last_update"] > 0 + # Verify stake availability batch query: Bob has stake+lock, Alice (subnet owner) has no lock + availability = await async_subtensor.staking.get_stake_availability_for_coldkeys( + coldkey_ss58s=[ + bob_wallet.coldkey.ss58_address, + alice_wallet.coldkey.ss58_address, + ], + netuids=[alice_sn.netuid], + ) + logging.console.info(f"Stake availability: {availability}") + bob_avail = availability[bob_wallet.coldkey.ss58_address] + assert alice_sn.netuid in bob_avail + bob_subnet = bob_avail[alice_sn.netuid] + assert bob_subnet["total"] > 0 + assert bob_subnet["locked"] <= lock_info["locked_mass"].rao + assert bob_subnet["locked"] > lock_info["locked_mass"].rao * 0.99 + assert bob_subnet["available"] == bob_subnet["total"] - bob_subnet["locked"] + # Alice (subnet owner) has stake but no lock — locked should be 0, available == total + alice_subnet = availability[alice_wallet.coldkey.ss58_address][alice_sn.netuid] + assert alice_subnet["locked"] == 0 + assert alice_subnet["available"] == alice_subnet["total"] + + # netuids=None should also include Bob's subnet + availability_all = ( + await async_subtensor.staking.get_stake_availability_for_coldkeys( + coldkey_ss58s=[bob_wallet.coldkey.ss58_address], + ) + ) + assert alice_sn.netuid in availability_all[bob_wallet.coldkey.ss58_address] + + # Non-existent netuid should yield empty inner dicts + availability_empty = ( + await async_subtensor.staking.get_stake_availability_for_coldkeys( + coldkey_ss58s=[bob_wallet.coldkey.ss58_address], + netuids=[999], + ) + ) + assert availability_empty[bob_wallet.coldkey.ss58_address] == {} + # Hotkey conviction should be near zero for a fresh non-owner lock conviction = await async_subtensor.staking.get_hotkey_conviction( hotkey_ss58=bob_wallet.hotkey.ss58_address, From 9a12ff169305ca8e768a27170cd6e433c2c876dc Mon Sep 17 00:00:00 2001 From: Thykof Date: Tue, 16 Jun 2026 19:22:47 +0200 Subject: [PATCH 52/59] fix(axon): reject requests with missing signature in default_verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit default_verify gated the signature check behind `if synapse.dendrite.signature`, so a request with an empty or absent signature skipped verification entirely. This lets anyone impersonate any dendrite hotkey (e.g. a high-stake validator) by simply omitting the signature — the request then passes verify and reaches blacklist/forward as if it were genuinely signed. Require a signature to be present, mirroring the existing "Missing Nonce" guard, then verify it. A present-but-invalid signature was already rejected; this closes the empty-signature hole. --- bittensor/core/axon.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bittensor/core/axon.py b/bittensor/core/axon.py index 3fd9dd46c2..7d5c5d1a2d 100644 --- a/bittensor/core/axon.py +++ b/bittensor/core/axon.py @@ -974,9 +974,10 @@ async def default_verify(self, synapse: "Synapse"): ): raise Exception("Nonce is too old, a newer one was last processed") - if synapse.dendrite.signature and not keypair.verify( - message, synapse.dendrite.signature - ): + if not synapse.dendrite.signature: + raise Exception("Missing signature") + + if not keypair.verify(message, synapse.dendrite.signature): raise Exception( f"Signature mismatch with {message} and {synapse.dendrite.signature}" ) From a187d2608bab83a1ef47f893efc0d904c9df56b0 Mon Sep 17 00:00:00 2001 From: Thykof Date: Tue, 16 Jun 2026 19:22:47 +0200 Subject: [PATCH 53/59] fix(axon): align case for error message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit default_verify gated the signature check behind `if synapse.dendrite.signature`, so a request with an empty or absent signature skipped verification entirely. This lets anyone impersonate any dendrite hotkey (e.g. a high-stake validator) by simply omitting the signature — the request then passes verify and reaches blacklist/forward as if it were genuinely signed. Require a signature to be present, mirroring the existing "Missing Nonce" guard, then verify it. A present-but-invalid signature was already rejected; this closes the empty-signature hole. --- bittensor/core/axon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bittensor/core/axon.py b/bittensor/core/axon.py index 7d5c5d1a2d..61573ae6fc 100644 --- a/bittensor/core/axon.py +++ b/bittensor/core/axon.py @@ -975,7 +975,7 @@ async def default_verify(self, synapse: "Synapse"): raise Exception("Nonce is too old, a newer one was last processed") if not synapse.dendrite.signature: - raise Exception("Missing signature") + raise Exception("Missing Signature") if not keypair.verify(message, synapse.dendrite.signature): raise Exception( From 2071469cbecba957d0ae5c22b42c1caf5d281757 Mon Sep 17 00:00:00 2001 From: Thykof Date: Thu, 18 Jun 2026 15:44:27 +0200 Subject: [PATCH 54/59] test(axon): cover missing/empty signature in default_verify Exercise Axon.default_verify directly: a valid signature passes (nonce recorded), an empty or absent signature now raises "Missing Signature" (the auth-bypass regression this branch fixes), and a present-but-invalid signature still raises "Signature mismatch". Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/unit_tests/test_axon.py | 81 ++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/test_axon.py b/tests/unit_tests/test_axon.py index d1b8e03a97..75c3bd4f11 100644 --- a/tests/unit_tests/test_axon.py +++ b/tests/unit_tests/test_axon.py @@ -21,7 +21,7 @@ from bittensor.core.errors import RunException from bittensor.core.settings import version_as_int from bittensor.core.stream import StreamingSynapse -from bittensor.core.synapse import Synapse +from bittensor.core.synapse import Synapse, TerminalInfo from bittensor.core.threadpool import PriorityThreadPoolExecutor from bittensor.utils.axon_utils import ( allowed_nonce_window_ns, @@ -29,6 +29,7 @@ ALLOWED_DELTA, NANOSECONDS_IN_SECOND, ) +from bittensor_wallet import Keypair def test_attach_initial(mock_get_external_ip): @@ -755,6 +756,84 @@ def test_nonce_diff_seconds(nonce_offset_seconds): ) +# --------------------------------------------------------------------------- +# default_verify signature checks +# --------------------------------------------------------------------------- +def _make_default_verify_inputs(): + """Build an (axon, synapse, dendrite_keypair) trio for default_verify. + + The synapse carries a fresh nonce (so the v7.2 freshness check passes) and + a valid dendrite hotkey; each test sets `synapse.dendrite.signature` to + probe the signature branch. + """ + dendrite_keypair = Keypair.create_from_uri("//Alice") + receiver_keypair = Keypair.create_from_uri("//Bob") + axon = Axon( + ip="192.0.2.1", + external_ip="192.0.2.1", + wallet=MockWallet(MockHotkey(receiver_keypair.ss58_address)), + ) + synapse = SynapseMock() + synapse.dendrite = TerminalInfo( + hotkey=dendrite_keypair.ss58_address, + nonce=time.time_ns(), + uuid="5ecbd69c-1cec-11ee-b0dc-e29ce36fec1a", + version=version_as_int, + ) + return axon, synapse, dendrite_keypair + + +def _default_verify_message(axon, synapse): + return ( + f"{synapse.dendrite.nonce}.{synapse.dendrite.hotkey}." + f"{axon.wallet.hotkey.ss58_address}.{synapse.dendrite.uuid}." + f"{synapse.computed_body_hash}" + ) + + +@pytest.mark.asyncio +async def test_default_verify_valid_signature_passes(): + axon, synapse, dendrite_keypair = _make_default_verify_inputs() + message = _default_verify_message(axon, synapse) + synapse.dendrite.signature = "0x" + dendrite_keypair.sign(message).hex() + + await axon.default_verify(synapse) + + endpoint_key = f"{synapse.dendrite.hotkey}:{synapse.dendrite.uuid}" + assert axon.nonces[endpoint_key] == synapse.dendrite.nonce + + +@pytest.mark.asyncio +async def test_default_verify_empty_signature_raises(): + # Regression: an empty signature must NOT skip verification. Previously the + # check was `if signature and not verify(...)`, so an empty signature + # short-circuited and the request was accepted, allowing hotkey spoofing. + axon, synapse, _ = _make_default_verify_inputs() + synapse.dendrite.signature = "" + + with pytest.raises(Exception, match="Missing Signature"): + await axon.default_verify(synapse) + + +@pytest.mark.asyncio +async def test_default_verify_missing_signature_raises(): + axon, synapse, _ = _make_default_verify_inputs() + synapse.dendrite.signature = None + + with pytest.raises(Exception, match="Missing Signature"): + await axon.default_verify(synapse) + + +@pytest.mark.asyncio +async def test_default_verify_wrong_signature_raises(): + # A present-but-invalid signature is still rejected (unchanged behavior). + axon, synapse, _ = _make_default_verify_inputs() + synapse.dendrite.signature = "0x" + ("00" * 64) + + with pytest.raises(Exception, match="Signature mismatch"): + await axon.default_verify(synapse) + + # Mimicking axon default_verify nonce verification # True: Nonce is fresh, False: Nonce is old def is_nonce_within_allowed_window(synapse_nonce, allowed_window_ns): From 06bca61c7d2127245d481e7041232553b68a5e59 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Thu, 18 Jun 2026 10:19:16 -0700 Subject: [PATCH 55/59] update bittensor-drand deps --- pyproject.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d5c6f93a2f..fea33ca61a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,8 +29,7 @@ dependencies = [ "pydantic>=2.3,<3", "cyscale==0.5.0", "uvicorn", -# "bittensor-drand>=2.0.0,<3.0.0", - "bittensor-drand @ git+https://github.com/latent-to/bittensor-drand.git@feat/basfroman/dynamic-tempo-support", ### temporarelly + "bittensor-drand>=2.0.0,<3.0.0", "bittensor-wallet>=4.1.0", "async-substrate-interface>=2.0.4,<3.0.0", ] From e595a257c18765e1369437e24bfaff6be075108c Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Thu, 18 Jun 2026 10:47:24 -0700 Subject: [PATCH 56/59] extend the range of expected_commit_block bc of fast blocks --- tests/e2e_tests/test_dynamic_tempo.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/e2e_tests/test_dynamic_tempo.py b/tests/e2e_tests/test_dynamic_tempo.py index 7b19c20c2a..12fb1f9acb 100644 --- a/tests/e2e_tests/test_dynamic_tempo.py +++ b/tests/e2e_tests/test_dynamic_tempo.py @@ -362,7 +362,7 @@ def test_commit_reveal_after_owner_set_tempo(subtensor, alice_wallet): f"Current block: {current_block}, next epoch: {upcoming_tempo}" ) - expected_commit_block = subtensor.block + 1 + expected_commit_block = subtensor.block + 2 response = subtensor.extrinsics.set_weights( wallet=alice_wallet, netuid=netuid, @@ -388,9 +388,9 @@ def test_commit_reveal_after_owner_set_tempo(subtensor, alice_wallet): assert expected_reveal_round == reveal_round assert address == alice_wallet.hotkey.ss58_address assert expected_commit_block in [ - commit_block - 1, + commit_block - 2, commit_block, - commit_block + 1, + commit_block + 2, ] assert subtensor.subnets.weights(netuid=netuid, mechid=0) == [] @@ -473,7 +473,7 @@ async def test_commit_reveal_after_owner_set_tempo_async(async_subtensor, alice_ f"Current block: {current_block}, next epoch: {upcoming_tempo}" ) - expected_commit_block = await async_subtensor.block + 1 + expected_commit_block = await async_subtensor.block + 2 response = await async_subtensor.extrinsics.set_weights( wallet=alice_wallet, netuid=netuid, @@ -499,9 +499,9 @@ async def test_commit_reveal_after_owner_set_tempo_async(async_subtensor, alice_ assert expected_reveal_round == reveal_round assert address == alice_wallet.hotkey.ss58_address assert expected_commit_block in [ - commit_block - 1, + commit_block - 2, commit_block, - commit_block + 1, + commit_block + 2, ] assert await async_subtensor.subnets.weights(netuid=netuid, mechid=0) == [] From 183cf61c85a8dd0d1cd73a182de01043f2683050 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Thu, 18 Jun 2026 11:02:12 -0700 Subject: [PATCH 57/59] opps --- tests/e2e_tests/test_dynamic_tempo.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/tests/e2e_tests/test_dynamic_tempo.py b/tests/e2e_tests/test_dynamic_tempo.py index 12fb1f9acb..94e6de3a05 100644 --- a/tests/e2e_tests/test_dynamic_tempo.py +++ b/tests/e2e_tests/test_dynamic_tempo.py @@ -387,11 +387,7 @@ def test_commit_reveal_after_owner_set_tempo(subtensor, alice_wallet): 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 [ - commit_block - 2, - commit_block, - commit_block + 2, - ] + 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 @@ -498,11 +494,8 @@ async def test_commit_reveal_after_owner_set_tempo_async(async_subtensor, alice_ 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 [ - commit_block - 2, - commit_block, - commit_block + 2, - ] + 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 = ( From 49260fe1a2b3b95488b126b12bf0e78d65a349ae Mon Sep 17 00:00:00 2001 From: Thykof Date: Fri, 19 Jun 2026 12:35:02 +0200 Subject: [PATCH 58/59] test(axon): merge empty/None signature tests into single parametrized test Co-Authored-By: Claude Sonnet 4.6 --- tests/unit_tests/test_axon.py | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/tests/unit_tests/test_axon.py b/tests/unit_tests/test_axon.py index 75c3bd4f11..0712e50dc1 100644 --- a/tests/unit_tests/test_axon.py +++ b/tests/unit_tests/test_axon.py @@ -804,21 +804,13 @@ async def test_default_verify_valid_signature_passes(): @pytest.mark.asyncio -async def test_default_verify_empty_signature_raises(): - # Regression: an empty signature must NOT skip verification. Previously the - # check was `if signature and not verify(...)`, so an empty signature +@pytest.mark.parametrize("empty_sig", ["", None]) +async def test_default_verify_missing_signature_raises(empty_sig): + # Regression: a falsy signature must NOT skip verification. Previously the + # check was `if signature and not verify(...)`, so an empty/None signature # short-circuited and the request was accepted, allowing hotkey spoofing. axon, synapse, _ = _make_default_verify_inputs() - synapse.dendrite.signature = "" - - with pytest.raises(Exception, match="Missing Signature"): - await axon.default_verify(synapse) - - -@pytest.mark.asyncio -async def test_default_verify_missing_signature_raises(): - axon, synapse, _ = _make_default_verify_inputs() - synapse.dendrite.signature = None + synapse.dendrite.signature = empty_sig with pytest.raises(Exception, match="Missing Signature"): await axon.default_verify(synapse) From 1267a1018e83ff877ac7d899107554e35d8d1933 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Thu, 25 Jun 2026 14:25:02 -0700 Subject: [PATCH 59/59] CHANGELOG.md up to date --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccab8724f8..7511946218 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,6 @@ ## 10.5.0 /2026-06-25 ## What's Changed -* Fix for flaky test_staking tests by @thewhaleking in https://github.com/latent-to/bittensor/pull/3367 -* Fix for flaky root claims tests by @thewhaleking in https://github.com/latent-to/bittensor/pull/3366 * Fix after aiohttp latest release by @basfroman in https://github.com/latent-to/bittensor/pull/3369 * Forward version_key from commit_weights by @Yupsecous in https://github.com/latent-to/bittensor/pull/3368 * Update docstrings for kill_pure_proxy_extrinsic by @chideraao in https://github.com/latent-to/bittensor/pull/3374 @@ -18,7 +16,7 @@ * @Yupsecous made their first contribution in https://github.com/latent-to/bittensor/pull/3368 * @kilyanni made their first contribution in https://github.com/latent-to/bittensor/pull/3377 -**Full Changelog**: https://github.com/latent-to/bittensor/compare/v10.4.0...v10.5.0 +**Full Changelog**: https://github.com/latent-to/bittensor/compare/v10.4.1...v10.5.0 ## 10.4.1 /2026-06-11