From e24ab2415f03457a24533ad9c57adf3a2d9b7b1a Mon Sep 17 00:00:00 2001 From: bdhimes Date: Tue, 9 Sep 2025 17:25:23 +0200 Subject: [PATCH 01/15] Adds a warning on transfers that transferring is not the same as staking. --- bittensor_cli/src/bittensor/extrinsics/transfer.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bittensor_cli/src/bittensor/extrinsics/transfer.py b/bittensor_cli/src/bittensor/extrinsics/transfer.py index cd435b641..ad3168a23 100644 --- a/bittensor_cli/src/bittensor/extrinsics/transfer.py +++ b/bittensor_cli/src/bittensor/extrinsics/transfer.py @@ -175,7 +175,9 @@ async def do_transfer() -> tuple[bool, str, str]: f" amount: [bright_cyan]{amount if not transfer_all else account_balance}[/bright_cyan]\n" f" from: [light_goldenrod2]{wallet.name}[/light_goldenrod2] : " f"[bright_magenta]{wallet.coldkey.ss58_address}\n[/bright_magenta]" - f" to: [bright_magenta]{destination}[/bright_magenta]\n for fee: [bright_cyan]{fee}[/bright_cyan]" + f" to: [bright_magenta]{destination}[/bright_magenta]\n for fee: [bright_cyan]{fee}[/bright_cyan]\n" + f":warning:[bright_yellow]Transferring is not the same as staking. To instead stake, use " + f"[dark_orange]btcli stake add[/dark_orange] instead[/bright_yellow]:warning:" ): return False From d167d4be9127ec218e15f37505fe1e47c4c98d08 Mon Sep 17 00:00:00 2001 From: bdhimes Date: Tue, 9 Sep 2025 21:06:31 +0200 Subject: [PATCH 02/15] Better type annotations --- tests/e2e_tests/conftest.py | 25 ++++++++++++++++++++----- tests/e2e_tests/utils.py | 19 ++++++++++++++++--- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/tests/e2e_tests/conftest.py b/tests/e2e_tests/conftest.py index 1b93ac0ae..0e1b13cc6 100644 --- a/tests/e2e_tests/conftest.py +++ b/tests/e2e_tests/conftest.py @@ -1,4 +1,5 @@ import asyncio +from collections.abc import Iterator, Callable import logging import os import re @@ -8,11 +9,13 @@ import subprocess import sys import time +from typing import Generator +import bittensor_wallet.keypair import pytest from async_substrate_interface.async_substrate import AsyncSubstrateInterface -from .utils import setup_wallet +from .utils import setup_wallet, ExecCommand LOCALNET_IMAGE_NAME = "ghcr.io/opentensor/subtensor-localnet:devnet-ready" @@ -31,7 +34,7 @@ def wait_for_node_start(process, pattern, timestamp: int = None): # Fixture for setting up and tearing down a localnet.sh chain between tests @pytest.fixture(scope="function") -def local_chain(request): +def local_chain(request) -> Iterator[AsyncSubstrateInterface]: """Determines whether to run the localnet.sh script in a subprocess or a Docker container.""" args = request.param if hasattr(request, "param") else None params = "" if args is None else f"{args}" @@ -58,7 +61,7 @@ def local_chain(request): yield from legacy_runner(request) -def legacy_runner(request): +def legacy_runner(request) -> Iterator[AsyncSubstrateInterface]: param = request.param if hasattr(request, "param") else None # Get the environment variable for the script path script_path = os.getenv("LOCALNET_SH_PATH") @@ -103,7 +106,7 @@ def legacy_runner(request): process.wait() -def docker_runner(params): +def docker_runner(params) -> Iterator[AsyncSubstrateInterface]: """Starts a Docker container before tests and gracefully terminates it after.""" def is_docker_running(): @@ -211,7 +214,19 @@ def try_start_docker(): @pytest.fixture(scope="function") -def wallet_setup(): +def wallet_setup() -> Generator[ + Callable[ + [str], + tuple[ + bittensor_wallet.Keypair, + bittensor_wallet.Wallet, + str, + ExecCommand, + ], + ], + None, + None, +]: wallet_paths = [] def _setup_wallet(uri: str): diff --git a/tests/e2e_tests/utils.py b/tests/e2e_tests/utils.py index b8b729b3e..c2f80f3e1 100644 --- a/tests/e2e_tests/utils.py +++ b/tests/e2e_tests/utils.py @@ -5,9 +5,10 @@ import shutil import subprocess import sys -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, Protocol from bittensor_wallet import Keypair, Wallet +from click.testing import Result from packaging.version import parse as parse_version, Version from typer.testing import CliRunner @@ -20,7 +21,19 @@ templates_repo = "templates repository" -def setup_wallet(uri: str): +class ExecCommand(Protocol): + """Type Protocol for setup_wallet's exec_command fn""" + + def __call__( + self, + command: str, + sub_command: str, + extra_args: Optional[list[str]], + inputs: Optional[list[str]], + ) -> Result: ... + + +def setup_wallet(uri: str) -> tuple[Keypair, Wallet, str, ExecCommand]: keypair = Keypair.create_from_uri(uri) wallet_path = f"/tmp/btcli-e2e-wallet-{uri.strip('/')}" wallet = Wallet(path=wallet_path) @@ -32,7 +45,7 @@ def exec_command( command: str, sub_command: str, extra_args: Optional[list[str]] = None, - inputs: list[str] = None, + inputs: Optional[list[str]] = None, ): extra_args = extra_args or [] cli_manager = CLIManager() From b4045fd2474e2fd20ddfe2ffad45593da88650be Mon Sep 17 00:00:00 2001 From: bdhimes Date: Tue, 9 Sep 2025 21:06:51 +0200 Subject: [PATCH 03/15] Adds command to turn off hyperparams freeze window, applies it as necessary --- tests/e2e_tests/test_hyperparams_setting.py | 9 ++++++- tests/e2e_tests/test_liquidity.py | 8 +++++++ tests/e2e_tests/test_staking_sudo.py | 8 +++++++ tests/e2e_tests/utils.py | 26 +++++++++++++++++++++ 4 files changed, 50 insertions(+), 1 deletion(-) diff --git a/tests/e2e_tests/test_hyperparams_setting.py b/tests/e2e_tests/test_hyperparams_setting.py index 03d8616c3..ba50b09ad 100644 --- a/tests/e2e_tests/test_hyperparams_setting.py +++ b/tests/e2e_tests/test_hyperparams_setting.py @@ -1,6 +1,8 @@ +import asyncio import json from bittensor_cli.src import HYPERPARAMS +from tests.e2e_tests.utils import turn_off_hyperparam_freeze_window """ Verify commands: @@ -18,7 +20,12 @@ def test_hyperparams_setting(local_chain, wallet_setup): keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( wallet_path_alice ) - + try: + asyncio.run(turn_off_hyperparam_freeze_window(local_chain, wallet_alice)) + except ValueError: + print( + "Skipping turning off hyperparams freeze window. This indicates the call does not exist on the chain you are testing." + ) # Register a subnet with sudo as Alice result = exec_command_alice( command="subnets", diff --git a/tests/e2e_tests/test_liquidity.py b/tests/e2e_tests/test_liquidity.py index c8a7b7d4c..8414b5f29 100644 --- a/tests/e2e_tests/test_liquidity.py +++ b/tests/e2e_tests/test_liquidity.py @@ -1,7 +1,9 @@ +import asyncio import json import re from bittensor_cli.src.bittensor.balances import Balance +from tests.e2e_tests.utils import turn_off_hyperparam_freeze_window """ Verify commands: @@ -40,6 +42,12 @@ def liquidity_list(): keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( wallet_path_alice ) + try: + asyncio.run(turn_off_hyperparam_freeze_window(local_chain, wallet_alice)) + except ValueError: + print( + "Skipping turning off hyperparams freeze window. This indicates the call does not exist on the chain you are testing." + ) # Register a subnet with sudo as Alice result = exec_command_alice( diff --git a/tests/e2e_tests/test_staking_sudo.py b/tests/e2e_tests/test_staking_sudo.py index 359fbb508..0f3d6f8d9 100644 --- a/tests/e2e_tests/test_staking_sudo.py +++ b/tests/e2e_tests/test_staking_sudo.py @@ -1,7 +1,9 @@ +import asyncio import json import re from bittensor_cli.src.bittensor.balances import Balance +from tests.e2e_tests.utils import turn_off_hyperparam_freeze_window """ Verify commands: @@ -45,6 +47,12 @@ def test_staking(local_chain, wallet_setup): keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( wallet_path_alice ) + try: + asyncio.run(turn_off_hyperparam_freeze_window(local_chain, wallet_alice)) + except ValueError: + print( + "Skipping turning off hyperparams freeze window. This indicates the call does not exist on the chain you are testing." + ) burn_cost = exec_command_alice( "subnets", diff --git a/tests/e2e_tests/utils.py b/tests/e2e_tests/utils.py index c2f80f3e1..9917c2c10 100644 --- a/tests/e2e_tests/utils.py +++ b/tests/e2e_tests/utils.py @@ -383,3 +383,29 @@ async def set_storage_extrinsic( print(":white_heavy_check_mark: [dark_sea_green_3]Success[/dark_sea_green_3]") return response + + +async def turn_off_hyperparam_freeze_window( + substrate: "AsyncSubstrateInterface", wallet: Wallet +): + call = await substrate.compose_call( + call_module="Sudo", + call_function="sudo", + call_params={ + "call": await substrate.compose_call( + call_module="AdminUtils", + call_function="sudo_set_admin_freeze_window", + call_params={"window": 0}, + ) + }, + ) + extrinsic = await substrate.create_signed_extrinsic( + call=call, keypair=wallet.coldkey + ) + response = await substrate.submit_extrinsic( + extrinsic, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + + return await response.is_success, await response.error_message From 81401f4b334a7e69c7a6cf9681c973a4d337b4f5 Mon Sep 17 00:00:00 2001 From: bdhimes Date: Tue, 9 Sep 2025 21:28:28 +0200 Subject: [PATCH 04/15] CI's PYTHON_PATH not set correctly. This is a bandage. --- tests/e2e_tests/test_hyperparams_setting.py | 2 +- tests/e2e_tests/test_liquidity.py | 2 +- tests/e2e_tests/test_staking_sudo.py | 2 +- tests/e2e_tests/test_unstaking.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/e2e_tests/test_hyperparams_setting.py b/tests/e2e_tests/test_hyperparams_setting.py index ba50b09ad..3af86c140 100644 --- a/tests/e2e_tests/test_hyperparams_setting.py +++ b/tests/e2e_tests/test_hyperparams_setting.py @@ -2,7 +2,7 @@ import json from bittensor_cli.src import HYPERPARAMS -from tests.e2e_tests.utils import turn_off_hyperparam_freeze_window +from .utils import turn_off_hyperparam_freeze_window """ Verify commands: diff --git a/tests/e2e_tests/test_liquidity.py b/tests/e2e_tests/test_liquidity.py index 8414b5f29..218ef91f0 100644 --- a/tests/e2e_tests/test_liquidity.py +++ b/tests/e2e_tests/test_liquidity.py @@ -3,7 +3,7 @@ import re from bittensor_cli.src.bittensor.balances import Balance -from tests.e2e_tests.utils import turn_off_hyperparam_freeze_window +from .utils import turn_off_hyperparam_freeze_window """ Verify commands: diff --git a/tests/e2e_tests/test_staking_sudo.py b/tests/e2e_tests/test_staking_sudo.py index 0f3d6f8d9..9034c51da 100644 --- a/tests/e2e_tests/test_staking_sudo.py +++ b/tests/e2e_tests/test_staking_sudo.py @@ -3,7 +3,7 @@ import re from bittensor_cli.src.bittensor.balances import Balance -from tests.e2e_tests.utils import turn_off_hyperparam_freeze_window +from .utils import turn_off_hyperparam_freeze_window """ Verify commands: diff --git a/tests/e2e_tests/test_unstaking.py b/tests/e2e_tests/test_unstaking.py index 68af71087..4b7ca0765 100644 --- a/tests/e2e_tests/test_unstaking.py +++ b/tests/e2e_tests/test_unstaking.py @@ -4,7 +4,7 @@ from bittensor_cli.src.bittensor.balances import Balance -from btcli.tests.e2e_tests.utils import set_storage_extrinsic +from .utils import set_storage_extrinsic def test_unstaking(local_chain, wallet_setup): From 5397f36849046f802f1e54bf9a8ab5ce8e84650c Mon Sep 17 00:00:00 2001 From: bdhimes Date: Wed, 10 Sep 2025 17:03:41 +0200 Subject: [PATCH 05/15] Corrects the stake fee calculation --- .../src/bittensor/subtensor_interface.py | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index cb3b295f3..02a62baaf 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -1547,14 +1547,24 @@ async def get_stake_fee( if origin_netuid is None: origin_netuid = 0 + if destination_netuid is None: + destination_netuid = 0 - fee_rate = await self.query("Swap", "FeeRate", [origin_netuid]) - fee = amount * (fee_rate / U16_MAX) + fee_rate, mechanism = await asyncio.gather( + self.query("Swap", "FeeRate", [origin_netuid]), + self.query("SubtensorModule", "SubnetMechanism", [destination_netuid]), + ) + if mechanism == 0: + # Stake Swap Fee is only charged if subnet mechanism is not 0 (stable), otherwise it is dynamic + fee = Balance(0).set_unit(origin_netuid) + return fee + else: + fee = amount * (fee_rate / U16_MAX) - result = Balance.from_rao(fee) - result.set_unit(origin_netuid) + result = Balance.from_rao(fee) + result.set_unit(origin_netuid) - return result + return result async def get_scheduled_coldkey_swap( self, From 4a694170370b6d4b690a07be8bf9da3f4802e373 Mon Sep 17 00:00:00 2001 From: bdhimes Date: Wed, 10 Sep 2025 17:47:18 +0200 Subject: [PATCH 06/15] Added SimSwap Runtime call --- bittensor_cli/src/bittensor/chain_data.py | 17 +++++ .../src/bittensor/subtensor_interface.py | 75 +++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/bittensor_cli/src/bittensor/chain_data.py b/bittensor_cli/src/bittensor/chain_data.py index 0f64d8519..07fd8c906 100644 --- a/bittensor_cli/src/bittensor/chain_data.py +++ b/bittensor_cli/src/bittensor/chain_data.py @@ -1193,3 +1193,20 @@ def _fix_decoded(cls, decoded: dict) -> "MetagraphInfo": for adphk in decoded["alpha_dividends_per_hotkey"] ], ) + + +@dataclass +class SimSwapResult: + tao_amount: Balance + alpha_amount: Balance + tao_fee: Balance + alpha_fee: Balance + + @classmethod + def from_dict(cls, d: dict, netuid: int) -> "SimSwapResult": + return cls( + tao_amount=Balance.from_rao(d["tao_amount"]).set_unit(0), + alpha_amount=Balance.from_rao(d["alpha_amount"]).set_unit(netuid), + tao_fee=Balance.from_rao(d["tao_fee"]).set_unit(0), + alpha_fee=Balance.from_rao(d["alpha_fee"]).set_unit(netuid), + ) diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index 02a62baaf..6b9171cde 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -28,6 +28,7 @@ DynamicInfo, SubnetState, MetagraphInfo, + SimSwapResult, ) from bittensor_cli.src import DelegatesDetails from bittensor_cli.src.bittensor.balances import Balance, fixed_to_float @@ -1502,6 +1503,80 @@ async def get_extrinsic_fee(self, call: GenericCall, keypair: Keypair) -> Balanc fee_dict = await self.substrate.get_payment_info(call, keypair) return Balance.from_rao(fee_dict["partial_fee"]) + async def sim_swap( + self, + origin_netuid: int, + destination_netuid: int, + amount: int, + block_hash: Optional[str] = None, + ) -> SimSwapResult: + """ + Hits the SimSwap Runtime API to calculate the fee and result for a given transaction. This should be used + instead of get_stake_fee for staking fee calculations. The SimSwapResult contains the staking fees and expected + returned amounts of a given transaction. This does not include the transaction (extrinsic) fee. + + Args: + origin_netuid: Netuid of the source subnet (0 if new stake) + destination_netuid: Netuid of the destination subnet + amount: Amount to transfer in Rao + block_hash: The hash of the blockchain block number for the query. + + Returns: + SimSwapResult object representing the result + """ + block_hash = block_hash or await self.substrate.get_chain_head() + if origin_netuid > 0 and destination_netuid > 0: + # for cross-subnet moves + intermediate_result_, sn_price = await asyncio.gather( + self.query_runtime_api( + "SwapRuntimeApi", + "sim_swap_alpha_for_tao", + params={"netuid": origin_netuid, "alpha": amount}, + block_hash=block_hash, + ), + self.get_subnet_price(origin_netuid, block_hash=block_hash), + ) + intermediate_result = SimSwapResult.from_dict( + intermediate_result_, origin_netuid + ) + result = SimSwapResult.from_dict( + await self.query_runtime_api( + "SwapRuntimeApi", + "sim_swap_tao_for_alpha", + params={ + "netuid": destination_netuid, + "tao": intermediate_result.tao_amount, + }, + block_hash=block_hash, + ), + destination_netuid, + ) + secondary_fee = (result.tao_fee * sn_price).set_unit(origin_netuid) + result.alpha_fee = result.alpha_fee + secondary_fee + return result + elif origin_netuid > 0: + # dynamic to tao + return SimSwapResult.from_dict( + await self.query_runtime_api( + "SwapRuntimeApi", + "sim_swap_alpha_for_tao", + params={"netuid": origin_netuid, "alpha": amount}, + block_hash=block_hash, + ), + origin_netuid, + ) + else: + # tao to dynamic or unstaked to staked tao (SN0) + return SimSwapResult.from_dict( + await self.query_runtime_api( + "SwapRuntimeApi", + "sim_swap_tao_for_alpha", + params={"netuid": destination_netuid, "tao": amount}, + block_hash=block_hash, + ), + destination_netuid, + ) + async def get_stake_fee( self, origin_hotkey_ss58: Optional[str], From e6e76294c2a76f2beec04c5bc5d04017f9d989b4 Mon Sep 17 00:00:00 2001 From: bdhimes Date: Wed, 10 Sep 2025 17:54:19 +0200 Subject: [PATCH 07/15] Swap fee calc in stake add for simswap --- bittensor_cli/src/commands/stake/add.py | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/bittensor_cli/src/commands/stake/add.py b/bittensor_cli/src/commands/stake/add.py index 6579d9767..18a507c6d 100644 --- a/bittensor_cli/src/commands/stake/add.py +++ b/bittensor_cli/src/commands/stake/add.py @@ -347,17 +347,6 @@ async def stake_extrinsic( return False remaining_wallet_balance -= amount_to_stake - # TODO this should be asyncio gathered before the for loop - stake_fee = await subtensor.get_stake_fee( - origin_hotkey_ss58=None, - origin_netuid=None, - origin_coldkey_ss58=wallet.coldkeypub.ss58_address, - destination_hotkey_ss58=hotkey[1], - destination_netuid=netuid, - destination_coldkey_ss58=wallet.coldkeypub.ss58_address, - amount=amount_to_stake.rao, - ) - # Calculate slippage # TODO: Update for V3, slippage calculation is significantly different in v3 # try: @@ -409,7 +398,13 @@ async def stake_extrinsic( safe_staking_=safe_staking, ) row_extension = [] - received_amount = rate * (amount_to_stake - stake_fee - extrinsic_fee) + # TODO this should be asyncio gathered before the for loop + sim_swap = await subtensor.sim_swap( + origin_netuid=0, + destination_netuid=netuid, + amount=(amount_to_stake - extrinsic_fee).rao, + ) + received_amount = sim_swap.alpha_amount # Add rows for the table base_row = [ str(netuid), # netuid @@ -418,7 +413,7 @@ async def stake_extrinsic( str(rate) + f" {Balance.get_unit(netuid)}/{Balance.get_unit(0)} ", # rate str(received_amount.set_unit(netuid)), # received - str(stake_fee), # fee + str(sim_swap.tao_fee), # fee str(extrinsic_fee), # str(slippage_pct), # slippage ] + row_extension From 323f9a059ddfcf3c1ac2a472593db472c15c0bc7 Mon Sep 17 00:00:00 2001 From: bdhimes Date: Wed, 10 Sep 2025 18:15:39 +0200 Subject: [PATCH 08/15] Swapped all fee calculation for simswap --- .../src/bittensor/subtensor_interface.py | 2 +- bittensor_cli/src/commands/stake/move.py | 36 ++++++++----------- bittensor_cli/src/commands/stake/remove.py | 35 +++++------------- 3 files changed, 24 insertions(+), 49 deletions(-) diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index 6b9171cde..38b4213ad 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -1526,7 +1526,7 @@ async def sim_swap( """ block_hash = block_hash or await self.substrate.get_chain_head() if origin_netuid > 0 and destination_netuid > 0: - # for cross-subnet moves + # for cross-subnet moves where neither origin nor destination is root intermediate_result_, sn_price = await asyncio.gather( self.query_runtime_api( "SwapRuntimeApi", diff --git a/bittensor_cli/src/commands/stake/move.py b/bittensor_cli/src/commands/stake/move.py index c72bbb41e..b4360ffdf 100644 --- a/bittensor_cli/src/commands/stake/move.py +++ b/bittensor_cli/src/commands/stake/move.py @@ -520,14 +520,10 @@ async def move_stake( "alpha_amount": amount_to_move_as_balance.rao, }, ) - stake_fee, extrinsic_fee = await asyncio.gather( - subtensor.get_stake_fee( - origin_hotkey_ss58=origin_hotkey, + sim_swap, extrinsic_fee = await asyncio.gather( + subtensor.sim_swap( origin_netuid=origin_netuid, - origin_coldkey_ss58=wallet.coldkeypub.ss58_address, - destination_hotkey_ss58=destination_hotkey, destination_netuid=destination_netuid, - destination_coldkey_ss58=wallet.coldkeypub.ss58_address, amount=amount_to_move_as_balance.rao, ), subtensor.get_extrinsic_fee(call, wallet.coldkeypub), @@ -543,7 +539,9 @@ async def move_stake( origin_hotkey=origin_hotkey, destination_hotkey=destination_hotkey, amount_to_move=amount_to_move_as_balance, - stake_fee=stake_fee, + stake_fee=sim_swap.alpha_fee + if origin_netuid != 0 + else sim_swap.tao_fee, extrinsic_fee=extrinsic_fee, ) except ValueError: @@ -709,14 +707,10 @@ async def transfer_stake( "alpha_amount": amount_to_transfer.rao, }, ) - stake_fee, extrinsic_fee = await asyncio.gather( - subtensor.get_stake_fee( - origin_hotkey_ss58=origin_hotkey, + sim_swap, extrinsic_fee = await asyncio.gather( + subtensor.sim_swap( origin_netuid=origin_netuid, - origin_coldkey_ss58=wallet.coldkeypub.ss58_address, - destination_hotkey_ss58=origin_hotkey, destination_netuid=dest_netuid, - destination_coldkey_ss58=dest_coldkey_ss58, amount=amount_to_transfer.rao, ), subtensor.get_extrinsic_fee(call, wallet.coldkeypub), @@ -732,7 +726,9 @@ async def transfer_stake( origin_hotkey=origin_hotkey, destination_hotkey=origin_hotkey, amount_to_move=amount_to_transfer, - stake_fee=stake_fee, + stake_fee=sim_swap.alpha_fee + if origin_netuid != 0 + else sim_swap.tao_fee, extrinsic_fee=extrinsic_fee, ) except ValueError: @@ -880,14 +876,10 @@ async def swap_stake( "alpha_amount": amount_to_swap.rao, }, ) - stake_fee, extrinsic_fee = await asyncio.gather( - subtensor.get_stake_fee( - origin_hotkey_ss58=hotkey_ss58, + sim_swap, extrinsic_fee = await asyncio.gather( + subtensor.sim_swap( origin_netuid=origin_netuid, - origin_coldkey_ss58=wallet.coldkeypub.ss58_address, - destination_hotkey_ss58=hotkey_ss58, destination_netuid=destination_netuid, - destination_coldkey_ss58=wallet.coldkeypub.ss58_address, amount=amount_to_swap.rao, ), subtensor.get_extrinsic_fee(call, wallet.coldkeypub), @@ -903,7 +895,9 @@ async def swap_stake( origin_hotkey=hotkey_ss58, destination_hotkey=hotkey_ss58, amount_to_move=amount_to_swap, - stake_fee=stake_fee, + stake_fee=sim_swap.alpha_fee + if origin_netuid != 0 + else sim_swap.tao_fee, extrinsic_fee=extrinsic_fee, ) except ValueError: diff --git a/bittensor_cli/src/commands/stake/remove.py b/bittensor_cli/src/commands/stake/remove.py index 3a37b8cbe..ecec77fa5 100644 --- a/bittensor_cli/src/commands/stake/remove.py +++ b/bittensor_cli/src/commands/stake/remove.py @@ -200,16 +200,6 @@ async def unstake( ) continue # Skip to the next subnet - useful when single amount is specified for all subnets - stake_fee = await subtensor.get_stake_fee( - origin_hotkey_ss58=staking_address_ss58, - origin_netuid=netuid, - origin_coldkey_ss58=wallet.coldkeypub.ss58_address, - destination_hotkey_ss58=None, - destination_netuid=None, - destination_coldkey_ss58=wallet.coldkeypub.ss58_address, - amount=amount_to_unstake_as_balance.rao, - ) - try: current_price = subnet_info.price.tao if safe_staking: @@ -240,10 +230,10 @@ async def unstake( netuid=netuid, amount=amount_to_unstake_as_balance, ) - rate = current_price - received_amount = ( - (amount_to_unstake_as_balance - stake_fee) * rate - ) - extrinsic_fee + sim_swap = await subtensor.sim_swap( + netuid, 0, amount_to_unstake_as_balance.rao + ) + received_amount = sim_swap.tao_amount - extrinsic_fee except ValueError: continue total_received_amount += received_amount @@ -266,7 +256,7 @@ async def unstake( str(amount_to_unstake_as_balance), # Amount to Unstake f"{subnet_info.price.tao:.6f}" + f"(τ/{Balance.get_unit(netuid)})", # Rate - str(stake_fee.set_unit(netuid)), # Fee + str(sim_swap.alpha_fee), # Fee str(extrinsic_fee), # Extrinsic fee str(received_amount), # Received Amount # slippage_pct, # Slippage Percent @@ -494,15 +484,6 @@ async def unstake_all( hotkey_display = hotkey_names.get(stake.hotkey_ss58, stake.hotkey_ss58) subnet_info = all_sn_dynamic_info.get(stake.netuid) stake_amount = stake.stake - stake_fee = await subtensor.get_stake_fee( - origin_hotkey_ss58=stake.hotkey_ss58, - origin_netuid=stake.netuid, - origin_coldkey_ss58=wallet.coldkeypub.ss58_address, - destination_hotkey_ss58=None, - destination_netuid=None, - destination_coldkey_ss58=wallet.coldkeypub.ss58_address, - amount=stake_amount.rao, - ) try: current_price = subnet_info.price.tao @@ -515,8 +496,8 @@ async def unstake_all( subtensor, hotkey_ss58=stake.hotkey_ss58, ) - rate = current_price - received_amount = ((stake_amount - stake_fee) * rate) - extrinsic_fee + sim_swap = await subtensor.sim_swap(stake.netuid, 0, stake_amount.rao) + received_amount = sim_swap.tao_amount - extrinsic_fee if received_amount < Balance.from_tao(0): print_error("Not enough Alpha to pay the transaction fee.") @@ -532,7 +513,7 @@ async def unstake_all( str(stake_amount), f"{float(subnet_info.price):.6f}" + f"({Balance.get_unit(0)}/{Balance.get_unit(stake.netuid)})", - str(stake_fee), + str(sim_swap.alpha_fee), str(extrinsic_fee), str(received_amount), ) From e9a24b60393ea454fa260e21a2e1ce4168ee77c5 Mon Sep 17 00:00:00 2001 From: bdhimes Date: Wed, 10 Sep 2025 18:16:11 +0200 Subject: [PATCH 09/15] Removed get_stake_fee as we no longer use it. --- .../src/bittensor/subtensor_interface.py | 64 ------------------- 1 file changed, 64 deletions(-) diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index 38b4213ad..cafef0439 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -1577,70 +1577,6 @@ async def sim_swap( destination_netuid, ) - async def get_stake_fee( - self, - origin_hotkey_ss58: Optional[str], - origin_netuid: Optional[int], - origin_coldkey_ss58: str, - destination_hotkey_ss58: Optional[str], - destination_netuid: Optional[int], - destination_coldkey_ss58: str, - amount: int, - block_hash: Optional[str] = None, - ) -> Balance: - """ - Calculates the fee for a staking operation. - - :param origin_hotkey_ss58: SS58 address of source hotkey (None for new stake) - :param origin_netuid: Netuid of source subnet (None for new stake) - :param origin_coldkey_ss58: SS58 address of source coldkey - :param destination_hotkey_ss58: SS58 address of destination hotkey (None for removing stake) - :param destination_netuid: Netuid of destination subnet (None for removing stake) - :param destination_coldkey_ss58: SS58 address of destination coldkey - :param amount: Amount of stake to transfer in RAO - :param block_hash: Optional block hash at which to perform the calculation - - :return: The calculated stake fee as a Balance object - - When to use None: - - 1. Adding new stake (default fee): - - origin_hotkey_ss58 = None - - origin_netuid = None - - All other fields required - - 2. Removing stake (default fee): - - destination_hotkey_ss58 = None - - destination_netuid = None - - All other fields required - - For all other operations, no None values - provide all parameters: - 3. Moving between subnets - 4. Moving between hotkeys - 5. Moving between coldkeys - """ - - if origin_netuid is None: - origin_netuid = 0 - if destination_netuid is None: - destination_netuid = 0 - - fee_rate, mechanism = await asyncio.gather( - self.query("Swap", "FeeRate", [origin_netuid]), - self.query("SubtensorModule", "SubnetMechanism", [destination_netuid]), - ) - if mechanism == 0: - # Stake Swap Fee is only charged if subnet mechanism is not 0 (stable), otherwise it is dynamic - fee = Balance(0).set_unit(origin_netuid) - return fee - else: - fee = amount * (fee_rate / U16_MAX) - - result = Balance.from_rao(fee) - result.set_unit(origin_netuid) - - return result - async def get_scheduled_coldkey_swap( self, block_hash: Optional[str] = None, From 9f49b1e19f64a012f395b97baddacda82128244f Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Fri, 12 Sep 2025 14:44:17 -0700 Subject: [PATCH 10/15] handles encrypted hotkeys --- bittensor_cli/src/commands/wallets.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bittensor_cli/src/commands/wallets.py b/bittensor_cli/src/commands/wallets.py index 7b90641fa..391fe2cf2 100644 --- a/bittensor_cli/src/commands/wallets.py +++ b/bittensor_cli/src/commands/wallets.py @@ -852,6 +852,9 @@ async def wallet_list(wallet_path: str, json_output: bool): except KeyFileError: hkey_ss58 = hkey.get_hotkeypub().ss58_address pub_only = True + except AttributeError: + hkey_ss58 = hkey.hotkey.ss58_address + pub_only = False try: data = ( f"[bold red]Hotkey[/bold red] [green]{hkey.hotkey_str}[/green] " From 3e07125b58b8aa673bac3b18d5c62664f35c9a2f Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Fri, 12 Sep 2025 15:44:47 -0700 Subject: [PATCH 11/15] coldkeypub is not a file --- bittensor_cli/src/commands/wallets.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bittensor_cli/src/commands/wallets.py b/bittensor_cli/src/commands/wallets.py index 391fe2cf2..1112d195d 100644 --- a/bittensor_cli/src/commands/wallets.py +++ b/bittensor_cli/src/commands/wallets.py @@ -823,6 +823,7 @@ async def wallet_list(wallet_path: str, json_output: bool): for wallet in wallets: if ( wallet.coldkeypub_file.exists_on_device() + and os.path.isfile(wallet.coldkeypub_file.path) and not wallet.coldkeypub_file.is_encrypted() ): coldkeypub_str = wallet.coldkeypub.ss58_address From 66fef1d5abfe131fa51507784675d97b2bca51a7 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Fri, 12 Sep 2025 15:45:27 -0700 Subject: [PATCH 12/15] 'hotkey' is not a directory --- bittensor_cli/src/bittensor/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bittensor_cli/src/bittensor/utils.py b/bittensor_cli/src/bittensor/utils.py index 1497470a9..d2a6bed83 100644 --- a/bittensor_cli/src/bittensor/utils.py +++ b/bittensor_cli/src/bittensor/utils.py @@ -266,7 +266,7 @@ def get_hotkey_wallets_for_wallet( hotkeys_path = wallet_path / wallet.name / "hotkeys" try: hotkeys = [entry.name for entry in hotkeys_path.iterdir()] - except FileNotFoundError: + except (FileNotFoundError, NotADirectoryError): hotkeys = [] for h_name in hotkeys: if h_name.endswith("pub.txt"): From 8e8efadeb635904556b798e40ce965b922659fed Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Fri, 12 Sep 2025 15:46:17 -0700 Subject: [PATCH 13/15] individual hotkey file is malformed --- bittensor_cli/src/bittensor/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bittensor_cli/src/bittensor/utils.py b/bittensor_cli/src/bittensor/utils.py index d2a6bed83..80aab6916 100644 --- a/bittensor_cli/src/bittensor/utils.py +++ b/bittensor_cli/src/bittensor/utils.py @@ -307,6 +307,7 @@ def get_hotkey_wallets_for_wallet( AttributeError, TypeError, KeyFileError, + ValueError, ): # usually an unrelated file like .DS_Store continue From 6741ebcbde39bc340c428a52e3b4eff295936a13 Mon Sep 17 00:00:00 2001 From: bdhimes Date: Tue, 16 Sep 2025 16:38:28 +0200 Subject: [PATCH 14/15] `min_burn` now not root sudo only --- bittensor_cli/src/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bittensor_cli/src/__init__.py b/bittensor_cli/src/__init__.py index 7160cbe25..ba96fe488 100644 --- a/bittensor_cli/src/__init__.py +++ b/bittensor_cli/src/__init__.py @@ -641,7 +641,7 @@ class WalletValidationTypes(Enum): "adjustment_interval": ("sudo_set_adjustment_interval", True), "activity_cutoff": ("sudo_set_activity_cutoff", False), "target_regs_per_interval": ("sudo_set_target_registrations_per_interval", True), - "min_burn": ("sudo_set_min_burn", True), + "min_burn": ("sudo_set_min_burn", False), "max_burn": ("sudo_set_max_burn", True), "bonds_moving_avg": ("sudo_set_bonds_moving_average", False), "max_regs_per_block": ("sudo_set_max_registrations_per_block", True), From 56068750f7bb1aff02fa276387b03b3bebba32b1 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 16 Sep 2025 12:54:17 -0700 Subject: [PATCH 15/15] bumps version and updates changelog --- CHANGELOG.md | 11 +++++++++++ pyproject.toml | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 598bba947..56ea625bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 9.11.1 /2025-09-16 + +* Transfer not staking warning by @thewhaleking in https://github.com/opentensor/btcli/pull/618 +* update e2e tests for hyperparam freeze window by @thewhaleking in https://github.com/opentensor/btcli/pull/620 +* Corrects the stake fee calculation by @thewhaleking in https://github.com/opentensor/btcli/pull/621 +* Fix: Handle encrypted wallet hotkeys by @ibraheem-abe in https://github.com/opentensor/btcli/pull/622 +* Fix: Handle malformed wallets/files by @ibraheem-abe in https://github.com/opentensor/btcli/pull/623 +* `min_burn` now not root sudo only by @thewhaleking in https://github.com/opentensor/btcli/pull/624 + +**Full Changelog**: https://github.com/opentensor/btcli/compare/v9.11.0...v9.11.1 + ## 9.11.0 /2025-09-05 * Better arg naming + type annotations by @thewhaleking in https://github.com/opentensor/btcli/pull/590 * disk cache in config by @thewhaleking in https://github.com/opentensor/btcli/pull/588 diff --git a/pyproject.toml b/pyproject.toml index 651cdaf9c..56814fe27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "bittensor-cli" -version = "9.11.0" +version = "9.11.1" description = "Bittensor CLI" readme = "README.md" authors = [