diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index dadf57eb..39f5e312 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -1164,6 +1164,9 @@ def __init__(self): self.sudo_app.command("trim", rich_help_panel=HELP_PANELS["SUDO"]["CONFIG"])( self.sudo_trim ) + self.sudo_app.command( + "stake-burn", rich_help_panel=HELP_PANELS["SUDO"]["CONFIG"] + )(self.sudo_stake_burn) # subnets commands self.subnets_app.command( @@ -1298,6 +1301,7 @@ def __init__(self): self.sudo_app.command("senate_vote", hidden=True)(self.sudo_senate_vote) self.sudo_app.command("get_take", hidden=True)(self.sudo_get_take) self.sudo_app.command("set_take", hidden=True)(self.sudo_set_take) + self.sudo_app.command("buyback", hidden=True)(self.sudo_stake_burn) # Stake self.stake_app.command( @@ -7382,6 +7386,107 @@ def sudo_trim( ) ) + def sudo_stake_burn( + self, + network: Optional[list[str]] = Options.network, + wallet_name: Optional[str] = Options.wallet_name, + wallet_path: Optional[str] = Options.wallet_path, + wallet_hotkey: Optional[str] = Options.wallet_hotkey_ss58, + netuid: int = Options.netuid, + amount: float = typer.Option( + None, + "--amount", + "-a", + help="Amount of TAO to stake and burn", + prompt="Enter the amount of TAO to stake and burn", + ), + proxy: Optional[str] = Options.proxy, + rate_tolerance: Optional[float] = Options.rate_tolerance, + safe_staking: Optional[bool] = Options.safe_staking, + mev_protection: bool = Options.mev_protection, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + prompt: bool = Options.prompt, + decline: bool = Options.decline, + period: int = Options.period, + ): + """ + Allows subnet owners to buy back alpha on their subnet by staking TAO and immediately burning the acquired alpha. + + [bold]Examples:[/bold] + 1. Stake and burn 10 TAO on subnet 14: + [green]$[/green] btcli sudo stake-burn --netuid 14 --amount 10 + 2. Stake and burn 10 TAO on subnet 14 with safe staking and 5% rate tolerance: + [green]$[/green] btcli sudo stake-burn --netuid 14 --amount 10 --tolerance 0.05 + 3. Stake and burn 10 TAO on subnet 14 with a specific hotkey: + [green]$[/green] btcli sudo stake-burn --netuid 14 --amount 10 --wallet-hotkey + """ + self.verbosity_handler(quiet, verbose, json_output, prompt) + proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only=False) + safe_staking = self.ask_safe_staking(safe_staking) + if safe_staking: + rate_tolerance = self.ask_rate_tolerance(rate_tolerance) + + print_protection_warnings( + mev_protection=mev_protection, + safe_staking=safe_staking, + command_name="sudo stake-burn", + ) + + if not wallet_hotkey: + wallet_hotkey = Prompt.ask( + "Enter the [blue]hotkey[/blue] name or " + "[blue]hotkey ss58 address[/blue] [dim](to use for the stake burn)[/dim]", + default=self.config.get("wallet_hotkey") or defaults.wallet.hotkey, + ) + + if wallet_hotkey and is_valid_ss58_address(wallet_hotkey): + hotkey_ss58 = wallet_hotkey + wallet = self.wallet_ask( + wallet_name, + wallet_path, + None, + ask_for=[WO.NAME, WO.PATH], + validate=WV.WALLET, + ) + else: + wallet = self.wallet_ask( + wallet_name, + wallet_path, + wallet_hotkey, + ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], + validate=WV.WALLET_AND_HOTKEY, + ) + hotkey_ss58 = get_hotkey_pub_ss58(wallet) + + if amount <= 0: + print_error(f"You entered an incorrect stake and burn amount: {amount}") + raise typer.Exit() + + if netuid == 0: + print_error("Cannot stake and burn on the root subnet.") + raise typer.Exit() + + self._run_command( + sudo.stake_burn( + subtensor=self.initialize_chain(network), + wallet=wallet, + netuid=netuid, + amount=amount, + hotkey_ss58=hotkey_ss58, + safe_staking=safe_staking, + proxy=proxy, + rate_tolerance=rate_tolerance, + mev_protection=mev_protection, + json_output=json_output, + prompt=prompt, + decline=decline, + quiet=quiet, + period=period, + ) + ) + # Subnets def subnets_list( diff --git a/bittensor_cli/src/commands/sudo.py b/bittensor_cli/src/commands/sudo.py index ec6d1461..2ea78c13 100644 --- a/bittensor_cli/src/commands/sudo.py +++ b/bittensor_cli/src/commands/sudo.py @@ -18,10 +18,16 @@ DelegatesDetails, COLOR_PALETTE, ) +from bittensor_cli.src.bittensor.balances import Balance +from bittensor_cli.src.bittensor.extrinsics.mev_shield import ( + extract_mev_shield_id, + wait_for_extrinsic_by_hash, +) from bittensor_cli.src.bittensor.chain_data import decode_account_id from bittensor_cli.src.bittensor.utils import ( confirm_action, console, + create_table, print_error, print_success, print_verbose, @@ -94,6 +100,236 @@ def string_to_bool(val) -> Union[bool, Type[ValueError]]: return ValueError +async def stake_burn( + wallet: Wallet, + subtensor: "SubtensorInterface", + netuid: int, + amount: float, + hotkey_ss58: Optional[str], + safe_staking: bool, + proxy: Optional[str], + rate_tolerance: Optional[float], + mev_protection: bool, + json_output: bool, + prompt: bool, + decline: bool, + quiet: bool, + period: int, +) -> bool: + """ + Perform a stake burn (owner-only). + Stakes TAO into the subnet and immediately burns the acquired alpha. + """ + subnet_owner = await subtensor.query( + module="SubtensorModule", + storage_function="SubnetOwner", + params=[netuid], + ) + if subnet_owner != wallet.coldkeypub.ss58_address: + err_msg = ( + f"Coldkey {wallet.coldkeypub.ss58_address} does not own subnet {netuid}." + ) + if json_output: + json_console.print_json( + data={ + "success": False, + "message": err_msg, + "extrinsic_identifier": None, + } + ) + else: + print_error(err_msg) + return False + + subnet_info = await subtensor.subnet(netuid=netuid) + stake_burn_amount = Balance.from_tao(amount) + rate_tolerance = rate_tolerance if rate_tolerance is not None else 0.0 + + price_limit: Optional[Balance] = None + if safe_staking: + price_limit = Balance.from_tao(subnet_info.price.tao * (1 + rate_tolerance)) + + call_params = { + "hotkey": hotkey_ss58, + "netuid": netuid, + "amount": stake_burn_amount.rao, + "limit": price_limit.rao if price_limit else None, + } + + call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="add_stake_burn", + call_params=call_params, + ) + + if not json_output: + extrinsic_fee = await subtensor.get_extrinsic_fee( + call, wallet.coldkeypub, proxy=proxy + ) + amount_minus_fee = stake_burn_amount - extrinsic_fee + sim_swap = await subtensor.sim_swap( + origin_netuid=0, + destination_netuid=netuid, + amount=amount_minus_fee.rao, + ) + + received_amount = sim_swap.alpha_amount + current_price_float = subnet_info.price.tao + rate = 1.0 / current_price_float + + table = _define_stake_burn_table( + wallet=wallet, + subtensor=subtensor, + safe_staking=safe_staking, + rate_tolerance=rate_tolerance, + ) + row = [ + str(netuid), + hotkey_ss58, + str(stake_burn_amount), + str(rate) + f" {Balance.get_unit(netuid)}/{Balance.get_unit(0)} ", + str(received_amount.set_unit(netuid)), + str(sim_swap.tao_fee), + str(extrinsic_fee), + ] + if safe_staking: + price_with_tolerance = current_price_float * (1 + rate_tolerance) + rate_with_tolerance = 1.0 / price_with_tolerance + rate_with_tolerance_str = ( + f"{rate_with_tolerance:.4f} " + f"{Balance.get_unit(netuid)}/{Balance.get_unit(0)} " + ) + row.append(rate_with_tolerance_str) + + table.add_row(*row) + console.print(table) + + if prompt and not confirm_action( + "Would you like to continue?", decline=decline, quiet=quiet + ): + print_error("User aborted.") + return False + + if not unlock_key(wallet).success: + return False + + with console.status( + f":satellite: Performing subnet stake burn on [bold]{netuid}[/bold]...", + spinner="earth", + ) as status: + next_nonce = await subtensor.substrate.get_account_next_index( + wallet.coldkeypub.ss58_address + ) + success, err_msg, ext_receipt = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + nonce=next_nonce, + era={"period": period}, + proxy=proxy, + mev_protection=mev_protection, + ) + + if not success: + if json_output: + json_console.print_json( + data={ + "success": False, + "message": err_msg, + "extrinsic_identifier": None, + } + ) + else: + print_error(err_msg, status=status) + return False + + if mev_protection: + inner_hash = err_msg + mev_shield_id = await extract_mev_shield_id(ext_receipt) + mev_success, mev_error, ext_receipt = await wait_for_extrinsic_by_hash( + subtensor=subtensor, + extrinsic_hash=inner_hash, + shield_id=mev_shield_id, + submit_block_hash=ext_receipt.block_hash, + status=status, + ) + if not mev_success: + status.stop() + if json_output: + json_console.print_json( + data={ + "success": False, + "message": mev_error, + "extrinsic_identifier": None, + } + ) + else: + print_error(mev_error, status=status) + return False + + ext_id = await ext_receipt.get_extrinsic_identifier() + + msg = f"Subnet stake burn succeeded on SN{netuid}." + if json_output: + json_console.print_json( + data={"success": True, "message": msg, "extrinsic_identifier": ext_id} + ) + else: + await print_extrinsic_id(ext_receipt) + print_success(msg) + + return True + + +def _define_stake_burn_table( + wallet: Wallet, + subtensor: "SubtensorInterface", + safe_staking: bool, + rate_tolerance: float, +) -> Table: + table = create_table( + title=f"\n[{COLOR_PALETTE.G.HEADER}]Subnet Buyback:\n" + f"Wallet: [{COLOR_PALETTE.G.CK}]{wallet.name}[/{COLOR_PALETTE.G.CK}], " + f"Coldkey ss58: [{COLOR_PALETTE.G.CK}]{wallet.coldkeypub.ss58_address}[/{COLOR_PALETTE.G.CK}]\n" + f"Network: {subtensor.network}[/{COLOR_PALETTE.G.HEADER}]\n", + ) + table.add_column("Netuid", justify="center", style="grey89") + table.add_column( + "Hotkey", justify="center", style=COLOR_PALETTE["GENERAL"]["HOTKEY"] + ) + table.add_column( + "Amount (τ)", + justify="center", + style=COLOR_PALETTE["POOLS"]["TAO"], + ) + table.add_column( + "Rate (per τ)", + justify="center", + style=COLOR_PALETTE["POOLS"]["RATE"], + ) + table.add_column( + "Est. Burned", + justify="center", + style=COLOR_PALETTE["POOLS"]["TAO_EQUIV"], + ) + table.add_column( + "Fee (τ)", + justify="center", + style=COLOR_PALETTE["STAKE"]["STAKE_AMOUNT"], + ) + table.add_column( + "Extrinsic Fee (τ)", + justify="center", + style=COLOR_PALETTE.STAKE.TAO, + ) + if safe_staking: + table.add_column( + f"Rate with tolerance: [blue]({rate_tolerance * 100}%)[/blue]", + justify="center", + style=COLOR_PALETTE["POOLS"]["RATE"], + ) + return table + + def search_metadata( param_name: str, value: Union[str, bool, float, list[float]], diff --git a/tests/e2e_tests/test_coldkey_swap.py b/tests/e2e_tests/test_coldkey_swap.py index dfa42378..0f7f977c 100644 --- a/tests/e2e_tests/test_coldkey_swap.py +++ b/tests/e2e_tests/test_coldkey_swap.py @@ -1,6 +1,6 @@ import asyncio import json - +import time from .utils import ( find_stake_entries, ) @@ -35,7 +35,7 @@ def test_coldkey_swap_with_stake(local_chain, wallet_setup): _, wallet_bob, path_bob, exec_command_bob = wallet_setup(wallet_path_bob) _, wallet_new, path_new, _ = wallet_setup(wallet_path_new) netuid = 2 - + time.sleep(12) # Create a new subnet by Bob create_sn = exec_command_bob( command="subnets", @@ -240,6 +240,7 @@ def test_coldkey_swap_dispute(local_chain, wallet_setup): _, wallet_bob, path_bob, exec_command_bob = wallet_setup(wallet_path_bob) _, wallet_new, _, _ = wallet_setup(wallet_path_new) + time.sleep(12) # Create subnet, start, and stake on it create_sn = exec_command_bob( command="subnets", diff --git a/tests/e2e_tests/test_stake_burn.py b/tests/e2e_tests/test_stake_burn.py new file mode 100644 index 00000000..c6694fa5 --- /dev/null +++ b/tests/e2e_tests/test_stake_burn.py @@ -0,0 +1,165 @@ +import json +import time + +import pytest + +from .utils import extract_coldkey_balance + + +@pytest.mark.parametrize("local_chain", [False], indirect=True) +def test_stake_burn(local_chain, wallet_setup): + """ + Test stake burn + 1. Create a subnet + 2. Start the subnet's emission schedule + 3. Buyback the subnet (stake burn) + 3. Check the balance before and after the buyback upon success + 4. Try to buyback again and expect it to fail due to rate limit + """ + + _, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup("//Alice") + time.sleep(12) + netuid = 2 + result = exec_command_alice( + command="subnets", + sub_command="create", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--subnet-name", + "Test Subnet", + "--repo", + "https://github.com/username/repo", + "--contact", + "test@opentensor.dev", + "--url", + "https://testsubnet.com", + "--discord", + "test#1234", + "--description", + "A test subnet for e2e testing", + "--logo-url", + "https://testsubnet.com/logo.png", + "--additional-info", + "Test subnet", + "--no-prompt", + "--no-mev-protection", + ], + ) + assert "✅ Registered subnetwork with netuid: 2" in result.stdout + + # Start the subnet's emission schedule + start_call_netuid_2 = exec_command_alice( + command="subnets", + sub_command="start", + extra_args=[ + "--netuid", + str(netuid), + "--wallet-name", + wallet_alice.name, + "--no-prompt", + "--chain", + "ws://127.0.0.1:9945", + "--wallet-path", + wallet_path_alice, + ], + ) + assert ( + "Successfully started subnet 2's emission schedule." + in start_call_netuid_2.stdout + ) + assert "Your extrinsic has been included" in start_call_netuid_2.stdout + time.sleep(2) + + # Balance before buyback + _balance_before = exec_command_alice( + "wallet", + "balance", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--network", + "ws://127.0.0.1:9945", + ], + ) + balance_before = extract_coldkey_balance( + _balance_before.stdout, wallet_alice.name, wallet_alice.coldkey.ss58_address + )["free_balance"] + + # First stake burn + amount_tao = 5.0 + stake_burn_result = exec_command_alice( + "sudo", + "stake-burn", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--network", + "ws://127.0.0.1:9945", + "--netuid", + str(netuid), + "--amount", + str(amount_tao), + "--no-prompt", + "--json-output", + ], + ) + stale_burn_ok_out = json.loads(stake_burn_result.stdout) + assert stale_burn_ok_out["success"] is True, stake_burn_result.stdout + + # Balance after stake burn + _balance_after = exec_command_alice( + "wallet", + "balance", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--network", + "ws://127.0.0.1:9945", + ], + ) + balance_after = extract_coldkey_balance( + _balance_after.stdout, wallet_alice.name, wallet_alice.coldkey.ss58_address + )["free_balance"] + assert balance_after < balance_before, (balance_before, balance_after) + + # Should fail due to rate limit + stake_burn_ratelimited_result = exec_command_alice( + "sudo", + "buyback", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--network", + "ws://127.0.0.1:9945", + "--netuid", + str(netuid), + "--amount", + str(amount_tao), + "--no-prompt", + "--json-output", + ], + ) + stake_burn_ratelimited = json.loads(stake_burn_ratelimited_result.stdout) + assert stake_burn_ratelimited["success"] is False, ( + stake_burn_ratelimited_result.stdout + ) + assert "AddStakeBurnRateLimitExceeded" in stake_burn_ratelimited["message"]