diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 9fd55345c..407ae34eb 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -5439,6 +5439,9 @@ def stake_transfer( announce_only: bool = Options.announce_only, prompt: bool = Options.prompt, decline: bool = Options.decline, + rate_tolerance: Optional[float] = Options.rate_tolerance, + safe_staking: Optional[bool] = Options.safe_staking, + allow_partial_stake: Optional[bool] = Options.allow_partial_stake, quiet: bool = Options.quiet, verbose: bool = Options.verbose, json_output: bool = Options.json_output, @@ -5477,6 +5480,15 @@ def stake_transfer( Transfer stake without MEV protection: [green]$[/green] btcli stake transfer --origin-netuid 1 --dest-netuid 2 --amount 100 --no-mev-protection + + Safe transfer with rate tolerance of 10%: + [green]$[/green] btcli stake transfer --origin-netuid 1 --dest-netuid 2 --amount 100 --safe --tolerance 0.1 + + Allow partial stake if rates change with tolerance of 10%: + [green]$[/green] btcli stake transfer --origin-netuid 1 --dest-netuid 2 --amount 100 --safe --partial --tolerance 0.1 + + Unsafe transfer with no rate protection: + [green]$[/green] btcli stake transfer --origin-netuid 1 --dest-netuid 2 --amount 100 --unsafe """ self.verbosity_handler(quiet, verbose, json_output, prompt, decline) proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) @@ -5565,6 +5577,11 @@ def stake_transfer( dest_netuid = IntPrompt.ask( "Enter the [blue]destination subnet[/blue] (netuid)" ) + safe_staking_transfer = self.ask_safe_staking(safe_staking) + if safe_staking_transfer: + rate_tolerance_val = self.ask_rate_tolerance(rate_tolerance) + allow_partial_stake_val = self.ask_partial_stake(allow_partial_stake) + logger.debug( "args:\n" f"network: {network}\n" @@ -5576,7 +5593,10 @@ def stake_transfer( f"amount: {amount}\n" f"era: {period}\n" f"stake_all: {stake_all}\n" - f"mev_protection: {mev_protection}" + f"mev_protection: {mev_protection}\n" + f"safe_staking: {safe_staking_transfer}\n" + f"rate_tolerance: {rate_tolerance_val}\n" + f"allow_partial_stake: {allow_partial_stake_val}\n" f"proxy: {proxy}" ) result, ext_id = self._run_command( @@ -5644,6 +5664,9 @@ def stake_swap( wait_for_inclusion: bool = Options.wait_for_inclusion, wait_for_finalization: bool = Options.wait_for_finalization, mev_protection: bool = Options.mev_protection, + rate_tolerance: Optional[float] = Options.rate_tolerance, + safe_staking: Optional[bool] = Options.safe_staking, + allow_partial_stake: Optional[bool] = Options.allow_partial_stake, quiet: bool = Options.quiet, verbose: bool = Options.verbose, json_output: bool = Options.json_output, @@ -5670,6 +5693,15 @@ def stake_swap( 2. Swap stake without MEV protection: [green]$[/green] btcli stake swap --origin-netuid 1 --dest-netuid 2 --amount 100 --no-mev-protection + + 3. Safe swapping with rate tolerance of 10%: + [green]$[/green] btcli stake swap --origin-netuid 1 --dest-netuid 2 --amount 100 --safe --tolerance 0.1 + + 4. Allow partial stake if rates change with tolerance of 10%: + [green]$[/green] btcli stake swap --origin-netuid 1 --dest-netuid 2 --amount 100 --safe --partial --tolerance 0.1 + + 5. Unsafe swapping with no rate protection: + [green]$[/green] btcli stake swap --origin-netuid 1 --dest-netuid 2 --amount 100 --unsafe """ self.verbosity_handler(quiet, verbose, json_output, prompt, decline) proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) @@ -5686,6 +5718,11 @@ def stake_swap( validate=WV.WALLET_AND_HOTKEY, ) + safe_staking = self.ask_safe_staking(safe_staking) + if safe_staking: + rate_tolerance = self.ask_rate_tolerance(rate_tolerance) + allow_partial_stake = self.ask_partial_stake(allow_partial_stake) + interactive_selection = False if origin_netuid is None and dest_netuid is None and not amount: interactive_selection = True @@ -5714,6 +5751,9 @@ def stake_swap( f"wait_for_inclusion: {wait_for_inclusion}\n" f"wait_for_finalization: {wait_for_finalization}\n" f"mev_protection: {mev_protection}\n" + f"safe_staking: {safe_staking}\n" + f"rate_tolerance: {rate_tolerance}\n" + f"allow_partial_stake: {allow_partial_stake}\n" ) result, ext_id = self._run_command( move_stake.swap_stake( @@ -5732,6 +5772,9 @@ def stake_swap( wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, mev_protection=mev_protection, + safe_staking=safe_staking, + allow_partial_stake=allow_partial_stake, + rate_tolerance=rate_tolerance, ) ) if json_output: diff --git a/bittensor_cli/src/commands/stake/move.py b/bittensor_cli/src/commands/stake/move.py index b43ead425..839c8d3de 100644 --- a/bittensor_cli/src/commands/stake/move.py +++ b/bittensor_cli/src/commands/stake/move.py @@ -884,6 +884,9 @@ async def swap_stake( wait_for_inclusion: bool = True, wait_for_finalization: bool = False, mev_protection: bool = True, + safe_staking: bool = False, + allow_partial_stake: bool = False, + rate_tolerance: float = 0.005, ) -> tuple[bool, str]: """Swaps stake between subnets while keeping the same coldkey-hotkey pair ownership. @@ -901,6 +904,12 @@ async def swap_stake( wait_for_inclusion: If true, waits for the transaction to be included in a block. wait_for_finalization: If true, waits for the transaction to be finalized. mev_protection: If true, will encrypt the extrinsic behind the mev protection shield. + safe_swapping: If true, enables price safety checks to protect against fluctuating prices. + allow_partial_stake: If true and safe_swapping is enabled, allows partial stake swaps when the full amount + would exceed the price tolerance. + rate_tolerance: The maximum allowed increase in the price ratio between subnets + (origin_price/destination_price). For example, 0.005 = 0.5% maximum increase. Only used when + safe_swapping is True. Returns: (success, extrinsic_identifier): @@ -958,16 +967,40 @@ async def swap_stake( ) return False, "" - 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, - }, - ) + # Build call with or without safe swapping + if safe_staking: + # Get subnet prices to calculate rate ratio with tolerance + origin_subnet, destination_subnet = await asyncio.gather( + subtensor.subnet(origin_netuid), + subtensor.subnet(destination_netuid), + ) + swap_rate_ratio = origin_subnet.price.rao / destination_subnet.price.rao + swap_rate_ratio_with_tolerance = swap_rate_ratio * (1 - rate_tolerance) + limit_price_rao = Balance.from_tao(swap_rate_ratio_with_tolerance).rao + + call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="swap_stake_limit", + call_params={ + "hotkey": hotkey_ss58, + "origin_netuid": origin_netuid, + "destination_netuid": destination_netuid, + "alpha_amount": amount_to_swap.rao, + "limit_price": limit_price_rao, + "allow_partial": allow_partial_stake, + }, + ) + else: + 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, + }, + ) sim_swap, extrinsic_fee, next_nonce = await asyncio.gather( subtensor.sim_swap( origin_netuid=origin_netuid, diff --git a/tests/e2e_tests/test_stake_slippage_protection.py b/tests/e2e_tests/test_stake_slippage_protection.py new file mode 100644 index 000000000..43ecd1a15 --- /dev/null +++ b/tests/e2e_tests/test_stake_slippage_protection.py @@ -0,0 +1,525 @@ +import asyncio +import json +import pytest + +from .utils import find_stake_entries, set_storage_extrinsic + + +@pytest.mark.parametrize("local_chain", [False], indirect=True) +def test_stake_slippage_protection(local_chain, wallet_setup): + """ + Test slippage protection options for swap command. + + Steps: + 0. Initial setup: Make alice own SN 0, create SN2, SN3, SN4, start emissions on all subnets. + 1. Activation: Register Bob on subnets 2 and 3; add initial stake for V3 activation. + 2. Test Swap with slippage protection (--safe --tolerance) + 3. Test Swap without slippage protection (--unsafe) + 4. Test Swap with slippage protection and partial stake (--safe --tolerance --partial) + + Note: + - All movement commands executed with mev shield + - Stake commands executed without shield to speed up tests + - Shield for stake commands is already covered in its own test + """ + print("Testing slippage protection for swap command ๐Ÿงช") + + wallet_path_alice = "//Alice" + wallet_path_bob = "//Bob" + + keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( + wallet_path_alice + ) + keypair_bob, wallet_bob, wallet_path_bob, exec_command_bob = wallet_setup( + wallet_path_bob + ) + + # Force Alice to own SN0 by setting storage + sn0_owner_storage_items = [ + ( + bytes.fromhex( + "658faa385070e074c85bf6b568cf055536e3e82152c8758267395fe524fbbd160000" + ), + bytes.fromhex( + "d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d" + ), + ) + ] + asyncio.run( + set_storage_extrinsic( + local_chain, + wallet=wallet_alice, + items=sn0_owner_storage_items, + ) + ) + + # Create SN2, SN3, SN4 for swap/transfer checks + subnets_to_create = [2, 3, 4] + for netuid in subnets_to_create: + create_subnet_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", + "alice@opentensor.dev", + "--url", + "https://testsubnet.com", + "--discord", + "alice#1234", + "--description", + "A test subnet for e2e testing", + "--additional-info", + "Created by Alice", + "--logo-url", + "https://testsubnet.com/logo.png", + "--no-prompt", + "--json-output", + ], + ) + create_subnet_payload = json.loads(create_subnet_result.stdout) + assert create_subnet_payload["success"] is True + assert create_subnet_payload["netuid"] == netuid + + # Start emission schedule for subnets (including root netuid 0) + for netuid in [0] + subnets_to_create: + start_emission_result = 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 ( + f"Successfully started subnet {netuid}'s emission schedule." + in start_emission_result.stdout + ) + + # Alice is already registered - register Bob on the two non-root subnets + for netuid in [2, 3]: + register_bob_result = exec_command_bob( + command="subnets", + sub_command="register", + extra_args=[ + "--netuid", + str(netuid), + "--wallet-path", + wallet_path_bob, + "--wallet-name", + wallet_bob.name, + "--hotkey", + wallet_bob.hotkey_str, + "--chain", + "ws://127.0.0.1:9945", + "--no-prompt", + ], + ) + assert "โœ… Registered" in register_bob_result.stdout, register_bob_result.stderr + assert "Your extrinsic has been included" in register_bob_result.stdout, ( + register_bob_result.stdout + ) + + # Add initial stake to enable V3 (1 TAO) on all created subnets + for netuid in [2, 3, 4]: + add_initial_stake_result = exec_command_alice( + command="stake", + sub_command="add", + extra_args=[ + "--netuid", + str(netuid), + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--hotkey", + wallet_alice.hotkey_str, + "--chain", + "ws://127.0.0.1:9945", + "--amount", + "1", + "--unsafe", + "--no-prompt", + "--era", + "144", + "--no-mev-protection", + ], + ) + assert "โœ… Finalized" in add_initial_stake_result.stdout, ( + add_initial_stake_result.stderr + ) + + ################################ + # TEST 1: Swap with slippage protection (--safe --tolerance) + ################################ + + # Add stake for swap test with slippage protection + swap_safe_seed_stake_result = exec_command_alice( + command="stake", + sub_command="add", + extra_args=[ + "--netuid", + "2", + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--hotkey", + wallet_alice.hotkey_str, + "--chain", + "ws://127.0.0.1:9945", + "--amount", + "25", + "--no-prompt", + "--era", + "144", + "--unsafe", + "--no-mev-protection", + ], + ) + assert "โœ… Finalized" in swap_safe_seed_stake_result.stdout, ( + swap_safe_seed_stake_result.stderr + ) + + print("โœ… Swap with slippage protection seed stake finalized") + + # Verify stake was added + alice_stake_before_swap_safe = exec_command_alice( + command="stake", + sub_command="list", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--chain", + "ws://127.0.0.1:9945", + "--no-prompt", + "--verbose", + "--json-output", + ], + ) + alice_stake_list_before_swap_safe = json.loads(alice_stake_before_swap_safe.stdout) + alice_stakes_before_swap_safe = find_stake_entries( + alice_stake_list_before_swap_safe, + netuid=2, + hotkey_ss58=wallet_alice.hotkey.ss58_address, + ) + assert len(alice_stakes_before_swap_safe) > 0 + assert any(stake["stake_value"] >= 20 for stake in alice_stakes_before_swap_safe) + + # Swap stake with slippage protection (--safe --tolerance) + # Swap to root netuid (0) which has more liquidity + swap_safe_amount = 20 + swap_safe_result = exec_command_alice( + command="stake", + sub_command="swap", + extra_args=[ + "--origin-netuid", + "2", + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--dest-netuid", + "0", # Root netuid has more liquidity + "--amount", + str(swap_safe_amount), + "--chain", + "ws://127.0.0.1:9945", + "--no-prompt", + "--safe", + "--tolerance", + "0.1", # 10% tolerance + ], + ) + assert "โœ… Sent" in swap_safe_result.stdout, swap_safe_result.stderr + + # Verify stake was swapped + alice_stake_after_swap_safe = exec_command_alice( + command="stake", + sub_command="list", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--chain", + "ws://127.0.0.1:9945", + "--no-prompt", + "--verbose", + "--json-output", + ], + ) + alice_stake_list_after_swap_safe = json.loads(alice_stake_after_swap_safe.stdout) + alice_stakes_after_swap_safe = find_stake_entries( + alice_stake_list_after_swap_safe, + netuid=0, # Root netuid + hotkey_ss58=wallet_alice.hotkey.ss58_address, + ) + assert len(alice_stakes_after_swap_safe) > 0 + assert any( + stake["stake_value"] >= swap_safe_amount + for stake in alice_stakes_after_swap_safe + ) + print("โœ… TEST 1: Swap with slippage protection completed successfully") + + ################################ + # TEST 2: Swap without slippage protection (--unsafe) + ################################ + + # Add stake for swap test without slippage protection + # Use netuid 4 as origin since we already have stake in netuid 0 from previous swap + swap_unsafe_seed_stake_result = exec_command_alice( + command="stake", + sub_command="add", + extra_args=[ + "--netuid", + "4", + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--hotkey", + wallet_alice.hotkey_str, + "--chain", + "ws://127.0.0.1:9945", + "--amount", + "25", + "--no-prompt", + "--era", + "144", + "--unsafe", + "--no-mev-protection", + ], + ) + assert "โœ… Finalized" in swap_unsafe_seed_stake_result.stdout, ( + swap_unsafe_seed_stake_result.stderr + ) + print("โœ… Swap without slippage protection seed stake finalized") + + # Verify stake was added + alice_stake_before_swap_unsafe = exec_command_alice( + command="stake", + sub_command="list", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--chain", + "ws://127.0.0.1:9945", + "--no-prompt", + "--verbose", + "--json-output", + ], + ) + alice_stake_list_before_swap_unsafe = json.loads( + alice_stake_before_swap_unsafe.stdout + ) + alice_stakes_before_swap_unsafe = find_stake_entries( + alice_stake_list_before_swap_unsafe, + netuid=4, + hotkey_ss58=wallet_alice.hotkey.ss58_address, + ) + assert len(alice_stakes_before_swap_unsafe) > 0 + assert any(stake["stake_value"] >= 20 for stake in alice_stakes_before_swap_unsafe) + + # Swap stake without slippage protection (--unsafe) + # Swap to root netuid (0) which has more liquidity + swap_unsafe_amount = 20 + swap_unsafe_result = exec_command_alice( + command="stake", + sub_command="swap", + extra_args=[ + "--origin-netuid", + "4", + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--dest-netuid", + "0", # Root netuid has more liquidity + "--amount", + str(swap_unsafe_amount), + "--chain", + "ws://127.0.0.1:9945", + "--no-prompt", + "--unsafe", + ], + ) + assert "โœ… Sent" in swap_unsafe_result.stdout, swap_unsafe_result.stderr + + # Verify stake was swapped + alice_stake_after_swap_unsafe = exec_command_alice( + command="stake", + sub_command="list", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--chain", + "ws://127.0.0.1:9945", + "--no-prompt", + "--verbose", + "--json-output", + ], + ) + alice_stake_list_after_swap_unsafe = json.loads( + alice_stake_after_swap_unsafe.stdout + ) + alice_stakes_after_swap_unsafe = find_stake_entries( + alice_stake_list_after_swap_unsafe, + netuid=0, # Root netuid + hotkey_ss58=wallet_alice.hotkey.ss58_address, + ) + assert len(alice_stakes_after_swap_unsafe) > 0 + assert any( + stake["stake_value"] >= swap_unsafe_amount + for stake in alice_stakes_after_swap_unsafe + ) + print("โœ… TEST 2: Swap without slippage protection completed successfully") + + ################################ + # TEST 3: Swap with slippage protection and partial stake (--safe --tolerance --partial) + ################################ + + # Add stake for swap test with partial stake option + swap_partial_seed_stake_result = exec_command_alice( + command="stake", + sub_command="add", + extra_args=[ + "--netuid", + "2", + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--hotkey", + wallet_alice.hotkey_str, + "--chain", + "ws://127.0.0.1:9945", + "--amount", + "25", + "--no-prompt", + "--era", + "144", + "--unsafe", + "--no-mev-protection", + ], + ) + assert "โœ… Finalized" in swap_partial_seed_stake_result.stdout, ( + swap_partial_seed_stake_result.stderr + ) + print("โœ… Swap with partial stake seed stake finalized") + + # Verify stake was added + alice_stake_before_swap_partial = exec_command_alice( + command="stake", + sub_command="list", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--chain", + "ws://127.0.0.1:9945", + "--no-prompt", + "--verbose", + "--json-output", + ], + ) + alice_stake_list_before_swap_partial = json.loads( + alice_stake_before_swap_partial.stdout + ) + alice_stakes_before_swap_partial = find_stake_entries( + alice_stake_list_before_swap_partial, + netuid=2, + hotkey_ss58=wallet_alice.hotkey.ss58_address, + ) + assert len(alice_stakes_before_swap_partial) > 0 + assert any(stake["stake_value"] >= 20 for stake in alice_stakes_before_swap_partial) + + # Swap stake with slippage protection and partial stake (--safe --tolerance --partial) + swap_partial_amount = 20 + swap_partial_result = exec_command_alice( + command="stake", + sub_command="swap", + extra_args=[ + "--origin-netuid", + "2", + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--dest-netuid", + "0", # Root netuid has more liquidity + "--amount", + str(swap_partial_amount), + "--chain", + "ws://127.0.0.1:9945", + "--no-prompt", + "--safe", + "--tolerance", + "0.1", # 10% tolerance + "--partial", # Allow partial stake if rates change + ], + ) + assert "โœ… Sent" in swap_partial_result.stdout, swap_partial_result.stderr + + # Verify stake was swapped (may be partial) + alice_stake_after_swap_partial = exec_command_alice( + command="stake", + sub_command="list", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--chain", + "ws://127.0.0.1:9945", + "--no-prompt", + "--verbose", + "--json-output", + ], + ) + alice_stake_list_after_swap_partial = json.loads( + alice_stake_after_swap_partial.stdout + ) + alice_stakes_after_swap_partial = find_stake_entries( + alice_stake_list_after_swap_partial, + netuid=0, # Root netuid + hotkey_ss58=wallet_alice.hotkey.ss58_address, + ) + # With partial stake, we expect at least some stake to be swapped + assert len(alice_stakes_after_swap_partial) > 0 + print( + "โœ… TEST 3: Swap with slippage protection and partial stake completed successfully" + ) + + print("โœ… Passed all slippage protection tests for swap command")