From 45b405316b8ce5f10783cdc9e6418e59528f45bc Mon Sep 17 00:00:00 2001 From: Benjamin Himes Date: Wed, 23 Jul 2025 19:57:06 +0200 Subject: [PATCH 01/12] Improved speed of query_all_identities and fetch_coldkey_hotkey_identities --- .../src/bittensor/subtensor_interface.py | 30 ++++++++++--------- pyproject.toml | 2 +- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index cbe12e9c1..4151910ec 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -3,6 +3,7 @@ from typing import Optional, Any, Union, TypedDict, Iterable import aiohttp +from async_substrate_interface.utils.storage import StorageKey from bittensor_wallet import Wallet from bittensor_wallet.utils import SS58_FORMAT from scalecodec import GenericCall @@ -881,9 +882,10 @@ async def query_all_identities( storage_function="IdentitiesV2", block_hash=block_hash, reuse_block_hash=reuse_block, + fully_exhaust=True, ) all_identities = {} - async for ss58_address, identity in identities: + for ss58_address, identity in identities.records: all_identities[decode_account_id(ss58_address[0])] = decode_hex_identity( identity.value ) @@ -939,22 +941,22 @@ async def fetch_coldkey_hotkey_identities( :param reuse_block: Whether to reuse the last-used blockchain block hash. :return: Dict with 'coldkeys' and 'hotkeys' as keys. """ - - coldkey_identities = await self.query_all_identities() + if block_hash is None: + block_hash = await self.substrate.get_chain_head() + coldkey_identities = await self.query_all_identities(block_hash=block_hash) identities = {"coldkeys": {}, "hotkeys": {}} - if not coldkey_identities: - return identities - query = await self.substrate.query_multiple( # TODO probably more efficient to do this with query_multi - params=list(coldkey_identities.keys()), - module="SubtensorModule", - storage_function="OwnedHotkeys", - block_hash=block_hash, - reuse_block_hash=reuse_block, - ) + sks = [ + await self.substrate.create_storage_key( + "SubtensorModule", "OwnedHotkeys", [ck], block_hash=block_hash + ) + for ck in coldkey_identities.keys() + ] + query = await self.substrate.query_multi(sks, block_hash=block_hash) - for coldkey_ss58, hotkeys in query.items(): + storage_key: StorageKey + for storage_key, hotkeys in query: + coldkey_ss58 = storage_key.params[0] coldkey_identity = coldkey_identities.get(coldkey_ss58) - hotkeys = [decode_account_id(hotkey[0]) for hotkey in hotkeys or []] identities["coldkeys"][coldkey_ss58] = { "identity": coldkey_identity, diff --git a/pyproject.toml b/pyproject.toml index c4b709ffc..2c46094f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ scripts = { btcli = "bittensor_cli.cli:main" } requires-python = ">=3.9,<3.14" dependencies = [ "wheel", - "async-substrate-interface>=1.1.0", + "async-substrate-interface>=1.4.2", "aiohttp~=3.10.2", "backoff~=2.2.1", "click<8.2.0", # typer.testing.CliRunner(mix_stderr=) is broken in click 8.2.0+ From 07b226022968a3733441899e49d082c100f3d90f Mon Sep 17 00:00:00 2001 From: ibraheem-opentensor Date: Wed, 23 Jul 2025 16:56:56 -0700 Subject: [PATCH 02/12] Adds wallet verify --- bittensor_cli/src/commands/wallets.py | 92 ++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 3 deletions(-) diff --git a/bittensor_cli/src/commands/wallets.py b/bittensor_cli/src/commands/wallets.py index 99842ef98..2947a6a0a 100644 --- a/bittensor_cli/src/commands/wallets.py +++ b/bittensor_cli/src/commands/wallets.py @@ -1800,10 +1800,96 @@ async def sign( ) signed_message = keypair.sign(message.encode("utf-8")).hex() - console.print("[dark_sea_green3]Message signed successfully:") + signer_address = keypair.ss58_address + console.print("[dark_sea_green3]Message signed successfully!\n") + if json_output: - json_console.print(json.dumps({"signed_message": signed_message})) - console.print(signed_message) + json_console.print( + json.dumps( + {"signed_message": signed_message, "signer_address": signer_address} + ) + ) + else: + console.print(f"[yellow]Signature:[/yellow]\n{signed_message}") + console.print(f"[yellow]Signer address:[/yellow] {signer_address}") + + +async def verify( + message: str, + signature: str, + public_key_or_ss58: str, + json_output: bool = False, +): + """Verify a message signature using a public key or SS58 address.""" + + if is_valid_ss58_address(public_key_or_ss58): + print_verbose(f"[blue]SS58 address detected:[/blue] {public_key_or_ss58}") + keypair = Keypair(ss58_address=public_key_or_ss58) + signer_address = public_key_or_ss58 + else: + try: + public_key_hex = public_key_or_ss58.strip().lower() + if public_key_hex.startswith("0x"): + public_key_hex = public_key_hex[2:] + if len(public_key_hex) == 64: + bytes.fromhex(public_key_hex) + print_verbose("[blue]Hex public key detected[/blue] (64 characters)") + keypair = Keypair(public_key=public_key_hex) + signer_address = keypair.ss58_address + print_verbose( + f"[blue]Corresponding SS58 address:[/blue] {signer_address}" + ) + else: + raise ValueError("Public key must be 32 bytes (64 hex characters)") + + except (ValueError, TypeError) as e: + if json_output: + json_console.print( + json.dumps( + { + "verified": False, + "error": f"Invalid public key or SS58 address: {str(e)}", + } + ) + ) + else: + err_console.print( + f":cross_mark: Invalid SS58 address or hex public key (64 chars, with or without 0x prefix)- {str(e)}" + ) + return False + + try: + signature_bytes = bytes.fromhex(signature.strip().lower().replace("0x", "")) + except ValueError as e: + if json_output: + json_console.print( + json.dumps( + { + "verified": False, + "error": f"Invalid signature format: {str(e)}", + } + ) + ) + else: + err_console.print(f"[red]:cross_mark: Invalid signature format: {str(e)}") + return False + + is_valid = keypair.verify(message.encode("utf-8"), signature_bytes) + + if json_output: + json_console.print( + json.dumps( + {"verified": is_valid, "signer": signer_address, "message": message} + ) + ) + else: + if is_valid: + console.print("[dark_sea_green3]Signature is valid!\n") + console.print(f"[yellow]Signer:[/yellow] {signer_address}") + else: + err_console.print(":cross_mark: [red]Signature verification failed!") + + return is_valid async def schedule_coldkey_swap( From 0b9499b700f6270952ec150a67dd9130ace4bbfd Mon Sep 17 00:00:00 2001 From: ibraheem-opentensor Date: Wed, 23 Jul 2025 16:57:07 -0700 Subject: [PATCH 03/12] add cli cmd --- bittensor_cli/cli.py | 56 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index d751e06ad..de3cecec3 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -817,6 +817,9 @@ def __init__(self): self.wallet_app.command( "sign", rich_help_panel=HELP_PANELS["WALLET"]["OPERATIONS"] )(self.wallet_sign) + self.wallet_app.command( + "verify", rich_help_panel=HELP_PANELS["WALLET"]["OPERATIONS"] + )(self.wallet_verify) # stake commands self.stake_app.command( @@ -3091,6 +3094,59 @@ def wallet_sign( return self._run_command(wallets.sign(wallet, message, use_hotkey, json_output)) + def wallet_verify( + self, + message: Optional[str] = typer.Option( + None, "--message", "-m", help="The message that was signed" + ), + signature: Optional[str] = typer.Option( + None, "--signature", "-s", help="The signature to verify (hex format)" + ), + public_key_or_ss58: Optional[str] = typer.Option( + None, + "--address", + "-a", + "--public-key", + "-p", + help="SS58 address or public key (hex) of the signer", + ), + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """ + Verify a message signature using the signer's public key or SS58 address. + + This command allows you to verify that a message was signed by the owner of a specific address. + + USAGE + + Provide the original message, the signature (in hex format), and either the SS58 address + or public key of the signer to verify the signature. + + EXAMPLES + + [green]$[/green] btcli wallet verify --message "Hello world" --signature "0xabc123..." --address "5GrwvaEF..." + + [green]$[/green] btcli wallet verify -m "Test message" -s "0xdef456..." -p "0x1234abcd..." + """ + self.verbosity_handler(quiet, verbose, json_output) + + if not public_key_or_ss58: + public_key_or_ss58 = Prompt.ask( + "Enter the [blue]address[/blue] (SS58 or hex format)" + ) + + if not message: + message = Prompt.ask("Enter the [blue]message[/blue]") + + if not signature: + signature = Prompt.ask("Enter the [blue]signature[/blue]") + + return self._run_command( + wallets.verify(message, signature, public_key_or_ss58, json_output) + ) + def wallet_swap_coldkey( self, wallet_name: Optional[str] = Options.wallet_name, From fb226f4c524b8eb8419333fc6bcea846271c74ce Mon Sep 17 00:00:00 2001 From: Benjamin Himes Date: Thu, 24 Jul 2025 20:54:57 +0200 Subject: [PATCH 04/12] Adds a new option to `btcli w transfer`: `--allow-death`, which allows death of the account (below the existential deposit) if specified. --- bittensor_cli/cli.py | 9 +++- .../src/bittensor/extrinsics/transfer.py | 52 +++++++++++++------ .../src/bittensor/subtensor_interface.py | 2 + bittensor_cli/src/commands/wallets.py | 2 + 4 files changed, 47 insertions(+), 18 deletions(-) diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index d751e06ad..3c92fc24f 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -1889,6 +1889,12 @@ def wallet_transfer( transfer_all: bool = typer.Option( False, "--all", prompt=False, help="Transfer all available balance." ), + allow_death: bool = typer.Option( + False, + "--allow-death", + prompt=False, + help="Transfer balance even if the resulting balance falls below the existential deposit.", + ), period: int = Options.period, wallet_name: str = Options.wallet_name, wallet_path: str = Options.wallet_path, @@ -1932,7 +1938,7 @@ def wallet_transfer( subtensor = self.initialize_chain(network) if transfer_all and amount: print_error("Cannot specify an amount and '--all' flag.") - raise typer.Exit() + return False elif transfer_all: amount = 0 elif not amount: @@ -1944,6 +1950,7 @@ def wallet_transfer( destination=destination_ss58_address, amount=amount, transfer_all=transfer_all, + allow_death=allow_death, era=period, prompt=prompt, json_output=json_output, diff --git a/bittensor_cli/src/bittensor/extrinsics/transfer.py b/bittensor_cli/src/bittensor/extrinsics/transfer.py index a720588bd..6507846e8 100644 --- a/bittensor_cli/src/bittensor/extrinsics/transfer.py +++ b/bittensor_cli/src/bittensor/extrinsics/transfer.py @@ -26,9 +26,9 @@ async def transfer_extrinsic( amount: Balance, era: int = 3, transfer_all: bool = False, + allow_death: bool = False, wait_for_inclusion: bool = True, wait_for_finalization: bool = False, - keep_alive: bool = True, prompt: bool = False, ) -> bool: """Transfers funds from this wallet to the destination public key address. @@ -39,11 +39,11 @@ async def transfer_extrinsic( :param amount: Amount to stake as Bittensor balance. :param era: Length (in blocks) for which the transaction should be valid. :param transfer_all: Whether to transfer all funds from this wallet to the destination address. + :param allow_death: Whether to allow for falling below the existential deposit when performing this transfer. :param wait_for_inclusion: If set, waits for the extrinsic to enter a block before returning `True`, or returns `False` if the extrinsic fails to enter the block within the timeout. :param wait_for_finalization: If set, waits for the extrinsic to be finalized on the chain before returning `True`, or returns `False` if the extrinsic fails to be finalized within the timeout. - :param keep_alive: If set, keeps the account alive by keeping the balance above the existential deposit. :param prompt: If `True`, the call waits for confirmation from the user before proceeding. :return: success: Flag is `True` if extrinsic was finalized or included in the block. If we did not wait for finalization / inclusion, the response is `True`, regardless of its inclusion. @@ -57,8 +57,8 @@ async def get_transfer_fee() -> Balance: """ call = await subtensor.substrate.compose_call( call_module="Balances", - call_function="transfer_keep_alive", - call_params={"dest": destination, "value": amount.rao}, + call_function=call_function, + call_params=call_params, ) try: @@ -82,8 +82,8 @@ async def do_transfer() -> tuple[bool, str, str]: """ call = await subtensor.substrate.compose_call( call_module="Balances", - call_function="transfer_keep_alive", - call_params={"dest": destination, "value": amount.rao}, + call_function=call_function, + call_params=call_params, ) extrinsic = await subtensor.substrate.create_signed_extrinsic( call=call, keypair=wallet.coldkey, era={"period": era} @@ -102,6 +102,7 @@ async def do_transfer() -> tuple[bool, str, str]: block_hash_ = response.block_hash return True, block_hash_, "" else: + print(response) return False, "", format_error_message(await response.error_message) # Validate destination address. @@ -115,6 +116,20 @@ async def do_transfer() -> tuple[bool, str, str]: if not unlock_key(wallet).success: return False + call_params = {"dest": destination} + if transfer_all: + call_function = "transfer_all" + if allow_death: + call_params["keep_alive"] = False + else: + call_params["keep_alive"] = True + else: + call_params["value"] = amount.rao + if allow_death: + call_function = "transfer_allow_death" + else: + call_function = "transfer_keep_alive" + # Check balance. with console.status( f":satellite: Checking balance and fees on chain [white]{subtensor.network}[/white]", @@ -131,23 +146,26 @@ async def do_transfer() -> tuple[bool, str, str]: ) fee = await get_transfer_fee() - if not keep_alive: - # Check if the transfer should keep_alive the account + if allow_death: + # Check if the transfer should keep alive the account existential_deposit = Balance(0) - # Check if we have enough balance. - if transfer_all is True: - amount = account_balance - fee - existential_deposit - if amount < Balance(0): - print_error("Not enough balance to transfer") - return False - - if account_balance < (amount + fee + existential_deposit): + if account_balance < (amount + fee + existential_deposit) and not allow_death: err_console.print( ":cross_mark: [bold red]Not enough balance[/bold red]:\n\n" f" balance: [bright_cyan]{account_balance}[/bright_cyan]\n" f" amount: [bright_cyan]{amount}[/bright_cyan]\n" - f" for fee: [bright_cyan]{fee}[/bright_cyan]" + f" for fee: [bright_cyan]{fee}[/bright_cyan]\n" + f" would bring you under the existential deposit: [bright_cyan]{existential_deposit}[/bright_cyan].\n" + f"You can try again with `--allow-death`." + ) + return False + elif account_balance < (amount + fee) and allow_death: + print_error( + ":cross_mark: [bold red]Not enough balance[/bold red]:\n\n" + f" balance: [bright_red]{account_balance}[/bright_red]\n" + f" amount: [bright_red]{amount}[/bright_red]\n" + f" for fee: [bright_red]{fee}[/bright_red]" ) return False diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index 4151910ec..2f117f147 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -1457,6 +1457,8 @@ async def subnet( ), self.get_subnet_price(netuid=netuid, block_hash=block_hash), ) + if not result: + raise ValueError(f"Subnet {netuid} not found") subnet_ = DynamicInfo.from_any(result) subnet_.price = price return subnet_ diff --git a/bittensor_cli/src/commands/wallets.py b/bittensor_cli/src/commands/wallets.py index 99842ef98..75a6f483e 100644 --- a/bittensor_cli/src/commands/wallets.py +++ b/bittensor_cli/src/commands/wallets.py @@ -1408,6 +1408,7 @@ async def transfer( destination: str, amount: float, transfer_all: bool, + allow_death: bool, era: int, prompt: bool, json_output: bool, @@ -1419,6 +1420,7 @@ async def transfer( destination=destination, amount=Balance.from_tao(amount), transfer_all=transfer_all, + allow_death=allow_death, era=era, prompt=prompt, ) From be1898651e2cdf45924fe6ee0feaed775e63e0bb Mon Sep 17 00:00:00 2001 From: Benjamin Himes Date: Thu, 24 Jul 2025 21:35:28 +0200 Subject: [PATCH 05/12] Removed debug --- bittensor_cli/src/bittensor/extrinsics/transfer.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bittensor_cli/src/bittensor/extrinsics/transfer.py b/bittensor_cli/src/bittensor/extrinsics/transfer.py index 6507846e8..5302a33d0 100644 --- a/bittensor_cli/src/bittensor/extrinsics/transfer.py +++ b/bittensor_cli/src/bittensor/extrinsics/transfer.py @@ -102,7 +102,6 @@ async def do_transfer() -> tuple[bool, str, str]: block_hash_ = response.block_hash return True, block_hash_, "" else: - print(response) return False, "", format_error_message(await response.error_message) # Validate destination address. From efddf0873213d85a80c332c8a1f31f0517e41137 Mon Sep 17 00:00:00 2001 From: Benjamin Himes Date: Mon, 28 Jul 2025 18:19:40 +0200 Subject: [PATCH 06/12] Add extrinsic fee to stake add --- .../src/bittensor/subtensor_interface.py | 4 ++ bittensor_cli/src/commands/stake/add.py | 53 +++++++++++++++++-- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index 2f117f147..e3312be4b 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -1488,6 +1488,10 @@ async def get_owned_hotkeys( return [decode_account_id(hotkey[0]) for hotkey in owned_hotkeys or []] + async def get_extrinsic_fee(self, call, keypair) -> Balance: + fee_dict = await self.substrate.get_payment_info(call, keypair) + return Balance.from_rao(fee_dict["partial_fee"]) + async def get_stake_fee( self, origin_hotkey_ss58: Optional[str], diff --git a/bittensor_cli/src/commands/stake/add.py b/bittensor_cli/src/commands/stake/add.py index 45b17dfbf..69514c2d1 100644 --- a/bittensor_cli/src/commands/stake/add.py +++ b/bittensor_cli/src/commands/stake/add.py @@ -65,6 +65,33 @@ async def stake_add( bool: True if stake operation is successful, False otherwise """ + async def get_stake_extrinsic_fee( + netuid_: int, + amount_: Balance, + staking_address_: str, + safe_staking_: bool, + price_limit: Optional[Balance] = None, + ): + call_fn = "add_stake" if not safe_staking_ else "add_stake_limit" + call_params = { + "hotkey": staking_address_, + "netuid": netuid_, + "amount_staked": amount_.rao, + } + if safe_staking_: + call_params.update( + { + "limit_price": price_limit, + "allow_partial": allow_partial_stake, + } + ) + call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function=call_fn, + call_params=call_params, + ) + return await subtensor.get_extrinsic_fee(call, wallet.coldkeypub) + async def safe_stake_extrinsic( netuid_: int, amount_: Balance, @@ -87,7 +114,7 @@ async def safe_stake_extrinsic( "hotkey": hotkey_ss58_, "netuid": netuid_, "amount_staked": amount_.rao, - "limit_price": price_limit, + "limit_price": price_limit.rao, "allow_partial": allow_partial_stake, }, ), @@ -356,20 +383,35 @@ async def stake_extrinsic( rate_with_tolerance = f"{_rate_with_tolerance:.4f}" price_with_tolerance = Balance.from_tao( price_with_tolerance - ).rao # Actual price to pass to extrinsic + ) # Actual price to pass to extrinsic else: rate_with_tolerance = "1" price_with_tolerance = Balance.from_rao(1) + extrinsic_fee = await get_stake_extrinsic_fee( + netuid_=netuid, + amount_=amount_to_stake, + staking_address_=hotkey[1], + safe_staking_=safe_staking, + price_limit=price_with_tolerance, + ) prices_with_tolerance.append(price_with_tolerance) - base_row.extend( [ + str(extrinsic_fee), f"{rate_with_tolerance} {Balance.get_unit(netuid)}/{Balance.get_unit(0)} ", f"[{'dark_sea_green3' if allow_partial_stake else 'red'}]" # safe staking f"{allow_partial_stake}[/{'dark_sea_green3' if allow_partial_stake else 'red'}]", ] ) + else: + extrinsic_fee = await get_stake_extrinsic_fee( + netuid_=netuid, + amount_=amount_to_stake, + staking_address_=hotkey[1], + safe_staking_=safe_staking, + ) + base_row.append(str(extrinsic_fee)) rows.append(tuple(base_row)) @@ -588,6 +630,11 @@ def _define_stake_table( justify="center", style=COLOR_PALETTE["STAKE"]["STAKE_AMOUNT"], ) + table.add_column( + "Extrinsic Fee (τ)", + justify="center", + style=COLOR_PALETTE["STAKE"]["STAKE_AMOUNT"], + ) # TODO: Uncomment when slippage is reimplemented for v3 # table.add_column( # "Slippage", justify="center", style=COLOR_PALETTE["STAKE"]["SLIPPAGE_PERCENT"] From 66389e699317cdcd9b2918127ff7e4006da0ad83 Mon Sep 17 00:00:00 2001 From: Benjamin Himes Date: Mon, 28 Jul 2025 18:19:53 +0200 Subject: [PATCH 07/12] Correct received amount calculation in remove all --- bittensor_cli/src/commands/stake/remove.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bittensor_cli/src/commands/stake/remove.py b/bittensor_cli/src/commands/stake/remove.py index 5dbef5fe9..89ef310b1 100644 --- a/bittensor_cli/src/commands/stake/remove.py +++ b/bittensor_cli/src/commands/stake/remove.py @@ -468,7 +468,7 @@ async def unstake_all( try: current_price = subnet_info.price.tao rate = current_price - received_amount = stake_amount * rate - stake_fee + received_amount = (stake_amount - stake_fee) * rate if received_amount < Balance.from_tao(0): print_error("Not enough Alpha to pay the transaction fee.") From 990912f551f56027cffe10321cd5b17360907f8c Mon Sep 17 00:00:00 2001 From: Benjamin Himes Date: Mon, 28 Jul 2025 19:21:17 +0200 Subject: [PATCH 08/12] Update logic flow. --- bittensor_cli/src/commands/stake/add.py | 50 ++++++++++++------------- 1 file changed, 23 insertions(+), 27 deletions(-) diff --git a/bittensor_cli/src/commands/stake/add.py b/bittensor_cli/src/commands/stake/add.py index 69514c2d1..4710c7a42 100644 --- a/bittensor_cli/src/commands/stake/add.py +++ b/bittensor_cli/src/commands/stake/add.py @@ -359,19 +359,6 @@ async def stake_extrinsic( # Temporary workaround - calculations without slippage current_price_float = float(subnet_info.price.tao) rate = 1.0 / current_price_float - received_amount = rate * amount_to_stake - - # Add rows for the table - base_row = [ - str(netuid), # netuid - f"{hotkey[1]}", # hotkey - str(amount_to_stake), # amount - 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 - ] # If we are staking safe, add price tolerance if safe_staking: @@ -395,15 +382,12 @@ async def stake_extrinsic( price_limit=price_with_tolerance, ) prices_with_tolerance.append(price_with_tolerance) - base_row.extend( - [ - str(extrinsic_fee), - f"{rate_with_tolerance} {Balance.get_unit(netuid)}/{Balance.get_unit(0)} ", - f"[{'dark_sea_green3' if allow_partial_stake else 'red'}]" - # safe staking - f"{allow_partial_stake}[/{'dark_sea_green3' if allow_partial_stake else 'red'}]", - ] - ) + row_extension = [ + f"{rate_with_tolerance} {Balance.get_unit(netuid)}/{Balance.get_unit(0)} ", + f"[{'dark_sea_green3' if allow_partial_stake else 'red'}]" + # safe staking + f"{allow_partial_stake}[/{'dark_sea_green3' if allow_partial_stake else 'red'}]", + ] else: extrinsic_fee = await get_stake_extrinsic_fee( netuid_=netuid, @@ -411,8 +395,20 @@ async def stake_extrinsic( staking_address_=hotkey[1], safe_staking_=safe_staking, ) - base_row.append(str(extrinsic_fee)) - + row_extension = [] + received_amount = rate * (amount_to_stake - stake_fee - extrinsic_fee) + # Add rows for the table + base_row = [ + str(netuid), # netuid + f"{hotkey[1]}", # hotkey + str(amount_to_stake), # amount + str(rate) + + f" {Balance.get_unit(netuid)}/{Balance.get_unit(0)} ", # rate + str(received_amount.set_unit(netuid)), # received + str(stake_fee), # fee + str(extrinsic_fee), + # str(slippage_pct), # slippage + ] + row_extension rows.append(tuple(base_row)) # Define and print stake table + slippage warning @@ -611,17 +607,17 @@ def _define_stake_table( "Hotkey", justify="center", style=COLOR_PALETTE["GENERAL"]["HOTKEY"] ) table.add_column( - f"Amount ({Balance.get_unit(0)})", + "Amount (τ)", justify="center", style=COLOR_PALETTE["POOLS"]["TAO"], ) table.add_column( - f"Rate (per {Balance.get_unit(0)})", + "Rate (per τ)", justify="center", style=COLOR_PALETTE["POOLS"]["RATE"], ) table.add_column( - "Received", + "Est. Received", justify="center", style=COLOR_PALETTE["POOLS"]["TAO_EQUIV"], ) From 7d1589b52abc7e0e783a6cbbb78f2317a281dd51 Mon Sep 17 00:00:00 2001 From: Benjamin Himes Date: Mon, 28 Jul 2025 20:38:31 +0200 Subject: [PATCH 09/12] Stake movement --- .../src/bittensor/subtensor_interface.py | 3 +- bittensor_cli/src/commands/stake/add.py | 2 +- bittensor_cli/src/commands/stake/move.py | 182 ++++++++++-------- bittensor_cli/src/commands/stake/remove.py | 119 +++++++++++- 4 files changed, 217 insertions(+), 89 deletions(-) diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index e3312be4b..5143b3f1c 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -5,6 +5,7 @@ import aiohttp from async_substrate_interface.utils.storage import StorageKey from bittensor_wallet import Wallet +from bittensor_wallet.bittensor_wallet import Keypair from bittensor_wallet.utils import SS58_FORMAT from scalecodec import GenericCall from async_substrate_interface.errors import SubstrateRequestException @@ -1488,7 +1489,7 @@ async def get_owned_hotkeys( return [decode_account_id(hotkey[0]) for hotkey in owned_hotkeys or []] - async def get_extrinsic_fee(self, call, keypair) -> Balance: + async def get_extrinsic_fee(self, call: GenericCall, keypair: Keypair) -> Balance: fee_dict = await self.substrate.get_payment_info(call, keypair) return Balance.from_rao(fee_dict["partial_fee"]) diff --git a/bittensor_cli/src/commands/stake/add.py b/bittensor_cli/src/commands/stake/add.py index 4710c7a42..ee784c012 100644 --- a/bittensor_cli/src/commands/stake/add.py +++ b/bittensor_cli/src/commands/stake/add.py @@ -629,7 +629,7 @@ def _define_stake_table( table.add_column( "Extrinsic Fee (τ)", justify="center", - style=COLOR_PALETTE["STAKE"]["STAKE_AMOUNT"], + style=COLOR_PALETTE.STAKE.TAO, ) # TODO: Uncomment when slippage is reimplemented for v3 # table.add_column( diff --git a/bittensor_cli/src/commands/stake/move.py b/bittensor_cli/src/commands/stake/move.py index ede380597..b4efbd127 100644 --- a/bittensor_cli/src/commands/stake/move.py +++ b/bittensor_cli/src/commands/stake/move.py @@ -33,12 +33,15 @@ async def display_stake_movement_cross_subnets( destination_hotkey: str, amount_to_move: Balance, stake_fee: Balance, + extrinsic_fee: Balance, ) -> tuple[Balance, str]: """Calculate and display stake movement information""" if origin_netuid == destination_netuid: subnet = await subtensor.subnet(origin_netuid) - received_amount_tao = subnet.alpha_to_tao(amount_to_move - stake_fee) + received_amount_tao = ( + subnet.alpha_to_tao(amount_to_move - stake_fee) - extrinsic_fee + ) received_amount = subnet.tao_to_alpha(received_amount_tao) if received_amount < Balance.from_tao(0).set_unit(destination_netuid): @@ -62,7 +65,9 @@ async def display_stake_movement_cross_subnets( price_destination = dynamic_destination.price.tao rate = price_origin / (price_destination or 1) - received_amount_tao = dynamic_origin.alpha_to_tao(amount_to_move - stake_fee) + received_amount_tao = ( + dynamic_origin.alpha_to_tao(amount_to_move - stake_fee) - extrinsic_fee + ) received_amount = dynamic_destination.tao_to_alpha(received_amount_tao) received_amount.set_unit(destination_netuid) @@ -81,14 +86,14 @@ async def display_stake_movement_cross_subnets( # Create and display table table = Table( title=( - f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]" + f"\n[{COLOR_PALETTE.G.HEADER}]" f"Moving stake from: " - f"[{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{Balance.get_unit(origin_netuid)}(Netuid: {origin_netuid})" - f"[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}] " + f"[{COLOR_PALETTE.G.SUBHEAD}]{Balance.get_unit(origin_netuid)}(Netuid: {origin_netuid})" + f"[/{COLOR_PALETTE.G.SUBHEAD}] " f"to: " - f"[{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{Balance.get_unit(destination_netuid)}(Netuid: {destination_netuid})" - f"[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}]\nNetwork: {subtensor.network}\n" - f"[/{COLOR_PALETTE['GENERAL']['HEADER']}]" + f"[{COLOR_PALETTE.G.SUBHEAD}]{Balance.get_unit(destination_netuid)}(Netuid: {destination_netuid})" + f"[/{COLOR_PALETTE.G.SUBHEAD}]\nNetwork: {subtensor.network}\n" + f"[/{COLOR_PALETTE.G.HEADER}]" ), show_footer=True, show_edge=False, @@ -132,6 +137,9 @@ async def display_stake_movement_cross_subnets( justify="center", style=COLOR_PALETTE["STAKE"]["STAKE_AMOUNT"], ) + table.add_column( + "Extrinsic Fee (τ)", justify="center", style=COLOR_PALETTE.STAKE.TAO + ) table.add_row( f"{Balance.get_unit(origin_netuid)}({origin_netuid})", @@ -142,6 +150,7 @@ async def display_stake_movement_cross_subnets( price_str, str(received_amount), str(stake_fee.set_unit(origin_netuid)), + str(extrinsic_fee), ) console.print(table) @@ -165,10 +174,10 @@ def prompt_stake_amount( while True: amount_input = Prompt.ask( f"\nEnter the amount to {action_name} " - f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{Balance.get_unit(netuid)}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}] " - f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}](max: {current_balance})[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}] " + f"[{COLOR_PALETTE.S.STAKE_AMOUNT}]{Balance.get_unit(netuid)}[/{COLOR_PALETTE.S.STAKE_AMOUNT}] " + f"[{COLOR_PALETTE.S.STAKE_AMOUNT}](max: {current_balance})[/{COLOR_PALETTE.S.STAKE_AMOUNT}] " f"or " - f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]'all'[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}] " + f"[{COLOR_PALETTE.S.STAKE_AMOUNT}]'all'[/{COLOR_PALETTE.S.STAKE_AMOUNT}] " f"for entire balance" ) @@ -183,7 +192,7 @@ def prompt_stake_amount( if amount > current_balance.tao: console.print( f"[red]Amount exceeds available balance of " - f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{current_balance}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]" + f"[{COLOR_PALETTE.S.STAKE_AMOUNT}]{current_balance}[/{COLOR_PALETTE.S.STAKE_AMOUNT}]" f"[/red]" ) continue @@ -270,8 +279,8 @@ async def stake_move_transfer_selection( # Display available netuids for selected hotkey table = Table( - title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Available Stakes for Hotkey\n[/{COLOR_PALETTE['GENERAL']['HEADER']}]" - f"[{COLOR_PALETTE['GENERAL']['HOTKEY']}]{origin_hotkey_ss58}[/{COLOR_PALETTE['GENERAL']['HOTKEY']}]\n", + title=f"\n[{COLOR_PALETTE.G.HEADER}]Available Stakes for Hotkey\n[/{COLOR_PALETTE.G.HEADER}]" + f"[{COLOR_PALETTE.G.HK}]{origin_hotkey_ss58}[/{COLOR_PALETTE.G.HK}]\n", show_edge=False, header_style="bold white", border_style="bright_black", @@ -279,7 +288,7 @@ async def stake_move_transfer_selection( width=len(origin_hotkey_ss58) + 20, ) table.add_column("Netuid", style="cyan") - table.add_column("Stake Amount", style=COLOR_PALETTE["STAKE"]["STAKE_AMOUNT"]) + table.add_column("Stake Amount", style=COLOR_PALETTE.STAKE.STAKE_AMOUNT) available_netuids = [] for netuid in origin_hotkey_info["netuids"]: @@ -347,8 +356,8 @@ async def stake_swap_selection( # Display available stakes table = Table( - title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Available Stakes for Hotkey\n[/{COLOR_PALETTE['GENERAL']['HEADER']}]" - f"[{COLOR_PALETTE['GENERAL']['HOTKEY']}]{wallet.hotkey_str}: {wallet.hotkey.ss58_address}[/{COLOR_PALETTE['GENERAL']['HOTKEY']}]\n", + title=f"\n[{COLOR_PALETTE.G.HEADER}]Available Stakes for Hotkey\n[/{COLOR_PALETTE.G.HEADER}]" + f"[{COLOR_PALETTE.G.HK}]{wallet.hotkey_str}: {wallet.hotkey.ss58_address}[/{COLOR_PALETTE.G.HK}]\n", show_edge=False, header_style="bold white", border_style="bright_black", @@ -366,7 +375,7 @@ async def stake_swap_selection( for idx, (netuid, stake_info) in enumerate(sorted(hotkey_stakes.items())): subnet_info = subnet_dict[netuid] subnet_name_cell = ( - f"[{COLOR_PALETTE['GENERAL']['SYMBOL']}]{subnet_info.symbol if netuid != 0 else 'τ'}[/{COLOR_PALETTE['GENERAL']['SYMBOL']}]" + f"[{COLOR_PALETTE.G.SYM}]{subnet_info.symbol if netuid != 0 else 'τ'}[/{COLOR_PALETTE.G.SYM}]" f" {get_subnet_name(subnet_info)}" ) @@ -498,14 +507,28 @@ 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, + call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="move_stake", + call_params={ + "origin_hotkey": origin_hotkey, + "origin_netuid": origin_netuid, + "destination_hotkey": destination_hotkey, + "destination_netuid": destination_netuid, + "alpha_amount": amount_to_move_as_balance.rao, + }, + ) + stake_fee, extrinsic_fee = await asyncio.gather( + 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, + ), + subtensor.get_extrinsic_fee(call, wallet.coldkeypub), ) # Display stake movement details @@ -519,6 +542,7 @@ async def move_stake( destination_hotkey=destination_hotkey, amount_to_move=amount_to_move_as_balance, stake_fee=stake_fee, + extrinsic_fee=extrinsic_fee, ) except ValueError: return False @@ -529,20 +553,10 @@ async def move_stake( if not unlock_key(wallet).success: return False with console.status( - f"\n:satellite: Moving [blue]{amount_to_move_as_balance}[/blue] from [blue]{origin_hotkey}[/blue] on netuid: [blue]{origin_netuid}[/blue] \nto " + f"\n:satellite: Moving [blue]{amount_to_move_as_balance}[/blue] from [blue]{origin_hotkey}[/blue] on netuid: " + f"[blue]{origin_netuid}[/blue] \nto " f"[blue]{destination_hotkey}[/blue] on netuid: [blue]{destination_netuid}[/blue] ..." ): - call = await subtensor.substrate.compose_call( - call_module="SubtensorModule", - call_function="move_stake", - call_params={ - "origin_hotkey": origin_hotkey, - "origin_netuid": origin_netuid, - "destination_hotkey": destination_hotkey, - "destination_netuid": destination_netuid, - "alpha_amount": amount_to_move_as_balance.rao, - }, - ) extrinsic = await subtensor.substrate.create_signed_extrinsic( call=call, keypair=wallet.coldkey, era={"period": era} ) @@ -677,19 +691,33 @@ async def transfer_stake( if amount_to_transfer > current_stake: err_console.print( f"[red]Not enough stake to transfer[/red]:\n" - f"Stake balance: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{current_stake}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}] < " - f"Transfer amount: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{amount_to_transfer}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]" + f"Stake balance: [{COLOR_PALETTE.S.STAKE_AMOUNT}]{current_stake}[/{COLOR_PALETTE.S.STAKE_AMOUNT}] < " + f"Transfer amount: [{COLOR_PALETTE.S.STAKE_AMOUNT}]{amount_to_transfer}[/{COLOR_PALETTE.S.STAKE_AMOUNT}]" ) 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, + call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="transfer_stake", + call_params={ + "destination_coldkey": dest_coldkey_ss58, + "hotkey": origin_hotkey, + "origin_netuid": origin_netuid, + "destination_netuid": dest_netuid, + "alpha_amount": amount_to_transfer.rao, + }, + ) + stake_fee, extrinsic_fee = await asyncio.gather( + 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, + ), + subtensor.get_extrinsic_fee(call, wallet.coldkeypub), ) # Display stake movement details @@ -703,6 +731,7 @@ async def transfer_stake( destination_hotkey=origin_hotkey, amount_to_move=amount_to_transfer, stake_fee=stake_fee, + extrinsic_fee=extrinsic_fee, ) except ValueError: return False @@ -715,18 +744,6 @@ async def transfer_stake( return False with console.status("\n:satellite: Transferring stake ..."): - call = await subtensor.substrate.compose_call( - call_module="SubtensorModule", - call_function="transfer_stake", - call_params={ - "destination_coldkey": dest_coldkey_ss58, - "hotkey": origin_hotkey, - "origin_netuid": origin_netuid, - "destination_netuid": dest_netuid, - "alpha_amount": amount_to_transfer.rao, - }, - ) - extrinsic = await subtensor.substrate.create_signed_extrinsic( call=call, keypair=wallet.coldkey, era={"period": era} ) @@ -846,19 +863,32 @@ async def swap_stake( if amount_to_swap > current_stake: err_console.print( f"[red]Not enough stake to swap[/red]:\n" - f"Stake balance: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{current_stake}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}] < " - f"Swap amount: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{amount_to_swap}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]" + f"Stake balance: [{COLOR_PALETTE.S.STAKE_AMOUNT}]{current_stake}[/{COLOR_PALETTE.S.STAKE_AMOUNT}] < " + f"Swap amount: [{COLOR_PALETTE.S.STAKE_AMOUNT}]{amount_to_swap}[/{COLOR_PALETTE.S.STAKE_AMOUNT}]" ) 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, + call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="swap_stake", + call_params={ + "hotkey": hotkey_ss58, + "origin_netuid": origin_netuid, + "destination_netuid": destination_netuid, + "alpha_amount": amount_to_swap.rao, + }, + ) + stake_fee, extrinsic_fee = await asyncio.gather( + 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, + ), + subtensor.get_extrinsic_fee(call, wallet.coldkeypub), ) # Display stake movement details @@ -872,6 +902,7 @@ async def swap_stake( destination_hotkey=hotkey_ss58, amount_to_move=amount_to_swap, stake_fee=stake_fee, + extrinsic_fee=extrinsic_fee, ) except ValueError: return False @@ -887,17 +918,6 @@ async def swap_stake( f"\n:satellite: Swapping stake from netuid [blue]{origin_netuid}[/blue] " f"to netuid [blue]{destination_netuid}[/blue]..." ): - call = await subtensor.substrate.compose_call( - call_module="SubtensorModule", - call_function="swap_stake", - call_params={ - "hotkey": hotkey_ss58, - "origin_netuid": origin_netuid, - "destination_netuid": destination_netuid, - "alpha_amount": amount_to_swap.rao, - }, - ) - extrinsic = await subtensor.substrate.create_signed_extrinsic( call=call, keypair=wallet.coldkey, era={"period": era} ) diff --git a/bittensor_cli/src/commands/stake/remove.py b/bittensor_cli/src/commands/stake/remove.py index 89ef310b1..4da48c05a 100644 --- a/bittensor_cli/src/commands/stake/remove.py +++ b/bittensor_cli/src/commands/stake/remove.py @@ -47,6 +47,7 @@ async def unstake( era: int, ): """Unstake from hotkey(s).""" + with console.status( f"Retrieving subnet data & identities from {subtensor.network}...", spinner="earth", @@ -82,7 +83,7 @@ async def unstake( hotkey_to_unstake_all = hotkeys_to_unstake_from[0] unstake_all_alpha = Confirm.ask( "\nDo you want to:\n" - "[blue]Yes[/blue]: Unstake from all subnets and automatically restake to subnet 0 (root)\n" + "[blue]Yes[/blue]: Unstake from all subnets and automatically re-stake to subnet 0 (root)\n" "[blue]No[/blue]: Unstake everything (including subnet 0)", default=True, ) @@ -210,8 +211,38 @@ async def unstake( try: current_price = subnet_info.price.tao + if safe_staking: + if subnet_info.is_dynamic: + price_with_tolerance = current_price * (1 - rate_tolerance) + rate_with_tolerance = price_with_tolerance + price_with_tolerance = Balance.from_tao( + rate_with_tolerance + ).rao # Actual price to pass to extrinsic + else: + price_with_tolerance = 1 + extrinsic_fee = await _get_extrinsic_fee( + "unstake_safe", + wallet, + subtensor, + hotkey_ss58=staking_address_ss58, + amount=amount_to_unstake_as_balance, + netuid=netuid, + price_limit=price_with_tolerance, + allow_partial_stake=allow_partial_stake, + ) + else: + extrinsic_fee = await _get_extrinsic_fee( + "unstake", + wallet, + subtensor, + hotkey_ss58=staking_address_ss58, + netuid=netuid, + amount=amount_to_unstake_as_balance, + ) rate = current_price - received_amount = amount_to_unstake_as_balance * rate + received_amount = ( + (amount_to_unstake_as_balance - stake_fee) * rate + ) - extrinsic_fee except ValueError: continue total_received_amount += received_amount @@ -233,8 +264,9 @@ async def unstake( staking_address_name, # Hotkey Name str(amount_to_unstake_as_balance), # Amount to Unstake f"{subnet_info.price.tao:.6f}" - + f"({Balance.get_unit(0)}/{Balance.get_unit(netuid)})", # Rate + + f"(τ/{Balance.get_unit(netuid)})", # Rate str(stake_fee.set_unit(netuid)), # Fee + str(extrinsic_fee), # Extrinsic fee str(received_amount), # Received Amount # slippage_pct, # Slippage Percent ] @@ -435,6 +467,11 @@ async def unstake_all( justify="center", style=COLOR_PALETTE["STAKE"]["STAKE_AMOUNT"], ) + table.add_column( + "Extrinsic Fee (τ)", + justify="center", + style=COLOR_PALETTE.STAKE.TAO, + ) table.add_column( f"Received ({Balance.unit})", justify="center", @@ -467,8 +504,17 @@ async def unstake_all( try: current_price = subnet_info.price.tao + extrinsic_type = ( + "unstake_all" if not unstake_all_alpha else "unstake_all_alpha" + ) + extrinsic_fee = await _get_extrinsic_fee( + extrinsic_type, + wallet, + subtensor, + hotkey_ss58=stake.hotkey_ss58, + ) rate = current_price - received_amount = (stake_amount - stake_fee) * rate + received_amount = ((stake_amount - stake_fee) * rate) - extrinsic_fee if received_amount < Balance.from_tao(0): print_error("Not enough Alpha to pay the transaction fee.") @@ -485,6 +531,7 @@ async def unstake_all( f"{float(subnet_info.price):.6f}" + f"({Balance.get_unit(0)}/{Balance.get_unit(stake.netuid)})", str(stake_fee), + str(extrinsic_fee), str(received_amount), ) console.print(table) @@ -830,6 +877,63 @@ async def _unstake_all_extrinsic( err_out(f"{failure_prelude} with error: {str(e)}") +async def _get_extrinsic_fee( + _type: str, + wallet: Wallet, + subtensor: "SubtensorInterface", + hotkey_ss58: str, + netuid: Optional[int] = None, + amount: Optional[Balance] = None, + price_limit: Optional[Balance] = None, + allow_partial_stake: bool = False, +) -> Balance: + """ + Retrieves the extrinsic fee for a given unstaking call. + Args: + _type: 'unstake', 'unstake_safe', 'unstake_all', 'unstake_all_alpha' depending on the specific + extrinsic to be called + wallet: Wallet object + subtensor: SubtensorInterface object + hotkey_ss58: the hotkey ss58 to unstake from + netuid: the netuid from which to remove the stake + amount: the amount of stake to remove + price_limit: the price limit + allow_partial_stake: whether to allow partial unstaking + + Returns: + Balance object representing the extrinsic fee. + """ + lookup_table = { + "unstake": lambda: ( + "remove_stake", + { + "hotkey": hotkey_ss58, + "netuid": netuid, + "amount_unstaked": amount.rao, + }, + ), + "unstake_safe": lambda: ( + "remove_stake_limit", + { + "hotkey": hotkey_ss58, + "netuid": netuid, + "amount_unstaked": amount.rao, + "limit_price": price_limit, + "allow_partial": allow_partial_stake, + }, + ), + "unstake_all": lambda: ("unstake_all", {"hotkey": hotkey_ss58}), + "unstake_all_alpha": lambda: ("unstake_all_alpha", {"hotkey": hotkey_ss58}), + } + call_fn, call_params = lookup_table[_type]() + call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function=call_fn, + call_params=call_params, + ) + return await subtensor.get_extrinsic_fee(call, wallet.coldkeypub) + + # Helpers async def _unstake_selection( dynamic_info, @@ -1184,7 +1288,7 @@ def _create_unstake_table( style=COLOR_PALETTE["POOLS"]["TAO"], ) table.add_column( - f"Rate ({Balance.get_unit(0)}/{Balance.get_unit(1)})", + f"Rate (τ/{Balance.get_unit(1)})", justify="center", style=COLOR_PALETTE["POOLS"]["RATE"], ) @@ -1194,7 +1298,10 @@ def _create_unstake_table( style=COLOR_PALETTE["STAKE"]["STAKE_AMOUNT"], ) table.add_column( - f"Received ({Balance.get_unit(0)})", + "Extrinsic Fee (τ)", justify="center", style=COLOR_PALETTE.STAKE.TAO + ) + table.add_column( + "Received (τ)", justify="center", style=COLOR_PALETTE["POOLS"]["TAO_EQUIV"], footer=str(total_received_amount), From 396c9f17b527d8a3114f14a0e7dff440aa96846b Mon Sep 17 00:00:00 2001 From: Benjamin Himes Date: Mon, 28 Jul 2025 21:04:45 +0200 Subject: [PATCH 10/12] Fix unit incorrectness --- bittensor_cli/src/commands/stake/remove.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bittensor_cli/src/commands/stake/remove.py b/bittensor_cli/src/commands/stake/remove.py index 4da48c05a..30bbce07a 100644 --- a/bittensor_cli/src/commands/stake/remove.py +++ b/bittensor_cli/src/commands/stake/remove.py @@ -463,7 +463,7 @@ async def unstake_all( style=COLOR_PALETTE["POOLS"]["RATE"], ) table.add_column( - f"Fee ({Balance.unit})", + f"Fee ({Balance.get_unit(1)})", justify="center", style=COLOR_PALETTE["STAKE"]["STAKE_AMOUNT"], ) From 96a3813ee66de79075687001e551e1d34be13aa3 Mon Sep 17 00:00:00 2001 From: Benjamin Himes Date: Mon, 28 Jul 2025 22:37:10 +0200 Subject: [PATCH 11/12] PR suggestions --- bittensor_cli/src/bittensor/subtensor_interface.py | 9 +++++++++ bittensor_cli/src/commands/stake/add.py | 12 ++++++++++++ bittensor_cli/src/commands/stake/remove.py | 8 ++++---- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index 5143b3f1c..3d8a632d5 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -1490,6 +1490,15 @@ async def get_owned_hotkeys( return [decode_account_id(hotkey[0]) for hotkey in owned_hotkeys or []] async def get_extrinsic_fee(self, call: GenericCall, keypair: Keypair) -> Balance: + """ + Determines the fee for the extrinsic call. + Args: + call: Created extrinsic call + keypair: The keypair that would sign the extrinsic (usually you would just want to use the *pub for this) + + Returns: + Balance object representing the fee for this extrinsic. + """ fee_dict = await self.substrate.get_payment_info(call, keypair) return Balance.from_rao(fee_dict["partial_fee"]) diff --git a/bittensor_cli/src/commands/stake/add.py b/bittensor_cli/src/commands/stake/add.py index ee784c012..b223eaf2e 100644 --- a/bittensor_cli/src/commands/stake/add.py +++ b/bittensor_cli/src/commands/stake/add.py @@ -72,6 +72,18 @@ async def get_stake_extrinsic_fee( safe_staking_: bool, price_limit: Optional[Balance] = None, ): + """ + Quick method to get the extrinsic fee for adding stake depending on the args supplied. + Args: + netuid_: The netuid where the stake will be added + amount_: the amount of stake to add + staking_address_: the hotkey ss58 to stake to + safe_staking_: whether to use safe staking + price_limit: rate with tolerance + + Returns: + Balance object representing the extrinsic fee for adding stake. + """ call_fn = "add_stake" if not safe_staking_ else "add_stake_limit" call_params = { "hotkey": staking_address_, diff --git a/bittensor_cli/src/commands/stake/remove.py b/bittensor_cli/src/commands/stake/remove.py index 30bbce07a..a28254e32 100644 --- a/bittensor_cli/src/commands/stake/remove.py +++ b/bittensor_cli/src/commands/stake/remove.py @@ -215,11 +215,11 @@ async def unstake( if subnet_info.is_dynamic: price_with_tolerance = current_price * (1 - rate_tolerance) rate_with_tolerance = price_with_tolerance - price_with_tolerance = Balance.from_tao( + price_limit = Balance.from_tao( rate_with_tolerance - ).rao # Actual price to pass to extrinsic + ) # Actual price to pass to extrinsic else: - price_with_tolerance = 1 + price_limit = Balance.from_rao(1) extrinsic_fee = await _get_extrinsic_fee( "unstake_safe", wallet, @@ -227,7 +227,7 @@ async def unstake( hotkey_ss58=staking_address_ss58, amount=amount_to_unstake_as_balance, netuid=netuid, - price_limit=price_with_tolerance, + price_limit=price_limit, allow_partial_stake=allow_partial_stake, ) else: From 993a2e5827086a5d83fc49faaf33c42dbf081d81 Mon Sep 17 00:00:00 2001 From: Benjamin Himes Date: Mon, 28 Jul 2025 22:47:28 +0200 Subject: [PATCH 12/12] Changelog + version --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d444460b..b0200574a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 9.9.0 /2025-07-28 +* Feat/wallet verify by @ibraheem-abe in https://github.com/opentensor/btcli/pull/561 +* Improved speed of query_all_identities and fetch_coldkey_hotkey_identities by @thewhaleking in https://github.com/opentensor/btcli/pull/560 +* fix transfer all by @thewhaleking in https://github.com/opentensor/btcli/pull/562 +* Add extrinsic fees by @thewhaleking in https://github.com/opentensor/btcli/pull/564 + +**Full Changelog**: https://github.com/opentensor/btcli/compare/v9.8.7...v9.9.0 + ## 9.8.7 /2025-07-23 * Fix for handling tuples for `additional` by @thewhaleking in https://github.com/opentensor/btcli/pull/557 diff --git a/pyproject.toml b/pyproject.toml index 2c46094f9..91a77e297 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "bittensor-cli" -version = "9.8.7" +version = "9.9.0" description = "Bittensor CLI" readme = "README.md" authors = [