From 2b35709bc8d2374e4a99f9638f96780f80e297aa Mon Sep 17 00:00:00 2001 From: bittoby <218712309+bittoby@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:13:08 +0000 Subject: [PATCH 01/10] feat: Add batch extrinsic support to reduce transaction fees --- CHANGELOG.md | 14 +- .../src/bittensor/subtensor_interface.py | 82 ++++- bittensor_cli/src/commands/stake/add.py | 205 ++++++++++--- bittensor_cli/src/commands/stake/remove.py | 287 +++++++++++++++--- pyproject.toml | 2 +- tests/unit_tests/test_batching.py | 254 ++++++++++++++++ 6 files changed, 745 insertions(+), 99 deletions(-) create mode 100644 tests/unit_tests/test_batching.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 68714fd26..97a21833e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ * fix: JSON output empty for `btcli subnets list --json-out` command by @GlobalStar117 in https://github.com/opentensor/btcli/pull/800 * fix: disable wallet history command due to external API deprecation by @jose-blockchain in https://github.com/opentensor/btcli/pull/811 * Reusable `create_table()` Utility for Consistent Table Styling by @eureka928 in https://github.com/opentensor/btcli/pull/790 -* fix: replace broad exception catches with specific exception types by @Good0987 in https://github.com/opentensor/btcli/pull/773 +* fix: replace broad exception catches with specific exception types by @Achieve3318 in https://github.com/opentensor/btcli/pull/773 * Improve disk caching by @thewhaleking in https://github.com/opentensor/btcli/pull/682 * Feat/rework ck swap by @ibraheem-abe in https://github.com/opentensor/btcli/pull/792 * Error message handled properly by @thewhaleking in https://github.com/opentensor/btcli/pull/814 @@ -29,11 +29,21 @@ * Fix: Remove old mev_shield artifact from stake_burn by @ibraheem-abe in https://github.com/opentensor/btcli/pull/842 * Revert "Feat/balancer swap updates" by @ibraheem-abe in https://github.com/opentensor/btcli/pull/836 * Update/CK swap error handling by @ibraheem-abe in https://github.com/opentensor/btcli/pull/844 +* Tests: Add custom tags for docker images in e2e by @ibraheem-abe in https://github.com/opentensor/btcli/pull/848 +* Update: Cap MeV shield txs era to 8 by @ibraheem-abe in https://github.com/opentensor/btcli/pull/850 +* Update: Enforce era 'always' for mev_shield txs by @ibraheem-abe in https://github.com/opentensor/btcli/pull/851 +* Adds max_allowed_uids hyperparam for setting by @thewhaleking in https://github.com/opentensor/btcli/pull/852 +* Applies type hint to to `process_nested` by @thewhaleking in https://github.com/opentensor/btcli/pull/856 +* Update/runtime update by @ibraheem-abe in https://github.com/opentensor/btcli/pull/857 +* feat: add support for the `--all` to proxy remove by @eureka928 in https://github.com/opentensor/btcli/pull/834 +* Adds signed commits info to docs by @thewhaleking in https://github.com/opentensor/btcli/pull/859 +* Add better typing by @thewhaleking in https://github.com/opentensor/btcli/pull/858 +* Update: Pin btwallet requirement by @ibraheem-abe in https://github.com/opentensor/btcli/pull/864 ## New Contributors * @GlobalStar117 made their first contribution in https://github.com/opentensor/btcli/pull/800 * @jose-blockchain made their first contribution in https://github.com/opentensor/btcli/pull/811 -* @Good0987 made their first contribution in https://github.com/opentensor/btcli/pull/773 +* @Achieve3318 made their first contribution in https://github.com/opentensor/btcli/pull/773 * @GeObts made their first contribution in https://github.com/opentensor/btcli/pull/839 **Full Changelog**: https://github.com/opentensor/btcli/compare/v9.18.1...v9.19.0 diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index 41edb1cea..e439ae8e3 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -1271,8 +1271,8 @@ async def create_signed(call_to_sign, n): err_msg = format_error_message(e) if mev_protection and "'result': 'invalid'" in str(e).lower(): err_msg = ( - f"MEV Shield extrinsic rejected as invalid. " - f"This usually means the MEV Shield NextKey changed between fetching and submission." + "MEV Shield extrinsic rejected as invalid. " + "This usually means the MEV Shield NextKey changed between fetching and submission." ) if proxy and "Invalid Transaction" in err_msg: extrinsic_fee, signer_balance = await asyncio.gather( @@ -1289,6 +1289,84 @@ async def create_signed(call_to_sign, n): ) return False, err_msg, None + async def sign_and_send_batch_extrinsic( + self, + calls: list[GenericCall], + wallet: Wallet, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, + era: Optional[dict[str, int]] = None, + proxy: Optional[str] = None, + nonce: Optional[int] = None, + sign_with: Literal["coldkey", "hotkey", "coldkeypub"] = "coldkey", + announce_only: bool = False, + mev_protection: bool = False, + block_hash: Optional[str] = None, + ) -> tuple[bool, str, Optional[AsyncExtrinsicReceipt]]: + """ + Wraps multiple extrinsic calls into a single Utility.batch_all transaction + and submits it. This reduces fees by combining N separate transactions into one. + + batch_all is atomic: if any call in the batch fails, the entire batch reverts. + + For a single call, this delegates directly to sign_and_send_extrinsic without + wrapping, so there's no overhead for non-batch use cases. + + :param calls: list of prepared GenericCall objects to batch together. + :param wallet: the wallet whose key will sign the extrinsic. + :param wait_for_inclusion: wait until the extrinsic is included on chain. + :param wait_for_finalization: wait until the extrinsic is finalized on chain. + :param era: validity period in blocks for the transaction. + :param proxy: the real account if using a proxy. None otherwise. + :param nonce: explicit nonce for submission. Fetched automatically if None. + :param sign_with: which wallet keypair signs the 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. + + :return: (success, error message or inner hash, extrinsic receipt | None) + """ + if not calls: + return False, "No calls to batch", None + + # No need to wrap a single call in a batch + if len(calls) == 1: + return await self.sign_and_send_extrinsic( + call=calls[0], + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + era=era, + proxy=proxy, + nonce=nonce, + sign_with=sign_with, + announce_only=announce_only, + mev_protection=mev_protection, + ) + + if block_hash is None: + block_hash = await self.substrate.get_chain_head() + + batch_call = await self.substrate.compose_call( + call_module="Utility", + call_function="batch_all", + call_params={"calls": calls}, + block_hash=block_hash, + ) + + return await self.sign_and_send_extrinsic( + call=batch_call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + era=era, + proxy=proxy, + nonce=nonce, + sign_with=sign_with, + announce_only=announce_only, + mev_protection=mev_protection, + ) + async def get_children(self, hotkey, netuid) -> tuple[bool, list, str]: """ This method retrieves the children of a given hotkey and netuid. It queries the SubtensorModule's ChildKeys diff --git a/bittensor_cli/src/commands/stake/add.py b/bittensor_cli/src/commands/stake/add.py index 2172de8ac..01206492c 100644 --- a/bittensor_cli/src/commands/stake/add.py +++ b/bittensor_cli/src/commands/stake/add.py @@ -467,62 +467,169 @@ async def stake_extrinsic( if not unlock_key(wallet).success: return + # Build the list of (netuid, hotkey, amount, current_stake, price_limit) tuples + # that describe each staking operation we need to perform. + # The zip aligns netuids with amounts/balances (which are populated per + # hotkey-netuid pair, but the zip truncates to len(netuids), matching the + # original execution order). Each netuid's amount/price applies to all hotkeys. + operations = [] + if safe_staking: + for ni, am, curr, price in zip( + netuids, amounts_to_stake, current_stake_balances, prices_with_tolerance + ): + for _, staking_address in hotkeys_to_stake_to: + operations.append((ni, staking_address, am, curr, price)) + else: + for ni, am, curr in zip(netuids, amounts_to_stake, current_stake_balances): + for _, staking_address in hotkeys_to_stake_to: + operations.append((ni, staking_address, am, curr, None)) + + total_ops = len(operations) + use_batch = total_ops > 1 + successes = defaultdict(dict) error_messages = defaultdict(dict) extrinsic_ids = defaultdict(dict) - with console.status(f"\n:satellite: Staking on netuid(s): {netuids} ...") as status: - if safe_staking: - stake_coroutines = {} - for i, (ni, am, curr, price_with_tolerance) in enumerate( - zip( - netuids, - amounts_to_stake, - current_stake_balances, - prices_with_tolerance, - ) - ): - for _, staking_address in hotkeys_to_stake_to: - # Regular extrinsic for root subnet - if ni == 0: - stake_coroutines[(ni, staking_address)] = stake_extrinsic( - netuid_i=ni, - amount_=am, - current=curr, - staking_address_ss58=staking_address, - status_=status, + + if use_batch: + # Batch path: compose all calls, submit as a single Utility.batch_all transaction + with console.status( + f"\n:satellite: Batching {total_ops} stake operations on netuid(s): {netuids} ..." + ) as status: + batch_block_hash = await subtensor.substrate.get_chain_head() + current_balance = await subtensor.get_balance( + coldkey_ss58, block_hash=batch_block_hash + ) + + # compose_call with a block_hash does no I/O, so no need for gather + calls = [] + for ni, hk, am, _, price in operations: + if safe_staking and ni != 0: + calls.append( + await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="add_stake_limit", + call_params={ + "hotkey": hk, + "netuid": ni, + "amount_staked": am.rao, + "limit_price": price.rao, + "allow_partial": allow_partial_stake, + }, + block_hash=batch_block_hash, ) - else: - stake_coroutines[(ni, staking_address)] = safe_stake_extrinsic( - netuid_=ni, - amount_=am, - current_stake=curr, - hotkey_ss58_=staking_address, - price_limit=price_with_tolerance, - status_=status, + ) + else: + calls.append( + await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="add_stake", + call_params={ + "hotkey": hk, + "netuid": ni, + "amount_staked": am.rao, + }, + block_hash=batch_block_hash, ) - else: - stake_coroutines = { - (ni, staking_address): stake_extrinsic( - netuid_i=ni, - amount_=am, - current=curr, - staking_address_ss58=staking_address, - status_=status, - ) - for i, (ni, am, curr) in enumerate( - zip(netuids, amounts_to_stake, current_stake_balances) + ) + + success, err_msg, response = await subtensor.sign_and_send_batch_extrinsic( + calls=list(calls), + wallet=wallet, + era={"period": era}, + proxy=proxy, + mev_protection=mev_protection, + block_hash=batch_block_hash, + ) + + if success and mev_protection: + inner_hash = err_msg + success, mev_error, response = await wait_for_extrinsic_by_hash( + subtensor=subtensor, + extrinsic_hash=inner_hash, + submit_block_hash=response.block_hash, + status=status, ) - for _, staking_address in hotkeys_to_stake_to - } - # We can gather them all at once but balance reporting will be in race-condition. - for (ni, staking_address), coroutine in stake_coroutines.items(): - success, er_msg, ext_receipt = await coroutine - successes[ni][staking_address] = success - error_messages[ni][staking_address] = er_msg + if not success: + err_msg = mev_error + + # batch_all is atomic: all succeed or all revert + for ni, hk, am, curr, _ in operations: + successes[ni][hk] = success + error_messages[ni][hk] = "" if success else err_msg + if success: - extrinsic_ids[ni][ - staking_address - ] = await ext_receipt.get_extrinsic_identifier() + ext_id = await response.get_extrinsic_identifier() + for ni, hk, _, _, _ in operations: + extrinsic_ids[ni][hk] = ext_id + + if not json_output: + await print_extrinsic_id(response) + new_block_hash = await subtensor.substrate.get_chain_head() + new_balance = await subtensor.get_balance( + coldkey_ss58, block_hash=new_block_hash + ) + print_success( + f"[dark_sea_green3]Batch finalized. " + f"Staked across {total_ops} operations.[/dark_sea_green3]" + ) + console.print( + f"Balance:\n [blue]{current_balance}[/blue] :arrow_right: " + f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_balance}" + ) + for ni, hk, am, curr, _ in operations: + new_stake = await subtensor.get_stake( + hotkey_ss58=hk, + coldkey_ss58=coldkey_ss58, + netuid=ni, + block_hash=new_block_hash, + ) + console.print( + f"Subnet: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]" + f"{ni}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}] " + f"Hotkey: [{COLOR_PALETTE['GENERAL']['HOTKEY']}]{hk}" + f"[/{COLOR_PALETTE['GENERAL']['HOTKEY']}] " + f"Stake:\n" + f" [blue]{curr}[/blue] " + f":arrow_right: " + f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_stake}" + ) + else: + print_error( + f":cross_mark: [red]Batch staking failed[/red]: {err_msg}", + status=status, + ) + else: + # Single operation path: use the existing per-operation extrinsics + with console.status( + f"\n:satellite: Staking on netuid(s): {netuids} ..." + ) as status: + for ni, staking_address, am, curr, price in operations: + if safe_staking and ni != 0: + result = await safe_stake_extrinsic( + netuid_=ni, + amount_=am, + current_stake=curr, + hotkey_ss58_=staking_address, + price_limit=price, + status_=status, + ) + else: + result = await stake_extrinsic( + netuid_i=ni, + amount_=am, + current=curr, + staking_address_ss58=staking_address, + status_=status, + ) + success, er_msg, ext_receipt = result + successes[ni][staking_address] = success + error_messages[ni][staking_address] = er_msg + if success and ext_receipt: + extrinsic_ids[ni][ + staking_address + ] = await ext_receipt.get_extrinsic_identifier() + if json_output: json_console.print_json( data={ diff --git a/bittensor_cli/src/commands/stake/remove.py b/bittensor_cli/src/commands/stake/remove.py index 84b753c40..b99458641 100644 --- a/bittensor_cli/src/commands/stake/remove.py +++ b/bittensor_cli/src/commands/stake/remove.py @@ -329,43 +329,163 @@ async def unstake( if not unlock_key(wallet).success: return False + total_ops = len(unstake_operations) + use_batch = total_ops > 1 successes = [] - with console.status("\n:satellite: Performing unstaking operations...") as status: - for op in unstake_operations: - common_args = { - "wallet": wallet, - "subtensor": subtensor, - "netuid": op["netuid"], - "amount": op["amount_to_unstake"], - "hotkey_ss58": op["hotkey_ss58"], - "status": status, - "era": era, - "proxy": proxy, - "mev_protection": mev_protection, - } - if safe_staking and op["netuid"] != 0: - func = _safe_unstake_extrinsic - specific_args = { - "price_limit": op["price_with_tolerance"], - "allow_partial_stake": allow_partial_stake, - } - else: - func = _unstake_extrinsic - specific_args = {"current_stake": op["current_stake_balance"]} + if use_batch: + # Batch path: compose all calls, submit as a single Utility.batch_all transaction + with console.status( + f"\n:satellite: Batching {total_ops} unstake operations..." + ) as status: + batch_block_hash = await subtensor.substrate.get_chain_head() + current_balance = await subtensor.get_balance( + coldkey_ss58, block_hash=batch_block_hash + ) - suc, ext_receipt = await func(**common_args, **specific_args) - ext_id = await ext_receipt.get_extrinsic_identifier() if suc else None + # compose_call with a block_hash does no I/O, so no need for gather + calls = [] + for op in unstake_operations: + if safe_staking and op["netuid"] != 0: + calls.append( + await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="remove_stake_limit", + call_params={ + "hotkey": op["hotkey_ss58"], + "netuid": op["netuid"], + "amount_unstaked": op["amount_to_unstake"].rao, + "limit_price": op["price_with_tolerance"], + "allow_partial": allow_partial_stake, + }, + block_hash=batch_block_hash, + ) + ) + else: + calls.append( + await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="remove_stake", + call_params={ + "hotkey": op["hotkey_ss58"], + "netuid": op["netuid"], + "amount_unstaked": op["amount_to_unstake"].rao, + }, + block_hash=batch_block_hash, + ) + ) - successes.append( - { + success, err_msg, response = await subtensor.sign_and_send_batch_extrinsic( + calls=list(calls), + wallet=wallet, + era={"period": era}, + proxy=proxy, + mev_protection=mev_protection, + block_hash=batch_block_hash, + ) + + if success and mev_protection: + inner_hash = err_msg + success, mev_error, response = await wait_for_extrinsic_by_hash( + subtensor=subtensor, + extrinsic_hash=inner_hash, + submit_block_hash=response.block_hash, + status=status, + ) + if not success: + err_msg = mev_error + + ext_id = ( + await response.get_extrinsic_identifier() + if success and response + else None + ) + + for op in unstake_operations: + successes.append( + { + "netuid": op["netuid"], + "hotkey_ss58": op["hotkey_ss58"], + "unstake_amount": op["amount_to_unstake"].tao, + "success": success, + "extrinsic_identifier": ext_id, + } + ) + + if success: + if not json_output: + await print_extrinsic_id(response) + new_block_hash = await subtensor.substrate.get_chain_head() + new_balance = await subtensor.get_balance( + coldkey_ss58, block_hash=new_block_hash + ) + print_success( + f"Batch finalized. Unstaked across {total_ops} operations." + ) + console.print( + f"Balance:\n [blue]{current_balance}[/blue] :arrow_right: " + f"[{COLOR_PALETTE.S.AMOUNT}]{new_balance}" + ) + for op in unstake_operations: + new_stake = await subtensor.get_stake( + hotkey_ss58=op["hotkey_ss58"], + coldkey_ss58=coldkey_ss58, + netuid=op["netuid"], + block_hash=new_block_hash, + ) + console.print( + f"Subnet: [{COLOR_PALETTE.G.SUBHEAD}]{op['netuid']}" + f"[/{COLOR_PALETTE.G.SUBHEAD}] " + f"Hotkey: [{COLOR_PALETTE.G.HK}]{op['hotkey_ss58']}" + f"[/{COLOR_PALETTE.G.HK}] " + f"Stake:\n [blue]{op['current_stake_balance']}[/blue] " + f":arrow_right: [{COLOR_PALETTE.S.AMOUNT}]{new_stake}" + ) + else: + print_error( + f":cross_mark: [red]Batch unstaking failed[/red]: {err_msg}", + status=status, + ) + else: + # Single operation path: use the existing per-operation extrinsics + with console.status( + "\n:satellite: Performing unstaking operations..." + ) as status: + for op in unstake_operations: + common_args = { + "wallet": wallet, + "subtensor": subtensor, "netuid": op["netuid"], + "amount": op["amount_to_unstake"], "hotkey_ss58": op["hotkey_ss58"], - "unstake_amount": op["amount_to_unstake"].tao, - "success": suc, - "extrinsic_identifier": ext_id, + "status": status, + "era": era, + "proxy": proxy, + "mev_protection": mev_protection, } - ) + + if safe_staking and op["netuid"] != 0: + func = _safe_unstake_extrinsic + specific_args = { + "price_limit": op["price_with_tolerance"], + "allow_partial_stake": allow_partial_stake, + } + else: + func = _unstake_extrinsic + specific_args = {"current_stake": op["current_stake_balance"]} + + suc, ext_receipt = await func(**common_args, **specific_args) + ext_id = await ext_receipt.get_extrinsic_identifier() if suc else None + + successes.append( + { + "netuid": op["netuid"], + "hotkey_ss58": op["hotkey_ss58"], + "unstake_amount": op["amount_to_unstake"].tao, + "success": suc, + "extrinsic_identifier": ext_id, + } + ) console.print( f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]Unstaking operations completed." @@ -504,7 +624,7 @@ async def unstake_all( stake_amount = stake.stake try: - current_price = subnet_info.price.tao + _ = subnet_info.price.tao extrinsic_type = ( "unstake_all" if not unstake_all_alpha else "unstake_all_alpha" ) @@ -554,24 +674,101 @@ async def unstake_all( if not unlock_key(wallet).success: return successes = {} - with console.status("Unstaking all stakes...") as status: - for hotkey_ss58 in hotkey_ss58s: - success, ext_receipt = await _unstake_all_extrinsic( + use_batch = len(hotkey_ss58s) > 1 + + if use_batch: + # Batch path: compose unstake_all calls for all hotkeys into one transaction + with console.status( + f"Batching unstake-all for {len(hotkey_ss58s)} hotkeys..." + ) as status: + batch_block_hash = await subtensor.substrate.get_chain_head() + call_function = "unstake_all_alpha" if unstake_all_alpha else "unstake_all" + # compose_call with a block_hash does no I/O, so no need for gather + calls = [] + for hk in hotkey_ss58s: + calls.append( + await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function=call_function, + call_params={"hotkey": hk}, + block_hash=batch_block_hash, + ) + ) + + success, err_msg, response = await subtensor.sign_and_send_batch_extrinsic( + calls=list(calls), wallet=wallet, - subtensor=subtensor, - hotkey_ss58=hotkey_ss58, - hotkey_name=hotkey_names.get(hotkey_ss58, hotkey_ss58), - unstake_all_alpha=unstake_all_alpha, - status=status, - era=era, + era={"period": era}, proxy=proxy, mev_protection=mev_protection, + block_hash=batch_block_hash, ) - ext_id = await ext_receipt.get_extrinsic_identifier() if success else None - successes[hotkey_ss58] = { - "success": success, - "extrinsic_identifier": ext_id, - } + + if success and mev_protection: + inner_hash = err_msg + success, mev_error, response = await wait_for_extrinsic_by_hash( + subtensor=subtensor, + extrinsic_hash=inner_hash, + submit_block_hash=response.block_hash, + status=status, + ) + if not success: + err_msg = mev_error + + ext_id = ( + await response.get_extrinsic_identifier() + if success and response + else None + ) + + for hk in hotkey_ss58s: + successes[hk] = { + "success": success, + "extrinsic_identifier": ext_id, + } + + if success: + await print_extrinsic_id(response) + msg_modifier = "Alpha " if unstake_all_alpha else "" + new_block_hash = await subtensor.substrate.get_chain_head() + new_balance = await subtensor.get_balance( + coldkey_ss58, block_hash=new_block_hash + ) + print_success( + f"Batch finalized. Unstaked all {msg_modifier}stakes " + f"from {len(hotkey_ss58s)} hotkeys." + ) + console.print( + f"Balance:\n [blue]{current_wallet_balance}[/blue] " + f":arrow_right: [{COLOR_PALETTE.S.AMOUNT}]{new_balance}" + ) + else: + print_error( + f":cross_mark: [red]Batch unstake-all failed[/red]: {err_msg}", + status=status, + ) + else: + # Single hotkey path: use existing per-hotkey extrinsic + with console.status("Unstaking all stakes...") as status: + for hotkey_ss58 in hotkey_ss58s: + success, ext_receipt = await _unstake_all_extrinsic( + wallet=wallet, + subtensor=subtensor, + hotkey_ss58=hotkey_ss58, + hotkey_name=hotkey_names.get(hotkey_ss58, hotkey_ss58), + unstake_all_alpha=unstake_all_alpha, + status=status, + era=era, + proxy=proxy, + mev_protection=mev_protection, + ) + ext_id = ( + await ext_receipt.get_extrinsic_identifier() if success else None + ) + successes[hotkey_ss58] = { + "success": success, + "extrinsic_identifier": ext_id, + } if json_output: json_console.print(json.dumps({"success": successes})) diff --git a/pyproject.toml b/pyproject.toml index 719d7d035..f2a8aee59 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ dependencies = [ "scalecodec==1.2.12", "typer>=0.16", "typing_extensions>4.0.0; python_version<'3.11'", - "bittensor-wallet>=4.0.0", + "bittensor-wallet==4.0.1", "packaging", "plotille>=5.0.0", "plotly>=6.0.0", diff --git a/tests/unit_tests/test_batching.py b/tests/unit_tests/test_batching.py new file mode 100644 index 000000000..0f22cae4e --- /dev/null +++ b/tests/unit_tests/test_batching.py @@ -0,0 +1,254 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface + + +@pytest.fixture +def subtensor(): + """Create a SubtensorInterface with a mocked substrate connection.""" + st = SubtensorInterface("finney") + st.substrate = AsyncMock() + return st + + +@pytest.fixture +def mock_wallet(): + """Create a mock wallet with coldkey for signing.""" + wallet = MagicMock() + wallet.coldkey = MagicMock() + wallet.coldkey.ss58_address = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + wallet.coldkeypub = MagicMock() + wallet.coldkeypub.ss58_address = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + return wallet + + +@pytest.mark.asyncio +async def test_batch_empty_calls_returns_error(subtensor, mock_wallet): + """Passing an empty call list should return failure without touching the chain.""" + success, msg, receipt = await subtensor.sign_and_send_batch_extrinsic( + calls=[], wallet=mock_wallet + ) + assert success is False + assert "No calls to batch" in msg + assert receipt is None + subtensor.substrate.compose_call.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_batch_single_call_skips_batch_wrapper(subtensor, mock_wallet): + """A single call should go directly to sign_and_send_extrinsic, not wrapped.""" + single_call = MagicMock() + mock_receipt = MagicMock() + + with patch.object( + subtensor, + "sign_and_send_extrinsic", + new_callable=AsyncMock, + return_value=(True, "", mock_receipt), + ) as mock_send: + success, msg, receipt = await subtensor.sign_and_send_batch_extrinsic( + calls=[single_call], + wallet=mock_wallet, + era={"period": 3}, + proxy="5Proxy...", + mev_protection=True, + ) + + assert success is True + assert receipt is mock_receipt + # Should have been called with the raw call, not a batch wrapper + mock_send.assert_awaited_once_with( + call=single_call, + wallet=mock_wallet, + wait_for_inclusion=True, + wait_for_finalization=False, + era={"period": 3}, + proxy="5Proxy...", + nonce=None, + sign_with="coldkey", + announce_only=False, + mev_protection=True, + ) + # compose_call should NOT have been called (no batch wrapping) + subtensor.substrate.compose_call.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_batch_multiple_calls_wraps_in_batch_all(subtensor, mock_wallet): + """Multiple calls should be wrapped in a Utility.batch_all call.""" + call_a = MagicMock(name="call_a") + call_b = MagicMock(name="call_b") + call_c = MagicMock(name="call_c") + batch_call = MagicMock(name="batch_all_call") + mock_receipt = MagicMock() + + subtensor.substrate.compose_call.return_value = batch_call + subtensor.substrate.get_chain_head = AsyncMock(return_value="0xabc123") + + with patch.object( + subtensor, + "sign_and_send_extrinsic", + new_callable=AsyncMock, + return_value=(True, "", mock_receipt), + ) as mock_send: + success, msg, receipt = await subtensor.sign_and_send_batch_extrinsic( + calls=[call_a, call_b, call_c], + wallet=mock_wallet, + era={"period": 5}, + ) + + assert success is True + # Should compose a Utility.batch_all call + subtensor.substrate.compose_call.assert_awaited_once_with( + call_module="Utility", + call_function="batch_all", + call_params={"calls": [call_a, call_b, call_c]}, + block_hash="0xabc123", + ) + # The composed batch call should be sent via sign_and_send_extrinsic + mock_send.assert_awaited_once_with( + call=batch_call, + wallet=mock_wallet, + wait_for_inclusion=True, + wait_for_finalization=False, + era={"period": 5}, + proxy=None, + nonce=None, + sign_with="coldkey", + announce_only=False, + mev_protection=False, + ) + + +@pytest.mark.asyncio +async def test_batch_uses_provided_block_hash(subtensor, mock_wallet): + """When block_hash is provided, it should be used directly instead of fetching.""" + call_a = MagicMock() + call_b = MagicMock() + batch_call = MagicMock() + mock_receipt = MagicMock() + + subtensor.substrate.compose_call.return_value = batch_call + + with patch.object( + subtensor, + "sign_and_send_extrinsic", + new_callable=AsyncMock, + return_value=(True, "", mock_receipt), + ): + await subtensor.sign_and_send_batch_extrinsic( + calls=[call_a, call_b], + wallet=mock_wallet, + block_hash="0xcached_hash", + ) + + # Should use the provided block_hash, not call get_chain_head + subtensor.substrate.get_chain_head.assert_not_awaited() + subtensor.substrate.compose_call.assert_awaited_once_with( + call_module="Utility", + call_function="batch_all", + call_params={"calls": [call_a, call_b]}, + block_hash="0xcached_hash", + ) + + +@pytest.mark.asyncio +async def test_batch_fetches_block_hash_when_not_provided(subtensor, mock_wallet): + """When no block_hash is given, get_chain_head should be called.""" + call_a = MagicMock() + call_b = MagicMock() + batch_call = MagicMock() + mock_receipt = MagicMock() + + subtensor.substrate.compose_call.return_value = batch_call + subtensor.substrate.get_chain_head = AsyncMock(return_value="0xfetched") + + with patch.object( + subtensor, + "sign_and_send_extrinsic", + new_callable=AsyncMock, + return_value=(True, "", mock_receipt), + ): + await subtensor.sign_and_send_batch_extrinsic( + calls=[call_a, call_b], + wallet=mock_wallet, + ) + + subtensor.substrate.get_chain_head.assert_awaited_once() + subtensor.substrate.compose_call.assert_awaited_once_with( + call_module="Utility", + call_function="batch_all", + call_params={"calls": [call_a, call_b]}, + block_hash="0xfetched", + ) + + +@pytest.mark.asyncio +async def test_batch_passes_all_params_through(subtensor, mock_wallet): + """All signing parameters should be forwarded to sign_and_send_extrinsic.""" + call_a = MagicMock() + call_b = MagicMock() + batch_call = MagicMock() + mock_receipt = MagicMock() + + subtensor.substrate.compose_call.return_value = batch_call + subtensor.substrate.get_chain_head = AsyncMock(return_value="0xblock") + + with patch.object( + subtensor, + "sign_and_send_extrinsic", + new_callable=AsyncMock, + return_value=(True, "", mock_receipt), + ) as mock_send: + await subtensor.sign_and_send_batch_extrinsic( + calls=[call_a, call_b], + wallet=mock_wallet, + wait_for_inclusion=False, + wait_for_finalization=True, + era={"period": 8}, + proxy="5ProxyAddr", + nonce=42, + sign_with="hotkey", + announce_only=True, + mev_protection=False, + ) + + mock_send.assert_awaited_once_with( + call=batch_call, + wallet=mock_wallet, + wait_for_inclusion=False, + wait_for_finalization=True, + era={"period": 8}, + proxy="5ProxyAddr", + nonce=42, + sign_with="hotkey", + announce_only=True, + mev_protection=False, + ) + + +@pytest.mark.asyncio +async def test_batch_propagates_failure(subtensor, mock_wallet): + """If the batch transaction fails, the error should propagate correctly.""" + call_a = MagicMock() + call_b = MagicMock() + batch_call = MagicMock() + + subtensor.substrate.compose_call.return_value = batch_call + subtensor.substrate.get_chain_head = AsyncMock(return_value="0xblock") + + with patch.object( + subtensor, + "sign_and_send_extrinsic", + new_callable=AsyncMock, + return_value=(False, "batch_all interrupted at index 1", None), + ): + success, err_msg, receipt = await subtensor.sign_and_send_batch_extrinsic( + calls=[call_a, call_b], + wallet=mock_wallet, + ) + + assert success is False + assert "batch_all interrupted at index 1" in err_msg + assert receipt is None From 10e411ca6ea4adc7feae5c2caf2d12923ab6c1e9 Mon Sep 17 00:00:00 2001 From: BD Himes Date: Mon, 23 Mar 2026 18:50:08 +0200 Subject: [PATCH 02/10] Fixes an issue (my fault) where `process_nested` can really be `Any`, though I was unaware of that at the time. --- bittensor_cli/src/bittensor/chain_data.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bittensor_cli/src/bittensor/chain_data.py b/bittensor_cli/src/bittensor/chain_data.py index b5df2bd37..0e8f27c43 100644 --- a/bittensor_cli/src/bittensor/chain_data.py +++ b/bittensor_cli/src/bittensor/chain_data.py @@ -76,9 +76,9 @@ def _chr_str(codes: tuple[int]) -> str: def process_nested( - data: Sequence[dict[Hashable, tuple[int]]] | dict, + data: Sequence[dict[Hashable, tuple[int]]] | dict | Any, chr_transform: Callable[[tuple[int]], str], -) -> list[dict[Hashable, str]] | dict[Hashable, str]: +) -> list[dict[Hashable, str]] | dict[Hashable, str] | Any: """Processes nested data structures by applying a transformation function to their elements.""" if isinstance(data, Sequence): if len(data) > 0 and isinstance(data[0], dict): @@ -93,7 +93,7 @@ def process_nested( elif isinstance(data, dict): return {k: chr_transform(v) for k, v in data.items()} else: - raise TypeError(f"Unsupported data type {type(data)}") + return data @dataclass From 251ed06b0873245e34bc94c5127782836303dc14 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 24 Mar 2026 09:47:11 -0700 Subject: [PATCH 03/10] add clear command to btcli swap-coldkey --- bittensor_cli/cli.py | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index ebf4d29d8..63d209f14 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -4144,7 +4144,11 @@ def wallet_swap_coldkey( self, action: str = typer.Argument( None, - help="Action to perform: 'announce' to announce intent, 'execute' to complete swap after delay, 'dispute' to freeze the swap.", + help=( + "Action to perform: 'announce' to announce intent, " + "'execute' to complete swap after delay, 'dispute' to freeze the swap, " + "'clear' to withdraw announcement." + ), ), wallet_name: Optional[str] = Options.wallet_name, wallet_path: Optional[str] = Options.wallet_path, @@ -4176,6 +4180,9 @@ def wallet_swap_coldkey( If you suspect compromise, you can [bold]Dispute[/bold] an active announcement to freeze all activity for the coldkey until the triumvirate can intervene. + If you want to withdraw your announcement, you can [bold]Clear[/bold] (withdraw) an announcement once the + reannouncement delay has elapsed. + EXAMPLES Step 1 - Announce your intent to swap: @@ -4190,6 +4197,10 @@ def wallet_swap_coldkey( [green]$[/green] btcli wallet swap-coldkey dispute + Clear (withdraw) an announcement: + + [green]$[/green] btcli wallet swap-coldkey clear + Check status of pending swaps: [green]$[/green] btcli wallet swap-check @@ -4201,18 +4212,19 @@ def wallet_swap_coldkey( "\n[bold][blue]Coldkey Swap Actions:[/blue][/bold]\n" " [dark_sea_green3]announce[/dark_sea_green3] - Start the swap process (pays fee, starts delay timer)\n" " [dark_sea_green3]execute[/dark_sea_green3] - Complete the swap (after delay period)\n" - " [dark_sea_green3]dispute[/dark_sea_green3] - Freeze the swap process if you suspect compromise\n\n" + " [dark_sea_green3]dispute[/dark_sea_green3] - Freeze the swap process if you suspect compromise\n" + " [dark_sea_green3]clear[/dark_sea_green3] - Withdraw your swap announcement\n\n" " [dim]You can check the current status of your swap with 'btcli wallet swap-check'.[/dim]\n" ) action = Prompt.ask( "Select action", - choices=["announce", "execute", "dispute"], + choices=["announce", "execute", "dispute", "clear"], default="announce", ) - if action.lower() not in ("announce", "execute", "dispute"): + if action.lower() not in ("announce", "execute", "dispute", "clear"): print_error( - f"Invalid action: {action}. Must be 'announce', 'execute', or 'dispute'." + f"Invalid action: {action}. Must be 'announce', 'execute', 'dispute', or 'clear'." ) raise typer.Exit(1) @@ -4233,7 +4245,7 @@ def wallet_swap_coldkey( ) new_wallet_coldkey_ss58 = None - if action != "dispute": + if action not in ("dispute", "clear"): if not new_wallet_or_ss58: new_wallet_or_ss58 = Prompt.ask( "Enter the [blue]new wallet name[/blue] or [blue]SS58 address[/blue] of the new coldkey", @@ -4285,6 +4297,17 @@ def wallet_swap_coldkey( mev_protection=mev_protection, ) ) + elif action == "clear": + return self._run_command( + wallets.clear_coldkey_swap_announcement( + wallet=wallet, + subtensor=self.initialize_chain(network), + decline=decline, + quiet=quiet, + prompt=prompt, + mev_protection=mev_protection, + ) + ) else: return self._run_command( wallets.execute_coldkey_swap( From 564e19c22b305a315b22a95346b0164ef5589349 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 24 Mar 2026 09:51:06 -0700 Subject: [PATCH 04/10] add clear_coldkey_swap_announcement --- bittensor_cli/src/commands/wallets.py | 128 ++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/bittensor_cli/src/commands/wallets.py b/bittensor_cli/src/commands/wallets.py index 03293a611..08ea398a5 100644 --- a/bittensor_cli/src/commands/wallets.py +++ b/bittensor_cli/src/commands/wallets.py @@ -2345,6 +2345,134 @@ async def dispute_coldkey_swap( return True +async def clear_coldkey_swap_announcement( + wallet: Wallet, + subtensor: SubtensorInterface, + decline: bool = False, + quiet: bool = False, + prompt: bool = True, + mev_protection: bool = False, +) -> bool: + """Clear (withdraw) a pending coldkey swap announcement. + + The announcement can only be cleared after the reannouncement delay has elapsed + past the execution block, and the swap must not be disputed. + + Args: + wallet: Wallet that owns the announcement (must be the announcing coldkey). + subtensor: Connection to the Bittensor network. + decline: If True, default to declining at confirmation prompt. + quiet: If True, skip confirmation prompts and proceed. + prompt: If True, show confirmation prompts. + mev_protection: If True, encrypt the extrinsic with MEV protection. + + Returns: + bool: True if the clear extrinsic was included successfully, else False. + """ + block_hash = await subtensor.substrate.get_chain_head() + announcement, dispute, current_block, reannounce_delay = await asyncio.gather( + subtensor.get_coldkey_swap_announcement( + wallet.coldkeypub.ss58_address, block_hash=block_hash + ), + subtensor.get_coldkey_swap_dispute( + wallet.coldkeypub.ss58_address, block_hash=block_hash + ), + subtensor.substrate.get_block_number(block_hash=block_hash), + subtensor.get_coldkey_swap_reannouncement_delay(block_hash=block_hash), + ) + + if not announcement: + print_error( + f"No coldkey swap announcement found for {wallet.coldkeypub.ss58_address}.\n" + "Nothing to clear." + ) + return False + + if dispute is not None: + console.print( + f"[yellow]Swap is disputed at block {dispute}.[/yellow] " + "Cannot clear a disputed announcement." + ) + return False + + clear_block = announcement.execution_block + reannounce_delay + if current_block < clear_block: + remaining = clear_block - current_block + console.print( + f"[yellow]Cannot clear yet.[/yellow] " + f"You can clear after block {clear_block} ({blocks_to_duration(remaining)} from now).\n" + f"Current block: {current_block}" + ) + return False + + info = create_key_value_table("Clear Coldkey Swap Announcement\n") + info.add_row( + "Coldkey", f"[{COLORS.G.CK}]{wallet.coldkeypub.ss58_address}[/{COLORS.G.CK}]" + ) + info.add_row("Announced Hash", f"[dim]{announcement.new_coldkey_hash}[/dim]") + info.add_row("Execution Block", str(announcement.execution_block)) + info.add_row( + "Status", + "[yellow]Pending[/yellow]" + if current_block < announcement.execution_block + else "[green]Ready[/green]", + ) + console.print(info) + + if prompt and not confirm_action( + "Proceed with clearing this swap announcement?", + decline=decline, + quiet=quiet, + ): + return False + + if not unlock_key(wallet).success: + return False + + with console.status( + ":satellite: Clearing coldkey swap announcement on-chain..." + ) as status: + call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="clear_coldkey_swap_announcement", + call_params={}, + ) + success, err_msg, ext_receipt = await subtensor.sign_and_send_extrinsic( + call, + wallet, + wait_for_inclusion=True, + wait_for_finalization=True, + mev_protection=mev_protection, + ) + + if not success: + print_error(f"Failed to clear coldkey swap announcement: {err_msg}") + return False + + if mev_protection: + inner_hash = err_msg + mev_success, mev_error, ext_receipt = await wait_for_extrinsic_by_hash( + subtensor=subtensor, + extrinsic_hash=inner_hash, + submit_block_hash=ext_receipt.block_hash, + status=status, + ) + if not mev_success: + print_error( + f"Failed to clear coldkey swap announcement: {mev_error}", + status=status, + ) + return False + + print_success("[dark_sea_green3]Coldkey swap announcement cleared.") + await print_extrinsic_id(ext_receipt) + + console.print( + "\n[dim]Your coldkey is no longer locked by a pending swap announcement.[/dim]" + ) + return True + + async def execute_coldkey_swap( wallet: Wallet, subtensor: SubtensorInterface, From 1b298652c4d133f4ef8775a57d45002238b655f8 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 24 Mar 2026 09:54:42 -0700 Subject: [PATCH 05/10] add clear/withdraw announcement info to swap check --- bittensor_cli/src/commands/wallets.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/bittensor_cli/src/commands/wallets.py b/bittensor_cli/src/commands/wallets.py index 08ea398a5..7bb985b91 100644 --- a/bittensor_cli/src/commands/wallets.py +++ b/bittensor_cli/src/commands/wallets.py @@ -2634,10 +2634,11 @@ async def check_swap_status( """ block_hash = await subtensor.substrate.get_chain_head() if origin_ss58: - announcement, dispute, current_block = await asyncio.gather( + announcement, dispute, current_block, reannounce_delay = await asyncio.gather( subtensor.get_coldkey_swap_announcement(origin_ss58, block_hash=block_hash), subtensor.get_coldkey_swap_dispute(origin_ss58, block_hash=block_hash), subtensor.substrate.get_block_number(block_hash=block_hash), + subtensor.get_coldkey_swap_reannouncement_delay(block_hash=block_hash), ) if not announcement: console.print( @@ -2649,10 +2650,11 @@ async def check_swap_status( disputes = [(origin_ss58, dispute)] if dispute is not None else [] else: - announcements, disputes, current_block = await asyncio.gather( + announcements, disputes, current_block, reannounce_delay = await asyncio.gather( subtensor.get_coldkey_swap_announcements(block_hash=block_hash), subtensor.get_coldkey_swap_disputes(block_hash=block_hash), subtensor.substrate.get_block_number(block_hash=block_hash), + subtensor.get_coldkey_swap_reannouncement_delay(block_hash=block_hash), ) if not announcements: console.print( @@ -2691,6 +2693,7 @@ async def check_swap_status( Column("Execution Block", justify="right", style="dark_sea_green3"), Column("Time Remaining", justify="right", style="yellow"), Column("Status", justify="center", style="green"), + Column("Clear Announcement", justify="right", style="yellow"), title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Pending Coldkey Swap Announcements\nCurrent Block: {current_block}\n", show_header=True, show_edge=False, @@ -2705,18 +2708,28 @@ async def check_swap_status( for announcement in announcements: dispute_block = dispute_map.get(announcement.coldkey) remaining_blocks = announcement.execution_block - current_block + clear_block = announcement.execution_block + reannounce_delay + clear_remaining = clear_block - current_block if dispute_block is not None: status = "[red]Disputed[/red]" time_str = f"Disputed @ {dispute_block}" status_label = "disputed" + clear_str = "[red]Disputed[/red]" elif remaining_blocks <= 0: status = "Ready" time_str = "[green]Ready[/green]" status_label = "ready" + if clear_remaining <= 0: + clear_str = "[green]Ready[/green]" + else: + clear_str = ( + f"Block {clear_block} ({blocks_to_duration(clear_remaining)})" + ) else: status = "Pending" time_str = blocks_to_duration(remaining_blocks) status_label = "pending" + clear_str = f"Block {clear_block} ({blocks_to_duration(clear_remaining)})" hash_display = f"{announcement.new_coldkey_hash[:12]}...{announcement.new_coldkey_hash[-6:]}" table.add_row( @@ -2725,6 +2738,7 @@ async def check_swap_status( str(announcement.execution_block), time_str, status, + clear_str, ) payload["announcements"].append( @@ -2735,6 +2749,8 @@ async def check_swap_status( "status": status_label, "time_remaining_blocks": max(0, remaining_blocks), "disputed_block": dispute_block, + "clear_block": clear_block, + "clear_remaining_blocks": max(0, clear_remaining), } ) @@ -2745,5 +2761,7 @@ async def check_swap_status( console.print(table) console.print( "\n[dim]To execute a ready swap:[/dim] " - "[green]btcli wallet swap-coldkey execute[/green]" + "[green]btcli wallet swap-coldkey execute[/green]\n" + "[dim]To clear (withdraw) an announcement:[/dim] " + "[green]btcli wallet swap-coldkey clear[/green]" ) From 57c9e0971617f7b36e2830da03f3b55063365f50 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 24 Mar 2026 10:07:55 -0700 Subject: [PATCH 06/10] add ck swap check directly in swap-coldkey as well --- bittensor_cli/cli.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 63d209f14..12a6a9d75 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -4147,7 +4147,7 @@ def wallet_swap_coldkey( help=( "Action to perform: 'announce' to announce intent, " "'execute' to complete swap after delay, 'dispute' to freeze the swap, " - "'clear' to withdraw announcement." + "'clear' to withdraw announcement, 'check' to view status." ), ), wallet_name: Optional[str] = Options.wallet_name, @@ -4201,9 +4201,9 @@ def wallet_swap_coldkey( [green]$[/green] btcli wallet swap-coldkey clear - Check status of pending swaps: + Check status of your swap: - [green]$[/green] btcli wallet swap-check + [green]$[/green] btcli wallet swap-coldkey check """ self.verbosity_handler(quiet, verbose, prompt=False, json_output=False) @@ -4213,18 +4213,18 @@ def wallet_swap_coldkey( " [dark_sea_green3]announce[/dark_sea_green3] - Start the swap process (pays fee, starts delay timer)\n" " [dark_sea_green3]execute[/dark_sea_green3] - Complete the swap (after delay period)\n" " [dark_sea_green3]dispute[/dark_sea_green3] - Freeze the swap process if you suspect compromise\n" - " [dark_sea_green3]clear[/dark_sea_green3] - Withdraw your swap announcement\n\n" - " [dim]You can check the current status of your swap with 'btcli wallet swap-check'.[/dim]\n" + " [dark_sea_green3]clear[/dark_sea_green3] - Withdraw your swap announcement\n" + " [dark_sea_green3]check[/dark_sea_green3] - Check the status of your swap\n\n" ) action = Prompt.ask( "Select action", - choices=["announce", "execute", "dispute", "clear"], + choices=["announce", "execute", "dispute", "clear", "check"], default="announce", ) - if action.lower() not in ("announce", "execute", "dispute", "clear"): + if action.lower() not in ("announce", "execute", "dispute", "clear", "check"): print_error( - f"Invalid action: {action}. Must be 'announce', 'execute', 'dispute', or 'clear'." + f"Invalid action: {action}. Must be 'announce', 'execute', 'dispute', 'clear', or 'check'." ) raise typer.Exit(1) @@ -4245,7 +4245,7 @@ def wallet_swap_coldkey( ) new_wallet_coldkey_ss58 = None - if action not in ("dispute", "clear"): + if action not in ("dispute", "clear", "check"): if not new_wallet_or_ss58: new_wallet_or_ss58 = Prompt.ask( "Enter the [blue]new wallet name[/blue] or [blue]SS58 address[/blue] of the new coldkey", @@ -4308,6 +4308,13 @@ def wallet_swap_coldkey( mev_protection=mev_protection, ) ) + elif action == "check": + return self._run_command( + wallets.check_swap_status( + subtensor=self.initialize_chain(network), + origin_ss58=wallet.coldkeypub.ss58_address, + ) + ) else: return self._run_command( wallets.execute_coldkey_swap( From 00e40b1060a335a335bb0993ca3ac74727856f0e Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 24 Mar 2026 10:24:18 -0700 Subject: [PATCH 07/10] add e2e for clearing announcement --- tests/e2e_tests/test_coldkey_swap.py | 202 +++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) diff --git a/tests/e2e_tests/test_coldkey_swap.py b/tests/e2e_tests/test_coldkey_swap.py index b91fb543b..fe69633fe 100644 --- a/tests/e2e_tests/test_coldkey_swap.py +++ b/tests/e2e_tests/test_coldkey_swap.py @@ -425,3 +425,205 @@ def test_coldkey_swap_dispute(local_chain, wallet_setup): status_after_payload = json.loads(status_after.stdout) assert status_after_payload["announcements"], status_after_payload assert status_after_payload["announcements"][0]["status"] == "disputed" + + +def test_coldkey_swap_and_clear_announcement(local_chain, wallet_setup): + """ + Coldkey swap with stake: + 1. Bob announces coldkey swap. + 2. Status shows pending. + 3. Bob clears announcement. + 4. Status shows cleared. + """ + print("Testing coldkey swap with stake 🧪") + wallet_path_bob = "//Bob" + wallet_path_new = "//Charlie" + + _, 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", + sub_command="create", + extra_args=[ + "--wallet-path", + path_bob, + "--wallet-name", + wallet_bob.name, + "--wallet-hotkey", + wallet_bob.hotkey_str, + "--chain", + "ws://127.0.0.1:9945", + "--subnet-name", + "Test Subnet CK Swap", + "--repo", + "https://github.com/opentensor/subnet-repo", + "--contact", + "bob@opentensor.dev", + "--url", + "https://subnet.example.com", + "--discord", + "bob#1234", + "--description", + "Subnet for coldkey swap e2e", + "--logo-url", + "https://subnet.example.com/logo.png", + "--additional-info", + "Created for e2e coldkey swap test", + "--no-prompt", + "--json-output", + "--no-mev-protection", + ], + ) + create_payload = json.loads(create_sn.stdout) + assert create_payload["success"] is True + + # Start emission schedule + start_sn = exec_command_bob( + command="subnets", + sub_command="start", + extra_args=[ + "--netuid", + str(2), + "--wallet-name", + wallet_bob.name, + "--wallet-path", + path_bob, + "--network", + "ws://127.0.0.1:9945", + "--no-prompt", + ], + ) + assert "Successfully started subnet" in start_sn.stdout, start_sn.stdout + + # Add stake to the new subnet + stake_add = exec_command_bob( + command="stake", + sub_command="add", + extra_args=[ + "--netuid", + str(netuid), + "--wallet-path", + path_bob, + "--wallet-name", + wallet_bob.name, + "--hotkey", + wallet_bob.hotkey_str, + "--chain", + "ws://127.0.0.1:9945", + "--amount", + "5", + "--unsafe", + "--no-prompt", + "--no-mev-protection", + ], + ) + assert "✅ Finalized" in stake_add.stdout, stake_add.stdout + + # Announce swap + announce = exec_command_bob( + command="wallet", + sub_command="swap-coldkey", + extra_args=[ + "announce", + "--wallet-path", + path_bob, + "--wallet-name", + wallet_bob.name, + "--network", + "ws://127.0.0.1:9945", + "--new-coldkey", + wallet_new.coldkeypub.ss58_address, + "--no-prompt", + "--no-mev-protection", + ], + ) + assert "Successfully announced coldkey swap" in announce.stdout, announce.stdout + + # Fetch announcement and wait for execution block + status_json = exec_command_bob( + command="wallet", + sub_command="swap-check", + extra_args=[ + "--wallet-path", + path_bob, + "--wallet-name", + wallet_bob.name, + "--network", + "ws://127.0.0.1:9945", + "--json-output", + ], + ) + status_payload = json.loads(status_json.stdout) + assert status_payload["announcements"], status_payload + when = status_payload["announcements"][0]["clear_block"] + 1 + _wait_until_block(local_chain, when) + + # Clear the announcement + clear = exec_command_bob( + command="wallet", + sub_command="swap-coldkey", + extra_args=[ + "clear", + "--wallet-path", + path_bob, + "--wallet-name", + wallet_bob.name, + "--network", + "ws://127.0.0.1:9945", + "--new-coldkey", + wallet_new.coldkeypub.ss58_address, + "--no-prompt", + "--no-mev-protection", + ], + ) + + assert ( + "Your coldkey is no longer locked by a pending swap announcement." + in clear.stdout + ), clear.stdout + + # Check the status after clearing the announcement + status_after_clear = exec_command_bob( + command="wallet", + sub_command="swap-check", + extra_args=[ + "--wallet-path", + path_bob, + "--wallet-name", + wallet_bob.name, + "--network", + "ws://127.0.0.1:9945", + ], + ) + assert "No pending swap announcement" in status_after_clear.stdout, ( + status_after_clear.stdout + ) + + # Add stake after clearing the announcement + stake_add_post_announcement = exec_command_bob( + command="stake", + sub_command="add", + extra_args=[ + "--netuid", + str(netuid), + "--wallet-path", + path_bob, + "--wallet-name", + wallet_bob.name, + "--hotkey", + wallet_bob.hotkey_str, + "--chain", + "ws://127.0.0.1:9945", + "--amount", + "5", + "--unsafe", + "--no-prompt", + "--no-mev-protection", + ], + ) + assert "✅ Finalized" in stake_add_post_announcement.stdout, ( + stake_add_post_announcement.stdout + ) From 73af3ccd221947edffee7e350af1268fa6241660 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 24 Mar 2026 10:31:32 -0700 Subject: [PATCH 08/10] update post delay instructions --- bittensor_cli/src/commands/wallets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bittensor_cli/src/commands/wallets.py b/bittensor_cli/src/commands/wallets.py index 7bb985b91..eddf9d611 100644 --- a/bittensor_cli/src/commands/wallets.py +++ b/bittensor_cli/src/commands/wallets.py @@ -2224,7 +2224,7 @@ async def announce_coldkey_swap( console.print(details_table) console.print( f"\n[dim]After the delay, run:" - f"\n[green]btcli wallet swap-coldkey execute --new-coldkey {new_coldkey_ss58}[/green]" + f"\n[green]btcli wallet swap-coldkey execute --new-coldkey {new_coldkey_ss58} --wallet-name {wallet.name}[/green]" ) return True From 585b111bb9e53c1c8df7ca7e9fe931869c37daa1 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 24 Mar 2026 15:34:58 -0700 Subject: [PATCH 09/10] update changelog --- CHANGELOG.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97a21833e..fb641cf5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,18 @@ # Changelog -## 9.19.0 /2026-03-02 +## 9.20.0 /2026-03-24 + +## What's Changed +* feat: Add batch extrinsic support to reduce transaction fees by @bittoby in https://github.com/opentensor/btcli/pull/863 +* Fixes an issue (my fault) where `process_nested` can really be `Any`,… by @thewhaleking in https://github.com/opentensor/btcli/pull/869 +* Feat/coldkey swap clear by @ibraheem-abe in https://github.com/opentensor/btcli/pull/871 + +## New Contributors +* @bittoby made their first contribution in https://github.com/opentensor/btcli/pull/863 + +**Full Changelog**: https://github.com/opentensor/btcli/compare/v9.19.0...v9.20.0 + +## 9.19.0 /2026-03-19 ## What's Changed * fix: JSON output empty for `btcli subnets list --json-out` command by @GlobalStar117 in https://github.com/opentensor/btcli/pull/800 From 3ec2c8c403cedc9baa16c3bba771790a8bef35eb Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 24 Mar 2026 15:35:04 -0700 Subject: [PATCH 10/10] bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f2a8aee59..e8e6d5cf7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "flit_core.buildapi" [project] name = "bittensor-cli" -version = "9.19.0" +version = "9.20.0" description = "Bittensor CLI" readme = "README.md" authors = [