diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index def64c80d..08995dbec 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -417,6 +417,11 @@ def edit_help(cls, option_name: str, help_text: str): show_default=False, help="Enable or disable MEV protection [dim](default: enabled)[/dim].", ) + relay = typer.Option( + None, + "--relay", + help="Coldkey wallet name or SS58 that signs the outer MEV Shield transaction (requires MEV protection).", + ) json_output = typer.Option( False, "--json-output", @@ -2279,6 +2284,38 @@ def is_valid_proxy_name_or_ss58( raise typer.BadParameter(f"Invalid SS58 address: {address}") return address + def resolve_relay_wallet( + self, relay: Optional[str], wallet_base_path: str + ) -> Wallet: + """ + Resolve ``--relay`` to a Wallet whose coldkey signs the outer MEV Shield extrinsic. + + ``relay`` may be a coldkey SS58 present under ``wallet_base_path`` or a wallet directory name. + """ + relay = relay.strip() if relay else "" + if not relay: + print_error("Relay wallet name or SS58 is empty.") + raise typer.Exit(1) + if is_valid_ss58_address(relay): + from bittensor_cli.src.commands.wallets import _get_wallet_by_ss58 + + w = _get_wallet_by_ss58(wallet_base_path, relay) + if w is None: + print_error( + f"No wallet under {wallet_base_path} has coldkey {relay}. " + "Import the relay coldkey or pass a wallet name." + ) + raise typer.Exit(1) + return w + w = Wallet(name=relay, path=wallet_base_path) + valid = utils.is_valid_wallet(w) + if not valid[0]: + print_error( + f"Relay wallet '{relay}' does not exist under {wallet_base_path}." + ) + raise typer.Exit(1) + return w + def ask_rate_tolerance( self, rate_tolerance: Optional[float], @@ -4772,6 +4809,7 @@ def stake_add( quiet: bool = Options.quiet, verbose: bool = Options.verbose, json_output: bool = Options.json_output, + relay: Optional[str] = Options.relay, ): """ Stake TAO to one or more hotkeys on specific netuids with your coldkey. @@ -4812,6 +4850,11 @@ def stake_add( """ netuids = netuids or [] self.verbosity_handler(quiet, verbose, json_output, prompt, decline) + if relay and not mev_protection: + print_error( + "Cannot use --relay without MEV protection (--mev-protection)." + ) + raise typer.Exit(1) proxy = self.is_valid_proxy_name_or_ss58(proxy, False) safe_staking = self.ask_safe_staking(safe_staking) if safe_staking: @@ -5021,6 +5064,7 @@ def stake_add( f"period: {period}\n" f"mev_protection: {mev_protection}\n" ) + relay_wallet = self.resolve_relay_wallet(relay, wallet.path) if relay else None return self._run_command( add_stake.stake_add( wallet=wallet, @@ -5041,6 +5085,7 @@ def stake_add( era=period, proxy=proxy, mev_protection=mev_protection, + relay_wallet=relay_wallet, ) ) @@ -5106,6 +5151,7 @@ def stake_remove( quiet: bool = Options.quiet, verbose: bool = Options.verbose, json_output: bool = Options.json_output, + relay: Optional[str] = Options.relay, ): """ Unstake TAO from one or more hotkeys and transfer them back to the user's coldkey wallet. @@ -5141,6 +5187,11 @@ def stake_remove( • [blue]--partial[/blue]: Complete partial unstake if rates exceed tolerance """ self.verbosity_handler(quiet, verbose, json_output, prompt, decline) + if relay and not mev_protection: + print_error( + "Cannot use --relay without MEV protection (--mev-protection)." + ) + raise typer.Exit(1) proxy = self.is_valid_proxy_name_or_ss58(proxy, False) if not unstake_all and not unstake_all_alpha: safe_staking = self.ask_safe_staking(safe_staking) @@ -5315,6 +5366,9 @@ def stake_remove( f"era: {period}\n" f"mev_protection: {mev_protection}" ) + relay_wallet = ( + self.resolve_relay_wallet(relay, wallet.path) if relay else None + ) return self._run_command( remove_stake.unstake_all( wallet=wallet, @@ -5331,6 +5385,7 @@ def stake_remove( era=period, mev_protection=mev_protection, proxy=proxy, + relay_wallet=relay_wallet, ) ) elif ( @@ -5387,6 +5442,7 @@ def stake_remove( f"mev_protection: {mev_protection}\n" ) + relay_wallet = self.resolve_relay_wallet(relay, wallet.path) if relay else None return self._run_command( remove_stake.unstake( wallet=wallet, @@ -5408,6 +5464,7 @@ def stake_remove( era=period, proxy=proxy, mev_protection=mev_protection, + relay_wallet=relay_wallet, ) ) @@ -5460,6 +5517,7 @@ def stake_move( quiet: bool = Options.quiet, verbose: bool = Options.verbose, json_output: bool = Options.json_output, + relay: Optional[str] = Options.relay, ): """ Move staked TAO between hotkeys while keeping the same coldkey ownership. @@ -5486,6 +5544,11 @@ def stake_move( [green]$[/green] btcli stake move --no-mev-protection """ self.verbosity_handler(quiet, verbose, json_output, prompt, decline) + if relay and not mev_protection: + print_error( + "Cannot use --relay without MEV protection (--mev-protection)." + ) + raise typer.Exit(1) proxy = self.is_valid_proxy_name_or_ss58(proxy, False) print_protection_warnings( mev_protection=mev_protection, @@ -5595,6 +5658,7 @@ def stake_move( f"proxy: {proxy}\n" f"mev_protection: {mev_protection}\n" ) + relay_wallet = self.resolve_relay_wallet(relay, wallet.path) if relay else None result, ext_id = self._run_command( move_stake.move_stake( subtensor=self.initialize_chain(network), @@ -5612,6 +5676,7 @@ def stake_move( quiet=quiet, proxy=proxy, mev_protection=mev_protection, + relay_wallet=relay_wallet, ) ) if json_output: @@ -5660,6 +5725,7 @@ def stake_transfer( quiet: bool = Options.quiet, verbose: bool = Options.verbose, json_output: bool = Options.json_output, + relay: Optional[str] = Options.relay, ): """ Transfer stake between coldkeys while keeping the same hotkey ownership. @@ -5697,6 +5763,11 @@ def stake_transfer( [green]$[/green] btcli stake transfer --origin-netuid 1 --dest-netuid 2 --amount 100 --no-mev-protection """ self.verbosity_handler(quiet, verbose, json_output, prompt, decline) + if relay and not mev_protection: + print_error( + "Cannot use --relay without MEV protection (--mev-protection)." + ) + raise typer.Exit(1) proxy = self.is_valid_proxy_name_or_ss58(proxy, False) print_protection_warnings( mev_protection=mev_protection, @@ -5802,6 +5873,7 @@ def stake_transfer( f"mev_protection: {mev_protection}" f"proxy: {proxy}" ) + relay_wallet = self.resolve_relay_wallet(relay, wallet.path) if relay else None result, ext_id = self._run_command( move_stake.transfer_stake( wallet=wallet, @@ -5819,6 +5891,7 @@ def stake_transfer( quiet=quiet, proxy=proxy, mev_protection=mev_protection, + relay_wallet=relay_wallet, ) ) if json_output: @@ -5872,6 +5945,7 @@ def stake_swap( quiet: bool = Options.quiet, verbose: bool = Options.verbose, json_output: bool = Options.json_output, + relay: Optional[str] = Options.relay, ): """ Swap stake between different subnets while keeping the same coldkey-hotkey pair ownership. @@ -5903,6 +5977,11 @@ def stake_swap( [green]$[/green] btcli stake swap --origin-netuid 1 --dest-netuid 2 --amount 100 --unsafe """ self.verbosity_handler(quiet, verbose, json_output, prompt, decline) + if relay and not mev_protection: + print_error( + "Cannot use --relay without MEV protection (--mev-protection)." + ) + raise typer.Exit(1) proxy = self.is_valid_proxy_name_or_ss58(proxy, False) console.print( "[dim]This command moves stake from one subnet to another subnet while keeping " @@ -5959,6 +6038,7 @@ def stake_swap( f"wait_for_finalization: {wait_for_finalization}\n" f"mev_protection: {mev_protection}\n" ) + relay_wallet = self.resolve_relay_wallet(relay, wallet.path) if relay else None result, ext_id = self._run_command( move_stake.swap_stake( wallet=wallet, @@ -5979,6 +6059,7 @@ def stake_swap( wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, mev_protection=mev_protection, + relay_wallet=relay_wallet, ) ) if json_output: diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index 6cb3f592a..356da9679 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -1159,6 +1159,7 @@ async def sign_and_send_extrinsic( sign_with: Literal["coldkey", "hotkey", "coldkeypub"] = "coldkey", announce_only: bool = False, mev_protection: bool = False, + relay_wallet: Optional[Wallet] = None, ) -> tuple[bool, str, Optional[AsyncExtrinsicReceipt]]: """ Helper method to sign and submit an extrinsic call to chain. @@ -1175,14 +1176,17 @@ async def sign_and_send_extrinsic( be used with `mev_protection=True`. :param mev_protection: If set, uses Mev Protection on the extrinsic, thus encrypting it. Cannot be used with `announce_only=True`. + :param relay_wallet: If set with ``mev_protection``, signs the outer ``MevShield.submit_encrypted`` + extrinsic with this wallet's coldkey instead of ``wallet``. :return: (success, error message or inner extrinsic hash (if using mev_protection), extrinsic receipt | None) """ - async def create_signed(call_to_sign, n): + async def create_signed(call_to_sign, n, signing_keypair: Optional[Keypair] = None): + kp = signing_keypair if signing_keypair is not None else keypair kwargs = { "call": call_to_sign, - "keypair": keypair, + "keypair": kp, "nonce": n, } if era is not None: @@ -1194,6 +1198,10 @@ async def create_signed(call_to_sign, n): "Cannot use announce-only and mev-protection. Calls should be announced without mev protection," "and executed with them." ) + if relay_wallet is not None and not mev_protection: + raise ValueError( + "Relay requires MEV protection; remove relay or enable MEV protection." + ) if proxy is not None: if announce_only: call_to_announce = call @@ -1236,7 +1244,17 @@ async def create_signed(call_to_sign, n): inner_extrinsic = await create_signed(call, next_nonce) inner_hash = f"0x{inner_extrinsic.extrinsic_hash.hex()}" shield_call = await encrypt_extrinsic(self, inner_extrinsic) - extrinsic = await create_signed(shield_call, nonce) + if relay_wallet is not None: + relay_keypair = relay_wallet.coldkey + outer_nonce = await self.substrate.get_account_next_index( + relay_keypair.ss58_address + ) + extrinsic = await create_signed( + shield_call, outer_nonce, signing_keypair=relay_keypair + ) + else: + outer_nonce = nonce if nonce is not None else call_args["nonce"] + extrinsic = await create_signed(shield_call, outer_nonce) else: extrinsic = await self.substrate.create_signed_extrinsic(**call_args) try: @@ -1304,6 +1322,7 @@ async def sign_and_send_batch_extrinsic( announce_only: bool = False, mev_protection: bool = False, block_hash: Optional[str] = None, + relay_wallet: Optional[Wallet] = None, ) -> tuple[bool, str, Optional[AsyncExtrinsicReceipt]]: """ Wraps multiple extrinsic calls into a single Utility.batch_all transaction @@ -1325,6 +1344,7 @@ async def sign_and_send_batch_extrinsic( :param announce_only: make the call as a proxy announcement. :param mev_protection: encrypt the extrinsic via MEV Shield. :param block_hash: cached block hash for compose_call. Fetched if None. + :param relay_wallet: If set with ``mev_protection``, signs the outer MEV Shield extrinsic with this wallet. :return: (success, error message or inner hash, extrinsic receipt | None) """ @@ -1344,6 +1364,7 @@ async def sign_and_send_batch_extrinsic( sign_with=sign_with, announce_only=announce_only, mev_protection=mev_protection, + relay_wallet=relay_wallet, ) if block_hash is None: @@ -1367,6 +1388,7 @@ async def sign_and_send_batch_extrinsic( sign_with=sign_with, announce_only=announce_only, mev_protection=mev_protection, + relay_wallet=relay_wallet, ) async def get_children(self, hotkey, netuid) -> tuple[bool, list, str]: diff --git a/bittensor_cli/src/commands/stake/add.py b/bittensor_cli/src/commands/stake/add.py index f165bf3d6..162feda50 100644 --- a/bittensor_cli/src/commands/stake/add.py +++ b/bittensor_cli/src/commands/stake/add.py @@ -53,6 +53,7 @@ async def stake_add( era: int, mev_protection: bool, proxy: Optional[str], + relay_wallet: Optional[Wallet] = None, ): """ Args: @@ -72,6 +73,7 @@ async def stake_add( era: Blocks for which the transaction should be valid. proxy: Optional proxy to use for staking. mev_protection: If true, will encrypt the extrinsic behind the mev protection shield. + relay_wallet: If set with MEV protection, signs the outer MEV Shield extrinsic with this coldkey. Returns: bool: True if stake operation is successful, False otherwise @@ -150,6 +152,7 @@ async def safe_stake_extrinsic( era={"period": era}, proxy=proxy, mev_protection=mev_protection, + relay_wallet=relay_wallet, ) if not success_: if "Custom error: 8" in err_msg: @@ -248,6 +251,7 @@ async def stake_extrinsic( era={"period": era}, proxy=proxy, mev_protection=mev_protection, + relay_wallet=relay_wallet, ) if not success_: err_msg = f"{failure_prelude} with error: {err_msg}" @@ -481,6 +485,8 @@ async def stake_extrinsic( return if not unlock_key(wallet).success: return + if relay_wallet is not None and not unlock_key(relay_wallet).success: + return total_ops = len(operations) use_batch = total_ops > 1 @@ -538,6 +544,7 @@ async def stake_extrinsic( proxy=proxy, mev_protection=mev_protection, block_hash=batch_block_hash, + relay_wallet=relay_wallet, ) if success and mev_protection: diff --git a/bittensor_cli/src/commands/stake/move.py b/bittensor_cli/src/commands/stake/move.py index 229ff76ec..7007dc4b6 100644 --- a/bittensor_cli/src/commands/stake/move.py +++ b/bittensor_cli/src/commands/stake/move.py @@ -561,6 +561,7 @@ async def move_stake( quiet: bool = False, proxy: Optional[str] = None, mev_protection: bool = True, + relay_wallet: Optional[Wallet] = None, ) -> tuple[bool, str]: coldkey_ss58 = proxy or wallet.coldkeypub.ss58_address if interactive_selection: @@ -691,6 +692,8 @@ async def move_stake( # Perform moving operation. if not unlock_key(wallet).success: return False, "" + if relay_wallet is not None and not unlock_key(relay_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: " f"[blue]{origin_netuid}[/blue] \nto " @@ -703,6 +706,7 @@ async def move_stake( proxy=proxy, mev_protection=mev_protection, nonce=next_nonce, + relay_wallet=relay_wallet, ) ext_id = await response.get_extrinsic_identifier() if response else "" @@ -774,6 +778,7 @@ async def transfer_stake( quiet: bool = False, proxy: Optional[str] = None, mev_protection: bool = True, + relay_wallet: Optional[Wallet] = None, ) -> tuple[bool, str]: """Transfers stake from one network to another. @@ -912,6 +917,8 @@ async def transfer_stake( # Perform transfer operation if not unlock_key(wallet).success: return False, "" + if relay_wallet is not None and not unlock_key(relay_wallet).success: + return False, "" with console.status("\n:satellite: Transferring stake ...") as status: success_, err_msg, response = await subtensor.sign_and_send_extrinsic( @@ -921,6 +928,7 @@ async def transfer_stake( proxy=proxy, mev_protection=mev_protection, nonce=next_nonce, + relay_wallet=relay_wallet, ) if success_: @@ -990,6 +998,7 @@ async def swap_stake( wait_for_inclusion: bool = True, wait_for_finalization: bool = False, mev_protection: bool = True, + relay_wallet: Optional[Wallet] = None, ) -> tuple[bool, str]: """Swaps stake between subnets while keeping the same coldkey-hotkey pair ownership. @@ -1143,6 +1152,8 @@ async def swap_stake( # Perform swap operation if not unlock_key(wallet).success: return False, "" + if relay_wallet is not None and not unlock_key(relay_wallet).success: + return False, "" with console.status( f"\n:satellite: Swapping stake from netuid [blue]{origin_netuid}[/blue] " @@ -1157,6 +1168,7 @@ async def swap_stake( wait_for_inclusion=wait_for_inclusion, mev_protection=mev_protection, nonce=next_nonce, + relay_wallet=relay_wallet, ) ext_id = await response.get_extrinsic_identifier() diff --git a/bittensor_cli/src/commands/stake/remove.py b/bittensor_cli/src/commands/stake/remove.py index 555974504..d02d3d907 100644 --- a/bittensor_cli/src/commands/stake/remove.py +++ b/bittensor_cli/src/commands/stake/remove.py @@ -57,6 +57,7 @@ async def unstake( era: int, proxy: Optional[str], mev_protection: bool, + relay_wallet: Optional[Wallet] = None, ): """Unstake from hotkey(s).""" coldkey_ss58 = proxy or wallet.coldkeypub.ss58_address @@ -102,6 +103,13 @@ async def unstake( hotkey_ss58_address=hotkey_to_unstake_all[1], unstake_all_alpha=unstake_all_alpha, prompt=prompt, + decline=decline, + quiet=quiet, + json_output=json_output, + era=era, + proxy=proxy, + mev_protection=mev_protection, + relay_wallet=relay_wallet, ) if not hotkeys_to_unstake_from: @@ -325,6 +333,8 @@ async def unstake( # Execute extrinsics if not unlock_key(wallet).success: return False + if relay_wallet is not None and not unlock_key(relay_wallet).success: + return False total_ops = len(unstake_operations) use_batch = total_ops > 1 @@ -379,6 +389,7 @@ async def unstake( proxy=proxy, mev_protection=mev_protection, block_hash=batch_block_hash, + relay_wallet=relay_wallet, ) if success and mev_protection: @@ -459,6 +470,7 @@ async def unstake( "era": era, "proxy": proxy, "mev_protection": mev_protection, + "relay_wallet": relay_wallet, } if safe_staking and op["netuid"] != 0: @@ -507,6 +519,7 @@ async def unstake_all( json_output: bool = False, proxy: Optional[str] = None, mev_protection: bool = True, + relay_wallet: Optional[Wallet] = None, ) -> None: """Unstakes all stakes from all hotkeys in all subnets.""" include_hotkeys = include_hotkeys or [] @@ -667,6 +680,8 @@ async def unstake_all( if not unlock_key(wallet).success: return + if relay_wallet is not None and not unlock_key(relay_wallet).success: + return successes = {} use_batch = len(hotkey_ss58s) > 1 @@ -696,6 +711,7 @@ async def unstake_all( proxy=proxy, mev_protection=mev_protection, block_hash=batch_block_hash, + relay_wallet=relay_wallet, ) if success and mev_protection: @@ -755,6 +771,7 @@ async def unstake_all( era=era, proxy=proxy, mev_protection=mev_protection, + relay_wallet=relay_wallet, ) ext_id = ( await ext_receipt.get_extrinsic_identifier() if success else None @@ -779,6 +796,7 @@ async def _unstake_extrinsic( era: int = 3, proxy: Optional[str] = None, mev_protection: bool = True, + relay_wallet: Optional[Wallet] = None, ) -> tuple[bool, Optional[AsyncExtrinsicReceipt]]: """Execute a standard unstake extrinsic. @@ -828,6 +846,7 @@ async def _unstake_extrinsic( proxy=proxy, mev_protection=mev_protection, nonce=next_nonce, + relay_wallet=relay_wallet, ) if success: if mev_protection: @@ -880,6 +899,7 @@ async def _safe_unstake_extrinsic( era: int = 3, proxy: Optional[str] = None, mev_protection: bool = True, + relay_wallet: Optional[Wallet] = None, ) -> tuple[bool, Optional[AsyncExtrinsicReceipt]]: """Execute a safe unstake extrinsic with price limit. @@ -938,6 +958,7 @@ async def _safe_unstake_extrinsic( era={"period": era}, proxy=proxy, mev_protection=mev_protection, + relay_wallet=relay_wallet, ) if success: if mev_protection: @@ -1005,6 +1026,7 @@ async def _unstake_all_extrinsic( era: int = 3, proxy: Optional[str] = None, mev_protection: bool = True, + relay_wallet: Optional[Wallet] = None, ) -> tuple[bool, Optional[AsyncExtrinsicReceipt]]: """Execute an unstake all extrinsic. @@ -1062,6 +1084,7 @@ async def _unstake_all_extrinsic( nonce=next_nonce, proxy=proxy, mev_protection=mev_protection, + relay_wallet=relay_wallet, ) if not success_: