diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index acb5fa870..dba786369 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -1435,3 +1435,69 @@ async def get_owned_hotkeys( ) return [decode_account_id(hotkey[0]) for hotkey in owned_hotkeys or []] + + 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 + """ + + origin = None + if origin_hotkey_ss58 is not None and origin_netuid is not None: + origin = (origin_hotkey_ss58, origin_netuid) + + destination = None + if destination_hotkey_ss58 is not None and destination_netuid is not None: + destination = (destination_hotkey_ss58, destination_netuid) + + result = await self.query_runtime_api( + runtime_api="StakeInfoRuntimeApi", + method="get_stake_fee", + params=[ + origin, + origin_coldkey_ss58, + destination, + destination_coldkey_ss58, + amount, + ], + block_hash=block_hash, + ) + + return Balance.from_rao(result) diff --git a/bittensor_cli/src/commands/stake/add.py b/bittensor_cli/src/commands/stake/add.py index 6d156f522..2451d51b2 100644 --- a/bittensor_cli/src/commands/stake/add.py +++ b/bittensor_cli/src/commands/stake/add.py @@ -282,10 +282,24 @@ async def stake_extrinsic( return False remaining_wallet_balance -= amount_to_stake - # Calculate slippage - received_amount, slippage_pct, slippage_pct_float, rate = ( - _calculate_slippage(subnet_info, amount_to_stake) + 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 + try: + received_amount, slippage_pct, slippage_pct_float, rate = ( + _calculate_slippage(subnet_info, amount_to_stake, stake_fee) + ) + except ValueError: + return False + max_slippage = max(slippage_pct_float, max_slippage) # Add rows for the table @@ -296,6 +310,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(slippage_pct), # slippage ] @@ -531,6 +546,11 @@ def _define_stake_table( justify="center", style=COLOR_PALETTE["POOLS"]["TAO_EQUIV"], ) + table.add_column( + "Fee (τ)", + justify="center", + style=COLOR_PALETTE["STAKE"]["STAKE_AMOUNT"], + ) table.add_column( "Slippage", justify="center", style=COLOR_PALETTE["STAKE"]["SLIPPAGE_PERCENT"] ) @@ -585,29 +605,41 @@ def _print_table_and_slippage(table: Table, max_slippage: float, safe_staking: b def _calculate_slippage( - subnet_info, amount: Balance + subnet_info, amount: Balance, stake_fee: Balance ) -> tuple[Balance, str, float, str]: """Calculate slippage when adding stake. Args: subnet_info: Subnet dynamic info amount: Amount being staked + stake_fee: Transaction fee for the stake operation Returns: tuple containing: - - received_amount: Amount received after slippage + - received_amount: Amount received after slippage and fees - slippage_str: Formatted slippage percentage string - slippage_float: Raw slippage percentage value + - rate: Exchange rate string """ - received_amount, _, slippage_pct_float = subnet_info.tao_to_alpha_with_slippage( - amount - ) + amount_after_fee = amount - stake_fee + + if amount_after_fee < 0: + print_error("You don't have enough balance to cover the stake fee.") + raise ValueError() + + received_amount, _, _ = subnet_info.tao_to_alpha_with_slippage(amount_after_fee) + if subnet_info.is_dynamic: + ideal_amount = subnet_info.tao_to_alpha(amount) + total_slippage = ideal_amount - received_amount + slippage_pct_float = 100 * (total_slippage.tao / ideal_amount.tao) slippage_str = f"{slippage_pct_float:.4f} %" rate = f"{(1 / subnet_info.price.tao or 1):.4f}" else: - slippage_pct_float = 0 - slippage_str = f"[{COLOR_PALETTE['STAKE']['SLIPPAGE_TEXT']}]N/A[/{COLOR_PALETTE['STAKE']['SLIPPAGE_TEXT']}]" + slippage_pct_float = ( + 100 * float(stake_fee.tao) / float(amount.tao) if amount.tao != 0 else 0 + ) + slippage_str = f"{slippage_pct_float:.4f} %" rate = "1" return received_amount, slippage_str, slippage_pct_float, rate diff --git a/bittensor_cli/src/commands/stake/move.py b/bittensor_cli/src/commands/stake/move.py index 70afaa453..403dc9668 100644 --- a/bittensor_cli/src/commands/stake/move.py +++ b/bittensor_cli/src/commands/stake/move.py @@ -32,13 +32,14 @@ async def display_stake_movement_cross_subnets( origin_hotkey: str, destination_hotkey: str, amount_to_move: Balance, + stake_fee: Balance, ) -> tuple[Balance, float, str, str]: """Calculate and display slippage information""" if origin_netuid == destination_netuid: subnet = await subtensor.subnet(origin_netuid) received_amount_tao = subnet.alpha_to_tao(amount_to_move) - received_amount_tao -= MIN_STAKE_FEE + received_amount_tao -= stake_fee if received_amount_tao < Balance.from_tao(0): print_error("Not enough Alpha to pay the transaction fee.") @@ -46,7 +47,7 @@ async def display_stake_movement_cross_subnets( received_amount = subnet.tao_to_alpha(received_amount_tao) slippage_pct_float = ( - 100 * float(MIN_STAKE_FEE) / float(MIN_STAKE_FEE + received_amount_tao) + 100 * float(stake_fee) / float(stake_fee + received_amount_tao) if received_amount_tao != 0 else 0 ) @@ -67,7 +68,7 @@ async def display_stake_movement_cross_subnets( received_amount_tao, _, _ = dynamic_origin.alpha_to_tao_with_slippage( amount_to_move ) - received_amount_tao -= MIN_STAKE_FEE + received_amount_tao -= stake_fee received_amount, _, _ = dynamic_destination.tao_to_alpha_with_slippage( received_amount_tao ) @@ -135,6 +136,11 @@ async def display_stake_movement_cross_subnets( justify="center", style=COLOR_PALETTE["POOLS"]["TAO_EQUIV"], ) + table.add_column( + "Fee (τ)", + justify="center", + style=COLOR_PALETTE["STAKE"]["STAKE_AMOUNT"], + ) table.add_column( "slippage", justify="center", @@ -149,13 +155,11 @@ async def display_stake_movement_cross_subnets( str(amount_to_move), price_str, str(received_amount), + str(stake_fee), str(slippage_pct), ) console.print(table) - # console.print( - # f"[dim]A fee of {MIN_STAKE_FEE} applies.[/dim]" - # ) # Display slippage warning if necessary if slippage_pct_float > 5: @@ -513,6 +517,16 @@ async def move_stake( ) return False + stake_fee = await subtensor.get_stake_fee( + origin_hotkey_ss58=origin_hotkey, + 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, + ) + # Slippage warning if prompt: try: @@ -523,6 +537,7 @@ async def move_stake( origin_hotkey=origin_hotkey, destination_hotkey=destination_hotkey, amount_to_move=amount_to_move_as_balance, + stake_fee=stake_fee, ) except ValueError: return False @@ -686,6 +701,16 @@ async def transfer_stake( ) return False + stake_fee = await subtensor.get_stake_fee( + origin_hotkey_ss58=origin_hotkey, + 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, + ) + # Slippage warning if prompt: try: @@ -696,6 +721,7 @@ async def transfer_stake( origin_hotkey=origin_hotkey, destination_hotkey=origin_hotkey, amount_to_move=amount_to_transfer, + stake_fee=stake_fee, ) except ValueError: return False @@ -844,6 +870,16 @@ async def swap_stake( ) return False + stake_fee = await subtensor.get_stake_fee( + origin_hotkey_ss58=hotkey_ss58, + 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, + ) + # Slippage warning if prompt: try: @@ -854,6 +890,7 @@ async def swap_stake( origin_hotkey=hotkey_ss58, destination_hotkey=hotkey_ss58, amount_to_move=amount_to_swap, + stake_fee=stake_fee, ) except ValueError: return False diff --git a/bittensor_cli/src/commands/stake/remove.py b/bittensor_cli/src/commands/stake/remove.py index 07718e405..898af9cf8 100644 --- a/bittensor_cli/src/commands/stake/remove.py +++ b/bittensor_cli/src/commands/stake/remove.py @@ -194,9 +194,25 @@ async def unstake( ) continue # Skip to the next subnet - useful when single amount is specified for all subnets - received_amount, slippage_pct, slippage_pct_float = _calculate_slippage( - subnet_info=subnet_info, amount=amount_to_unstake_as_balance + 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: + received_amount, slippage_pct, slippage_pct_float = _calculate_slippage( + subnet_info=subnet_info, + amount=amount_to_unstake_as_balance, + stake_fee=stake_fee, + ) + except ValueError: + continue + total_received_amount += received_amount max_float_slippage = max(max_float_slippage, slippage_pct_float) @@ -220,6 +236,7 @@ async def unstake( str(amount_to_unstake_as_balance), # Amount to Unstake str(subnet_info.price.tao) + f"({Balance.get_unit(0)}/{Balance.get_unit(netuid)})", # Rate + str(stake_fee), # Fee str(received_amount), # Received Amount slippage_pct, # Slippage Percent ] @@ -411,6 +428,11 @@ async def unstake_all( justify="center", style=COLOR_PALETTE["POOLS"]["RATE"], ) + table.add_column( + f"Fee ({Balance.unit})", + justify="center", + style=COLOR_PALETTE["STAKE"]["STAKE_AMOUNT"], + ) table.add_column( f"Recieved ({Balance.unit})", justify="center", @@ -432,9 +454,22 @@ 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 - received_amount, slippage_pct, slippage_pct_float = _calculate_slippage( - subnet_info=subnet_info, amount=stake_amount + 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: + received_amount, slippage_pct, slippage_pct_float = _calculate_slippage( + subnet_info=subnet_info, amount=stake_amount, stake_fee=stake_fee + ) + except ValueError: + continue + max_slippage = max(max_slippage, slippage_pct_float) total_received_value += received_amount @@ -444,6 +479,7 @@ async def unstake_all( str(stake_amount), str(float(subnet_info.price)) + f"({Balance.get_unit(0)}/{Balance.get_unit(stake.netuid)})", + str(stake_fee), str(received_amount), slippage_pct, ) @@ -791,28 +827,47 @@ async def _unstake_all_extrinsic( # Helpers -def _calculate_slippage(subnet_info, amount: Balance) -> tuple[Balance, str, float]: +def _calculate_slippage( + subnet_info, amount: Balance, stake_fee: Balance +) -> tuple[Balance, str, float]: """Calculate slippage and received amount for unstaking operation. Args: subnet_info: Subnet information containing price data amount: Amount being unstaked + stake_fee: Stake fee to include in slippage calculation Returns: tuple containing: - - received_amount: Balance after slippage + - received_amount: Balance after slippage deduction - slippage_pct: Formatted string of slippage percentage - slippage_pct_float: Float value of slippage percentage """ - received_amount, _, slippage_pct_float = subnet_info.alpha_to_tao_with_slippage( - amount - ) + received_amount, _, _ = subnet_info.alpha_to_tao_with_slippage(amount) + received_amount -= stake_fee + + if received_amount < Balance.from_tao(0): + print_error("Not enough Alpha to pay the transaction fee.") + raise ValueError if subnet_info.is_dynamic: + # Ideal amount w/o slippage + ideal_amount = subnet_info.alpha_to_tao(amount) + + # Total slippage including fees + total_slippage = ideal_amount - received_amount + slippage_pct_float = ( + 100 * (float(total_slippage.tao) / float(ideal_amount.tao)) + if ideal_amount.tao != 0 + else 0 + ) slippage_pct = f"{slippage_pct_float:.4f} %" else: - slippage_pct_float = 0 - slippage_pct = "[red]N/A[/red]" + # Root will only have fee-based slippage + slippage_pct_float = ( + 100 * float(stake_fee.tao) / float(amount.tao) if amount.tao != 0 else 0 + ) + slippage_pct = f"{slippage_pct_float:.4f} %" return received_amount, slippage_pct, slippage_pct_float @@ -1174,6 +1229,11 @@ def _create_unstake_table( justify="center", style=COLOR_PALETTE["POOLS"]["RATE"], ) + table.add_column( + f"Fee ({Balance.get_unit(0)})", + justify="center", + style=COLOR_PALETTE["STAKE"]["STAKE_AMOUNT"], + ) table.add_column( f"Received ({Balance.get_unit(0)})", justify="center", @@ -1227,7 +1287,8 @@ def _print_table_and_slippage( - [bold white]Hotkey[/bold white]: The ss58 address or identity of the hotkey you are unstaking from. - [bold white]Amount to Unstake[/bold white]: The stake amount you are removing from this key. - [bold white]Rate[/bold white]: The rate of exchange between TAO and the subnet's stake. - - [bold white]Received[/bold white]: The amount of free balance TAO you will receive on this subnet after slippage. + - [bold white]Fee[/bold white]: The transaction fee for this unstake operation. + - [bold white]Received[/bold white]: The amount of free balance TAO you will receive on this subnet after slippage and fees. - [bold white]Slippage[/bold white]: The slippage percentage of the unstake operation. (0% if the subnet is not dynamic i.e. root).""" safe_staking_description = """ diff --git a/pyproject.toml b/pyproject.toml index d7e557dae..4c9f3d3b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ requires-python = ">=3.9,<3.13" dependencies = [ "wheel", "async-property==0.2.2", - "async-substrate-interface>=1.0.7", + "async-substrate-interface>=1.0.8", "aiohttp~=3.10.2", "backoff~=2.2.1", "GitPython>=3.0.0",