From 690d2955a7df5e568aa8336ebd03f5d590dea2d0 Mon Sep 17 00:00:00 2001 From: BD Himes Date: Wed, 25 Mar 2026 20:51:17 +0200 Subject: [PATCH 01/49] Speeds up from 51 seconds to 3 for me --- bittensor_cli/src/bittensor/subtensor_interface.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index e439ae8e3..568b5754d 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -2239,10 +2239,11 @@ async def get_all_coldkeys_claim_type( params=[], block_hash=block_hash, reuse_block_hash=reuse_block, + fully_exhaust=True, ) root_claim_types = {} - async for coldkey, claim_type_data in result: + for coldkey, claim_type_data in result.records: coldkey_ss58 = decode_account_id(coldkey[0]) claim_type_key = next(iter(claim_type_data.value.keys())) From a9ef489ffaee72031ad12c3c9270e51e68eee738 Mon Sep 17 00:00:00 2001 From: BD Himes Date: Wed, 25 Mar 2026 20:53:07 +0200 Subject: [PATCH 02/49] Bump page size --- bittensor_cli/src/bittensor/subtensor_interface.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index 568b5754d..410dff0fd 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -2240,6 +2240,7 @@ async def get_all_coldkeys_claim_type( block_hash=block_hash, reuse_block_hash=reuse_block, fully_exhaust=True, + page_size=1_000, ) root_claim_types = {} From fb9f6927a52fecbfa8bb21cf32552cb615c05a9b Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Wed, 25 Mar 2026 13:25:11 -0700 Subject: [PATCH 03/49] update get_all_subnet_netuids --- bittensor_cli/src/bittensor/subtensor_interface.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index 410dff0fd..475107de9 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -229,9 +229,11 @@ async def get_all_subnet_netuids( storage_function="NetworksAdded", block_hash=block_hash, reuse_block_hash=True, + fully_exhaust=True, + page_size=200, ) res = [] - async for netuid, exists in result: + for netuid, exists in result.records: if exists.value: res.append(netuid) return res From 354b30db00f895f2904c1098181d42e679e17350 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Wed, 25 Mar 2026 13:37:36 -0700 Subject: [PATCH 04/49] update get_auto_stake_destinations --- bittensor_cli/src/bittensor/subtensor_interface.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index 475107de9..ab6484ae2 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -285,14 +285,14 @@ async def get_auto_stake_destinations( params=[coldkey_ss58], block_hash=block_hash, reuse_block_hash=reuse_block, + fully_exhaust=True, + page_size=200, ) - destinations: dict[int, str] = {} - async for netuid, destination in query: + for netuid, destination in query.records: hotkey_ss58 = decode_account_id(destination.value[0]) if hotkey_ss58: destinations[int(netuid)] = hotkey_ss58 - return destinations async def get_stake_for_coldkey_and_hotkey( From 8243b35ec94131e366120bb01645f7016bfa45da Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Wed, 25 Mar 2026 13:44:26 -0700 Subject: [PATCH 05/49] update get_all_subnet_mechanisms --- bittensor_cli/src/bittensor/subtensor_interface.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index ab6484ae2..b7e94c299 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -1451,9 +1451,11 @@ async def get_all_subnet_mechanisms( storage_function="MechanismCountCurrent", params=[], block_hash=block_hash, + fully_exhaust=True, + page_size=200, ) res = {} - async for netuid, count in results: + for netuid, count in results.records: res[int(netuid)] = int(count.value) return res From 2b080f1f52f2b53e92b33ad249f47714fa3ac75d Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Wed, 25 Mar 2026 14:06:37 -0700 Subject: [PATCH 06/49] update get_all_subnet_ema_tao_inflow --- bittensor_cli/src/bittensor/subtensor_interface.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index b7e94c299..a969480fb 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -2613,7 +2613,7 @@ async def get_subnet_prices( async def get_all_subnet_ema_tao_inflow( self, block_hash: Optional[str] = None, - page_size: int = 100, + page_size: int = 200, ) -> dict[int, Balance]: """ Query EMA TAO inflow for all subnets. @@ -2633,9 +2633,10 @@ async def get_all_subnet_ema_tao_inflow( storage_function="SubnetEmaTaoFlow", page_size=page_size, block_hash=block_hash, + fully_exhaust=True, ) ema_map = {} - async for netuid, value in query: + for netuid, value in query.records: if not value: ema_map[netuid] = Balance.from_rao(0) else: From 7297287af07fd5d72a030fa33b147de669b1102b Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Wed, 25 Mar 2026 14:09:11 -0700 Subject: [PATCH 07/49] update get_subnet_prices --- bittensor_cli/src/bittensor/subtensor_interface.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index a969480fb..dc1f3c842 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -2585,7 +2585,7 @@ async def get_subnet_price( return Balance.from_rao(int(current_price * 1e9)) async def get_subnet_prices( - self, block_hash: Optional[str] = None, page_size: int = 100 + self, block_hash: Optional[str] = None, page_size: int = 200 ) -> dict[int, Balance]: """ Gets the current Alpha prices in TAO for all subnets. @@ -2600,10 +2600,11 @@ async def get_subnet_prices( storage_function="AlphaSqrtPrice", page_size=page_size, block_hash=block_hash, + fully_exhaust=True, ) map_ = {} - async for netuid_, current_sqrt_price in query: + for netuid_, current_sqrt_price in query.records: current_sqrt_price_ = fixed_to_float(current_sqrt_price.value) current_price = current_sqrt_price_**2 map_[netuid_] = Balance.from_rao(int(current_price * 1e9)) From df6703478bed886e314f580ce8fd5bb184736a6b Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Wed, 25 Mar 2026 14:14:19 -0700 Subject: [PATCH 08/49] update get_crowdloan_contributors --- bittensor_cli/src/bittensor/subtensor_interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index dc1f3c842..734d72b6a 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -2089,7 +2089,7 @@ async def get_crowdloan_contributors( ) contributor_contributions = {} - async for contributor_key, contribution_amount in contributors_data: + for contributor_key, contribution_amount in contributors_data.records: try: contributor_address = decode_account_id(contributor_key[0]) contribution_balance = Balance.from_rao(contribution_amount.value) From a4758800e5fea290cfd959b14547935a1923ebb0 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Wed, 25 Mar 2026 14:21:42 -0700 Subject: [PATCH 09/49] update get_crowdloans --- bittensor_cli/src/bittensor/subtensor_interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index 734d72b6a..25db34afd 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -1988,7 +1988,7 @@ async def get_crowdloans( fully_exhaust=True, ) crowdloans = {} - async for fund_id, fund_info in crowdloans_data: + for fund_id, fund_info in crowdloans_data.records: decoded_call = await self._decode_inline_call( fund_info["call"], block_hash=block_hash, From 12b20d709000b672b03ca3e7498f563a65fd21f1 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Wed, 25 Mar 2026 14:24:55 -0700 Subject: [PATCH 10/49] update get_coldkey_swap_disputes --- bittensor_cli/src/bittensor/subtensor_interface.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index 25db34afd..ab7b34aaf 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -1930,10 +1930,12 @@ async def get_coldkey_swap_disputes( storage_function="ColdkeySwapDisputes", block_hash=block_hash, reuse_block_hash=reuse_block, + fully_exhaust=True, + page_size=200, ) disputes: list[tuple[str, int]] = [] - async for ss58, data in result: + for ss58, data in result.records: coldkey = decode_account_id(ss58) disputes.append((coldkey, data.value)) return disputes From 6313f553c308a94d9b0797a1da76e1bd723e3857 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Wed, 25 Mar 2026 14:27:01 -0700 Subject: [PATCH 11/49] update get_coldkey_swap_announcements --- bittensor_cli/src/bittensor/subtensor_interface.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index ab7b34aaf..8d4f33240 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -1872,10 +1872,12 @@ async def get_coldkey_swap_announcements( storage_function="ColdkeySwapAnnouncements", block_hash=block_hash, reuse_block_hash=reuse_block, + fully_exhaust=True, + page_size=200, ) announcements = [] - async for ss58, data in result: + for ss58, data in result.records: coldkey = decode_account_id(ss58) announcements.append( ColdkeySwapAnnouncementInfo._fix_decoded(coldkey, data) From f1c4ddd9281cf6c6ec96a7d1a7b610188596aaf7 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Wed, 25 Mar 2026 14:50:58 -0700 Subject: [PATCH 12/49] update burned_register_extrinsic --- bittensor_cli/src/bittensor/extrinsics/registration.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bittensor_cli/src/bittensor/extrinsics/registration.py b/bittensor_cli/src/bittensor/extrinsics/registration.py index d082f58c6..237d4c3ec 100644 --- a/bittensor_cli/src/bittensor/extrinsics/registration.py +++ b/bittensor_cli/src/bittensor/extrinsics/registration.py @@ -708,11 +708,13 @@ async def burned_register_extrinsic( f":satellite: Checking Account on [bold]subnet:{netuid}[/bold]...", spinner="aesthetic", ) as status: + block_hash = await subtensor.substrate.get_chain_head() my_uid = await subtensor.query( - "SubtensorModule", "Uids", [netuid, get_hotkey_pub_ss58(wallet)] + module="SubtensorModule", + storage_function="Uids", + params=[netuid, get_hotkey_pub_ss58(wallet)], + block_hash=block_hash, ) - block_hash = await subtensor.substrate.get_chain_head() - print_verbose("Checking if already registered", status) neuron = await subtensor.neuron_for_uid( uid=my_uid, netuid=netuid, block_hash=block_hash From a5a8183ddbb946a048c81267950ad332eab0cdb9 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Wed, 25 Mar 2026 14:51:07 -0700 Subject: [PATCH 13/49] update get_netuids_for_hotkey --- bittensor_cli/src/bittensor/subtensor_interface.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index 8d4f33240..20380f208 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -575,9 +575,11 @@ async def get_netuids_for_hotkey( params=[hotkey_ss58], block_hash=block_hash, reuse_block_hash=reuse_block, + fully_exhaust=True, + page_size=200, ) res = [] - async for record in result: + for record in result.records: if record[1].value: res.append(record[0]) return res From 4d58c9e0e4ccbfb1608859e0a0f0dcc401758765 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Wed, 25 Mar 2026 15:21:02 -0700 Subject: [PATCH 14/49] update get_claimed_amount_all_netuids --- bittensor_cli/src/bittensor/subtensor_interface.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index 20380f208..e0aedecfa 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -2350,9 +2350,11 @@ async def get_claimed_amount_all_netuids( params=[hotkey_ss58, coldkey_ss58], block_hash=block_hash, reuse_block_hash=reuse_block, + fully_exhaust=True, + page_size=200, ) total_claimed = {} - async for netuid, claimed in query: + for netuid, claimed in query.records: total_claimed[netuid] = Balance.from_rao(claimed.value).set_unit( netuid=netuid ) From 4068a3ebbb4537baf3ad9708161f4f6bdbfd7330 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Wed, 25 Mar 2026 16:00:55 -0700 Subject: [PATCH 15/49] update subnet show/metagraph --- bittensor_cli/src/commands/subnets/subnets.py | 31 ++----------------- 1 file changed, 3 insertions(+), 28 deletions(-) diff --git a/bittensor_cli/src/commands/subnets/subnets.py b/bittensor_cli/src/commands/subnets/subnets.py index fcbc7dc03..900887aec 100644 --- a/bittensor_cli/src/commands/subnets/subnets.py +++ b/bittensor_cli/src/commands/subnets/subnets.py @@ -1061,13 +1061,11 @@ async def show_root(): all_subnets, root_state, identities, - old_identities, root_claim_types, ) = await asyncio.gather( subtensor.all_subnets(block_hash=block_hash), subtensor.get_subnet_state(netuid=0, block_hash=block_hash), subtensor.query_all_identities(block_hash=block_hash), - subtensor.get_delegate_identities(block_hash=block_hash), subtensor.get_all_coldkeys_claim_type(block_hash=block_hash), ) root_info = next((s for s in all_subnets if s.netuid == 0), None) @@ -1146,12 +1144,7 @@ async def show_root(): coldkey_identity = identities.get(root_state.coldkeys[idx], {}).get( "name", "" ) - hotkey_identity = old_identities.get(root_state.hotkeys[idx]) - validator_identity = ( - coldkey_identity - if coldkey_identity - else (hotkey_identity.display if hotkey_identity else "") - ) + validator_identity = coldkey_identity coldkey_ss58 = root_state.coldkeys[idx] claim_type_info = root_claim_types.get(coldkey_ss58, {"type": "Swap"}) @@ -1256,12 +1249,7 @@ async def show_root(): coldkey_identity = identities.get( root_state.coldkeys[original_idx], {} ).get("name", "") - hotkey_identity = old_identities.get(selected_hotkey) - validator_identity = ( - coldkey_identity - if coldkey_identity - else (hotkey_identity.display if hotkey_identity else "") - ) + validator_identity = coldkey_identity identity_str = f" ({validator_identity})" if validator_identity else "" console.print( @@ -1282,14 +1270,12 @@ async def show_subnet( ( subnet_info, identities, - old_identities, current_burn_cost, root_claim_types, ema_tao_inflow, ) = await asyncio.gather( subtensor.subnet(netuid=netuid_, block_hash=block_hash), subtensor.query_all_identities(block_hash=block_hash), - subtensor.get_delegate_identities(block_hash=block_hash), subtensor.get_hyperparameter( param_name="Burn", netuid=netuid_, block_hash=block_hash ), @@ -1340,12 +1326,6 @@ async def show_subnet( owner_hotkeys.append(subnet_info.owner_hotkey) owner_identity = identities.get(subnet_info.owner_coldkey, {}).get("name", "") - if not owner_identity: - # If no coldkey identity found, try each owner hotkey - for hotkey in owner_hotkeys: - if hotkey_identity := old_identities.get(hotkey): - owner_identity = hotkey_identity.display - break sorted_indices = sorted( range(len(metagraph_info.hotkeys)), @@ -1373,12 +1353,7 @@ async def show_subnet( coldkey_identity = identities.get(metagraph_info.coldkeys[idx], {}).get( "name", "" ) - hotkey_identity = old_identities.get(metagraph_info.hotkeys[idx]) - uid_identity = ( - coldkey_identity - if coldkey_identity - else (hotkey_identity.display if hotkey_identity else "~") - ) + uid_identity = coldkey_identity or "~" if ( metagraph_info.coldkeys[idx] == subnet_info.owner_coldkey From af2d0388b0f79d6e632cf07e04cd548fe2333b81 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Wed, 25 Mar 2026 16:13:23 -0700 Subject: [PATCH 16/49] update stake move --- bittensor_cli/src/commands/stake/move.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/bittensor_cli/src/commands/stake/move.py b/bittensor_cli/src/commands/stake/move.py index b7b28c213..0ff81c2cf 100644 --- a/bittensor_cli/src/commands/stake/move.py +++ b/bittensor_cli/src/commands/stake/move.py @@ -324,10 +324,9 @@ async def stake_move_transfer_selection( wallet: Wallet, ): """Selection interface for moving stakes between hotkeys and subnets.""" - stakes, ck_hk_identities, old_identities = await asyncio.gather( + stakes, ck_hk_identities = await asyncio.gather( subtensor.get_stake_for_coldkey(coldkey_ss58=wallet.coldkeypub.ss58_address), subtensor.fetch_coldkey_hotkey_identities(), - subtensor.get_delegate_identities(), ) hotkey_stakes = {} @@ -354,11 +353,10 @@ async def stake_move_transfer_selection( hotkeys_info = [] for idx, (hotkey_ss58, netuid_stakes) in enumerate(hotkey_stakes.items()): if hk_identity := ck_hk_identities["hotkeys"].get(hotkey_ss58): - hotkey_name = hk_identity.get("identity", {}).get( - "name", "" - ) or hk_identity.get("display", "~") - elif old_identity := old_identities.get(hotkey_ss58): - hotkey_name = old_identity.display + identity_data = hk_identity.get("identity", {}) + hotkey_name = ( + identity_data.get("name") or identity_data.get("display") or "~" + ) else: hotkey_name = "~" hotkeys_info.append( From 35573b5278a556cdb84e5dd8207e13f3ee827269 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Wed, 25 Mar 2026 16:24:03 -0700 Subject: [PATCH 17/49] update wizard --- bittensor_cli/src/commands/stake/wizard.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/bittensor_cli/src/commands/stake/wizard.py b/bittensor_cli/src/commands/stake/wizard.py index 1b11f93c3..205ef57ca 100644 --- a/bittensor_cli/src/commands/stake/wizard.py +++ b/bittensor_cli/src/commands/stake/wizard.py @@ -102,12 +102,11 @@ async def stake_movement_wizard( # Get stakes for the wallet with console.status("Retrieving stake information..."): - stakes, ck_hk_identities, old_identities = await asyncio.gather( + stakes, ck_hk_identities = await asyncio.gather( subtensor.get_stake_for_coldkey( coldkey_ss58=wallet.coldkeypub.ss58_address ), subtensor.fetch_coldkey_hotkey_identities(), - subtensor.get_delegate_identities(), ) # Filter stakes with actual amounts @@ -118,16 +117,16 @@ async def stake_movement_wizard( return None # Display available stakes - _display_available_stakes(available_stakes, ck_hk_identities, old_identities) + _display_available_stakes(available_stakes, ck_hk_identities) # Guide user through the specific operation if operation == "move": return await _guide_move_operation( - subtensor, wallet, available_stakes, ck_hk_identities, old_identities + subtensor, wallet, available_stakes, ck_hk_identities ) elif operation == "transfer": return await _guide_transfer_operation( - subtensor, wallet, available_stakes, ck_hk_identities, old_identities + subtensor, wallet, available_stakes, ck_hk_identities ) elif operation == "swap": return await _guide_swap_operation(subtensor, wallet, available_stakes) @@ -138,7 +137,6 @@ async def stake_movement_wizard( def _display_available_stakes( stakes: list, ck_hk_identities: dict, - old_identities: dict, ): """Display a table of available stakes.""" # Group stakes by hotkey @@ -152,11 +150,8 @@ def _display_available_stakes( # Get identities def get_identity(hotkey_ss58_: str) -> str: if hk_identity := ck_hk_identities["hotkeys"].get(hotkey_ss58_): - return hk_identity.get("identity", {}).get("name", "") or hk_identity.get( - "display", "~" - ) - elif old_identity := old_identities.get(hotkey_ss58_): - return old_identity.display + identity_data = hk_identity.get("identity", {}) + return identity_data.get("name") or identity_data.get("display") or "~" return "~" table = create_table( @@ -193,7 +188,6 @@ async def _guide_move_operation( wallet: Wallet, available_stakes: list, ck_hk_identities: dict, - old_identities: dict, ) -> dict: """Guide user through move operation.""" console.print( @@ -263,7 +257,6 @@ async def _guide_transfer_operation( wallet: Wallet, available_stakes: list, ck_hk_identities: dict, - old_identities: dict, ) -> dict: """Guide user through transfer operation.""" console.print( From 4cb9823b9656dc008ff2e442adf498fd03006515 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Wed, 25 Mar 2026 16:30:08 -0700 Subject: [PATCH 18/49] update view --- bittensor_cli/src/commands/view.py | 33 +++++++++--------------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/bittensor_cli/src/commands/view.py b/bittensor_cli/src/commands/view.py index 26bb3eb95..5e98c403f 100644 --- a/bittensor_cli/src/commands/view.py +++ b/bittensor_cli/src/commands/view.py @@ -91,30 +91,24 @@ def int_to_ip(int_val: int) -> str: def get_identity( hotkey_ss58: str, identities: dict, - old_identities: dict, truncate_length: int = 4, return_bool: bool = False, lookup_hk: bool = True, ) -> str: - """Fetch identity of hotkey from both sources""" + """Fetch identity from the V2-backed coldkey/hotkey identity map.""" if lookup_hk: if hk_identity := identities["hotkeys"].get(hotkey_ss58): - return hk_identity.get("identity", {}).get("name", "") or hk_identity.get( - "display", "~" - ) + identity_data = hk_identity.get("identity", {}) + return identity_data.get("name") or identity_data.get("display") or "~" else: if ck_identity := identities["coldkeys"].get(hotkey_ss58): - return ck_identity.get("identity", {}).get("name", "") or ck_identity.get( - "display", "~" - ) + identity_data = ck_identity.get("identity", {}) + return identity_data.get("name") or identity_data.get("display") or "~" - if old_identity := old_identities.get(hotkey_ss58): - return old_identity.display + if return_bool: + return False else: - if return_bool: - return False - else: - return f"{hotkey_ss58[:truncate_length]}...{hotkey_ss58[-truncate_length:]}" + return f"{hotkey_ss58[:truncate_length]}...{hotkey_ss58[-truncate_length:]}" async def fetch_subnet_data( @@ -131,7 +125,6 @@ async def fetch_subnet_data( metagraphs_info, subnets_info, ck_hk_identities, - old_identities, block_number, ) = await asyncio.gather( subtensor.get_balance(wallet.coldkeypub.ss58_address, block_hash=block_hash), @@ -141,7 +134,6 @@ async def fetch_subnet_data( subtensor.get_all_metagraphs_info(block_hash=block_hash), subtensor.all_subnets(block_hash=block_hash), subtensor.fetch_coldkey_hotkey_identities(block_hash=block_hash), - subtensor.get_delegate_identities(block_hash=block_hash), subtensor.substrate.get_block_number(block_hash=block_hash), ) @@ -151,7 +143,6 @@ async def fetch_subnet_data( "metagraphs_info": metagraphs_info, "subnets_info": subnets_info, "ck_hk_identities": ck_hk_identities, - "old_identities": old_identities, "wallet": wallet, "block_number": block_number, } @@ -166,7 +157,6 @@ def process_subnet_data(raw_data: dict[str, Any]) -> dict[str, Any]: metagraphs_info = raw_data["metagraphs_info"] subnets_info = raw_data["subnets_info"] ck_hk_identities = raw_data["ck_hk_identities"] - old_identities = raw_data["old_identities"] wallet = raw_data["wallet"] block_number = raw_data["block_number"] @@ -189,7 +179,7 @@ def process_subnet_data(raw_data: dict[str, Any]) -> dict[str, Any]: { "hotkey": stake.hotkey_ss58, "hotkey_identity": get_identity( - stake.hotkey_ss58, ck_hk_identities, old_identities + stake.hotkey_ss58, ck_hk_identities ), "amount": stake.stake.tao, "emission": stake.emission.tao, @@ -252,9 +242,7 @@ def process_subnet_data(raw_data: dict[str, Any]) -> dict[str, Any]: # Add identities for hotkey in meta_info.hotkeys: - identity = get_identity( - hotkey, ck_hk_identities, old_identities, truncate_length=2 - ) + identity = get_identity(hotkey, ck_hk_identities, truncate_length=2) metagraph_info["updated_identities"].append(identity) # Balance conversion @@ -306,7 +294,6 @@ def process_subnet_data(raw_data: dict[str, Any]) -> dict[str, Any]: wallet_identity = get_identity( wallet.coldkeypub.ss58_address, ck_hk_identities, - old_identities, return_bool=True, lookup_hk=False, ) From 1e4be77e8e12981873a9f8ad1ad1f9d1dfa78642 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Wed, 25 Mar 2026 16:34:28 -0700 Subject: [PATCH 19/49] update set_auto_stake_destination --- bittensor_cli/src/commands/stake/auto_staking.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/bittensor_cli/src/commands/stake/auto_staking.py b/bittensor_cli/src/commands/stake/auto_staking.py index 0afee7bce..42e77c1b7 100644 --- a/bittensor_cli/src/commands/stake/auto_staking.py +++ b/bittensor_cli/src/commands/stake/auto_staking.py @@ -45,7 +45,6 @@ async def show_auto_stake_destinations( subnet_info, auto_destinations, identities, - delegate_identities, ) = await asyncio.gather( subtensor.all_subnets(block_hash=chain_head), subtensor.get_auto_stake_destinations( @@ -54,13 +53,11 @@ async def show_auto_stake_destinations( reuse_block=True, ), subtensor.fetch_coldkey_hotkey_identities(block_hash=chain_head), - subtensor.get_delegate_identities(block_hash=chain_head), ) subnet_map = {info.netuid: info for info in subnet_info} auto_destinations = auto_destinations or {} identities = identities or {} - delegate_identities = delegate_identities or {} hotkey_identities = identities.get("hotkeys", {}) def resolve_identity(hotkey: str) -> Optional[str]: @@ -73,10 +70,6 @@ def resolve_identity(hotkey: str) -> Optional[str]: if display_name: return display_name - delegate_info = delegate_identities.get(hotkey) - if delegate_info and getattr(delegate_info, "display", ""): - return delegate_info.display - return None coldkey_display = wallet_name @@ -183,10 +176,9 @@ async def set_auto_stake_destination( try: chain_head = await subtensor.substrate.get_chain_head() - subnet_info, identities, delegate_identities = await asyncio.gather( + subnet_info, identities = await asyncio.gather( subtensor.subnet(netuid, block_hash=chain_head), subtensor.fetch_coldkey_hotkey_identities(block_hash=chain_head), - subtensor.get_delegate_identities(block_hash=chain_head), ) except ValueError: print_error(f"Subnet with netuid {netuid} does not exist") @@ -194,17 +186,12 @@ async def set_auto_stake_destination( hotkey_identity = "" identities = identities or {} - delegate_identities = delegate_identities or {} hotkey_identity_entry = identities.get("hotkeys", {}).get(hotkey_ss58, {}) if identity_data := hotkey_identity_entry.get("identity"): hotkey_identity = ( identity_data.get("name") or identity_data.get("display") or "" ) - if not hotkey_identity: - delegate_info = delegate_identities.get(hotkey_ss58) - if delegate_info and getattr(delegate_info, "display", ""): - hotkey_identity = delegate_info.display if prompt_user and not json_output: table = create_table( From cbab0b86721f48090569d474c38b0a83ef0b0d2a Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Wed, 25 Mar 2026 16:37:49 -0700 Subject: [PATCH 20/49] update remove --- bittensor_cli/src/commands/stake/remove.py | 23 ++++------------------ 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/bittensor_cli/src/commands/stake/remove.py b/bittensor_cli/src/commands/stake/remove.py index b99458641..215f973d2 100644 --- a/bittensor_cli/src/commands/stake/remove.py +++ b/bittensor_cli/src/commands/stake/remove.py @@ -67,12 +67,10 @@ async def unstake( ( all_sn_dynamic_info_, ck_hk_identities, - old_identities, stake_infos, ) = await asyncio.gather( subtensor.all_subnets(block_hash=chain_head), subtensor.fetch_coldkey_hotkey_identities(block_hash=chain_head), - subtensor.get_delegate_identities(block_hash=chain_head), subtensor.get_stake_for_coldkey(coldkey_ss58, block_hash=chain_head), ) all_sn_dynamic_info = {info.netuid: info for info in all_sn_dynamic_info_} @@ -82,7 +80,6 @@ async def unstake( hotkeys_to_unstake_from, unstake_all_from_hk = await _unstake_selection( all_sn_dynamic_info, ck_hk_identities, - old_identities, stake_infos, netuid=netuid, ) @@ -125,7 +122,6 @@ async def unstake( exclude_hotkeys=exclude_hotkeys, stake_infos=stake_infos, identities=ck_hk_identities, - old_identities=old_identities, ) with console.status( @@ -522,13 +518,11 @@ async def unstake_all( ( stake_info, ck_hk_identities, - old_identities, all_sn_dynamic_info_, current_wallet_balance, ) = await asyncio.gather( subtensor.get_stake_for_coldkey(coldkey_ss58), subtensor.fetch_coldkey_hotkey_identities(), - subtensor.get_delegate_identities(), subtensor.all_subnets(), subtensor.get_balance(coldkey_ss58), ) @@ -542,7 +536,6 @@ async def unstake_all( exclude_hotkeys=exclude_hotkeys, stake_infos=stake_info, identities=ck_hk_identities, - old_identities=old_identities, ) elif not hotkey_ss58_address: hotkeys = [(wallet.hotkey_str, get_hotkey_pub_ss58(wallet), None)] @@ -1196,7 +1189,6 @@ async def _get_extrinsic_fee( async def _unstake_selection( dynamic_info, identities, - old_identities, stake_infos, netuid: Optional[int] = None, ) -> tuple[list[tuple[str, str, int]], bool]: @@ -1226,7 +1218,6 @@ async def _unstake_selection( hotkey_name = get_hotkey_identity( hotkey_ss58=hotkey_ss58, identities=identities, - old_identities=old_identities, ) hotkeys_info.append( { @@ -1418,7 +1409,6 @@ def _get_hotkeys_to_unstake( exclude_hotkeys: list[str], stake_infos: list, identities: dict, - old_identities: dict, ) -> list[tuple[Optional[str], str, None]]: """Get list of hotkeys to unstake from based on input parameters. @@ -1449,7 +1439,7 @@ def _get_hotkeys_to_unstake( wallet_hotkey_addresses = {hk[1] for hk in wallet_hotkeys} chain_hotkeys = [ ( - get_hotkey_identity(stake_info.hotkey_ss58, identities, old_identities), + get_hotkey_identity(stake_info.hotkey_ss58, identities), stake_info.hotkey_ss58, None, ) @@ -1603,23 +1593,18 @@ def _print_table_and_slippage( def get_hotkey_identity( hotkey_ss58: str, identities: dict, - old_identities: dict, ) -> str: - """Get identity name for a hotkey from identities or old_identities. + """Get identity name for a hotkey from the V2-backed identity map. Args: hotkey_ss58 (str): The hotkey SS58 address identities (dict): Current identities from fetch_coldkey_hotkey_identities - old_identities (dict): Old identities from get_delegate_identities Returns: str: Identity name or truncated address """ if hk_identity := identities["hotkeys"].get(hotkey_ss58): - return hk_identity.get("identity", {}).get("name", "") or hk_identity.get( - "display", "~" - ) - elif old_identity := old_identities.get(hotkey_ss58): - return old_identity.display + identity_data = hk_identity.get("identity", {}) + return identity_data.get("name") or identity_data.get("display") or "~" else: return f"{hotkey_ss58[:4]}...{hotkey_ss58[-4:]}" From 72e0520ffeba8dc4899cd6253acab426ab7eeaac Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Wed, 25 Mar 2026 17:22:57 -0700 Subject: [PATCH 21/49] update inspect --- bittensor_cli/src/commands/wallets.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/bittensor_cli/src/commands/wallets.py b/bittensor_cli/src/commands/wallets.py index eddf9d611..e10e3f6bd 100644 --- a/bittensor_cli/src/commands/wallets.py +++ b/bittensor_cli/src/commands/wallets.py @@ -1571,10 +1571,13 @@ def delegate_row_maker( for d_, staked in delegates_: if not staked.tao > 0: continue - if d_.hotkey_ss58 in registered_delegate_info: - delegate_name = registered_delegate_info[d_.hotkey_ss58].display - else: - delegate_name = d_.hotkey_ss58 + hotkey_identity = delegate_identity_map["hotkeys"].get(d_.hotkey_ss58, {}) + identity_data = hotkey_identity.get("identity", {}) + delegate_name = ( + identity_data.get("name") + or identity_data.get("display") + or d_.hotkey_ss58 + ) yield ( [""] * 2 + [ @@ -1634,12 +1637,15 @@ def neuron_row_maker( block_hash=block_hash, ) # bittensor.logging.debug(f"Netuids to check: {all_netuids}") - with console.status("Pulling delegates info...", spinner="aesthetic"): - registered_delegate_info = await subtensor.get_delegate_identities() - if not registered_delegate_info: + with console.status("Pulling identity info...", spinner="aesthetic"): + delegate_identity_map = await subtensor.fetch_coldkey_hotkey_identities( + block_hash=block_hash + ) + if not delegate_identity_map: console.print( - ":warning:[yellow]Could not get delegate info from chain.[/yellow]" + ":warning:[yellow]Could not get identity info from chain.[/yellow]" ) + delegate_identity_map = delegate_identity_map or {"hotkeys": {}, "coldkeys": {}} table = Table( Column("[bold white]Coldkey", style="dark_orange"), From fe433a496214536fdf177e9ced5e529eebf3751e Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Wed, 25 Mar 2026 17:55:23 -0700 Subject: [PATCH 22/49] update stake list --- bittensor_cli/src/commands/stake/list.py | 35 ++++++++++-------------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/bittensor_cli/src/commands/stake/list.py b/bittensor_cli/src/commands/stake/list.py index 24e5dbaa7..3ce8cd9fd 100644 --- a/bittensor_cli/src/commands/stake/list.py +++ b/bittensor_cli/src/commands/stake/list.py @@ -40,16 +40,17 @@ async def stake_list( async def get_stake_data(block_hash_: str = None): ( sub_stakes_, - registered_delegate_info_, + hotkey_identity_map_, _dynamic_info, ) = await asyncio.gather( subtensor.get_stake_for_coldkey( coldkey_ss58=coldkey_address, block_hash=block_hash_ ), - subtensor.get_delegate_identities(block_hash=block_hash_), + subtensor.fetch_coldkey_hotkey_identities(block_hash=block_hash_), subtensor.all_subnets(block_hash=block_hash_), ) + hotkey_identity_map_ = hotkey_identity_map_ or {"hotkeys": {}, "coldkeys": {}} claimable_amounts_ = {} if sub_stakes_: claimable_amounts_ = await subtensor.get_claimable_stakes_for_coldkey( @@ -61,11 +62,17 @@ async def get_stake_data(block_hash_: str = None): dynamic_info__ = {info.netuid: info for info in _dynamic_info} return ( sub_stakes_, - registered_delegate_info_, + hotkey_identity_map_, dynamic_info__, claimable_amounts_, ) + def format_hotkey_name(hotkey_ss58_: str, hotkey_identity_map_: dict) -> str: + hotkey_identity = hotkey_identity_map_.get("hotkeys", {}).get(hotkey_ss58_, {}) + identity_data = hotkey_identity.get("identity", {}) + display_name = identity_data.get("name") or identity_data.get("display") + return f"{display_name} ({hotkey_ss58_})" if display_name else hotkey_ss58_ + def define_table( hotkey_name_: str, rows: list[list[str]], @@ -156,11 +163,7 @@ def create_table( substakes_: list[StakeInfo], claimable_amounts_: dict[str, dict[int, Balance]], ): - name_ = ( - f"{registered_delegate_info[hotkey_].display} ({hotkey_})" - if hotkey_ in registered_delegate_info - else hotkey_ - ) + name_ = format_hotkey_name(hotkey_, hotkey_identity_map) rows = [] total_tao_value_ = Balance(0) total_swapped_tao_value_ = Balance(0) @@ -481,7 +484,7 @@ def format_cell( ( ( sub_stakes, - registered_delegate_info, + hotkey_identity_map, dynamic_info, claimable_amounts, ), @@ -509,11 +512,7 @@ def format_cell( "\n[bold]Multiple hotkeys found. Please select one for live monitoring:[/bold]" ) for idx, hotkey in enumerate(hotkeys_to_substakes.keys()): - name = ( - f"{registered_delegate_info[hotkey].display} ({hotkey})" - if hotkey in registered_delegate_info - else hotkey - ) + name = format_hotkey_name(hotkey, hotkey_identity_map) console.print(f"[{idx}] [{COLOR_PALETTE['GENERAL']['HEADER']}]{name}") selected_idx = Prompt.ask( @@ -524,11 +523,7 @@ def format_cell( else: selected_hotkey = list(hotkeys_to_substakes.keys())[0] - hotkey_name = ( - f"{registered_delegate_info[selected_hotkey].display} ({selected_hotkey})" - if selected_hotkey in registered_delegate_info - else selected_hotkey - ) + hotkey_name = format_hotkey_name(selected_hotkey, hotkey_identity_map) refresh_interval = 10 # seconds progress = Progress( @@ -549,7 +544,7 @@ def format_cell( block_hash = await subtensor.substrate.get_chain_head() ( sub_stakes, - registered_delegate_info, + _hotkey_identity_map, dynamic_info_, claimable_amounts_live, ) = await get_stake_data(block_hash) From 0620a4cab6903659206009f101e4d52b62172171 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Wed, 25 Mar 2026 18:13:50 -0700 Subject: [PATCH 23/49] add utils --- bittensor_cli/src/bittensor/utils.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/bittensor_cli/src/bittensor/utils.py b/bittensor_cli/src/bittensor/utils.py index 9851a9016..fe6c7c6b9 100644 --- a/bittensor_cli/src/bittensor/utils.py +++ b/bittensor_cli/src/bittensor/utils.py @@ -147,6 +147,24 @@ def create_table(*columns, title: str = "", **overrides) -> Table: return Table(*columns, **config) +def get_hotkey_identity_name( + identities: dict[str, Any], hotkey_ss58: str +) -> Optional[str]: + """Return a hotkey display name from the V2 identity map, if present.""" + hotkey_identity = identities.get("hotkeys", {}).get(hotkey_ss58, {}) + identity_data = hotkey_identity.get("identity", {}) + return identity_data.get("name") or identity_data.get("display") or None + + +def get_coldkey_identity_name( + identities: dict[str, Any], coldkey_ss58: str +) -> Optional[str]: + """Return a coldkey display name from the V2 identity map, if present.""" + coldkey_identity = identities.get("coldkeys", {}).get(coldkey_ss58, {}) + identity_data = coldkey_identity.get("identity", {}) + return identity_data.get("name") or identity_data.get("display") or None + + jinja_env = Environment( loader=PackageLoader("bittensor_cli", "src/bittensor/templates"), autoescape=select_autoescape(), From 5d8ca8debbf132b4b5722886711f93ab52252e5c Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Wed, 25 Mar 2026 18:14:17 -0700 Subject: [PATCH 24/49] update sudo --- bittensor_cli/src/commands/sudo.py | 38 ++++++++++++++---------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/bittensor_cli/src/commands/sudo.py b/bittensor_cli/src/commands/sudo.py index 6fb51e4bb..898375a51 100644 --- a/bittensor_cli/src/commands/sudo.py +++ b/bittensor_cli/src/commands/sudo.py @@ -15,7 +15,6 @@ HYPERPARAMS_MODULE, HYPERPARAMS_METADATA, RootSudoOnly, - DelegatesDetails, COLOR_PALETTE, ) from bittensor_cli.src.bittensor.balances import Balance @@ -38,6 +37,7 @@ string_to_u64, get_hotkey_pub_ss58, print_extrinsic_id, + get_hotkey_identity_name, ) if TYPE_CHECKING: @@ -730,22 +730,24 @@ async def get_proposal_call_data(p_hash: str) -> Optional[GenericCall]: def display_votes( - vote_data: "ProposalVoteData", delegate_info: dict[str, DelegatesDetails] + vote_data: "ProposalVoteData", hotkey_identity_map: dict[str, dict] ) -> str: vote_list = list() for address in vote_data.ayes: + display_name = get_hotkey_identity_name(hotkey_identity_map, address) or address vote_list.append( "{}: {}".format( - delegate_info[address].display if address in delegate_info else address, + display_name, "[bold green]Aye[/bold green]", ) ) for address in vote_data.nays: + display_name = get_hotkey_identity_name(hotkey_identity_map, address) or address vote_list.append( "{}: {}".format( - delegate_info[address].display if address in delegate_info else address, + display_name, "[bold red]Nay[/bold red]", ) ) @@ -754,14 +756,14 @@ def display_votes( def serialize_vote_data( - vote_data: "ProposalVoteData", delegate_info: dict[str, DelegatesDetails] + vote_data: "ProposalVoteData", hotkey_identity_map: dict[str, dict] ) -> list[dict[str, bool]]: vote_list = {} for address in vote_data.ayes: - f_add = delegate_info[address].display if address in delegate_info else address + f_add = get_hotkey_identity_name(hotkey_identity_map, address) or address vote_list[f_add] = True for address in vote_data.nays: - f_add = delegate_info[address].display if address in delegate_info else address + f_add = get_hotkey_identity_name(hotkey_identity_map, address) or address vote_list[f_add] = False return vote_list @@ -1216,9 +1218,8 @@ async def get_senate( senate_members = await _get_senate_members(subtensor) print_verbose("Fetching member details from Github and on-chain identities") - delegate_info: dict[ - str, DelegatesDetails - ] = await subtensor.get_delegate_identities() + hotkey_identity_map = await subtensor.fetch_coldkey_hotkey_identities() + hotkey_identity_map = hotkey_identity_map or {"hotkeys": {}, "coldkeys": {}} table = Table( Column( @@ -1241,11 +1242,7 @@ async def get_senate( dict_output = [] for ss58_address in senate_members: - member_name = ( - delegate_info[ss58_address].display - if ss58_address in delegate_info - else "~" - ) + member_name = get_hotkey_identity_name(hotkey_identity_map, ss58_address) or "~" table.add_row( member_name, ss58_address, @@ -1271,9 +1268,10 @@ async def proposals( subtensor.substrate.get_block_number(block_hash), ) - registered_delegate_info: dict[ - str, DelegatesDetails - ] = await subtensor.get_delegate_identities() + hotkey_identity_map = await subtensor.fetch_coldkey_hotkey_identities( + block_hash=block_hash + ) + hotkey_identity_map = hotkey_identity_map or {"hotkeys": {}, "coldkeys": {}} title = ( f"[bold #4196D6]Bittensor Governance Proposals[/bold #4196D6]\n" @@ -1329,7 +1327,7 @@ async def proposals( str(vote_data.threshold), f"{len(vote_data.ayes)} ({ayes_threshold:.2f}%)", f"{len(vote_data.nays)} ({nays_threshold:.2f}%)", - display_votes(vote_data, registered_delegate_info), + display_votes(vote_data, hotkey_identity_map), vote_end_cell, f_call_data, ) @@ -1339,7 +1337,7 @@ async def proposals( "threshold": vote_data.threshold, "ayes": len(vote_data.ayes), "nays": len(vote_data.nays), - "votes": serialize_vote_data(vote_data, registered_delegate_info), + "votes": serialize_vote_data(vote_data, hotkey_identity_map), "end": vote_data.end, "call_data": f_call_data, } From 6d04b952aed6f88cf1f9355553dbc69b859d1baf Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Wed, 25 Mar 2026 18:14:41 -0700 Subject: [PATCH 25/49] update view & wizard --- bittensor_cli/src/commands/stake/wizard.py | 6 ++---- bittensor_cli/src/commands/view.py | 18 +++++++++++------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/bittensor_cli/src/commands/stake/wizard.py b/bittensor_cli/src/commands/stake/wizard.py index 205ef57ca..8875ee8f5 100644 --- a/bittensor_cli/src/commands/stake/wizard.py +++ b/bittensor_cli/src/commands/stake/wizard.py @@ -21,6 +21,7 @@ get_hotkey_pub_ss58, group_subnets, get_hotkey_wallets_for_wallet, + get_hotkey_identity_name, ) from bittensor_cli.src.commands.stake.move import ( stake_move_transfer_selection, @@ -149,10 +150,7 @@ def _display_available_stakes( # Get identities def get_identity(hotkey_ss58_: str) -> str: - if hk_identity := ck_hk_identities["hotkeys"].get(hotkey_ss58_): - identity_data = hk_identity.get("identity", {}) - return identity_data.get("name") or identity_data.get("display") or "~" - return "~" + return get_hotkey_identity_name(ck_hk_identities, hotkey_ss58_) or "~" table = create_table( title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Your Available Stakes[/{COLOR_PALETTE['GENERAL']['HEADER']}]\n", diff --git a/bittensor_cli/src/commands/view.py b/bittensor_cli/src/commands/view.py index 5e98c403f..94cfe1546 100644 --- a/bittensor_cli/src/commands/view.py +++ b/bittensor_cli/src/commands/view.py @@ -7,7 +7,13 @@ from bittensor_cli.src.bittensor.balances import Balance from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface -from bittensor_cli.src.bittensor.utils import console, WalletLike, jinja_env +from bittensor_cli.src.bittensor.utils import ( + console, + WalletLike, + jinja_env, + get_hotkey_identity_name, + get_coldkey_identity_name, +) from bittensor_wallet import Wallet from bittensor_cli.src import defaults @@ -97,13 +103,11 @@ def get_identity( ) -> str: """Fetch identity from the V2-backed coldkey/hotkey identity map.""" if lookup_hk: - if hk_identity := identities["hotkeys"].get(hotkey_ss58): - identity_data = hk_identity.get("identity", {}) - return identity_data.get("name") or identity_data.get("display") or "~" + if display_name := get_hotkey_identity_name(identities, hotkey_ss58): + return display_name else: - if ck_identity := identities["coldkeys"].get(hotkey_ss58): - identity_data = ck_identity.get("identity", {}) - return identity_data.get("name") or identity_data.get("display") or "~" + if display_name := get_coldkey_identity_name(identities, hotkey_ss58): + return display_name if return_bool: return False From 154e9337ec1cff674735235e4c0f5d3d771cabd2 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Wed, 25 Mar 2026 18:15:12 -0700 Subject: [PATCH 26/49] update staking cmds --- bittensor_cli/src/commands/stake/move.py | 9 ++------- bittensor_cli/src/commands/stake/remove.py | 9 ++++----- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/bittensor_cli/src/commands/stake/move.py b/bittensor_cli/src/commands/stake/move.py index 0ff81c2cf..f9b3693ca 100644 --- a/bittensor_cli/src/commands/stake/move.py +++ b/bittensor_cli/src/commands/stake/move.py @@ -22,6 +22,7 @@ unlock_key, get_hotkey_pub_ss58, print_extrinsic_id, + get_hotkey_identity_name, ) if TYPE_CHECKING: @@ -352,13 +353,7 @@ async def stake_move_transfer_selection( hotkeys_info = [] for idx, (hotkey_ss58, netuid_stakes) in enumerate(hotkey_stakes.items()): - if hk_identity := ck_hk_identities["hotkeys"].get(hotkey_ss58): - identity_data = hk_identity.get("identity", {}) - hotkey_name = ( - identity_data.get("name") or identity_data.get("display") or "~" - ) - else: - hotkey_name = "~" + hotkey_name = get_hotkey_identity_name(ck_hk_identities, hotkey_ss58) or "~" hotkeys_info.append( { "index": idx, diff --git a/bittensor_cli/src/commands/stake/remove.py b/bittensor_cli/src/commands/stake/remove.py index 215f973d2..129bf7363 100644 --- a/bittensor_cli/src/commands/stake/remove.py +++ b/bittensor_cli/src/commands/stake/remove.py @@ -29,6 +29,7 @@ json_console, get_hotkey_pub_ss58, print_extrinsic_id, + get_hotkey_identity_name, ) if TYPE_CHECKING: @@ -1603,8 +1604,6 @@ def get_hotkey_identity( Returns: str: Identity name or truncated address """ - if hk_identity := identities["hotkeys"].get(hotkey_ss58): - identity_data = hk_identity.get("identity", {}) - return identity_data.get("name") or identity_data.get("display") or "~" - else: - return f"{hotkey_ss58[:4]}...{hotkey_ss58[-4:]}" + return get_hotkey_identity_name(identities, hotkey_ss58) or ( + f"{hotkey_ss58[:4]}...{hotkey_ss58[-4:]}" + ) From dda55b8c9c167d95719858f8c4d590ec39088ee2 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Wed, 25 Mar 2026 18:15:28 -0700 Subject: [PATCH 27/49] update auto staking --- .../src/commands/stake/auto_staking.py | 21 +++++-------------- bittensor_cli/src/commands/wallets.py | 5 +---- 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/bittensor_cli/src/commands/stake/auto_staking.py b/bittensor_cli/src/commands/stake/auto_staking.py index 42e77c1b7..39896294f 100644 --- a/bittensor_cli/src/commands/stake/auto_staking.py +++ b/bittensor_cli/src/commands/stake/auto_staking.py @@ -17,6 +17,8 @@ print_error, unlock_key, print_extrinsic_id, + get_hotkey_identity_name, + get_coldkey_identity_name, ) if TYPE_CHECKING: @@ -58,25 +60,16 @@ async def show_auto_stake_destinations( subnet_map = {info.netuid: info for info in subnet_info} auto_destinations = auto_destinations or {} identities = identities or {} - hotkey_identities = identities.get("hotkeys", {}) def resolve_identity(hotkey: str) -> Optional[str]: if not hotkey: return None - identity_entry = hotkey_identities.get(hotkey, {}).get("identity") - if identity_entry: - display_name = identity_entry.get("name") or identity_entry.get("display") - if display_name: - return display_name - - return None + return get_hotkey_identity_name(identities, hotkey) coldkey_display = wallet_name if not coldkey_display: - coldkey_identity = identities.get("coldkeys", {}).get(coldkey_ss58, {}) - if identity_data := coldkey_identity.get("identity"): - coldkey_display = identity_data.get("name") or identity_data.get("display") + coldkey_display = get_coldkey_identity_name(identities, coldkey_ss58) if not coldkey_display: coldkey_display = f"{coldkey_ss58[:6]}...{coldkey_ss58[-6:]}" @@ -187,11 +180,7 @@ async def set_auto_stake_destination( hotkey_identity = "" identities = identities or {} - hotkey_identity_entry = identities.get("hotkeys", {}).get(hotkey_ss58, {}) - if identity_data := hotkey_identity_entry.get("identity"): - hotkey_identity = ( - identity_data.get("name") or identity_data.get("display") or "" - ) + hotkey_identity = get_hotkey_identity_name(identities, hotkey_ss58) or "" if prompt_user and not json_output: table = create_table( diff --git a/bittensor_cli/src/commands/wallets.py b/bittensor_cli/src/commands/wallets.py index e10e3f6bd..fbe48fcac 100644 --- a/bittensor_cli/src/commands/wallets.py +++ b/bittensor_cli/src/commands/wallets.py @@ -1571,11 +1571,8 @@ def delegate_row_maker( for d_, staked in delegates_: if not staked.tao > 0: continue - hotkey_identity = delegate_identity_map["hotkeys"].get(d_.hotkey_ss58, {}) - identity_data = hotkey_identity.get("identity", {}) delegate_name = ( - identity_data.get("name") - or identity_data.get("display") + utils.get_hotkey_identity_name(delegate_identity_map, d_.hotkey_ss58) or d_.hotkey_ss58 ) yield ( From 7fe612c4f3218280d6e43eb9d2c6cda659cec828 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Wed, 25 Mar 2026 18:15:37 -0700 Subject: [PATCH 28/49] stake list --- bittensor_cli/src/commands/stake/list.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bittensor_cli/src/commands/stake/list.py b/bittensor_cli/src/commands/stake/list.py index 3ce8cd9fd..61fc611e2 100644 --- a/bittensor_cli/src/commands/stake/list.py +++ b/bittensor_cli/src/commands/stake/list.py @@ -20,6 +20,7 @@ millify_tao, get_subnet_name, json_console, + get_hotkey_identity_name, ) if TYPE_CHECKING: @@ -68,9 +69,7 @@ async def get_stake_data(block_hash_: str = None): ) def format_hotkey_name(hotkey_ss58_: str, hotkey_identity_map_: dict) -> str: - hotkey_identity = hotkey_identity_map_.get("hotkeys", {}).get(hotkey_ss58_, {}) - identity_data = hotkey_identity.get("identity", {}) - display_name = identity_data.get("name") or identity_data.get("display") + display_name = get_hotkey_identity_name(hotkey_identity_map_, hotkey_ss58_) return f"{display_name} ({hotkey_ss58_})" if display_name else hotkey_ss58_ def define_table( From e2a1fc0ae8b58081f623032db6b2129640f266b7 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Wed, 25 Mar 2026 18:17:51 -0700 Subject: [PATCH 29/49] remove deprecated funcs + classes --- bittensor_cli/src/__init__.py | 43 ------------------- .../src/bittensor/subtensor_interface.py | 35 --------------- 2 files changed, 78 deletions(-) diff --git a/bittensor_cli/src/__init__.py b/bittensor_cli/src/__init__.py index fc1931fd6..95a4d3c39 100644 --- a/bittensor_cli/src/__init__.py +++ b/bittensor_cli/src/__init__.py @@ -1,6 +1,4 @@ from enum import Enum -from dataclasses import dataclass -from typing import Any, Optional class Constants: @@ -37,47 +35,6 @@ class Constants: delegates_detail_url = "https://raw.githubusercontent.com/opentensor/bittensor-delegates/main/public/delegates.json" -@dataclass -class DelegatesDetails: - display: str - additional: list[tuple[str, str]] - web: str - legal: Optional[str] = None - riot: Optional[str] = None - email: Optional[str] = None - pgp_fingerprint: Optional[str] = None - image: Optional[str] = None - twitter: Optional[str] = None - - @classmethod - def from_chain_data(cls, data: dict[str, Any]) -> "DelegatesDetails": - def decode(key: str, default=""): - try: - if isinstance(data.get(key), dict): - value = next(data.get(key).values()) - return bytes(value[0]).decode("utf-8") - elif isinstance(data.get(key), int): - return data.get(key) - elif isinstance(data.get(key), tuple): - return bytes(data.get(key)[0]).decode("utf-8") - else: - return default - except (UnicodeDecodeError, TypeError): - return default - - return cls( - display=decode("display"), - additional=decode("additional", []), - web=decode("web"), - legal=decode("legal"), - riot=decode("riot"), - email=decode("email"), - pgp_fingerprint=decode("pgp_fingerprint", None), - image=decode("image"), - twitter=decode("twitter"), - ) - - class Defaults: netuid = 1 rate_tolerance = 0.005 diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index e0aedecfa..6cb3f592a 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -32,7 +32,6 @@ CrowdloanData, ColdkeySwapAnnouncementInfo, ) -from bittensor_cli.src import DelegatesDetails from bittensor_cli.src.bittensor.balances import Balance, fixed_to_float from bittensor_cli.src import Constants, defaults, TYPE_REGISTRY from bittensor_cli.src.bittensor.extrinsics.mev_shield import encrypt_extrinsic @@ -40,7 +39,6 @@ format_error_message, console, print_error, - decode_hex_identity_dict, validate_chain_endpoint, u16_normalized_float, MEV_SHIELD_PUBLIC_KEY_SIZE, @@ -1518,39 +1516,6 @@ async def get_vote_data( else: return ProposalVoteData(vote_data) - async def get_delegate_identities( - self, block_hash: Optional[str] = None - ) -> dict[str, DelegatesDetails]: - """ - Fetches delegates identities from the chain and GitHub. Preference is given to chain data, and missing info - is filled-in by the info from GitHub. At some point, we want to totally move away from fetching this info - from GitHub, but chain data is still limited in that regard. - - :param block_hash: the hash of the blockchain block for the query - - :return: {ss58: DelegatesDetails, ...} - - """ - identities_info = await self.substrate.query_map( - module="Registry", - storage_function="IdentityOf", - block_hash=block_hash, - ) - - all_delegates_details = {} - async for ss58_address, identity in identities_info: - all_delegates_details.update( - { - decode_account_id( - ss58_address[0] - ): DelegatesDetails.from_chain_data( - decode_hex_identity_dict(identity.value["info"]) - ) - } - ) - - return all_delegates_details - async def get_mechagraph_info( self, netuid: int, mech_id: int, block_hash: Optional[str] = None ) -> Optional[MetagraphInfo]: From 8d1e0982719d6d4a368a9af8ffbc8faca047aec1 Mon Sep 17 00:00:00 2001 From: BD Himes Date: Mon, 30 Mar 2026 19:46:36 +0200 Subject: [PATCH 30/49] With the upcoming release of ASI 2.0 (probably in a month or two), we want to ensure users of old versions don't accidentally bump use an incompatible version. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e8e6d5cf7..df85cab2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ classifiers = [ ] dependencies = [ "wheel", - "async-substrate-interface>=1.6.2", + "async-substrate-interface>=1.6.2,<2.0.0", "aiohttp~=3.13", "backoff~=2.2.1", "bittensor-drand>=1.3.0", From 4e3416ce2f223b3c2ac8824dd16946d86a53e4a3 Mon Sep 17 00:00:00 2001 From: bitloi Date: Tue, 31 Mar 2026 20:23:19 +0200 Subject: [PATCH 31/49] fix: resolve proxy address for stake queries in move/transfer/swap and sudo trim --- bittensor_cli/src/commands/stake/move.py | 25 ++-- bittensor_cli/src/commands/sudo.py | 4 +- .../test_proxy_address_resolution.py | 139 ++++++++++++++++++ 3 files changed, 154 insertions(+), 14 deletions(-) create mode 100644 tests/unit_tests/test_proxy_address_resolution.py diff --git a/bittensor_cli/src/commands/stake/move.py b/bittensor_cli/src/commands/stake/move.py index f9b3693ca..0925ec987 100644 --- a/bittensor_cli/src/commands/stake/move.py +++ b/bittensor_cli/src/commands/stake/move.py @@ -540,6 +540,7 @@ async def move_stake( proxy: Optional[str] = None, mev_protection: bool = True, ) -> tuple[bool, str]: + coldkey_ss58 = proxy or wallet.coldkeypub.ss58_address if interactive_selection: try: selection = await stake_move_transfer_selection(subtensor, wallet) @@ -553,16 +554,15 @@ async def move_stake( # Get the wallet stake balances. block_hash = await subtensor.substrate.get_chain_head() - # TODO should this use `proxy if proxy else wallet.coldkeypub.ss58_address`? origin_stake_balance, destination_stake_balance = await asyncio.gather( subtensor.get_stake( - coldkey_ss58=wallet.coldkeypub.ss58_address, + coldkey_ss58=coldkey_ss58, hotkey_ss58=origin_hotkey, netuid=origin_netuid, block_hash=block_hash, ), subtensor.get_stake( - coldkey_ss58=wallet.coldkeypub.ss58_address, + coldkey_ss58=coldkey_ss58, hotkey_ss58=destination_hotkey, netuid=destination_netuid, block_hash=block_hash, @@ -705,13 +705,13 @@ async def move_stake( new_destination_stake_balance, ) = await asyncio.gather( subtensor.get_stake( - coldkey_ss58=wallet.coldkeypub.ss58_address, + coldkey_ss58=coldkey_ss58, hotkey_ss58=origin_hotkey, netuid=origin_netuid, block_hash=block_hash, ), subtensor.get_stake( - coldkey_ss58=wallet.coldkeypub.ss58_address, + coldkey_ss58=coldkey_ss58, hotkey_ss58=destination_hotkey, netuid=destination_netuid, block_hash=block_hash, @@ -771,6 +771,7 @@ async def transfer_stake( bool: True if transfer was successful, False otherwise. str: error message """ + coldkey_ss58 = proxy or wallet.coldkeypub.ss58_address if interactive_selection: selection = await stake_move_transfer_selection(subtensor, wallet) origin_netuid = selection["origin_netuid"] @@ -795,9 +796,8 @@ async def transfer_stake( # Get current stake balances with console.status(f"Retrieving stake data from {subtensor.network}..."): - # TODO should use proxy for these checks? current_stake = await subtensor.get_stake( - coldkey_ss58=wallet.coldkeypub.ss58_address, + coldkey_ss58=coldkey_ss58, hotkey_ss58=origin_hotkey, netuid=origin_netuid, ) @@ -918,7 +918,7 @@ async def transfer_stake( # Get and display new stake balances new_stake, new_dest_stake = await asyncio.gather( subtensor.get_stake( - coldkey_ss58=wallet.coldkeypub.ss58_address, + coldkey_ss58=coldkey_ss58, hotkey_ss58=origin_hotkey, netuid=origin_netuid, ), @@ -989,6 +989,7 @@ async def swap_stake( success is True if the swap was successful, False otherwise. extrinsic_identifier if the extrinsic was successfully included """ + coldkey_ss58 = proxy or wallet.coldkeypub.ss58_address hotkey_ss58 = get_hotkey_pub_ss58(wallet) if interactive_selection: try: @@ -1016,12 +1017,12 @@ async def swap_stake( # Get current stake balances with console.status(f"Retrieving stake data from {subtensor.network}..."): current_stake = await subtensor.get_stake( - coldkey_ss58=wallet.coldkeypub.ss58_address, + coldkey_ss58=coldkey_ss58, hotkey_ss58=hotkey_ss58, netuid=origin_netuid, ) current_dest_stake = await subtensor.get_stake( - coldkey_ss58=wallet.coldkeypub.ss58_address, + coldkey_ss58=coldkey_ss58, hotkey_ss58=hotkey_ss58, netuid=destination_netuid, ) @@ -1153,12 +1154,12 @@ async def swap_stake( # Get and display new stake balances new_stake, new_dest_stake = await asyncio.gather( subtensor.get_stake( - coldkey_ss58=wallet.coldkeypub.ss58_address, + coldkey_ss58=coldkey_ss58, hotkey_ss58=hotkey_ss58, netuid=origin_netuid, ), subtensor.get_stake( - coldkey_ss58=wallet.coldkeypub.ss58_address, + coldkey_ss58=coldkey_ss58, hotkey_ss58=hotkey_ss58, netuid=destination_netuid, ), diff --git a/bittensor_cli/src/commands/sudo.py b/bittensor_cli/src/commands/sudo.py index 898375a51..4f37d1613 100644 --- a/bittensor_cli/src/commands/sudo.py +++ b/bittensor_cli/src/commands/sudo.py @@ -1481,14 +1481,14 @@ async def trim( """ Trims a subnet's UIDs to a specified amount """ + coldkey_ss58 = proxy or wallet.coldkeypub.ss58_address print_verbose("Confirming subnet owner") subnet_owner = await subtensor.query( module="SubtensorModule", storage_function="SubnetOwner", params=[netuid], ) - # TODO should this check proxy also? - if subnet_owner != wallet.coldkeypub.ss58_address: + if subnet_owner != coldkey_ss58: err_msg = "This wallet doesn't own the specified subnet." if json_output: json_console.print_json(data={"success": False, "message": err_msg}) diff --git a/tests/unit_tests/test_proxy_address_resolution.py b/tests/unit_tests/test_proxy_address_resolution.py new file mode 100644 index 000000000..1068ee694 --- /dev/null +++ b/tests/unit_tests/test_proxy_address_resolution.py @@ -0,0 +1,139 @@ +"""Tests for proxy address resolution in stake move/transfer/swap and sudo trim. + +When a proxy is active, chain queries (get_stake, SubnetOwner) must use the +proxied account address, not the signer's address. +""" + +import pytest +from contextlib import contextmanager +from unittest.mock import AsyncMock, MagicMock, patch + +from bittensor_cli.src.bittensor.balances import Balance + +SIGNER_SS58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" +PROXY_SS58 = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" +HOTKEY_SS58 = "5CiQ1cV1MmMwsep7YP37QZKEgBgaVXeSPnETB5JBgwYRoXbP" +DEST_HOTKEY_SS58 = "5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy" + + +def _mock_wallet(): + wallet = MagicMock() + wallet.coldkeypub.ss58_address = SIGNER_SS58 + wallet.hotkey.ss58_address = HOTKEY_SS58 + wallet.hotkey_str = "default" + return wallet + + +def _mock_subtensor(stake_balance=Balance.from_tao(100)): + receipt = AsyncMock() + receipt.get_extrinsic_identifier = AsyncMock(return_value="0x123-1") + receipt.is_success = True + subtensor = MagicMock() + subtensor.network = "finney" + subtensor.substrate = MagicMock() + subtensor.substrate.get_chain_head = AsyncMock(return_value="0xabc") + subtensor.substrate.compose_call = AsyncMock(return_value=MagicMock()) + subtensor.get_stake = AsyncMock(return_value=stake_balance) + subtensor.get_balance = AsyncMock(return_value=Balance.from_tao(500)) + subtensor.subnet_exists = AsyncMock(return_value=True) + subtensor.get_extrinsic_fee = AsyncMock(return_value=Balance.from_tao(0.001)) + subtensor.substrate.get_account_next_index = AsyncMock(return_value=0) + subtensor.sim_swap = AsyncMock( + return_value=MagicMock(alpha_amount=100, tao_fee=1, alpha_fee=1) + ) + subtensor.sign_and_send_extrinsic = AsyncMock(return_value=(True, "", receipt)) + subtensor.query = AsyncMock() + return subtensor + + +@contextmanager +def _move_patches(**extra): + base = "bittensor_cli.src.commands.stake.move" + with ( + patch( + f"{base}.get_movement_pricing", + new_callable=AsyncMock, + return_value=MagicMock(rate_with_tolerance=None), + ), + patch(f"{base}.unlock_key", return_value=MagicMock(success=True)), + patch(f"{base}.print_extrinsic_id", new_callable=AsyncMock), + ): + if "hotkey" in extra: + with patch(f"{base}.get_hotkey_pub_ss58", return_value=extra["hotkey"]): + yield + else: + yield + + +@pytest.mark.asyncio +async def test_move_stake_uses_proxy_for_stake_lookup(): + """move_stake must query stake using the proxied account address.""" + from bittensor_cli.src.commands.stake.move import move_stake + + subtensor = _mock_subtensor() + with _move_patches(): + await move_stake( + subtensor=subtensor, + wallet=_mock_wallet(), + origin_netuid=1, + origin_hotkey=HOTKEY_SS58, + destination_netuid=2, + destination_hotkey=DEST_HOTKEY_SS58, + amount=10.0, + stake_all=False, + era=3, + prompt=False, + proxy=PROXY_SS58, + mev_protection=False, + ) + for call in subtensor.get_stake.call_args_list: + assert call.kwargs["coldkey_ss58"] == PROXY_SS58 + + +@pytest.mark.asyncio +async def test_trim_allows_proxy_owner(): + """trim must accept the proxied account as subnet owner.""" + from bittensor_cli.src.commands.sudo import trim + + subtensor = _mock_subtensor() + subtensor.query = AsyncMock(return_value=PROXY_SS58) + base = "bittensor_cli.src.commands.sudo" + with ( + patch(f"{base}.unlock_key", return_value=MagicMock(success=True)), + patch(f"{base}.print_extrinsic_id", new_callable=AsyncMock), + ): + result = await trim( + wallet=_mock_wallet(), + subtensor=subtensor, + netuid=1, + proxy=PROXY_SS58, + max_n=100, + period=100, + prompt=False, + decline=False, + quiet=True, + json_output=False, + ) + assert result is True + + +@pytest.mark.asyncio +async def test_trim_rejects_non_owner_with_proxy(): + """trim must reject when the proxy doesn't own the subnet.""" + from bittensor_cli.src.commands.sudo import trim + + subtensor = _mock_subtensor() + subtensor.query = AsyncMock(return_value="5UNRELATED_ADDRESS") + result = await trim( + wallet=_mock_wallet(), + subtensor=subtensor, + netuid=1, + proxy=PROXY_SS58, + max_n=100, + period=100, + prompt=False, + decline=False, + quiet=True, + json_output=False, + ) + assert result is False From 4128ea95eb0785445eba68fd76858f18ad3c79aa Mon Sep 17 00:00:00 2001 From: BD Himes Date: Wed, 1 Apr 2026 14:14:09 +0200 Subject: [PATCH 32/49] Bumps workflow versions, uses permission in release --- .github/workflows/e2e-subtensor-tests.yml | 10 +++++----- .github/workflows/release.yml | 12 +++++++----- .github/workflows/ruff-formatter.yml | 2 +- .github/workflows/unit-tests.yml | 4 ++-- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/.github/workflows/e2e-subtensor-tests.yml b/.github/workflows/e2e-subtensor-tests.yml index 5d1493835..87526faf5 100644 --- a/.github/workflows/e2e-subtensor-tests.yml +++ b/.github/workflows/e2e-subtensor-tests.yml @@ -46,7 +46,7 @@ jobs: test-files: ${{ steps.get-tests.outputs.test-files }} steps: - name: Check-out repository under $GITHUB_WORKSPACE - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Find test files id: get-tests @@ -158,7 +158,7 @@ jobs: run: docker save -o subtensor-localnet.tar ${{ steps.set-image.outputs.image }} - name: Upload Docker Image as Artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: subtensor-localnet path: subtensor-localnet.tar @@ -180,15 +180,15 @@ jobs: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - name: Check-out repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install uv - uses: astral-sh/setup-uv@v4 + uses: astral-sh/setup-uv@v8.0.0 with: python-version: 3.13 - name: Download Cached Docker Image - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: subtensor-localnet diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a0b25dec7..8c10dfeef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,13 +12,15 @@ jobs: build: name: Build Python distribution runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: - python-version: '3.10' + python-version: '3.12' - name: Install dependencies run: | @@ -45,7 +47,7 @@ jobs: fi - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: dist path: dist/ @@ -60,7 +62,7 @@ jobs: steps: - name: Download artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: dist path: dist/ diff --git a/.github/workflows/ruff-formatter.yml b/.github/workflows/ruff-formatter.yml index 92d7c30c4..b8e5fbd32 100644 --- a/.github/workflows/ruff-formatter.yml +++ b/.github/workflows/ruff-formatter.yml @@ -17,7 +17,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Ruff format check uses: astral-sh/ruff-action@v3 diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 4e96f29b6..736ef3f1f 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -30,10 +30,10 @@ jobs: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - name: Check-out repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install uv - uses: astral-sh/setup-uv@v4 + uses: astral-sh/setup-uv@v8.0.0 with: python-version: ${{ matrix.python-version }} From 32a6f699c9ccf792202adff69749cdf021b41a08 Mon Sep 17 00:00:00 2001 From: BD Himes <37844818+thewhaleking@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:49:09 +0200 Subject: [PATCH 33/49] inspect hotkey support (#883) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: remove unused --hotkey option and split inspect into two tables Remove the regressed --hotkey parameter from wallet inspect command and split the single 9-column table into two focused tables: a Coldkey Overview table and a Hotkey Details table. - Remove wallet_hotkey parameter from wallet_inspect signature - Update help text to reflect coldkey-only wallet display - Refactor inspect output into separate coldkey and hotkey tables - Extract helper functions for delegate/neuron row generation - Add unit tests for all new helper functions - Remove unused itertools import Fixes #233 * fix: address review — add ss58 option, fix alpha symbols, re-enable command - Remove early exit that disabled inspect on rao network - Add --ss58-address option so users can inspect any coldkey without a local wallet file (read-only, no extrinsics) - Fix hotkey details table to show subnet alpha symbols instead of tao symbol by calling Balance.set_unit(netuid) - Refactor inspect() to work with SS58 addresses directly, decoupling data fetching from wallet file existence Verified get_delegated in subtensor_interface.py — the method signature and return type match upstream (main and staging). No data structure update needed. * Typing and backwards compatibility * Ruff * fix subtensor init * use new identities * update tests --------- Co-authored-by: Nicknamess96 Co-authored-by: ibraheem-latent --- bittensor_cli/cli.py | 52 ++++- bittensor_cli/src/commands/wallets.py | 294 +++++++++++++++++--------- tests/unit_tests/test_inspect.py | 181 ++++++++++++++++ 3 files changed, 418 insertions(+), 109 deletions(-) create mode 100644 tests/unit_tests/test_inspect.py diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 12a6a9d75..f231a1cfb 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -2913,7 +2913,17 @@ def wallet_inspect( ), wallet_name: str = Options.wallet_name, wallet_path: str = Options.wallet_path, - wallet_hotkey: str = Options.wallet_hotkey, + wallet_hotkey: str = Options.edit_help( + "wallet_hotkey", + help_text="Deprecated option, " + "preserved for backwards compatibility to not break workflows which utilise it.", + ), + ss58_address: Optional[str] = typer.Option( + None, + "--ss58-address", + "--ss58", + help="SS58 address of the coldkey to inspect. Allows inspecting any coldkey without a local wallet file.", + ), network: Optional[list[str]] = Options.network, netuids: str = Options.netuids, quiet: bool = Options.quiet, @@ -2921,9 +2931,11 @@ def wallet_inspect( json_output: bool = Options.json_output, ): """ - Displays the details of the user's wallet pairs (coldkey, hotkey) on the Bittensor network. + Displays the details of the user's wallet (coldkey) on the Bittensor network. + + The output is presented as two separate tables: - The output is presented as a table with the below columns: + [bold]Coldkey Overview[/bold]: - [blue bold]Coldkey[/blue bold]: The coldkey associated with the user's wallet. @@ -2931,14 +2943,22 @@ def wallet_inspect( - [blue bold]Delegate[/blue bold]: The name of the delegate to which the coldkey has staked TAO. - - [blue bold]Stake[/blue bold]: The amount of stake held by both the coldkey and hotkey. + - [blue bold]Stake[/blue bold]: The amount of stake delegated. - - [blue bold]Emission[/blue bold]: The emission or rewards earned from staking. + - [blue bold]Emission[/blue bold]: The daily emission earned from delegation. + + [bold]Hotkey Details[/bold]: + + - [blue bold]Coldkey[/blue bold]: The parent coldkey of the hotkey. - - [blue bold]Netuid[/blue bold]: The network unique identifier of the subnet where the hotkey is active (i.e., validating). + - [blue bold]Netuid[/blue bold]: The network unique identifier of the subnet where the hotkey is active. - [blue bold]Hotkey[/blue bold]: The hotkey associated with the neuron on the network. + - [blue bold]Stake[/blue bold]: The amount of stake held by the hotkey. + + - [blue bold]Emission[/blue bold]: The emission or rewards earned from staking. + USAGE This command can be used to inspect a single wallet or all the wallets located at a specified path. It is useful for a comprehensive overview of a user's participation and performance in the Bittensor network. @@ -2949,10 +2969,10 @@ def wallet_inspect( [green]$[/green] btcli wallet inspect --all -n 1 -n 2 -n 3 + [green]$[/green] btcli wallet inspect --ss58-address 5FHneW46... + [bold]Note[/bold]: The `inspect` command is for displaying information only and does not perform any transactions or state changes on the blockchain. It is intended to be used with Bittensor CLI and not as a standalone function in user code. """ - print_error("This command is disabled on the 'rao' network.") - raise typer.Exit() self.verbosity_handler(quiet, verbose, json_output, False) if netuids: @@ -2962,18 +2982,28 @@ def wallet_inspect( "Netuids must be a comma-separated list of ints, e.g., `--netuids 1,2,3,4`.", ) + if ss58_address: + return self._run_command( + wallets.inspect( + None, + self.initialize_chain(network), + netuids_filter=netuids, + all_wallets=False, + ss58_address=ss58_address, + ) + ) + # if all-wallets is entered, ask for path ask_for = [WO.NAME, WO.PATH] if not all_wallets else [WO.PATH] validate = WV.WALLET if not all_wallets else WV.NONE wallet = self.wallet_ask( - wallet_name, wallet_path, wallet_hotkey, ask_for=ask_for, validate=validate + wallet_name, wallet_path, None, ask_for=ask_for, validate=validate ) - self.initialize_chain(network) return self._run_command( wallets.inspect( wallet, - self.subtensor, + self.initialize_chain(network), netuids_filter=netuids, all_wallets=all_wallets, ) diff --git a/bittensor_cli/src/commands/wallets.py b/bittensor_cli/src/commands/wallets.py index fbe48fcac..768d521f6 100644 --- a/bittensor_cli/src/commands/wallets.py +++ b/bittensor_cli/src/commands/wallets.py @@ -1,6 +1,5 @@ import asyncio import hashlib -import itertools import json import os from collections import defaultdict @@ -51,6 +50,7 @@ WalletLike, blocks_to_duration, get_hotkey_pub_ss58, + get_hotkey_identity_name, print_extrinsic_id, ) @@ -1558,69 +1558,174 @@ async def transfer( return result +def _build_coldkey_table(network: str) -> Table: + """Build the coldkey overview table for wallet inspect output.""" + return Table( + Column("[bold white]Coldkey", style="dark_orange"), + Column("[bold white]Balance", style="dark_sea_green"), + Column("[bold white]Delegate", style="bright_cyan", overflow="fold"), + Column("[bold white]Stake", style="light_goldenrod2"), + Column("[bold white]Emission", style="rgb(42,161,152)"), + title=( + f"[underline dark_orange]Coldkey Overview" + f"[/underline dark_orange]\n" + f"[dark_orange]Network: {network}\n" + ), + show_edge=False, + expand=True, + box=box.MINIMAL, + border_style="bright_black", + ) + + +def _build_hotkey_table(network: str) -> Table: + """Build the hotkey details table for wallet inspect output.""" + return Table( + Column("[bold white]Coldkey", style="dark_orange"), + Column("[bold white]Netuid", style="dark_orange"), + Column("[bold white]Hotkey", style="bright_magenta", overflow="fold"), + Column("[bold white]Stake", style="light_goldenrod2"), + Column("[bold white]Emission", style="rgb(42,161,152)"), + title=( + f"[underline dark_orange]Hotkey Details" + f"[/underline dark_orange]\n" + f"[dark_orange]Network: {network}\n" + ), + show_edge=False, + expand=True, + box=box.MINIMAL, + border_style="bright_black", + ) + + +def _make_delegate_rows( + delegates: list[tuple[DelegateInfo, Balance]], + delegate_identity_map: dict, +) -> Generator[list[str], None, None]: + """Yield coldkey table rows for each delegate with a positive stake.""" + for delegate_info, staked in delegates: + if not staked.tao > 0: + continue + delegate_name = ( + _resolve_delegate_name(delegate_info.hotkey_ss58, delegate_identity_map) + or delegate_info.hotkey_ss58 + ) + daily_return = _calculate_daily_return(delegate_info, staked) + yield ["", "", str(delegate_name), str(staked), str(daily_return)] + + +def _resolve_delegate_name(hotkey_ss58: str, delegate_identity_map: dict) -> str: + """Look up the display name for a delegate, falling back to the SS58 address.""" + name = get_hotkey_identity_name(delegate_identity_map, hotkey_ss58) + return name if name else hotkey_ss58 + + +def _calculate_daily_return(delegate_info: DelegateInfo, staked: Balance) -> float: + """Calculate the estimated daily return for a delegation.""" + if delegate_info.total_stake.tao != 0: + return delegate_info.total_daily_return.tao * ( + staked.tao / delegate_info.total_stake.tao + ) + return 0 + + +def _make_neuron_rows( + coldkey_ss58: str, + coldkey_name: str, + all_netuids: list[int], + neuron_state_dict: dict, + hotkeys: Optional[list[Optional[Wallet]]] = None, +) -> Generator[list[str], None, None]: + """Yield hotkey table rows for each neuron registered under the given coldkey.""" + hotkeys = hotkeys or [] + for netuid in all_netuids: + for neuron in neuron_state_dict[netuid]: + if neuron.coldkey == coldkey_ss58: + hotkey_label = _format_hotkey_label(neuron.hotkey, hotkeys) + stake = Balance.from_rao(neuron.stake.rao).set_unit(netuid) + emission = Balance.from_tao(neuron.emission).set_unit(netuid) + yield [ + coldkey_name, + str(netuid), + hotkey_label, + str(stake), + str(emission), + ] + + +def _format_hotkey_label(hotkey_ss58: str, hotkeys: list) -> str: + """Format a hotkey address with its wallet name prefix if available.""" + for wallet in hotkeys: + if get_hotkey_pub_ss58(wallet) == hotkey_ss58: + return f"{wallet.hotkey_str}-{hotkey_ss58}" + return hotkey_ss58 + + +def _populate_coldkey_table( + coldkey_table: Table, + coldkey_rows: list[list[str]], +) -> None: + """Add rows to the coldkey overview table with section separators.""" + for i, row in enumerate(coldkey_rows): + is_last_row = i + 1 == len(coldkey_rows) + coldkey_table.add_row(*row) + if is_last_row or (coldkey_rows[i + 1][0] != ""): + coldkey_table.add_row(end_section=True) + + +def _populate_hotkey_table( + hotkey_table: Table, + hotkey_rows: list[list[str]], +) -> None: + """Add rows to the hotkey details table with section separators.""" + for i, row in enumerate(hotkey_rows): + is_last_row = i + 1 == len(hotkey_rows) + hotkey_table.add_row(*row) + if is_last_row or (hotkey_rows[i + 1][0] != ""): + hotkey_table.add_row(end_section=True) + + async def inspect( - wallet: Wallet, + wallet: Optional[Wallet], subtensor: SubtensorInterface, netuids_filter: list[int], all_wallets: bool = False, + ss58_address: Optional[str] = None, ): - # TODO add json_output when this is re-enabled and updated for dTAO - def delegate_row_maker( - delegates_: list[tuple[DelegateInfo, Balance]], - ) -> Generator[list[str], None, None]: - for d_, staked in delegates_: - if not staked.tao > 0: - continue - delegate_name = ( - utils.get_hotkey_identity_name(delegate_identity_map, d_.hotkey_ss58) - or d_.hotkey_ss58 - ) - yield ( - [""] * 2 - + [ - str(delegate_name), - str(staked), - str( - d_.total_daily_return.tao * (staked.tao / d_.total_stake.tao) - if d_.total_stake.tao != 0 - else 0 - ), - ] - + [""] * 4 - ) - - def neuron_row_maker( - wallet_, all_netuids_, nsd - ) -> Generator[list[str], None, None]: - hotkeys = get_hotkey_wallets_for_wallet(wallet_) - for netuid in all_netuids_: - for n in nsd[netuid]: - if n.coldkey == wallet_.coldkeypub.ss58_address: - hotkey_name: str = "" - if hotkey_names := [ - w.hotkey_str - for w in hotkeys - if get_hotkey_pub_ss58(w) == n.hotkey - ]: - hotkey_name = f"{hotkey_names[0]}-" - yield [""] * 5 + [ - str(netuid), - f"{hotkey_name}{n.hotkey}", - str(n.stake), - str(Balance.from_tao(n.emission)), - ] - - if all_wallets: + if ss58_address: + # Direct SS58 address lookup — no wallet file needed + coldkey_addresses = [ss58_address] + coldkey_names = {ss58_address: ss58_address[:8] + "..."} + hotkeys_by_coldkey: dict[str, list] = {ss58_address: []} + elif all_wallets: print_verbose("Fetching data for all wallets") wallets = get_coldkey_wallets_for_path(wallet.path) - all_hotkeys = get_all_wallets_for_path( - wallet.path - ) # TODO verify this is correct - + wallets_with_ckp = [w for w in wallets if w.coldkeypub_file.exists_on_device()] + coldkey_addresses = [w.coldkeypub.ss58_address for w in wallets_with_ckp] + coldkey_names = {w.coldkeypub.ss58_address: w.name for w in wallets_with_ckp} + hotkeys_by_coldkey = { + w.coldkeypub.ss58_address: get_hotkey_wallets_for_wallet(w) + for w in wallets_with_ckp + } + all_hotkeys = get_all_wallets_for_path(wallet.path) else: print_verbose(f"Fetching data for wallet: {wallet.name}") - wallets = [wallet] - all_hotkeys = get_hotkey_wallets_for_wallet(wallet) + wallets_with_ckp = [wallet] if wallet.coldkeypub_file.exists_on_device() else [] + coldkey_addresses = [w.coldkeypub.ss58_address for w in wallets_with_ckp] + coldkey_names = {w.coldkeypub.ss58_address: w.name for w in wallets_with_ckp} + hotkeys_by_coldkey: dict[str, list[Optional[Wallet]]] = { + w.coldkeypub.ss58_address: get_hotkey_wallets_for_wallet(w) + for w in wallets_with_ckp + } + all_hotkeys = get_hotkey_wallets_for_wallet(wallet) if wallet else [] + + if not coldkey_addresses: + console.print("[yellow]No wallet data found.[/yellow]") + return + + # For ss58_address mode we skip the hotkey-based netuid filter + if ss58_address: + all_hotkeys = [] with console.status("Synchronising with chain...", spinner="aesthetic") as status: block_hash = await subtensor.substrate.get_chain_head() @@ -1633,7 +1738,7 @@ def neuron_row_maker( all_hotkeys, block_hash=block_hash, ) - # bittensor.logging.debug(f"Netuids to check: {all_netuids}") + with console.status("Pulling identity info...", spinner="aesthetic"): delegate_identity_map = await subtensor.fetch_coldkey_hotkey_identities( block_hash=block_hash @@ -1644,33 +1749,17 @@ def neuron_row_maker( ) delegate_identity_map = delegate_identity_map or {"hotkeys": {}, "coldkeys": {}} - table = Table( - Column("[bold white]Coldkey", style="dark_orange"), - Column("[bold white]Balance", style="dark_sea_green"), - Column("[bold white]Delegate", style="bright_cyan", overflow="fold"), - Column("[bold white]Stake", style="light_goldenrod2"), - Column("[bold white]Emission", style="rgb(42,161,152)"), - Column("[bold white]Netuid", style="dark_orange"), - Column("[bold white]Hotkey", style="bright_magenta", overflow="fold"), - Column("[bold white]Stake", style="light_goldenrod2"), - Column("[bold white]Emission", style="rgb(42,161,152)"), - title=f"[underline dark_orange]Wallets[/underline dark_orange]\n[dark_orange]Network: {subtensor.network}\n", - show_edge=False, - expand=True, - box=box.MINIMAL, - border_style="bright_black", - ) - rows = [] - wallets_with_ckp_file = [ - wallet for wallet in wallets if wallet.coldkeypub_file.exists_on_device() - ] + coldkey_table = _build_coldkey_table(subtensor.network) + hotkey_table = _build_hotkey_table(subtensor.network) + all_delegates: list[list[tuple[DelegateInfo, Balance]]] with console.status("Pulling balance data...", spinner="aesthetic"): + balances: dict[str, Balance] + all_neurons: list[NeuronInfoLite] + all_delegates: list[tuple[DelegateInfo, Balance]] + balances, all_neurons, all_delegates = await asyncio.gather( - subtensor.get_balances( - *[w.coldkeypub.ss58_address for w in wallets_with_ckp_file], - block_hash=block_hash, - ), + subtensor.get_balances(*coldkey_addresses, block_hash=block_hash), asyncio.gather( *[ subtensor.neurons_lite(netuid=netuid, block_hash=block_hash) @@ -1678,33 +1767,42 @@ def neuron_row_maker( ] ), asyncio.gather( - *[ - subtensor.get_delegated(w.coldkeypub.ss58_address) - for w in wallets_with_ckp_file - ] + *[subtensor.get_delegated(addr) for addr in coldkey_addresses] ), ) - neuron_state_dict = {} + neuron_state_dict: dict[int, list | NeuronInfoLite] = {} for netuid, neuron in zip(all_netuids, all_neurons): neuron_state_dict[netuid] = neuron if neuron else [] - for wall, d in zip(wallets_with_ckp_file, all_delegates): - rows.append([wall.name, str(balances[wall.coldkeypub.ss58_address])] + [""] * 7) - for row in itertools.chain( - delegate_row_maker(d), - neuron_row_maker(wall, all_netuids, neuron_state_dict), + coldkey_rows = [] + hotkey_rows = [] + for addr, delegates in zip(coldkey_addresses, all_delegates): + name = coldkey_names[addr] + coldkey_rows.append([name, str(balances.get(addr, Balance(0))), "", "", ""]) + for row in _make_delegate_rows(delegates, delegate_identity_map): + coldkey_rows.append(row) + + hotkeys = hotkeys_by_coldkey.get(addr, []) + for row in _make_neuron_rows( + coldkey_ss58=addr, + coldkey_name=name, + all_netuids=all_netuids, + neuron_state_dict=neuron_state_dict, + hotkeys=hotkeys, ): - rows.append(row) + hotkey_rows.append(row) - for i, row in enumerate(rows): - is_last_row = i + 1 == len(rows) - table.add_row(*row) + if coldkey_rows: + _populate_coldkey_table(coldkey_table, coldkey_rows) + console.print(coldkey_table) - # If last row or new coldkey starting next - if is_last_row or (rows[i + 1][0] != ""): - table.add_row(end_section=True) + if hotkey_rows: + console.print() + _populate_hotkey_table(hotkey_table, hotkey_rows) + console.print(hotkey_table) - return console.print(table) + if not coldkey_rows and not hotkey_rows: + console.print("[yellow]No wallet data found.[/yellow]") async def faucet( diff --git a/tests/unit_tests/test_inspect.py b/tests/unit_tests/test_inspect.py new file mode 100644 index 000000000..b9d414ca6 --- /dev/null +++ b/tests/unit_tests/test_inspect.py @@ -0,0 +1,181 @@ +from unittest.mock import MagicMock, patch + +from rich.table import Table + +from bittensor_cli.src.bittensor.balances import Balance +from bittensor_cli.src.bittensor.chain_data import DelegateInfo +from bittensor_cli.src.commands.wallets import ( + _build_coldkey_table, + _build_hotkey_table, + _calculate_daily_return, + _format_hotkey_label, + _make_delegate_rows, + _populate_coldkey_table, + _populate_hotkey_table, + _resolve_delegate_name, +) + + +def _make_mock_delegate( + hotkey_ss58: str, + total_stake_tao: float, + total_daily_return_tao: float, +) -> DelegateInfo: + """Create a mock DelegateInfo with the specified stake and return values.""" + delegate = MagicMock(spec=DelegateInfo) + delegate.hotkey_ss58 = hotkey_ss58 + delegate.total_stake = Balance.from_tao(total_stake_tao) + delegate.total_daily_return = Balance.from_tao(total_daily_return_tao) + return delegate + + +def _make_mock_neuron(coldkey: str, hotkey: str, stake_tao: float, emission: float): + """Create a mock NeuronInfoLite with the specified fields.""" + neuron = MagicMock() + neuron.coldkey = coldkey + neuron.hotkey = hotkey + neuron.stake = Balance.from_tao(stake_tao) + neuron.emission = emission + return neuron + + +class TestBuildColdkeyTable: + def test_returns_table_with_correct_columns(self): + table = _build_coldkey_table("finney") + assert isinstance(table, Table) + column_names = [col.header for col in table.columns] + assert "[bold white]Coldkey" in column_names + assert "[bold white]Balance" in column_names + assert "[bold white]Delegate" in column_names + assert "[bold white]Stake" in column_names + assert "[bold white]Emission" in column_names + assert len(table.columns) == 5 + + def test_title_contains_network(self): + table = _build_coldkey_table("test") + assert "test" in table.title + + +class TestBuildHotkeyTable: + def test_returns_table_with_correct_columns(self): + table = _build_hotkey_table("finney") + assert isinstance(table, Table) + column_names = [col.header for col in table.columns] + assert "[bold white]Coldkey" in column_names + assert "[bold white]Netuid" in column_names + assert "[bold white]Hotkey" in column_names + assert "[bold white]Stake" in column_names + assert "[bold white]Emission" in column_names + assert len(table.columns) == 5 + + def test_title_contains_network(self): + table = _build_hotkey_table("test") + assert "test" in table.title + + +def _identity_map_with_hotkey_name(hotkey_ss58: str, name: str) -> dict: + """Shape returned by fetch_coldkey_hotkey_identities (see get_hotkey_identity_name).""" + return { + "hotkeys": {hotkey_ss58: {"identity": {"name": name}}}, + "coldkeys": {}, + } + + +class TestResolveDelegateName: + def test_known_delegate_returns_display_name(self): + info = _identity_map_with_hotkey_name("5abc", "MyDelegate") + assert _resolve_delegate_name("5abc", info) == "MyDelegate" + + def test_unknown_delegate_returns_ss58(self): + assert _resolve_delegate_name("5xyz", {}) == "5xyz" + + +class TestCalculateDailyReturn: + def test_positive_stake_returns_proportional_return(self): + delegate = _make_mock_delegate("5abc", 100.0, 10.0) + staked = Balance.from_tao(50.0) + result = _calculate_daily_return(delegate, staked) + assert result == 5.0 + + def test_zero_total_stake_returns_zero(self): + delegate = _make_mock_delegate("5abc", 0.0, 10.0) + staked = Balance.from_tao(50.0) + result = _calculate_daily_return(delegate, staked) + assert result == 0 + + +class TestMakeDelegateRows: + def test_yields_rows_for_positive_stakes(self): + delegate = _make_mock_delegate("5abc", 100.0, 10.0) + delegates = [(delegate, Balance.from_tao(50.0))] + info = _identity_map_with_hotkey_name("5abc", "MyDelegate") + rows = list(_make_delegate_rows(delegates, info)) + assert len(rows) == 1 + assert rows[0][2] == "MyDelegate" + + def test_skips_zero_stake_delegates(self): + delegate = _make_mock_delegate("5abc", 100.0, 10.0) + delegates = [(delegate, Balance.from_tao(0.0))] + rows = list(_make_delegate_rows(delegates, {})) + assert len(rows) == 0 + + def test_multiple_delegates(self): + d1 = _make_mock_delegate("5aaa", 100.0, 10.0) + d2 = _make_mock_delegate("5bbb", 200.0, 20.0) + delegates = [ + (d1, Balance.from_tao(10.0)), + (d2, Balance.from_tao(20.0)), + ] + rows = list(_make_delegate_rows(delegates, {})) + assert len(rows) == 2 + + +class TestFormatHotkeyLabel: + def test_known_hotkey_includes_wallet_name(self): + mock_wallet = MagicMock() + mock_wallet.hotkey_str = "myhk" + with patch( + "bittensor_cli.src.commands.wallets.get_hotkey_pub_ss58", + return_value="5hotkey", + ): + result = _format_hotkey_label("5hotkey", [mock_wallet]) + assert result == "myhk-5hotkey" + + def test_unknown_hotkey_returns_ss58(self): + with patch( + "bittensor_cli.src.commands.wallets.get_hotkey_pub_ss58", + return_value="5other", + ): + result = _format_hotkey_label("5hotkey", [MagicMock()]) + assert result == "5hotkey" + + +class TestPopulateColdkeyTable: + def test_adds_rows_to_table(self): + table = _build_coldkey_table("finney") + rows = [ + ["alice", "100.0", "", "", ""], + ["", "", "Delegate1", "50.0", "1.0"], + ] + _populate_coldkey_table(table, rows) + assert table.row_count == 3 + + def test_empty_rows_noop(self): + table = _build_coldkey_table("finney") + _populate_coldkey_table(table, []) + assert table.row_count == 0 + + +class TestPopulateHotkeyTable: + def test_adds_rows_to_table(self): + table = _build_hotkey_table("finney") + rows = [ + ["alice", "1", "5hotkey", "10.0", "0.5"], + ] + _populate_hotkey_table(table, rows) + assert table.row_count == 2 + + def test_empty_rows_noop(self): + table = _build_hotkey_table("finney") + _populate_hotkey_table(table, []) + assert table.row_count == 0 From 22d7108474dc7251ff36910e6bcaf939aff8f1c2 Mon Sep 17 00:00:00 2001 From: BD Himes Date: Wed, 1 Apr 2026 21:11:19 +0200 Subject: [PATCH 34/49] Correctly uses the already-formatted error message in a failure for unstake --- bittensor_cli/src/commands/stake/remove.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/bittensor_cli/src/commands/stake/remove.py b/bittensor_cli/src/commands/stake/remove.py index 129bf7363..555974504 100644 --- a/bittensor_cli/src/commands/stake/remove.py +++ b/bittensor_cli/src/commands/stake/remove.py @@ -864,10 +864,7 @@ async def _unstake_extrinsic( ) return True, response else: - err_out( - f"{failure_prelude} with error: " - f"{format_error_message(await response.error_message)}" - ) + err_out(f"{failure_prelude} with error: {err_msg}") return False, None From 2272651ca9f4d04df355acd1299b217bc4d6c17e Mon Sep 17 00:00:00 2001 From: bitloi <89318445+bitloi@users.noreply.github.com> Date: Thu, 2 Apr 2026 07:35:59 -0300 Subject: [PATCH 35/49] Merge pull request #888 from bitloi/fix/wallet-create-safety-bugs fix: error handling in wallet_create() and new_coldkey() --- bittensor_cli/src/commands/wallets.py | 33 ++-- tests/unit_tests/test_wallet_create.py | 228 +++++++++++++++++++++++++ 2 files changed, 248 insertions(+), 13 deletions(-) create mode 100644 tests/unit_tests/test_wallet_create.py diff --git a/bittensor_cli/src/commands/wallets.py b/bittensor_cli/src/commands/wallets.py index 768d521f6..68da4a5fa 100644 --- a/bittensor_cli/src/commands/wallets.py +++ b/bittensor_cli/src/commands/wallets.py @@ -427,8 +427,19 @@ async def new_coldkey( keypair = Keypair.create_from_uri(uri) except TypeError as e: print_error(f"Failed to create keypair from URI {uri}: {str(e)}") - wallet.set_coldkey(keypair=keypair, encrypt=False, overwrite=False) - wallet.set_coldkeypub(keypair=keypair, encrypt=False, overwrite=False) + if json_output: + json_console.print( + json.dumps( + { + "success": False, + "error": f"Failed to create keypair from URI: {e}", + "data": None, + } + ) + ) + return + wallet.set_coldkey(keypair=keypair, encrypt=False, overwrite=overwrite) + wallet.set_coldkeypub(keypair=keypair, encrypt=False, overwrite=overwrite) console.print( f"[dark_sea_green]Coldkey created from URI: {uri}[/dark_sea_green]" ) @@ -489,7 +500,6 @@ async def wallet_create( wallet.set_coldkeypub(keypair=keypair, encrypt=False, overwrite=overwrite) wallet.set_hotkey(keypair=keypair, encrypt=False, overwrite=overwrite) wallet.set_hotkeypub(keypair=keypair, encrypt=False, overwrite=overwrite) - wallet.set_coldkeypub(keypair=keypair, encrypt=False, overwrite=overwrite) output_dict["success"] = True output_dict["data"] = { "name": wallet.name, @@ -498,13 +508,13 @@ async def wallet_create( "hotkey_ss58": wallet.hotkeypub.ss58_address, "coldkey_ss58": wallet.coldkeypub.ss58_address, } + console.print( + f"[dark_sea_green]Wallet created from URI: {uri}[/dark_sea_green]" + ) except (ValueError, TypeError, KeyFileError) as e: err = f"Failed to create keypair from URI: {str(e)}" print_error(err) output_dict["error"] = err - console.print( - f"[dark_sea_green]Wallet created from URI: {uri}[/dark_sea_green]" - ) else: try: wallet.create_new_coldkey( @@ -513,17 +523,13 @@ async def wallet_create( overwrite=overwrite, ) console.print("[dark_sea_green]Coldkey created[/dark_sea_green]") - output_dict["success"] = True - output_dict["data"] = { - "name": wallet.name, - "path": wallet.path, - "hotkey": wallet.hotkey_str, - "coldkey_ss58": wallet.coldkeypub.ss58_address, - } except KeyFileError as error: err = str(error) print_error(err) output_dict["error"] = err + if json_output: + json_console.print(json.dumps(output_dict)) + return try: wallet.create_new_hotkey( n_words=n_words, @@ -537,6 +543,7 @@ async def wallet_create( "path": wallet.path, "hotkey": wallet.hotkey_str, "hotkey_ss58": wallet.hotkeypub.ss58_address, + "coldkey_ss58": wallet.coldkeypub.ss58_address, } except KeyFileError as error: err = str(error) diff --git a/tests/unit_tests/test_wallet_create.py b/tests/unit_tests/test_wallet_create.py new file mode 100644 index 000000000..1ee0ed5ea --- /dev/null +++ b/tests/unit_tests/test_wallet_create.py @@ -0,0 +1,228 @@ +"""Tests for wallet creation safety in new_coldkey() and wallet_create().""" + +import json + +import pytest +from unittest.mock import MagicMock, patch +from bittensor_wallet import Keypair +from bittensor_wallet.errors import KeyFileError + + +MODULE = "bittensor_cli.src.commands.wallets" + + +def _mock_wallet(): + wallet = MagicMock() + wallet.name = "test_wallet" + wallet.path = "/tmp/wallets" + wallet.hotkey_str = "default" + wallet.coldkeypub.ss58_address = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + wallet.hotkeypub.ss58_address = "5HGjWAeFDfFCWPsjFQdVV2Msvz2XtMktvgocEZcCj68kUMaw" + return wallet + + +# --------------------------------------------------------------------------- +# new_coldkey — URI path: missing return after TypeError +# --------------------------------------------------------------------------- + + +class TestNewColdkeyUriFailure: + """Regression tests for new_coldkey() when create_from_uri raises TypeError.""" + + @pytest.mark.asyncio + async def test_returns_early_on_invalid_uri(self): + """new_coldkey must return without calling set_coldkey when URI is invalid.""" + from bittensor_cli.src.commands.wallets import new_coldkey + + wallet = _mock_wallet() + with patch.object(Keypair, "create_from_uri", side_effect=TypeError("bad uri")): + await new_coldkey( + wallet=wallet, + n_words=12, + use_password=False, + uri="//bad", + json_output=False, + ) + + wallet.set_coldkey.assert_not_called() + wallet.set_coldkeypub.assert_not_called() + + @pytest.mark.asyncio + async def test_emits_json_error_on_invalid_uri(self): + """new_coldkey must emit JSON error output when URI fails and json_output is on.""" + from bittensor_cli.src.commands.wallets import new_coldkey + + wallet = _mock_wallet() + with ( + patch.object(Keypair, "create_from_uri", side_effect=TypeError("bad uri")), + patch(f"{MODULE}.json_console") as mock_json, + ): + await new_coldkey( + wallet=wallet, + n_words=12, + use_password=False, + uri="//bad", + json_output=True, + ) + + mock_json.print.assert_called_once() + output = json.loads(mock_json.print.call_args[0][0]) + assert output["success"] is False + assert "bad uri" in output["error"] + assert output["data"] is None + + @pytest.mark.asyncio + async def test_uses_overwrite_parameter_on_valid_uri(self): + """new_coldkey must pass the overwrite parameter to set_coldkey, not hardcode False.""" + from bittensor_cli.src.commands.wallets import new_coldkey + + wallet = _mock_wallet() + fake_keypair = MagicMock(spec=Keypair) + with patch.object(Keypair, "create_from_uri", return_value=fake_keypair): + await new_coldkey( + wallet=wallet, + n_words=12, + use_password=False, + uri="//Alice", + overwrite=True, + json_output=False, + ) + + wallet.set_coldkey.assert_called_once_with( + keypair=fake_keypair, encrypt=False, overwrite=True + ) + wallet.set_coldkeypub.assert_called_once_with( + keypair=fake_keypair, encrypt=False, overwrite=True + ) + + +# --------------------------------------------------------------------------- +# wallet_create — URI path: duplicate set_coldkeypub + success msg after failure +# --------------------------------------------------------------------------- + + +class TestWalletCreateUri: + """Tests for wallet_create() URI code path.""" + + @pytest.mark.asyncio + async def test_set_coldkeypub_called_once_on_success(self): + """wallet_create must call set_coldkeypub exactly once, not twice.""" + from bittensor_cli.src.commands.wallets import wallet_create + + wallet = _mock_wallet() + fake_keypair = MagicMock(spec=Keypair) + with patch.object(Keypair, "create_from_uri", return_value=fake_keypair): + await wallet_create( + wallet=wallet, + uri="//Alice", + json_output=False, + ) + + assert wallet.set_coldkeypub.call_count == 1 + + @pytest.mark.asyncio + async def test_no_success_message_on_uri_failure(self): + """wallet_create must not print success message when URI creation fails.""" + from bittensor_cli.src.commands.wallets import wallet_create + + wallet = _mock_wallet() + with ( + patch.object(Keypair, "create_from_uri", side_effect=TypeError("bad")), + patch(f"{MODULE}.console") as mock_console, + ): + await wallet_create(wallet=wallet, uri="//bad", json_output=False) + + for c in mock_console.print.call_args_list: + assert "Wallet created" not in str(c) + + @pytest.mark.asyncio + async def test_json_reports_failure_on_uri_error(self): + """wallet_create JSON output must report failure when URI is invalid.""" + from bittensor_cli.src.commands.wallets import wallet_create + + wallet = _mock_wallet() + with ( + patch.object(Keypair, "create_from_uri", side_effect=ValueError("invalid")), + patch(f"{MODULE}.json_console") as mock_json, + ): + await wallet_create(wallet=wallet, uri="//bad", json_output=True) + + mock_json.print.assert_called_once() + output = json.loads(mock_json.print.call_args[0][0]) + assert output["success"] is False + assert output["error"] != "" + + +# --------------------------------------------------------------------------- +# wallet_create — mnemonic path: orphan hotkey on coldkey failure +# --------------------------------------------------------------------------- + + +class TestWalletCreateMnemonicColdkeyFailure: + """Tests for wallet_create() when coldkey creation fails in the mnemonic path.""" + + @pytest.mark.asyncio + async def test_no_hotkey_created_when_coldkey_fails(self): + """wallet_create must not attempt hotkey creation if coldkey creation fails.""" + from bittensor_cli.src.commands.wallets import wallet_create + + wallet = _mock_wallet() + wallet.create_new_coldkey = MagicMock(side_effect=KeyFileError("not writable")) + wallet.create_new_hotkey = MagicMock() + + await wallet_create(wallet=wallet, json_output=False) + + wallet.create_new_coldkey.assert_called_once() + wallet.create_new_hotkey.assert_not_called() + + @pytest.mark.asyncio + async def test_json_reports_failure_when_coldkey_fails(self): + """wallet_create JSON must report failure, not success, when coldkey fails.""" + from bittensor_cli.src.commands.wallets import wallet_create + + wallet = _mock_wallet() + wallet.create_new_coldkey = MagicMock(side_effect=KeyFileError("not writable")) + + with patch(f"{MODULE}.json_console") as mock_json: + await wallet_create(wallet=wallet, json_output=True) + + mock_json.print.assert_called_once() + output = json.loads(mock_json.print.call_args[0][0]) + assert output["success"] is False + assert "not writable" in output["error"] + + @pytest.mark.asyncio + async def test_json_reports_success_when_both_keys_created(self): + """wallet_create JSON must report success only when both keys succeed.""" + from bittensor_cli.src.commands.wallets import wallet_create + + wallet = _mock_wallet() + wallet.create_new_coldkey = MagicMock() + wallet.create_new_hotkey = MagicMock() + + with patch(f"{MODULE}.json_console") as mock_json: + await wallet_create(wallet=wallet, json_output=True) + + mock_json.print.assert_called_once() + output = json.loads(mock_json.print.call_args[0][0]) + assert output["success"] is True + assert output["data"]["coldkey_ss58"] == wallet.coldkeypub.ss58_address + assert output["data"]["hotkey_ss58"] == wallet.hotkeypub.ss58_address + + @pytest.mark.asyncio + async def test_hotkey_failure_reports_error(self): + """wallet_create must report error when hotkey creation fails after coldkey succeeds.""" + from bittensor_cli.src.commands.wallets import wallet_create + + wallet = _mock_wallet() + wallet.create_new_coldkey = MagicMock() + wallet.create_new_hotkey = MagicMock( + side_effect=KeyFileError("hotkey not writable") + ) + + with patch(f"{MODULE}.json_console") as mock_json: + await wallet_create(wallet=wallet, json_output=True) + + output = json.loads(mock_json.print.call_args[0][0]) + assert output["success"] is False + assert "hotkey not writable" in output["error"] From d4b79cea933880a2a6fff6f1e5a022a5f78d8226 Mon Sep 17 00:00:00 2001 From: bitloi <89318445+bitloi@users.noreply.github.com> Date: Thu, 2 Apr 2026 08:03:46 -0300 Subject: [PATCH 36/49] fix: guard stake add rate inversions on zero prices (#886) --- bittensor_cli/src/commands/stake/add.py | 13 ++- tests/unit_tests/test_stake_add.py | 127 ++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 tests/unit_tests/test_stake_add.py diff --git a/bittensor_cli/src/commands/stake/add.py b/bittensor_cli/src/commands/stake/add.py index 01206492c..d2bd7407d 100644 --- a/bittensor_cli/src/commands/stake/add.py +++ b/bittensor_cli/src/commands/stake/add.py @@ -391,14 +391,14 @@ async def stake_extrinsic( # Temporary workaround - calculations without slippage current_price_float = float(subnet_info.price.tao) - rate = 1.0 / current_price_float + rate = _safe_inverse_rate(current_price_float) # If we are staking safe, add price tolerance if safe_staking: if subnet_info.is_dynamic: price_with_tolerance = current_price_float * (1 + rate_tolerance) - _rate_with_tolerance = ( - 1.0 / price_with_tolerance + _rate_with_tolerance = _safe_inverse_rate( + price_with_tolerance ) # Rate only for display rate_with_tolerance = f"{_rate_with_tolerance:.4f}" price_with_tolerance = Balance.from_tao( @@ -849,6 +849,11 @@ def _print_table_and_slippage(table: Table, max_slippage: float, safe_staking: b console.print(base_description + (safe_staking_description if safe_staking else "")) +def _safe_inverse_rate(value: float) -> float: + """Returns the inverse of a positive rate, otherwise zero.""" + return 1.0 / value if value > 0 else 0.0 + + def _calculate_slippage( subnet_info, amount: Balance, stake_fee: Balance ) -> tuple[Balance, str, float, str]: @@ -882,7 +887,7 @@ def _calculate_slippage( total_slippage = ideal_amount - received_amount slippage_pct_float = 100 * (total_slippage.tao / ideal_amount.tao) slippage_str = f"{slippage_pct_float:.4f} %" - rate = f"{(1 / subnet_info.price.tao or 1):.4f}" + rate = f"{_safe_inverse_rate(float(subnet_info.price.tao)):.4f}" else: # TODO: Fix this. Slippage is always zero for static networks. slippage_pct_float = ( diff --git a/tests/unit_tests/test_stake_add.py b/tests/unit_tests/test_stake_add.py new file mode 100644 index 000000000..ce59ba552 --- /dev/null +++ b/tests/unit_tests/test_stake_add.py @@ -0,0 +1,127 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from bittensor_cli.src.bittensor.balances import Balance +from bittensor_cli.src.commands.stake.add import stake_add + + +TEST_SS58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + + +class MockSubnetInfo: + def __init__(self, netuid: int, price_tao: float): + self.netuid = netuid + self.price = Balance.from_tao(price_tao) + self.is_dynamic = netuid != 0 + + +@pytest.fixture +def mock_wallet(): + return SimpleNamespace( + coldkeypub=SimpleNamespace(ss58_address=TEST_SS58), + path="/tmp", + name="test_wallet", + ) + + +@pytest.fixture +def mock_subtensor(): + async def sim_swap_mock(origin_netuid, destination_netuid, amount): + del origin_netuid, amount + return SimpleNamespace( + alpha_amount=Balance.from_tao(1).set_unit(destination_netuid), + tao_fee=Balance.from_tao(0.1), + ) + + return SimpleNamespace( + substrate=SimpleNamespace( + get_chain_head=AsyncMock(return_value="0xabc"), + compose_call=AsyncMock(return_value=SimpleNamespace()), + ), + network="test", + all_subnets=AsyncMock(return_value=[]), + get_stake_for_coldkey=AsyncMock(return_value=[]), + get_balance=AsyncMock(return_value=Balance.from_tao(100)), + get_extrinsic_fee=AsyncMock(return_value=Balance.from_tao(0.01)), + sim_swap=AsyncMock(side_effect=sim_swap_mock), + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("safe_staking", [False, True]) +async def test_stake_add_zero_price_does_not_raise( + mock_wallet, + mock_subtensor, + safe_staking, +): + mock_subtensor.all_subnets.return_value = [MockSubnetInfo(netuid=427, price_tao=0)] + + with patch( + "bittensor_cli.src.commands.stake.add.confirm_action", return_value=False + ): + await stake_add( + wallet=mock_wallet, + subtensor=mock_subtensor, + netuids=[427], + stake_all=False, + amount=10.0, + prompt=True, + decline=False, + quiet=True, + all_hotkeys=False, + include_hotkeys=[TEST_SS58], + exclude_hotkeys=[], + safe_staking=safe_staking, + rate_tolerance=0.05, + allow_partial_stake=True, + json_output=False, + era=16, + mev_protection=False, + proxy=None, + ) + + assert mock_subtensor.substrate.compose_call.await_count == 1 + composed_call = mock_subtensor.substrate.compose_call.await_args.kwargs + expected_fn = "add_stake_limit" if safe_staking else "add_stake" + assert composed_call["call_function"] == expected_fn + assert mock_subtensor.sim_swap.await_count == 1 + + +@pytest.mark.asyncio +async def test_stake_add_mixed_prices_including_zero_does_not_raise( + mock_wallet, + mock_subtensor, +): + mock_subtensor.all_subnets.return_value = [ + MockSubnetInfo(netuid=427, price_tao=0), + MockSubnetInfo(netuid=1, price_tao=2.0), + ] + + with patch( + "bittensor_cli.src.commands.stake.add.confirm_action", return_value=False + ): + await stake_add( + wallet=mock_wallet, + subtensor=mock_subtensor, + netuids=[427, 1], + stake_all=False, + amount=10.0, + prompt=True, + decline=False, + quiet=True, + all_hotkeys=False, + include_hotkeys=[TEST_SS58], + exclude_hotkeys=[], + safe_staking=True, + rate_tolerance=0.05, + allow_partial_stake=True, + json_output=False, + era=16, + mev_protection=False, + proxy=None, + ) + + assert mock_subtensor.substrate.compose_call.await_count == 2 + assert mock_subtensor.sim_swap.await_count == 2 From 8dc24f6d38ec1d1f9e442bd5e0f7562c701bd2db Mon Sep 17 00:00:00 2001 From: BD Himes Date: Thu, 2 Apr 2026 13:41:13 +0200 Subject: [PATCH 37/49] Consolidate text fixtures in one place --- tests/unit_tests/conftest.py | 188 ++++++++++++++++++ tests/unit_tests/test_axon_commands.py | 183 +++-------------- tests/unit_tests/test_batching.py | 10 - .../test_proxy_address_resolution.py | 58 ++---- tests/unit_tests/test_subnets_register.py | 49 +---- tests/unit_tests/test_wallet_create.py | 92 ++++----- 6 files changed, 276 insertions(+), 304 deletions(-) create mode 100644 tests/unit_tests/conftest.py diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py new file mode 100644 index 000000000..f886654e4 --- /dev/null +++ b/tests/unit_tests/conftest.py @@ -0,0 +1,188 @@ +""" +Shared fixtures for btcli unit tests. + +Provides common mock objects, SS58 address constants, and receipt helpers that +are duplicated across multiple test files. Import constants directly: + + from conftest import COLDKEY_SS58, HOTKEY_SS58, ... + +Fixtures (mock_wallet, mock_wallet_spec, mock_subtensor, successful_receipt, +failed_receipt) are discovered automatically by pytest. +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock +from bittensor_wallet import Wallet + +from bittensor_cli.src.bittensor.balances import Balance + +# --------------------------------------------------------------------------- +# Common SS58 addresses (valid Substrate SS58, format 42) +# These replace per-file inline literals and per-file constant blocks. +# --------------------------------------------------------------------------- +COLDKEY_SS58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" # signer / default coldkey +HOTKEY_SS58 = "5CiQ1cV1MmMwsep7YP37QZKEgBgaVXeSPnETB5JBgwYRoXbP" # default hotkey +PROXY_SS58 = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" # proxy account +DEST_SS58 = "5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy" # transfer destination +ALT_HOTKEY_SS58 = "5HGjWAeFDfFCWPsjFQdVV2Msvz2XtMktvgocEZcCj68kUMaw" # secondary hotkey + + +# --------------------------------------------------------------------------- +# Receipt helpers +# --------------------------------------------------------------------------- + +def _make_successful_receipt(identifier: str = "0x123-1") -> MagicMock: + """ + Build a mock substrate extrinsic receipt where ``await receipt.is_success`` + returns True. Used as the default return value of mock_subtensor's + ``substrate.submit_extrinsic`` and as the basis of the ``successful_receipt`` + fixture. + + Note: ``is_success`` is a coroutine (single-use awaitable), matching the + real ``AsyncExtrinsicReceipt.is_success`` behaviour. + """ + + async def _is_success() -> bool: + return True + + receipt = MagicMock() + receipt.is_success = _is_success() + receipt.get_extrinsic_identifier = AsyncMock(return_value=identifier) + receipt.error_message = AsyncMock(return_value=None) + receipt.block_hash = "0xblock" + return receipt + + +def _make_failed_receipt(error: str = "Network error") -> MagicMock: + """ + Build a mock substrate extrinsic receipt where ``await receipt.is_success`` + returns False. + """ + + async def _is_success() -> bool: + return False + + receipt = MagicMock() + receipt.is_success = _is_success() + receipt.error_message = AsyncMock(return_value=error) + receipt.get_extrinsic_identifier = AsyncMock(return_value=None) + return receipt + + +@pytest.fixture +def successful_receipt() -> MagicMock: + """ + Substrate extrinsic receipt where ``await receipt.is_success`` returns True. + + Use this when a test needs to assert on the receipt object returned by + ``substrate.submit_extrinsic``, e.g.:: + + mock_subtensor.substrate.submit_extrinsic.return_value = successful_receipt + """ + return _make_successful_receipt() + + +@pytest.fixture +def failed_receipt() -> MagicMock: + """ + Substrate extrinsic receipt where ``await receipt.is_success`` returns False. + ``await receipt.error_message`` returns ``"Network error"``. + """ + return _make_failed_receipt() + + +# --------------------------------------------------------------------------- +# Wallet fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def mock_wallet() -> MagicMock: + """ + Plain MagicMock wallet with standard attributes. + + Use when the code under test does NOT perform ``isinstance(wallet, Wallet)`` + checks. Replaces the ``_mock_wallet()`` helper functions scattered across + test_wallet_create.py, test_proxy_address_resolution.py, and test_batching.py. + """ + wallet = MagicMock() + wallet.name = "test_wallet" + wallet.path = "/tmp/wallets" + wallet.hotkey_str = "default" + wallet.coldkeypub.ss58_address = COLDKEY_SS58 + wallet.coldkey.ss58_address = COLDKEY_SS58 + wallet.hotkey.ss58_address = HOTKEY_SS58 + wallet.hotkeypub.ss58_address = ALT_HOTKEY_SS58 + return wallet + + +@pytest.fixture +def mock_wallet_spec() -> MagicMock: + """ + ``MagicMock(spec=Wallet)`` wallet. + + Use when the code under test performs ``isinstance(wallet, Wallet)`` checks + or accesses spec-enforced attributes. Replaces the local ``mock_wallet`` + fixture in test_subnets_register.py and inline patterns in + test_axon_commands.py. + """ + wallet = MagicMock(spec=Wallet) + wallet.name = "test_wallet" + wallet.path = "/tmp/wallets" + wallet.hotkey_str = "default" + wallet.coldkeypub.ss58_address = COLDKEY_SS58 + wallet.hotkey.ss58_address = HOTKEY_SS58 + return wallet + + +# --------------------------------------------------------------------------- +# Subtensor fixture +# --------------------------------------------------------------------------- + +@pytest.fixture +def mock_subtensor() -> MagicMock: + """ + General-purpose async subtensor mock with commonly-used async methods preset. + + All async methods return sensible defaults. Override specific methods + per-test as needed, e.g.:: + + mock_subtensor.subnet_exists = AsyncMock(return_value=False) + mock_subtensor.substrate.submit_extrinsic.return_value = failed_receipt + + Replaces: + - ``mock_subtensor_base()`` fixture in test_subnets_register.py + - ``_mock_subtensor()`` helper in test_proxy_address_resolution.py + - Repeated inline ``MagicMock()`` setup blocks in test_axon_commands.py + """ + st = MagicMock() + st.network = "finney" + + # substrate layer (low-level blockchain interface) + st.substrate = MagicMock() + st.substrate.get_chain_head = AsyncMock(return_value="0xabc123") + st.substrate.compose_call = AsyncMock(return_value=MagicMock()) + st.substrate.create_signed_extrinsic = AsyncMock(return_value="mock_extrinsic") + st.substrate.submit_extrinsic = AsyncMock(return_value=_make_successful_receipt()) + st.substrate.get_account_next_index = AsyncMock(return_value=0) + st.substrate.get_block_number = AsyncMock(return_value=1000) + + # subtensor-level queries + st.subnet_exists = AsyncMock(return_value=True) + st.get_balance = AsyncMock(return_value=Balance.from_tao(100)) + st.get_existential_deposit = AsyncMock(return_value=Balance.from_tao(0.001)) + st.get_extrinsic_fee = AsyncMock(return_value=Balance.from_tao(0.01)) + st.get_stake = AsyncMock(return_value=Balance.from_tao(50)) + st.get_stake_for_coldkey = AsyncMock(return_value=[]) + st.all_subnets = AsyncMock(return_value=[]) + st.get_hyperparameter = AsyncMock(return_value=1) + st.query = AsyncMock(return_value=None) + st.neuron_for_uid = AsyncMock(return_value=None) + st.sign_and_send_extrinsic = AsyncMock(return_value=(True, "", AsyncMock())) + st.sim_swap = AsyncMock( + return_value=MagicMock(alpha_amount=100, tao_fee=1, alpha_fee=1) + ) + st.fetch_coldkey_hotkey_identities = AsyncMock( + return_value={"hotkeys": {}, "coldkeys": {}} + ) + st.get_all_subnet_netuids = AsyncMock(return_value=[0, 1]) + return st diff --git a/tests/unit_tests/test_axon_commands.py b/tests/unit_tests/test_axon_commands.py index 88803d937..447789029 100644 --- a/tests/unit_tests/test_axon_commands.py +++ b/tests/unit_tests/test_axon_commands.py @@ -4,15 +4,12 @@ import pytest from unittest.mock import AsyncMock, MagicMock, Mock, patch -from async_substrate_interface import AsyncSubstrateInterface -from bittensor_wallet import Wallet from bittensor_cli.src.bittensor.extrinsics.serving import ( reset_axon_extrinsic, set_axon_extrinsic, ip_to_int, ) -from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface class TestIpToInt: @@ -48,30 +45,9 @@ class TestResetAxonExtrinsic: """Tests for reset_axon_extrinsic function.""" @pytest.mark.asyncio - async def test_reset_axon_success(self): + async def test_reset_axon_success(self, mock_subtensor, mock_wallet_spec, successful_receipt): """Test successful axon reset.""" - # Setup mocks - mock_subtensor = MagicMock() - mock_subtensor.substrate.compose_call = AsyncMock(return_value="mock_call") - mock_subtensor.substrate.create_signed_extrinsic = AsyncMock( - return_value="mock_extrinsic" - ) - mock_response = MagicMock() - - # is_success is a property that returns a coroutine - async def mock_is_success(): - return True - - mock_response.is_success = mock_is_success() - mock_response.get_extrinsic_identifier = AsyncMock(return_value="0x123") - mock_subtensor.substrate.submit_extrinsic = AsyncMock( - return_value=mock_response - ) - - mock_wallet = MagicMock(spec=Wallet) - mock_wallet.hotkey.ss58_address = ( - "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" - ) + mock_subtensor.substrate.submit_extrinsic.return_value = successful_receipt with ( patch( @@ -84,22 +60,19 @@ async def mock_is_success(): ): mock_unlock.return_value = MagicMock(success=True) - # Execute success, message, ext_id = await reset_axon_extrinsic( subtensor=mock_subtensor, - wallet=mock_wallet, + wallet=mock_wallet_spec, netuid=1, prompt=False, wait_for_inclusion=True, wait_for_finalization=False, ) - # Verify assert success is True assert "successfully" in message.lower() - assert ext_id == "0x123" + assert ext_id == "0x123-1" - # Verify compose_call was called with correct parameters mock_subtensor.substrate.compose_call.assert_called_once() call_args = mock_subtensor.substrate.compose_call.call_args assert call_args[1]["call_module"] == "SubtensorModule" @@ -110,11 +83,8 @@ async def mock_is_success(): assert call_args[1]["call_params"]["ip_type"] == 4 @pytest.mark.asyncio - async def test_reset_axon_unlock_failure(self): + async def test_reset_axon_unlock_failure(self, mock_subtensor, mock_wallet_spec): """Test axon reset when hotkey unlock fails.""" - mock_subtensor = MagicMock() - mock_wallet = MagicMock(spec=Wallet) - with patch( "bittensor_cli.src.bittensor.extrinsics.serving.unlock_key" ) as mock_unlock: @@ -124,7 +94,7 @@ async def test_reset_axon_unlock_failure(self): success, message, ext_id = await reset_axon_extrinsic( subtensor=mock_subtensor, - wallet=mock_wallet, + wallet=mock_wallet_spec, netuid=1, prompt=False, ) @@ -134,15 +104,8 @@ async def test_reset_axon_unlock_failure(self): assert ext_id is None @pytest.mark.asyncio - async def test_reset_axon_user_cancellation(self): + async def test_reset_axon_user_cancellation(self, mock_subtensor, mock_wallet_spec): """Test axon reset when user cancels prompt.""" - mock_subtensor = MagicMock(spec=SubtensorInterface) - mock_subtensor.substrate = MagicMock(spec=AsyncSubstrateInterface) - mock_wallet = MagicMock(spec=Wallet) - mock_wallet.hotkey.ss58_address = ( - "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" - ) - with ( patch( "bittensor_cli.src.bittensor.extrinsics.serving.unlock_key" @@ -156,7 +119,7 @@ async def test_reset_axon_user_cancellation(self): success, message, ext_id = await reset_axon_extrinsic( subtensor=mock_subtensor, - wallet=mock_wallet, + wallet=mock_wallet_spec, netuid=1, prompt=True, ) @@ -166,28 +129,9 @@ async def test_reset_axon_user_cancellation(self): assert ext_id is None @pytest.mark.asyncio - async def test_reset_axon_extrinsic_failure(self): + async def test_reset_axon_extrinsic_failure(self, mock_subtensor, mock_wallet_spec, failed_receipt): """Test axon reset when extrinsic submission fails.""" - mock_subtensor = MagicMock() - mock_subtensor.substrate.compose_call = AsyncMock(return_value="mock_call") - mock_subtensor.substrate.create_signed_extrinsic = AsyncMock( - return_value="mock_extrinsic" - ) - mock_response = MagicMock() - - async def mock_is_success(): - return False - - mock_response.is_success = mock_is_success() - mock_response.error_message = AsyncMock(return_value="Network error") - mock_subtensor.substrate.submit_extrinsic = AsyncMock( - return_value=mock_response - ) - - mock_wallet = MagicMock(spec=Wallet) - mock_wallet.hotkey.ss58_address = ( - "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" - ) + mock_subtensor.substrate.submit_extrinsic.return_value = failed_receipt with patch( "bittensor_cli.src.bittensor.extrinsics.serving.unlock_key" @@ -196,7 +140,7 @@ async def mock_is_success(): success, message, ext_id = await reset_axon_extrinsic( subtensor=mock_subtensor, - wallet=mock_wallet, + wallet=mock_wallet_spec, netuid=1, prompt=False, ) @@ -210,28 +154,9 @@ class TestSetAxonExtrinsic: """Tests for set_axon_extrinsic function.""" @pytest.mark.asyncio - async def test_set_axon_success(self): + async def test_set_axon_success(self, mock_subtensor, mock_wallet_spec, successful_receipt): """Test successful axon set.""" - mock_subtensor = MagicMock() - mock_subtensor.substrate.compose_call = AsyncMock(return_value="mock_call") - mock_subtensor.substrate.create_signed_extrinsic = AsyncMock( - return_value="mock_extrinsic" - ) - mock_response = MagicMock() - - async def mock_is_success(): - return True - - mock_response.is_success = mock_is_success() - mock_response.get_extrinsic_identifier = AsyncMock(return_value="0x123") - mock_subtensor.substrate.submit_extrinsic = AsyncMock( - return_value=mock_response - ) - - mock_wallet = MagicMock(spec=Wallet) - mock_wallet.hotkey.ss58_address = ( - "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" - ) + mock_subtensor.substrate.submit_extrinsic.return_value = successful_receipt with ( patch( @@ -246,7 +171,7 @@ async def mock_is_success(): success, message, ext_id = await set_axon_extrinsic( subtensor=mock_subtensor, - wallet=mock_wallet, + wallet=mock_wallet_spec, netuid=1, ip="192.168.1.100", port=8091, @@ -259,10 +184,9 @@ async def mock_is_success(): assert success is True assert "successfully" in message.lower() - assert ext_id == "0x123" + assert ext_id == "0x123-1" assert "192.168.1.100:8091" in message - # Verify compose_call was called with correct parameters mock_subtensor.substrate.compose_call.assert_called_once() call_args = mock_subtensor.substrate.compose_call.call_args assert call_args[1]["call_module"] == "SubtensorModule" @@ -273,15 +197,12 @@ async def mock_is_success(): assert call_args[1]["call_params"]["protocol"] == 4 @pytest.mark.asyncio - async def test_set_axon_invalid_port(self): + async def test_set_axon_invalid_port(self, mock_subtensor, mock_wallet_spec): """Test axon set with invalid port number.""" - mock_subtensor = MagicMock() - mock_wallet = MagicMock(spec=Wallet) - # Test port too high success, message, ext_id = await set_axon_extrinsic( subtensor=mock_subtensor, - wallet=mock_wallet, + wallet=mock_wallet_spec, netuid=1, ip="192.168.1.100", port=70000, @@ -295,7 +216,7 @@ async def test_set_axon_invalid_port(self): # Test negative port success, message, ext_id = await set_axon_extrinsic( subtensor=mock_subtensor, - wallet=mock_wallet, + wallet=mock_wallet_spec, netuid=1, ip="192.168.1.100", port=-1, @@ -307,14 +228,11 @@ async def test_set_axon_invalid_port(self): assert ext_id is None @pytest.mark.asyncio - async def test_set_axon_invalid_ip(self): + async def test_set_axon_invalid_ip(self, mock_subtensor, mock_wallet_spec): """Test axon set with invalid IP address.""" - mock_subtensor = MagicMock() - mock_wallet = MagicMock(spec=Wallet) - success, message, ext_id = await set_axon_extrinsic( subtensor=mock_subtensor, - wallet=mock_wallet, + wallet=mock_wallet_spec, netuid=1, ip="invalid.ip.address", port=8091, @@ -326,11 +244,8 @@ async def test_set_axon_invalid_ip(self): assert ext_id is None @pytest.mark.asyncio - async def test_set_axon_unlock_failure(self): + async def test_set_axon_unlock_failure(self, mock_subtensor, mock_wallet_spec): """Test axon set when hotkey unlock fails.""" - mock_subtensor = MagicMock() - mock_wallet = MagicMock(spec=Wallet) - with patch( "bittensor_cli.src.bittensor.extrinsics.serving.unlock_key" ) as mock_unlock: @@ -340,7 +255,7 @@ async def test_set_axon_unlock_failure(self): success, message, ext_id = await set_axon_extrinsic( subtensor=mock_subtensor, - wallet=mock_wallet, + wallet=mock_wallet_spec, netuid=1, ip="192.168.1.100", port=8091, @@ -352,14 +267,8 @@ async def test_set_axon_unlock_failure(self): assert "Failed to unlock hotkey" in message @pytest.mark.asyncio - async def test_set_axon_user_cancellation(self): + async def test_set_axon_user_cancellation(self, mock_subtensor, mock_wallet_spec): """Test axon set when user cancels prompt.""" - mock_subtensor = MagicMock() - mock_wallet = MagicMock(spec=Wallet) - mock_wallet.hotkey.ss58_address = ( - "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" - ) - with ( patch( "bittensor_cli.src.bittensor.extrinsics.serving.unlock_key" @@ -373,7 +282,7 @@ async def test_set_axon_user_cancellation(self): success, message, ext_id = await set_axon_extrinsic( subtensor=mock_subtensor, - wallet=mock_wallet, + wallet=mock_wallet_spec, netuid=1, ip="192.168.1.100", port=8091, @@ -384,28 +293,9 @@ async def test_set_axon_user_cancellation(self): assert "cancelled" in message.lower() @pytest.mark.asyncio - async def test_set_axon_with_ipv6(self): + async def test_set_axon_with_ipv6(self, mock_subtensor, mock_wallet_spec, successful_receipt): """Test axon set with IPv6 address.""" - mock_subtensor = MagicMock() - mock_subtensor.substrate.compose_call = AsyncMock(return_value="mock_call") - mock_subtensor.substrate.create_signed_extrinsic = AsyncMock( - return_value="mock_extrinsic" - ) - mock_response = MagicMock() - - async def mock_is_success(): - return True - - mock_response.is_success = mock_is_success() - mock_response.get_extrinsic_identifier = AsyncMock(return_value="0x123") - mock_subtensor.substrate.submit_extrinsic = AsyncMock( - return_value=mock_response - ) - - mock_wallet = MagicMock(spec=Wallet) - mock_wallet.hotkey.ss58_address = ( - "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" - ) + mock_subtensor.substrate.submit_extrinsic.return_value = successful_receipt with ( patch( @@ -420,7 +310,7 @@ async def mock_is_success(): success, message, ext_id = await set_axon_extrinsic( subtensor=mock_subtensor, - wallet=mock_wallet, + wallet=mock_wallet_spec, netuid=1, ip="2001:db8::1", port=8091, @@ -431,24 +321,17 @@ async def mock_is_success(): assert success is True assert "successfully" in message.lower() - assert ext_id == "0x123" - # Verify ip_type was set to 6 + assert ext_id == "0x123-1" call_args = mock_subtensor.substrate.compose_call.call_args assert call_args[1]["call_params"]["ip_type"] == 6 @pytest.mark.asyncio - async def test_set_axon_exception_handling(self): + async def test_set_axon_exception_handling(self, mock_subtensor, mock_wallet_spec): """Test axon set handles exceptions gracefully.""" - mock_subtensor = MagicMock() mock_subtensor.substrate.compose_call = AsyncMock( side_effect=Exception("Unexpected error") ) - mock_wallet = MagicMock(spec=Wallet) - mock_wallet.hotkey.ss58_address = ( - "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" - ) - with patch( "bittensor_cli.src.bittensor.extrinsics.serving.unlock_key" ) as mock_unlock: @@ -456,7 +339,7 @@ async def test_set_axon_exception_handling(self): success, message, ext_id = await set_axon_extrinsic( subtensor=mock_subtensor, - wallet=mock_wallet, + wallet=mock_wallet_spec, netuid=1, ip="192.168.1.100", port=8091, @@ -504,10 +387,7 @@ def test_axon_reset_command_handler(self, mock_axon): json_output=False, ) - # Verify wallet_ask was called correctly mock_wallet_ask.assert_called_once() - - # Verify _run_command was called mock_run_command.assert_called_once() @patch("bittensor_cli.cli.axon") @@ -547,8 +427,5 @@ def test_axon_set_command_handler(self, mock_axon): json_output=False, ) - # Verify wallet_ask was called correctly mock_wallet_ask.assert_called_once() - - # Verify _run_command was called mock_run_command.assert_called_once() diff --git a/tests/unit_tests/test_batching.py b/tests/unit_tests/test_batching.py index 0f22cae4e..2d6c9517b 100644 --- a/tests/unit_tests/test_batching.py +++ b/tests/unit_tests/test_batching.py @@ -12,16 +12,6 @@ def subtensor(): 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): diff --git a/tests/unit_tests/test_proxy_address_resolution.py b/tests/unit_tests/test_proxy_address_resolution.py index 1068ee694..96143b6ae 100644 --- a/tests/unit_tests/test_proxy_address_resolution.py +++ b/tests/unit_tests/test_proxy_address_resolution.py @@ -10,42 +10,11 @@ from bittensor_cli.src.bittensor.balances import Balance -SIGNER_SS58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" PROXY_SS58 = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" HOTKEY_SS58 = "5CiQ1cV1MmMwsep7YP37QZKEgBgaVXeSPnETB5JBgwYRoXbP" DEST_HOTKEY_SS58 = "5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy" -def _mock_wallet(): - wallet = MagicMock() - wallet.coldkeypub.ss58_address = SIGNER_SS58 - wallet.hotkey.ss58_address = HOTKEY_SS58 - wallet.hotkey_str = "default" - return wallet - - -def _mock_subtensor(stake_balance=Balance.from_tao(100)): - receipt = AsyncMock() - receipt.get_extrinsic_identifier = AsyncMock(return_value="0x123-1") - receipt.is_success = True - subtensor = MagicMock() - subtensor.network = "finney" - subtensor.substrate = MagicMock() - subtensor.substrate.get_chain_head = AsyncMock(return_value="0xabc") - subtensor.substrate.compose_call = AsyncMock(return_value=MagicMock()) - subtensor.get_stake = AsyncMock(return_value=stake_balance) - subtensor.get_balance = AsyncMock(return_value=Balance.from_tao(500)) - subtensor.subnet_exists = AsyncMock(return_value=True) - subtensor.get_extrinsic_fee = AsyncMock(return_value=Balance.from_tao(0.001)) - subtensor.substrate.get_account_next_index = AsyncMock(return_value=0) - subtensor.sim_swap = AsyncMock( - return_value=MagicMock(alpha_amount=100, tao_fee=1, alpha_fee=1) - ) - subtensor.sign_and_send_extrinsic = AsyncMock(return_value=(True, "", receipt)) - subtensor.query = AsyncMock() - return subtensor - - @contextmanager def _move_patches(**extra): base = "bittensor_cli.src.commands.stake.move" @@ -66,15 +35,14 @@ def _move_patches(**extra): @pytest.mark.asyncio -async def test_move_stake_uses_proxy_for_stake_lookup(): +async def test_move_stake_uses_proxy_for_stake_lookup(mock_wallet, mock_subtensor): """move_stake must query stake using the proxied account address.""" from bittensor_cli.src.commands.stake.move import move_stake - subtensor = _mock_subtensor() with _move_patches(): await move_stake( - subtensor=subtensor, - wallet=_mock_wallet(), + subtensor=mock_subtensor, + wallet=mock_wallet, origin_netuid=1, origin_hotkey=HOTKEY_SS58, destination_netuid=2, @@ -86,25 +54,24 @@ async def test_move_stake_uses_proxy_for_stake_lookup(): proxy=PROXY_SS58, mev_protection=False, ) - for call in subtensor.get_stake.call_args_list: + for call in mock_subtensor.get_stake.call_args_list: assert call.kwargs["coldkey_ss58"] == PROXY_SS58 @pytest.mark.asyncio -async def test_trim_allows_proxy_owner(): +async def test_trim_allows_proxy_owner(mock_wallet, mock_subtensor): """trim must accept the proxied account as subnet owner.""" from bittensor_cli.src.commands.sudo import trim - subtensor = _mock_subtensor() - subtensor.query = AsyncMock(return_value=PROXY_SS58) + mock_subtensor.query = AsyncMock(return_value=PROXY_SS58) base = "bittensor_cli.src.commands.sudo" with ( patch(f"{base}.unlock_key", return_value=MagicMock(success=True)), patch(f"{base}.print_extrinsic_id", new_callable=AsyncMock), ): result = await trim( - wallet=_mock_wallet(), - subtensor=subtensor, + wallet=mock_wallet, + subtensor=mock_subtensor, netuid=1, proxy=PROXY_SS58, max_n=100, @@ -118,15 +85,14 @@ async def test_trim_allows_proxy_owner(): @pytest.mark.asyncio -async def test_trim_rejects_non_owner_with_proxy(): +async def test_trim_rejects_non_owner_with_proxy(mock_wallet, mock_subtensor): """trim must reject when the proxy doesn't own the subnet.""" from bittensor_cli.src.commands.sudo import trim - subtensor = _mock_subtensor() - subtensor.query = AsyncMock(return_value="5UNRELATED_ADDRESS") + mock_subtensor.query = AsyncMock(return_value="5UNRELATED_ADDRESS") result = await trim( - wallet=_mock_wallet(), - subtensor=subtensor, + wallet=mock_wallet, + subtensor=mock_subtensor, netuid=1, proxy=PROXY_SS58, max_n=100, diff --git a/tests/unit_tests/test_subnets_register.py b/tests/unit_tests/test_subnets_register.py index 854374c2d..b766d8739 100644 --- a/tests/unit_tests/test_subnets_register.py +++ b/tests/unit_tests/test_subnets_register.py @@ -5,41 +5,12 @@ from asyncio import Future import pytest -from unittest.mock import AsyncMock, MagicMock, Mock, patch -from async_substrate_interface.async_substrate import ( - AsyncExtrinsicReceipt, - AsyncSubstrateInterface, -) -from async_substrate_interface.utils.storage import StorageKey -from bittensor_wallet import Wallet -from scalecodec import GenericCall +from unittest.mock import AsyncMock, patch from bittensor_cli.src.commands.subnets.subnets import register from bittensor_cli.src.bittensor.balances import Balance -@pytest.fixture -def mock_subtensor_base(): - """Base subtensor mock with common async methods.""" - mock = MagicMock() - mock.substrate.get_chain_head = AsyncMock(return_value="0xabc123") - mock.subnet_exists = AsyncMock(return_value=True) - mock.substrate.get_block_number = AsyncMock(return_value=1000) - mock.query = AsyncMock() - mock.neuron_for_uid = AsyncMock() - mock.get_hyperparameter = AsyncMock() - mock.network = "finney" - return mock - - -@pytest.fixture -def mock_wallet(): - """Standard mock wallet.""" - wallet = MagicMock(spec=Wallet) - wallet.coldkeypub.ss58_address = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" - return wallet - - def create_gather_result( registration_allowed=True, target_registrations=1, @@ -84,15 +55,15 @@ class TestSubnetsRegister: @pytest.mark.asyncio async def test_register_subnet_does_not_exist( - self, mock_subtensor_base, mock_wallet + self, mock_subtensor, mock_wallet_spec ): """Test registration fails when subnet does not exist.""" - mock_subtensor_base.subnet_exists = AsyncMock(return_value=False) + mock_subtensor.subnet_exists = AsyncMock(return_value=False) with patch("bittensor_cli.src.bittensor.utils.err_console") as mock_err_console: result = await register( - wallet=mock_wallet, - subtensor=mock_subtensor_base, + wallet=mock_wallet_spec, + subtensor=mock_subtensor, netuid=1, era=None, json_output=False, @@ -100,7 +71,7 @@ async def test_register_subnet_does_not_exist( ) assert result is None - mock_subtensor_base.subnet_exists.assert_awaited_once_with( + mock_subtensor.subnet_exists.assert_awaited_once_with( netuid=1, block_hash="0xabc123" ) mock_err_console.print.assert_called_once() @@ -108,17 +79,17 @@ async def test_register_subnet_does_not_exist( @pytest.mark.asyncio async def test_register_json_output_subnet_not_exist( - self, mock_subtensor_base, mock_wallet + self, mock_subtensor, mock_wallet_spec ): """Test JSON output when subnet does not exist.""" - mock_subtensor_base.subnet_exists = AsyncMock(return_value=False) + mock_subtensor.subnet_exists = AsyncMock(return_value=False) with patch( "bittensor_cli.src.commands.subnets.subnets.json_console" ) as mock_json_console: result = await register( - wallet=mock_wallet, - subtensor=mock_subtensor_base, + wallet=mock_wallet_spec, + subtensor=mock_subtensor, netuid=1, era=None, json_output=True, diff --git a/tests/unit_tests/test_wallet_create.py b/tests/unit_tests/test_wallet_create.py index 1ee0ed5ea..cf36c5b95 100644 --- a/tests/unit_tests/test_wallet_create.py +++ b/tests/unit_tests/test_wallet_create.py @@ -11,16 +11,6 @@ MODULE = "bittensor_cli.src.commands.wallets" -def _mock_wallet(): - wallet = MagicMock() - wallet.name = "test_wallet" - wallet.path = "/tmp/wallets" - wallet.hotkey_str = "default" - wallet.coldkeypub.ss58_address = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" - wallet.hotkeypub.ss58_address = "5HGjWAeFDfFCWPsjFQdVV2Msvz2XtMktvgocEZcCj68kUMaw" - return wallet - - # --------------------------------------------------------------------------- # new_coldkey — URI path: missing return after TypeError # --------------------------------------------------------------------------- @@ -30,35 +20,33 @@ class TestNewColdkeyUriFailure: """Regression tests for new_coldkey() when create_from_uri raises TypeError.""" @pytest.mark.asyncio - async def test_returns_early_on_invalid_uri(self): + async def test_returns_early_on_invalid_uri(self, mock_wallet): """new_coldkey must return without calling set_coldkey when URI is invalid.""" from bittensor_cli.src.commands.wallets import new_coldkey - wallet = _mock_wallet() with patch.object(Keypair, "create_from_uri", side_effect=TypeError("bad uri")): await new_coldkey( - wallet=wallet, + wallet=mock_wallet, n_words=12, use_password=False, uri="//bad", json_output=False, ) - wallet.set_coldkey.assert_not_called() - wallet.set_coldkeypub.assert_not_called() + mock_wallet.set_coldkey.assert_not_called() + mock_wallet.set_coldkeypub.assert_not_called() @pytest.mark.asyncio - async def test_emits_json_error_on_invalid_uri(self): + async def test_emits_json_error_on_invalid_uri(self, mock_wallet): """new_coldkey must emit JSON error output when URI fails and json_output is on.""" from bittensor_cli.src.commands.wallets import new_coldkey - wallet = _mock_wallet() with ( patch.object(Keypair, "create_from_uri", side_effect=TypeError("bad uri")), patch(f"{MODULE}.json_console") as mock_json, ): await new_coldkey( - wallet=wallet, + wallet=mock_wallet, n_words=12, use_password=False, uri="//bad", @@ -72,15 +60,14 @@ async def test_emits_json_error_on_invalid_uri(self): assert output["data"] is None @pytest.mark.asyncio - async def test_uses_overwrite_parameter_on_valid_uri(self): + async def test_uses_overwrite_parameter_on_valid_uri(self, mock_wallet): """new_coldkey must pass the overwrite parameter to set_coldkey, not hardcode False.""" from bittensor_cli.src.commands.wallets import new_coldkey - wallet = _mock_wallet() fake_keypair = MagicMock(spec=Keypair) with patch.object(Keypair, "create_from_uri", return_value=fake_keypair): await new_coldkey( - wallet=wallet, + wallet=mock_wallet, n_words=12, use_password=False, uri="//Alice", @@ -88,10 +75,10 @@ async def test_uses_overwrite_parameter_on_valid_uri(self): json_output=False, ) - wallet.set_coldkey.assert_called_once_with( + mock_wallet.set_coldkey.assert_called_once_with( keypair=fake_keypair, encrypt=False, overwrite=True ) - wallet.set_coldkeypub.assert_called_once_with( + mock_wallet.set_coldkeypub.assert_called_once_with( keypair=fake_keypair, encrypt=False, overwrite=True ) @@ -105,47 +92,44 @@ class TestWalletCreateUri: """Tests for wallet_create() URI code path.""" @pytest.mark.asyncio - async def test_set_coldkeypub_called_once_on_success(self): + async def test_set_coldkeypub_called_once_on_success(self, mock_wallet): """wallet_create must call set_coldkeypub exactly once, not twice.""" from bittensor_cli.src.commands.wallets import wallet_create - wallet = _mock_wallet() fake_keypair = MagicMock(spec=Keypair) with patch.object(Keypair, "create_from_uri", return_value=fake_keypair): await wallet_create( - wallet=wallet, + wallet=mock_wallet, uri="//Alice", json_output=False, ) - assert wallet.set_coldkeypub.call_count == 1 + assert mock_wallet.set_coldkeypub.call_count == 1 @pytest.mark.asyncio - async def test_no_success_message_on_uri_failure(self): + async def test_no_success_message_on_uri_failure(self, mock_wallet): """wallet_create must not print success message when URI creation fails.""" from bittensor_cli.src.commands.wallets import wallet_create - wallet = _mock_wallet() with ( patch.object(Keypair, "create_from_uri", side_effect=TypeError("bad")), patch(f"{MODULE}.console") as mock_console, ): - await wallet_create(wallet=wallet, uri="//bad", json_output=False) + await wallet_create(wallet=mock_wallet, uri="//bad", json_output=False) for c in mock_console.print.call_args_list: assert "Wallet created" not in str(c) @pytest.mark.asyncio - async def test_json_reports_failure_on_uri_error(self): + async def test_json_reports_failure_on_uri_error(self, mock_wallet): """wallet_create JSON output must report failure when URI is invalid.""" from bittensor_cli.src.commands.wallets import wallet_create - wallet = _mock_wallet() with ( patch.object(Keypair, "create_from_uri", side_effect=ValueError("invalid")), patch(f"{MODULE}.json_console") as mock_json, ): - await wallet_create(wallet=wallet, uri="//bad", json_output=True) + await wallet_create(wallet=mock_wallet, uri="//bad", json_output=True) mock_json.print.assert_called_once() output = json.loads(mock_json.print.call_args[0][0]) @@ -162,29 +146,27 @@ class TestWalletCreateMnemonicColdkeyFailure: """Tests for wallet_create() when coldkey creation fails in the mnemonic path.""" @pytest.mark.asyncio - async def test_no_hotkey_created_when_coldkey_fails(self): + async def test_no_hotkey_created_when_coldkey_fails(self, mock_wallet): """wallet_create must not attempt hotkey creation if coldkey creation fails.""" from bittensor_cli.src.commands.wallets import wallet_create - wallet = _mock_wallet() - wallet.create_new_coldkey = MagicMock(side_effect=KeyFileError("not writable")) - wallet.create_new_hotkey = MagicMock() + mock_wallet.create_new_coldkey = MagicMock(side_effect=KeyFileError("not writable")) + mock_wallet.create_new_hotkey = MagicMock() - await wallet_create(wallet=wallet, json_output=False) + await wallet_create(wallet=mock_wallet, json_output=False) - wallet.create_new_coldkey.assert_called_once() - wallet.create_new_hotkey.assert_not_called() + mock_wallet.create_new_coldkey.assert_called_once() + mock_wallet.create_new_hotkey.assert_not_called() @pytest.mark.asyncio - async def test_json_reports_failure_when_coldkey_fails(self): + async def test_json_reports_failure_when_coldkey_fails(self, mock_wallet): """wallet_create JSON must report failure, not success, when coldkey fails.""" from bittensor_cli.src.commands.wallets import wallet_create - wallet = _mock_wallet() - wallet.create_new_coldkey = MagicMock(side_effect=KeyFileError("not writable")) + mock_wallet.create_new_coldkey = MagicMock(side_effect=KeyFileError("not writable")) with patch(f"{MODULE}.json_console") as mock_json: - await wallet_create(wallet=wallet, json_output=True) + await wallet_create(wallet=mock_wallet, json_output=True) mock_json.print.assert_called_once() output = json.loads(mock_json.print.call_args[0][0]) @@ -192,36 +174,34 @@ async def test_json_reports_failure_when_coldkey_fails(self): assert "not writable" in output["error"] @pytest.mark.asyncio - async def test_json_reports_success_when_both_keys_created(self): + async def test_json_reports_success_when_both_keys_created(self, mock_wallet): """wallet_create JSON must report success only when both keys succeed.""" from bittensor_cli.src.commands.wallets import wallet_create - wallet = _mock_wallet() - wallet.create_new_coldkey = MagicMock() - wallet.create_new_hotkey = MagicMock() + mock_wallet.create_new_coldkey = MagicMock() + mock_wallet.create_new_hotkey = MagicMock() with patch(f"{MODULE}.json_console") as mock_json: - await wallet_create(wallet=wallet, json_output=True) + await wallet_create(wallet=mock_wallet, json_output=True) mock_json.print.assert_called_once() output = json.loads(mock_json.print.call_args[0][0]) assert output["success"] is True - assert output["data"]["coldkey_ss58"] == wallet.coldkeypub.ss58_address - assert output["data"]["hotkey_ss58"] == wallet.hotkeypub.ss58_address + assert output["data"]["coldkey_ss58"] == mock_wallet.coldkeypub.ss58_address + assert output["data"]["hotkey_ss58"] == mock_wallet.hotkeypub.ss58_address @pytest.mark.asyncio - async def test_hotkey_failure_reports_error(self): + async def test_hotkey_failure_reports_error(self, mock_wallet): """wallet_create must report error when hotkey creation fails after coldkey succeeds.""" from bittensor_cli.src.commands.wallets import wallet_create - wallet = _mock_wallet() - wallet.create_new_coldkey = MagicMock() - wallet.create_new_hotkey = MagicMock( + mock_wallet.create_new_coldkey = MagicMock() + mock_wallet.create_new_hotkey = MagicMock( side_effect=KeyFileError("hotkey not writable") ) with patch(f"{MODULE}.json_console") as mock_json: - await wallet_create(wallet=wallet, json_output=True) + await wallet_create(wallet=mock_wallet, json_output=True) output = json.loads(mock_json.print.call_args[0][0]) assert output["success"] is False From cbec5637da70781c8b00a48698d362c8825aae0c Mon Sep 17 00:00:00 2001 From: BD Himes Date: Thu, 2 Apr 2026 13:41:26 +0200 Subject: [PATCH 38/49] Adds more tests --- tests/unit_tests/test_balances.py | 395 ++++++++++++++++++++ tests/unit_tests/test_root_extrinsics.py | 245 ++++++++++++ tests/unit_tests/test_transfer_extrinsic.py | 203 ++++++++++ tests/unit_tests/test_unstake_helpers.py | 296 +++++++++++++++ tests/unit_tests/test_utils_pure.py | 321 ++++++++++++++++ 5 files changed, 1460 insertions(+) create mode 100644 tests/unit_tests/test_balances.py create mode 100644 tests/unit_tests/test_root_extrinsics.py create mode 100644 tests/unit_tests/test_transfer_extrinsic.py create mode 100644 tests/unit_tests/test_unstake_helpers.py create mode 100644 tests/unit_tests/test_utils_pure.py diff --git a/tests/unit_tests/test_balances.py b/tests/unit_tests/test_balances.py new file mode 100644 index 000000000..7b65d1dad --- /dev/null +++ b/tests/unit_tests/test_balances.py @@ -0,0 +1,395 @@ +""" +Unit tests for the Balance class in bittensor_cli/src/bittensor/balances.py. + +All tests are synchronous and require no mocks — Balance is a pure value object. +""" + +import pytest + +from bittensor_cli.src.bittensor.balances import Balance, fixed_to_float +from bittensor_cli.src import UNITS + + +RAO_PER_TAO = 1_000_000_000 # 10^9 + + +# --------------------------------------------------------------------------- +# Construction +# --------------------------------------------------------------------------- + + +class TestBalanceConstruction: + def test_int_stores_as_rao(self): + b = Balance(RAO_PER_TAO) + assert b.rao == RAO_PER_TAO + + def test_zero_int(self): + b = Balance(0) + assert b.rao == 0 + + def test_float_converts_tao_to_rao(self): + b = Balance(1.0) + assert b.rao == RAO_PER_TAO + + def test_float_half_tao(self): + b = Balance(0.5) + assert b.rao == RAO_PER_TAO // 2 + + def test_invalid_type_raises_type_error(self): + with pytest.raises(TypeError): + Balance("1") + + def test_invalid_type_none_raises_type_error(self): + with pytest.raises(TypeError): + Balance(None) + + def test_large_int(self): + b = Balance(10 * RAO_PER_TAO) + assert b.rao == 10 * RAO_PER_TAO + + +# --------------------------------------------------------------------------- +# Properties +# --------------------------------------------------------------------------- + + +class TestBalanceProperties: + def test_tao_property_one_tao(self): + b = Balance(RAO_PER_TAO) + assert b.tao == pytest.approx(1.0) + + def test_tao_property_zero(self): + b = Balance(0) + assert b.tao == 0.0 + + def test_int_conversion(self): + b = Balance(42) + assert int(b) == 42 + + def test_float_conversion(self): + b = Balance(RAO_PER_TAO) + assert float(b) == pytest.approx(1.0) + + +# --------------------------------------------------------------------------- +# Factory methods +# --------------------------------------------------------------------------- + + +class TestBalanceFactories: + def test_from_rao(self): + b = Balance.from_rao(RAO_PER_TAO) + assert b.rao == RAO_PER_TAO + + def test_from_tao(self): + b = Balance.from_tao(1.0) + assert b.rao == RAO_PER_TAO + + def test_from_float(self): + b = Balance.from_float(2.0) + assert b.rao == 2 * RAO_PER_TAO + + def test_from_tao_and_from_float_agree(self): + assert Balance.from_tao(3.5).rao == Balance.from_float(3.5).rao + + def test_from_rao_zero(self): + b = Balance.from_rao(0) + assert b.rao == 0 + + +# --------------------------------------------------------------------------- +# Arithmetic +# --------------------------------------------------------------------------- + + +class TestBalanceArithmetic: + def test_add_two_balances(self): + a = Balance.from_tao(1.0) + b = Balance.from_tao(2.0) + assert (a + b).rao == 3 * RAO_PER_TAO + + def test_add_int(self): + a = Balance.from_tao(1.0) + result = a + RAO_PER_TAO + assert result.rao == 2 * RAO_PER_TAO + + def test_radd_int(self): + a = Balance.from_tao(1.0) + result = RAO_PER_TAO + a + assert result.rao == 2 * RAO_PER_TAO + + def test_add_unsupported_raises(self): + with pytest.raises(NotImplementedError): + Balance.from_tao(1.0) + "x" + + def test_sub_two_balances(self): + a = Balance.from_tao(3.0) + b = Balance.from_tao(1.0) + assert (a - b).rao == 2 * RAO_PER_TAO + + def test_sub_int(self): + a = Balance.from_tao(2.0) + result = a - RAO_PER_TAO + assert result.rao == RAO_PER_TAO + + def test_rsub_int(self): + b = Balance.from_tao(1.0) + result = 2 * RAO_PER_TAO - b + assert result.rao == RAO_PER_TAO + + def test_mul_balance(self): + a = Balance.from_rao(4) + b = Balance.from_rao(3) + assert (a * b).rao == 12 # 4*3 rao + + def test_mul_int(self): + a = Balance.from_tao(2.0) + result = a * 3 + assert result.rao == 6 * RAO_PER_TAO + + def test_rmul_int(self): + a = Balance.from_tao(2.0) + result = 3 * a + assert result.rao == 6 * RAO_PER_TAO + + def test_mul_unsupported_raises(self): + with pytest.raises(NotImplementedError): + Balance.from_tao(1.0) * "x" + + def test_truediv_by_int(self): + a = Balance.from_tao(6.0) + result = a / 3 + assert result.rao == 2 * RAO_PER_TAO + + def test_rtruediv_int(self): + a = Balance.from_rao(4) + result = 12 / a + assert result.rao == 3 # 12 // 4 rao + + def test_floordiv_by_int(self): + a = Balance.from_rao(7) + result = a // 2 + assert result.rao == 3 # 7 // 2 rao + + def test_rfloordiv_int(self): + a = Balance.from_rao(3) + result = 10 // a + assert result.rao == 3 # 10 // 3 rao + + +# --------------------------------------------------------------------------- +# Unary operators +# --------------------------------------------------------------------------- + + +class TestBalanceUnary: + def test_neg(self): + b = Balance.from_tao(1.0) + assert (-b).rao == -RAO_PER_TAO + + def test_pos(self): + b = Balance.from_tao(1.0) + assert (+b).rao == RAO_PER_TAO + + def test_abs_positive(self): + b = Balance.from_rao(5) + assert abs(b).rao == 5 + + def test_abs_negative(self): + b = Balance.from_rao(-5) + assert abs(b).rao == 5 + + +# --------------------------------------------------------------------------- +# Comparisons +# --------------------------------------------------------------------------- + + +class TestBalanceComparisons: + def test_eq_same_balance(self): + a = Balance.from_tao(1.0) + b = Balance.from_tao(1.0) + assert a == b + + def test_eq_different_balance(self): + a = Balance.from_tao(1.0) + b = Balance.from_tao(2.0) + assert not (a == b) + + def test_eq_none_returns_false(self): + b = Balance.from_tao(1.0) + assert (b == None) is False # noqa: E711 + + def test_ne(self): + a = Balance.from_tao(1.0) + b = Balance.from_tao(2.0) + assert a != b + + def test_gt_true(self): + a = Balance.from_tao(2.0) + b = Balance.from_tao(1.0) + assert a > b + + def test_gt_false(self): + a = Balance.from_tao(1.0) + b = Balance.from_tao(2.0) + assert not (a > b) + + def test_lt_true(self): + a = Balance.from_tao(1.0) + b = Balance.from_tao(2.0) + assert a < b + + def test_le_equal(self): + a = Balance.from_tao(1.0) + b = Balance.from_tao(1.0) + assert a <= b + + def test_le_less(self): + a = Balance.from_tao(0.5) + b = Balance.from_tao(1.0) + assert a <= b + + def test_ge_equal(self): + a = Balance.from_tao(1.0) + b = Balance.from_tao(1.0) + assert a >= b + + def test_ge_greater(self): + a = Balance.from_tao(2.0) + b = Balance.from_tao(1.0) + assert a >= b + + def test_eq_with_int_rao(self): + b = Balance.from_tao(1.0) + assert b == RAO_PER_TAO + + +# --------------------------------------------------------------------------- +# Boolean +# --------------------------------------------------------------------------- + + +class TestBalanceBool: + def test_zero_is_falsy(self): + assert not Balance(0) + + def test_nonzero_is_truthy(self): + assert Balance(1) + + def test_negative_is_truthy(self): + assert Balance.from_rao(-1) + + +# --------------------------------------------------------------------------- +# String representation +# --------------------------------------------------------------------------- + + +class TestBalanceStr: + def test_str_contains_tao_symbol(self): + b = Balance.from_tao(1.0) + s = str(b) + # Default unit is lowercase τ (chr(0x03C4)), not the uppercase UNITS[0] Τ + assert chr(0x03C4) in s + + def test_str_contains_value(self): + b = Balance.from_tao(1.0) + s = str(b) + assert "1" in s + + def test_str_with_non_default_unit(self): + b = Balance.from_tao(1.0) + b.set_unit(1) # Alpha unit + s = str(b) + assert UNITS[1] in s + + +# --------------------------------------------------------------------------- +# to_dict +# --------------------------------------------------------------------------- + + +class TestBalanceToDict: + def test_to_dict_keys(self): + b = Balance.from_tao(1.0) + d = b.to_dict() + assert set(d.keys()) == {"rao", "tao"} + + def test_to_dict_rao_is_int(self): + b = Balance.from_tao(1.0) + assert isinstance(b.to_dict()["rao"], int) + + def test_to_dict_tao_is_float(self): + b = Balance.from_tao(1.0) + assert isinstance(b.to_dict()["tao"], float) + + def test_to_dict_values(self): + b = Balance.from_tao(2.0) + d = b.to_dict() + assert d["rao"] == 2 * RAO_PER_TAO + assert d["tao"] == pytest.approx(2.0) + + +# --------------------------------------------------------------------------- +# get_unit / set_unit +# --------------------------------------------------------------------------- + + +class TestBalanceUnit: + def test_get_unit_netuid_0_is_tau(self): + assert Balance.get_unit(0) == UNITS[0] + + def test_get_unit_netuid_1_is_alpha(self): + assert Balance.get_unit(1) == UNITS[1] + + def test_get_unit_within_range(self): + for i in range(len(UNITS)): + assert Balance.get_unit(i) == UNITS[i] + + def test_get_unit_beyond_units_length(self): + # Should compute a combined unit string for netuids >= len(UNITS) + unit = Balance.get_unit(len(UNITS)) + assert isinstance(unit, str) + assert len(unit) > 0 + + def test_set_unit_mutates_and_returns_self(self): + b = Balance.from_tao(1.0) + result = b.set_unit(2) + assert result is b + assert b.unit == UNITS[2] + + def test_set_unit_zero(self): + b = Balance.from_tao(1.0) + b.set_unit(0) + assert b.unit == UNITS[0] + + +# --------------------------------------------------------------------------- +# fixed_to_float +# --------------------------------------------------------------------------- + + +class TestFixedToFloat: + def test_zero_fixed(self): + result = fixed_to_float({"bits": 0}) + assert result == pytest.approx(0.0) + + def test_one_integer_part(self): + # 1.0 in U64F64: integer_part=1, fractional_part=0 + # bits = 1 << 64 + bits = 1 << 64 + result = fixed_to_float({"bits": bits}) + assert result == pytest.approx(1.0) + + def test_half_fractional(self): + # 0.5 in U64F64: integer_part=0, fractional_part=2^63 + bits = 1 << 63 + result = fixed_to_float({"bits": bits}) + assert result == pytest.approx(0.5) + + def test_one_and_half(self): + # 1.5: integer_part bits=1<<64, fractional=1<<63 + bits = (1 << 64) + (1 << 63) + result = fixed_to_float({"bits": bits}) + assert result == pytest.approx(1.5) diff --git a/tests/unit_tests/test_root_extrinsics.py b/tests/unit_tests/test_root_extrinsics.py new file mode 100644 index 000000000..432e3fb18 --- /dev/null +++ b/tests/unit_tests/test_root_extrinsics.py @@ -0,0 +1,245 @@ +""" +Unit tests for bittensor_cli/src/bittensor/extrinsics/root.py. + +Covers the pure mathematical functions (normalize_max_weight, +convert_weights_and_uids_for_emit, generate_weight_hash) and the +async helper functions (get_current_weights_for_uid, get_limits). +""" + +import pytest +import numpy as np +from unittest.mock import AsyncMock + +from bittensor_cli.src.bittensor.extrinsics.root import ( + normalize_max_weight, + convert_weights_and_uids_for_emit, + generate_weight_hash, + get_current_weights_for_uid, + get_limits, +) + +# A valid SS58 address for generate_weight_hash (needs real Keypair lookup) +_SS58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + +U16_MAX = 65535 + + +# --------------------------------------------------------------------------- +# normalize_max_weight +# --------------------------------------------------------------------------- + + +class TestNormalizeMaxWeight: + def test_all_zero_returns_uniform(self): + x = np.zeros(4, dtype=np.float32) + result = normalize_max_weight(x, limit=0.5) + np.testing.assert_allclose(result, [0.25, 0.25, 0.25, 0.25], atol=1e-6) + + def test_uniform_input_below_limit(self): + x = np.ones(4, dtype=np.float32) + result = normalize_max_weight(x, limit=0.5) + # Each element is 0.25, which is below limit=0.5 → normalized by sum + np.testing.assert_allclose(result.sum(), 1.0, atol=1e-6) + assert result.max() <= 0.5 + 1e-6 + + def test_single_element_returns_one(self): + x = np.array([1.0], dtype=np.float32) + result = normalize_max_weight(x, limit=0.5) + # x.shape[0]*limit = 0.5 <= 1 → uniform = 1.0 + np.testing.assert_allclose(result, [1.0], atol=1e-6) + + def test_clipping_reduces_max(self): + # One very large weight, others small + x = np.array([100.0, 1.0, 1.0, 1.0], dtype=np.float32) + result = normalize_max_weight(x, limit=0.4) + assert result.max() <= 0.4 + 1e-5 + np.testing.assert_allclose(result.sum(), 1.0, atol=1e-5) + + def test_output_sums_to_one(self): + x = np.array([0.1, 0.5, 0.3, 0.8], dtype=np.float32) + result = normalize_max_weight(x, limit=0.4) + np.testing.assert_allclose(result.sum(), 1.0, atol=1e-5) + + def test_limit_of_one_allows_any_weight(self): + x = np.array([0.2, 0.3, 0.5], dtype=np.float32) + result = normalize_max_weight(x, limit=1.0) + np.testing.assert_allclose(result.sum(), 1.0, atol=1e-6) + + +# --------------------------------------------------------------------------- +# convert_weights_and_uids_for_emit +# --------------------------------------------------------------------------- + + +class TestConvertWeightsAndUidsForEmit: + def test_empty_input_raises(self): + # min() on an empty list raises ValueError in the current implementation + uids = np.array([], dtype=np.int64) + weights = np.array([], dtype=np.float32) + with pytest.raises(ValueError): + convert_weights_and_uids_for_emit(uids, weights) + + def test_all_zero_weights_returns_empty(self): + uids = np.array([0, 1, 2], dtype=np.int64) + weights = np.array([0.0, 0.0, 0.0], dtype=np.float32) + result_uids, result_vals = convert_weights_and_uids_for_emit(uids, weights) + assert result_uids == [] + assert result_vals == [] + + def test_mismatched_lengths_raise_value_error(self): + uids = np.array([0, 1], dtype=np.int64) + weights = np.array([0.5, 0.3, 0.2], dtype=np.float32) + with pytest.raises(ValueError): + convert_weights_and_uids_for_emit(uids, weights) + + def test_negative_weight_raises_value_error(self): + uids = np.array([0, 1], dtype=np.int64) + weights = np.array([0.5, -0.1], dtype=np.float32) + with pytest.raises(ValueError): + convert_weights_and_uids_for_emit(uids, weights) + + def test_normal_case_filters_zeros(self): + uids = np.array([0, 1, 2], dtype=np.int64) + weights = np.array([1.0, 0.0, 0.5], dtype=np.float32) + result_uids, result_vals = convert_weights_and_uids_for_emit(uids, weights) + # uid 1 has weight 0 → filtered + assert 1 not in result_uids + + def test_max_weight_is_u16_max(self): + uids = np.array([0, 1], dtype=np.int64) + weights = np.array([1.0, 0.5], dtype=np.float32) + result_uids, result_vals = convert_weights_and_uids_for_emit(uids, weights) + # The max weight should be U16_MAX + assert max(result_vals) == U16_MAX + + def test_output_lengths_match(self): + uids = np.array([0, 1, 2], dtype=np.int64) + weights = np.array([0.3, 0.5, 0.2], dtype=np.float32) + result_uids, result_vals = convert_weights_and_uids_for_emit(uids, weights) + assert len(result_uids) == len(result_vals) + + def test_uids_preserved_in_output(self): + uids = np.array([5, 10, 15], dtype=np.int64) + weights = np.array([1.0, 0.5, 0.25], dtype=np.float32) + result_uids, _ = convert_weights_and_uids_for_emit(uids, weights) + for uid in result_uids: + assert uid in [5, 10, 15] + + +# --------------------------------------------------------------------------- +# generate_weight_hash +# --------------------------------------------------------------------------- + + +class TestGenerateWeightHash: + def test_returns_0x_prefixed_string(self): + result = generate_weight_hash( + address=_SS58, + netuid=1, + uids=[0, 1, 2], + values=[U16_MAX, U16_MAX // 2, U16_MAX // 4], + version_key=0, + salt=[1, 2, 3], + ) + assert isinstance(result, str) + assert result.startswith("0x") + + def test_returns_64_char_hex_after_prefix(self): + result = generate_weight_hash( + address=_SS58, + netuid=1, + uids=[0], + values=[U16_MAX], + version_key=0, + salt=[42], + ) + hex_part = result[2:] # Remove 0x + assert len(hex_part) == 64 # Blake2b 32 bytes = 64 hex chars + + def test_deterministic_same_inputs(self): + kwargs = dict( + address=_SS58, + netuid=2, + uids=[0, 1], + values=[30000, 35535], + version_key=1, + salt=[7, 8], + ) + hash1 = generate_weight_hash(**kwargs) + hash2 = generate_weight_hash(**kwargs) + assert hash1 == hash2 + + def test_different_salt_produces_different_hash(self): + base = dict( + address=_SS58, netuid=1, uids=[0], values=[U16_MAX], version_key=0 + ) + hash1 = generate_weight_hash(**base, salt=[1]) + hash2 = generate_weight_hash(**base, salt=[2]) + assert hash1 != hash2 + + def test_different_netuid_produces_different_hash(self): + base = dict( + address=_SS58, uids=[0], values=[U16_MAX], version_key=0, salt=[1] + ) + hash1 = generate_weight_hash(**base, netuid=1) + hash2 = generate_weight_hash(**base, netuid=2) + assert hash1 != hash2 + + +# --------------------------------------------------------------------------- +# get_current_weights_for_uid (async) +# --------------------------------------------------------------------------- + + +class TestGetCurrentWeightsForUid: + async def test_returns_weights_for_matching_uid(self, mock_subtensor): + # Return [(uid, [(dest, raw_weight), ...])] + mock_subtensor.weights = AsyncMock( + return_value=[(5, [(0, 65535), (1, 32767)])] + ) + result = await get_current_weights_for_uid( + subtensor=mock_subtensor, netuid=0, uid=5 + ) + assert 0 in result + assert 1 in result + assert result[0] == pytest.approx(1.0) + assert 0.4 < result[1] < 0.6 + + async def test_returns_empty_for_nonmatching_uid(self, mock_subtensor): + mock_subtensor.weights = AsyncMock( + return_value=[(3, [(0, 65535)])] + ) + result = await get_current_weights_for_uid( + subtensor=mock_subtensor, netuid=0, uid=99 + ) + assert result == {} + + async def test_returns_empty_when_no_weights(self, mock_subtensor): + mock_subtensor.weights = AsyncMock(return_value=[]) + result = await get_current_weights_for_uid( + subtensor=mock_subtensor, netuid=0, uid=0 + ) + assert result == {} + + +# --------------------------------------------------------------------------- +# get_limits (async) +# --------------------------------------------------------------------------- + + +class TestGetLimits: + async def test_returns_int_and_float(self, mock_subtensor): + call_count = [0] + + async def side_effect(param, netuid): + call_count[0] += 1 + if call_count[0] == 1: + return "4" # MinAllowedWeights as string (int-able) + return "32767" # MaxWeightsLimit as string + + mock_subtensor.get_hyperparameter = AsyncMock(side_effect=side_effect) + min_w, max_w = await get_limits(mock_subtensor) + assert isinstance(min_w, int) + assert isinstance(max_w, float) + assert min_w == 4 + assert 0.0 < max_w < 1.0 diff --git a/tests/unit_tests/test_transfer_extrinsic.py b/tests/unit_tests/test_transfer_extrinsic.py new file mode 100644 index 000000000..4af6cbe84 --- /dev/null +++ b/tests/unit_tests/test_transfer_extrinsic.py @@ -0,0 +1,203 @@ +""" +Unit tests for bittensor_cli/src/bittensor/extrinsics/transfer.py. + +Tests the branching logic in transfer_extrinsic using the shared mock_wallet +and mock_subtensor fixtures from conftest.py. +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from bittensor_cli.src.bittensor.balances import Balance +from bittensor_cli.src.bittensor.extrinsics.transfer import transfer_extrinsic + +# A valid destination SS58 address +_DEST_SS58 = "5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy" +# An invalid destination +_INVALID_DEST = "not_a_valid_address" + +MODULE = "bittensor_cli.src.bittensor.extrinsics.transfer" + + +def _setup_transfer(mock_subtensor, balance_tao=100, fee_tao=0.01, existential_tao=0.001): + """Configure mock_subtensor for a standard successful transfer scenario.""" + mock_subtensor.get_balance = AsyncMock(return_value=Balance.from_tao(balance_tao)) + mock_subtensor.get_existential_deposit = AsyncMock(return_value=Balance.from_tao(existential_tao)) + mock_subtensor.get_extrinsic_fee = AsyncMock(return_value=Balance.from_tao(fee_tao)) + mock_subtensor.sign_and_send_extrinsic = AsyncMock(return_value=(True, "", AsyncMock())) + + +class TestTransferExtrinsicValidation: + async def test_invalid_destination_returns_false(self, mock_wallet, mock_subtensor): + """Invalid SS58 destination should immediately return (False, None).""" + result = await transfer_extrinsic( + subtensor=mock_subtensor, + wallet=mock_wallet, + destination=_INVALID_DEST, + amount=Balance.from_tao(1.0), + prompt=False, + ) + assert result == (False, None) + + async def test_valid_destination_proceeds(self, mock_wallet, mock_subtensor): + """Valid SS58 destination should proceed past validation.""" + _setup_transfer(mock_subtensor) + with patch(f"{MODULE}.unlock_key", return_value=MagicMock(success=True)): + success, receipt = await transfer_extrinsic( + subtensor=mock_subtensor, + wallet=mock_wallet, + destination=_DEST_SS58, + amount=Balance.from_tao(1.0), + prompt=False, + ) + # Should succeed (not fail at validation) + assert success is True + + +class TestTransferExtrinsicCallFunction: + async def test_transfer_all_uses_transfer_all_function(self, mock_wallet, mock_subtensor): + """transfer_all=True must use 'transfer_all' call_function.""" + _setup_transfer(mock_subtensor) + with patch(f"{MODULE}.unlock_key", return_value=MagicMock(success=True)): + await transfer_extrinsic( + subtensor=mock_subtensor, + wallet=mock_wallet, + destination=_DEST_SS58, + amount=Balance.from_tao(1.0), + transfer_all=True, + prompt=False, + ) + # Verify compose_call was called with 'transfer_all' + calls = mock_subtensor.substrate.compose_call.call_args_list + call_functions = [c.kwargs.get("call_function") for c in calls] + assert "transfer_all" in call_functions + + async def test_allow_death_uses_transfer_allow_death(self, mock_wallet, mock_subtensor): + """allow_death=True must use 'transfer_allow_death' call_function.""" + _setup_transfer(mock_subtensor) + with patch(f"{MODULE}.unlock_key", return_value=MagicMock(success=True)): + await transfer_extrinsic( + subtensor=mock_subtensor, + wallet=mock_wallet, + destination=_DEST_SS58, + amount=Balance.from_tao(1.0), + allow_death=True, + prompt=False, + ) + calls = mock_subtensor.substrate.compose_call.call_args_list + call_functions = [c.kwargs.get("call_function") for c in calls] + assert "transfer_allow_death" in call_functions + + async def test_default_uses_transfer_keep_alive(self, mock_wallet, mock_subtensor): + """Default (no allow_death, no transfer_all) must use 'transfer_keep_alive'.""" + _setup_transfer(mock_subtensor) + with patch(f"{MODULE}.unlock_key", return_value=MagicMock(success=True)): + await transfer_extrinsic( + subtensor=mock_subtensor, + wallet=mock_wallet, + destination=_DEST_SS58, + amount=Balance.from_tao(1.0), + prompt=False, + ) + calls = mock_subtensor.substrate.compose_call.call_args_list + call_functions = [c.kwargs.get("call_function") for c in calls] + assert "transfer_keep_alive" in call_functions + + async def test_transfer_all_keep_alive_when_allow_death_false(self, mock_wallet, mock_subtensor): + """transfer_all=True, allow_death=False → keep_alive param must be True.""" + _setup_transfer(mock_subtensor) + with patch(f"{MODULE}.unlock_key", return_value=MagicMock(success=True)): + await transfer_extrinsic( + subtensor=mock_subtensor, + wallet=mock_wallet, + destination=_DEST_SS58, + amount=Balance.from_tao(1.0), + transfer_all=True, + allow_death=False, + prompt=False, + ) + calls = mock_subtensor.substrate.compose_call.call_args_list + transfer_all_calls = [c for c in calls if c.kwargs.get("call_function") == "transfer_all"] + assert len(transfer_all_calls) >= 1 + params = transfer_all_calls[0].kwargs.get("call_params", {}) + assert params.get("keep_alive") is True + + +class TestTransferExtrinsicBalanceChecks: + async def test_insufficient_balance_no_proxy_returns_false(self, mock_wallet, mock_subtensor): + """Insufficient balance without proxy should return (False, None).""" + # Balance is only 0.1 tao, trying to transfer 10 tao + mock_subtensor.get_balance = AsyncMock(return_value=Balance.from_tao(0.1)) + mock_subtensor.get_existential_deposit = AsyncMock(return_value=Balance.from_tao(0.001)) + mock_subtensor.get_extrinsic_fee = AsyncMock(return_value=Balance.from_tao(0.01)) + + result = await transfer_extrinsic( + subtensor=mock_subtensor, + wallet=mock_wallet, + destination=_DEST_SS58, + amount=Balance.from_tao(10.0), + prompt=False, + ) + assert result == (False, None) + + +class TestTransferExtrinsicUnlockKey: + async def test_unlock_failure_returns_false(self, mock_wallet, mock_subtensor): + """unlock_key failure should return (False, None).""" + _setup_transfer(mock_subtensor) + with patch(f"{MODULE}.unlock_key", return_value=MagicMock(success=False)): + result = await transfer_extrinsic( + subtensor=mock_subtensor, + wallet=mock_wallet, + destination=_DEST_SS58, + amount=Balance.from_tao(1.0), + prompt=False, + ) + assert result == (False, None) + + +class TestTransferExtrinsicSuccess: + async def test_successful_transfer_returns_true_and_receipt(self, mock_wallet, mock_subtensor): + """Successful transfer should return (True, receipt).""" + _setup_transfer(mock_subtensor) + with patch(f"{MODULE}.unlock_key", return_value=MagicMock(success=True)): + success, receipt = await transfer_extrinsic( + subtensor=mock_subtensor, + wallet=mock_wallet, + destination=_DEST_SS58, + amount=Balance.from_tao(1.0), + prompt=False, + ) + assert success is True + assert receipt is not None + + async def test_successful_transfer_calls_get_balance_twice(self, mock_wallet, mock_subtensor): + """After success, get_balance should be called again for balance display.""" + _setup_transfer(mock_subtensor) + with patch(f"{MODULE}.unlock_key", return_value=MagicMock(success=True)): + await transfer_extrinsic( + subtensor=mock_subtensor, + wallet=mock_wallet, + destination=_DEST_SS58, + amount=Balance.from_tao(1.0), + prompt=False, + ) + # Once for balance check + once after success for display + assert mock_subtensor.get_balance.await_count >= 2 + + +class TestTransferExtrinsicAnnounceOnly: + async def test_announce_only_passed_to_sign_and_send(self, mock_wallet, mock_subtensor): + """announce_only=True should be forwarded to sign_and_send_extrinsic.""" + _setup_transfer(mock_subtensor) + with patch(f"{MODULE}.unlock_key", return_value=MagicMock(success=True)): + await transfer_extrinsic( + subtensor=mock_subtensor, + wallet=mock_wallet, + destination=_DEST_SS58, + amount=Balance.from_tao(1.0), + prompt=False, + announce_only=True, + ) + calls = mock_subtensor.sign_and_send_extrinsic.call_args_list + assert any(c.kwargs.get("announce_only") is True for c in calls) diff --git a/tests/unit_tests/test_unstake_helpers.py b/tests/unit_tests/test_unstake_helpers.py new file mode 100644 index 000000000..4726ced6f --- /dev/null +++ b/tests/unit_tests/test_unstake_helpers.py @@ -0,0 +1,296 @@ +""" +Unit tests for helper functions in bittensor_cli/src/commands/stake/remove.py. + +Focuses on the pure/simple helper functions that can be tested without +running the full unstake flow: + - _get_hotkeys_to_unstake + - get_hotkey_identity + - _create_unstake_table + - _print_table_and_slippage +""" + +import pytest +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from rich.table import Table + +from bittensor_cli.src.commands.stake.remove import ( + _get_hotkeys_to_unstake, + _create_unstake_table, + _print_table_and_slippage, + get_hotkey_identity, +) +from bittensor_cli.src.bittensor.balances import Balance + +MODULE = "bittensor_cli.src.commands.stake.remove" + +# Known-valid SS58 addresses +_HOTKEY_SS58 = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" +_COLDKEY_SS58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + + +# --------------------------------------------------------------------------- +# _get_hotkeys_to_unstake +# --------------------------------------------------------------------------- + + +class TestGetHotkeysToUnstake: + def test_specific_ss58_returns_single_entry(self, mock_wallet): + """Providing hotkey_ss58_address returns exactly one tuple.""" + result = _get_hotkeys_to_unstake( + wallet=mock_wallet, + hotkey_ss58_address=_HOTKEY_SS58, + all_hotkeys=False, + include_hotkeys=[], + exclude_hotkeys=[], + stake_infos=[], + identities={}, + ) + assert len(result) == 1 + assert result[0] == (None, _HOTKEY_SS58, None) + + def test_include_hotkeys_with_ss58_passes_through(self, mock_wallet): + """include_hotkeys with a valid SS58 address → passed through directly.""" + result = _get_hotkeys_to_unstake( + wallet=mock_wallet, + hotkey_ss58_address=None, + all_hotkeys=False, + include_hotkeys=[_HOTKEY_SS58], + exclude_hotkeys=[], + stake_infos=[], + identities={}, + ) + assert len(result) == 1 + assert result[0] == (None, _HOTKEY_SS58, None) + + def test_include_hotkeys_with_name_creates_wallet(self, mock_wallet): + """include_hotkeys with a non-SS58 string creates a Wallet and calls get_hotkey_pub_ss58.""" + hotkey_name = "my_hotkey" + with ( + patch(f"{MODULE}.Wallet") as mock_wallet_cls, + patch(f"{MODULE}.get_hotkey_pub_ss58", return_value=_HOTKEY_SS58), + ): + mock_inner_wallet = MagicMock() + mock_inner_wallet.hotkey_str = hotkey_name + mock_wallet_cls.return_value = mock_inner_wallet + + result = _get_hotkeys_to_unstake( + wallet=mock_wallet, + hotkey_ss58_address=None, + all_hotkeys=False, + include_hotkeys=[hotkey_name], + exclude_hotkeys=[], + stake_infos=[], + identities={}, + ) + + assert len(result) == 1 + assert result[0][1] == _HOTKEY_SS58 # ss58 is correct + mock_wallet_cls.assert_called_once_with( + name=mock_wallet.name, + path=mock_wallet.path, + hotkey=hotkey_name, + ) + + def test_all_hotkeys_combines_wallet_and_chain_hotkeys(self, mock_wallet): + """all_hotkeys=True merges wallet hotkeys and chain-only stake_infos.""" + wallet_hotkey = MagicMock() + wallet_hotkey.hotkey_str = "default" + + stake_info_chain = SimpleNamespace(hotkey_ss58="5CHAIN_HOTKEY_ADDRESS") + + with ( + patch(f"{MODULE}.get_hotkey_wallets_for_wallet", return_value=[wallet_hotkey]), + patch(f"{MODULE}.get_hotkey_pub_ss58", return_value=_HOTKEY_SS58), + patch(f"{MODULE}.get_hotkey_identity", return_value="chain_hk"), + ): + result = _get_hotkeys_to_unstake( + wallet=mock_wallet, + hotkey_ss58_address=None, + all_hotkeys=True, + include_hotkeys=[], + exclude_hotkeys=[], + stake_infos=[stake_info_chain], + identities={}, + ) + + # Wallet hotkey + chain-only hotkey + ss58_list = [r[1] for r in result] + assert _HOTKEY_SS58 in ss58_list + assert "5CHAIN_HOTKEY_ADDRESS" in ss58_list + + def test_all_hotkeys_excludes_specified(self, mock_wallet): + """exclude_hotkeys list is respected in all_hotkeys mode.""" + wallet_hotkey = MagicMock() + wallet_hotkey.hotkey_str = "to_exclude" + + with ( + patch(f"{MODULE}.get_hotkey_wallets_for_wallet", return_value=[wallet_hotkey]), + patch(f"{MODULE}.get_hotkey_pub_ss58", return_value=_HOTKEY_SS58), + ): + result = _get_hotkeys_to_unstake( + wallet=mock_wallet, + hotkey_ss58_address=None, + all_hotkeys=True, + include_hotkeys=[], + exclude_hotkeys=["to_exclude"], + stake_infos=[], + identities={}, + ) + + # "to_exclude" hotkey should not appear + names = [r[0] for r in result] + assert "to_exclude" not in names + + def test_default_uses_wallet_hotkey(self, mock_wallet): + """Default path (no flags) returns the wallet's current hotkey.""" + with patch(f"{MODULE}.get_hotkey_pub_ss58", return_value=_HOTKEY_SS58): + result = _get_hotkeys_to_unstake( + wallet=mock_wallet, + hotkey_ss58_address=None, + all_hotkeys=False, + include_hotkeys=[], + exclude_hotkeys=[], + stake_infos=[], + identities={}, + ) + + assert len(result) == 1 + assert result[0][1] == _HOTKEY_SS58 + assert result[0][2] is None + + +# --------------------------------------------------------------------------- +# get_hotkey_identity +# --------------------------------------------------------------------------- + + +class TestGetHotkeyIdentity: + def test_returns_identity_name_when_present(self): + """If identities map has a name for the hotkey, return it.""" + identities = {"hotkeys": {_HOTKEY_SS58: {"name": "MyValidator"}}} + with patch( + f"{MODULE}.get_hotkey_identity_name", return_value="MyValidator" + ): + result = get_hotkey_identity(hotkey_ss58=_HOTKEY_SS58, identities=identities) + assert result == "MyValidator" + + def test_returns_truncated_address_when_no_identity(self): + """If no identity found, return truncated SS58 address.""" + with patch(f"{MODULE}.get_hotkey_identity_name", return_value=None): + result = get_hotkey_identity(hotkey_ss58=_HOTKEY_SS58, identities={}) + expected = f"{_HOTKEY_SS58[:4]}...{_HOTKEY_SS58[-4:]}" + assert result == expected + + +# --------------------------------------------------------------------------- +# _create_unstake_table +# --------------------------------------------------------------------------- + + +class TestCreateUnstakeTable: + def test_returns_rich_table(self): + """_create_unstake_table must return a rich.Table instance.""" + table = _create_unstake_table( + wallet_name="test_wallet", + wallet_coldkey_ss58=_COLDKEY_SS58, + network="finney", + total_received_amount=Balance.from_tao(10.0), + safe_staking=False, + rate_tolerance=0.01, + ) + assert isinstance(table, Table) + + def test_table_has_basic_columns(self): + """Table should include at least the standard columns.""" + table = _create_unstake_table( + wallet_name="test_wallet", + wallet_coldkey_ss58=_COLDKEY_SS58, + network="finney", + total_received_amount=Balance.from_tao(10.0), + safe_staking=False, + rate_tolerance=0.01, + ) + col_names = [c.header for c in table.columns] + assert any("Netuid" in h for h in col_names) + assert any("Hotkey" in h for h in col_names) + assert any("Received" in h for h in col_names) + + def test_safe_staking_adds_extra_columns(self): + """With safe_staking=True, additional tolerance columns should appear.""" + table_safe = _create_unstake_table( + wallet_name="test_wallet", + wallet_coldkey_ss58=_COLDKEY_SS58, + network="finney", + total_received_amount=Balance.from_tao(10.0), + safe_staking=True, + rate_tolerance=0.05, + ) + table_plain = _create_unstake_table( + wallet_name="test_wallet", + wallet_coldkey_ss58=_COLDKEY_SS58, + network="finney", + total_received_amount=Balance.from_tao(10.0), + safe_staking=False, + rate_tolerance=0.05, + ) + assert len(table_safe.columns) > len(table_plain.columns) + + def test_title_contains_wallet_name(self): + """Table title should include the wallet name.""" + table = _create_unstake_table( + wallet_name="my_wallet", + wallet_coldkey_ss58=_COLDKEY_SS58, + network="finney", + total_received_amount=Balance.from_tao(5.0), + safe_staking=False, + rate_tolerance=0.01, + ) + assert "my_wallet" in table.title + + +# --------------------------------------------------------------------------- +# _print_table_and_slippage +# --------------------------------------------------------------------------- + + +class TestPrintTableAndSlippage: + def test_high_slippage_prints_warning(self): + """Slippage > 5 should trigger a warning message via console.print.""" + table = MagicMock(spec=Table) + with patch(f"{MODULE}.console") as mock_console: + _print_table_and_slippage( + table=table, + max_float_slippage=10.0, + safe_staking=False, + ) + # console.print should be called at least twice: table + warning + assert mock_console.print.call_count >= 2 + all_calls_str = str(mock_console.print.call_args_list) + assert "WARNING" in all_calls_str + + def test_low_slippage_no_warning(self): + """Slippage <= 5 should NOT print a warning.""" + table = MagicMock(spec=Table) + with patch(f"{MODULE}.console") as mock_console: + _print_table_and_slippage( + table=table, + max_float_slippage=2.0, + safe_staking=False, + ) + all_calls_str = str(mock_console.print.call_args_list) + assert "WARNING" not in all_calls_str + + def test_table_is_printed(self): + """The table must always be printed.""" + table = MagicMock(spec=Table) + with patch(f"{MODULE}.console") as mock_console: + _print_table_and_slippage( + table=table, + max_float_slippage=0.0, + safe_staking=False, + ) + # The table object should appear in the first print call + first_call_args = mock_console.print.call_args_list[0][0] + assert table in first_call_args diff --git a/tests/unit_tests/test_utils_pure.py b/tests/unit_tests/test_utils_pure.py new file mode 100644 index 000000000..b76fef6ae --- /dev/null +++ b/tests/unit_tests/test_utils_pure.py @@ -0,0 +1,321 @@ +""" +Unit tests for pure (side-effect-free) functions in: + - bittensor_cli/src/bittensor/networking.py (int_to_ip) + - bittensor_cli/src/bittensor/utils.py (validation & conversion functions) + +All tests are synchronous and require no mocks. +""" + +import pytest +import typer + +from bittensor_cli.src.bittensor.networking import int_to_ip +from bittensor_cli.src.bittensor.utils import ( + u16_normalized_float, + u64_normalized_float, + float_to_u16, + float_to_u64, + u16_to_float, + u64_to_float, + validate_chain_endpoint, + is_valid_ss58_address, + is_valid_ed25519_pubkey, + is_valid_bittensor_address_or_public_key, + format_error_message, + validate_netuid, +) + +# A known-valid SS58 address (format 42, Substrate default) +_VALID_SS58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + + +# --------------------------------------------------------------------------- +# int_to_ip +# --------------------------------------------------------------------------- + + +class TestIntToIp: + def test_zero_is_all_zeros(self): + assert int_to_ip(0) == "0.0.0.0" + + def test_loopback_ipv4(self): + assert int_to_ip(2130706433) == "127.0.0.1" + + def test_broadcast(self): + assert int_to_ip(4294967295) == "255.255.255.255" + + def test_ipv6_loopback(self): + # ::1 maps to integer 1 for IPv6 + result = int_to_ip(1) + # netaddr returns IPv6 for 1 (>32-bit threshold) + assert ":" in result or result == "0.0.0.1" + + def test_round_trip_with_ip_to_int(self): + """int_to_ip should be the inverse of netaddr.IPAddress(str) -> int.""" + from bittensor_cli.src.bittensor.extrinsics.serving import ip_to_int + + ip = "192.168.1.100" + assert int_to_ip(ip_to_int(ip)) == ip + + +# --------------------------------------------------------------------------- +# u16_normalized_float / u64_normalized_float +# --------------------------------------------------------------------------- + + +class TestNormalizedFloat: + def test_u16_zero(self): + assert u16_normalized_float(0) == pytest.approx(0.0) + + def test_u16_max(self): + assert u16_normalized_float(65535) == pytest.approx(1.0) + + def test_u16_midpoint(self): + result = u16_normalized_float(65535 // 2) + assert 0.0 < result < 1.0 + + def test_u64_zero(self): + assert u64_normalized_float(0) == pytest.approx(0.0) + + def test_u64_max(self): + assert u64_normalized_float(2**64 - 1) == pytest.approx(1.0) + + def test_u64_midpoint(self): + result = u64_normalized_float((2**64 - 1) // 2) + assert 0.0 < result < 1.0 + + +# --------------------------------------------------------------------------- +# float_to_u16 / u16_to_float +# --------------------------------------------------------------------------- + + +class TestU16Conversion: + def test_float_to_u16_zero(self): + assert float_to_u16(0.0) == 0 + + def test_float_to_u16_one(self): + assert float_to_u16(1.0) == 65535 + + def test_float_to_u16_half(self): + result = float_to_u16(0.5) + assert 32000 < result < 33000 + + def test_float_to_u16_out_of_range_high(self): + with pytest.raises(ValueError): + float_to_u16(1.1) + + def test_float_to_u16_out_of_range_low(self): + with pytest.raises(ValueError): + float_to_u16(-0.1) + + def test_u16_to_float_zero(self): + assert u16_to_float(0) == pytest.approx(0.0) + + def test_u16_to_float_max(self): + assert u16_to_float(65535) == pytest.approx(1.0) + + def test_u16_to_float_out_of_range(self): + with pytest.raises(ValueError): + u16_to_float(65536) + + def test_round_trip(self): + value = 0.75 + assert u16_to_float(float_to_u16(value)) == pytest.approx(value, rel=1e-4) + + +# --------------------------------------------------------------------------- +# float_to_u64 / u64_to_float +# --------------------------------------------------------------------------- + + +class TestU64Conversion: + def test_float_to_u64_zero(self): + assert float_to_u64(0.0) == 0 + + def test_float_to_u64_one(self): + # float precision: 1.0 * (2**64-1) rounds up to 2**64 in float arithmetic + result = float_to_u64(1.0) + assert result >= 2**64 - 1 + + def test_float_to_u64_out_of_range_high(self): + with pytest.raises(ValueError): + float_to_u64(1.1) + + def test_float_to_u64_out_of_range_low(self): + with pytest.raises(ValueError): + float_to_u64(-0.1) + + def test_u64_to_float_zero(self): + assert u64_to_float(0) == pytest.approx(0.0) + + def test_u64_to_float_max(self): + assert u64_to_float(2**64 - 1) == pytest.approx(1.0) + + def test_u64_to_float_out_of_range(self): + with pytest.raises(ValueError): + u64_to_float(2**64 + 10) + + def test_round_trip(self): + value = 0.6 + result = u64_to_float(float_to_u64(value)) + assert result == pytest.approx(value, rel=1e-9) + + +# --------------------------------------------------------------------------- +# validate_chain_endpoint +# --------------------------------------------------------------------------- + + +class TestValidateChainEndpoint: + def test_ws_localhost_valid(self): + ok, msg = validate_chain_endpoint("ws://localhost:9944") + assert ok is True + assert msg == "" + + def test_wss_valid(self): + ok, msg = validate_chain_endpoint("wss://finney.opentensor.ai:443") + assert ok is True + + def test_http_invalid(self): + ok, msg = validate_chain_endpoint("http://localhost:9944") + assert ok is False + assert len(msg) > 0 + + def test_https_invalid(self): + ok, msg = validate_chain_endpoint("https://example.com") + assert ok is False + + def test_no_scheme_invalid(self): + ok, msg = validate_chain_endpoint("localhost:9944") + assert ok is False + + def test_missing_netloc_invalid(self): + ok, msg = validate_chain_endpoint("ws://") + assert ok is False + + +# --------------------------------------------------------------------------- +# is_valid_ss58_address +# --------------------------------------------------------------------------- + + +class TestIsValidSS58Address: + def test_valid_address(self): + assert is_valid_ss58_address(_VALID_SS58) is True + + def test_garbage_string(self): + assert is_valid_ss58_address("not_an_address") is False + + def test_empty_string(self): + assert is_valid_ss58_address("") is False + + def test_too_short(self): + assert is_valid_ss58_address("5ABC") is False + + def test_another_valid_address(self): + # Another well-known valid SS58 address + assert is_valid_ss58_address("5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY") is True + + +# --------------------------------------------------------------------------- +# is_valid_ed25519_pubkey +# --------------------------------------------------------------------------- + + +class TestIsValidEd25519Pubkey: + def test_valid_64_char_hex(self): + # 64 hex chars = 32 bytes + valid_hex = "a" * 64 + assert is_valid_ed25519_pubkey(valid_hex) is True + + def test_bytes_wrong_length_returns_false(self): + # 16 bytes is wrong length — returns False + assert is_valid_ed25519_pubkey(b"\x00" * 16) is False + + def test_too_short_string(self): + assert is_valid_ed25519_pubkey("abc") is False + + def test_non_string_non_bytes(self): + assert is_valid_ed25519_pubkey(12345) is False + + +# --------------------------------------------------------------------------- +# is_valid_bittensor_address_or_public_key +# --------------------------------------------------------------------------- + + +class TestIsValidBittensorAddressOrPublicKey: + def test_valid_ss58_dispatches_correctly(self): + assert is_valid_bittensor_address_or_public_key(_VALID_SS58) is True + + def test_hex_prefix_dispatches_to_ed25519(self): + # 0x prefix → ed25519 path; 64 hex chars valid + assert is_valid_bittensor_address_or_public_key("0x" + "a" * 64) is True + + def test_hex_prefix_wrong_length(self): + assert is_valid_bittensor_address_or_public_key("0xabc") is False + + def test_int_invalid(self): + assert is_valid_bittensor_address_or_public_key(12345) is False + + def test_garbage_string(self): + assert is_valid_bittensor_address_or_public_key("definitely_not_valid") is False + + +# --------------------------------------------------------------------------- +# format_error_message +# --------------------------------------------------------------------------- + + +class TestFormatErrorMessage: + def test_dict_with_type_name_docs(self): + err = {"type": "ModuleError", "name": "StakeNotEnough", "docs": ["Stake too low"]} + result = format_error_message(err) + assert "StakeNotEnough" in result + + def test_dict_with_code_message_data(self): + err = {"code": 1001, "message": "Bad request", "data": "Custom error: details"} + result = format_error_message(err) + assert isinstance(result, str) + assert len(result) > 0 + + def test_plain_exception(self): + exc = Exception("something went wrong") + result = format_error_message(exc) + assert "something went wrong" in result + + def test_exception_with_dict_arg(self): + # SubstrateRequestException pattern: arg is a dict literal string + err_dict = str({"error": {"type": "Err", "name": "TestErr", "docs": ["doc"]}}) + exc = Exception(err_dict) + result = format_error_message(exc) + assert isinstance(result, str) + + def test_unknown_dict_falls_back(self): + err = {"unknown_key": "value"} + result = format_error_message(err) + assert isinstance(result, str) + + +# --------------------------------------------------------------------------- +# validate_netuid +# --------------------------------------------------------------------------- + + +class TestValidateNetuid: + def test_positive_passes_through(self): + assert validate_netuid(1) == 1 + + def test_zero_passes_through(self): + assert validate_netuid(0) == 0 + + def test_none_passes_through(self): + assert validate_netuid(None) is None + + def test_negative_raises_bad_parameter(self): + with pytest.raises(typer.BadParameter): + validate_netuid(-1) + + def test_large_value_passes(self): + assert validate_netuid(999) == 999 From 6dd47af5ae1259adc1ffac0eb87e19b24aade91f Mon Sep 17 00:00:00 2001 From: BD Himes Date: Thu, 2 Apr 2026 13:56:10 +0200 Subject: [PATCH 39/49] Adds more tests --- tests/unit_tests/test_chain_data.py | 422 ++++++++++++++++++++++++++++ 1 file changed, 422 insertions(+) create mode 100644 tests/unit_tests/test_chain_data.py diff --git a/tests/unit_tests/test_chain_data.py b/tests/unit_tests/test_chain_data.py new file mode 100644 index 000000000..23f81066a --- /dev/null +++ b/tests/unit_tests/test_chain_data.py @@ -0,0 +1,422 @@ +""" +Unit tests for bittensor_cli/src/bittensor/chain_data.py. + +Focuses on the _fix_decoded() class methods and the DynamicInfo price/slippage +math — the areas most likely to cause silent data corruption or crashes in +production when chain data has unexpected values. +""" + +import pytest + +from bittensor_cli.src.bittensor.chain_data import ( + DynamicInfo, + StakeInfo, + NeuronInfo, +) +from bittensor_cli.src.bittensor.balances import Balance + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# 32-byte account id tuple used to stand in for owner_hotkey / owner_coldkey. +# bytes(tuple(range(32))) is valid input to decode_account_id. +_ACCOUNT_ID = tuple(range(32)) + +# A fixed-point dict as returned by the chain for moving_price (value ≈ 1.0). +# fixed_to_float reads fixed["bits"]; bits = 2**32 ≈ 1.0 in Q32.32 format. +_MOVING_PRICE_ONE = {"bits": 2**32} +_MOVING_PRICE_ZERO = {"bits": 0} + +TAO = 1_000_000_000 # 1 TAO in rao + + +def _make_dynamic_decoded( + netuid: int = 1, + alpha_in_rao: int = 100 * TAO, + tao_in_rao: int = 50 * TAO, + symbol: bytes = b"SN", + subnet_name: bytes = b"TestNet", + subnet_identity=None, +) -> dict: + """Construct a minimal decoded dict that mirrors what the chain returns.""" + return { + "netuid": netuid, + "token_symbol": list(symbol), + "subnet_name": list(subnet_name), + "owner_hotkey": _ACCOUNT_ID, + "owner_coldkey": _ACCOUNT_ID, + "emission": 0, + "alpha_in": alpha_in_rao, + "alpha_out": 0, + "tao_in": tao_in_rao, + "alpha_out_emission": 0, + "alpha_in_emission": 0, + "subnet_volume": 0, + "tao_in_emission": 0, + "pending_alpha_emission": 0, + "pending_root_emission": 0, + "tempo": 100, + "last_step": 500, + "blocks_since_last_step": 50, + "network_registered_at": 1000, + "moving_price": _MOVING_PRICE_ONE, + "subnet_identity": subnet_identity, + } + + +def _make_stake_decoded( + netuid: int = 1, + stake_rao: int = 10 * TAO, +) -> dict: + """Construct a minimal decoded dict for StakeInfo.""" + return { + "hotkey": _ACCOUNT_ID, + "coldkey": _ACCOUNT_ID, + "netuid": netuid, + "stake": stake_rao, + "locked": 0, + "emission": 0, + "tao_emission": 0, + "drain": 0, + "is_registered": True, + } + + +def _make_neuron_decoded(emission_rao: int = 1_000_000_000) -> dict: + """Construct a minimal decoded dict for NeuronInfo.""" + return { + "hotkey": _ACCOUNT_ID, + "coldkey": _ACCOUNT_ID, + "uid": 0, + "netuid": 1, + "active": 1, + "stake": [(_ACCOUNT_ID, emission_rao)], + "rank": 0, + "emission": emission_rao, + "incentive": 0, + "consensus": 0, + "trust": 0, + "validator_trust": 0, + "dividends": 0, + "last_update": 0, + "validator_permit": False, + "weights": [(0, 65535)], + "bonds": [(0, 65535)], + "pruning_score": 0, + "axon_info": { + "version": 0, + "ip": 0, + "port": 0, + "ip_type": 4, + "placeholder1": 0, + "placeholder2": 0, + "protocol": 4, + }, + } + + +# --------------------------------------------------------------------------- +# DynamicInfo._fix_decoded +# --------------------------------------------------------------------------- + + +class TestDynamicInfoFixDecoded: + def test_normal_case_returns_dynamic_info(self): + """Standard decoding should return a DynamicInfo with correct fields.""" + decoded = _make_dynamic_decoded(netuid=1, alpha_in_rao=100 * TAO, tao_in_rao=50 * TAO) + info = DynamicInfo._fix_decoded(decoded) + assert isinstance(info, DynamicInfo) + assert info.netuid == 1 + assert info.is_dynamic is True + + def test_netuid_zero_is_not_dynamic(self): + """netuid=0 must set is_dynamic=False and price=1.0.""" + decoded = _make_dynamic_decoded(netuid=0, alpha_in_rao=100 * TAO, tao_in_rao=0) + info = DynamicInfo._fix_decoded(decoded) + assert info.is_dynamic is False + assert info.price.tao == pytest.approx(1.0) + + def test_alpha_in_zero_price_defaults_to_one(self): + """alpha_in=0 must not cause ZeroDivisionError; price must be 1.0.""" + decoded = _make_dynamic_decoded(netuid=1, alpha_in_rao=0, tao_in_rao=50 * TAO) + info = DynamicInfo._fix_decoded(decoded) + # The guard `if alpha_in.tao > 0 else Balance.from_tao(1)` must fire. + assert info.price.tao == pytest.approx(1.0) + + def test_price_calculation_correct(self): + """price = tao_in / alpha_in — verify the ratio is computed correctly.""" + tao_in = 50 * TAO + alpha_in = 100 * TAO + decoded = _make_dynamic_decoded(netuid=1, alpha_in_rao=alpha_in, tao_in_rao=tao_in) + info = DynamicInfo._fix_decoded(decoded) + expected = Balance.from_rao(tao_in).tao / Balance.from_rao(alpha_in).tao + assert info.price.tao == pytest.approx(expected, rel=1e-6) + + def test_k_is_tao_rao_times_alpha_rao(self): + """k = tao_in.rao * alpha_in.rao.""" + decoded = _make_dynamic_decoded(netuid=1, alpha_in_rao=100 * TAO, tao_in_rao=50 * TAO) + info = DynamicInfo._fix_decoded(decoded) + assert info.k == (50 * TAO) * (100 * TAO) + + def test_symbol_and_name_decoded_from_bytes(self): + """token_symbol and subnet_name are decoded from byte lists.""" + decoded = _make_dynamic_decoded(symbol=b"TEST", subnet_name=b"MySubnet") + info = DynamicInfo._fix_decoded(decoded) + assert info.symbol == "TEST" + assert info.subnet_name == "MySubnet" + + def test_subnet_identity_none_when_absent(self): + """subnet_identity field is None when not present in decoded dict.""" + decoded = _make_dynamic_decoded() + info = DynamicInfo._fix_decoded(decoded) + assert info.subnet_identity is None + + def test_balances_have_correct_units(self): + """alpha_in and alpha_out should be in subnet unit; tao_in in TAO unit.""" + decoded = _make_dynamic_decoded(netuid=3, alpha_in_rao=100 * TAO) + info = DynamicInfo._fix_decoded(decoded) + # alpha_in unit is set to netuid (3); tao_in unit is 0 (TAO) + assert info.alpha_in.unit == Balance.get_unit(3) + assert info.tao_in.unit == Balance.get_unit(0) + + +# --------------------------------------------------------------------------- +# DynamicInfo.tao_to_alpha / alpha_to_tao +# --------------------------------------------------------------------------- + + +class TestDynamicInfoConversions: + def _make_info(self, alpha_in_rao=100 * TAO, tao_in_rao=50 * TAO, netuid=1) -> DynamicInfo: + return DynamicInfo._fix_decoded( + _make_dynamic_decoded(netuid=netuid, alpha_in_rao=alpha_in_rao, tao_in_rao=tao_in_rao) + ) + + def test_tao_to_alpha_converts_at_price(self): + """tao_to_alpha should return tao / price alpha.""" + info = self._make_info(alpha_in_rao=100 * TAO, tao_in_rao=50 * TAO) + # price = 50/100 = 0.5 TAO/alpha → 1 TAO should give 2 alpha + result = info.tao_to_alpha(Balance.from_tao(1.0)) + assert result.tao == pytest.approx(2.0, rel=1e-6) + + def test_tao_to_alpha_zero_price_returns_zero(self): + """With price=0 guard, tao_to_alpha should return 0 without dividing by zero.""" + # Force price to 0 by direct construction (not via _fix_decoded, which guards it) + decoded = _make_dynamic_decoded(netuid=1, alpha_in_rao=100 * TAO, tao_in_rao=50 * TAO) + info = DynamicInfo._fix_decoded(decoded) + info.price = Balance.from_tao(0) + result = info.tao_to_alpha(Balance.from_tao(1.0)) + assert result.tao == 0.0 + + def test_alpha_to_tao_converts_at_price(self): + """alpha_to_tao should return alpha * price.""" + info = self._make_info(alpha_in_rao=100 * TAO, tao_in_rao=50 * TAO) + # price = 0.5 TAO/alpha → 10 alpha = 5 TAO + result = info.alpha_to_tao(Balance.from_tao(10.0)) + assert result.tao == pytest.approx(5.0, rel=1e-6) + + def test_netuid_zero_tao_to_alpha_is_identity(self): + """For netuid 0 (non-dynamic), price=1.0 so tao_to_alpha is a 1:1 conversion.""" + info = self._make_info(netuid=0, alpha_in_rao=0, tao_in_rao=0) + result = info.tao_to_alpha(Balance.from_tao(5.0)) + assert result.tao == pytest.approx(5.0, rel=1e-6) + + +# --------------------------------------------------------------------------- +# DynamicInfo.tao_to_alpha_with_slippage +# --------------------------------------------------------------------------- + + +class TestTaoToAlphaWithSlippage: + def _make_dynamic_info(self) -> DynamicInfo: + # tao_in=50, alpha_in=100 → price=0.5, k=5000*TAO² + return DynamicInfo._fix_decoded( + _make_dynamic_decoded(netuid=1, alpha_in_rao=100 * TAO, tao_in_rao=50 * TAO) + ) + + def test_returns_three_tuple(self): + info = self._make_dynamic_info() + result = info.tao_to_alpha_with_slippage(Balance.from_tao(1.0)) + assert len(result) == 3 + + def test_alpha_returned_is_positive(self): + info = self._make_dynamic_info() + alpha_returned, slippage, pct = info.tao_to_alpha_with_slippage(Balance.from_tao(1.0)) + assert alpha_returned.tao > 0 + + def test_slippage_is_non_negative(self): + info = self._make_dynamic_info() + alpha_returned, slippage, pct = info.tao_to_alpha_with_slippage(Balance.from_tao(1.0)) + assert slippage.tao >= 0 + + def test_slippage_pct_between_0_and_100(self): + info = self._make_dynamic_info() + _, _, pct = info.tao_to_alpha_with_slippage(Balance.from_tao(1.0)) + assert 0.0 <= pct <= 100.0 + + def test_small_amount_has_low_slippage(self): + """A tiny stake relative to pool size should produce near-zero slippage.""" + info = self._make_dynamic_info() + _, _, pct = info.tao_to_alpha_with_slippage(Balance.from_rao(1)) # 1 rao + assert pct < 0.01 + + def test_non_dynamic_subnet_no_slippage(self): + """For netuid=0 (non-dynamic), slippage must always be 0.""" + info = DynamicInfo._fix_decoded( + _make_dynamic_decoded(netuid=0, alpha_in_rao=0, tao_in_rao=0) + ) + alpha_returned, slippage, pct = info.tao_to_alpha_with_slippage(Balance.from_tao(5.0)) + assert slippage.tao == pytest.approx(0.0) + assert pct == pytest.approx(0.0) + + def test_large_stake_has_high_slippage(self): + """Staking more than the pool size should produce significant slippage.""" + info = self._make_dynamic_info() + # Stake 50x the pool size + _, _, pct = info.tao_to_alpha_with_slippage(Balance.from_tao(50 * 50.0)) + assert pct > 50.0 + + +# --------------------------------------------------------------------------- +# DynamicInfo.alpha_to_tao_with_slippage +# --------------------------------------------------------------------------- + + +class TestAlphaToTaoWithSlippage: + def _make_dynamic_info(self) -> DynamicInfo: + return DynamicInfo._fix_decoded( + _make_dynamic_decoded(netuid=1, alpha_in_rao=100 * TAO, tao_in_rao=50 * TAO) + ) + + def test_returns_three_tuple(self): + info = self._make_dynamic_info() + result = info.alpha_to_tao_with_slippage(Balance.from_tao(1.0)) + assert len(result) == 3 + + def test_tao_returned_is_positive(self): + info = self._make_dynamic_info() + tao_returned, slippage, pct = info.alpha_to_tao_with_slippage(Balance.from_tao(1.0)) + assert tao_returned.tao > 0 + + def test_slippage_pct_between_0_and_100(self): + info = self._make_dynamic_info() + _, _, pct = info.alpha_to_tao_with_slippage(Balance.from_tao(1.0)) + assert 0.0 <= pct <= 100.0 + + def test_non_dynamic_subnet_no_slippage(self): + """For netuid=0 (non-dynamic), slippage must be 0.""" + info = DynamicInfo._fix_decoded( + _make_dynamic_decoded(netuid=0, alpha_in_rao=0, tao_in_rao=0) + ) + tao_returned, slippage, pct = info.alpha_to_tao_with_slippage(Balance.from_tao(5.0)) + assert slippage.tao == pytest.approx(0.0) + assert pct == pytest.approx(0.0) + + def test_small_unstake_low_slippage(self): + """Tiny unstake relative to pool should have near-zero slippage.""" + info = self._make_dynamic_info() + _, _, pct = info.alpha_to_tao_with_slippage(Balance.from_rao(1)) + assert pct < 0.01 + + +# --------------------------------------------------------------------------- +# StakeInfo._fix_decoded +# --------------------------------------------------------------------------- + + +class TestStakeInfoFixDecoded: + def test_normal_decode(self): + """StakeInfo decodes correctly from a standard decoded dict.""" + decoded = _make_stake_decoded(netuid=1, stake_rao=10 * TAO) + info = StakeInfo._fix_decoded(decoded) + assert isinstance(info, StakeInfo) + assert info.netuid == 1 + assert info.stake.tao == pytest.approx(10.0) + assert info.is_registered is True + + def test_stake_unit_matches_netuid(self): + """stake Balance unit should be set to the netuid.""" + decoded = _make_stake_decoded(netuid=5, stake_rao=1 * TAO) + info = StakeInfo._fix_decoded(decoded) + assert info.stake.unit == Balance.get_unit(5) + + def test_tao_emission_unit_is_not_netuid_unit(self): + """tao_emission is constructed without .set_unit(), so it keeps the + default Balance unit (lowercase τ, chr(0x03C4)), NOT the netuid unit.""" + decoded = _make_stake_decoded(netuid=3) + info = StakeInfo._fix_decoded(decoded) + # Default unit is lowercase τ; the netuid 3 unit would be something else. + assert info.tao_emission.unit == chr(0x03C4) + assert info.tao_emission.unit != Balance.get_unit(3) + + def test_zero_stake(self): + """A stake of 0 rao should decode to a zero Balance.""" + decoded = _make_stake_decoded(stake_rao=0) + info = StakeInfo._fix_decoded(decoded) + assert info.stake.rao == 0 + + def test_list_from_any_decodes_multiple(self): + """list_from_any should decode a list of stake dicts.""" + decoded_list = [_make_stake_decoded(netuid=i, stake_rao=i * TAO) for i in range(3)] + infos = StakeInfo.list_from_any(decoded_list) + assert len(infos) == 3 + for i, info in enumerate(infos): + assert info.netuid == i + assert info.stake.tao == pytest.approx(float(i)) + + +# --------------------------------------------------------------------------- +# NeuronInfo._fix_decoded +# --------------------------------------------------------------------------- + + +class TestNeuronInfoFixDecoded: + def test_normal_decode(self): + """NeuronInfo decodes correctly from a standard decoded dict.""" + decoded = _make_neuron_decoded(emission_rao=TAO) + info = NeuronInfo._fix_decoded(decoded) + assert isinstance(info, NeuronInfo) + assert info.uid == 0 + assert info.netuid == 1 + assert info.is_null is False + + def test_emission_converted_from_rao_to_float(self): + """emission field should be rao/1e9 (i.e. in tao units).""" + decoded = _make_neuron_decoded(emission_rao=TAO) # 1 TAO + info = NeuronInfo._fix_decoded(decoded) + assert info.emission == pytest.approx(1.0) + + def test_weights_list_decoded(self): + """weights should be a list of [uid, weight] pairs.""" + decoded = _make_neuron_decoded() + info = NeuronInfo._fix_decoded(decoded) + assert isinstance(info.weights, list) + assert info.weights == [[0, 65535]] + + def test_bonds_list_decoded(self): + """bonds should be a list of [uid, value] pairs.""" + decoded = _make_neuron_decoded() + info = NeuronInfo._fix_decoded(decoded) + assert isinstance(info.bonds, list) + + def test_rank_normalized(self): + """rank must be u16-normalized float in [0, 1].""" + decoded = _make_neuron_decoded() + decoded["rank"] = 32767 # ~0.5 + info = NeuronInfo._fix_decoded(decoded) + assert 0.0 <= info.rank <= 1.0 + + def test_get_null_neuron(self): + """get_null_neuron() should return a NeuronInfo with is_null=True.""" + null = NeuronInfo.get_null_neuron() + assert null.is_null is True + assert null.uid == 0 + assert null.stake.rao == 0 + + def test_stake_dict_populated_from_stake_list(self): + """stake_dict should contain the decoded coldkey → Balance mapping.""" + decoded = _make_neuron_decoded(emission_rao=5 * TAO) + info = NeuronInfo._fix_decoded(decoded) + assert isinstance(info.stake_dict, dict) + # One entry from _ACCOUNT_ID → 5 TAO + assert len(info.stake_dict) == 1 From 253a354eeb2aefb551633db1588e60e69e9bd918 Mon Sep 17 00:00:00 2001 From: BD Himes Date: Thu, 2 Apr 2026 14:07:34 +0200 Subject: [PATCH 40/49] Ruff --- tests/unit_tests/conftest.py | 7 ++- tests/unit_tests/test_axon_commands.py | 16 +++++-- tests/unit_tests/test_batching.py | 1 - tests/unit_tests/test_chain_data.py | 48 ++++++++++++++----- tests/unit_tests/test_root_extrinsics.py | 16 ++----- tests/unit_tests/test_transfer_extrinsic.py | 52 +++++++++++++++------ tests/unit_tests/test_unstake_helpers.py | 16 ++++--- tests/unit_tests/test_utils_pure.py | 11 ++++- tests/unit_tests/test_wallet_create.py | 8 +++- 9 files changed, 122 insertions(+), 53 deletions(-) diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index f886654e4..3a380bb91 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -20,7 +20,9 @@ # Common SS58 addresses (valid Substrate SS58, format 42) # These replace per-file inline literals and per-file constant blocks. # --------------------------------------------------------------------------- -COLDKEY_SS58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" # signer / default coldkey +COLDKEY_SS58 = ( + "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" # signer / default coldkey +) HOTKEY_SS58 = "5CiQ1cV1MmMwsep7YP37QZKEgBgaVXeSPnETB5JBgwYRoXbP" # default hotkey PROXY_SS58 = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" # proxy account DEST_SS58 = "5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy" # transfer destination @@ -31,6 +33,7 @@ # Receipt helpers # --------------------------------------------------------------------------- + def _make_successful_receipt(identifier: str = "0x123-1") -> MagicMock: """ Build a mock substrate extrinsic receipt where ``await receipt.is_success`` @@ -95,6 +98,7 @@ def failed_receipt() -> MagicMock: # Wallet fixtures # --------------------------------------------------------------------------- + @pytest.fixture def mock_wallet() -> MagicMock: """ @@ -138,6 +142,7 @@ def mock_wallet_spec() -> MagicMock: # Subtensor fixture # --------------------------------------------------------------------------- + @pytest.fixture def mock_subtensor() -> MagicMock: """ diff --git a/tests/unit_tests/test_axon_commands.py b/tests/unit_tests/test_axon_commands.py index 447789029..cb66e8dfb 100644 --- a/tests/unit_tests/test_axon_commands.py +++ b/tests/unit_tests/test_axon_commands.py @@ -45,7 +45,9 @@ class TestResetAxonExtrinsic: """Tests for reset_axon_extrinsic function.""" @pytest.mark.asyncio - async def test_reset_axon_success(self, mock_subtensor, mock_wallet_spec, successful_receipt): + async def test_reset_axon_success( + self, mock_subtensor, mock_wallet_spec, successful_receipt + ): """Test successful axon reset.""" mock_subtensor.substrate.submit_extrinsic.return_value = successful_receipt @@ -129,7 +131,9 @@ async def test_reset_axon_user_cancellation(self, mock_subtensor, mock_wallet_sp assert ext_id is None @pytest.mark.asyncio - async def test_reset_axon_extrinsic_failure(self, mock_subtensor, mock_wallet_spec, failed_receipt): + async def test_reset_axon_extrinsic_failure( + self, mock_subtensor, mock_wallet_spec, failed_receipt + ): """Test axon reset when extrinsic submission fails.""" mock_subtensor.substrate.submit_extrinsic.return_value = failed_receipt @@ -154,7 +158,9 @@ class TestSetAxonExtrinsic: """Tests for set_axon_extrinsic function.""" @pytest.mark.asyncio - async def test_set_axon_success(self, mock_subtensor, mock_wallet_spec, successful_receipt): + async def test_set_axon_success( + self, mock_subtensor, mock_wallet_spec, successful_receipt + ): """Test successful axon set.""" mock_subtensor.substrate.submit_extrinsic.return_value = successful_receipt @@ -293,7 +299,9 @@ async def test_set_axon_user_cancellation(self, mock_subtensor, mock_wallet_spec assert "cancelled" in message.lower() @pytest.mark.asyncio - async def test_set_axon_with_ipv6(self, mock_subtensor, mock_wallet_spec, successful_receipt): + async def test_set_axon_with_ipv6( + self, mock_subtensor, mock_wallet_spec, successful_receipt + ): """Test axon set with IPv6 address.""" mock_subtensor.substrate.submit_extrinsic.return_value = successful_receipt diff --git a/tests/unit_tests/test_batching.py b/tests/unit_tests/test_batching.py index 2d6c9517b..ba65cff6a 100644 --- a/tests/unit_tests/test_batching.py +++ b/tests/unit_tests/test_batching.py @@ -12,7 +12,6 @@ def subtensor(): return st - @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.""" diff --git a/tests/unit_tests/test_chain_data.py b/tests/unit_tests/test_chain_data.py index 23f81066a..eac472e2a 100644 --- a/tests/unit_tests/test_chain_data.py +++ b/tests/unit_tests/test_chain_data.py @@ -124,7 +124,9 @@ def _make_neuron_decoded(emission_rao: int = 1_000_000_000) -> dict: class TestDynamicInfoFixDecoded: def test_normal_case_returns_dynamic_info(self): """Standard decoding should return a DynamicInfo with correct fields.""" - decoded = _make_dynamic_decoded(netuid=1, alpha_in_rao=100 * TAO, tao_in_rao=50 * TAO) + decoded = _make_dynamic_decoded( + netuid=1, alpha_in_rao=100 * TAO, tao_in_rao=50 * TAO + ) info = DynamicInfo._fix_decoded(decoded) assert isinstance(info, DynamicInfo) assert info.netuid == 1 @@ -148,14 +150,18 @@ def test_price_calculation_correct(self): """price = tao_in / alpha_in — verify the ratio is computed correctly.""" tao_in = 50 * TAO alpha_in = 100 * TAO - decoded = _make_dynamic_decoded(netuid=1, alpha_in_rao=alpha_in, tao_in_rao=tao_in) + decoded = _make_dynamic_decoded( + netuid=1, alpha_in_rao=alpha_in, tao_in_rao=tao_in + ) info = DynamicInfo._fix_decoded(decoded) expected = Balance.from_rao(tao_in).tao / Balance.from_rao(alpha_in).tao assert info.price.tao == pytest.approx(expected, rel=1e-6) def test_k_is_tao_rao_times_alpha_rao(self): """k = tao_in.rao * alpha_in.rao.""" - decoded = _make_dynamic_decoded(netuid=1, alpha_in_rao=100 * TAO, tao_in_rao=50 * TAO) + decoded = _make_dynamic_decoded( + netuid=1, alpha_in_rao=100 * TAO, tao_in_rao=50 * TAO + ) info = DynamicInfo._fix_decoded(decoded) assert info.k == (50 * TAO) * (100 * TAO) @@ -187,9 +193,13 @@ def test_balances_have_correct_units(self): class TestDynamicInfoConversions: - def _make_info(self, alpha_in_rao=100 * TAO, tao_in_rao=50 * TAO, netuid=1) -> DynamicInfo: + def _make_info( + self, alpha_in_rao=100 * TAO, tao_in_rao=50 * TAO, netuid=1 + ) -> DynamicInfo: return DynamicInfo._fix_decoded( - _make_dynamic_decoded(netuid=netuid, alpha_in_rao=alpha_in_rao, tao_in_rao=tao_in_rao) + _make_dynamic_decoded( + netuid=netuid, alpha_in_rao=alpha_in_rao, tao_in_rao=tao_in_rao + ) ) def test_tao_to_alpha_converts_at_price(self): @@ -202,7 +212,9 @@ def test_tao_to_alpha_converts_at_price(self): def test_tao_to_alpha_zero_price_returns_zero(self): """With price=0 guard, tao_to_alpha should return 0 without dividing by zero.""" # Force price to 0 by direct construction (not via _fix_decoded, which guards it) - decoded = _make_dynamic_decoded(netuid=1, alpha_in_rao=100 * TAO, tao_in_rao=50 * TAO) + decoded = _make_dynamic_decoded( + netuid=1, alpha_in_rao=100 * TAO, tao_in_rao=50 * TAO + ) info = DynamicInfo._fix_decoded(decoded) info.price = Balance.from_tao(0) result = info.tao_to_alpha(Balance.from_tao(1.0)) @@ -241,12 +253,16 @@ def test_returns_three_tuple(self): def test_alpha_returned_is_positive(self): info = self._make_dynamic_info() - alpha_returned, slippage, pct = info.tao_to_alpha_with_slippage(Balance.from_tao(1.0)) + alpha_returned, slippage, pct = info.tao_to_alpha_with_slippage( + Balance.from_tao(1.0) + ) assert alpha_returned.tao > 0 def test_slippage_is_non_negative(self): info = self._make_dynamic_info() - alpha_returned, slippage, pct = info.tao_to_alpha_with_slippage(Balance.from_tao(1.0)) + alpha_returned, slippage, pct = info.tao_to_alpha_with_slippage( + Balance.from_tao(1.0) + ) assert slippage.tao >= 0 def test_slippage_pct_between_0_and_100(self): @@ -265,7 +281,9 @@ def test_non_dynamic_subnet_no_slippage(self): info = DynamicInfo._fix_decoded( _make_dynamic_decoded(netuid=0, alpha_in_rao=0, tao_in_rao=0) ) - alpha_returned, slippage, pct = info.tao_to_alpha_with_slippage(Balance.from_tao(5.0)) + alpha_returned, slippage, pct = info.tao_to_alpha_with_slippage( + Balance.from_tao(5.0) + ) assert slippage.tao == pytest.approx(0.0) assert pct == pytest.approx(0.0) @@ -295,7 +313,9 @@ def test_returns_three_tuple(self): def test_tao_returned_is_positive(self): info = self._make_dynamic_info() - tao_returned, slippage, pct = info.alpha_to_tao_with_slippage(Balance.from_tao(1.0)) + tao_returned, slippage, pct = info.alpha_to_tao_with_slippage( + Balance.from_tao(1.0) + ) assert tao_returned.tao > 0 def test_slippage_pct_between_0_and_100(self): @@ -308,7 +328,9 @@ def test_non_dynamic_subnet_no_slippage(self): info = DynamicInfo._fix_decoded( _make_dynamic_decoded(netuid=0, alpha_in_rao=0, tao_in_rao=0) ) - tao_returned, slippage, pct = info.alpha_to_tao_with_slippage(Balance.from_tao(5.0)) + tao_returned, slippage, pct = info.alpha_to_tao_with_slippage( + Balance.from_tao(5.0) + ) assert slippage.tao == pytest.approx(0.0) assert pct == pytest.approx(0.0) @@ -357,7 +379,9 @@ def test_zero_stake(self): def test_list_from_any_decodes_multiple(self): """list_from_any should decode a list of stake dicts.""" - decoded_list = [_make_stake_decoded(netuid=i, stake_rao=i * TAO) for i in range(3)] + decoded_list = [ + _make_stake_decoded(netuid=i, stake_rao=i * TAO) for i in range(3) + ] infos = StakeInfo.list_from_any(decoded_list) assert len(infos) == 3 for i, info in enumerate(infos): diff --git a/tests/unit_tests/test_root_extrinsics.py b/tests/unit_tests/test_root_extrinsics.py index 432e3fb18..a93b899b5 100644 --- a/tests/unit_tests/test_root_extrinsics.py +++ b/tests/unit_tests/test_root_extrinsics.py @@ -170,17 +170,13 @@ def test_deterministic_same_inputs(self): assert hash1 == hash2 def test_different_salt_produces_different_hash(self): - base = dict( - address=_SS58, netuid=1, uids=[0], values=[U16_MAX], version_key=0 - ) + base = dict(address=_SS58, netuid=1, uids=[0], values=[U16_MAX], version_key=0) hash1 = generate_weight_hash(**base, salt=[1]) hash2 = generate_weight_hash(**base, salt=[2]) assert hash1 != hash2 def test_different_netuid_produces_different_hash(self): - base = dict( - address=_SS58, uids=[0], values=[U16_MAX], version_key=0, salt=[1] - ) + base = dict(address=_SS58, uids=[0], values=[U16_MAX], version_key=0, salt=[1]) hash1 = generate_weight_hash(**base, netuid=1) hash2 = generate_weight_hash(**base, netuid=2) assert hash1 != hash2 @@ -194,9 +190,7 @@ def test_different_netuid_produces_different_hash(self): class TestGetCurrentWeightsForUid: async def test_returns_weights_for_matching_uid(self, mock_subtensor): # Return [(uid, [(dest, raw_weight), ...])] - mock_subtensor.weights = AsyncMock( - return_value=[(5, [(0, 65535), (1, 32767)])] - ) + mock_subtensor.weights = AsyncMock(return_value=[(5, [(0, 65535), (1, 32767)])]) result = await get_current_weights_for_uid( subtensor=mock_subtensor, netuid=0, uid=5 ) @@ -206,9 +200,7 @@ async def test_returns_weights_for_matching_uid(self, mock_subtensor): assert 0.4 < result[1] < 0.6 async def test_returns_empty_for_nonmatching_uid(self, mock_subtensor): - mock_subtensor.weights = AsyncMock( - return_value=[(3, [(0, 65535)])] - ) + mock_subtensor.weights = AsyncMock(return_value=[(3, [(0, 65535)])]) result = await get_current_weights_for_uid( subtensor=mock_subtensor, netuid=0, uid=99 ) diff --git a/tests/unit_tests/test_transfer_extrinsic.py b/tests/unit_tests/test_transfer_extrinsic.py index 4af6cbe84..782e0aedc 100644 --- a/tests/unit_tests/test_transfer_extrinsic.py +++ b/tests/unit_tests/test_transfer_extrinsic.py @@ -19,12 +19,18 @@ MODULE = "bittensor_cli.src.bittensor.extrinsics.transfer" -def _setup_transfer(mock_subtensor, balance_tao=100, fee_tao=0.01, existential_tao=0.001): +def _setup_transfer( + mock_subtensor, balance_tao=100, fee_tao=0.01, existential_tao=0.001 +): """Configure mock_subtensor for a standard successful transfer scenario.""" mock_subtensor.get_balance = AsyncMock(return_value=Balance.from_tao(balance_tao)) - mock_subtensor.get_existential_deposit = AsyncMock(return_value=Balance.from_tao(existential_tao)) + mock_subtensor.get_existential_deposit = AsyncMock( + return_value=Balance.from_tao(existential_tao) + ) mock_subtensor.get_extrinsic_fee = AsyncMock(return_value=Balance.from_tao(fee_tao)) - mock_subtensor.sign_and_send_extrinsic = AsyncMock(return_value=(True, "", AsyncMock())) + mock_subtensor.sign_and_send_extrinsic = AsyncMock( + return_value=(True, "", AsyncMock()) + ) class TestTransferExtrinsicValidation: @@ -55,7 +61,9 @@ async def test_valid_destination_proceeds(self, mock_wallet, mock_subtensor): class TestTransferExtrinsicCallFunction: - async def test_transfer_all_uses_transfer_all_function(self, mock_wallet, mock_subtensor): + async def test_transfer_all_uses_transfer_all_function( + self, mock_wallet, mock_subtensor + ): """transfer_all=True must use 'transfer_all' call_function.""" _setup_transfer(mock_subtensor) with patch(f"{MODULE}.unlock_key", return_value=MagicMock(success=True)): @@ -72,7 +80,9 @@ async def test_transfer_all_uses_transfer_all_function(self, mock_wallet, mock_s call_functions = [c.kwargs.get("call_function") for c in calls] assert "transfer_all" in call_functions - async def test_allow_death_uses_transfer_allow_death(self, mock_wallet, mock_subtensor): + async def test_allow_death_uses_transfer_allow_death( + self, mock_wallet, mock_subtensor + ): """allow_death=True must use 'transfer_allow_death' call_function.""" _setup_transfer(mock_subtensor) with patch(f"{MODULE}.unlock_key", return_value=MagicMock(success=True)): @@ -103,7 +113,9 @@ async def test_default_uses_transfer_keep_alive(self, mock_wallet, mock_subtenso call_functions = [c.kwargs.get("call_function") for c in calls] assert "transfer_keep_alive" in call_functions - async def test_transfer_all_keep_alive_when_allow_death_false(self, mock_wallet, mock_subtensor): + async def test_transfer_all_keep_alive_when_allow_death_false( + self, mock_wallet, mock_subtensor + ): """transfer_all=True, allow_death=False → keep_alive param must be True.""" _setup_transfer(mock_subtensor) with patch(f"{MODULE}.unlock_key", return_value=MagicMock(success=True)): @@ -117,19 +129,27 @@ async def test_transfer_all_keep_alive_when_allow_death_false(self, mock_wallet, prompt=False, ) calls = mock_subtensor.substrate.compose_call.call_args_list - transfer_all_calls = [c for c in calls if c.kwargs.get("call_function") == "transfer_all"] + transfer_all_calls = [ + c for c in calls if c.kwargs.get("call_function") == "transfer_all" + ] assert len(transfer_all_calls) >= 1 params = transfer_all_calls[0].kwargs.get("call_params", {}) assert params.get("keep_alive") is True class TestTransferExtrinsicBalanceChecks: - async def test_insufficient_balance_no_proxy_returns_false(self, mock_wallet, mock_subtensor): + async def test_insufficient_balance_no_proxy_returns_false( + self, mock_wallet, mock_subtensor + ): """Insufficient balance without proxy should return (False, None).""" # Balance is only 0.1 tao, trying to transfer 10 tao mock_subtensor.get_balance = AsyncMock(return_value=Balance.from_tao(0.1)) - mock_subtensor.get_existential_deposit = AsyncMock(return_value=Balance.from_tao(0.001)) - mock_subtensor.get_extrinsic_fee = AsyncMock(return_value=Balance.from_tao(0.01)) + mock_subtensor.get_existential_deposit = AsyncMock( + return_value=Balance.from_tao(0.001) + ) + mock_subtensor.get_extrinsic_fee = AsyncMock( + return_value=Balance.from_tao(0.01) + ) result = await transfer_extrinsic( subtensor=mock_subtensor, @@ -157,7 +177,9 @@ async def test_unlock_failure_returns_false(self, mock_wallet, mock_subtensor): class TestTransferExtrinsicSuccess: - async def test_successful_transfer_returns_true_and_receipt(self, mock_wallet, mock_subtensor): + async def test_successful_transfer_returns_true_and_receipt( + self, mock_wallet, mock_subtensor + ): """Successful transfer should return (True, receipt).""" _setup_transfer(mock_subtensor) with patch(f"{MODULE}.unlock_key", return_value=MagicMock(success=True)): @@ -171,7 +193,9 @@ async def test_successful_transfer_returns_true_and_receipt(self, mock_wallet, m assert success is True assert receipt is not None - async def test_successful_transfer_calls_get_balance_twice(self, mock_wallet, mock_subtensor): + async def test_successful_transfer_calls_get_balance_twice( + self, mock_wallet, mock_subtensor + ): """After success, get_balance should be called again for balance display.""" _setup_transfer(mock_subtensor) with patch(f"{MODULE}.unlock_key", return_value=MagicMock(success=True)): @@ -187,7 +211,9 @@ async def test_successful_transfer_calls_get_balance_twice(self, mock_wallet, mo class TestTransferExtrinsicAnnounceOnly: - async def test_announce_only_passed_to_sign_and_send(self, mock_wallet, mock_subtensor): + async def test_announce_only_passed_to_sign_and_send( + self, mock_wallet, mock_subtensor + ): """announce_only=True should be forwarded to sign_and_send_extrinsic.""" _setup_transfer(mock_subtensor) with patch(f"{MODULE}.unlock_key", return_value=MagicMock(success=True)): diff --git a/tests/unit_tests/test_unstake_helpers.py b/tests/unit_tests/test_unstake_helpers.py index 4726ced6f..58d419798 100644 --- a/tests/unit_tests/test_unstake_helpers.py +++ b/tests/unit_tests/test_unstake_helpers.py @@ -101,7 +101,9 @@ def test_all_hotkeys_combines_wallet_and_chain_hotkeys(self, mock_wallet): stake_info_chain = SimpleNamespace(hotkey_ss58="5CHAIN_HOTKEY_ADDRESS") with ( - patch(f"{MODULE}.get_hotkey_wallets_for_wallet", return_value=[wallet_hotkey]), + patch( + f"{MODULE}.get_hotkey_wallets_for_wallet", return_value=[wallet_hotkey] + ), patch(f"{MODULE}.get_hotkey_pub_ss58", return_value=_HOTKEY_SS58), patch(f"{MODULE}.get_hotkey_identity", return_value="chain_hk"), ): @@ -126,7 +128,9 @@ def test_all_hotkeys_excludes_specified(self, mock_wallet): wallet_hotkey.hotkey_str = "to_exclude" with ( - patch(f"{MODULE}.get_hotkey_wallets_for_wallet", return_value=[wallet_hotkey]), + patch( + f"{MODULE}.get_hotkey_wallets_for_wallet", return_value=[wallet_hotkey] + ), patch(f"{MODULE}.get_hotkey_pub_ss58", return_value=_HOTKEY_SS58), ): result = _get_hotkeys_to_unstake( @@ -170,10 +174,10 @@ class TestGetHotkeyIdentity: def test_returns_identity_name_when_present(self): """If identities map has a name for the hotkey, return it.""" identities = {"hotkeys": {_HOTKEY_SS58: {"name": "MyValidator"}}} - with patch( - f"{MODULE}.get_hotkey_identity_name", return_value="MyValidator" - ): - result = get_hotkey_identity(hotkey_ss58=_HOTKEY_SS58, identities=identities) + with patch(f"{MODULE}.get_hotkey_identity_name", return_value="MyValidator"): + result = get_hotkey_identity( + hotkey_ss58=_HOTKEY_SS58, identities=identities + ) assert result == "MyValidator" def test_returns_truncated_address_when_no_identity(self): diff --git a/tests/unit_tests/test_utils_pure.py b/tests/unit_tests/test_utils_pure.py index b76fef6ae..8b8f31851 100644 --- a/tests/unit_tests/test_utils_pure.py +++ b/tests/unit_tests/test_utils_pure.py @@ -215,7 +215,10 @@ def test_too_short(self): def test_another_valid_address(self): # Another well-known valid SS58 address - assert is_valid_ss58_address("5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY") is True + assert ( + is_valid_ss58_address("5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY") + is True + ) # --------------------------------------------------------------------------- @@ -270,7 +273,11 @@ def test_garbage_string(self): class TestFormatErrorMessage: def test_dict_with_type_name_docs(self): - err = {"type": "ModuleError", "name": "StakeNotEnough", "docs": ["Stake too low"]} + err = { + "type": "ModuleError", + "name": "StakeNotEnough", + "docs": ["Stake too low"], + } result = format_error_message(err) assert "StakeNotEnough" in result diff --git a/tests/unit_tests/test_wallet_create.py b/tests/unit_tests/test_wallet_create.py index cf36c5b95..dd90b7bcf 100644 --- a/tests/unit_tests/test_wallet_create.py +++ b/tests/unit_tests/test_wallet_create.py @@ -150,7 +150,9 @@ async def test_no_hotkey_created_when_coldkey_fails(self, mock_wallet): """wallet_create must not attempt hotkey creation if coldkey creation fails.""" from bittensor_cli.src.commands.wallets import wallet_create - mock_wallet.create_new_coldkey = MagicMock(side_effect=KeyFileError("not writable")) + mock_wallet.create_new_coldkey = MagicMock( + side_effect=KeyFileError("not writable") + ) mock_wallet.create_new_hotkey = MagicMock() await wallet_create(wallet=mock_wallet, json_output=False) @@ -163,7 +165,9 @@ async def test_json_reports_failure_when_coldkey_fails(self, mock_wallet): """wallet_create JSON must report failure, not success, when coldkey fails.""" from bittensor_cli.src.commands.wallets import wallet_create - mock_wallet.create_new_coldkey = MagicMock(side_effect=KeyFileError("not writable")) + mock_wallet.create_new_coldkey = MagicMock( + side_effect=KeyFileError("not writable") + ) with patch(f"{MODULE}.json_console") as mock_json: await wallet_create(wallet=mock_wallet, json_output=True) From 6d71a6e599233e0a3db5fc6722aef8393ad495bf Mon Sep 17 00:00:00 2001 From: BD Himes Date: Thu, 2 Apr 2026 14:09:34 +0200 Subject: [PATCH 41/49] Update contributing guide --- contrib/CONTRIBUTING.MD | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contrib/CONTRIBUTING.MD b/contrib/CONTRIBUTING.MD index 7458a848c..fe1d98098 100644 --- a/contrib/CONTRIBUTING.MD +++ b/contrib/CONTRIBUTING.MD @@ -60,3 +60,8 @@ git config --global commit.gpgsign true For instructions on setting up GPG key signing, see [GitHub's documentation on signing commits](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits). > **Note:** Pull requests containing unsigned commits will not be merged. + +### Tests +Try to cover with unit/e2e tests any changes you're making. +Make strong use of the `conftest.py` file (e.g. do not recreate test fixtures unless they're not there). +If you need a test fixture that is not there, add it in a reusable way to `conftest.py`. From 372aff395e7d7824e511d14ffd8b48ce67397f93 Mon Sep 17 00:00:00 2001 From: BD Himes Date: Thu, 2 Apr 2026 15:48:55 +0200 Subject: [PATCH 42/49] Better interactivity in `st move` --- bittensor_cli/cli.py | 28 ++------ bittensor_cli/src/commands/stake/move.py | 81 +++++++++++++++--------- 2 files changed, 58 insertions(+), 51 deletions(-) diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index f231a1cfb..efbfc741b 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -5512,33 +5512,20 @@ def stake_move( console.print( "[dim]This command moves stake from one hotkey to another hotkey while keeping the same coldkey.[/dim]" ) + interactive_selection = False if not destination_hotkey: dest_wallet_or_ss58 = Prompt.ask( - "Enter the [blue]destination wallet[/blue] where destination hotkey is located or " - "[blue]ss58 address[/blue]" + "Enter the [blue]ss58 address[/blue] of the hotkey to stake to, leave blank for other options" ) if is_valid_ss58_address(dest_wallet_or_ss58): destination_hotkey = dest_wallet_or_ss58 + elif dest_wallet_or_ss58.strip() == "": + interactive_selection = True else: - dest_wallet = self.wallet_ask( - dest_wallet_or_ss58, - wallet_path, - None, - ask_for=[WO.NAME, WO.PATH], - validate=WV.WALLET, - ) - destination_hotkey = Prompt.ask( - "Enter the [blue]destination hotkey[/blue] name", - default=dest_wallet.hotkey_str, - ) - destination_wallet = self.wallet_ask( - dest_wallet_or_ss58, - wallet_path, - destination_hotkey, - ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], - validate=WV.WALLET_AND_HOTKEY, + print_error( + "Invalid destination hotkey ss58 address. Please enter a valid ss58 address." ) - destination_hotkey = get_hotkey_pub_ss58(destination_wallet) + raise typer.Exit() else: if is_valid_ss58_address(destination_hotkey): destination_hotkey = destination_hotkey @@ -5557,7 +5544,6 @@ def stake_move( wallet_name, wallet_path, wallet_hotkey, ask_for=[WO.NAME, WO.PATH] ) - interactive_selection = False if not wallet_hotkey: origin_hotkey = Prompt.ask( "Enter the [blue]origin hotkey[/blue] name or " diff --git a/bittensor_cli/src/commands/stake/move.py b/bittensor_cli/src/commands/stake/move.py index 0925ec987..3d748a661 100644 --- a/bittensor_cli/src/commands/stake/move.py +++ b/bittensor_cli/src/commands/stake/move.py @@ -15,6 +15,7 @@ confirm_action, console, create_table, + is_valid_ss58_address, print_error, group_subnets, get_subnet_name, @@ -323,6 +324,7 @@ def prompt_stake_amount( async def stake_move_transfer_selection( subtensor: "SubtensorInterface", wallet: Wallet, + destination_hotkey: Optional[str] = None, ): """Selection interface for moving stakes between hotkeys and subnets.""" stakes, ck_hk_identities = await asyncio.gather( @@ -342,42 +344,47 @@ async def stake_move_transfer_selection( print_error("You have no stakes to move.") raise ValueError - # Display hotkeys with stakes - table = create_table( - title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Hotkeys with Stakes\n", - ) - table.add_column("Index", justify="right") - table.add_column("Identity", style=COLOR_PALETTE["GENERAL"]["SUBHEADING"]) - table.add_column("Netuids", style=COLOR_PALETTE["GENERAL"]["NETUID"]) - table.add_column("Hotkey Address", style=COLOR_PALETTE["GENERAL"]["HOTKEY"]) - - hotkeys_info = [] - for idx, (hotkey_ss58, netuid_stakes) in enumerate(hotkey_stakes.items()): - hotkey_name = get_hotkey_identity_name(ck_hk_identities, hotkey_ss58) or "~" - hotkeys_info.append( - { - "index": idx, - "identity": hotkey_name, - "hotkey_ss58": hotkey_ss58, - "netuids": list(netuid_stakes.keys()), - "stakes": netuid_stakes, - } - ) - table.add_row( - str(idx), - hotkey_name, - group_subnets([n for n in netuid_stakes.keys()]), - hotkey_ss58, + def _display_hotkey_table() -> list[ + dict[str, int | str | list[str] | dict[str, dict[int, Balance]]] + ]: + # Display hotkeys with stakes + table_ = create_table( + title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Hotkeys with Stakes\n", ) + table_.add_column("Index", justify="right") + table_.add_column("Identity", style=COLOR_PALETTE["GENERAL"]["SUBHEADING"]) + table_.add_column("Netuids", style=COLOR_PALETTE["GENERAL"]["NETUID"]) + table_.add_column("Hotkey Address", style=COLOR_PALETTE["GENERAL"]["HOTKEY"]) + + hotkeys_info = [] + for idx, (hotkey_ss58, netuid_stakes) in enumerate(hotkey_stakes.items()): + hotkey_name = get_hotkey_identity_name(ck_hk_identities, hotkey_ss58) or "~" + hotkeys_info.append( + { + "index": idx, + "identity": hotkey_name, + "hotkey_ss58": hotkey_ss58, + "netuids": list(netuid_stakes.keys()), + "stakes": netuid_stakes, + } + ) + table_.add_row( + str(idx), + hotkey_name, + group_subnets([n for n in netuid_stakes.keys()]), + hotkey_ss58, + ) - console.print("\n", table) + console.print("\n", table_) + return hotkeys_info + hks_info = _display_hotkey_table() # Select origin hotkey origin_idx = Prompt.ask( "\nEnter the index of the hotkey you want to move stake from", - choices=[str(i) for i in range(len(hotkeys_info))], + choices=[str(i) for i in range(len(hks_info))], ) - origin_hotkey_info = hotkeys_info[int(origin_idx)] + origin_hotkey_info = hks_info[int(origin_idx)] origin_hotkey_ss58 = origin_hotkey_info["hotkey_ss58"] # Display available netuids for selected hotkey @@ -409,6 +416,16 @@ async def stake_move_transfer_selection( origin_netuid = int(origin_netuid) origin_stake = origin_hotkey_info["stakes"][origin_netuid] + if not destination_hotkey: + hks_info = _display_hotkey_table() + # Select destination hotkey + dest_idx = Prompt.ask( + "\nEnter the index of the hotkey you want to move stake to", + choices=[str(i) for i in range(len(hks_info))], + ) + dest_hotkey_info = hks_info[int(dest_idx)] + destination_hotkey = dest_hotkey_info["hotkey_ss58"] + # Ask for amount to move amount, stake_all = prompt_stake_amount(origin_stake, origin_netuid, "move") @@ -426,6 +443,7 @@ async def stake_move_transfer_selection( "amount": amount.tao, "stake_all": stake_all, "destination_netuid": int(destination_netuid), + "destination_hotkey": destination_hotkey, } @@ -543,7 +561,9 @@ async def move_stake( coldkey_ss58 = proxy or wallet.coldkeypub.ss58_address if interactive_selection: try: - selection = await stake_move_transfer_selection(subtensor, wallet) + selection = await stake_move_transfer_selection( + subtensor, wallet, destination_hotkey + ) except ValueError: return False, "" origin_hotkey = selection["origin_hotkey"] @@ -551,6 +571,7 @@ async def move_stake( amount = selection["amount"] stake_all = selection["stake_all"] destination_netuid = selection["destination_netuid"] + destination_hotkey = selection["destination_hotkey"] # Get the wallet stake balances. block_hash = await subtensor.substrate.get_chain_head() From 197aedfbeddb7d0516e90c653411a81955410117 Mon Sep 17 00:00:00 2001 From: BD Himes Date: Thu, 2 Apr 2026 15:52:02 +0200 Subject: [PATCH 43/49] Adds tests --- tests/unit_tests/test_stake_move.py | 289 ++++++++++++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 tests/unit_tests/test_stake_move.py diff --git a/tests/unit_tests/test_stake_move.py b/tests/unit_tests/test_stake_move.py new file mode 100644 index 000000000..6b026eaeb --- /dev/null +++ b/tests/unit_tests/test_stake_move.py @@ -0,0 +1,289 @@ +""" +Unit tests for stake/move.py changes: + + - stake_move_transfer_selection: destination_hotkey pre-filled skips dest prompt + - stake_move_transfer_selection: destination_hotkey=None triggers dest table prompt + - stake_move_transfer_selection: no stakes raises ValueError + - move_stake: interactive_selection=True propagates destination_hotkey to selection call + - move_stake: interactive_selection=True + ValueError from selection returns (False, "") +""" + +import pytest +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch, call + +from bittensor_cli.src.bittensor.balances import Balance +from bittensor_cli.src.commands.stake.move import ( + stake_move_transfer_selection, + move_stake, +) + +MODULE = "bittensor_cli.src.commands.stake.move" + +HOTKEY_A = "5CiQ1cV1MmMwsep7YP37QZKEgBgaVXeSPnETB5JBgwYRoXbP" +HOTKEY_B = "5HGjWAeFDfFCWPsjFQdVV2Msvz2XtMktvgocEZcCj68kUMaw" +COLDKEY = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + + +def _make_stake(hotkey_ss58: str, netuid: int, tao: float): + s = MagicMock() + s.hotkey_ss58 = hotkey_ss58 + s.netuid = netuid + s.stake = Balance.from_tao(tao) + return s + + +@pytest.fixture +def wallet(): + w = MagicMock() + w.coldkeypub.ss58_address = COLDKEY + return w + + +@pytest.fixture +def subtensor(): + st = MagicMock() + st.get_stake_for_coldkey = AsyncMock( + return_value=[ + _make_stake(HOTKEY_A, 1, 10.0), + _make_stake(HOTKEY_B, 2, 5.0), + ] + ) + st.fetch_coldkey_hotkey_identities = AsyncMock( + return_value={"hotkeys": {}, "coldkeys": {}} + ) + st.get_all_subnet_netuids = AsyncMock(return_value=[1, 2]) + return st + + +# --------------------------------------------------------------------------- +# stake_move_transfer_selection +# --------------------------------------------------------------------------- + + +class TestStakeMoveTransferSelection: + @pytest.mark.asyncio + async def test_no_stakes_raises_value_error(self, wallet, subtensor): + """With no positive stakes, should raise ValueError.""" + subtensor.get_stake_for_coldkey = AsyncMock(return_value=[]) + with pytest.raises(ValueError): + await stake_move_transfer_selection(subtensor, wallet) + + @pytest.mark.asyncio + async def test_destination_hotkey_provided_skips_dest_prompt( + self, wallet, subtensor + ): + """When destination_hotkey is pre-filled, the dest table/prompt is skipped + and the provided value is returned as-is.""" + prompt_responses = [ + "0", # origin hotkey index + "1", # origin netuid + "10", # amount + "2", # destination netuid + ] + with ( + patch(f"{MODULE}.Prompt.ask", side_effect=prompt_responses), + patch(f"{MODULE}.console"), + patch(f"{MODULE}.prompt_stake_amount", return_value=(Balance.from_tao(10), False)), + ): + result = await stake_move_transfer_selection( + subtensor, wallet, destination_hotkey=HOTKEY_B + ) + + assert result["destination_hotkey"] == HOTKEY_B + + @pytest.mark.asyncio + async def test_destination_hotkey_none_prompts_for_dest( + self, wallet, subtensor + ): + """When destination_hotkey is None, the user is prompted to pick one + from the table and the selected hotkey is returned.""" + prompt_responses = [ + "0", # origin hotkey index + "1", # origin netuid + "1", # destination hotkey index (HOTKEY_B is index 1) + "2", # destination netuid + ] + with ( + patch(f"{MODULE}.Prompt.ask", side_effect=prompt_responses), + patch(f"{MODULE}.console"), + patch(f"{MODULE}.prompt_stake_amount", return_value=(Balance.from_tao(5), False)), + ): + result = await stake_move_transfer_selection(subtensor, wallet) + + assert result["destination_hotkey"] == HOTKEY_B + + @pytest.mark.asyncio + async def test_selection_returns_all_expected_keys(self, wallet, subtensor): + """Return dict must contain all required keys.""" + prompt_responses = ["0", "1", "2"] + with ( + patch(f"{MODULE}.Prompt.ask", side_effect=prompt_responses), + patch(f"{MODULE}.console"), + patch( + f"{MODULE}.prompt_stake_amount", + return_value=(Balance.from_tao(10), False), + ), + ): + result = await stake_move_transfer_selection( + subtensor, wallet, destination_hotkey=HOTKEY_B + ) + + assert set(result.keys()) == { + "origin_hotkey", + "origin_netuid", + "amount", + "stake_all", + "destination_netuid", + "destination_hotkey", + } + + @pytest.mark.asyncio + async def test_origin_hotkey_and_netuid_set_correctly(self, wallet, subtensor): + """origin_hotkey and origin_netuid in the result match user selection.""" + prompt_responses = [ + "0", # origin hotkey index → HOTKEY_A + "1", # origin netuid + "2", # destination netuid + ] + with ( + patch(f"{MODULE}.Prompt.ask", side_effect=prompt_responses), + patch(f"{MODULE}.console"), + patch( + f"{MODULE}.prompt_stake_amount", + return_value=(Balance.from_tao(10), False), + ), + ): + result = await stake_move_transfer_selection( + subtensor, wallet, destination_hotkey=HOTKEY_B + ) + + assert result["origin_hotkey"] == HOTKEY_A + assert result["origin_netuid"] == 1 + + +# --------------------------------------------------------------------------- +# move_stake — interactive_selection path +# --------------------------------------------------------------------------- + + +class TestMoveStakeInteractiveSelection: + def _make_subtensor(self): + st = MagicMock() + st.substrate = MagicMock() + st.substrate.get_chain_head = AsyncMock(return_value="0xabc") + st.get_stake = AsyncMock(return_value=Balance.from_tao(0)) + return st + + def _make_wallet(self): + w = MagicMock() + w.coldkeypub.ss58_address = COLDKEY + return w + + @pytest.mark.asyncio + async def test_interactive_passes_destination_hotkey_to_selection(self): + """move_stake passes the existing destination_hotkey into + stake_move_transfer_selection so it is not re-prompted.""" + selection = { + "origin_hotkey": HOTKEY_A, + "origin_netuid": 1, + "amount": 5.0, + "stake_all": False, + "destination_netuid": 2, + "destination_hotkey": HOTKEY_B, + } + with patch( + f"{MODULE}.stake_move_transfer_selection", new_callable=AsyncMock, + return_value=selection, + ) as mock_sel: + # Patch the rest of move_stake so it doesn't try to do chain calls + with patch(f"{MODULE}.get_movement_pricing", new_callable=AsyncMock): + await move_stake( + subtensor=self._make_subtensor(), + wallet=self._make_wallet(), + origin_netuid=None, + origin_hotkey=None, + destination_netuid=None, + destination_hotkey=HOTKEY_B, + amount=None, + stake_all=False, + era=16, + interactive_selection=True, + prompt=False, + decline=False, + ) + + mock_sel.assert_awaited_once() + _, kwargs = mock_sel.call_args + # Third positional arg (or keyword) is destination_hotkey + args = mock_sel.call_args[0] + assert args[2] == HOTKEY_B + + @pytest.mark.asyncio + async def test_interactive_value_error_returns_false(self): + """If stake_move_transfer_selection raises ValueError, + move_stake returns (False, '').""" + with patch( + f"{MODULE}.stake_move_transfer_selection", + new_callable=AsyncMock, + side_effect=ValueError, + ): + result = await move_stake( + subtensor=self._make_subtensor(), + wallet=self._make_wallet(), + origin_netuid=None, + origin_hotkey=None, + destination_netuid=None, + destination_hotkey=None, + amount=None, + stake_all=False, + era=16, + interactive_selection=True, + prompt=False, + decline=False, + ) + + assert result == (False, "") + + @pytest.mark.asyncio + async def test_interactive_selection_values_used_in_stake(self): + """Values from the selection dict are used downstream (not the original + None args passed in).""" + selection = { + "origin_hotkey": HOTKEY_A, + "origin_netuid": 1, + "amount": 7.0, + "stake_all": False, + "destination_netuid": 2, + "destination_hotkey": HOTKEY_B, + } + subtensor = self._make_subtensor() + + with ( + patch( + f"{MODULE}.stake_move_transfer_selection", + new_callable=AsyncMock, + return_value=selection, + ), + patch(f"{MODULE}.get_movement_pricing", new_callable=AsyncMock), + ): + await move_stake( + subtensor=subtensor, + wallet=self._make_wallet(), + origin_netuid=None, + origin_hotkey=None, + destination_netuid=None, + destination_hotkey=None, + amount=None, + stake_all=False, + era=16, + interactive_selection=True, + prompt=False, + decline=False, + ) + + # get_stake should be called with the hotkeys from the selection + stake_calls = subtensor.get_stake.call_args_list + hotkeys_queried = {c.kwargs.get("hotkey_ss58") for c in stake_calls} + assert HOTKEY_A in hotkeys_queried + assert HOTKEY_B in hotkeys_queried From 33f5532d5ff8bc713b726d462a75e556d5fd6269 Mon Sep 17 00:00:00 2001 From: BD Himes Date: Thu, 2 Apr 2026 15:54:33 +0200 Subject: [PATCH 44/49] Ruff --- tests/unit_tests/test_stake_move.py | 39 ++++++++++++++++------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/tests/unit_tests/test_stake_move.py b/tests/unit_tests/test_stake_move.py index 6b026eaeb..7a4a618c3 100644 --- a/tests/unit_tests/test_stake_move.py +++ b/tests/unit_tests/test_stake_move.py @@ -22,7 +22,7 @@ HOTKEY_A = "5CiQ1cV1MmMwsep7YP37QZKEgBgaVXeSPnETB5JBgwYRoXbP" HOTKEY_B = "5HGjWAeFDfFCWPsjFQdVV2Msvz2XtMktvgocEZcCj68kUMaw" -COLDKEY = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" +COLDKEY = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" def _make_stake(hotkey_ss58: str, netuid: int, tao: float): @@ -76,15 +76,18 @@ async def test_destination_hotkey_provided_skips_dest_prompt( """When destination_hotkey is pre-filled, the dest table/prompt is skipped and the provided value is returned as-is.""" prompt_responses = [ - "0", # origin hotkey index - "1", # origin netuid + "0", # origin hotkey index + "1", # origin netuid "10", # amount - "2", # destination netuid + "2", # destination netuid ] with ( patch(f"{MODULE}.Prompt.ask", side_effect=prompt_responses), patch(f"{MODULE}.console"), - patch(f"{MODULE}.prompt_stake_amount", return_value=(Balance.from_tao(10), False)), + patch( + f"{MODULE}.prompt_stake_amount", + return_value=(Balance.from_tao(10), False), + ), ): result = await stake_move_transfer_selection( subtensor, wallet, destination_hotkey=HOTKEY_B @@ -93,21 +96,22 @@ async def test_destination_hotkey_provided_skips_dest_prompt( assert result["destination_hotkey"] == HOTKEY_B @pytest.mark.asyncio - async def test_destination_hotkey_none_prompts_for_dest( - self, wallet, subtensor - ): + async def test_destination_hotkey_none_prompts_for_dest(self, wallet, subtensor): """When destination_hotkey is None, the user is prompted to pick one from the table and the selected hotkey is returned.""" prompt_responses = [ - "0", # origin hotkey index - "1", # origin netuid - "1", # destination hotkey index (HOTKEY_B is index 1) - "2", # destination netuid + "0", # origin hotkey index + "1", # origin netuid + "1", # destination hotkey index (HOTKEY_B is index 1) + "2", # destination netuid ] with ( patch(f"{MODULE}.Prompt.ask", side_effect=prompt_responses), patch(f"{MODULE}.console"), - patch(f"{MODULE}.prompt_stake_amount", return_value=(Balance.from_tao(5), False)), + patch( + f"{MODULE}.prompt_stake_amount", + return_value=(Balance.from_tao(5), False), + ), ): result = await stake_move_transfer_selection(subtensor, wallet) @@ -142,9 +146,9 @@ async def test_selection_returns_all_expected_keys(self, wallet, subtensor): async def test_origin_hotkey_and_netuid_set_correctly(self, wallet, subtensor): """origin_hotkey and origin_netuid in the result match user selection.""" prompt_responses = [ - "0", # origin hotkey index → HOTKEY_A - "1", # origin netuid - "2", # destination netuid + "0", # origin hotkey index → HOTKEY_A + "1", # origin netuid + "2", # destination netuid ] with ( patch(f"{MODULE}.Prompt.ask", side_effect=prompt_responses), @@ -193,7 +197,8 @@ async def test_interactive_passes_destination_hotkey_to_selection(self): "destination_hotkey": HOTKEY_B, } with patch( - f"{MODULE}.stake_move_transfer_selection", new_callable=AsyncMock, + f"{MODULE}.stake_move_transfer_selection", + new_callable=AsyncMock, return_value=selection, ) as mock_sel: # Patch the rest of move_stake so it doesn't try to do chain calls From fde30edf53fa14310c12d26e0b1e6c22c3b7b14c Mon Sep 17 00:00:00 2001 From: BD Himes Date: Thu, 2 Apr 2026 16:06:42 +0200 Subject: [PATCH 45/49] Updates tests --- bittensor_cli/src/commands/stake/move.py | 10 +- tests/unit_tests/test_stake_move.py | 212 ++++++++++++----------- 2 files changed, 116 insertions(+), 106 deletions(-) diff --git a/bittensor_cli/src/commands/stake/move.py b/bittensor_cli/src/commands/stake/move.py index 3d748a661..f618641f7 100644 --- a/bittensor_cli/src/commands/stake/move.py +++ b/bittensor_cli/src/commands/stake/move.py @@ -544,11 +544,11 @@ async def stake_swap_selection( async def move_stake( subtensor: "SubtensorInterface", wallet: Wallet, - origin_netuid: int, - origin_hotkey: str, - destination_netuid: int, - destination_hotkey: str, - amount: float, + origin_netuid: Optional[int], + origin_hotkey: Optional[str], + destination_netuid: Optional[int], + destination_hotkey: Optional[str], + amount: Optional[float], stake_all: bool, era: int, interactive_selection: bool = False, diff --git a/tests/unit_tests/test_stake_move.py b/tests/unit_tests/test_stake_move.py index 7a4a618c3..bb1257943 100644 --- a/tests/unit_tests/test_stake_move.py +++ b/tests/unit_tests/test_stake_move.py @@ -9,20 +9,27 @@ """ import pytest -from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch, call +from unittest.mock import AsyncMock, MagicMock, patch from bittensor_cli.src.bittensor.balances import Balance from bittensor_cli.src.commands.stake.move import ( stake_move_transfer_selection, move_stake, ) +from tests.unit_tests.conftest import HOTKEY_SS58, ALT_HOTKEY_SS58 MODULE = "bittensor_cli.src.commands.stake.move" -HOTKEY_A = "5CiQ1cV1MmMwsep7YP37QZKEgBgaVXeSPnETB5JBgwYRoXbP" -HOTKEY_B = "5HGjWAeFDfFCWPsjFQdVV2Msvz2XtMktvgocEZcCj68kUMaw" -COLDKEY = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + +def _make_receipt(): + async def _is_success(): + return True + + r = MagicMock() + r.is_success = _is_success() + r.substrate = None + r.get_extrinsic_identifier = AsyncMock(return_value="0x123") + return r def _make_stake(hotkey_ss58: str, netuid: int, tao: float): @@ -33,29 +40,6 @@ def _make_stake(hotkey_ss58: str, netuid: int, tao: float): return s -@pytest.fixture -def wallet(): - w = MagicMock() - w.coldkeypub.ss58_address = COLDKEY - return w - - -@pytest.fixture -def subtensor(): - st = MagicMock() - st.get_stake_for_coldkey = AsyncMock( - return_value=[ - _make_stake(HOTKEY_A, 1, 10.0), - _make_stake(HOTKEY_B, 2, 5.0), - ] - ) - st.fetch_coldkey_hotkey_identities = AsyncMock( - return_value={"hotkeys": {}, "coldkeys": {}} - ) - st.get_all_subnet_netuids = AsyncMock(return_value=[1, 2]) - return st - - # --------------------------------------------------------------------------- # stake_move_transfer_selection # --------------------------------------------------------------------------- @@ -63,22 +47,27 @@ def subtensor(): class TestStakeMoveTransferSelection: @pytest.mark.asyncio - async def test_no_stakes_raises_value_error(self, wallet, subtensor): + async def test_no_stakes_raises_value_error(self, mock_wallet, mock_subtensor): """With no positive stakes, should raise ValueError.""" - subtensor.get_stake_for_coldkey = AsyncMock(return_value=[]) + mock_subtensor.get_stake_for_coldkey = AsyncMock(return_value=[]) with pytest.raises(ValueError): - await stake_move_transfer_selection(subtensor, wallet) + await stake_move_transfer_selection(mock_subtensor, mock_wallet) @pytest.mark.asyncio async def test_destination_hotkey_provided_skips_dest_prompt( - self, wallet, subtensor + self, mock_wallet, mock_subtensor ): """When destination_hotkey is pre-filled, the dest table/prompt is skipped and the provided value is returned as-is.""" + mock_subtensor.get_stake_for_coldkey = AsyncMock( + return_value=[ + _make_stake(HOTKEY_SS58, 1, 10.0), + _make_stake(ALT_HOTKEY_SS58, 2, 5.0), + ] + ) prompt_responses = [ "0", # origin hotkey index "1", # origin netuid - "10", # amount "2", # destination netuid ] with ( @@ -90,19 +79,27 @@ async def test_destination_hotkey_provided_skips_dest_prompt( ), ): result = await stake_move_transfer_selection( - subtensor, wallet, destination_hotkey=HOTKEY_B + mock_subtensor, mock_wallet, destination_hotkey=ALT_HOTKEY_SS58 ) - assert result["destination_hotkey"] == HOTKEY_B + assert result["destination_hotkey"] == ALT_HOTKEY_SS58 @pytest.mark.asyncio - async def test_destination_hotkey_none_prompts_for_dest(self, wallet, subtensor): + async def test_destination_hotkey_none_prompts_for_dest( + self, mock_wallet, mock_subtensor + ): """When destination_hotkey is None, the user is prompted to pick one from the table and the selected hotkey is returned.""" + mock_subtensor.get_stake_for_coldkey = AsyncMock( + return_value=[ + _make_stake(HOTKEY_SS58, 1, 10.0), + _make_stake(ALT_HOTKEY_SS58, 2, 5.0), + ] + ) prompt_responses = [ "0", # origin hotkey index "1", # origin netuid - "1", # destination hotkey index (HOTKEY_B is index 1) + "1", # destination hotkey index → ALT_HOTKEY_SS58 "2", # destination netuid ] with ( @@ -113,16 +110,20 @@ async def test_destination_hotkey_none_prompts_for_dest(self, wallet, subtensor) return_value=(Balance.from_tao(5), False), ), ): - result = await stake_move_transfer_selection(subtensor, wallet) + result = await stake_move_transfer_selection(mock_subtensor, mock_wallet) - assert result["destination_hotkey"] == HOTKEY_B + assert result["destination_hotkey"] == ALT_HOTKEY_SS58 @pytest.mark.asyncio - async def test_selection_returns_all_expected_keys(self, wallet, subtensor): + async def test_selection_returns_all_expected_keys( + self, mock_wallet, mock_subtensor + ): """Return dict must contain all required keys.""" - prompt_responses = ["0", "1", "2"] + mock_subtensor.get_stake_for_coldkey = AsyncMock( + return_value=[_make_stake(HOTKEY_SS58, 1, 10.0)] + ) with ( - patch(f"{MODULE}.Prompt.ask", side_effect=prompt_responses), + patch(f"{MODULE}.Prompt.ask", side_effect=["0", "1", "1"]), patch(f"{MODULE}.console"), patch( f"{MODULE}.prompt_stake_amount", @@ -130,7 +131,7 @@ async def test_selection_returns_all_expected_keys(self, wallet, subtensor): ), ): result = await stake_move_transfer_selection( - subtensor, wallet, destination_hotkey=HOTKEY_B + mock_subtensor, mock_wallet, destination_hotkey=ALT_HOTKEY_SS58 ) assert set(result.keys()) == { @@ -143,10 +144,18 @@ async def test_selection_returns_all_expected_keys(self, wallet, subtensor): } @pytest.mark.asyncio - async def test_origin_hotkey_and_netuid_set_correctly(self, wallet, subtensor): + async def test_origin_hotkey_and_netuid_set_correctly( + self, mock_wallet, mock_subtensor + ): """origin_hotkey and origin_netuid in the result match user selection.""" + mock_subtensor.get_stake_for_coldkey = AsyncMock( + return_value=[ + _make_stake(HOTKEY_SS58, 1, 10.0), + _make_stake(ALT_HOTKEY_SS58, 2, 5.0), + ] + ) prompt_responses = [ - "0", # origin hotkey index → HOTKEY_A + "0", # origin hotkey index → HOTKEY_SS58 "1", # origin netuid "2", # destination netuid ] @@ -159,10 +168,10 @@ async def test_origin_hotkey_and_netuid_set_correctly(self, wallet, subtensor): ), ): result = await stake_move_transfer_selection( - subtensor, wallet, destination_hotkey=HOTKEY_B + mock_subtensor, mock_wallet, destination_hotkey=ALT_HOTKEY_SS58 ) - assert result["origin_hotkey"] == HOTKEY_A + assert result["origin_hotkey"] == HOTKEY_SS58 assert result["origin_netuid"] == 1 @@ -172,60 +181,56 @@ async def test_origin_hotkey_and_netuid_set_correctly(self, wallet, subtensor): class TestMoveStakeInteractiveSelection: - def _make_subtensor(self): - st = MagicMock() - st.substrate = MagicMock() - st.substrate.get_chain_head = AsyncMock(return_value="0xabc") - st.get_stake = AsyncMock(return_value=Balance.from_tao(0)) - return st - - def _make_wallet(self): - w = MagicMock() - w.coldkeypub.ss58_address = COLDKEY - return w - @pytest.mark.asyncio - async def test_interactive_passes_destination_hotkey_to_selection(self): + async def test_interactive_passes_destination_hotkey_to_selection( + self, mock_wallet, mock_subtensor + ): """move_stake passes the existing destination_hotkey into stake_move_transfer_selection so it is not re-prompted.""" selection = { - "origin_hotkey": HOTKEY_A, + "origin_hotkey": HOTKEY_SS58, "origin_netuid": 1, "amount": 5.0, "stake_all": False, "destination_netuid": 2, - "destination_hotkey": HOTKEY_B, + "destination_hotkey": ALT_HOTKEY_SS58, } - with patch( - f"{MODULE}.stake_move_transfer_selection", - new_callable=AsyncMock, - return_value=selection, - ) as mock_sel: - # Patch the rest of move_stake so it doesn't try to do chain calls - with patch(f"{MODULE}.get_movement_pricing", new_callable=AsyncMock): - await move_stake( - subtensor=self._make_subtensor(), - wallet=self._make_wallet(), - origin_netuid=None, - origin_hotkey=None, - destination_netuid=None, - destination_hotkey=HOTKEY_B, - amount=None, - stake_all=False, - era=16, - interactive_selection=True, - prompt=False, - decline=False, - ) + with ( + patch( + f"{MODULE}.stake_move_transfer_selection", + new_callable=AsyncMock, + return_value=selection, + ) as mock_sel, + patch(f"{MODULE}.get_movement_pricing", new_callable=AsyncMock), + patch(f"{MODULE}.unlock_key", return_value=MagicMock(success=True)), + patch( + f"{MODULE}.wait_for_extrinsic_by_hash", + new_callable=AsyncMock, + return_value=(True, None, _make_receipt()), + ), + ): + await move_stake( + subtensor=mock_subtensor, + wallet=mock_wallet, + origin_netuid=None, + origin_hotkey=None, + destination_netuid=None, + destination_hotkey=ALT_HOTKEY_SS58, + amount=None, + stake_all=False, + era=16, + interactive_selection=True, + prompt=False, + decline=False, + ) mock_sel.assert_awaited_once() - _, kwargs = mock_sel.call_args - # Third positional arg (or keyword) is destination_hotkey - args = mock_sel.call_args[0] - assert args[2] == HOTKEY_B + assert mock_sel.call_args[0][2] == ALT_HOTKEY_SS58 @pytest.mark.asyncio - async def test_interactive_value_error_returns_false(self): + async def test_interactive_value_error_returns_false( + self, mock_wallet, mock_subtensor + ): """If stake_move_transfer_selection raises ValueError, move_stake returns (False, '').""" with patch( @@ -234,8 +239,8 @@ async def test_interactive_value_error_returns_false(self): side_effect=ValueError, ): result = await move_stake( - subtensor=self._make_subtensor(), - wallet=self._make_wallet(), + subtensor=mock_subtensor, + wallet=mock_wallet, origin_netuid=None, origin_hotkey=None, destination_netuid=None, @@ -251,19 +256,19 @@ async def test_interactive_value_error_returns_false(self): assert result == (False, "") @pytest.mark.asyncio - async def test_interactive_selection_values_used_in_stake(self): + async def test_interactive_selection_values_used_in_stake( + self, mock_wallet, mock_subtensor + ): """Values from the selection dict are used downstream (not the original None args passed in).""" selection = { - "origin_hotkey": HOTKEY_A, + "origin_hotkey": HOTKEY_SS58, "origin_netuid": 1, "amount": 7.0, "stake_all": False, "destination_netuid": 2, - "destination_hotkey": HOTKEY_B, + "destination_hotkey": ALT_HOTKEY_SS58, } - subtensor = self._make_subtensor() - with ( patch( f"{MODULE}.stake_move_transfer_selection", @@ -271,10 +276,16 @@ async def test_interactive_selection_values_used_in_stake(self): return_value=selection, ), patch(f"{MODULE}.get_movement_pricing", new_callable=AsyncMock), + patch(f"{MODULE}.unlock_key", return_value=MagicMock(success=True)), + patch( + f"{MODULE}.wait_for_extrinsic_by_hash", + new_callable=AsyncMock, + return_value=(True, None, _make_receipt()), + ), ): await move_stake( - subtensor=subtensor, - wallet=self._make_wallet(), + subtensor=mock_subtensor, + wallet=mock_wallet, origin_netuid=None, origin_hotkey=None, destination_netuid=None, @@ -287,8 +298,7 @@ async def test_interactive_selection_values_used_in_stake(self): decline=False, ) - # get_stake should be called with the hotkeys from the selection - stake_calls = subtensor.get_stake.call_args_list + stake_calls = mock_subtensor.get_stake.call_args_list hotkeys_queried = {c.kwargs.get("hotkey_ss58") for c in stake_calls} - assert HOTKEY_A in hotkeys_queried - assert HOTKEY_B in hotkeys_queried + assert HOTKEY_SS58 in hotkeys_queried + assert ALT_HOTKEY_SS58 in hotkeys_queried From 9644a28a22fd789be450d23b8d13d64b04ca1599 Mon Sep 17 00:00:00 2001 From: BD Himes Date: Thu, 2 Apr 2026 16:16:13 +0200 Subject: [PATCH 46/49] More reuse --- .../test_proxy_address_resolution.py | 9 +++--- tests/unit_tests/test_root_extrinsics.py | 4 +-- tests/unit_tests/test_stake_add.py | 30 ++++--------------- tests/unit_tests/test_transfer_extrinsic.py | 3 +- tests/unit_tests/test_unstake_helpers.py | 8 ++--- tests/unit_tests/test_utils_pure.py | 4 +-- 6 files changed, 17 insertions(+), 41 deletions(-) diff --git a/tests/unit_tests/test_proxy_address_resolution.py b/tests/unit_tests/test_proxy_address_resolution.py index 96143b6ae..5ca7e97a2 100644 --- a/tests/unit_tests/test_proxy_address_resolution.py +++ b/tests/unit_tests/test_proxy_address_resolution.py @@ -9,10 +9,11 @@ from unittest.mock import AsyncMock, MagicMock, patch from bittensor_cli.src.bittensor.balances import Balance - -PROXY_SS58 = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" -HOTKEY_SS58 = "5CiQ1cV1MmMwsep7YP37QZKEgBgaVXeSPnETB5JBgwYRoXbP" -DEST_HOTKEY_SS58 = "5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy" +from tests.unit_tests.conftest import ( + PROXY_SS58, + HOTKEY_SS58, + DEST_SS58 as DEST_HOTKEY_SS58, +) @contextmanager diff --git a/tests/unit_tests/test_root_extrinsics.py b/tests/unit_tests/test_root_extrinsics.py index a93b899b5..d7d789320 100644 --- a/tests/unit_tests/test_root_extrinsics.py +++ b/tests/unit_tests/test_root_extrinsics.py @@ -17,9 +17,7 @@ get_current_weights_for_uid, get_limits, ) - -# A valid SS58 address for generate_weight_hash (needs real Keypair lookup) -_SS58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" +from tests.unit_tests.conftest import COLDKEY_SS58 as _SS58 U16_MAX = 65535 diff --git a/tests/unit_tests/test_stake_add.py b/tests/unit_tests/test_stake_add.py index ce59ba552..5537a1fa9 100644 --- a/tests/unit_tests/test_stake_add.py +++ b/tests/unit_tests/test_stake_add.py @@ -6,8 +6,7 @@ from bittensor_cli.src.bittensor.balances import Balance from bittensor_cli.src.commands.stake.add import stake_add - -TEST_SS58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" +from tests.unit_tests.conftest import COLDKEY_SS58 as TEST_SS58 class MockSubnetInfo: @@ -17,17 +16,7 @@ def __init__(self, netuid: int, price_tao: float): self.is_dynamic = netuid != 0 -@pytest.fixture -def mock_wallet(): - return SimpleNamespace( - coldkeypub=SimpleNamespace(ss58_address=TEST_SS58), - path="/tmp", - name="test_wallet", - ) - - -@pytest.fixture -def mock_subtensor(): +def _sim_swap_side_effect(): async def sim_swap_mock(origin_netuid, destination_netuid, amount): del origin_netuid, amount return SimpleNamespace( @@ -35,18 +24,7 @@ async def sim_swap_mock(origin_netuid, destination_netuid, amount): tao_fee=Balance.from_tao(0.1), ) - return SimpleNamespace( - substrate=SimpleNamespace( - get_chain_head=AsyncMock(return_value="0xabc"), - compose_call=AsyncMock(return_value=SimpleNamespace()), - ), - network="test", - all_subnets=AsyncMock(return_value=[]), - get_stake_for_coldkey=AsyncMock(return_value=[]), - get_balance=AsyncMock(return_value=Balance.from_tao(100)), - get_extrinsic_fee=AsyncMock(return_value=Balance.from_tao(0.01)), - sim_swap=AsyncMock(side_effect=sim_swap_mock), - ) + return AsyncMock(side_effect=sim_swap_mock) @pytest.mark.asyncio @@ -56,6 +34,7 @@ async def test_stake_add_zero_price_does_not_raise( mock_subtensor, safe_staking, ): + mock_subtensor.sim_swap = _sim_swap_side_effect() mock_subtensor.all_subnets.return_value = [MockSubnetInfo(netuid=427, price_tao=0)] with patch( @@ -94,6 +73,7 @@ async def test_stake_add_mixed_prices_including_zero_does_not_raise( mock_wallet, mock_subtensor, ): + mock_subtensor.sim_swap = _sim_swap_side_effect() mock_subtensor.all_subnets.return_value = [ MockSubnetInfo(netuid=427, price_tao=0), MockSubnetInfo(netuid=1, price_tao=2.0), diff --git a/tests/unit_tests/test_transfer_extrinsic.py b/tests/unit_tests/test_transfer_extrinsic.py index 782e0aedc..182e4c99a 100644 --- a/tests/unit_tests/test_transfer_extrinsic.py +++ b/tests/unit_tests/test_transfer_extrinsic.py @@ -10,9 +10,8 @@ from bittensor_cli.src.bittensor.balances import Balance from bittensor_cli.src.bittensor.extrinsics.transfer import transfer_extrinsic +from tests.unit_tests.conftest import DEST_SS58 as _DEST_SS58 -# A valid destination SS58 address -_DEST_SS58 = "5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy" # An invalid destination _INVALID_DEST = "not_a_valid_address" diff --git a/tests/unit_tests/test_unstake_helpers.py b/tests/unit_tests/test_unstake_helpers.py index 58d419798..81f0fc5f2 100644 --- a/tests/unit_tests/test_unstake_helpers.py +++ b/tests/unit_tests/test_unstake_helpers.py @@ -22,13 +22,13 @@ get_hotkey_identity, ) from bittensor_cli.src.bittensor.balances import Balance +from tests.unit_tests.conftest import ( + PROXY_SS58 as _HOTKEY_SS58, + COLDKEY_SS58 as _COLDKEY_SS58, +) MODULE = "bittensor_cli.src.commands.stake.remove" -# Known-valid SS58 addresses -_HOTKEY_SS58 = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" -_COLDKEY_SS58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" - # --------------------------------------------------------------------------- # _get_hotkeys_to_unstake diff --git a/tests/unit_tests/test_utils_pure.py b/tests/unit_tests/test_utils_pure.py index 8b8f31851..63279c019 100644 --- a/tests/unit_tests/test_utils_pure.py +++ b/tests/unit_tests/test_utils_pure.py @@ -24,9 +24,7 @@ format_error_message, validate_netuid, ) - -# A known-valid SS58 address (format 42, Substrate default) -_VALID_SS58 = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" +from tests.unit_tests.conftest import COLDKEY_SS58 as _VALID_SS58 # --------------------------------------------------------------------------- From 6cfcb4e31d52ba16db8d59faa8fc69858848ec85 Mon Sep 17 00:00:00 2001 From: BD Himes <37844818+thewhaleking@users.noreply.github.com> Date: Thu, 2 Apr 2026 18:16:16 +0200 Subject: [PATCH 47/49] Update bittensor_cli/cli.py Co-authored-by: Ibraheem <165814940+ibraheem-abe@users.noreply.github.com> --- bittensor_cli/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index efbfc741b..ab92d1753 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -5515,7 +5515,7 @@ def stake_move( interactive_selection = False if not destination_hotkey: dest_wallet_or_ss58 = Prompt.ask( - "Enter the [blue]ss58 address[/blue] of the hotkey to stake to, leave blank for other options" + "Enter the [blue]ss58 address[/blue] of the hotkey to move the stake to, leave blank for other options" ) if is_valid_ss58_address(dest_wallet_or_ss58): destination_hotkey = dest_wallet_or_ss58 From 6a7a939374b6828c6faa2d899ba5b4dbe7ebd9f5 Mon Sep 17 00:00:00 2001 From: bitloi <89318445+bitloi@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:19:24 -0300 Subject: [PATCH 48/49] fix proxy allow-death transfer balance check (#891) Co-authored-by: Ibraheem <165814940+ibraheem-abe@users.noreply.github.com> Co-authored-by: BD Himes <37844818+thewhaleking@users.noreply.github.com> --- .../src/bittensor/extrinsics/transfer.py | 4 +- tests/unit_tests/test_transfer_extrinsic.py | 83 +++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/bittensor_cli/src/bittensor/extrinsics/transfer.py b/bittensor_cli/src/bittensor/extrinsics/transfer.py index fb714bfd2..8875e5596 100644 --- a/bittensor_cli/src/bittensor/extrinsics/transfer.py +++ b/bittensor_cli/src/bittensor/extrinsics/transfer.py @@ -156,10 +156,10 @@ async def do_transfer() -> tuple[bool, str, str, Optional[AsyncExtrinsicReceipt] f" would bring you under the existential deposit: [bright_cyan]{existential_deposit}[/bright_cyan].\n" ) return False, None - if account_balance < amount and allow_death: + if proxy_balance < amount and allow_death: print_error( "[bold red]Not enough balance[/bold red]:\n\n" - f" balance: [bright_red]{account_balance}[/bright_red]\n" + f" balance: [bright_red]{proxy_balance}[/bright_red]\n" f" amount: [bright_red]{amount}[/bright_red]\n" ) return False, None diff --git a/tests/unit_tests/test_transfer_extrinsic.py b/tests/unit_tests/test_transfer_extrinsic.py index 182e4c99a..93c09fdf4 100644 --- a/tests/unit_tests/test_transfer_extrinsic.py +++ b/tests/unit_tests/test_transfer_extrinsic.py @@ -11,6 +11,7 @@ from bittensor_cli.src.bittensor.balances import Balance from bittensor_cli.src.bittensor.extrinsics.transfer import transfer_extrinsic from tests.unit_tests.conftest import DEST_SS58 as _DEST_SS58 +from tests.unit_tests.conftest import PROXY_SS58 as _PROXY_SS58 # An invalid destination _INVALID_DEST = "not_a_valid_address" @@ -159,6 +160,88 @@ async def test_insufficient_balance_no_proxy_returns_false( ) assert result == (False, None) + async def test_proxy_allow_death_uses_proxy_balance_for_amount_check( + self, mock_wallet, mock_subtensor + ): + """ + With proxy + allow_death, transfer amount should be validated against + proxy balance (not signer balance). + """ + proxy_balance = Balance.from_tao(1000) + signer_balance = Balance.from_tao(1) + new_proxy_balance = Balance.from_tao(900) + amount = Balance.from_tao(100) + + mock_subtensor.get_balance = AsyncMock( + side_effect=[proxy_balance, signer_balance, new_proxy_balance] + ) + mock_subtensor.get_existential_deposit = AsyncMock( + return_value=Balance.from_tao(1) + ) + mock_subtensor.get_extrinsic_fee = AsyncMock(return_value=Balance.from_tao(0.1)) + mock_subtensor.sign_and_send_extrinsic = AsyncMock( + return_value=(True, "", AsyncMock()) + ) + + with ( + patch(f"{MODULE}.unlock_key", return_value=MagicMock(success=True)), + patch(f"{MODULE}.print_error") as mock_error, + ): + success, receipt = await transfer_extrinsic( + subtensor=mock_subtensor, + wallet=mock_wallet, + destination=_DEST_SS58, + amount=amount, + allow_death=True, + proxy=_PROXY_SS58, + prompt=False, + wait_for_inclusion=False, + wait_for_finalization=False, + ) + + assert success is True + assert receipt is not None + mock_error.assert_not_called() + mock_subtensor.sign_and_send_extrinsic.assert_awaited_once() + + async def test_proxy_allow_death_insufficient_proxy_balance_returns_false( + self, mock_wallet, mock_subtensor + ): + """With proxy + allow_death, low proxy balance should fail the transfer.""" + proxy_balance = Balance.from_tao(50) + signer_balance = Balance.from_tao(10) + amount = Balance.from_tao(100) + + mock_subtensor.get_balance = AsyncMock( + side_effect=[proxy_balance, signer_balance] + ) + mock_subtensor.get_existential_deposit = AsyncMock( + return_value=Balance.from_tao(1) + ) + mock_subtensor.get_extrinsic_fee = AsyncMock(return_value=Balance.from_tao(0.1)) + + with ( + patch(f"{MODULE}.unlock_key", return_value=MagicMock(success=True)), + patch(f"{MODULE}.print_error") as mock_error, + ): + success, receipt = await transfer_extrinsic( + subtensor=mock_subtensor, + wallet=mock_wallet, + destination=_DEST_SS58, + amount=amount, + allow_death=True, + proxy=_PROXY_SS58, + prompt=False, + wait_for_inclusion=False, + wait_for_finalization=False, + ) + + assert success is False + assert receipt is None + mock_subtensor.sign_and_send_extrinsic.assert_not_awaited() + mock_error.assert_called_once() + assert str(proxy_balance) in mock_error.call_args.args[0] + class TestTransferExtrinsicUnlockKey: async def test_unlock_failure_returns_false(self, mock_wallet, mock_subtensor): From c187c26fe505a37dc9d576c763a40a2fbd1ce495 Mon Sep 17 00:00:00 2001 From: BD Himes Date: Thu, 2 Apr 2026 18:54:37 +0200 Subject: [PATCH 49/49] Changelog + version --- CHANGELOG.md | 22 ++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb641cf5c..a9c726e2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## 9.20.1 /2026-04-02 + +## What's Changed +* Fully_exhaust=True for by @thewhaleking in https://github.com/latent-to/btcli/pull/876 +* Fix/speed issue with network calls by @ibraheem-abe in https://github.com/latent-to/btcli/pull/877 +* Update/deprecate old identities by @ibraheem-abe in https://github.com/latent-to/btcli/pull/878 +* ASI version <2.0 by @thewhaleking in https://github.com/latent-to/btcli/pull/880 +* fix: resolve proxy address for stake queries in move/transfer/swap and sudo trim by @bitloi in https://github.com/latent-to/btcli/pull/881 +* Bumps workflow versions, uses permission in release by @thewhaleking in https://github.com/latent-to/btcli/pull/882 +* inspect hotkey support by @thewhaleking in https://github.com/latent-to/btcli/pull/883 +* unstake: format error message incorrect by @thewhaleking in https://github.com/latent-to/btcli/pull/884 +* fix: error handling in wallet_create() and new_coldkey() by @bitloi in https://github.com/latent-to/btcli/pull/888 +* fix: prevent division by zero in stake add when subnet price is zero by @bitloi in https://github.com/latent-to/btcli/pull/886 +* Improved test coverage by @thewhaleking in https://github.com/latent-to/btcli/pull/889 +* fix: proxy allow-death transfer balance check by @bitloi in https://github.com/latent-to/btcli/pull/891 +* Better st move interactivity by @thewhaleking in https://github.com/latent-to/btcli/pull/892 + +## New Contributors +* @bitloi made their first contribution in https://github.com/latent-to/btcli/pull/881 + +**Full Changelog**: https://github.com/latent-to/btcli/compare/v9.20.0...v9.20.1 + ## 9.20.0 /2026-03-24 ## What's Changed diff --git a/pyproject.toml b/pyproject.toml index df85cab2a..cf15c659d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "flit_core.buildapi" [project] name = "bittensor-cli" -version = "9.20.0" +version = "9.20.1" description = "Bittensor CLI" readme = "README.md" authors = [