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 }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e4c16a27c..7511946218 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 10.5.0 /2026-06-25 + +## What's Changed +* 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.1...v10.5.0 + ## 10.4.1 /2026-06-11 ## What's Changed diff --git a/bittensor/core/async_subtensor.py b/bittensor/core/async_subtensor.py index c83cd30a80..e1f1d1b51d 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, @@ -29,8 +29,10 @@ ProposalVoteData, ProxyAnnouncementInfo, ProxyConstants, + ProxyFilterInfo, ProxyInfo, ProxyType, + ProxyTypeInfo, RootClaimType, SelectiveMetagraphIndex, SimSwapResult, @@ -80,12 +82,12 @@ remove_liquidity_extrinsic, toggle_user_liquidity_extrinsic, ) -from bittensor.core.extrinsics.asyncex.mev_shield import submit_encrypted_extrinsic from bittensor.core.extrinsics.asyncex.lock import ( lock_stake_extrinsic, move_lock_extrinsic, set_perpetual_lock_extrinsic, ) +from bittensor.core.extrinsics.asyncex.mev_shield import submit_encrypted_extrinsic from bittensor.core.extrinsics.asyncex.move_stake import ( move_stake_extrinsic, swap_stake_extrinsic, @@ -127,6 +129,12 @@ ) from bittensor.core.extrinsics.asyncex.start_call import start_call_extrinsic from bittensor.core.extrinsics.asyncex.take import set_take_extrinsic +from bittensor.core.extrinsics.asyncex.tempo_control import ( + root_set_activity_cutoff_factor_extrinsic, + set_activity_cutoff_factor_extrinsic, + set_tempo_extrinsic, + trigger_epoch_extrinsic, +) from bittensor.core.extrinsics.asyncex.transfer import transfer_extrinsic from bittensor.core.extrinsics.asyncex.unstaking import ( unstake_all_extrinsic, @@ -151,13 +159,13 @@ ) from bittensor.core.types import ( BlockInfo, + EpochScheduleState, ExtrinsicResponse, LockState, Salt, SubtensorMixin, UIDs, Weights, - PositionResponse, NeuronCertificateResponse, CommitmentOfResponse, CrowdloansResponse, @@ -165,7 +173,9 @@ ) from bittensor.utils import ( Certificate, + ChainFeatureDisabledWarning, decode_hex_identity_dict, + deprecated_message, format_error_message, get_caller_name, get_mechid_storage_index, @@ -174,19 +184,13 @@ u64_normalized_float, validate_max_attempts, ) +from bittensor.utils import epoch_schedule from bittensor.utils.balance import ( Balance, check_balance_amount, 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 @@ -462,11 +466,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 +487,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 +496,25 @@ 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 retrieved_block_hash - # Return the appropriate value. - if 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( @@ -1110,36 +1123,35 @@ async def blocks_since_last_update( async def blocks_until_next_epoch( self, netuid: int, - tempo: Optional[int] = None, + *, block: Optional[int] = None, block_hash: Optional[str] = None, reuse_block: bool = False, ) -> Optional[int]: - """Returns the number of blocks until the next epoch of subnet with provided netuid. + """Returns the number of blocks until the next epoch for the given subnet. + + Derives the answer from the ``get_next_epoch_start_block`` runtime API. Parameters: netuid: The unique identifier of the subnetwork. - tempo: The tempo of the subnet. - block: The block number to query. Do not specify if using block_hash or reuse_block. - block_hash: The block hash at which to check the parameter. Do not set if using block or reuse_block. - reuse_block: Whether to reuse the last-used block hash. Do not set if using block_hash or block. + block: The block number to query. Do not specify if using ``block_hash`` or ``reuse_block``. + block_hash: The block hash at which to check. Do not set if using ``block`` or ``reuse_block``. + reuse_block: Whether to reuse the last-used block hash. Returns: - The number of blocks until the next epoch of the subnet with provided netuid. + The number of blocks remaining, or ``None`` if the subnet has + tempo 0 (no epochs). """ block_hash = await self.determine_block_hash(block, block_hash, reuse_block) - block = block or await self.substrate.get_block_number(block_hash=block_hash) - tempo = tempo or await self.tempo(netuid=netuid, block_hash=block_hash) - - if not tempo: + block_number = block or await self.substrate.get_block_number( + block_hash=block_hash + ) + next_start = await self.get_next_epoch_start_block( + netuid, block_hash=block_hash + ) + if next_start is None: return None - - # the logic is the same as in SubtensorModule:blocks_until_next_epoch - netuid_plus_one = int(netuid) + 1 - tempo_plus_one = tempo + 1 - adjusted_block = (block + netuid_plus_one) % (2**64) - remainder = adjusted_block % tempo_plus_one - return tempo - remainder + return max(0, next_start - block_number) async def bonds( self, @@ -1306,6 +1318,33 @@ async def does_hotkey_exist( ) return result.value != "5C4hrfjw9DjXZTzV3MwzrrAr9P1MJhSrvWGWqi1eSuyUpnhM" + async def get_activity_cutoff_factor_milli( + self, + netuid: int, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> int: + """Returns the activity cutoff factor (per-mille) for the given subnet. + + Parameters: + netuid: The unique identifier of the subnetwork. + block: The block number to query. Do not specify if using `block_hash` or `reuse_block`. + block_hash: The block hash at which to check. Do not set if using `block` or `reuse_block`. + reuse_block: Whether to reuse the last-used block hash. + + Returns: + The activity cutoff factor in per-mille units. + """ + query = await self.query_subtensor( + name="ActivityCutoffFactorMilli", + block=block, + block_hash=block_hash, + reuse_block=reuse_block, + params=[netuid], + ) + return query.value + async def get_admin_freeze_window( self, block: Optional[int] = None, @@ -2828,6 +2867,59 @@ async def get_ema_tao_inflow( # TODO verify this from rao, seems like we're just rounding down return block_updated, Balance.from_rao(ema_value) + async def get_epoch_schedule_state( + self, + netuid: int, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> "EpochScheduleState": + """Returns a snapshot of all epoch-related storage for the given subnet. + + All fields are read at the same block to ensure consistency. + Used by `bittensor-drand` v2 for commit/reveal prediction. + + Parameters: + netuid: The unique identifier of the subnetwork. + block: The block number to query. Do not specify if using `block_hash` or `reuse_block`. + block_hash: The block hash at which to check. Do not set if using `block` or `reuse_block`. + reuse_block: Whether to reuse the last-used block hash. + + Returns: + An `EpochScheduleState` populated from on-chain storage. + """ + block_hash = ( + await self.determine_block_hash(block, block_hash, reuse_block) + or await self.substrate.get_chain_head() + ) + + block_number = block or await self.substrate.get_block_number( + block_hash=block_hash + ) + + ( + last_epoch_block, + pending_epoch_at, + subnet_epoch_index, + tempo, + blocks_since_last_step, + ) = await asyncio.gather( + self.get_last_epoch_block(netuid, block_hash=block_hash), + self.get_pending_epoch_at(netuid, block_hash=block_hash), + self.get_subnet_epoch_index(netuid, block_hash=block_hash), + self.tempo(netuid, block_hash=block_hash), + self.blocks_since_last_step(netuid, block_hash=block_hash), + ) + + return EpochScheduleState( + last_epoch_block=last_epoch_block, + pending_epoch_at=pending_epoch_at, + subnet_epoch_index=subnet_epoch_index, + tempo=tempo, + blocks_since_last_step=blocks_since_last_step, + current_block=block_number, + ) + async def get_hotkey_conviction( self, hotkey_ss58: str, @@ -2970,6 +3062,33 @@ async def get_last_commitment_bonds_reset_block( ) return block_data.value + async def get_last_epoch_block( + self, + netuid: int, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> int: + """Returns the block number at which the last epoch ran for the given subnet. + + Parameters: + netuid: The unique identifier of the subnetwork. + block: The block number to query. Do not specify if using `block_hash` or `reuse_block`. + block_hash: The block hash at which to check. Do not set if using `block` or `reuse_block`. + reuse_block: Whether to reuse the last-used block hash. + + Returns: + The block number of the most recent epoch. + """ + query = await self.query_subtensor( + name="LastEpochBlock", + block=block, + block_hash=block_hash, + reuse_block=reuse_block, + params=[netuid], + ) + return query.value + async def get_liquidity_list( self, wallet: "Wallet", @@ -2977,9 +3096,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. @@ -2989,173 +3107,110 @@ 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 + 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, + ) + return [] - if not await self.is_subnet_active(netuid=netuid): - logging.debug(f"Subnet {netuid} is not active.") - return None + 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). - 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 [] + 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. - block_hash = await self.determine_block_hash( - block=block, block_hash=block_hash, reuse_block=reuse_block - ) + 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. - # 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], + 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, ) - ( - 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, - ], + 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, ) - # 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, - ) + if result is None: + raise Exception("Unable to retrieve MaxEpochsPerBlock constant.") - # 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, - ) + return result.value - 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"), - ) - ) + 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 positions + return result.value async def get_mechanism_emission_split( self, @@ -3416,6 +3471,68 @@ async def get_mev_shield_next_key( return public_key_bytes + async def get_min_activity_cutoff_factor_milli( + self, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> int: + """Returns the lower bound for the activity-cutoff factor (per-mille). + + This chain constant defines the minimum value a subnet owner can set for the activity-cutoff factor via + ``set_activity_cutoff_factor``. The factor is expressed in per-mille units relative to the subnet's tempo. + + Parameters: + block: The blockchain block number for the query. + block_hash: The block hash at which to query. + reuse_block: Whether to reuse the last-used blockchain block hash. + + Returns: + The lower bound for the activity-cutoff factor in per-mille. + """ + block_hash = await self.determine_block_hash(block, block_hash, reuse_block) + result = await self.substrate.get_constant( + module_name="SubtensorModule", + constant_name="MinActivityCutoffFactorMilli", + block_hash=block_hash, + ) + + if result is None: + raise Exception("Unable to retrieve MinActivityCutoffFactorMilli constant.") + + return result.value + + async def get_min_tempo( + self, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> int: + """Returns the lower bound for owner-set tempo. + + This chain constant defines the minimum epoch period (in blocks) that a subnet owner can configure via + ``set_tempo``. Also serves as the fixed cooldown between consecutive ``set_tempo`` calls. + + Parameters: + block: The blockchain block number for the query. + block_hash: The block hash at which to query. + reuse_block: Whether to reuse the last-used blockchain block hash. + + Returns: + The minimum allowed tempo value in blocks. + """ + block_hash = await self.determine_block_hash(block, block_hash, reuse_block) + result = await self.substrate.get_constant( + module_name="SubtensorModule", + constant_name="MinTempo", + block_hash=block_hash, + ) + + if result is None: + raise Exception("Unable to retrieve MinTempo constant.") + + return result.value + async def get_minimum_required_stake(self) -> Balance: """Returns the minimum required stake threshold for nominator cleanup operations. @@ -3596,39 +3713,34 @@ async def get_next_epoch_start_block( block_hash: Optional[str] = None, reuse_block: bool = False, ) -> Optional[int]: - """ - Calculates the first block number of the next epoch for the given subnet. + """Returns the block at which the next epoch will fire for the given subnet. - If `block` is not provided, the current chain block will be used. Epochs are determined based on the subnet's - tempo (i.e., blocks per epoch). The result is the block number at which the next epoch will begin. + Delegates to the ``SubnetInfoRuntimeApi.get_next_epoch_start_block`` + runtime API, which accounts for both the auto-timer + (``last_epoch_block + tempo``) and any pending owner-triggered epoch. Parameters: netuid: The unique identifier of the subnet. - block: The reference block to calculate from. If `None`, uses the current chain block height. - block_hash: The blockchain block number at which to perform the query. + block: The reference block to query. If ``None``, uses the current chain head. + block_hash: The blockchain block hash at which to perform the query. reuse_block: Whether to reuse the last-used blockchain block hash. Returns: - int: The block number at which the next epoch will start. + The block number at which the next epoch will start, or ``None`` + if tempo is 0 (subnet does not run epochs). Notes: - """ - block_hash = await self.determine_block_hash(block, block_hash, reuse_block) - tempo = await self.tempo(netuid=netuid, block_hash=block_hash) - current_block = block or await self.block - - if not tempo: - return None - - blocks_until = await self.blocks_until_next_epoch( - netuid=netuid, tempo=tempo, block_hash=block_hash + result = await self.query_runtime_api( + runtime_api="SubnetInfoRuntimeApi", + method="get_next_epoch_start_block", + params=[netuid], + block=block, + block_hash=block_hash, + reuse_block=reuse_block, ) - - if blocks_until is None: - return None - - return current_block + blocks_until + 1 + return None if result is None else int(result) async def get_owned_hotkeys( self, @@ -3659,6 +3771,30 @@ async def get_owned_hotkeys( return owned_hotkeys.value or [] + async def get_owner_hyperparam_rate_limit( + self, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> int: + """Returns the owner hyperparameter rate limit (in tempos). + + Parameters: + block: The block number to query. Do not specify if using `block_hash` or `reuse_block`. + block_hash: The block hash at which to check. Do not set if using `block` or `reuse_block`. + reuse_block: Whether to reuse the last-used block hash. + + Returns: + The rate limit in tempos. + """ + block_hash = await self.determine_block_hash(block, block_hash, reuse_block) + query = await self.substrate.query( + module="SubtensorModule", + storage_function="OwnerHyperparamRateLimit", + block_hash=block_hash, + ) + return query.value + async def get_parents( self, hotkey_ss58: str, @@ -3702,6 +3838,33 @@ async def get_parents( return [] + async def get_pending_epoch_at( + self, + netuid: int, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> int: + """Returns the pending (owner-triggered) epoch block, or 0 if none is scheduled. + + Parameters: + netuid: The unique identifier of the subnetwork. + block: The block number to query. Do not specify if using `block_hash` or `reuse_block`. + block_hash: The block hash at which to check. Do not set if using `block` or `reuse_block`. + reuse_block: Whether to reuse the last-used block hash. + + Returns: + The block at which the triggered epoch will fire, or 0. + """ + query = await self.query_subtensor( + name="PendingEpochAt", + block=block, + block_hash=block_hash, + reuse_block=reuse_block, + params=[netuid], + ) + return query.value + async def get_proxies( self, block: Optional[int] = None, @@ -3909,6 +4072,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, @@ -4126,14 +4358,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( @@ -4302,6 +4535,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, @@ -4686,6 +4965,33 @@ async def get_subnet_burn_cost( else: return lock_cost + async def get_subnet_epoch_index( + self, + netuid: int, + block: Optional[int] = None, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> int: + """Returns the monotonic epoch counter for the given subnet. + + Parameters: + netuid: The unique identifier of the subnetwork. + block: The block number to query. Do not specify if using `block_hash` or `reuse_block`. + block_hash: The block hash at which to check. Do not set if using `block` or `reuse_block`. + reuse_block: Whether to reuse the last-used block hash. + + Returns: + The current epoch index. + """ + query = await self.query_subtensor( + name="SubnetEpochIndex", + block=block, + block_hash=block_hash, + reuse_block=reuse_block, + params=[netuid], + ) + return query.value + async def get_subnet_hyperparameters( self, netuid: int, @@ -4693,9 +4999,10 @@ async def get_subnet_hyperparameters( block_hash: Optional[str] = None, reuse_block: bool = False, ) -> Optional["SubnetHyperparameters"]: - """ - Retrieves the hyperparameters for a specific subnet within the Bittensor network. These hyperparameters define - the operational settings and rules governing the subnet's behavior. + """Retrieves the hyperparameters for a specific subnet. + + Tries the v3 ``Vec`` runtime API first, then falls + back to v2 and v1 struct APIs at the requested block. Parameters: netuid: The network UID of the subnet to query. @@ -4704,24 +5011,21 @@ async def get_subnet_hyperparameters( reuse_block: Whether to reuse the last-used blockchain hash. Returns: - The subnet's hyperparameters, or `None` if not available. - - Understanding the hyperparameters is crucial for comprehending how subnets are configured and managed, and how - they interact with the network's consensus and incentive mechanisms. + The subnet's hyperparameters, or ``None`` if not available. """ - result = await self.query_runtime_api( - runtime_api="SubnetInfoRuntimeApi", - method="get_subnet_hyperparams_v2", - params=[netuid], + block_hash = await self.determine_block_hash( block=block, block_hash=block_hash, reuse_block=reuse_block, ) - - if not result: - return None - - return SubnetHyperparameters.from_dict(result) + result = await self._runtime_call_with_fallback( + ("SubnetInfoRuntimeApi", "get_subnet_hyperparams_v3", [netuid]), + ("SubnetInfoRuntimeApi", "get_subnet_hyperparams_v2", [netuid]), + ("SubnetInfoRuntimeApi", "get_subnet_hyperparams", [netuid]), + block_hash=block_hash, + default_value=None, + ) + return SubnetHyperparameters.from_any(result) if result else None async def get_subnet_info( self, @@ -4846,23 +5150,37 @@ 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( + list[dict], + await 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)}) + 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( @@ -5291,10 +5609,11 @@ async def is_in_admin_freeze_window( block_hash: Optional[str] = None, reuse_block: bool = False, ) -> bool: - """ - Returns True if the current block is within the terminal freeze window of the tempo - for the given subnet. During this window, admin ops are prohibited to avoid interference - with validator weight submissions. + """Returns whether owner operations are currently blocked for the subnet. + + Matches the chain's ``is_in_admin_freeze_window`` logic: a pending + triggered epoch in the future **or** fewer than ``admin_freeze_window`` + blocks remaining until the next auto epoch. Parameters: netuid: The unique identifier of the subnet. @@ -5303,28 +5622,35 @@ async def is_in_admin_freeze_window( reuse_block: Whether to reuse the last-used blockchain block hash. Returns: - bool: True if in freeze window, else False. + ``True`` if in freeze window, ``False`` otherwise. """ - # SN0 doesn't have admin_freeze_window if netuid == 0: return False - next_epoch_start_block, window = await asyncio.gather( - self.get_next_epoch_start_block( - netuid=netuid, - block=block, - block_hash=block_hash, - reuse_block=reuse_block, - ), - self.get_admin_freeze_window( - block=block, block_hash=block_hash, reuse_block=reuse_block - ), + block_hash = await self.determine_block_hash(block, block_hash, reuse_block) + block_number = block or await self.substrate.get_block_number( + block_hash=block_hash + ) + + ( + 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), ) - if next_epoch_start_block is not None: - remaining = next_epoch_start_block - await self.block - return remaining < window - return False + 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. @@ -7041,6 +7367,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, @@ -7490,9 +7817,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 @@ -8670,6 +8996,53 @@ async def root_register( wait_for_revealed_execution=wait_for_revealed_execution, ) + async def root_set_activity_cutoff_factor( + self, + wallet: "Wallet", + netuid: int, + factor_milli: int, + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = DEFAULT_PERIOD, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, + ) -> ExtrinsicResponse: + """ + Sets the activity cutoff factor via sudo (root only). + + Parameters: + wallet: The wallet used to sign the extrinsic. + netuid: The unique identifier of the subnet. + factor_milli: Activity cutoff factor in per-mille units. + mev_protection: If `True`, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If `False`, submits the transaction directly without encryption. + period: The number of blocks during which the transaction will remain valid after it's + submitted. If the transaction is not included in a block within that number of blocks, it will expire + and be rejected. You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + return await root_set_activity_cutoff_factor_extrinsic( + subtensor=self, + wallet=wallet, + netuid=netuid, + factor_milli=factor_milli, + mev_protection=mev_protection, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + async def root_set_pending_childkey_cooldown( self, wallet: "Wallet", @@ -8716,6 +9089,53 @@ async def root_set_pending_childkey_cooldown( wait_for_revealed_execution=wait_for_revealed_execution, ) + async def set_activity_cutoff_factor( + self, + wallet: "Wallet", + netuid: int, + factor_milli: int, + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = DEFAULT_PERIOD, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, + ) -> ExtrinsicResponse: + """ + Sets the activity cutoff factor for a subnet. Owner (coldkey) only. + + Parameters: + wallet: The wallet used to sign the extrinsic (coldkey must be the subnet owner). + netuid: The unique identifier of the subnet. + factor_milli: Activity cutoff factor in per-mille units. + mev_protection: If `True`, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If `False`, submits the transaction directly without encryption. + period: The number of blocks during which the transaction will remain valid after it's + submitted. If the transaction is not included in a block within that number of blocks, it will expire + and be rejected. You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + return await set_activity_cutoff_factor_extrinsic( + subtensor=self, + wallet=wallet, + netuid=netuid, + factor_milli=factor_milli, + mev_protection=mev_protection, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + async def set_auto_stake( self, wallet: "Wallet", @@ -9072,6 +9492,53 @@ async def set_subnet_identity( wait_for_revealed_execution=wait_for_revealed_execution, ) + async def set_tempo( + self, + wallet: "Wallet", + netuid: int, + tempo: int, + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = DEFAULT_PERIOD, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, + ) -> ExtrinsicResponse: + """ + Sets the epoch tempo for a subnet. Owner (coldkey) only. + + Parameters: + wallet: The wallet used to sign the extrinsic (coldkey must be the subnet owner). + netuid: The unique identifier of the subnet. + tempo: New tempo value (blocks per epoch). + mev_protection: If `True`, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If `False`, submits the transaction directly without encryption. + period: The number of blocks during which the transaction will remain valid after it's + submitted. If the transaction is not included in a block within that number of blocks, it will expire + and be rejected. You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + return await set_tempo_extrinsic( + subtensor=self, + wallet=wallet, + netuid=netuid, + tempo=tempo, + mev_protection=mev_protection, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + async def set_weights( self, wallet: "Wallet", @@ -9820,6 +10287,50 @@ async def transfer_stake( wait_for_revealed_execution=wait_for_revealed_execution, ) + async def trigger_epoch( + self, + wallet: "Wallet", + netuid: int, + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = DEFAULT_PERIOD, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, + ) -> ExtrinsicResponse: + """ + Schedules an owner-triggered epoch to fire after the admin freeze window elapses. Owner (coldkey) only. + + Parameters: + wallet: The wallet used to sign the extrinsic (coldkey must be the subnet owner). + netuid: The unique identifier of the subnet. + mev_protection: If `True`, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If `False`, submits the transaction directly without encryption. + period: The number of blocks during which the transaction will remain valid after it's + submitted. If the transaction is not included in a block within that number of blocks, it will expire + and be rejected. You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + return await trigger_epoch_extrinsic( + subtensor=self, + wallet=wallet, + netuid=netuid, + mev_protection=mev_protection, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + async def unstake( self, wallet: "Wallet", diff --git a/bittensor/core/axon.py b/bittensor/core/axon.py index 3fd9dd46c2..61573ae6fc 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}" ) 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/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, 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/core/chain_data/subnet_hyperparameters.py b/bittensor/core/chain_data/subnet_hyperparameters.py index 5317bad9e3..c856902971 100644 --- a/bittensor/core/chain_data/subnet_hyperparameters.py +++ b/bittensor/core/chain_data/subnet_hyperparameters.py @@ -1,120 +1,163 @@ -from dataclasses import dataclass -from bittensor.utils.balance import fixed_to_float +import re +from dataclasses import dataclass, field, fields +from typing import Any, Optional + from bittensor.core.chain_data.info_base import InfoBase +from bittensor.utils import Self +from bittensor.utils.balance import fixed_to_float + +_FIXED_POINT_TAG = re.compile(r"^[UI]\d+F(\d+)$") + +# Chain field name -> dataclass attribute (v2/v1 use ``max_weights_limit``). +_FIELD_ALIASES = {"max_weights_limit": "max_weight_limit"} @dataclass class SubnetHyperparameters(InfoBase): """ - This class represents the hyperparameters for a subnet. - - Attributes: - rho: The rate of decay of some value. - kappa: A constant multiplier used in calculations. - immunity_period: The period during which immunity is active. - min_allowed_weights: Minimum allowed weights. - max_weight_limit: Maximum weight limit. - tempo: The tempo or rate of operation. - min_difficulty: Minimum difficulty for some operations. - max_difficulty: Maximum difficulty for some operations. - weights_version: The version number of the weights used. - weights_rate_limit: Rate limit for processing weights. - adjustment_interval: Interval at which adjustments are made. - activity_cutoff: Activity cutoff threshold. - registration_allowed: Indicates if registration is allowed. - target_regs_per_interval: Target number of registrations per interval. - min_burn: Minimum burn value. - max_burn: Maximum burn value. - bonds_moving_avg: Moving average of bonds. - max_regs_per_block: Maximum number of registrations per block. - serving_rate_limit: Limit on the rate of service. - max_validators: Maximum number of validators. - adjustment_alpha: Alpha value for adjustments. - difficulty: Difficulty level. - commit_reveal_period: Interval for commit-reveal weights. - commit_reveal_weights_enabled: Flag indicating if commit-reveal weights are enabled. - alpha_high: High value of alpha. - alpha_low: Low value of alpha. - liquid_alpha_enabled: Flag indicating if liquid alpha is enabled. - alpha_sigmoid_steepness: Sigmoid steepness parameter for converting miner-validator alignment into alpha. - yuma_version: Version of yuma. - subnet_is_active: Indicates if subnet is active after START CALL. - transfers_enabled: Flag indicating if transfers are enabled. - bonds_reset_enabled: Flag indicating if bonds are reset enabled. - user_liquidity_enabled: Flag indicating if user liquidity is enabled. + Hyperparameters for a subnet. + + Known fields are explicit typed attributes for IDE support. Values returned + by the chain under other names are stored in ``hyperparameters`` and are + accessible via attribute, item, or mapping-style access. """ - rho: int - kappa: int - immunity_period: int - min_allowed_weights: int - max_weight_limit: float - tempo: int - min_difficulty: int - max_difficulty: int - weights_version: int - weights_rate_limit: int - adjustment_interval: int - activity_cutoff: int - registration_allowed: bool - target_regs_per_interval: int - min_burn: int - max_burn: int - bonds_moving_avg: int - max_regs_per_block: int - serving_rate_limit: int - max_validators: int - adjustment_alpha: int - difficulty: int - commit_reveal_period: int - commit_reveal_weights_enabled: bool - alpha_high: int - alpha_low: int - liquid_alpha_enabled: bool - alpha_sigmoid_steepness: float - yuma_version: int - subnet_is_active: bool - transfers_enabled: bool - bonds_reset_enabled: bool - user_liquidity_enabled: bool + rho: Optional[int] = None + kappa: Optional[int] = None + immunity_period: Optional[int] = None + min_allowed_weights: Optional[int] = None + max_weight_limit: Optional[float] = None + tempo: Optional[int] = None + min_difficulty: Optional[int] = None + max_difficulty: Optional[int] = None + weights_version: Optional[int] = None + weights_rate_limit: Optional[int] = None + adjustment_interval: Optional[int] = None + activity_cutoff: Optional[int] = None + registration_allowed: Optional[bool] = None + target_regs_per_interval: Optional[int] = None + min_burn: Optional[int] = None + max_burn: Optional[int] = None + bonds_moving_avg: Optional[int] = None + max_regs_per_block: Optional[int] = None + serving_rate_limit: Optional[int] = None + max_validators: Optional[int] = None + adjustment_alpha: Optional[int] = None + difficulty: Optional[int] = None + commit_reveal_period: Optional[int] = None + commit_reveal_weights_enabled: Optional[bool] = None + alpha_high: Optional[int] = None + alpha_low: Optional[int] = None + liquid_alpha_enabled: Optional[bool] = None + alpha_sigmoid_steepness: Optional[float] = None + yuma_version: Optional[int] = None + subnet_is_active: Optional[bool] = None + transfers_enabled: Optional[bool] = None + bonds_reset_enabled: Optional[bool] = None + user_liquidity_enabled: Optional[bool] = None + activity_cutoff_factor: Optional[int] = None + hyperparameters: dict[str, Any] = field(default_factory=dict, repr=False) - @classmethod - def _from_dict(cls, decoded: dict) -> "SubnetHyperparameters": - """Returns a SubnetHyperparameters object from decoded chain data.""" - return SubnetHyperparameters( - activity_cutoff=decoded["activity_cutoff"], - adjustment_alpha=decoded["adjustment_alpha"], - adjustment_interval=decoded["adjustment_interval"], - alpha_high=decoded["alpha_high"], - alpha_low=decoded["alpha_low"], - alpha_sigmoid_steepness=fixed_to_float( - decoded["alpha_sigmoid_steepness"], frac_bits=32 - ), - bonds_moving_avg=decoded["bonds_moving_avg"], - bonds_reset_enabled=decoded["bonds_reset_enabled"], - commit_reveal_weights_enabled=decoded["commit_reveal_weights_enabled"], - commit_reveal_period=decoded["commit_reveal_period"], - difficulty=decoded["difficulty"], - immunity_period=decoded["immunity_period"], - kappa=decoded["kappa"], - liquid_alpha_enabled=decoded["liquid_alpha_enabled"], - max_burn=decoded["max_burn"], - max_difficulty=decoded["max_difficulty"], - max_regs_per_block=decoded["max_regs_per_block"], - max_validators=decoded["max_validators"], - max_weight_limit=decoded["max_weights_limit"], - min_allowed_weights=decoded["min_allowed_weights"], - min_burn=decoded["min_burn"], - min_difficulty=decoded["min_difficulty"], - registration_allowed=decoded["registration_allowed"], - rho=decoded["rho"], - serving_rate_limit=decoded["serving_rate_limit"], - subnet_is_active=decoded["subnet_is_active"], - target_regs_per_interval=decoded["target_regs_per_interval"], - tempo=decoded["tempo"], - transfers_enabled=decoded["transfers_enabled"], - user_liquidity_enabled=decoded["user_liquidity_enabled"], - weights_rate_limit=decoded["weights_rate_limit"], - weights_version=decoded["weights_version"], - yuma_version=decoded["yuma_version"], + @staticmethod + def _typed_field_names() -> frozenset[str]: + return frozenset( + f.name for f in fields(SubnetHyperparameters) if f.name != "hyperparameters" ) + + @staticmethod + def _decode_value(value: Any) -> Any: + """Decode a single ``{: }`` hyperparameter value.""" + if isinstance(value, dict) and set(value.keys()) == {"bits"}: + # V2 struct encodes fixed-point fields as {"bits": N} without a type tag. All V2 fixed-point fields are + # I32F32. V3 entries carry the enum variant tag and are handled below. + return fixed_to_float(value["bits"], frac_bits=32) + if not isinstance(value, dict) or len(value) != 1: + return value + ((type_tag, payload),) = value.items() + if type_tag == "Bool": + return bool(payload) + if match := _FIXED_POINT_TAG.match(type_tag): + if isinstance(payload, dict) and "bits" in payload: + payload = payload["bits"] + return fixed_to_float(payload, frac_bits=int(match.group(1))) + try: + return int(payload) + except (TypeError, ValueError): + return payload + + @classmethod + def _fix_decoded(cls, decoded: list | dict | Self) -> Self: + if isinstance(decoded, SubnetHyperparameters): + return decoded + + if isinstance(decoded, dict): + entries = decoded.items() + else: + entries = ((record["name"], record["value"]) for record in decoded) + + flat: dict[str, Any] = {} + for name, value in entries: + if isinstance(name, (bytes, bytearray)): + name = name.decode("utf-8", errors="replace") + if not isinstance(name, str): + continue + flat[name] = cls._decode_value(value) + + for chain_name, attr_name in _FIELD_ALIASES.items(): + if chain_name in flat and attr_name not in flat: + flat[attr_name] = flat[chain_name] + + typed_names = cls._typed_field_names() + typed_kwargs = {name: flat[name] for name in typed_names if name in flat} + spillover = { + name: value for name, value in flat.items() if name not in typed_names + } + return cls(hyperparameters=spillover, **typed_kwargs) + + @classmethod + def from_any(cls, data: Any) -> Self: + return cls._fix_decoded(data) + + @classmethod + def from_dict(cls, decoded: dict) -> Self: + return cls.from_any(decoded) + + def __getattr__(self, item: str) -> Any: + try: + return self.__dict__["hyperparameters"][item] + except KeyError: + raise AttributeError( + f"{type(self).__name__!r} object has no hyperparameter {item!r}" + ) + + def __getitem__(self, item: str) -> Any: + if item in self._typed_field_names(): + return getattr(self, item) + return self.hyperparameters[item] + + def __iter__(self): + return self.keys() + + def __contains__(self, item: str) -> bool: + if item in self._typed_field_names(): + return getattr(self, item) is not None + return item in self.hyperparameters + + def get(self, item: str, default: Any = None) -> Any: + if item in self._typed_field_names(): + value = getattr(self, item) + return default if value is None else value + return self.hyperparameters.get(item, default) + + def items(self): + for name in sorted(self._typed_field_names()): + value = getattr(self, name) + if value is not None: + yield name, value + yield from self.hyperparameters.items() + + def keys(self): + return (name for name, _ in self.items()) + + def values(self): + return (value for _, value in self.items()) diff --git a/bittensor/core/extrinsics/asyncex/liquidity.py b/bittensor/core/extrinsics/asyncex/liquidity.py index 5f6a3fc78a..a035e90252 100644 --- a/bittensor/core/extrinsics/asyncex/liquidity.py +++ b/bittensor/core/extrinsics/asyncex/liquidity.py @@ -1,350 +1,85 @@ +# TODO: remove this module in the next major release (include all references) from typing import Optional, 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, + liquidity: Optional[Balance] = None, + price_low: Optional[Balance] = None, + price_high: Optional[Balance] = None, 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, + position_id: Optional[int] = None, + liquidity_delta: Optional[Balance] = None, 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, + position_id: Optional[int] = None, 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, + enable: Optional[bool] = None, + **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/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/asyncex/tempo_control.py b/bittensor/core/extrinsics/asyncex/tempo_control.py new file mode 100644 index 0000000000..9510370375 --- /dev/null +++ b/bittensor/core/extrinsics/asyncex/tempo_control.py @@ -0,0 +1,283 @@ +"""Async extrinsics for tempo-control operations (subnet owner and root sudo).""" + +from typing import TYPE_CHECKING, Optional + +from bittensor.core.extrinsics.asyncex.mev_shield import submit_encrypted_extrinsic +from bittensor.core.extrinsics.pallets import SubtensorModule, Sudo +from bittensor.core.settings import DEFAULT_MEV_PROTECTION +from bittensor.core.types import ExtrinsicResponse + +if TYPE_CHECKING: + from bittensor_wallet import Wallet + from bittensor.core.async_subtensor import AsyncSubtensor + + +async def root_set_activity_cutoff_factor_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + netuid: int, + factor_milli: int, + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, +) -> ExtrinsicResponse: + """ + Sets the activity cutoff factor via sudo (root only). + + Parameters: + subtensor: The AsyncSubtensor client instance used for blockchain interaction. + wallet: The wallet used to sign the extrinsic (must be unlocked). + netuid: The unique identifier of the subnet. + factor_milli: Activity cutoff factor in per-mille units. + mev_protection: If True, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If False, submits the transaction directly without encryption. + period: The number of blocks during which the transaction will remain valid after it's submitted. If the + transaction is not included in a block within that number of blocks, it will expire and be rejected. You can + think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + inner = await SubtensorModule(subtensor).set_activity_cutoff_factor( + netuid=netuid, factor_milli=factor_milli + ) + call = await Sudo(subtensor).sudo(call=inner) + + if mev_protection: + return await submit_encrypted_extrinsic( + subtensor=subtensor, + wallet=wallet, + call=call, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + else: + return await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + + +async def set_activity_cutoff_factor_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + netuid: int, + factor_milli: int, + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, +) -> ExtrinsicResponse: + """ + Sets the activity cutoff factor for a subnet. Owner (coldkey) only. + + Parameters: + subtensor: The AsyncSubtensor client instance used for blockchain interaction. + wallet: The wallet used to sign the extrinsic (coldkey must be the subnet owner). + netuid: The unique identifier of the subnet. + factor_milli: Activity cutoff factor in per-mille units. + mev_protection: If True, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If False, submits the transaction directly without encryption. + period: The number of blocks during which the transaction will remain valid after it's submitted. If the + transaction is not included in a block within that number of blocks, it will expire and be rejected. You can + think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + call = await SubtensorModule(subtensor).set_activity_cutoff_factor( + netuid=netuid, factor_milli=factor_milli + ) + + if mev_protection: + return await submit_encrypted_extrinsic( + subtensor=subtensor, + wallet=wallet, + call=call, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + else: + return await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + + +async def set_tempo_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + netuid: int, + tempo: int, + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, +) -> ExtrinsicResponse: + """ + Sets the epoch tempo for a subnet. Owner (coldkey) only. + + Parameters: + subtensor: The AsyncSubtensor client instance used for blockchain interaction. + wallet: The wallet used to sign the extrinsic (coldkey must be the subnet owner). + netuid: The unique identifier of the subnet. + tempo: New tempo value (blocks per epoch). + mev_protection: If True, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If False, submits the transaction directly without encryption. + period: The number of blocks during which the transaction will remain valid after it's submitted. If the + transaction is not included in a block within that number of blocks, it will expire and be rejected. You can + think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + call = await SubtensorModule(subtensor).set_tempo(netuid=netuid, tempo=tempo) + + if mev_protection: + return await submit_encrypted_extrinsic( + subtensor=subtensor, + wallet=wallet, + call=call, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + else: + return await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + + +async def trigger_epoch_extrinsic( + subtensor: "AsyncSubtensor", + wallet: "Wallet", + netuid: int, + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, +) -> ExtrinsicResponse: + """ + Schedules an owner-triggered epoch to fire after the admin freeze window elapses. Owner (coldkey) only. + + Parameters: + subtensor: The AsyncSubtensor client instance used for blockchain interaction. + wallet: The wallet used to sign the extrinsic (coldkey must be the subnet owner). + netuid: The unique identifier of the subnet. + mev_protection: If True, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If False, submits the transaction directly without encryption. + period: The number of blocks during which the transaction will remain valid after it's submitted. If the + transaction is not included in a block within that number of blocks, it will expire and be rejected. You can + think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + call = await SubtensorModule(subtensor).trigger_epoch(netuid=netuid) + + if mev_protection: + return await submit_encrypted_extrinsic( + subtensor=subtensor, + wallet=wallet, + call=call, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + else: + return await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) diff --git a/bittensor/core/extrinsics/asyncex/weights.py b/bittensor/core/extrinsics/asyncex/weights.py index bfed04bc8a..a08bd3b382 100644 --- a/bittensor/core/extrinsics/asyncex/weights.py +++ b/bittensor/core/extrinsics/asyncex/weights.py @@ -2,7 +2,7 @@ from typing import Optional, Union, TYPE_CHECKING -from bittensor_drand import get_encrypted_commit +from bittensor_drand import get_encrypted_commit_v2 from bittensor.core.extrinsics.asyncex.mev_shield import submit_encrypted_extrinsic from bittensor.core.extrinsics.pallets import SubtensorModule @@ -81,19 +81,19 @@ async def commit_timelocked_weights_extrinsic( subnet_hyperparameters = await subtensor.get_subnet_hyperparameters( netuid, block=current_block ) - tempo = subnet_hyperparameters.tempo subnet_reveal_period_epochs = subnet_hyperparameters.commit_reveal_period - storage_index = get_mechid_storage_index(netuid=netuid, mechid=mechid) - - # Encrypt `commit_hash` with t-lock and `get reveal_round` - commit_for_reveal, reveal_round = get_encrypted_commit( - uids=uids, - weights=weights, + schedule = await subtensor.get_epoch_schedule_state(netuid, block=current_block) + commit_for_reveal, reveal_round = get_encrypted_commit_v2( + uids=list(uids), + weights=list(weights), version_key=version_key, - tempo=tempo, - current_block=current_block, - netuid=storage_index, + last_epoch_block=schedule.last_epoch_block, + pending_epoch_at=schedule.pending_epoch_at, + subnet_epoch_index=schedule.subnet_epoch_index, + tempo=schedule.tempo, + blocks_since_last_step=schedule.blocks_since_last_step, + current_block=schedule.current_block, subnet_reveal_period_epochs=subnet_reveal_period_epochs, block_time=block_time, hotkey=wallet.hotkey.public_key, diff --git a/bittensor/core/extrinsics/liquidity.py b/bittensor/core/extrinsics/liquidity.py index bff99f1f18..b55c9c90ea 100644 --- a/bittensor/core/extrinsics/liquidity.py +++ b/bittensor/core/extrinsics/liquidity.py @@ -1,350 +1,85 @@ +# TODO: remove this module in the next major release (include all references) from typing import Optional, 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, + liquidity: Optional[Balance] = None, + price_low: Optional[Balance] = None, + price_high: Optional[Balance] = None, 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, + position_id: Optional[int] = None, + liquidity_delta: Optional[Balance] = None, 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, + position_id: Optional[int] = None, 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, + enable: Optional[bool] = None, + **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/core/extrinsics/pallets/subtensor_module.py b/bittensor/core/extrinsics/pallets/subtensor_module.py index 9f6daf5f17..ace42e02f2 100644 --- a/bittensor/core/extrinsics/pallets/subtensor_module.py +++ b/bittensor/core/extrinsics/pallets/subtensor_module.py @@ -848,3 +848,38 @@ def transfer_stake( destination_netuid=destination_netuid, alpha_amount=alpha_amount, ) + + def set_tempo(self, netuid: int, tempo: int) -> Call: + """Returns GenericCall instance for SubtensorModule.set_tempo. + + Parameters: + netuid: The unique identifier of the subnet. + tempo: New tempo value (blocks per epoch). + + Returns: + GenericCall instance. + """ + return self.create_composed_call(netuid=netuid, tempo=tempo) + + def set_activity_cutoff_factor(self, netuid: int, factor_milli: int) -> Call: + """Returns GenericCall instance for SubtensorModule.set_activity_cutoff_factor. + + Parameters: + netuid: The unique identifier of the subnet. + factor_milli: Activity cutoff factor in per-mille units. + + Returns: + GenericCall instance. + """ + return self.create_composed_call(netuid=netuid, factor_milli=factor_milli) + + def trigger_epoch(self, netuid: int) -> Call: + """Returns GenericCall instance for SubtensorModule.trigger_epoch. + + Parameters: + netuid: The unique identifier of the subnet. + + Returns: + GenericCall instance. + """ + return self.create_composed_call(netuid=netuid) diff --git a/bittensor/core/extrinsics/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 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/extrinsics/tempo_control.py b/bittensor/core/extrinsics/tempo_control.py new file mode 100644 index 0000000000..62e14b0b95 --- /dev/null +++ b/bittensor/core/extrinsics/tempo_control.py @@ -0,0 +1,283 @@ +"""Sync extrinsics for tempo-control operations (subnet owner and root sudo).""" + +from typing import TYPE_CHECKING, Optional + +from bittensor.core.extrinsics.mev_shield import submit_encrypted_extrinsic +from bittensor.core.extrinsics.pallets import SubtensorModule, Sudo +from bittensor.core.settings import DEFAULT_MEV_PROTECTION +from bittensor.core.types import ExtrinsicResponse + +if TYPE_CHECKING: + from bittensor_wallet import Wallet + from bittensor.core.subtensor import Subtensor + + +def root_set_activity_cutoff_factor_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + netuid: int, + factor_milli: int, + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, +) -> ExtrinsicResponse: + """ + Sets the activity cutoff factor via sudo (root only). + + Parameters: + subtensor: The Subtensor client instance used for blockchain interaction. + wallet: The wallet used to sign the extrinsic (must be unlocked). + netuid: The unique identifier of the subnet. + factor_milli: Activity cutoff factor in per-mille units. + mev_protection: If True, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If False, submits the transaction directly without encryption. + period: The number of blocks during which the transaction will remain valid after it's submitted. If the + transaction is not included in a block within that number of blocks, it will expire and be rejected. You can + think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + inner = SubtensorModule(subtensor).set_activity_cutoff_factor( + netuid=netuid, factor_milli=factor_milli + ) + call = Sudo(subtensor).sudo(call=inner) + + if mev_protection: + return submit_encrypted_extrinsic( + subtensor=subtensor, + wallet=wallet, + call=call, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + else: + return subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + + +def set_activity_cutoff_factor_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + netuid: int, + factor_milli: int, + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, +) -> ExtrinsicResponse: + """ + Sets the activity cutoff factor for a subnet. Owner (coldkey) only. + + Parameters: + subtensor: The Subtensor client instance used for blockchain interaction. + wallet: The wallet used to sign the extrinsic (coldkey must be the subnet owner). + netuid: The unique identifier of the subnet. + factor_milli: Activity cutoff factor in per-mille units. + mev_protection: If True, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If False, submits the transaction directly without encryption. + period: The number of blocks during which the transaction will remain valid after it's submitted. If the + transaction is not included in a block within that number of blocks, it will expire and be rejected. You can + think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + call = SubtensorModule(subtensor).set_activity_cutoff_factor( + netuid=netuid, factor_milli=factor_milli + ) + + if mev_protection: + return submit_encrypted_extrinsic( + subtensor=subtensor, + wallet=wallet, + call=call, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + else: + return subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + + +def set_tempo_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + netuid: int, + tempo: int, + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, +) -> ExtrinsicResponse: + """ + Sets the epoch tempo for a subnet. Owner (coldkey) only. + + Parameters: + subtensor: The Subtensor client instance used for blockchain interaction. + wallet: The wallet used to sign the extrinsic (coldkey must be the subnet owner). + netuid: The unique identifier of the subnet. + tempo: New tempo value (blocks per epoch). + mev_protection: If True, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If False, submits the transaction directly without encryption. + period: The number of blocks during which the transaction will remain valid after it's submitted. If the + transaction is not included in a block within that number of blocks, it will expire and be rejected. You can + think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + call = SubtensorModule(subtensor).set_tempo(netuid=netuid, tempo=tempo) + + if mev_protection: + return submit_encrypted_extrinsic( + subtensor=subtensor, + wallet=wallet, + call=call, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + else: + return subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) + + +def trigger_epoch_extrinsic( + subtensor: "Subtensor", + wallet: "Wallet", + netuid: int, + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = None, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, +) -> ExtrinsicResponse: + """ + Schedules an owner-triggered epoch to fire after the admin freeze window elapses. Owner (coldkey) only. + + Parameters: + subtensor: The Subtensor client instance used for blockchain interaction. + wallet: The wallet used to sign the extrinsic (coldkey must be the subnet owner). + netuid: The unique identifier of the subnet. + mev_protection: If True, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If False, submits the transaction directly without encryption. + period: The number of blocks during which the transaction will remain valid after it's submitted. If the + transaction is not included in a block within that number of blocks, it will expire and be rejected. You can + think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + try: + if not ( + unlocked := ExtrinsicResponse.unlock_wallet(wallet, raise_error) + ).success: + return unlocked + + call = SubtensorModule(subtensor).trigger_epoch(netuid=netuid) + + if mev_protection: + return submit_encrypted_extrinsic( + subtensor=subtensor, + wallet=wallet, + call=call, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + else: + return subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + except Exception as error: + return ExtrinsicResponse.from_exception(raise_error=raise_error, error=error) diff --git a/bittensor/core/extrinsics/weights.py b/bittensor/core/extrinsics/weights.py index 0f64c4aa89..618f8e807e 100644 --- a/bittensor/core/extrinsics/weights.py +++ b/bittensor/core/extrinsics/weights.py @@ -2,7 +2,7 @@ from typing import Optional, Union, TYPE_CHECKING -from bittensor_drand import get_encrypted_commit +from bittensor_drand import get_encrypted_commit_v2 from bittensor.core.extrinsics.mev_shield import submit_encrypted_extrinsic from bittensor.core.extrinsics.pallets import SubtensorModule @@ -82,19 +82,19 @@ def commit_timelocked_weights_extrinsic( subnet_hyperparameters = subtensor.get_subnet_hyperparameters( netuid, block=current_block ) - tempo = subnet_hyperparameters.tempo subnet_reveal_period_epochs = subnet_hyperparameters.commit_reveal_period - storage_index = get_mechid_storage_index(netuid=netuid, mechid=mechid) - - # Encrypt `commit_hash` with t-lock and `get reveal_round` - commit_for_reveal, reveal_round = get_encrypted_commit( - uids=uids, - weights=weights, + schedule = subtensor.get_epoch_schedule_state(netuid, block=current_block) + commit_for_reveal, reveal_round = get_encrypted_commit_v2( + uids=list(uids), + weights=list(weights), version_key=version_key, - tempo=tempo, - current_block=current_block, - netuid=storage_index, + last_epoch_block=schedule.last_epoch_block, + pending_epoch_at=schedule.pending_epoch_at, + subnet_epoch_index=schedule.subnet_epoch_index, + tempo=schedule.tempo, + blocks_since_last_step=schedule.blocks_since_last_step, + current_block=schedule.current_block, subnet_reveal_period_epochs=subnet_reveal_period_epochs, block_time=block_time, hotkey=wallet.hotkey.public_key, diff --git a/bittensor/core/subtensor.py b/bittensor/core/subtensor.py index 59f6207cd5..17951b9c15 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 ( @@ -30,8 +30,10 @@ ProposalVoteData, ProxyAnnouncementInfo, ProxyConstants, + ProxyFilterInfo, ProxyInfo, ProxyType, + ProxyTypeInfo, RootClaimType, SelectiveMetagraphIndex, SimSwapResult, @@ -47,6 +49,7 @@ decode_revealed_commitment, decode_revealed_commitment_with_hotkey, ) +from bittensor.utils import epoch_schedule from bittensor.core.config import Config from bittensor.core.errors import ChainError, chain_error_from_substrate_exception from bittensor.core.extrinsics.children import ( @@ -123,6 +126,12 @@ ) from bittensor.core.extrinsics.start_call import start_call_extrinsic from bittensor.core.extrinsics.take import set_take_extrinsic +from bittensor.core.extrinsics.tempo_control import ( + root_set_activity_cutoff_factor_extrinsic, + set_activity_cutoff_factor_extrinsic, + set_tempo_extrinsic, + trigger_epoch_extrinsic, +) from bittensor.core.extrinsics.transfer import transfer_extrinsic from bittensor.core.extrinsics.unstaking import ( unstake_all_extrinsic, @@ -147,13 +156,13 @@ ) from bittensor.core.types import ( BlockInfo, + EpochScheduleState, ExtrinsicResponse, LockState, Salt, SubtensorMixin, UIDs, Weights, - PositionResponse, NeuronCertificateResponse, CommitmentOfResponse, CrowdloansResponse, @@ -161,7 +170,9 @@ ) from bittensor.utils import ( Certificate, + ChainFeatureDisabledWarning, decode_hex_identity_dict, + deprecated_message, format_error_message, get_caller_name, get_mechid_storage_index, @@ -176,13 +187,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 @@ -900,30 +904,25 @@ def blocks_since_last_update( return None if len(call) == 0 else (block - int(call[uid])) def blocks_until_next_epoch( - self, netuid: int, tempo: Optional[int] = None, block: Optional[int] = None + self, netuid: int, *, block: Optional[int] = None ) -> Optional[int]: - """Returns the number of blocks until the next epoch of subnet with provided netuid. + """Returns the number of blocks until the next epoch for the given subnet. + + Derives the answer from the ``get_next_epoch_start_block`` runtime API. Parameters: netuid: The unique identifier of the subnetwork. - tempo: The tempo of the subnet. - block: the block number for this query. + block: The block number to query. If ``None``, queries the current chain head. Returns: - The number of blocks until the next epoch of the subnet with provided netuid. + The number of blocks remaining, or ``None`` if the subnet has + tempo 0 (no epochs). """ - block = block or self.block - - tempo = tempo or self.tempo(netuid=netuid) - if not tempo: + block_number = block or self.block + next_start = self.get_next_epoch_start_block(netuid, block=block_number) + if next_start is None: return None - - # the logic is the same as in SubtensorModule:blocks_until_next_epoch - netuid_plus_one = int(netuid) + 1 - tempo_plus_one = tempo + 1 - adjusted_block = (block + netuid_plus_one) % (2**64) - remainder = adjusted_block % tempo_plus_one - return tempo - remainder + return max(0, next_start - block_number) def bonds( self, @@ -1047,6 +1046,24 @@ def does_hotkey_exist(self, hotkey_ss58: str, block: Optional[int] = None) -> bo ) return result.value != "5C4hrfjw9DjXZTzV3MwzrrAr9P1MJhSrvWGWqi1eSuyUpnhM" + def get_activity_cutoff_factor_milli( + self, netuid: int, block: Optional[int] = None + ) -> int: + """Returns the activity cutoff factor (per-mille) for the given subnet. + + Parameters: + netuid: The unique identifier of the subnetwork. + block: The block number to query. If `None`, queries the current chain head. + + Returns: + The activity cutoff factor in per-mille units. + """ + query = self.query_subtensor( + name="ActivityCutoffFactorMilli", block=block, params=[netuid] + ) + + return cast(int, query.value) + def get_admin_freeze_window(self, block: Optional[int] = None) -> int: """Returns the duration, in blocks, of the administrative freeze window at the end of each epoch. @@ -2312,6 +2329,33 @@ def get_ema_tao_inflow( # TODO verify this from rao, seems like we're just rounding down return block_updated, Balance.from_rao(ema_value) + def get_epoch_schedule_state( + self, netuid: int, block: Optional[int] = None + ) -> "EpochScheduleState": + """Returns a snapshot of all epoch-related storage for the given subnet. + + All fields are read at the same block to ensure consistency. + Used by `bittensor-drand` v2 for commit/reveal prediction. + + Parameters: + netuid: The unique identifier of the subnetwork. + block: The block number to query. If `None`, queries the current chain head. + + Returns: + An `EpochScheduleState` populated from on-chain storage. + """ + block_number = block or self.block + return EpochScheduleState( + last_epoch_block=self.get_last_epoch_block(netuid, block=block_number), + pending_epoch_at=self.get_pending_epoch_at(netuid, block=block_number), + subnet_epoch_index=self.get_subnet_epoch_index(netuid, block=block_number), + tempo=cast(int, self.tempo(netuid, block=block_number)), + blocks_since_last_step=cast( + int, self.blocks_since_last_step(netuid, block=block_number) + ), + current_block=block_number, + ) + def get_hotkey_conviction( self, hotkey_ss58: str, @@ -2422,15 +2466,29 @@ 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", 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. @@ -2438,164 +2496,86 @@ 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 + 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, + ) + return [] - if not self.is_subnet_active(netuid=netuid): - logging.debug(f"Subnet {netuid} is not active.") - return None + def get_max_activity_cutoff_factor_milli(self, block: Optional[int] = None) -> int: + """Returns the upper bound for the activity-cutoff factor (per-mille). - # 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 [] + 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. - block_hash = self.determine_block_hash(block) + Parameters: + block: The blockchain block number for the query. - # 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, - ) - 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, + 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), ) - 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, - ) + + 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), ) - 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, - ) + if result is None: + raise Exception("Unable to retrieve MaxEpochsPerBlock constant.") - # 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, - ) + return cast(int, result.value) - 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"), - ) - ) + def get_max_tempo(self, block: Optional[int] = None) -> int: + """Returns the upper bound for owner-set tempo. - return positions + 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 @@ -2837,6 +2817,52 @@ def get_mev_shield_next_key(self, block: Optional[int] = None) -> Optional[bytes return public_key_bytes + def get_min_activity_cutoff_factor_milli(self, block: Optional[int] = None) -> int: + """Returns the lower bound for the activity-cutoff factor (per-mille). + + This chain constant defines the minimum value a subnet owner can set for the activity-cutoff factor via + ``set_activity_cutoff_factor``. The factor is expressed in per-mille units relative to the subnet's tempo. + + Parameters: + block: The blockchain block number for the query. + + Returns: + The lower bound for the activity-cutoff factor in per-mille. + """ + result = self.substrate.get_constant( + module_name="SubtensorModule", + constant_name="MinActivityCutoffFactorMilli", + block_hash=self.determine_block_hash(block), + ) + + if result is None: + raise Exception("Unable to retrieve MinActivityCutoffFactorMilli constant.") + + return cast(int, result.value) + + def get_min_tempo(self, block: Optional[int] = None) -> int: + """Returns the lower bound for owner-set tempo. + + This chain constant defines the minimum epoch period (in blocks) that a subnet owner can configure via + ``set_tempo``. Also serves as the fixed cooldown between consecutive ``set_tempo`` calls. + + Parameters: + block: The blockchain block number for the query. + + Returns: + The minimum allowed tempo value in blocks. + """ + result = self.substrate.get_constant( + module_name="SubtensorModule", + constant_name="MinTempo", + block_hash=self.determine_block_hash(block), + ) + + if result is None: + raise Exception("Unable to retrieve MinTempo constant.") + + return cast(int, result.value) + def get_minimum_required_stake(self) -> Balance: """Returns the minimum required stake threshold for nominator cleanup operations. @@ -2982,36 +3008,30 @@ def get_neuron_for_pubkey_and_subnet( def get_next_epoch_start_block( self, netuid: int, block: Optional[int] = None ) -> Optional[int]: - """ - Calculates the first block number of the next epoch for the given subnet. + """Returns the block at which the next epoch will fire for the given subnet. - If `block` is not provided, the current chain block will be used. Epochs are determined based on the subnet's - tempo (i.e., blocks per epoch). The result is the block number at which the next epoch will begin. + Delegates to the ``SubnetInfoRuntimeApi.get_next_epoch_start_block`` + runtime API, which accounts for both the auto-timer + (``last_epoch_block + tempo``) and any pending owner-triggered epoch. Parameters: netuid: The unique identifier of the subnet. - block: The reference block to calculate from. If None, uses the current chain block height. + block: The reference block to query. If ``None``, uses the current chain head. Returns: - int: The block number at which the next epoch will start, or None if tempo is 0 or invalid. + The block number at which the next epoch will start, or ``None`` + if tempo is 0 (subnet does not run epochs). Notes: - """ - tempo = self.tempo(netuid=netuid, block=block) - current_block = block or self.block - - if not tempo: - return None - - blocks_until = self.blocks_until_next_epoch( - netuid=netuid, tempo=tempo, block=current_block + result = self.query_runtime_api( + runtime_api="SubnetInfoRuntimeApi", + method="get_next_epoch_start_block", + params=[netuid], + block=block, ) - - if blocks_until is None: - return None - - return current_block + blocks_until + 1 + return None if result is None else int(result) def get_owned_hotkeys( self, @@ -3037,6 +3057,22 @@ def get_owned_hotkeys( ) return owned_hotkeys.value or [] + def get_owner_hyperparam_rate_limit(self, block: Optional[int] = None) -> int: + """Returns the owner hyperparameter rate limit (in tempos). + + Parameters: + block: The block number to query. If `None`, queries the current chain head. + + Returns: + The rate limit in tempos. + """ + query: ScaleType[int] = self.substrate.query( + module="SubtensorModule", + storage_function="OwnerHyperparamRateLimit", + block_hash=self.determine_block_hash(block), + ) + return query.value + def get_parents( self, hotkey_ss58: str, netuid: int, block: Optional[int] = None ) -> list[tuple[float, str]]: @@ -3072,6 +3108,21 @@ def get_parents( return [] + def get_pending_epoch_at(self, netuid: int, block: Optional[int] = None) -> int: + """Returns the pending (owner-triggered) epoch block, or 0 if none is scheduled. + + Parameters: + netuid: The unique identifier of the subnetwork. + block: The block number to query. If `None`, queries the current chain head. + + Returns: + The block at which the triggered epoch will fire, or 0. + """ + query = self.query_subtensor( + name="PendingEpochAt", block=block, params=[netuid] + ) + return cast(int, query.value) + def get_proxies(self, block: Optional[int] = None) -> dict[str, list[ProxyInfo]]: """ Retrieves all proxy relationships from the chain. @@ -3253,6 +3304,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, @@ -3440,14 +3558,15 @@ 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( @@ -3686,6 +3805,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, @@ -3863,34 +4022,45 @@ def get_subnet_burn_cost(self, block: Optional[int] = None) -> Optional[Balance] else: return lock_cost + def get_subnet_epoch_index(self, netuid: int, block: Optional[int] = None) -> int: + """Returns the monotonic epoch counter for the given subnet. + + Parameters: + netuid: The unique identifier of the subnetwork. + block: The block number to query. If `None`, queries the current chain head. + + Returns: + The current epoch index. + """ + query = self.query_subtensor( + name="SubnetEpochIndex", block=block, params=[netuid] + ) + return cast(int, query.value) + def get_subnet_hyperparameters( self, netuid: int, block: Optional[int] = None - ) -> Optional[Union[list, "SubnetHyperparameters"]]: - """ - Retrieves the hyperparameters for a specific subnet within the Bittensor network. These hyperparameters define - the operational settings and rules governing the subnet's behavior. + ) -> Optional["SubnetHyperparameters"]: + """Retrieves the hyperparameters for a specific subnet. + + Tries the v3 ``Vec`` runtime API first, then falls + back to v2 and v1 struct APIs at the requested block. Parameters: netuid: The network UID of the subnet to query. block: The blockchain block number for the query. Returns: - The subnet's hyperparameters, or `None` if not available. - - Understanding the hyperparameters is crucial for comprehending how subnets are configured and managed, and how - they interact with the network's consensus and incentive mechanisms. + The subnet's hyperparameters, or ``None`` if not available. """ - result = self.query_runtime_api( - runtime_api="SubnetInfoRuntimeApi", - method="get_subnet_hyperparams_v2", - params=[netuid], - block=block, + block_hash = self.determine_block_hash(block) + result = self._runtime_call_with_fallback( + ("SubnetInfoRuntimeApi", "get_subnet_hyperparams_v3", [netuid]), + ("SubnetInfoRuntimeApi", "get_subnet_hyperparams_v2", [netuid]), + ("SubnetInfoRuntimeApi", "get_subnet_hyperparams", [netuid]), + block_hash=block_hash, + default_value=None, ) - - if not result: - return None - - return SubnetHyperparameters.from_dict(result) + return SubnetHyperparameters.from_any(result) if result else None def get_subnet_info( self, netuid: int, block: Optional[int] = None @@ -3977,7 +4147,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. @@ -3986,23 +4156,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( + list[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( @@ -4345,32 +4519,31 @@ def is_in_admin_freeze_window( netuid: int, block: Optional[int] = None, ) -> bool: - """ - Returns True if the current block is within the terminal freeze window of the tempo - for the given subnet. During this window, admin ops are prohibited to avoid interference - with validator weight submissions. + """Returns whether owner operations are currently blocked for the subnet. + + Matches the chain's ``is_in_admin_freeze_window`` logic: a pending + triggered epoch in the future **or** fewer than ``admin_freeze_window`` + blocks remaining until the next auto epoch. Parameters: netuid: The unique identifier of the subnet. block: The blockchain block number for the query. Returns: - bool: True if in freeze window, else False. + ``True`` if in freeze window, ``False`` otherwise. """ - # SN0 doesn't have admin_freeze_window if netuid == 0: return False - next_epoch_start_block = self.get_next_epoch_start_block( - netuid=netuid, block=block + block_number = block or self.block + return epoch_schedule.is_in_admin_freeze_window( + tempo=self.tempo(netuid, block=block_number) or 0, + pending_epoch_at=self.get_pending_epoch_at(netuid, block=block_number), + last_epoch_block=self.get_last_epoch_block(netuid, block=block_number), + block_number=block_number, + admin_freeze_window=self.get_admin_freeze_window(block=block_number), ) - if next_epoch_start_block is not None: - remaining = next_epoch_start_block - self.block - window = self.get_admin_freeze_window(block=block) - return remaining < window - return False - def is_fast_blocks(self) -> bool: """Checks if the node is running with fast blocks enabled. @@ -5846,6 +6019,7 @@ def commit_weights( uids=uids, weights=weights, salt=salt, + version_key=version_key, mev_protection=mev_protection, period=period, raise_error=raise_error, @@ -6288,9 +6462,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 @@ -7456,6 +7629,53 @@ def root_register( wait_for_revealed_execution=wait_for_revealed_execution, ) + def root_set_activity_cutoff_factor( + self, + wallet: "Wallet", + netuid: int, + factor_milli: int, + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = DEFAULT_PERIOD, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, + ) -> ExtrinsicResponse: + """ + Sets the activity cutoff factor via sudo (root only). + + Parameters: + wallet: The wallet used to sign the extrinsic. + netuid: The unique identifier of the subnet. + factor_milli: Activity cutoff factor in per-mille units. + mev_protection: If `True`, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If `False`, submits the transaction directly without encryption. + period: The number of blocks during which the transaction will remain valid after it's + submitted. If the transaction is not included in a block within that number of blocks, it will expire + and be rejected. You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + return root_set_activity_cutoff_factor_extrinsic( + subtensor=self, + wallet=wallet, + netuid=netuid, + factor_milli=factor_milli, + mev_protection=mev_protection, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + def root_set_pending_childkey_cooldown( self, wallet: "Wallet", @@ -7501,6 +7721,53 @@ def root_set_pending_childkey_cooldown( wait_for_revealed_execution=wait_for_revealed_execution, ) + def set_activity_cutoff_factor( + self, + wallet: "Wallet", + netuid: int, + factor_milli: int, + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = DEFAULT_PERIOD, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, + ) -> ExtrinsicResponse: + """ + Sets the activity cutoff factor for a subnet. Owner (coldkey) only. + + Parameters: + wallet: The wallet used to sign the extrinsic (coldkey must be the subnet owner). + netuid: The unique identifier of the subnet. + factor_milli: Activity cutoff factor in per-mille units. + mev_protection: If `True`, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If `False`, submits the transaction directly without encryption. + period: The number of blocks during which the transaction will remain valid after it's + submitted. If the transaction is not included in a block within that number of blocks, it will expire + and be rejected. You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + return set_activity_cutoff_factor_extrinsic( + subtensor=self, + wallet=wallet, + netuid=netuid, + factor_milli=factor_milli, + mev_protection=mev_protection, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + def set_auto_stake( self, wallet: "Wallet", @@ -7837,6 +8104,53 @@ def set_subnet_identity( wait_for_revealed_execution=wait_for_revealed_execution, ) + def set_tempo( + self, + wallet: "Wallet", + netuid: int, + tempo: int, + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = DEFAULT_PERIOD, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, + ) -> ExtrinsicResponse: + """ + Sets the epoch tempo for a subnet. Owner (coldkey) only. + + Parameters: + wallet: The wallet used to sign the extrinsic (coldkey must be the subnet owner). + netuid: The unique identifier of the subnet. + tempo: New tempo value (blocks per epoch). + mev_protection: If `True`, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If `False`, submits the transaction directly without encryption. + period: The number of blocks during which the transaction will remain valid after it's + submitted. If the transaction is not included in a block within that number of blocks, it will expire + and be rejected. You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + return set_tempo_extrinsic( + subtensor=self, + wallet=wallet, + netuid=netuid, + tempo=tempo, + mev_protection=mev_protection, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + def set_weights( self, wallet: "Wallet", @@ -8507,6 +8821,50 @@ def transfer_stake( wait_for_revealed_execution=wait_for_revealed_execution, ) + def trigger_epoch( + self, + wallet: "Wallet", + netuid: int, + *, + mev_protection: bool = DEFAULT_MEV_PROTECTION, + period: Optional[int] = DEFAULT_PERIOD, + raise_error: bool = False, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, + wait_for_revealed_execution: bool = True, + ) -> ExtrinsicResponse: + """ + Schedules an owner-triggered epoch to fire after the admin freeze window elapses. Owner (coldkey) only. + + Parameters: + wallet: The wallet used to sign the extrinsic (coldkey must be the subnet owner). + netuid: The unique identifier of the subnet. + mev_protection: If `True`, encrypts and submits the transaction through the MEV Shield pallet to protect + against front-running and MEV attacks. The transaction remains encrypted in the mempool until validators + decrypt and execute it. If `False`, submits the transaction directly without encryption. + period: The number of blocks during which the transaction will remain valid after it's + submitted. If the transaction is not included in a block within that number of blocks, it will expire + and be rejected. You can think of it as an expiration date for the transaction. + raise_error: Raises a relevant exception rather than returning `False` if unsuccessful. + wait_for_inclusion: Waits for the transaction to be included in a block. + wait_for_finalization: Waits for the transaction to be finalized on the blockchain. + wait_for_revealed_execution: Whether to wait for the revealed execution of transaction if mev_protection used. + + Returns: + ExtrinsicResponse: The result object of the extrinsic execution. + """ + return trigger_epoch_extrinsic( + subtensor=self, + wallet=wallet, + netuid=netuid, + mev_protection=mev_protection, + period=period, + raise_error=raise_error, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + wait_for_revealed_execution=wait_for_revealed_execution, + ) + def unstake( self, wallet: "Wallet", diff --git a/bittensor/core/types.py b/bittensor/core/types.py index 03d47d58ac..2ef1bdf77b 100644 --- a/bittensor/core/types.py +++ b/bittensor/core/types.py @@ -673,3 +673,27 @@ class LockState(TypedDict): locked_mass: "Balance" conviction: float last_update: int + + +@dataclass(frozen=True) +class EpochScheduleState: + """Snapshot of on-chain epoch schedule state at a given block. + + Aggregates storage fields needed by the SDK and ``bittensor-drand`` to compute epoch predictions. + All fields are queried at the same ``block_hash`` to ensure consistency. + + Attributes: + last_epoch_block: The block at which the last epoch fired for this subnet. + pending_epoch_at: Block at which an owner-triggered epoch is scheduled (``0`` = none). + subnet_epoch_index: Monotonically increasing epoch counter for this subnet. + tempo: The subnet's tempo (epoch period in blocks). + blocks_since_last_step: Blocks elapsed since the last epoch. + current_block: The reference block at which the snapshot was taken. + """ + + last_epoch_block: int + pending_epoch_at: int + subnet_epoch_index: int + tempo: int + blocks_since_last_step: int + current_block: int diff --git a/bittensor/extras/dev_framework/calls/non_sudo_calls.py b/bittensor/extras/dev_framework/calls/non_sudo_calls.py index 537351c5c7..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: 397 + 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", [ @@ -430,6 +439,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 +449,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", [ @@ -646,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 @@ -705,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 @@ -724,9 +745,15 @@ 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 +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 @@ -761,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 @@ -849,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 1ffef1da5d..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: 397 +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 d7b5a0cdc4..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: 397 + Subtensor spec version: 417 """ 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,22 @@ "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_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 SUDO_SET_OWNER_HPARAM_RATE_LIMIT = namedtuple( "SUDO_SET_OWNER_HPARAM_RATE_LIMIT", ["wallet", "pallet", "sudo", "epochs"] ) # args: [epochs: u16] | Pallet: AdminUtils @@ -222,6 +236,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 diff --git a/bittensor/extras/dev_framework/subnet.py b/bittensor/extras/dev_framework/subnet.py index 13b15366cc..206a719f17 100644 --- a/bittensor/extras/dev_framework/subnet.py +++ b/bittensor/extras/dev_framework/subnet.py @@ -1,5 +1,4 @@ from typing import Optional, Union -from collections import namedtuple from bittensor_wallet import Wallet from bittensor.core.extrinsics.asyncex.utils import ( diff --git a/bittensor/extras/subtensor_api/chain.py b/bittensor/extras/subtensor_api/chain.py index 4cca40bfd1..ebf60a5702 100644 --- a/bittensor/extras/subtensor_api/chain.py +++ b/bittensor/extras/subtensor_api/chain.py @@ -13,7 +13,17 @@ def __init__(self, subtensor: Union["_Subtensor", "_AsyncSubtensor"]): self.get_current_block = subtensor.get_current_block self.get_delegate_identities = subtensor.get_delegate_identities self.get_existential_deposit = subtensor.get_existential_deposit + self.get_max_activity_cutoff_factor_milli = ( + subtensor.get_max_activity_cutoff_factor_milli + ) + self.get_max_epochs_per_block = subtensor.get_max_epochs_per_block + self.get_max_tempo = subtensor.get_max_tempo + self.get_min_activity_cutoff_factor_milli = ( + subtensor.get_min_activity_cutoff_factor_milli + ) + self.get_min_tempo = subtensor.get_min_tempo self.get_minimum_required_stake = subtensor.get_minimum_required_stake + self.get_owner_hyperparam_rate_limit = subtensor.get_owner_hyperparam_rate_limit self.get_start_call_delay = subtensor.get_start_call_delay self.get_timestamp = subtensor.get_timestamp self.get_vote_data = subtensor.get_vote_data diff --git a/bittensor/extras/subtensor_api/extrinsics.py b/bittensor/extras/subtensor_api/extrinsics.py index 438ae94978..fb2d0b6bc7 100644 --- a/bittensor/extras/subtensor_api/extrinsics.py +++ b/bittensor/extras/subtensor_api/extrinsics.py @@ -46,6 +46,10 @@ def __init__(self, subtensor: Union["_Subtensor", "_AsyncSubtensor"]): self.set_reveal_commitment = subtensor.set_reveal_commitment self.set_root_claim_type = subtensor.set_root_claim_type self.set_subnet_identity = subtensor.set_subnet_identity + self.set_tempo = subtensor.set_tempo + self.set_activity_cutoff_factor = subtensor.set_activity_cutoff_factor + self.root_set_activity_cutoff_factor = subtensor.root_set_activity_cutoff_factor + self.trigger_epoch = subtensor.trigger_epoch self.set_weights = subtensor.set_weights self.start_call = subtensor.start_call self.swap_coldkey_announced = subtensor.swap_coldkey_announced diff --git a/bittensor/extras/subtensor_api/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 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 ) diff --git a/bittensor/extras/subtensor_api/subnets.py b/bittensor/extras/subtensor_api/subnets.py index 37cf2ccece..8ed394bd69 100644 --- a/bittensor/extras/subtensor_api/subnets.py +++ b/bittensor/extras/subtensor_api/subnets.py @@ -16,6 +16,10 @@ def __init__(self, subtensor: Union["_Subtensor", "_AsyncSubtensor"]): self.burned_register = subtensor.burned_register self.commit_reveal_enabled = subtensor.commit_reveal_enabled self.difficulty = subtensor.difficulty + self.get_activity_cutoff_factor_milli = ( + subtensor.get_activity_cutoff_factor_milli + ) + self.get_epoch_schedule_state = subtensor.get_epoch_schedule_state self.get_all_ema_tao_inflow = subtensor.get_all_ema_tao_inflow self.get_all_subnets_info = subtensor.get_all_subnets_info self.get_all_subnets_netuid = subtensor.get_all_subnets_netuid @@ -28,7 +32,10 @@ def __init__(self, subtensor: Union["_Subtensor", "_AsyncSubtensor"]): self.get_neuron_for_pubkey_and_subnet = ( subtensor.get_neuron_for_pubkey_and_subnet ) + self.get_last_epoch_block = subtensor.get_last_epoch_block self.get_next_epoch_start_block = subtensor.get_next_epoch_start_block + self.get_pending_epoch_at = subtensor.get_pending_epoch_at + self.get_subnet_epoch_index = subtensor.get_subnet_epoch_index self.get_mechanism_emission_split = subtensor.get_mechanism_emission_split self.get_mechanism_count = subtensor.get_mechanism_count self.get_subnet_burn_cost = subtensor.get_subnet_burn_cost diff --git a/bittensor/utils/__init__.py b/bittensor/utils/__init__.py index e1ef7a0bd8..89dcc99dfe 100644 --- a/bittensor/utils/__init__.py +++ b/bittensor/utils/__init__.py @@ -25,6 +25,11 @@ from .registration import torch, use_torch from .version import check_version, VersionCheckError +try: + from typing import Self +except ImportError: + from typing_extensions import Self + if TYPE_CHECKING: from bittensor_wallet import Wallet from bittensor.core.types import ExtrinsicResponse, NeuronCertificateResponse diff --git a/bittensor/utils/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", diff --git a/bittensor/utils/epoch_schedule.py b/bittensor/utils/epoch_schedule.py new file mode 100644 index 0000000000..725c3b36cf --- /dev/null +++ b/bittensor/utils/epoch_schedule.py @@ -0,0 +1,53 @@ +"""Pure-function ports of subtensor's epoch scheduling logic.""" + + +def blocks_until_next_auto_epoch( + last_epoch_block: int, tempo: int, block_number: int +) -> int: + """Returns the number of blocks remaining before the next automatic epoch. + + Port of ``run_coinbase.rs::blocks_until_next_auto_epoch``. Does not account for ``PendingEpochAt``, the + ``BlocksSinceLastStep > MaxTempo`` safety-net, or the per-block-cap deferral. Caller must guard against + ``tempo == 0`` upstream. + + Parameters: + last_epoch_block: The block at which the last epoch fired for this subnet. + tempo: The subnet's tempo (epoch period in blocks). + block_number: The current (or reference) block number. + + Returns: + blocks_remaining: Non-negative number of blocks until ``last_epoch_block + tempo``. + """ + next_auto = last_epoch_block + tempo + return max(0, next_auto - block_number) + + +def is_in_admin_freeze_window( + *, + tempo: int, + pending_epoch_at: int, + last_epoch_block: int, + block_number: int, + admin_freeze_window: int, +) -> bool: + """Returns whether owner operations are blocked because an epoch is imminent. + + Returns ``True`` when the current block is within the terminal ``admin_freeze_window`` blocks before the next auto + epoch, or a pending manual trigger is armed (``pending_epoch_at > block_number``). + + Parameters: + tempo: The subnet's tempo (epoch period in blocks). Returns ``False`` when zero. + pending_epoch_at: Block at which an owner-triggered epoch is scheduled (``0`` = none). + last_epoch_block: The block at which the last epoch fired for this subnet. + block_number: The current (or reference) block number. + admin_freeze_window: How many blocks before an epoch owner operations are frozen. + + Returns: + is_frozen: ``True`` if owner operations should be blocked. + """ + if tempo == 0: + return False + if pending_epoch_at > 0 and pending_epoch_at > block_number: + return True + remaining = blocks_until_next_auto_epoch(last_epoch_block, tempo, block_number) + return remaining < admin_freeze_window diff --git a/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/pyproject.toml b/pyproject.toml index e7f17a71d8..fea33ca61a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [build-system] -requires = ["setuptools>=70.0.0", "wheel"] +requires = ["setuptools>=78.1.1"] build-backend = "setuptools.build_meta" [project] name = "bittensor" -version = "10.4.1" +version = "10.5.0" description = "Bittensor SDK" readme = "README.md" authors = [ @@ -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", @@ -29,9 +27,9 @@ dependencies = [ "retry==0.9.2", "requests>=2.33.0,<3.0", "pydantic>=2.3,<3", - "cyscale==0.4.0", + "cyscale==0.5.0", "uvicorn", - "bittensor-drand>=1.3.0,<2.0.0", + "bittensor-drand>=2.0.0,<3.0.0", "bittensor-wallet>=4.1.0", "async-substrate-interface>=2.0.4,<3.0.0", ] diff --git a/tests/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" + ) 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}" diff --git a/tests/e2e_tests/test_dynamic_tempo.py b/tests/e2e_tests/test_dynamic_tempo.py new file mode 100644 index 0000000000..94e6de3a05 --- /dev/null +++ b/tests/e2e_tests/test_dynamic_tempo.py @@ -0,0 +1,792 @@ +"""E2E tests for configurable tempo and owner-triggered epochs (Subtensor PR #2638).""" + +import time + +import numpy as np +import pytest + +from bittensor.utils.btlogging import logging +from bittensor.utils.weight_utils import convert_weights_and_uids_for_emit +from tests.e2e_tests.utils import ( + AdminUtils, + TestSubnet, + ACTIVATE_SUBNET, + REGISTER_SUBNET, + SUDO_SET_ADMIN_FREEZE_WINDOW, + SUDO_SET_COMMIT_REVEAL_WEIGHTS_ENABLED, + SUDO_SET_LOCK_REDUCTION_INTERVAL, + SUDO_SET_NETWORK_RATE_LIMIT, + SUDO_SET_TEMPO, + SUDO_SET_WEIGHTS_SET_RATE_LIMIT, + NETUID, +) + + +def _setup_subnet(subtensor, wallet, tempo=None, admin_freeze_window=0): + """Register and activate a subnet with the given tempo and freeze window.""" + if tempo is None: + tempo = subtensor.chain.get_min_tempo() + sn = TestSubnet(subtensor) + sn.execute_steps( + [ + SUDO_SET_ADMIN_FREEZE_WINDOW(wallet, AdminUtils, True, admin_freeze_window), + SUDO_SET_NETWORK_RATE_LIMIT(wallet, AdminUtils, True, 0), + SUDO_SET_LOCK_REDUCTION_INTERVAL(wallet, AdminUtils, True, 1), + ] + ) + sn.execute_steps( + [ + REGISTER_SUBNET(wallet), + SUDO_SET_TEMPO(wallet, AdminUtils, True, NETUID, tempo), + ACTIVATE_SUBNET(wallet), + ] + ) + return sn + + +async def _setup_subnet_async( + async_subtensor, wallet, tempo=None, admin_freeze_window=0 +): + """Register and activate a subnet with the given tempo and freeze window.""" + if tempo is None: + tempo = await async_subtensor.chain.get_min_tempo() + sn = TestSubnet(async_subtensor) + await sn.async_execute_steps( + [ + SUDO_SET_ADMIN_FREEZE_WINDOW(wallet, AdminUtils, True, admin_freeze_window), + SUDO_SET_NETWORK_RATE_LIMIT(wallet, AdminUtils, True, 0), + SUDO_SET_LOCK_REDUCTION_INTERVAL(wallet, AdminUtils, True, 1), + ] + ) + await sn.async_execute_steps( + [ + REGISTER_SUBNET(wallet), + SUDO_SET_TEMPO(wallet, AdminUtils, True, NETUID, tempo), + ACTIVATE_SUBNET(wallet), + ] + ) + return sn + + +def test_set_tempo(subtensor, alice_wallet): + """ + Verify owner set_tempo resets LastEpochBlock and matches get_next_epoch_start_block. + + Steps: + 1. Register and activate a subnet. + 2. Call owner set_tempo. + 3. Verify runtime epoch schedule and hyperparameters tempo. + """ + sn = _setup_subnet(subtensor, alice_wallet) + netuid = sn.netuid + + new_tempo = subtensor.chain.get_min_tempo() + 10 + result = subtensor.extrinsics.set_tempo( + wallet=alice_wallet, + netuid=netuid, + tempo=new_tempo, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert result.success, result.message + + block_at_set = subtensor.subnets.get_last_epoch_block(netuid) + assert ( + subtensor.subnets.get_next_epoch_start_block(netuid) == block_at_set + new_tempo + ) + + hp = subtensor.subnets.get_subnet_hyperparameters(netuid) + assert hp.tempo == new_tempo + + epoch_index = subtensor.subnets.get_subnet_epoch_index(netuid) + assert isinstance(epoch_index, int) and epoch_index >= 0 + + rate_limit = subtensor.chain.get_owner_hyperparam_rate_limit() + assert isinstance(rate_limit, int) and rate_limit > 0 + + +@pytest.mark.asyncio +async def test_set_tempo_async(async_subtensor, alice_wallet): + """ + Verify async owner set_tempo resets LastEpochBlock and matches get_next_epoch_start_block. + + Steps: + 1. Register and activate a subnet. + 2. Call owner set_tempo. + 3. Verify runtime epoch schedule and hyperparameters tempo. + """ + sn = await _setup_subnet_async(async_subtensor, alice_wallet) + netuid = sn.netuid + + new_tempo = await async_subtensor.chain.get_min_tempo() + 10 + result = await async_subtensor.extrinsics.set_tempo( + wallet=alice_wallet, + netuid=netuid, + tempo=new_tempo, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert result.success, result.message + + block_at_set = await async_subtensor.subnets.get_last_epoch_block(netuid) + next_start = await async_subtensor.subnets.get_next_epoch_start_block(netuid) + assert next_start == block_at_set + new_tempo + + hp = await async_subtensor.subnets.get_subnet_hyperparameters(netuid) + assert hp.tempo == new_tempo + + epoch_index = await async_subtensor.subnets.get_subnet_epoch_index(netuid) + assert isinstance(epoch_index, int) and epoch_index >= 0 + + rate_limit = await async_subtensor.chain.get_owner_hyperparam_rate_limit() + assert isinstance(rate_limit, int) and rate_limit > 0 + + +def test_trigger_epoch(subtensor, alice_wallet): + """ + Verify owner trigger_epoch is blocked by commit-reveal and succeeds after disabling it. + + Steps: + 1. Register and activate a subnet with a long tempo (CR enabled by default). + 2. Set admin freeze window. + 3. Attempt trigger_epoch — expect DynamicTempoBlockedByCommitReveal rejection. + 4. Disable commit-reveal. + 5. Call trigger_epoch again — expect success. + 6. Verify pending epoch and freeze window state. + """ + max_tempo = subtensor.chain.get_max_tempo() + sn = _setup_subnet(subtensor, alice_wallet, tempo=max_tempo, admin_freeze_window=0) + netuid = sn.netuid + + sn.execute_steps([SUDO_SET_ADMIN_FREEZE_WINDOW(alice_wallet, AdminUtils, True, 10)]) + freeze_window = subtensor.chain.get_admin_freeze_window() + + result = subtensor.extrinsics.trigger_epoch( + wallet=alice_wallet, + netuid=netuid, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert result.success is False + + sn.execute_steps( + [ + SUDO_SET_COMMIT_REVEAL_WEIGHTS_ENABLED( + alice_wallet, AdminUtils, True, NETUID, False + ) + ] + ) + + result = subtensor.extrinsics.trigger_epoch( + wallet=alice_wallet, + netuid=netuid, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert result.success, result.message + + pending = subtensor.subnets.get_pending_epoch_at(netuid) + assert pending > 0 + trigger_block = pending - freeze_window + assert pending == trigger_block + freeze_window + assert pending > subtensor.block + assert subtensor.chain.is_in_admin_freeze_window(netuid) is True + + +@pytest.mark.asyncio +async def test_trigger_epoch_async(async_subtensor, alice_wallet): + """ + Verify async owner trigger_epoch is blocked by commit-reveal and succeeds after disabling it. + + Steps: + 1. Register and activate a subnet with a long tempo (CR enabled by default). + 2. Set admin freeze window. + 3. Attempt trigger_epoch — expect DynamicTempoBlockedByCommitReveal rejection. + 4. Disable commit-reveal. + 5. Call trigger_epoch again — expect success. + 6. Verify pending epoch and freeze window state. + """ + max_tempo = await async_subtensor.chain.get_max_tempo() + sn = await _setup_subnet_async( + async_subtensor, alice_wallet, tempo=max_tempo, admin_freeze_window=0 + ) + netuid = sn.netuid + + await sn.async_execute_steps( + [SUDO_SET_ADMIN_FREEZE_WINDOW(alice_wallet, AdminUtils, True, 10)] + ) + freeze_window = await async_subtensor.chain.get_admin_freeze_window() + + result = await async_subtensor.extrinsics.trigger_epoch( + wallet=alice_wallet, + netuid=netuid, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert result.success is False + + await sn.async_execute_steps( + [ + SUDO_SET_COMMIT_REVEAL_WEIGHTS_ENABLED( + alice_wallet, AdminUtils, True, NETUID, False + ) + ] + ) + + result = await async_subtensor.extrinsics.trigger_epoch( + wallet=alice_wallet, + netuid=netuid, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert result.success, result.message + + pending = await async_subtensor.subnets.get_pending_epoch_at(netuid) + assert pending > 0 + trigger_block = pending - freeze_window + assert pending == trigger_block + freeze_window + assert pending > await async_subtensor.block + assert await async_subtensor.chain.is_in_admin_freeze_window(netuid) is True + + +def test_set_activity_cutoff_factor(subtensor, alice_wallet): + """ + Verify owner set_activity_cutoff_factor updates hyperparameters. + + Steps: + 1. Register and activate a subnet. + 2. Call owner set_activity_cutoff_factor. + 3. Verify activity_cutoff_factor in hyperparameters. + """ + sn = _setup_subnet(subtensor, alice_wallet) + netuid = sn.netuid + + new_factor = 5_000 + result = subtensor.extrinsics.set_activity_cutoff_factor( + wallet=alice_wallet, + netuid=netuid, + factor_milli=new_factor, + ) + assert result.success, result.message + + hp = subtensor.subnets.get_subnet_hyperparameters(netuid) + assert hp.activity_cutoff_factor == new_factor + + factor = subtensor.subnets.get_activity_cutoff_factor_milli(netuid) + assert factor == new_factor + + +@pytest.mark.asyncio +async def test_set_activity_cutoff_factor_async(async_subtensor, alice_wallet): + """ + Verify async owner set_activity_cutoff_factor updates hyperparameters. + + Steps: + 1. Register and activate a subnet. + 2. Call owner set_activity_cutoff_factor. + 3. Verify activity_cutoff_factor in hyperparameters. + """ + sn = await _setup_subnet_async(async_subtensor, alice_wallet) + netuid = sn.netuid + + new_factor = 5_000 + result = await async_subtensor.extrinsics.set_activity_cutoff_factor( + wallet=alice_wallet, + netuid=netuid, + factor_milli=new_factor, + ) + assert result.success, result.message + + hp = await async_subtensor.subnets.get_subnet_hyperparameters(netuid) + assert hp.activity_cutoff_factor == new_factor + + factor = await async_subtensor.subnets.get_activity_cutoff_factor_milli(netuid) + assert factor == new_factor + + +def test_commit_reveal_after_owner_set_tempo(subtensor, alice_wallet): + """ + Verify commit-reveal weights after owner set_tempo (not sudo tempo). + + Steps: + 1. Register and activate a subnet with commit-reveal enabled. + 2. Call owner set_tempo. + 3. Commit and reveal weights using CRv4 schedule. + """ + BLOCK_TIME = 0.25 if subtensor.chain.is_fast_blocks() else 12.0 + logging.console.info(f"Using block time: {BLOCK_TIME}") + + max_tempo = subtensor.chain.get_max_tempo() + min_tempo = subtensor.chain.get_min_tempo() + + sn = TestSubnet(subtensor) + sn.execute_steps( + [ + SUDO_SET_ADMIN_FREEZE_WINDOW(alice_wallet, AdminUtils, True, 0), + SUDO_SET_NETWORK_RATE_LIMIT(alice_wallet, AdminUtils, True, 0), + REGISTER_SUBNET(alice_wallet), + SUDO_SET_TEMPO(alice_wallet, AdminUtils, True, NETUID, max_tempo), + ACTIVATE_SUBNET(alice_wallet), + SUDO_SET_COMMIT_REVEAL_WEIGHTS_ENABLED( + alice_wallet, AdminUtils, True, NETUID, True + ), + SUDO_SET_WEIGHTS_SET_RATE_LIMIT(alice_wallet, AdminUtils, True, NETUID, 0), + ] + ) + netuid = sn.netuid + + owner_tempo = min_tempo + tempo_result = subtensor.extrinsics.set_tempo( + wallet=alice_wallet, + netuid=netuid, + tempo=owner_tempo, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert tempo_result.success, tempo_result.message + assert subtensor.subnets.get_subnet_hyperparameters(netuid).tempo == owner_tempo + + uids = np.array([0], dtype=np.int64) + weights = np.array([0.1], dtype=np.float32) + weight_uids, weight_vals = convert_weights_and_uids_for_emit( + uids=uids, weights=weights + ) + + current_block = subtensor.chain.get_current_block() + upcoming_tempo = subtensor.subnets.get_next_epoch_start_block(netuid) + if upcoming_tempo - current_block < 6: + sn.wait_next_epoch() + current_block = subtensor.chain.get_current_block() + upcoming_tempo = subtensor.subnets.get_next_epoch_start_block(netuid) + logging.console.info( + f"Current block: {current_block}, next epoch: {upcoming_tempo}" + ) + + expected_commit_block = subtensor.block + 2 + response = subtensor.extrinsics.set_weights( + wallet=alice_wallet, + netuid=netuid, + mechid=0, + uids=weight_uids, + weights=weight_vals, + wait_for_inclusion=True, + wait_for_finalization=True, + block_time=BLOCK_TIME, + period=16, + raise_error=True, + ) + assert response.success is True, response.message + expected_reveal_round = response.data.get("reveal_round") + assert expected_reveal_round is not None + + subtensor.wait_for_block(subtensor.block + 1) + + commits_on_chain = subtensor.commitments.get_timelocked_weight_commits( + netuid=netuid, mechid=0 + ) + address, commit_block, _commit, reveal_round = commits_on_chain[0] + assert expected_reveal_round == reveal_round + assert address == alice_wallet.hotkey.ss58_address + assert expected_commit_block in list(range(commit_block - 2, commit_block + 2)) + assert subtensor.subnets.weights(netuid=netuid, mechid=0) == [] + + expected_reveal_block = subtensor.subnets.get_next_epoch_start_block(netuid) + 5 + subtensor.wait_for_block(expected_reveal_block) + + latest_drand_round = 0 + while latest_drand_round <= expected_reveal_round: + latest_drand_round = subtensor.chain.last_drand_round() + time.sleep(3) + + subnet_weights = subtensor.subnets.weights(netuid=netuid, mechid=0) + assert subnet_weights != [] + revealed_weights = subnet_weights[0][1] + assert weight_uids[0] == revealed_weights[0][0] + assert weight_vals[0] == revealed_weights[0][1] + assert ( + subtensor.commitments.get_timelocked_weight_commits(netuid=netuid, mechid=0) + == [] + ) + + +@pytest.mark.asyncio +async def test_commit_reveal_after_owner_set_tempo_async(async_subtensor, alice_wallet): + """ + Verify async commit-reveal weights after owner set_tempo (not sudo tempo). + + Steps: + 1. Register and activate a subnet with commit-reveal enabled. + 2. Call owner set_tempo. + 3. Commit and reveal weights using CRv4 schedule. + """ + BLOCK_TIME = 0.25 if await async_subtensor.chain.is_fast_blocks() else 12.0 + logging.console.info(f"Using block time: {BLOCK_TIME}") + + max_tempo = await async_subtensor.chain.get_max_tempo() + min_tempo = await async_subtensor.chain.get_min_tempo() + + sn = TestSubnet(async_subtensor) + await sn.async_execute_steps( + [ + SUDO_SET_ADMIN_FREEZE_WINDOW(alice_wallet, AdminUtils, True, 0), + SUDO_SET_NETWORK_RATE_LIMIT(alice_wallet, AdminUtils, True, 0), + REGISTER_SUBNET(alice_wallet), + SUDO_SET_TEMPO(alice_wallet, AdminUtils, True, NETUID, max_tempo), + ACTIVATE_SUBNET(alice_wallet), + SUDO_SET_COMMIT_REVEAL_WEIGHTS_ENABLED( + alice_wallet, AdminUtils, True, NETUID, True + ), + SUDO_SET_WEIGHTS_SET_RATE_LIMIT(alice_wallet, AdminUtils, True, NETUID, 0), + ] + ) + netuid = sn.netuid + + owner_tempo = min_tempo + tempo_result = await async_subtensor.extrinsics.set_tempo( + wallet=alice_wallet, + netuid=netuid, + tempo=owner_tempo, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert tempo_result.success, tempo_result.message + hp = await async_subtensor.subnets.get_subnet_hyperparameters(netuid) + assert hp.tempo == owner_tempo + + uids = np.array([0], dtype=np.int64) + weights = np.array([0.1], dtype=np.float32) + weight_uids, weight_vals = convert_weights_and_uids_for_emit( + uids=uids, weights=weights + ) + + current_block = await async_subtensor.chain.get_current_block() + upcoming_tempo = await async_subtensor.subnets.get_next_epoch_start_block(netuid) + if upcoming_tempo - current_block < 6: + await sn.wait_next_epoch() + current_block = await async_subtensor.chain.get_current_block() + upcoming_tempo = await async_subtensor.subnets.get_next_epoch_start_block(netuid) + logging.console.info( + f"Current block: {current_block}, next epoch: {upcoming_tempo}" + ) + + expected_commit_block = await async_subtensor.block + 2 + response = await async_subtensor.extrinsics.set_weights( + wallet=alice_wallet, + netuid=netuid, + mechid=0, + uids=weight_uids, + weights=weight_vals, + wait_for_inclusion=True, + wait_for_finalization=True, + block_time=BLOCK_TIME, + period=16, + raise_error=True, + ) + assert response.success is True, response.message + expected_reveal_round = response.data.get("reveal_round") + assert expected_reveal_round is not None + + await async_subtensor.wait_for_block(await async_subtensor.block + 1) + + commits_on_chain = await async_subtensor.commitments.get_timelocked_weight_commits( + netuid=netuid, mechid=0 + ) + address, commit_block, _commit, reveal_round = commits_on_chain[0] + assert expected_reveal_round == reveal_round + assert address == alice_wallet.hotkey.ss58_address + assert expected_commit_block in list(range(commit_block - 2, commit_block + 2)) + + assert await async_subtensor.subnets.weights(netuid=netuid, mechid=0) == [] + + expected_reveal_block = ( + await async_subtensor.subnets.get_next_epoch_start_block(netuid) + 5 + ) + await async_subtensor.wait_for_block(expected_reveal_block) + + latest_drand_round = 0 + while latest_drand_round <= expected_reveal_round: + latest_drand_round = await async_subtensor.chain.last_drand_round() + time.sleep(3) + + subnet_weights = await async_subtensor.subnets.weights(netuid=netuid, mechid=0) + assert subnet_weights != [] + revealed_weights = subnet_weights[0][1] + assert weight_uids[0] == revealed_weights[0][0] + assert weight_vals[0] == revealed_weights[0][1] + assert ( + await async_subtensor.commitments.get_timelocked_weight_commits( + netuid=netuid, mechid=0 + ) + == [] + ) + + +def test_root_set_activity_cutoff_factor(subtensor, alice_wallet): + """ + Verify sudo root_set_activity_cutoff_factor overrides the owner-level value. + + Steps: + 1. Register and activate a subnet. + 2. Call root_set_activity_cutoff_factor via sudo. + 3. Verify the factor is updated in hyperparameters and direct query. + """ + sn = _setup_subnet(subtensor, alice_wallet) + netuid = sn.netuid + + new_factor = subtensor.chain.get_min_activity_cutoff_factor_milli() + 500 + result = subtensor.extrinsics.root_set_activity_cutoff_factor( + wallet=alice_wallet, + netuid=netuid, + factor_milli=new_factor, + ) + assert result.success, result.message + + hp = subtensor.subnets.get_subnet_hyperparameters(netuid) + assert hp.activity_cutoff_factor == new_factor + + factor = subtensor.subnets.get_activity_cutoff_factor_milli(netuid) + assert factor == new_factor + + +@pytest.mark.asyncio +async def test_root_set_activity_cutoff_factor_async(async_subtensor, alice_wallet): + """ + Verify async sudo root_set_activity_cutoff_factor overrides the owner-level value. + + Steps: + 1. Register and activate a subnet. + 2. Call root_set_activity_cutoff_factor via sudo. + 3. Verify the factor is updated in hyperparameters and direct query. + """ + sn = await _setup_subnet_async(async_subtensor, alice_wallet) + netuid = sn.netuid + + new_factor = ( + await async_subtensor.chain.get_min_activity_cutoff_factor_milli() + 500 + ) + result = await async_subtensor.extrinsics.root_set_activity_cutoff_factor( + wallet=alice_wallet, + netuid=netuid, + factor_milli=new_factor, + ) + assert result.success, result.message + + hp = await async_subtensor.subnets.get_subnet_hyperparameters(netuid) + assert hp.activity_cutoff_factor == new_factor + + factor = await async_subtensor.subnets.get_activity_cutoff_factor_milli(netuid) + assert factor == new_factor + + +def test_tempo_control_negative_cases(subtensor, alice_wallet, bob_wallet): + """ + Verify negative scenarios for tempo control extrinsics. + + Steps: + 1. Register and activate a subnet (owner = alice). + 2. Non-owner (bob) attempts set_tempo — expect failure. + 3. Owner sets tempo below chain minimum — expect failure. + 4. Owner sets tempo above chain maximum — expect failure. + 5. Owner sets activity cutoff factor above chain maximum — expect failure. + 6. Owner sets activity cutoff factor below chain minimum — expect failure. + """ + sn = _setup_subnet(subtensor, alice_wallet) + netuid = sn.netuid + + min_tempo = subtensor.chain.get_min_tempo() + max_tempo = subtensor.chain.get_max_tempo() + min_cutoff = subtensor.chain.get_min_activity_cutoff_factor_milli() + max_cutoff = subtensor.chain.get_max_activity_cutoff_factor_milli() + + result = subtensor.extrinsics.set_tempo( + wallet=bob_wallet, + netuid=netuid, + tempo=min_tempo + 5, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert result.success is False + + result = subtensor.extrinsics.set_tempo( + wallet=alice_wallet, + netuid=netuid, + tempo=min_tempo - 1, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert result.success is False + + result = subtensor.extrinsics.set_tempo( + wallet=alice_wallet, + netuid=netuid, + tempo=max_tempo + 1, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert result.success is False + + result = subtensor.extrinsics.set_activity_cutoff_factor( + wallet=alice_wallet, + netuid=netuid, + factor_milli=max_cutoff + 1, + ) + assert result.success is False + + result = subtensor.extrinsics.set_activity_cutoff_factor( + wallet=alice_wallet, + netuid=netuid, + factor_milli=min_cutoff - 1, + ) + assert result.success is False + + +@pytest.mark.asyncio +async def test_tempo_control_negative_cases_async( + async_subtensor, alice_wallet, bob_wallet +): + """ + Verify async negative scenarios for tempo control extrinsics. + + Steps: + 1. Register and activate a subnet (owner = alice). + 2. Non-owner (bob) attempts set_tempo — expect failure. + 3. Owner sets tempo below chain minimum — expect failure. + 4. Owner sets tempo above chain maximum — expect failure. + 5. Owner sets activity cutoff factor above chain maximum — expect failure. + 6. Owner sets activity cutoff factor below chain minimum — expect failure. + """ + sn = await _setup_subnet_async(async_subtensor, alice_wallet) + netuid = sn.netuid + + min_tempo = await async_subtensor.chain.get_min_tempo() + max_tempo = await async_subtensor.chain.get_max_tempo() + min_cutoff = await async_subtensor.chain.get_min_activity_cutoff_factor_milli() + max_cutoff = await async_subtensor.chain.get_max_activity_cutoff_factor_milli() + + result = await async_subtensor.extrinsics.set_tempo( + wallet=bob_wallet, + netuid=netuid, + tempo=min_tempo + 5, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert result.success is False + + result = await async_subtensor.extrinsics.set_tempo( + wallet=alice_wallet, + netuid=netuid, + tempo=min_tempo - 1, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert result.success is False + + result = await async_subtensor.extrinsics.set_tempo( + wallet=alice_wallet, + netuid=netuid, + tempo=max_tempo + 1, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert result.success is False + + result = await async_subtensor.extrinsics.set_activity_cutoff_factor( + wallet=alice_wallet, + netuid=netuid, + factor_milli=max_cutoff + 1, + ) + assert result.success is False + + result = await async_subtensor.extrinsics.set_activity_cutoff_factor( + wallet=alice_wallet, + netuid=netuid, + factor_milli=min_cutoff - 1, + ) + assert result.success is False + + +def test_set_tempo_rejected_in_freeze_window(subtensor, alice_wallet): + """ + Verify set_tempo is rejected when the subnet is in admin freeze window. + + Steps: + 1. Register and activate a subnet with long tempo and no freeze window. + 2. Set admin freeze window, trigger epoch to enter freeze state. + 3. Attempt set_tempo while frozen — expect failure. + """ + max_tempo = subtensor.chain.get_max_tempo() + min_tempo = subtensor.chain.get_min_tempo() + sn = _setup_subnet(subtensor, alice_wallet, tempo=max_tempo, admin_freeze_window=0) + netuid = sn.netuid + + sn.execute_steps( + [ + SUDO_SET_ADMIN_FREEZE_WINDOW(alice_wallet, AdminUtils, True, 10), + SUDO_SET_COMMIT_REVEAL_WEIGHTS_ENABLED( + alice_wallet, AdminUtils, True, NETUID, False + ), + ] + ) + + result = subtensor.extrinsics.trigger_epoch( + wallet=alice_wallet, + netuid=netuid, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert result.success, result.message + assert subtensor.chain.is_in_admin_freeze_window(netuid) is True + + result = subtensor.extrinsics.set_tempo( + wallet=alice_wallet, + netuid=netuid, + tempo=min_tempo, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert result.success is False + + +@pytest.mark.asyncio +async def test_set_tempo_rejected_in_freeze_window_async(async_subtensor, alice_wallet): + """ + Verify async set_tempo is rejected when the subnet is in admin freeze window. + + Steps: + 1. Register and activate a subnet with long tempo and no freeze window. + 2. Set admin freeze window, trigger epoch to enter freeze state. + 3. Attempt set_tempo while frozen — expect failure. + """ + max_tempo = await async_subtensor.chain.get_max_tempo() + min_tempo = await async_subtensor.chain.get_min_tempo() + sn = await _setup_subnet_async( + async_subtensor, alice_wallet, tempo=max_tempo, admin_freeze_window=0 + ) + netuid = sn.netuid + + await sn.async_execute_steps( + [ + SUDO_SET_ADMIN_FREEZE_WINDOW(alice_wallet, AdminUtils, True, 10), + SUDO_SET_COMMIT_REVEAL_WEIGHTS_ENABLED( + alice_wallet, AdminUtils, True, NETUID, False + ), + ] + ) + + result = await async_subtensor.extrinsics.trigger_epoch( + wallet=alice_wallet, + netuid=netuid, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert result.success, result.message + assert await async_subtensor.chain.is_in_admin_freeze_window(netuid) is True + + result = await async_subtensor.extrinsics.set_tempo( + wallet=alice_wallet, + netuid=netuid, + tempo=min_tempo, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert result.success is False diff --git a/tests/e2e_tests/test_liquidity.py b/tests/e2e_tests/test_liquidity.py deleted file mode 100644 index 674278ef14..0000000000 --- a/tests/e2e_tests/test_liquidity.py +++ /dev/null @@ -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/e2e_tests/test_lock_stake.py b/tests/e2e_tests/test_lock_stake.py index c26df229d9..7fc900208e 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): @@ -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, diff --git a/tests/e2e_tests/test_metagraph.py b/tests/e2e_tests/test_metagraph.py index b5d58bb37c..c6f7f6b1d3 100644 --- a/tests/e2e_tests/test_metagraph.py +++ b/tests/e2e_tests/test_metagraph.py @@ -614,6 +614,12 @@ def test_metagraph_info(subtensor, alice_wallet, bob_wallet): metagraph_info = subtensor.metagraphs.get_metagraph_info(netuid=1, block=1) + expected_activity_cutoff = max( + 1, + subtensor.subnets.get_activity_cutoff_factor_milli(1) + * 100 # tempo + // 1000, + ) expected_metagraph_info = MetagraphInfo( netuid=1, mechid=0, @@ -644,7 +650,7 @@ def test_metagraph_info(subtensor, alice_wallet, bob_wallet): max_weights_limit=1.0, weights_version=0, weights_rate_limit=100, - activity_cutoff=5000, + activity_cutoff=expected_activity_cutoff, max_validators=64, num_uids=1, max_uids=256, @@ -711,6 +717,12 @@ def test_metagraph_info(subtensor, alice_wallet, bob_wallet): metagraph_infos = subtensor.metagraphs.get_all_metagraphs_info(block=1) + root_activity_cutoff = max( + 1, + subtensor.subnets.get_activity_cutoff_factor_milli(0) + * 100 # tempo + // 1000, + ) expected_metagraph_infos = [ MetagraphInfo( netuid=0, @@ -742,7 +754,7 @@ def test_metagraph_info(subtensor, alice_wallet, bob_wallet): max_weights_limit=1.0, weights_version=0, weights_rate_limit=100, - activity_cutoff=5000, + activity_cutoff=root_activity_cutoff, max_validators=64, num_uids=0, max_uids=64, @@ -865,6 +877,12 @@ async def test_metagraph_info_async(async_subtensor, alice_wallet, bob_wallet): netuid=1, block=1 ) + expected_activity_cutoff = max( + 1, + await async_subtensor.subnets.get_activity_cutoff_factor_milli(1) + * 100 # tempo + // 1000, + ) expected_metagraph_info = MetagraphInfo( netuid=1, mechid=0, @@ -895,7 +913,7 @@ async def test_metagraph_info_async(async_subtensor, alice_wallet, bob_wallet): max_weights_limit=1.0, weights_version=0, weights_rate_limit=100, - activity_cutoff=5000, + activity_cutoff=expected_activity_cutoff, max_validators=64, num_uids=1, max_uids=256, @@ -962,6 +980,12 @@ async def test_metagraph_info_async(async_subtensor, alice_wallet, bob_wallet): metagraph_infos = await async_subtensor.metagraphs.get_all_metagraphs_info(block=1) + root_activity_cutoff = max( + 1, + await async_subtensor.subnets.get_activity_cutoff_factor_milli(0) + * 100 # tempo + // 1000, + ) expected_metagraph_infos = [ MetagraphInfo( netuid=0, @@ -993,7 +1017,7 @@ async def test_metagraph_info_async(async_subtensor, alice_wallet, bob_wallet): max_weights_limit=1.0, weights_version=0, weights_rate_limit=100, - activity_cutoff=5000, + activity_cutoff=root_activity_cutoff, max_validators=64, num_uids=0, max_uids=64, diff --git a/tests/e2e_tests/test_root_claim.py b/tests/e2e_tests/test_root_claim.py index 1fadb2ca9d..15fa140565 100644 --- a/tests/e2e_tests/test_root_claim.py +++ b/tests/e2e_tests/test_root_claim.py @@ -97,7 +97,7 @@ def test_root_claim_swap(subtensor, alice_wallet, bob_wallet, charlie_wallet): # We skip the era in which the stake was installed, since the emission doesn't occur (Subtensor implementation) logging.console.info(f"Skipping stake epoch") next_epoch_start_block = subtensor.subnets.get_next_epoch_start_block( - netuid=root_sn.netuid + netuid=sn2.netuid ) subtensor.wait_for_block(block=next_epoch_start_block) @@ -116,7 +116,7 @@ def test_root_claim_swap(subtensor, alice_wallet, bob_wallet, charlie_wallet): epochs_left = MAX_EPOCHS_FOR_INCREASE while charlie_root_stake <= prev_root_stake and epochs_left > 0: next_epoch_start_block = subtensor.subnets.get_next_epoch_start_block( - netuid=root_sn.netuid + netuid=sn2.netuid ) subtensor.wait_for_block(block=next_epoch_start_block + 4) charlie_root_stake = subtensor.staking.get_stake( @@ -210,7 +210,7 @@ async def test_root_claim_swap_async( # We skip the era in which the stake was installed, since the emission doesn't occur (Subtensor implementation) logging.console.info(f"Skipping stake epoch") next_epoch_start_block = await async_subtensor.subnets.get_next_epoch_start_block( - netuid=root_sn.netuid + netuid=sn2.netuid ) await async_subtensor.wait_for_block(block=next_epoch_start_block) @@ -230,7 +230,7 @@ async def test_root_claim_swap_async( while charlie_root_stake <= prev_root_stake and epochs_left > 0: next_epoch_start_block = ( await async_subtensor.subnets.get_next_epoch_start_block( - netuid=root_sn.netuid + netuid=sn2.netuid ) ) await async_subtensor.wait_for_block(block=next_epoch_start_block + 4) @@ -334,7 +334,7 @@ def test_root_claim_keep_with_zero_num_root_auto_claims( # proof that ROOT stake isn't changes until it's claimed manually while proof_counter > 0: next_epoch_start_block = subtensor.subnets.get_next_epoch_start_block( - root_sn.netuid + sn2.netuid ) subtensor.wait_for_block(next_epoch_start_block) @@ -505,7 +505,7 @@ async def test_root_claim_keep_with_zero_num_root_auto_claims_async( # proof that ROOT stake isn't changes until it's claimed manually while proof_counter > 0: next_epoch_start_block = ( - await async_subtensor.subnets.get_next_epoch_start_block(root_sn.netuid) + await async_subtensor.subnets.get_next_epoch_start_block(sn2.netuid) ) await async_subtensor.wait_for_block(next_epoch_start_block) @@ -674,7 +674,7 @@ def test_root_claim_keep_with_random_auto_claims( # Skip the epoch in which stake was installed. Emission doesn't occur in the same epoch as stake installation logging.console.info("Skipping stake epoch") next_epoch_start_block = subtensor.subnets.get_next_epoch_start_block( - netuid=root_sn.netuid + netuid=sn2.netuid ) subtensor.wait_for_block(next_epoch_start_block) @@ -702,7 +702,7 @@ def test_root_claim_keep_with_random_auto_claims( epochs_left = MAX_EPOCHS_FOR_INCREASE while claimed_stake_charlie <= prev_claimed_stake_charlie and epochs_left > 0: next_epoch_start_block = subtensor.subnets.get_next_epoch_start_block( - root_sn.netuid + sn2.netuid ) subtensor.wait_for_block(next_epoch_start_block + 4) @@ -817,7 +817,7 @@ async def test_root_claim_keep_with_random_auto_claims_async( # Skip the epoch in which stake was installed. Emission doesn't occur in the same epoch as stake installation logging.console.info("Skipping stake epoch") next_epoch_start_block = await async_subtensor.subnets.get_next_epoch_start_block( - netuid=root_sn.netuid + netuid=sn2.netuid ) await async_subtensor.wait_for_block(next_epoch_start_block) @@ -845,7 +845,7 @@ async def test_root_claim_keep_with_random_auto_claims_async( epochs_left = MAX_EPOCHS_FOR_INCREASE while claimed_stake_charlie <= prev_claimed_stake_charlie and epochs_left > 0: next_epoch_start_block = ( - await async_subtensor.subnets.get_next_epoch_start_block(root_sn.netuid) + await async_subtensor.subnets.get_next_epoch_start_block(sn2.netuid) ) await async_subtensor.wait_for_block(next_epoch_start_block + 4) @@ -946,13 +946,13 @@ def test_root_claim_keep_subnets_basic( # Skip the epoch in which stake was installed logging.console.info("Skipping stake epoch") next_epoch_start_block = subtensor.subnets.get_next_epoch_start_block( - netuid=root_sn.netuid + netuid=sn2.netuid ) subtensor.wait_for_block(next_epoch_start_block) # Wait for one epoch and check that get_root_alpha_dividends_per_subnet increases for SN2 next_epoch_start_block = subtensor.subnets.get_next_epoch_start_block( - netuid=root_sn.netuid + netuid=sn2.netuid ) subtensor.wait_for_block(next_epoch_start_block) @@ -1041,13 +1041,13 @@ async def test_root_claim_keep_subnets_basic_async( # Skip the epoch in which stake was installed logging.console.info("Skipping stake epoch") next_epoch_start_block = await async_subtensor.subnets.get_next_epoch_start_block( - netuid=root_sn.netuid + netuid=sn2.netuid ) await async_subtensor.wait_for_block(next_epoch_start_block) # Wait for one epoch and check that get_root_alpha_dividends_per_subnet increases for SN2 next_epoch_start_block = await async_subtensor.subnets.get_next_epoch_start_block( - netuid=root_sn.netuid + netuid=sn2.netuid ) await async_subtensor.wait_for_block(next_epoch_start_block) @@ -1136,7 +1136,7 @@ def test_root_claim_keep_subnets_with_auto_claims( # Skip the epoch in which stake was installed logging.console.info("Skipping stake epoch") next_epoch_start_block = subtensor.subnets.get_next_epoch_start_block( - netuid=root_sn.netuid + netuid=sn2.netuid ) subtensor.wait_for_block(next_epoch_start_block) @@ -1151,7 +1151,7 @@ def test_root_claim_keep_subnets_with_auto_claims( # Wait for epochs and check that stake increases on SN2 due to auto claims while proof_counter > 0: next_epoch_start_block = subtensor.subnets.get_next_epoch_start_block( - root_sn.netuid + sn2.netuid ) subtensor.wait_for_block(next_epoch_start_block) @@ -1247,7 +1247,7 @@ async def test_root_claim_keep_subnets_with_auto_claims_async( # Skip the epoch in which stake was installed logging.console.info("Skipping stake epoch") next_epoch_start_block = await async_subtensor.subnets.get_next_epoch_start_block( - netuid=root_sn.netuid + netuid=sn2.netuid ) await async_subtensor.wait_for_block(next_epoch_start_block) @@ -1262,7 +1262,7 @@ async def test_root_claim_keep_subnets_with_auto_claims_async( # Wait for epochs and check that stake increases on SN2 due to auto claims while proof_counter > 0: next_epoch_start_block = ( - await async_subtensor.subnets.get_next_epoch_start_block(root_sn.netuid) + await async_subtensor.subnets.get_next_epoch_start_block(sn2.netuid) ) await async_subtensor.wait_for_block(next_epoch_start_block) @@ -1377,13 +1377,13 @@ def test_root_claim_keep_subnets_multiple_subnets( # Skip the epoch in which stake was installed logging.console.info("Skipping stake epoch") next_epoch_start_block = subtensor.subnets.get_next_epoch_start_block( - netuid=root_sn.netuid + netuid=sn2.netuid ) subtensor.wait_for_block(next_epoch_start_block) # Wait for one epoch next_epoch_start_block = subtensor.subnets.get_next_epoch_start_block( - netuid=root_sn.netuid + netuid=sn2.netuid ) subtensor.wait_for_block(next_epoch_start_block) @@ -1516,13 +1516,13 @@ async def test_root_claim_keep_subnets_multiple_subnets_async( # Skip the epoch in which stake was installed logging.console.info("Skipping stake epoch") next_epoch_start_block = await async_subtensor.subnets.get_next_epoch_start_block( - netuid=root_sn.netuid + netuid=sn2.netuid ) await async_subtensor.wait_for_block(next_epoch_start_block) # Wait for one epoch next_epoch_start_block = await async_subtensor.subnets.get_next_epoch_start_block( - netuid=root_sn.netuid + netuid=sn2.netuid ) await async_subtensor.wait_for_block(next_epoch_start_block) diff --git a/tests/unit_tests/chain_data/test_subnet_hyperparameters.py b/tests/unit_tests/chain_data/test_subnet_hyperparameters.py new file mode 100644 index 0000000000..2ca0f08e54 --- /dev/null +++ b/tests/unit_tests/chain_data/test_subnet_hyperparameters.py @@ -0,0 +1,93 @@ +import pytest + +from bittensor.core.chain_data.subnet_hyperparameters import SubnetHyperparameters + +_HYPERPARAMS_V3 = [ + {"name": "kappa", "value": {"U16": 32767}}, + {"name": "tempo", "value": {"U16": 100}}, + {"name": "max_weights_limit", "value": {"U16": 65535}}, + {"name": "weights_rate_limit", "value": {"U64": 100}}, + {"name": "registration_allowed", "value": {"Bool": True}}, + {"name": "liquid_alpha_enabled", "value": {"Bool": False}}, + {"name": "min_burn", "value": {"TaoBalance": 500000}}, + {"name": "alpha_sigmoid_steepness", "value": {"I32F32": {"bits": 4294967296000}}}, + {"name": "activity_cutoff_factor", "value": {"U32": 13889}}, +] + + +@pytest.mark.parametrize( + "tag", + ["U8", "U16", "U32", "U64", "U128", "I8", "I16", "I32", "I64"], +) +def test_decode_value_integer_tags(tag): + """Verify integer SCALE tags decode to plain ints.""" + result = SubnetHyperparameters._decode_value({tag: 42}) + assert result == 42 + assert type(result) is int + + +def test_decode_value_bool_tag(): + """Verify Bool SCALE tag decodes to bool.""" + assert SubnetHyperparameters._decode_value({"Bool": True}) is True + assert SubnetHyperparameters._decode_value({"Bool": False}) is False + + +def test_from_any_decodes_v3_list(): + """Verify v3 Vec decoding into typed fields.""" + params = SubnetHyperparameters.from_any(_HYPERPARAMS_V3) + assert params.kappa == 32767 + assert params.tempo == 100 + assert params.max_weight_limit == 65535 + assert params.weights_rate_limit == 100 + assert params.registration_allowed is True + assert params.liquid_alpha_enabled is False + assert params.min_burn == 500000 + assert params.activity_cutoff_factor == 13889 + assert isinstance(params.alpha_sigmoid_steepness, float) + + +def test_from_any_decodes_v2_dict(): + """Verify v2 struct dict decoding into typed fields.""" + params = SubnetHyperparameters.from_any( + { + "rho": 10, + "tempo": 360, + "max_weights_limit": 49151, + "registration_allowed": True, + "alpha_sigmoid_steepness": {"bits": 4294967296000}, + } + ) + assert params.rho == 10 + assert params.tempo == 360 + assert params.max_weight_limit == 49151 + assert params.registration_allowed is True + assert isinstance(params.alpha_sigmoid_steepness, float) + + +def test_from_any_unknown_field_goes_to_spillover(): + """Verify unknown v3 entries are accessible via spillover mapping.""" + params = SubnetHyperparameters.from_any( + _HYPERPARAMS_V3 + [{"name": "future_param", "value": {"U128": 7}}] + ) + assert params.future_param == 7 + assert "future_param" in params + + +def test_from_any_bytes_name(): + """Verify byte-string entry names are decoded.""" + params = SubnetHyperparameters.from_any([{"name": b"tempo", "value": {"U16": 360}}]) + assert params.tempo == 360 + + +def test_subnet_hyperparameters_access(): + """Verify attribute, item, and get access patterns.""" + params = SubnetHyperparameters.from_any(_HYPERPARAMS_V3) + assert params.tempo == 100 + assert params["tempo"] == 100 + assert params.get("missing", "default") == "default" + + +def test_from_any_passthrough_existing_instance(): + """Verify from_any returns the same instance when already decoded.""" + original = SubnetHyperparameters.from_any(_HYPERPARAMS_V3) + assert SubnetHyperparameters.from_any(original) is original diff --git a/tests/unit_tests/extrinsics/asyncex/test_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/asyncex/test_tempo_control.py b/tests/unit_tests/extrinsics/asyncex/test_tempo_control.py new file mode 100644 index 0000000000..464d2b4a54 --- /dev/null +++ b/tests/unit_tests/extrinsics/asyncex/test_tempo_control.py @@ -0,0 +1,367 @@ +import pytest + +from bittensor.core.extrinsics.asyncex import tempo_control +from bittensor.core.types import ExtrinsicResponse + + +@pytest.mark.asyncio +async def test_set_tempo_extrinsic(subtensor, mocker, fake_wallet): + """Verify that async set_tempo_extrinsic composes correct call and submits it.""" + # Preps + netuid = 1 + tempo = 500 + mocked_set_tempo = mocker.patch.object( + tempo_control.SubtensorModule, + "set_tempo", + new=mocker.AsyncMock(), + ) + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + success, message = await tempo_control.set_tempo_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + tempo=tempo, + mev_protection=False, + ) + + # Asserts + mocked_set_tempo.assert_awaited_once_with(netuid=netuid, tempo=tempo) + mocked_sign_and_send_extrinsic.assert_awaited_once_with( + call=mocked_set_tempo.return_value, + wallet=fake_wallet, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert success is True + assert "Success" in message + + +@pytest.mark.asyncio +async def test_set_tempo_extrinsic_mev_protection(subtensor, mocker, fake_wallet): + """Verify that async set_tempo_extrinsic uses submit_encrypted_extrinsic when mev_protection=True.""" + # Preps + netuid = 1 + tempo = 500 + mocked_set_tempo = mocker.patch.object( + tempo_control.SubtensorModule, + "set_tempo", + new=mocker.AsyncMock(), + ) + mock_submit = mocker.patch( + "bittensor.core.extrinsics.asyncex.tempo_control.submit_encrypted_extrinsic", + new_callable=mocker.AsyncMock, + return_value=ExtrinsicResponse(True, "Success"), + ) + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, "sign_and_send_extrinsic" + ) + + # Call + success, message = await tempo_control.set_tempo_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + tempo=tempo, + mev_protection=True, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + + # Asserts + assert success is True + assert "Success" in message + mock_submit.assert_awaited_once_with( + subtensor=subtensor, + wallet=fake_wallet, + call=mocked_set_tempo.return_value, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + mocked_sign_and_send_extrinsic.assert_not_called() + + +@pytest.mark.asyncio +async def test_set_activity_cutoff_factor_extrinsic(subtensor, mocker, fake_wallet): + """Verify that async set_activity_cutoff_factor_extrinsic composes correct call and submits it.""" + # Preps + netuid = 1 + factor_milli = 5000 + mocked_set_factor = mocker.patch.object( + tempo_control.SubtensorModule, + "set_activity_cutoff_factor", + new=mocker.AsyncMock(), + ) + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + success, message = await tempo_control.set_activity_cutoff_factor_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + factor_milli=factor_milli, + mev_protection=False, + ) + + # Asserts + mocked_set_factor.assert_awaited_once_with(netuid=netuid, factor_milli=factor_milli) + mocked_sign_and_send_extrinsic.assert_awaited_once_with( + call=mocked_set_factor.return_value, + wallet=fake_wallet, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert success is True + assert "Success" in message + + +@pytest.mark.asyncio +async def test_set_activity_cutoff_factor_extrinsic_mev_protection( + subtensor, mocker, fake_wallet +): + """Verify that async set_activity_cutoff_factor_extrinsic uses submit_encrypted_extrinsic when mev_protection=True.""" + # Preps + netuid = 1 + factor_milli = 5000 + mocked_set_factor = mocker.patch.object( + tempo_control.SubtensorModule, + "set_activity_cutoff_factor", + new=mocker.AsyncMock(), + ) + mock_submit = mocker.patch( + "bittensor.core.extrinsics.asyncex.tempo_control.submit_encrypted_extrinsic", + new_callable=mocker.AsyncMock, + return_value=ExtrinsicResponse(True, "Success"), + ) + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, "sign_and_send_extrinsic" + ) + + # Call + success, message = await tempo_control.set_activity_cutoff_factor_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + factor_milli=factor_milli, + mev_protection=True, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + + # Asserts + assert success is True + assert "Success" in message + mock_submit.assert_awaited_once_with( + subtensor=subtensor, + wallet=fake_wallet, + call=mocked_set_factor.return_value, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + mocked_sign_and_send_extrinsic.assert_not_called() + + +@pytest.mark.asyncio +async def test_trigger_epoch_extrinsic(subtensor, mocker, fake_wallet): + """Verify that async trigger_epoch_extrinsic composes correct call and submits it.""" + # Preps + netuid = 1 + mocked_trigger_epoch = mocker.patch.object( + tempo_control.SubtensorModule, + "trigger_epoch", + new=mocker.AsyncMock(), + ) + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + success, message = await tempo_control.trigger_epoch_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + mev_protection=False, + ) + + # Asserts + mocked_trigger_epoch.assert_awaited_once_with(netuid=netuid) + mocked_sign_and_send_extrinsic.assert_awaited_once_with( + call=mocked_trigger_epoch.return_value, + wallet=fake_wallet, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert success is True + assert "Success" in message + + +@pytest.mark.asyncio +async def test_trigger_epoch_extrinsic_mev_protection(subtensor, mocker, fake_wallet): + """Verify that async trigger_epoch_extrinsic uses submit_encrypted_extrinsic when mev_protection=True.""" + # Preps + netuid = 1 + mocked_trigger_epoch = mocker.patch.object( + tempo_control.SubtensorModule, + "trigger_epoch", + new=mocker.AsyncMock(), + ) + mock_submit = mocker.patch( + "bittensor.core.extrinsics.asyncex.tempo_control.submit_encrypted_extrinsic", + new_callable=mocker.AsyncMock, + return_value=ExtrinsicResponse(True, "Success"), + ) + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, "sign_and_send_extrinsic" + ) + + # Call + success, message = await tempo_control.trigger_epoch_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + mev_protection=True, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + + # Asserts + assert success is True + assert "Success" in message + mock_submit.assert_awaited_once_with( + subtensor=subtensor, + wallet=fake_wallet, + call=mocked_trigger_epoch.return_value, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + mocked_sign_and_send_extrinsic.assert_not_called() + + +@pytest.mark.asyncio +async def test_root_set_activity_cutoff_factor_extrinsic( + subtensor, mocker, fake_wallet +): + """Verify async root_set_activity_cutoff_factor_extrinsic extrinsic.""" + # Preps + netuid = 1 + factor_milli = 5000 + mocked_set_factor = mocker.patch.object( + tempo_control.SubtensorModule, + "set_activity_cutoff_factor", + new=mocker.AsyncMock(), + ) + mocked_sudo = mocker.patch.object( + tempo_control.Sudo, "sudo", new=mocker.AsyncMock() + ) + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + success, message = await tempo_control.root_set_activity_cutoff_factor_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + factor_milli=factor_milli, + mev_protection=False, + ) + + # Asserts + mocked_set_factor.assert_awaited_once_with(netuid=netuid, factor_milli=factor_milli) + mocked_sudo.assert_awaited_once_with(call=mocked_set_factor.return_value) + mocked_sign_and_send_extrinsic.assert_awaited_once_with( + call=mocked_sudo.return_value, + wallet=fake_wallet, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert success is True + assert "Success" in message + + +@pytest.mark.asyncio +async def test_root_set_activity_cutoff_factor_extrinsic_mev_protection( + subtensor, mocker, fake_wallet +): + """Verify that async root_set_activity_cutoff_factor_extrinsic uses submit_encrypted_extrinsic when mev_protection=True.""" + # Preps + netuid = 1 + factor_milli = 5000 + mocked_set_factor = mocker.patch.object( + tempo_control.SubtensorModule, + "set_activity_cutoff_factor", + new=mocker.AsyncMock(), + ) + mocked_sudo = mocker.patch.object( + tempo_control.Sudo, "sudo", new=mocker.AsyncMock() + ) + mock_submit = mocker.patch( + "bittensor.core.extrinsics.asyncex.tempo_control.submit_encrypted_extrinsic", + new_callable=mocker.AsyncMock, + return_value=ExtrinsicResponse(True, "Success"), + ) + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, "sign_and_send_extrinsic" + ) + + # Call + success, message = await tempo_control.root_set_activity_cutoff_factor_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + factor_milli=factor_milli, + mev_protection=True, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + + # Asserts + assert success is True + assert "Success" in message + mocked_set_factor.assert_awaited_once_with(netuid=netuid, factor_milli=factor_milli) + mocked_sudo.assert_awaited_once_with(call=mocked_set_factor.return_value) + mock_submit.assert_awaited_once_with( + subtensor=subtensor, + wallet=fake_wallet, + call=mocked_sudo.return_value, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + mocked_sign_and_send_extrinsic.assert_not_called() diff --git a/tests/unit_tests/extrinsics/asyncex/test_weights.py b/tests/unit_tests/extrinsics/asyncex/test_weights.py index c365bf4ed5..18820a82c7 100644 --- a/tests/unit_tests/extrinsics/asyncex/test_weights.py +++ b/tests/unit_tests/extrinsics/asyncex/test_weights.py @@ -94,12 +94,13 @@ async def test_commit_timelocked_weights_extrinsic(mocker, subtensor, fake_walle mocked_get_subnet_hyperparameters = mocker.patch.object( subtensor, "get_subnet_hyperparameters" ) - mocked_get_mechanism_storage_index = mocker.patch.object( - weights_module, "get_mechid_storage_index" + fake_schedule = mocker.Mock() + mocker.patch.object( + subtensor, "get_epoch_schedule_state", return_value=fake_schedule ) - mocked_get_encrypted_commit = mocker.patch.object( + mocked_get_encrypted_commit_v2 = mocker.patch.object( weights_module, - "get_encrypted_commit", + "get_encrypted_commit_v2", return_value=(mocker.Mock(), mocker.Mock()), ) mocked_compose_call = mocker.patch.object(subtensor, "compose_call") @@ -108,7 +109,7 @@ async def test_commit_timelocked_weights_extrinsic(mocker, subtensor, fake_walle "sign_and_send_extrinsic", return_value=ExtrinsicResponse( True, - f"reveal_round:{mocked_get_encrypted_commit.return_value[1]}", + f"reveal_round:{mocked_get_encrypted_commit_v2.return_value[1]}", ), ) @@ -125,17 +126,17 @@ async def test_commit_timelocked_weights_extrinsic(mocker, subtensor, fake_walle # Asserts mocked_convert_and_normalize_weights_and_uids.assert_called_once_with(uids, weights) - mocked_get_mechanism_storage_index.assert_called_once_with( - netuid=netuid, mechid=mechid - ) - mocked_get_encrypted_commit.assert_called_once_with( - uids=uids, - weights=weights, - subnet_reveal_period_epochs=mocked_get_subnet_hyperparameters.return_value.commit_reveal_period, + mocked_get_encrypted_commit_v2.assert_called_once_with( + uids=list(uids), + weights=list(weights), version_key=weights_module.version_as_int, - tempo=mocked_get_subnet_hyperparameters.return_value.tempo, - netuid=mocked_get_mechanism_storage_index.return_value, - current_block=mocked_get_current_block.return_value, + last_epoch_block=fake_schedule.last_epoch_block, + pending_epoch_at=fake_schedule.pending_epoch_at, + subnet_epoch_index=fake_schedule.subnet_epoch_index, + tempo=fake_schedule.tempo, + blocks_since_last_step=fake_schedule.blocks_since_last_step, + current_block=fake_schedule.current_block, + subnet_reveal_period_epochs=mocked_get_subnet_hyperparameters.return_value.commit_reveal_period, block_time=block_time, hotkey=fake_wallet.hotkey.public_key, ) @@ -145,8 +146,8 @@ async def test_commit_timelocked_weights_extrinsic(mocker, subtensor, fake_walle call_params={ "netuid": netuid, "mecid": mechid, - "commit": mocked_get_encrypted_commit.return_value[0], - "reveal_round": mocked_get_encrypted_commit.return_value[1], + "commit": mocked_get_encrypted_commit_v2.return_value[0], + "reveal_round": mocked_get_encrypted_commit_v2.return_value[1], "commit_reveal_version": 4, }, ) diff --git a/tests/unit_tests/extrinsics/test_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/extrinsics/test_tempo_control.py b/tests/unit_tests/extrinsics/test_tempo_control.py new file mode 100644 index 0000000000..838a13b963 --- /dev/null +++ b/tests/unit_tests/extrinsics/test_tempo_control.py @@ -0,0 +1,327 @@ +from bittensor.core.extrinsics import tempo_control +from bittensor.core.types import ExtrinsicResponse + + +def test_set_tempo_extrinsic(subtensor, mocker, fake_wallet): + """Verify that set_tempo_extrinsic composes correct call and submits it.""" + # Preps + netuid = 1 + tempo = 500 + mocked_set_tempo = mocker.patch.object(tempo_control.SubtensorModule, "set_tempo") + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + success, message = tempo_control.set_tempo_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + tempo=tempo, + mev_protection=False, + ) + + # Asserts + mocked_set_tempo.assert_called_once_with(netuid=netuid, tempo=tempo) + mocked_sign_and_send_extrinsic.assert_called_once_with( + call=mocked_set_tempo.return_value, + wallet=fake_wallet, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert success is True + assert "Success" in message + + +def test_set_tempo_extrinsic_mev_protection(subtensor, mocker, fake_wallet): + """Verify that set_tempo_extrinsic uses submit_encrypted_extrinsic when mev_protection=True.""" + # Preps + netuid = 1 + tempo = 500 + mocked_set_tempo = mocker.patch.object(tempo_control.SubtensorModule, "set_tempo") + mock_submit = mocker.patch( + "bittensor.core.extrinsics.tempo_control.submit_encrypted_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, "sign_and_send_extrinsic" + ) + + # Call + success, message = tempo_control.set_tempo_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + tempo=tempo, + mev_protection=True, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + + # Asserts + assert success is True + assert "Success" in message + mock_submit.assert_called_once_with( + subtensor=subtensor, + wallet=fake_wallet, + call=mocked_set_tempo.return_value, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + mocked_sign_and_send_extrinsic.assert_not_called() + + +def test_set_activity_cutoff_factor_extrinsic(subtensor, mocker, fake_wallet): + """Verify that set_activity_cutoff_factor_extrinsic composes correct call and submits it.""" + # Preps + netuid = 1 + factor_milli = 5000 + mocked_set_factor = mocker.patch.object( + tempo_control.SubtensorModule, "set_activity_cutoff_factor" + ) + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + success, message = tempo_control.set_activity_cutoff_factor_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + factor_milli=factor_milli, + mev_protection=False, + ) + + # Asserts + mocked_set_factor.assert_called_once_with(netuid=netuid, factor_milli=factor_milli) + mocked_sign_and_send_extrinsic.assert_called_once_with( + call=mocked_set_factor.return_value, + wallet=fake_wallet, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert success is True + assert "Success" in message + + +def test_set_activity_cutoff_factor_extrinsic_mev_protection( + subtensor, mocker, fake_wallet +): + """Verify that set_activity_cutoff_factor_extrinsic uses submit_encrypted_extrinsic when mev_protection=True.""" + # Preps + netuid = 1 + factor_milli = 5000 + mocked_set_factor = mocker.patch.object( + tempo_control.SubtensorModule, "set_activity_cutoff_factor" + ) + mock_submit = mocker.patch( + "bittensor.core.extrinsics.tempo_control.submit_encrypted_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, "sign_and_send_extrinsic" + ) + + # Call + success, message = tempo_control.set_activity_cutoff_factor_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + factor_milli=factor_milli, + mev_protection=True, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + + # Asserts + assert success is True + assert "Success" in message + mock_submit.assert_called_once_with( + subtensor=subtensor, + wallet=fake_wallet, + call=mocked_set_factor.return_value, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + mocked_sign_and_send_extrinsic.assert_not_called() + + +def test_trigger_epoch_extrinsic(subtensor, mocker, fake_wallet): + """Verify that trigger_epoch_extrinsic composes correct call and submits it.""" + # Preps + netuid = 1 + mocked_trigger_epoch = mocker.patch.object( + tempo_control.SubtensorModule, "trigger_epoch" + ) + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + success, message = tempo_control.trigger_epoch_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + mev_protection=False, + ) + + # Asserts + mocked_trigger_epoch.assert_called_once_with(netuid=netuid) + mocked_sign_and_send_extrinsic.assert_called_once_with( + call=mocked_trigger_epoch.return_value, + wallet=fake_wallet, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert success is True + assert "Success" in message + + +def test_trigger_epoch_extrinsic_mev_protection(subtensor, mocker, fake_wallet): + """Verify that trigger_epoch_extrinsic uses submit_encrypted_extrinsic when mev_protection=True.""" + # Preps + netuid = 1 + mocked_trigger_epoch = mocker.patch.object( + tempo_control.SubtensorModule, "trigger_epoch" + ) + mock_submit = mocker.patch( + "bittensor.core.extrinsics.tempo_control.submit_encrypted_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, "sign_and_send_extrinsic" + ) + + # Call + success, message = tempo_control.trigger_epoch_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + mev_protection=True, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + + # Asserts + assert success is True + assert "Success" in message + mock_submit.assert_called_once_with( + subtensor=subtensor, + wallet=fake_wallet, + call=mocked_trigger_epoch.return_value, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + mocked_sign_and_send_extrinsic.assert_not_called() + + +def test_root_set_activity_cutoff_factor_extrinsic(subtensor, mocker, fake_wallet): + """Verify root_set_activity_cutoff_factor_extrinsic extrinsic.""" + # Preps + netuid = 1 + factor_milli = 5000 + mocked_set_factor = mocker.patch.object( + tempo_control.SubtensorModule, "set_activity_cutoff_factor" + ) + mocked_sudo = mocker.patch.object(tempo_control.Sudo, "sudo") + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, + "sign_and_send_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + + # Call + success, message = tempo_control.root_set_activity_cutoff_factor_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + factor_milli=factor_milli, + mev_protection=False, + ) + + # Asserts + mocked_set_factor.assert_called_once_with(netuid=netuid, factor_milli=factor_milli) + mocked_sudo.assert_called_once_with(call=mocked_set_factor.return_value) + mocked_sign_and_send_extrinsic.assert_called_once_with( + call=mocked_sudo.return_value, + wallet=fake_wallet, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + assert success is True + assert "Success" in message + + +def test_root_set_activity_cutoff_factor_extrinsic_mev_protection( + subtensor, mocker, fake_wallet +): + """Verify that root_set_activity_cutoff_factor_extrinsic uses submit_encrypted_extrinsic when mev_protection=True.""" + # Preps + netuid = 1 + factor_milli = 5000 + mocked_set_factor = mocker.patch.object( + tempo_control.SubtensorModule, "set_activity_cutoff_factor" + ) + mocked_sudo = mocker.patch.object(tempo_control.Sudo, "sudo") + mock_submit = mocker.patch( + "bittensor.core.extrinsics.tempo_control.submit_encrypted_extrinsic", + return_value=ExtrinsicResponse(True, "Success"), + ) + mocked_sign_and_send_extrinsic = mocker.patch.object( + subtensor, "sign_and_send_extrinsic" + ) + + # Call + success, message = tempo_control.root_set_activity_cutoff_factor_extrinsic( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + factor_milli=factor_milli, + mev_protection=True, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + + # Asserts + assert success is True + assert "Success" in message + mocked_set_factor.assert_called_once_with(netuid=netuid, factor_milli=factor_milli) + mocked_sudo.assert_called_once_with(call=mocked_set_factor.return_value) + mock_submit.assert_called_once_with( + subtensor=subtensor, + wallet=fake_wallet, + call=mocked_sudo.return_value, + period=None, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + mocked_sign_and_send_extrinsic.assert_not_called() diff --git a/tests/unit_tests/extrinsics/test_weights.py b/tests/unit_tests/extrinsics/test_weights.py index 99c0b233b3..31cfa16911 100644 --- a/tests/unit_tests/extrinsics/test_weights.py +++ b/tests/unit_tests/extrinsics/test_weights.py @@ -22,12 +22,13 @@ def test_commit_timelocked_weights_extrinsic(mocker, subtensor, fake_wallet): mocked_get_subnet_hyperparameters = mocker.patch.object( subtensor, "get_subnet_hyperparameters" ) - mocked_get_sub_subnet_storage_index = mocker.patch.object( - weights_module, "get_mechid_storage_index" + fake_schedule = mocker.Mock() + mocker.patch.object( + subtensor, "get_epoch_schedule_state", return_value=fake_schedule ) - mocked_get_encrypted_commit = mocker.patch.object( + mocked_get_encrypted_commit_v2 = mocker.patch.object( weights_module, - "get_encrypted_commit", + "get_encrypted_commit_v2", return_value=(mocker.Mock(), mocker.Mock()), ) mocked_compose_call = mocker.patch.object(subtensor, "compose_call") @@ -36,7 +37,7 @@ def test_commit_timelocked_weights_extrinsic(mocker, subtensor, fake_wallet): "sign_and_send_extrinsic", return_value=ExtrinsicResponse( True, - f"reveal_round:{mocked_get_encrypted_commit.return_value[1]}", + f"reveal_round:{mocked_get_encrypted_commit_v2.return_value[1]}", ), ) @@ -53,17 +54,17 @@ def test_commit_timelocked_weights_extrinsic(mocker, subtensor, fake_wallet): # Asserts mocked_convert_and_normalize_weights_and_uids.assert_called_once_with(uids, weights) - mocked_get_sub_subnet_storage_index.assert_called_once_with( - netuid=netuid, mechid=mechid - ) - mocked_get_encrypted_commit.assert_called_once_with( - uids=uids, - weights=weights, - subnet_reveal_period_epochs=mocked_get_subnet_hyperparameters.return_value.commit_reveal_period, + mocked_get_encrypted_commit_v2.assert_called_once_with( + uids=list(uids), + weights=list(weights), version_key=weights_module.version_as_int, - tempo=mocked_get_subnet_hyperparameters.return_value.tempo, - netuid=mocked_get_sub_subnet_storage_index.return_value, - current_block=mocked_get_current_block.return_value, + last_epoch_block=fake_schedule.last_epoch_block, + pending_epoch_at=fake_schedule.pending_epoch_at, + subnet_epoch_index=fake_schedule.subnet_epoch_index, + tempo=fake_schedule.tempo, + blocks_since_last_step=fake_schedule.blocks_since_last_step, + current_block=fake_schedule.current_block, + subnet_reveal_period_epochs=mocked_get_subnet_hyperparameters.return_value.commit_reveal_period, block_time=block_time, hotkey=fake_wallet.hotkey.public_key, ) @@ -73,8 +74,8 @@ def test_commit_timelocked_weights_extrinsic(mocker, subtensor, fake_wallet): call_params={ "netuid": netuid, "mecid": mechid, - "commit": mocked_get_encrypted_commit.return_value[0], - "reveal_round": mocked_get_encrypted_commit.return_value[1], + "commit": mocked_get_encrypted_commit_v2.return_value[0], + "reveal_round": mocked_get_encrypted_commit_v2.return_value[1], "commit_reveal_version": 4, }, ) diff --git a/tests/unit_tests/test_async_subtensor.py b/tests/unit_tests/test_async_subtensor.py index 9a6e6d867d..94029b20ab 100644 --- a/tests/unit_tests/test_async_subtensor.py +++ b/tests/unit_tests/test_async_subtensor.py @@ -915,6 +915,183 @@ async def test_get_existential_deposit_raise_exception(subtensor, mocker): spy_balance_from_rao.assert_not_called() +@pytest.mark.asyncio +async def test_get_max_activity_cutoff_factor_milli(subtensor, mocker): + """Tests get_max_activity_cutoff_factor_milli method.""" + # Preps + fake_block_hash = "block_hash" + mocked_substrate_get_constant = mocker.AsyncMock( + return_value=mocker.Mock(value=50_000) + ) + subtensor.substrate.get_constant = mocked_substrate_get_constant + + # Call + result = await subtensor.get_max_activity_cutoff_factor_milli( + block_hash=fake_block_hash + ) + + # Asserts + mocked_substrate_get_constant.assert_awaited_once() + mocked_substrate_get_constant.assert_called_once_with( + module_name="SubtensorModule", + constant_name="MaxActivityCutoffFactorMilli", + block_hash=fake_block_hash, + ) + assert result == 50_000 + + +@pytest.mark.asyncio +async def test_get_max_activity_cutoff_factor_milli_none(subtensor, mocker): + """Tests get_max_activity_cutoff_factor_milli raises when constant is missing.""" + # Preps + mocked_substrate_get_constant = mocker.AsyncMock(return_value=None) + subtensor.substrate.get_constant = mocked_substrate_get_constant + + # Call + with pytest.raises(Exception): + await subtensor.get_max_activity_cutoff_factor_milli(block_hash="block_hash") + + +@pytest.mark.asyncio +async def test_get_max_epochs_per_block(subtensor, mocker): + """Tests get_max_epochs_per_block method.""" + # Preps + fake_block_hash = "block_hash" + mocked_substrate_get_constant = mocker.AsyncMock(return_value=mocker.Mock(value=32)) + subtensor.substrate.get_constant = mocked_substrate_get_constant + + # Call + result = await subtensor.get_max_epochs_per_block(block_hash=fake_block_hash) + + # Asserts + mocked_substrate_get_constant.assert_awaited_once() + mocked_substrate_get_constant.assert_called_once_with( + module_name="SubtensorModule", + constant_name="MaxEpochsPerBlock", + block_hash=fake_block_hash, + ) + assert result == 32 + + +@pytest.mark.asyncio +async def test_get_max_epochs_per_block_none(subtensor, mocker): + """Tests get_max_epochs_per_block raises when constant is missing.""" + # Preps + mocked_substrate_get_constant = mocker.AsyncMock(return_value=None) + subtensor.substrate.get_constant = mocked_substrate_get_constant + + # Call + with pytest.raises(Exception): + await subtensor.get_max_epochs_per_block(block_hash="block_hash") + + +@pytest.mark.asyncio +async def test_get_max_tempo(subtensor, mocker): + """Tests get_max_tempo method.""" + # Preps + fake_block_hash = "block_hash" + mocked_substrate_get_constant = mocker.AsyncMock( + return_value=mocker.Mock(value=50_400) + ) + subtensor.substrate.get_constant = mocked_substrate_get_constant + + # Call + result = await subtensor.get_max_tempo(block_hash=fake_block_hash) + + # Asserts + mocked_substrate_get_constant.assert_awaited_once() + mocked_substrate_get_constant.assert_called_once_with( + module_name="SubtensorModule", + constant_name="MaxTempo", + block_hash=fake_block_hash, + ) + assert result == 50_400 + + +@pytest.mark.asyncio +async def test_get_max_tempo_none(subtensor, mocker): + """Tests get_max_tempo raises when constant is missing.""" + # Preps + mocked_substrate_get_constant = mocker.AsyncMock(return_value=None) + subtensor.substrate.get_constant = mocked_substrate_get_constant + + # Call + with pytest.raises(Exception): + await subtensor.get_max_tempo(block_hash="block_hash") + + +@pytest.mark.asyncio +async def test_get_min_activity_cutoff_factor_milli(subtensor, mocker): + """Tests get_min_activity_cutoff_factor_milli method.""" + # Preps + fake_block_hash = "block_hash" + mocked_substrate_get_constant = mocker.AsyncMock( + return_value=mocker.Mock(value=1_000) + ) + subtensor.substrate.get_constant = mocked_substrate_get_constant + + # Call + result = await subtensor.get_min_activity_cutoff_factor_milli( + block_hash=fake_block_hash + ) + + # Asserts + mocked_substrate_get_constant.assert_awaited_once() + mocked_substrate_get_constant.assert_called_once_with( + module_name="SubtensorModule", + constant_name="MinActivityCutoffFactorMilli", + block_hash=fake_block_hash, + ) + assert result == 1_000 + + +@pytest.mark.asyncio +async def test_get_min_activity_cutoff_factor_milli_none(subtensor, mocker): + """Tests get_min_activity_cutoff_factor_milli raises when constant is missing.""" + # Preps + mocked_substrate_get_constant = mocker.AsyncMock(return_value=None) + subtensor.substrate.get_constant = mocked_substrate_get_constant + + # Call + with pytest.raises(Exception): + await subtensor.get_min_activity_cutoff_factor_milli(block_hash="block_hash") + + +@pytest.mark.asyncio +async def test_get_min_tempo(subtensor, mocker): + """Tests get_min_tempo method.""" + # Preps + fake_block_hash = "block_hash" + mocked_substrate_get_constant = mocker.AsyncMock( + return_value=mocker.Mock(value=360) + ) + subtensor.substrate.get_constant = mocked_substrate_get_constant + + # Call + result = await subtensor.get_min_tempo(block_hash=fake_block_hash) + + # Asserts + mocked_substrate_get_constant.assert_awaited_once() + mocked_substrate_get_constant.assert_called_once_with( + module_name="SubtensorModule", + constant_name="MinTempo", + block_hash=fake_block_hash, + ) + assert result == 360 + + +@pytest.mark.asyncio +async def test_get_min_tempo_none(subtensor, mocker): + """Tests get_min_tempo raises when constant is missing.""" + # Preps + mocked_substrate_get_constant = mocker.AsyncMock(return_value=None) + subtensor.substrate.get_constant = mocked_substrate_get_constant + + # Call + with pytest.raises(Exception): + await subtensor.get_min_tempo(block_hash="block_hash") + + @pytest.mark.asyncio async def test_neurons(subtensor, mocker): """Tests neurons method.""" @@ -1989,92 +2166,49 @@ async def test_get_children_pending(mock_substrate, subtensor): @pytest.mark.asyncio async def test_get_subnet_hyperparameters_success(subtensor, mocker): - """Tests get_subnet_hyperparameters with successful hyperparameter retrieval.""" - # Preps + """Tests get_subnet_hyperparameters uses runtime fallback and from_any.""" fake_netuid = 1 fake_block_hash = "block_hash" - fake_result = object() - - mocked_query_runtime_api = mocker.AsyncMock(return_value=fake_result) - subtensor.query_runtime_api = mocked_query_runtime_api + fake_v3_entries = [ + {"name": "tempo", "value": {"U16": 360}}, + {"name": "kappa", "value": {"U16": 32767}}, + ] - mocked_from_dict = mocker.Mock() + mocker.patch.object(subtensor, "determine_block_hash", return_value=fake_block_hash) mocker.patch.object( - async_subtensor.SubnetHyperparameters, "from_dict", mocked_from_dict + subtensor, "_runtime_call_with_fallback", return_value=fake_v3_entries ) + mocker.patch.object(async_subtensor.SubnetHyperparameters, "from_any") - # Call - result = await subtensor.get_subnet_hyperparameters( + await subtensor.get_subnet_hyperparameters( netuid=fake_netuid, block_hash=fake_block_hash ) - # Asserts - mocked_query_runtime_api.assert_called_once_with( - runtime_api="SubnetInfoRuntimeApi", - method="get_subnet_hyperparams_v2", - params=[fake_netuid], - block=None, + subtensor._runtime_call_with_fallback.assert_awaited_once_with( + ("SubnetInfoRuntimeApi", "get_subnet_hyperparams_v3", [fake_netuid]), + ("SubnetInfoRuntimeApi", "get_subnet_hyperparams_v2", [fake_netuid]), + ("SubnetInfoRuntimeApi", "get_subnet_hyperparams", [fake_netuid]), block_hash=fake_block_hash, - reuse_block=False, + default_value=None, + ) + async_subtensor.SubnetHyperparameters.from_any.assert_called_once_with( + fake_v3_entries ) - assert result == mocked_from_dict.return_value @pytest.mark.asyncio async def test_get_subnet_hyperparameters_no_data(subtensor, mocker): - """Tests get_subnet_hyperparameters when no hyperparameters data is returned.""" - # Preps + """Tests get_subnet_hyperparameters returns None when runtime call is empty.""" fake_netuid = 1 - mocked_query_runtime_api = mocker.AsyncMock(return_value=None) - subtensor.query_runtime_api = mocked_query_runtime_api + mocker.patch.object(subtensor, "determine_block_hash", return_value=None) + mocker.patch.object(subtensor, "_runtime_call_with_fallback", return_value=None) - # Call result = await subtensor.get_subnet_hyperparameters(netuid=fake_netuid) - # Asserts - mocked_query_runtime_api.assert_called_once_with( - runtime_api="SubnetInfoRuntimeApi", - method="get_subnet_hyperparams_v2", - params=[fake_netuid], - block=None, - block_hash=None, - reuse_block=False, - ) assert result is None -@pytest.mark.asyncio -async def test_get_subnet_hyperparameters_without_0x_prefix(subtensor, mocker): - """Tests get_subnet_hyperparameters when hex_bytes_result is without 0x prefix.""" - # Preps - fake_netuid = 1 - fake_result = object() - - mocked_query_runtime_api = mocker.AsyncMock(return_value=fake_result) - subtensor.query_runtime_api = mocked_query_runtime_api - - mocked_from_dict = mocker.Mock() - mocker.patch.object( - async_subtensor.SubnetHyperparameters, "from_dict", mocked_from_dict - ) - - # Call - result = await subtensor.get_subnet_hyperparameters(netuid=fake_netuid) - - # Asserts - mocked_query_runtime_api.assert_called_once_with( - runtime_api="SubnetInfoRuntimeApi", - method="get_subnet_hyperparams_v2", - params=[fake_netuid], - block=None, - block_hash=None, - reuse_block=False, - ) - mocked_from_dict.assert_called_once_with(fake_result) - assert result == mocked_from_dict.return_value - - @pytest.mark.asyncio async def test_get_vote_data_success(subtensor, mocker): """Tests get_vote_data when voting data is successfully retrieved.""" @@ -3304,35 +3438,32 @@ async def test_get_subnet_info_no_data(mocker, subtensor): @pytest.mark.asyncio async def test_get_next_epoch_start_block(mocker, subtensor): - """Check that get_next_epoch_start_block returns the correct value.""" - # Prep + """Check that get_next_epoch_start_block delegates to runtime API.""" netuid = 14 block = 20 - fake_block_hash = mocker.MagicMock() - mocker.patch.object(subtensor, "get_block_hash", return_value=fake_block_hash) - mocked_tempo = mocker.patch.object(subtensor, "tempo", return_value=100) - mocked_get_block_number = mocker.patch.object( - subtensor.substrate, "get_block_number" - ) + mocker.patch.object(subtensor, "query_runtime_api", return_value=150) - # Call result = await subtensor.get_next_epoch_start_block(netuid=netuid, block=block) - # Asserts - mocked_tempo.assert_awaited_once_with( - netuid=netuid, - block_hash=fake_block_hash, - ) - assert ( - result - == mocked_get_block_number.return_value.__add__() - .__mod__() - .__mod__() - .__rsub__() - .__radd__() - .__add__() + subtensor.query_runtime_api.assert_awaited_once_with( + runtime_api="SubnetInfoRuntimeApi", + method="get_next_epoch_start_block", + params=[netuid], + block=block, + block_hash=None, + reuse_block=False, ) + assert result == 150 + + +@pytest.mark.asyncio +async def test_get_next_epoch_start_block_none(mocker, subtensor): + """Check that get_next_epoch_start_block returns None when API returns None.""" + mocker.patch.object(subtensor, "query_runtime_api", return_value=None) + + result = await subtensor.get_next_epoch_start_block(netuid=1, block=20) + assert result is None @pytest.mark.asyncio @@ -3367,243 +3498,56 @@ async def test_unstake_all(subtensor, fake_wallet, mocker): @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) +async def test_add_liquidity(subtensor, fake_wallet, mocker): + """Test add_liquidity extrinsic calls properly.""" + # preps + netuid = 123 + mocked_extrinsic = mocker.patch.object(async_subtensor, "add_liquidity_extrinsic") # Call - result = await subtensor.get_liquidity_list(wallet=mocker.Mock(), netuid=1) + result = await subtensor.add_liquidity( + wallet=fake_wallet, + netuid=netuid, + liquidity=Balance.from_tao(150), + price_low=Balance.from_tao(180), + price_high=Balance.from_tao(130), + ) # Asserts - subtensor.subnet_exists.assert_awaited_once_with(netuid=1) - assert result is None + mocked_extrinsic.assert_awaited_once_with( + subtensor=subtensor, + wallet=fake_wallet, + netuid=netuid, + liquidity=Balance.from_tao(150), + price_low=Balance.from_tao(180).rao, + price_high=Balance.from_tao(130).rao, + hotkey_ss58=None, + 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_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) +async def test_modify_liquidity(subtensor, fake_wallet, mocker): + """Test modify_liquidity extrinsic calls properly.""" + # preps + netuid = 123 + mocked_extrinsic = mocker.patch.object( + async_subtensor, "modify_liquidity_extrinsic" + ) + position_id = 2 # 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.""" - # preps - netuid = 123 - mocked_extrinsic = mocker.patch.object(async_subtensor, "add_liquidity_extrinsic") - - # Call - result = await subtensor.add_liquidity( - wallet=fake_wallet, - netuid=netuid, - liquidity=Balance.from_tao(150), - price_low=Balance.from_tao(180), - price_high=Balance.from_tao(130), - ) - - # Asserts - mocked_extrinsic.assert_awaited_once_with( - subtensor=subtensor, - wallet=fake_wallet, - netuid=netuid, - liquidity=Balance.from_tao(150), - price_low=Balance.from_tao(180).rao, - price_high=Balance.from_tao(130).rao, - hotkey_ss58=None, - 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_modify_liquidity(subtensor, fake_wallet, mocker): - """Test modify_liquidity extrinsic calls properly.""" - # preps - netuid = 123 - mocked_extrinsic = mocker.patch.object( - async_subtensor, "modify_liquidity_extrinsic" - ) - position_id = 2 - - # Call - result = await subtensor.modify_liquidity( - wallet=fake_wallet, - netuid=netuid, - position_id=position_id, - liquidity_delta=Balance.from_tao(150), + result = await subtensor.modify_liquidity( + wallet=fake_wallet, + netuid=netuid, + position_id=position_id, + liquidity_delta=Balance.from_tao(150), ) # Asserts @@ -3722,36 +3666,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.""" @@ -3854,6 +3832,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.""" @@ -4281,45 +4312,45 @@ async def test_get_mechanism_count(subtensor, mocker): @pytest.mark.asyncio async def test_is_in_admin_freeze_window_root_net(subtensor, mocker): """Verify that root net has no admin freeze window.""" - # Preps netuid = 0 - mocked_get_next_epoch_start_block = mocker.patch.object( - subtensor, "get_next_epoch_start_block" - ) + mocked_tempo = mocker.patch.object(subtensor, "tempo") - # Call result = await subtensor.is_in_admin_freeze_window(netuid=netuid) - # Asserts - mocked_get_next_epoch_start_block.assert_not_called() + mocked_tempo.assert_not_called() assert result is False @pytest.mark.parametrize( - "block, next_esb, expected_result", - ( - [89, 100, False], - [90, 100, False], - [91, 100, True], - ), + "block_number, pending, last, tempo, window, expected", + [ + (91, 200, 80, 20, 10, True), + (91, 0, 80, 20, 10, True), + (90, 0, 80, 20, 10, False), + (89, 0, 80, 20, 10, False), + ], ) @pytest.mark.asyncio async def test_is_in_admin_freeze_window( - subtensor, mocker, block, next_esb, expected_result + subtensor, mocker, block_number, pending, last, tempo, window, expected ): - """Verify that `is_in_admin_freeze_window` method processed the data correctly.""" - # Preps + """Verify is_in_admin_freeze_window matches chain logic with storage getters.""" netuid = 14 - mocker.patch.object(subtensor, "get_current_block", return_value=block) - mocker.patch.object(subtensor, "get_next_epoch_start_block", return_value=next_esb) - mocker.patch.object(subtensor, "get_admin_freeze_window", return_value=10) - - # Call + fake_block_hash = mocker.MagicMock() + mocker.patch.object(subtensor, "determine_block_hash", return_value=fake_block_hash) + mocker.patch.object( + subtensor.substrate, "get_block_number", return_value=block_number + ) + mocker.patch.object(subtensor, "tempo", return_value=tempo) + mocker.patch.object(subtensor, "get_pending_epoch_at", return_value=pending) + mocker.patch.object(subtensor, "get_last_epoch_block", return_value=last) + mocker.patch.object(subtensor, "get_admin_freeze_window", return_value=window) - result = await subtensor.is_in_admin_freeze_window(netuid=netuid) + result = await subtensor.is_in_admin_freeze_window( + netuid=netuid, block=block_number + ) - # Asserts - assert result is expected_result + assert result is expected @pytest.mark.asyncio @@ -4410,34 +4441,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 + + # block only + assert await subtensor.determine_block_hash(block=1) == mocked_hash - expected_hash_2 = await subtensor.determine_block_hash( - block=1, block_hash=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) @@ -4885,6 +4940,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", [ @@ -5019,7 +5172,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( @@ -5478,6 +5631,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.""" @@ -5866,28 +6064,31 @@ async def test_remove_proxy(mocker, subtensor): @pytest.mark.asyncio -async def test_blocks_until_next_epoch_uses_default_tempo(subtensor, mocker): - """Test blocks_until_next_epoch uses self.tempo when tempo is None.""" - # Prep - netuid = 0 +async def test_blocks_until_next_epoch_runtime_api(subtensor, mocker): + """Test blocks_until_next_epoch derives from get_next_epoch_start_block runtime API.""" + netuid = 1 block = 20 - tempo = 100 - mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") - spy_get_current_block = mocker.spy(subtensor, "get_current_block") - spy_tempo = mocker.spy(subtensor, "tempo") + fake_block_hash = mocker.MagicMock() + mocker.patch.object(subtensor, "determine_block_hash", return_value=fake_block_hash) + mocker.patch.object(subtensor.substrate, "get_block_number", return_value=block) + mocker.patch.object(subtensor, "get_next_epoch_start_block", return_value=150) - # Call - result = await subtensor.blocks_until_next_epoch( - netuid=netuid, tempo=tempo, block=block - ) + result = await subtensor.blocks_until_next_epoch(netuid=netuid, block=block) - # Assert - mocked_determine_block_hash.assert_awaited_once_with(block, None, False) - spy_get_current_block.assert_not_awaited() - spy_tempo.assert_not_awaited() - assert result is not None - assert isinstance(result, int) + assert result == 130 + + +@pytest.mark.asyncio +async def test_blocks_until_next_epoch_none_when_no_tempo(subtensor, mocker): + """Test blocks_until_next_epoch returns None when runtime API returns None.""" + fake_block_hash = mocker.MagicMock() + mocker.patch.object(subtensor, "determine_block_hash", return_value=fake_block_hash) + mocker.patch.object(subtensor.substrate, "get_block_number", return_value=20) + mocker.patch.object(subtensor, "get_next_epoch_start_block", return_value=None) + + result = await subtensor.blocks_until_next_epoch(netuid=1, block=20) + assert result is None @pytest.mark.asyncio @@ -6567,3 +6768,270 @@ async def test_dispute_coldkey_swap(mocker, subtensor): wait_for_revealed_execution=True, ) assert response == mocked_dispute_coldkey_swap_extrinsic.return_value + + +@pytest.mark.asyncio +async def test_get_last_epoch_block(subtensor, mocker): + """Verify that `get_last_epoch_block` queries LastEpochBlock storage.""" + # Preps + netuid = 7 + mocked_query = mocker.patch.object(subtensor, "query_subtensor") + + # Call + result = await subtensor.get_last_epoch_block(netuid=netuid) + + # Asserts + mocked_query.assert_awaited_once_with( + name="LastEpochBlock", + block=None, + block_hash=None, + reuse_block=False, + params=[netuid], + ) + assert result == mocked_query.return_value.value + + +@pytest.mark.asyncio +async def test_get_pending_epoch_at(subtensor, mocker): + """Verify that `get_pending_epoch_at` queries PendingEpochAt storage.""" + # Preps + netuid = 7 + mocked_query = mocker.patch.object(subtensor, "query_subtensor") + + # Call + result = await subtensor.get_pending_epoch_at(netuid=netuid) + + # Asserts + mocked_query.assert_awaited_once_with( + name="PendingEpochAt", + block=None, + block_hash=None, + reuse_block=False, + params=[netuid], + ) + assert result == mocked_query.return_value.value + + +@pytest.mark.asyncio +async def test_get_subnet_epoch_index(subtensor, mocker): + """Verify that `get_subnet_epoch_index` queries SubnetEpochIndex storage.""" + # Preps + netuid = 7 + mocked_query = mocker.patch.object(subtensor, "query_subtensor") + + # Call + result = await subtensor.get_subnet_epoch_index(netuid=netuid) + + # Asserts + mocked_query.assert_awaited_once_with( + name="SubnetEpochIndex", + block=None, + block_hash=None, + reuse_block=False, + params=[netuid], + ) + assert result == mocked_query.return_value.value + + +@pytest.mark.asyncio +async def test_get_activity_cutoff_factor_milli(subtensor, mocker): + """Verify that `get_activity_cutoff_factor_milli` queries ActivityCutoffFactorMilli storage.""" + # Preps + netuid = 7 + mocked_query = mocker.patch.object(subtensor, "query_subtensor") + + # Call + result = await subtensor.get_activity_cutoff_factor_milli(netuid=netuid) + + # Asserts + mocked_query.assert_awaited_once_with( + name="ActivityCutoffFactorMilli", + block=None, + block_hash=None, + reuse_block=False, + params=[netuid], + ) + assert result == mocked_query.return_value.value + + +@pytest.mark.asyncio +async def test_get_owner_hyperparam_rate_limit(subtensor, mocker): + """Verify that `get_owner_hyperparam_rate_limit` queries OwnerHyperparamRateLimit storage.""" + # Preps + mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") + mocked_query = mocker.patch.object(subtensor.substrate, "query") + + # Call + result = await subtensor.get_owner_hyperparam_rate_limit() + + # Asserts + mocked_query.assert_awaited_once_with( + module="SubtensorModule", + storage_function="OwnerHyperparamRateLimit", + block_hash=mocked_determine_block_hash.return_value, + ) + assert result == mocked_query.return_value.value + + +@pytest.mark.asyncio +async def test_get_epoch_schedule_state(subtensor, mocker): + """Verify that `get_epoch_schedule_state` gathers sub-queries into EpochScheduleState.""" + # Preps + netuid = 7 + fake_block_hash = mocker.MagicMock() + fake_block_number = 500 + mocker.patch.object(subtensor, "determine_block_hash", return_value=fake_block_hash) + mocker.patch.object( + subtensor.substrate, "get_block_number", return_value=fake_block_number + ) + mocker.patch.object(subtensor, "get_last_epoch_block", return_value=100) + mocker.patch.object(subtensor, "get_pending_epoch_at", return_value=0) + mocker.patch.object(subtensor, "get_subnet_epoch_index", return_value=42) + mocker.patch.object(subtensor, "tempo", return_value=360) + mocker.patch.object(subtensor, "blocks_since_last_step", return_value=50) + + # Call + result = await subtensor.get_epoch_schedule_state(netuid=netuid) + + # Asserts + subtensor.get_last_epoch_block.assert_awaited_once_with( + netuid, block_hash=fake_block_hash + ) + subtensor.get_pending_epoch_at.assert_awaited_once_with( + netuid, block_hash=fake_block_hash + ) + subtensor.get_subnet_epoch_index.assert_awaited_once_with( + netuid, block_hash=fake_block_hash + ) + subtensor.tempo.assert_awaited_once_with(netuid, block_hash=fake_block_hash) + subtensor.blocks_since_last_step.assert_awaited_once_with( + netuid, block_hash=fake_block_hash + ) + assert result.last_epoch_block == 100 + assert result.pending_epoch_at == 0 + assert result.subnet_epoch_index == 42 + assert result.tempo == 360 + assert result.blocks_since_last_step == 50 + assert result.current_block == fake_block_number + + +@pytest.mark.asyncio +async def test_set_tempo(subtensor, mocker): + """Verify that `set_tempo` delegates to set_tempo_extrinsic with correct parameters.""" + # Preps + wallet = mocker.Mock(spec=Wallet) + mocked_extrinsic = mocker.patch.object(async_subtensor, "set_tempo_extrinsic") + + # Call + result = await subtensor.set_tempo( + wallet=wallet, + netuid=7, + tempo=500, + ) + + # Asserts + mocked_extrinsic.assert_awaited_once_with( + subtensor=subtensor, + wallet=wallet, + netuid=7, + tempo=500, + mev_protection=DEFAULT_MEV_PROTECTION, + period=DEFAULT_PERIOD, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + assert result == mocked_extrinsic.return_value + + +@pytest.mark.asyncio +async def test_set_activity_cutoff_factor(subtensor, mocker): + """Verify that `set_activity_cutoff_factor` delegates to set_activity_cutoff_factor_extrinsic.""" + # Preps + wallet = mocker.Mock(spec=Wallet) + mocked_extrinsic = mocker.patch.object( + async_subtensor, "set_activity_cutoff_factor_extrinsic" + ) + + # Call + result = await subtensor.set_activity_cutoff_factor( + wallet=wallet, + netuid=7, + factor_milli=5000, + ) + + # Asserts + mocked_extrinsic.assert_awaited_once_with( + subtensor=subtensor, + wallet=wallet, + netuid=7, + factor_milli=5000, + mev_protection=DEFAULT_MEV_PROTECTION, + period=DEFAULT_PERIOD, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + assert result == mocked_extrinsic.return_value + + +@pytest.mark.asyncio +async def test_root_set_activity_cutoff_factor(subtensor, mocker): + """Verify that `root_set_activity_cutoff_factor` delegates to root_set_activity_cutoff_factor_extrinsic.""" + # Preps + wallet = mocker.Mock(spec=Wallet) + mocked_extrinsic = mocker.patch.object( + async_subtensor, "root_set_activity_cutoff_factor_extrinsic" + ) + + # Call + result = await subtensor.root_set_activity_cutoff_factor( + wallet=wallet, + netuid=7, + factor_milli=5000, + ) + + # Asserts + mocked_extrinsic.assert_awaited_once_with( + subtensor=subtensor, + wallet=wallet, + netuid=7, + factor_milli=5000, + mev_protection=DEFAULT_MEV_PROTECTION, + period=DEFAULT_PERIOD, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + assert result == mocked_extrinsic.return_value + + +@pytest.mark.asyncio +async def test_trigger_epoch(subtensor, mocker): + """Verify that `trigger_epoch` delegates to trigger_epoch_extrinsic.""" + # Preps + wallet = mocker.Mock(spec=Wallet) + mocked_extrinsic = mocker.patch.object(async_subtensor, "trigger_epoch_extrinsic") + + # Call + result = await subtensor.trigger_epoch( + wallet=wallet, + netuid=7, + ) + + # Asserts + mocked_extrinsic.assert_awaited_once_with( + subtensor=subtensor, + wallet=wallet, + netuid=7, + mev_protection=DEFAULT_MEV_PROTECTION, + period=DEFAULT_PERIOD, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + assert result == mocked_extrinsic.return_value diff --git a/tests/unit_tests/test_axon.py b/tests/unit_tests/test_axon.py index d1b8e03a97..0712e50dc1 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,76 @@ 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 +@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 = empty_sig + + 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): diff --git a/tests/unit_tests/test_subtensor.py b/tests/unit_tests/test_subtensor.py index 550a59732d..4407381deb 100644 --- a/tests/unit_tests/test_subtensor.py +++ b/tests/unit_tests/test_subtensor.py @@ -845,55 +845,45 @@ def test_get_subnets_no_block_specified(mocker, subtensor): # `get_subnet_hyperparameters` tests def test_get_subnet_hyperparameters_success(mocker, subtensor): - """Test get_subnet_hyperparameters returns correct data when hyperparameters are found.""" - # Prep + """Test get_subnet_hyperparameters uses runtime fallback and from_any.""" netuid = 1 block = 123 + block_hash = "0xabc" + fake_v3_entries = [ + {"name": "tempo", "value": {"U16": 360}}, + {"name": "kappa", "value": {"U16": 32767}}, + ] + mocker.patch.object(subtensor, "determine_block_hash", return_value=block_hash) mocker.patch.object( - subtensor, - "query_runtime_api", - ) - mocker.patch.object( - subtensor_module.SubnetHyperparameters, - "from_dict", + subtensor, "_runtime_call_with_fallback", return_value=fake_v3_entries ) + mocker.patch.object(subtensor_module.SubnetHyperparameters, "from_any") - # Call subtensor.get_subnet_hyperparameters(netuid, block) - # Asserts - subtensor.query_runtime_api.assert_called_once_with( - runtime_api="SubnetInfoRuntimeApi", - method="get_subnet_hyperparams_v2", - params=[netuid], - block=block, + subtensor._runtime_call_with_fallback.assert_called_once_with( + ("SubnetInfoRuntimeApi", "get_subnet_hyperparams_v3", [netuid]), + ("SubnetInfoRuntimeApi", "get_subnet_hyperparams_v2", [netuid]), + ("SubnetInfoRuntimeApi", "get_subnet_hyperparams", [netuid]), + block_hash=block_hash, + default_value=None, ) - subtensor_module.SubnetHyperparameters.from_dict.assert_called_once_with( - subtensor.query_runtime_api.return_value, + subtensor_module.SubnetHyperparameters.from_any.assert_called_once_with( + fake_v3_entries ) def test_get_subnet_hyperparameters_no_data(mocker, subtensor): - """Test get_subnet_hyperparameters returns empty list when no data is found.""" - # Prep + """Test get_subnet_hyperparameters returns None when runtime call is empty.""" netuid = 1 block = 123 - mocker.patch.object(subtensor, "query_runtime_api", return_value=None) - mocker.patch.object(subtensor_module.SubnetHyperparameters, "from_dict") + mocker.patch.object(subtensor, "determine_block_hash", return_value="0xabc") + mocker.patch.object(subtensor, "_runtime_call_with_fallback", return_value=None) - # Call result = subtensor.get_subnet_hyperparameters(netuid, block) - # Asserts assert result is None - subtensor.query_runtime_api.assert_called_once_with( - runtime_api="SubnetInfoRuntimeApi", - method="get_subnet_hyperparams_v2", - params=[netuid], - block=block, - ) - subtensor_module.SubnetHyperparameters.from_dict.assert_not_called() def test_query_subtensor(subtensor, mocker): @@ -1666,6 +1656,111 @@ def test_get_existential_deposit(subtensor, mocker): assert result == Balance.from_rao(value) +def test_get_max_activity_cutoff_factor_milli(subtensor, mocker): + """Successful get_max_activity_cutoff_factor_milli call.""" + # Prep + block = 123 + mocked_query_constant = mocker.MagicMock() + mocked_query_constant.return_value.value = 50_000 + subtensor.substrate.get_constant = mocked_query_constant + + # Call + result = subtensor.get_max_activity_cutoff_factor_milli(block=block) + + # Assertions + mocked_query_constant.assert_called_once_with( + module_name="SubtensorModule", + constant_name="MaxActivityCutoffFactorMilli", + block_hash=subtensor.substrate.get_block_hash.return_value, + ) + subtensor.substrate.get_block_hash.assert_called_once_with(block) + assert result == 50_000 + + +def test_get_max_epochs_per_block(subtensor, mocker): + """Successful get_max_epochs_per_block call.""" + # Prep + block = 123 + mocked_query_constant = mocker.MagicMock() + mocked_query_constant.return_value.value = 32 + subtensor.substrate.get_constant = mocked_query_constant + + # Call + result = subtensor.get_max_epochs_per_block(block=block) + + # Assertions + mocked_query_constant.assert_called_once_with( + module_name="SubtensorModule", + constant_name="MaxEpochsPerBlock", + block_hash=subtensor.substrate.get_block_hash.return_value, + ) + subtensor.substrate.get_block_hash.assert_called_once_with(block) + assert result == 32 + + +def test_get_max_tempo(subtensor, mocker): + """Successful get_max_tempo call.""" + # Prep + block = 123 + mocked_query_constant = mocker.MagicMock() + mocked_query_constant.return_value.value = 50_400 + subtensor.substrate.get_constant = mocked_query_constant + + # Call + result = subtensor.get_max_tempo(block=block) + + # Assertions + mocked_query_constant.assert_called_once_with( + module_name="SubtensorModule", + constant_name="MaxTempo", + block_hash=subtensor.substrate.get_block_hash.return_value, + ) + subtensor.substrate.get_block_hash.assert_called_once_with(block) + assert result == 50_400 + + +def test_get_min_activity_cutoff_factor_milli(subtensor, mocker): + """Successful get_min_activity_cutoff_factor_milli call.""" + # Prep + block = 123 + mocked_query_constant = mocker.MagicMock() + mocked_query_constant.return_value.value = 1_000 + subtensor.substrate.get_constant = mocked_query_constant + + # Call + result = subtensor.get_min_activity_cutoff_factor_milli(block=block) + + # Assertions + mocked_query_constant.assert_called_once_with( + module_name="SubtensorModule", + constant_name="MinActivityCutoffFactorMilli", + block_hash=subtensor.substrate.get_block_hash.return_value, + ) + subtensor.substrate.get_block_hash.assert_called_once_with(block) + assert result == 1_000 + + +def test_get_min_tempo(subtensor, mocker): + """Successful get_min_tempo call.""" + # Prep + block = 123 + mocked_query_constant = mocker.MagicMock() + mocked_query_constant.return_value.value = 360 + subtensor.substrate.get_constant = mocked_query_constant + + # Call + result = subtensor.get_min_tempo(block=block) + + # Assertions + mocked_query_constant.assert_called_once_with( + module_name="SubtensorModule", + constant_name="MinTempo", + block_hash=subtensor.substrate.get_block_hash.return_value, + ) + subtensor.substrate.get_block_hash.assert_called_once_with(block) + assert result == 360 + + def test_reveal_weights(subtensor, fake_wallet, mocker): """Successful test_reveal_weights call.""" # Preps @@ -1711,6 +1806,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 @@ -3441,26 +3629,29 @@ def test_get_subnet_info_no_data(mocker, subtensor): def test_get_next_epoch_start_block(mocker, subtensor): - """Check that get_next_epoch_start_block returns the correct value.""" - # Prep + """Check that get_next_epoch_start_block delegates to runtime API.""" netuid = 14 block = 20 - mocked_tempo = mocker.patch.object(subtensor, "tempo", return_value=100) - mocked_blocks_until_next_epoch = mocker.patch.object( - subtensor, - "blocks_until_next_epoch", - ) + mocker.patch.object(subtensor, "query_runtime_api", return_value=150) - # Call result = subtensor.get_next_epoch_start_block(netuid=netuid, block=block) - # Asserts - mocked_tempo.assert_called_once_with( - netuid=netuid, + subtensor.query_runtime_api.assert_called_once_with( + runtime_api="SubnetInfoRuntimeApi", + method="get_next_epoch_start_block", + params=[netuid], block=block, ) - assert result == mocked_blocks_until_next_epoch.return_value.__radd__().__add__() + assert result == 150 + + +def test_get_next_epoch_start_block_none(mocker, subtensor): + """Check that get_next_epoch_start_block returns None when API returns None.""" + mocker.patch.object(subtensor, "query_runtime_api", return_value=None) + + result = subtensor.get_next_epoch_start_block(netuid=1, block=20) + assert result is None def test_get_parents_success(subtensor, mocker): @@ -3590,121 +3781,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 +3944,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 @@ -3985,6 +4098,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 @@ -4357,42 +4517,37 @@ def test_get_mechanism_count(subtensor, mocker): def test_is_in_admin_freeze_window_root_net(subtensor, mocker): """Verify that root net has no admin freeze window.""" - # Preps netuid = 0 - mocked_get_next_epoch_start_block = mocker.patch.object( - subtensor, "get_next_epoch_start_block" - ) + mocked_tempo = mocker.patch.object(subtensor, "tempo") - # Call result = subtensor.is_in_admin_freeze_window(netuid=netuid) - # Asserts - mocked_get_next_epoch_start_block.assert_not_called() + mocked_tempo.assert_not_called() assert result is False @pytest.mark.parametrize( - "block, next_esb, expected_result", - ( - [89, 100, False], - [90, 100, False], - [91, 100, True], - ), + "block_number, pending, last, tempo, window, expected", + [ + (91, 200, 80, 20, 10, True), + (91, 0, 80, 20, 10, True), + (90, 0, 80, 20, 10, False), + (89, 0, 80, 20, 10, False), + ], ) -def test_is_in_admin_freeze_window(subtensor, mocker, block, next_esb, expected_result): - """Verify that `is_in_admin_freeze_window` method processed the data correctly.""" - # Preps +def test_is_in_admin_freeze_window( + subtensor, mocker, block_number, pending, last, tempo, window, expected +): + """Verify is_in_admin_freeze_window matches chain logic with storage getters.""" netuid = 14 - mocker.patch.object(subtensor, "get_current_block", return_value=block) - mocker.patch.object(subtensor, "get_next_epoch_start_block", return_value=next_esb) - mocker.patch.object(subtensor, "get_admin_freeze_window", return_value=10) + mocker.patch.object(subtensor, "tempo", return_value=tempo) + mocker.patch.object(subtensor, "get_pending_epoch_at", return_value=pending) + mocker.patch.object(subtensor, "get_last_epoch_block", return_value=last) + mocker.patch.object(subtensor, "get_admin_freeze_window", return_value=window) - # Call + result = subtensor.is_in_admin_freeze_window(netuid=netuid, 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): @@ -5021,7 +5176,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( @@ -5469,6 +5624,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 @@ -5845,24 +6045,25 @@ def test_remove_proxy(mocker, subtensor): assert response == mocked_remove_proxy_extrinsic.return_value -def test_blocks_until_next_epoch_uses_default_tempo(subtensor, mocker): - """Test blocks_until_next_epoch uses self.tempo when tempo is None.""" - # Prep - netuid = 0 +def test_blocks_until_next_epoch_runtime_api(subtensor, mocker): + """Test blocks_until_next_epoch derives from get_next_epoch_start_block runtime API.""" + netuid = 1 block = 20 - tempo = 100 - spy_get_current_block = mocker.spy(subtensor, "get_current_block") - spy_tempo = mocker.spy(subtensor, "tempo") + mocker.patch.object(subtensor, "get_next_epoch_start_block", return_value=150) - # Call - result = subtensor.blocks_until_next_epoch(netuid=netuid, tempo=tempo, block=block) + result = subtensor.blocks_until_next_epoch(netuid=netuid, block=block) - # Assert - spy_get_current_block.assert_not_called() - spy_tempo.assert_not_called() - assert result is not None - assert isinstance(result, int) + subtensor.get_next_epoch_start_block.assert_called_once_with(netuid, block=block) + assert result == 130 + + +def test_blocks_until_next_epoch_none_when_no_tempo(subtensor, mocker): + """Test blocks_until_next_epoch returns None when runtime API returns None.""" + mocker.patch.object(subtensor, "get_next_epoch_start_block", return_value=None) + + result = subtensor.blocks_until_next_epoch(netuid=1, block=20) + assert result is None def test_get_stake_info_for_coldkeys_none(subtensor, mocker): @@ -6535,3 +6736,233 @@ def test_register_on_root(mock_substrate, subtensor, fake_wallet, mocker): ) assert response == mocked_root_register_extrinsic.return_value + + +def test_get_last_epoch_block(subtensor, mocker): + """Verify that `get_last_epoch_block` queries LastEpochBlock storage.""" + # Preps + netuid = 7 + mocked_query = mocker.patch.object(subtensor, "query_subtensor") + + # Call + result = subtensor.get_last_epoch_block(netuid=netuid) + + # Asserts + mocked_query.assert_called_once_with( + name="LastEpochBlock", block=None, params=[netuid] + ) + assert result == mocked_query.return_value.value + + +def test_get_pending_epoch_at(subtensor, mocker): + """Verify that `get_pending_epoch_at` queries PendingEpochAt storage.""" + # Preps + netuid = 7 + mocked_query = mocker.patch.object(subtensor, "query_subtensor") + + # Call + result = subtensor.get_pending_epoch_at(netuid=netuid) + + # Asserts + mocked_query.assert_called_once_with( + name="PendingEpochAt", block=None, params=[netuid] + ) + assert result == mocked_query.return_value.value + + +def test_get_subnet_epoch_index(subtensor, mocker): + """Verify that `get_subnet_epoch_index` queries SubnetEpochIndex storage.""" + # Preps + netuid = 7 + mocked_query = mocker.patch.object(subtensor, "query_subtensor") + + # Call + result = subtensor.get_subnet_epoch_index(netuid=netuid) + + # Asserts + mocked_query.assert_called_once_with( + name="SubnetEpochIndex", block=None, params=[netuid] + ) + assert result == mocked_query.return_value.value + + +def test_get_activity_cutoff_factor_milli(subtensor, mocker): + """Verify that `get_activity_cutoff_factor_milli` queries ActivityCutoffFactorMilli storage.""" + # Preps + netuid = 7 + mocked_query = mocker.patch.object(subtensor, "query_subtensor") + + # Call + result = subtensor.get_activity_cutoff_factor_milli(netuid=netuid) + + # Asserts + mocked_query.assert_called_once_with( + name="ActivityCutoffFactorMilli", block=None, params=[netuid] + ) + assert result == mocked_query.return_value.value + + +def test_get_owner_hyperparam_rate_limit(subtensor, mocker): + """Verify that `get_owner_hyperparam_rate_limit` queries OwnerHyperparamRateLimit storage.""" + # Preps + mocked_determine_block_hash = mocker.patch.object(subtensor, "determine_block_hash") + mocked_query = mocker.patch.object(subtensor.substrate, "query") + + # Call + result = subtensor.get_owner_hyperparam_rate_limit() + + # Asserts + mocked_query.assert_called_once_with( + module="SubtensorModule", + storage_function="OwnerHyperparamRateLimit", + block_hash=mocked_determine_block_hash.return_value, + ) + assert result == mocked_query.return_value.value + + +def test_get_epoch_schedule_state(subtensor, mocker): + """Verify that `get_epoch_schedule_state` composes sub-queries into EpochScheduleState.""" + # Preps + netuid = 7 + fake_block = 500 + mocker.patch.object( + type(subtensor), + "block", + new_callable=mocker.PropertyMock, + return_value=fake_block, + ) + mocker.patch.object(subtensor, "get_last_epoch_block", return_value=100) + mocker.patch.object(subtensor, "get_pending_epoch_at", return_value=0) + mocker.patch.object(subtensor, "get_subnet_epoch_index", return_value=42) + mocker.patch.object(subtensor, "tempo", return_value=360) + mocker.patch.object(subtensor, "blocks_since_last_step", return_value=50) + + # Call + result = subtensor.get_epoch_schedule_state(netuid=netuid) + + # Asserts + subtensor.get_last_epoch_block.assert_called_once_with(netuid, block=fake_block) + subtensor.get_pending_epoch_at.assert_called_once_with(netuid, block=fake_block) + subtensor.get_subnet_epoch_index.assert_called_once_with(netuid, block=fake_block) + subtensor.tempo.assert_called_once_with(netuid, block=fake_block) + subtensor.blocks_since_last_step.assert_called_once_with(netuid, block=fake_block) + assert result.last_epoch_block == 100 + assert result.pending_epoch_at == 0 + assert result.subnet_epoch_index == 42 + assert result.tempo == 360 + assert result.blocks_since_last_step == 50 + assert result.current_block == fake_block + + +def test_set_tempo(subtensor, fake_wallet, mocker): + """Verify that `set_tempo` delegates to set_tempo_extrinsic with correct parameters.""" + # Preps + mocked_extrinsic = mocker.patch.object(subtensor_module, "set_tempo_extrinsic") + + # Call + result = subtensor.set_tempo( + wallet=fake_wallet, + netuid=7, + tempo=500, + ) + + # Asserts + mocked_extrinsic.assert_called_once_with( + subtensor=subtensor, + wallet=fake_wallet, + netuid=7, + tempo=500, + mev_protection=DEFAULT_MEV_PROTECTION, + period=DEFAULT_PERIOD, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + assert result == mocked_extrinsic.return_value + + +def test_set_activity_cutoff_factor(subtensor, fake_wallet, mocker): + """Verify that `set_activity_cutoff_factor` delegates to set_activity_cutoff_factor_extrinsic.""" + # Preps + mocked_extrinsic = mocker.patch.object( + subtensor_module, "set_activity_cutoff_factor_extrinsic" + ) + + # Call + result = subtensor.set_activity_cutoff_factor( + wallet=fake_wallet, + netuid=7, + factor_milli=5000, + ) + + # Asserts + mocked_extrinsic.assert_called_once_with( + subtensor=subtensor, + wallet=fake_wallet, + netuid=7, + factor_milli=5000, + mev_protection=DEFAULT_MEV_PROTECTION, + period=DEFAULT_PERIOD, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + assert result == mocked_extrinsic.return_value + + +def test_root_set_activity_cutoff_factor(subtensor, fake_wallet, mocker): + """Verify that `root_set_activity_cutoff_factor` delegates to root_set_activity_cutoff_factor_extrinsic.""" + # Preps + mocked_extrinsic = mocker.patch.object( + subtensor_module, "root_set_activity_cutoff_factor_extrinsic" + ) + + # Call + result = subtensor.root_set_activity_cutoff_factor( + wallet=fake_wallet, + netuid=7, + factor_milli=5000, + ) + + # Asserts + mocked_extrinsic.assert_called_once_with( + subtensor=subtensor, + wallet=fake_wallet, + netuid=7, + factor_milli=5000, + mev_protection=DEFAULT_MEV_PROTECTION, + period=DEFAULT_PERIOD, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + assert result == mocked_extrinsic.return_value + + +def test_trigger_epoch(subtensor, fake_wallet, mocker): + """Verify that `trigger_epoch` delegates to trigger_epoch_extrinsic.""" + # Preps + mocked_extrinsic = mocker.patch.object(subtensor_module, "trigger_epoch_extrinsic") + + # Call + result = subtensor.trigger_epoch( + wallet=fake_wallet, + netuid=7, + ) + + # Asserts + mocked_extrinsic.assert_called_once_with( + subtensor=subtensor, + wallet=fake_wallet, + netuid=7, + mev_protection=DEFAULT_MEV_PROTECTION, + period=DEFAULT_PERIOD, + raise_error=False, + wait_for_inclusion=True, + wait_for_finalization=True, + wait_for_revealed_execution=True, + ) + assert result == mocked_extrinsic.return_value diff --git a/tests/unit_tests/utils/test_epoch_schedule.py b/tests/unit_tests/utils/test_epoch_schedule.py new file mode 100644 index 0000000000..d8d1528d1e --- /dev/null +++ b/tests/unit_tests/utils/test_epoch_schedule.py @@ -0,0 +1,101 @@ +import pytest + +from bittensor.utils.epoch_schedule import ( + blocks_until_next_auto_epoch, + is_in_admin_freeze_window, +) + + +def test_blocks_until_next_auto_epoch_before_next_epoch(): + """Verify blocks remaining before the next auto epoch.""" + assert blocks_until_next_auto_epoch(100, 50, 120) == 30 + + +def test_blocks_until_next_auto_epoch_at_next_epoch(): + """Verify zero remaining at the next auto epoch boundary.""" + assert blocks_until_next_auto_epoch(100, 50, 150) == 0 + + +def test_blocks_until_next_auto_epoch_after_next_epoch(): + """Verify zero remaining after the next auto epoch boundary.""" + assert blocks_until_next_auto_epoch(100, 50, 200) == 0 + + +def test_is_in_admin_freeze_window_tempo_zero(): + """Verify tempo zero disables the admin freeze window.""" + assert ( + is_in_admin_freeze_window( + tempo=0, + pending_epoch_at=0, + last_epoch_block=100, + block_number=100, + admin_freeze_window=10, + ) + is False + ) + + +def test_is_in_admin_freeze_window_pending_epoch_in_future(): + """Verify pending triggered epoch in the future blocks owner operations.""" + assert ( + is_in_admin_freeze_window( + tempo=20, + pending_epoch_at=200, + last_epoch_block=80, + block_number=91, + admin_freeze_window=10, + ) + is True + ) + + +def test_is_in_admin_freeze_window_auto_epoch_inside_window(): + """Verify auto epoch inside the freeze window blocks owner operations.""" + assert ( + is_in_admin_freeze_window( + tempo=20, + pending_epoch_at=0, + last_epoch_block=80, + block_number=91, + admin_freeze_window=10, + ) + is True + ) + + +def test_is_in_admin_freeze_window_auto_epoch_at_boundary(): + """Verify remaining == window is not frozen (strict less-than).""" + assert ( + is_in_admin_freeze_window( + tempo=20, + pending_epoch_at=0, + last_epoch_block=80, + block_number=90, + admin_freeze_window=10, + ) + is False + ) + + +@pytest.mark.parametrize( + "block_number, expected", + [ + (89, False), + (90, False), + (91, True), + (99, True), + (100, True), + ], +) +def test_is_in_admin_freeze_window_edge_cases(block_number, expected): + """Sweep around the freeze boundary for last=80, tempo=20, window=10.""" + assert ( + is_in_admin_freeze_window( + tempo=20, + pending_epoch_at=0, + last_epoch_block=80, + block_number=block_number, + admin_freeze_window=10, + ) + is expected + ) 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