From 78d049a0ba1b07036329f643965ed30e7fcbf167 Mon Sep 17 00:00:00 2001 From: Dera Okeke Date: Mon, 22 Dec 2025 21:44:11 +0100 Subject: [PATCH 01/33] updated proxy help text --- bittensor_cli/cli.py | 48 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index ae7d04709..88af300e1 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -2006,6 +2006,9 @@ def config_add_proxy( ): """ Adds a new pure proxy to the address book. + + [bold]Example:[/bold] + [green]$[/green] btcli config add-proxy """ if self.proxies.get(name) is not None: err_console.print( @@ -2052,6 +2055,9 @@ def config_remove_proxy( Removes a pure proxy from the address book. Note: Does not remove the proxy on chain. Only removes it from the address book. + + [bold]Example:[/bold] + [green]$[/green] btcli config remove-proxy --name test-proxy """ if name in self.proxies: del self.proxies[name] @@ -2065,6 +2071,9 @@ def config_remove_proxy( def config_get_proxies(self): """ Displays the current proxies address book + + [bold]Example:[/bold] + [green]$[/green] btcli config proxies """ table = Table( Column("[bold white]Name", style=f"{COLORS.G.ARG}"), @@ -2113,6 +2122,14 @@ def config_update_proxy( delay: Optional[int] = typer.Option(None, help="Delay, in blocks."), note: Optional[str] = typer.Option(None, help="Any notes about this entry"), ): + """ + Updates the details of a proxy in the address book. + + Note: This command not update the proxy on chain. It only updates it on the address book. + + [bold]Example:[/bold] + [green]$[/green] btcli config update-proxy --name test-proxy + """ if name not in self.proxies: err_console.print(f"Proxy {name} not found in address book.") return @@ -9364,16 +9381,11 @@ def proxy_remove( Revokes proxy permissions previously granted to another account. This prevents the delegate account from executing any further transactions on your behalf. - [bold]Note[/bold]: You can specify a delegate to remove a single proxy or use the `--all` flag to remove all existing proxies linked to an account. - - [bold]Common Examples:[/bold] - 1. Revoke proxy permissions from a single proxy account + [bold]Example:[/bold] + Revoke proxy permissions from a single proxy account [green]$[/green] btcli proxy remove --delegate 5GDel... --proxy-type Transfer - 2. Remove all proxies linked to an account - [green]$[/green] btcli proxy remove --all - """ # TODO should add a --all flag to call Proxy.remove_proxies ? logger.debug( @@ -9532,7 +9544,25 @@ def proxy_execute_announced( verbose: bool = Options.verbose, json_output: bool = Options.json_output, ): - self.verbosity_handler(quiet, verbose, json_output, prompt, decline) + """ + Executes a previously announced proxy call. + + This command submits the inner call on-chain using the proxy relationship. The command will fail if the required delay has not passed or if the call does not match the announcement parameters. + + If you do not provide the call hash or call hex of the announced call in the command, you would be prompted to enter details of the call including the module name and call function. + + [bold]Note[/bold]: Using the `--call-hash` flag attempts to resolve the call from the proxy announcement address book. Use this flag only if the announcement was created through BTCLI. + If the announcement was created by any other method, you must provide the call using `--call-hex` or rebuild the call explicitly via the command prompts. + + [bold]Common Examples:[/bold] + 1. Using the call hash + [green]$[/green] btcli proxy execute --call-hash caf4da69610d379c2e2e5...0cbc6b012f6cff6340c45a1 + + 2. Using the call hex + [green]$[/green] btcli proxy execute --call-hex 0x0503008f0667364ff11915b0b2a54387...27948e8f950f79a69cff9c029cdb69 + + """ + self.verbosity_handler(quiet, verbose, json_output, prompt) outer_proxy_from_config = self.proxies.get(proxy, {}) proxy_from_config = outer_proxy_from_config.get("address") delay = 0 @@ -9644,7 +9674,7 @@ def proxy_execute_announced( else: console.print( f"The call hash you have provided matches {len(potential_call_matches)}" - f" possible entries. The results will be iterated for you to selected your intended" + f" possible entries. The results will be iterated for you to select your intended " f"call." ) for row in potential_call_matches: From 095a935ce04a757c34ca47ef1bb16329210617e6 Mon Sep 17 00:00:00 2001 From: Pegasus <42954461+leonace924@users.noreply.github.com> Date: Mon, 22 Dec 2025 20:45:04 -0500 Subject: [PATCH 02/33] Standardize Success Message Printing with print.success (#786) * feat: add the print_success util * feat: refactor with print_success * fix: update with print_success and print_error * ruff format * fix: revert cli * fix: update the util for print_success * fix: update the print_error and revert print_success for remove.py file * hotfix: add the missing character * fix: change to white_check_mark * fix: revert to white_heavy_check_mark * fix: revert print_success for wallet and few consol from utils * ruff format * fix: revert in the e2e util * ruff format --- .../src/bittensor/extrinsics/registration.py | 19 ++++----- .../src/bittensor/extrinsics/root.py | 11 ++--- .../src/bittensor/extrinsics/serving.py | 17 ++++---- .../src/bittensor/extrinsics/transfer.py | 6 +-- bittensor_cli/src/bittensor/utils.py | 11 +++++ .../src/commands/crowd/contribute.py | 31 +++++++------- bittensor_cli/src/commands/crowd/create.py | 41 +++++++++---------- bittensor_cli/src/commands/proxy.py | 11 ++--- bittensor_cli/src/commands/stake/add.py | 9 ++-- .../src/commands/stake/auto_staking.py | 5 ++- .../src/commands/stake/children_hotkeys.py | 21 ++++------ bittensor_cli/src/commands/stake/claim.py | 3 +- bittensor_cli/src/commands/stake/move.py | 11 +++-- bittensor_cli/src/commands/stake/remove.py | 5 ++- .../src/commands/subnets/mechanisms.py | 11 +++-- bittensor_cli/src/commands/subnets/subnets.py | 15 +++---- bittensor_cli/src/commands/sudo.py | 17 +++----- bittensor_cli/src/commands/wallets.py | 5 +-- bittensor_cli/src/commands/weights.py | 11 ++--- 19 files changed, 122 insertions(+), 138 deletions(-) diff --git a/bittensor_cli/src/bittensor/extrinsics/registration.py b/bittensor_cli/src/bittensor/extrinsics/registration.py index c10929301..d082f58c6 100644 --- a/bittensor_cli/src/bittensor/extrinsics/registration.py +++ b/bittensor_cli/src/bittensor/extrinsics/registration.py @@ -33,6 +33,7 @@ confirm_action, console, print_error, + print_success, format_error_message, millify, get_human_readable, @@ -587,8 +588,8 @@ async def get_neuron_for_pubkey_and_subnet(): subtensor, netuid=netuid, hotkey_ss58=get_hotkey_pub_ss58(wallet) ) if is_registered: - print_error( - f":white_heavy_check_mark: [dark_sea_green3]Already registered on netuid:{netuid}[/dark_sea_green3]" + print_success( + f"[dark_sea_green3]Already registered on netuid:{netuid}[/dark_sea_green3]" ) return True @@ -630,8 +631,8 @@ async def get_neuron_for_pubkey_and_subnet(): # https://github.com/opentensor/subtensor/blob/development/pallets/subtensor/src/errors.rs if "HotKeyAlreadyRegisteredInSubNet" in err_msg: - console.print( - f":white_heavy_check_mark: [dark_sea_green3]Already Registered on " + print_success( + f"[dark_sea_green3]Already Registered on " f"[bold]subnet:{netuid}[/bold][/dark_sea_green3]" ) return True @@ -647,8 +648,8 @@ async def get_neuron_for_pubkey_and_subnet(): hotkey_ss58=get_hotkey_pub_ss58(wallet), ) if is_registered: - console.print( - ":white_heavy_check_mark: [dark_sea_green3]Registered[/dark_sea_green3]" + print_success( + "[dark_sea_green3]Registered[/dark_sea_green3]" ) return True else: @@ -738,8 +739,8 @@ async def burned_register_extrinsic( era_ = {"period": era} if not neuron.is_null: + print_success("[dark_sea_green3]Already Registered[/dark_sea_green3]:") console.print( - ":white_heavy_check_mark: [dark_sea_green3]Already Registered[/dark_sea_green3]:\n" f"uid: [{COLOR_PALETTE.G.NETUID_EXTRA}]{neuron.uid}[/{COLOR_PALETTE.G.NETUID_EXTRA}]\n" f"netuid: [{COLOR_PALETTE.G.NETUID}]{neuron.netuid}[/{COLOR_PALETTE.G.NETUID}]\n" f"hotkey: [{COLOR_PALETTE.G.HK}]{neuron.hotkey}[/{COLOR_PALETTE.G.HK}]\n" @@ -798,9 +799,7 @@ async def burned_register_extrinsic( ) if len(netuids_for_hotkey) > 0: - console.print( - f":white_heavy_check_mark: [green]Registered on netuid {netuid} with UID {my_uid}[/green]" - ) + print_success(f"Registered on netuid {netuid} with UID {my_uid}") return True, f"Registered on {netuid} with UID {my_uid}", ext_id else: # neuron not found, try again diff --git a/bittensor_cli/src/bittensor/extrinsics/root.py b/bittensor_cli/src/bittensor/extrinsics/root.py index 2c346a47d..1bcdccf6b 100644 --- a/bittensor_cli/src/bittensor/extrinsics/root.py +++ b/bittensor_cli/src/bittensor/extrinsics/root.py @@ -32,6 +32,7 @@ confirm_action, console, print_error, + print_success, u16_normalized_float, print_verbose, format_error_message, @@ -343,9 +344,7 @@ async def root_register_extrinsic( subtensor, netuid=0, hotkey_ss58=get_hotkey_pub_ss58(wallet) ) if is_registered: - console.print( - ":white_heavy_check_mark: [green]Already registered on root network.[/green]" - ) + print_success("Already registered on root network.") return True, "Already registered on root network", None with console.status(":satellite: Registering to root network...", spinner="earth"): @@ -377,9 +376,7 @@ async def root_register_extrinsic( params=[0, get_hotkey_pub_ss58(wallet)], ) if uid is not None: - console.print( - f":white_heavy_check_mark: [green]Registered with UID {uid}[/green]" - ) + print_success(f"Registered with UID {uid}") return True, f"Registered with UID {uid}", ext_id else: # neuron not found, try again @@ -540,7 +537,7 @@ async def _do_set_weights(): return True if success is True: - console.print(":white_heavy_check_mark: [green]Finalized[/green]") + print_success("Finalized") return True else: fmt_err = format_error_message(error_message) diff --git a/bittensor_cli/src/bittensor/extrinsics/serving.py b/bittensor_cli/src/bittensor/extrinsics/serving.py index 14945d46c..3ace41062 100644 --- a/bittensor_cli/src/bittensor/extrinsics/serving.py +++ b/bittensor_cli/src/bittensor/extrinsics/serving.py @@ -11,6 +11,7 @@ confirm_action, console, print_error, + print_success, format_error_message, unlock_key, print_extrinsic_id, @@ -112,8 +113,8 @@ async def reset_axon_extrinsic( # We only wait here if we expect finalization. if not wait_for_finalization and not wait_for_inclusion: - console.print( - ":white_heavy_check_mark: [dark_sea_green3]Axon reset successfully[/dark_sea_green3]" + print_success( + "[dark_sea_green3]Axon reset successfully[/dark_sea_green3]" ) return True, "Not waiting for finalization or inclusion.", None @@ -125,8 +126,8 @@ async def reset_axon_extrinsic( else: ext_id = await response.get_extrinsic_identifier() await print_extrinsic_id(response) - console.print( - ":white_heavy_check_mark: [dark_sea_green3]Axon reset successfully[/dark_sea_green3]" + print_success( + "[dark_sea_green3]Axon reset successfully[/dark_sea_green3]" ) return True, "Axon reset successfully", ext_id @@ -230,8 +231,8 @@ async def set_axon_extrinsic( # We only wait here if we expect finalization. if not wait_for_finalization and not wait_for_inclusion: - console.print( - f":white_heavy_check_mark: [dark_sea_green3]Axon set successfully to {ip}:{port}[/dark_sea_green3]" + print_success( + f"[dark_sea_green3]Axon set successfully to {ip}:{port}[/dark_sea_green3]" ) return True, "Not waiting for finalization or inclusion.", None @@ -243,8 +244,8 @@ async def set_axon_extrinsic( else: ext_id = await response.get_extrinsic_identifier() await print_extrinsic_id(response) - console.print( - f":white_heavy_check_mark: [dark_sea_green3]Axon set successfully to {ip}:{port}[/dark_sea_green3]" + print_success( + f"[dark_sea_green3]Axon set successfully to {ip}:{port}[/dark_sea_green3]" ) return True, f"Axon set successfully to {ip}:{port}", ext_id diff --git a/bittensor_cli/src/bittensor/extrinsics/transfer.py b/bittensor_cli/src/bittensor/extrinsics/transfer.py index 5de403466..fb714bfd2 100644 --- a/bittensor_cli/src/bittensor/extrinsics/transfer.py +++ b/bittensor_cli/src/bittensor/extrinsics/transfer.py @@ -12,6 +12,7 @@ confirm_action, console, print_error, + print_success, print_verbose, is_valid_bittensor_address_or_public_key, print_error, @@ -219,11 +220,10 @@ async def do_transfer() -> tuple[bool, str, str, Optional[AsyncExtrinsicReceipt] success, block_hash, err_msg, ext_receipt = await do_transfer() if success: - console.print(":white_heavy_check_mark: [green]Finalized[/green]") - console.print(f"[green]Block Hash: {block_hash}[/green]") + print_success(f"Finalized. Block Hash: {block_hash}") else: - console.print(f":cross_mark: [red]Failed[/red]: {err_msg}") + print_error(f"Failed: {err_msg}") if success: with console.status(":satellite: Checking Balance...", spinner="aesthetic"): diff --git a/bittensor_cli/src/bittensor/utils.py b/bittensor_cli/src/bittensor/utils.py index a5c1896d3..d63759271 100644 --- a/bittensor_cli/src/bittensor/utils.py +++ b/bittensor_cli/src/bittensor/utils.py @@ -156,6 +156,17 @@ def print_error(message: str, status=None): print_console(error_message, "red", err_console) +def print_success(message: str, status=None): + """Print success messages while temporarily pausing the status spinner.""" + success_message = f":white_heavy_check_mark: {message}" + if status: + status.stop() + print_console(success_message, "green", console) + status.start() + else: + print_console(success_message, "green", console) + + RAO_PER_TAO = 1e9 U16_MAX = 65535 U64_MAX = 18446744073709551615 diff --git a/bittensor_cli/src/commands/crowd/contribute.py b/bittensor_cli/src/commands/crowd/contribute.py index 933e794a6..0e0097a95 100644 --- a/bittensor_cli/src/commands/crowd/contribute.py +++ b/bittensor_cli/src/commands/crowd/contribute.py @@ -16,6 +16,7 @@ json_console, print_error, print_extrinsic_id, + print_success, unlock_key, ) from bittensor_cli.src.commands.crowd.view import show_crowdloan_details @@ -89,7 +90,7 @@ async def contribute_to_crowdloan( if json_output: json_console.print(json.dumps({"success": False, "error": error_msg})) else: - print_error(f"[red]{error_msg}[/red]") + print_error(error_msg) return False, error_msg is_valid, error_message = validate_for_contribution( @@ -99,7 +100,7 @@ async def contribute_to_crowdloan( if json_output: json_console.print(json.dumps({"success": False, "error": error_message})) else: - print_error(f"[red]{error_message}[/red]") + print_error(error_message) return False, error_message contributor_address = proxy or wallet.coldkeypub.ss58_address @@ -136,7 +137,7 @@ async def contribute_to_crowdloan( if json_output: json_console.print(json.dumps({"success": False, "error": error_msg})) else: - print_error(f"[red]{error_msg}[/red]") + print_error(error_msg) return False, "Contribution below minimum requirement." if contribution_amount > user_balance: @@ -144,7 +145,7 @@ async def contribute_to_crowdloan( if json_output: json_console.print(json.dumps({"success": False, "error": error_msg})) else: - print_error(f"[red]{error_msg}[/red]") + print_error(error_msg) return False, "Insufficient balance." # Auto-adjustment @@ -245,7 +246,7 @@ async def contribute_to_crowdloan( json.dumps({"success": False, "error": unlock_status.message}) ) else: - print_error(f"[red]{unlock_status.message}[/red]") + print_error(unlock_status.message) return False, unlock_status.message with console.status(f"\n:satellite: Contributing to crowdloan #{crowdloan_id}..."): @@ -272,7 +273,7 @@ async def contribute_to_crowdloan( ) ) else: - print_error(f"[red]Failed to contribute: {error_message}[/red]") + print_error(f"Failed to contribute: {error_message}") return False, error_message or "Failed to contribute." new_balance, new_contribution, updated_crowdloan = await asyncio.gather( @@ -398,7 +399,7 @@ async def withdraw_from_crowdloan( if json_output: json_console.print(json.dumps({"success": False, "error": error_msg})) else: - print_error(f"[red]{error_msg}[/red]") + print_error(error_msg) return False, error_msg if crowdloan.finalized: @@ -406,7 +407,7 @@ async def withdraw_from_crowdloan( if json_output: json_console.print(json.dumps({"success": False, "error": error_msg})) else: - print_error(f"[red]{error_msg}[/red]") + print_error(error_msg) return False, "Cannot withdraw from finalized crowdloan." contributor_address = proxy or wallet.coldkeypub.ss58_address @@ -424,7 +425,7 @@ async def withdraw_from_crowdloan( if json_output: json_console.print(json.dumps({"success": False, "error": error_msg})) else: - print_error(f"[red]{error_msg}[/red]") + print_error(error_msg) return False, "No contribution to withdraw." is_creator = wallet.coldkeypub.ss58_address == crowdloan.creator @@ -435,7 +436,7 @@ async def withdraw_from_crowdloan( if json_output: json_console.print(json.dumps({"success": False, "error": error_msg})) else: - print_error(f"[red]{error_msg}[/red]") + print_error(error_msg) return False, "Creator cannot withdraw deposit amount." remaining_contribution = crowdloan.deposit else: @@ -534,7 +535,7 @@ async def withdraw_from_crowdloan( json.dumps({"success": False, "error": unlock_status.message}) ) else: - print_error(f"[red]{unlock_status.message}[/red]") + print_error(unlock_status.message) return False, unlock_status.message with console.status(f"\n:satellite: Withdrawing from crowdloan #{crowdloan_id}..."): @@ -561,9 +562,7 @@ async def withdraw_from_crowdloan( ) ) else: - print_error( - f"[red]Failed to withdraw: {error_message or 'Unknown error'}[/red]" - ) + print_error(f"Failed to withdraw: {error_message or 'Unknown error'}") return False, error_message or "Failed to withdraw from crowdloan." new_balance, updated_contribution, updated_crowdloan = await asyncio.gather( @@ -602,9 +601,7 @@ async def withdraw_from_crowdloan( } json_console.print(json.dumps(output_dict)) else: - console.print( - f"\n✅ [green]Successfully withdrew from crowdloan #{crowdloan_id}![/green]\n" - ) + print_success(f"Successfully withdrew from crowdloan #{crowdloan_id}!\n") console.print( f"Amount Withdrawn: [{COLORS.S.AMOUNT}]{withdrawable}[/{COLORS.S.AMOUNT}]\n" diff --git a/bittensor_cli/src/commands/crowd/create.py b/bittensor_cli/src/commands/crowd/create.py index ff64e41a0..f14d4aae3 100644 --- a/bittensor_cli/src/commands/crowd/create.py +++ b/bittensor_cli/src/commands/crowd/create.py @@ -18,6 +18,7 @@ console, json_console, print_error, + print_success, is_valid_ss58_address, unlock_key, print_extrinsic_id, @@ -186,11 +187,11 @@ async def create_crowdloan( if cap <= deposit: if prompt: print_error( - f"[red]Cap must be greater than the deposit ({deposit.tao:,.4f} TAO).[/red]" + f"Cap must be greater than the deposit ({deposit.tao:,.4f} TAO)." ) cap_value = None continue - print_error("[red]Cap must be greater than the initial deposit.[/red]") + print_error("Cap must be greater than the initial deposit.") return False, "Cap must be greater than the initial deposit." break @@ -204,12 +205,12 @@ async def create_crowdloan( if duration_value < min_duration or duration_value > max_duration: if prompt: print_error( - f"[red]Duration must be between {min_duration} and " - f"{max_duration} blocks.[/red]" + f"Duration must be between {min_duration} and " + f"{max_duration} blocks." ) duration_value = None continue - print_error("[red]Crowdloan duration is outside the allowed range.[/red]") + print_error("Crowdloan duration is outside the allowed range.") return False, "Crowdloan duration is outside the allowed range." duration = duration_value break @@ -227,7 +228,7 @@ async def create_crowdloan( if not 0 <= emissions_share <= 100: print_error( - f"[red]Emissions share must be between 0 and 100, got {emissions_share}[/red]" + f"Emissions share must be between 0 and 100, got {emissions_share}" ) return False, "Invalid emissions share percentage." @@ -255,9 +256,7 @@ async def create_crowdloan( if target_address: target_address = target_address.strip() if not is_valid_ss58_address(target_address): - print_error( - f"[red]Invalid target SS58 address provided: {target_address}[/red]" - ) + print_error(f"Invalid target SS58 address provided: {target_address}") return False, "Invalid target SS58 address provided." elif prompt: target_input = Prompt.ask( @@ -266,9 +265,7 @@ async def create_crowdloan( target_address = target_input.strip() or None if not is_valid_ss58_address(target_address): - print_error( - f"[red]Invalid target SS58 address provided: {target_address}[/red]" - ) + print_error(f"Invalid target SS58 address provided: {target_address}") return False, "Invalid target SS58 address provided." call_to_attach = None @@ -278,8 +275,8 @@ async def create_crowdloan( ) if deposit > creator_balance: print_error( - f"[red]Insufficient balance to cover the deposit. " - f"Available: {creator_balance}, required: {deposit}[/red]" + f"Insufficient balance to cover the deposit. " + f"Available: {creator_balance}, required: {deposit}" ) return False, "Insufficient balance to cover the deposit." @@ -383,7 +380,7 @@ async def create_crowdloan( ) ) else: - print_error(f"[red]{error_message or 'Failed to create crowdloan.'}[/red]") + print_error(f"{error_message or 'Failed to create crowdloan.'}") return False, error_message or "Failed to create crowdloan." if json_output: @@ -414,8 +411,8 @@ async def create_crowdloan( else: if crowdloan_type == "subnet": message = "Subnet lease crowdloan created successfully." + print_success(message) console.print( - f"\n:white_check_mark: [green]{message}[/green]\n" f" Type: [magenta]Subnet Leasing[/magenta]\n" f" Emissions Share: [cyan]{emissions_share}%[/cyan]\n" f" Deposit: [{COLORS.P.TAO}]{deposit}[/{COLORS.P.TAO}]\n" @@ -429,8 +426,8 @@ async def create_crowdloan( console.print(" Lease: [green]Perpetual[/green]") else: message = "Fundraising crowdloan created successfully." + print_success(message) console.print( - f"\n:white_check_mark: [green]{message}[/green]\n" f" Type: [cyan]General Fundraising[/cyan]\n" f" Deposit: [{COLORS.P.TAO}]{deposit}[/{COLORS.P.TAO}]\n" f" Min contribution: [{COLORS.P.TAO}]{min_contribution}[/{COLORS.P.TAO}]\n" @@ -489,7 +486,7 @@ async def finalize_crowdloan( if json_output: json_console.print(json.dumps({"success": False, "error": error_msg})) else: - print_error(f"[red]{error_msg}[/red]") + print_error(error_msg) return False, error_msg if wallet.coldkeypub.ss58_address != crowdloan.creator: @@ -499,7 +496,7 @@ async def finalize_crowdloan( if json_output: json_console.print(json.dumps({"success": False, "error": error_msg})) else: - print_error(f"[red]{error_msg}[/red]") + print_error(error_msg) return False, "Only the creator can finalize a crowdloan." if crowdloan.finalized: @@ -507,7 +504,7 @@ async def finalize_crowdloan( if json_output: json_console.print(json.dumps({"success": False, "error": error_msg})) else: - print_error(f"[red]{error_msg}[/red]") + print_error(error_msg) return False, "Crowdloan is already finalized." if crowdloan.raised < crowdloan.cap: @@ -520,9 +517,9 @@ async def finalize_crowdloan( json_console.print(json.dumps({"success": False, "error": error_msg})) else: print_error( - f"[red]Crowdloan #{crowdloan_id} has not reached its cap.\n" + f"Crowdloan #{crowdloan_id} has not reached its cap.\n" f"Raised: {crowdloan.raised}, Cap: {crowdloan.cap}\n" - f"Still needed: {still_needed.tao}[/red]" + f"Still needed: {still_needed.tao}" ) return False, "Crowdloan has not reached its cap." diff --git a/bittensor_cli/src/commands/proxy.py b/bittensor_cli/src/commands/proxy.py index 9f3c403d0..8852fedf1 100644 --- a/bittensor_cli/src/commands/proxy.py +++ b/bittensor_cli/src/commands/proxy.py @@ -13,6 +13,7 @@ json_console, console, print_error, + print_success, unlock_key, ProxyAddressBook, is_valid_ss58_address_prompt, @@ -95,7 +96,7 @@ async def submit_proxy( ) else: await print_extrinsic_id(receipt) - console.print(":white_check_mark:[green]Success![/green]") + print_success("Success!") else: if json_output: json_console.print_json( @@ -627,7 +628,7 @@ async def execute_announced( ) inner_call.process() except StateDiscardedError: - err_console.print( + print_error( "The state has already been discarded for this block " "(you are likely not using an archive node endpoint)" ) @@ -645,8 +646,8 @@ async def execute_announced( ) inner_call.process() except Exception as e: - err_console.print( - f":cross_mark:[red]Failure[/red]Unable to regenerate the call data using the latest runtime: {e}\n" + print_error( + f"Failure: Unable to regenerate the call data using the latest runtime: {e}\n" "You should rerun this command on an archive node endpoint." ) if json_output: @@ -687,7 +688,7 @@ async def execute_announced( } ) else: - console.print(":white_check_mark:[green]Success![/green]") + print_success("Success!") await print_extrinsic_id(receipt) else: if json_output: diff --git a/bittensor_cli/src/commands/stake/add.py b/bittensor_cli/src/commands/stake/add.py index 18c6578eb..275049be4 100644 --- a/bittensor_cli/src/commands/stake/add.py +++ b/bittensor_cli/src/commands/stake/add.py @@ -20,6 +20,7 @@ get_hotkey_wallets_for_wallet, is_valid_ss58_address, print_error, + print_success, print_verbose, unlock_key, json_console, @@ -192,9 +193,8 @@ async def safe_stake_extrinsic( block_hash=block_hash, ), ) - console.print( - f":white_heavy_check_mark: [dark_sea_green3]Finalized. " - f"Stake added to netuid: {netuid_}[/dark_sea_green3]" + print_success( + f"[dark_sea_green3]Finalized. Stake added to netuid: {netuid_}[/dark_sea_green3]" ) console.print( f"Balance:\n [blue]{current_balance}[/blue] :arrow_right: " @@ -287,8 +287,7 @@ async def stake_extrinsic( block_hash=new_block_hash, ), ) - console.print( - f":white_heavy_check_mark: " + print_success( f"[dark_sea_green3]Finalized. Stake added to netuid: {netuid_i}[/dark_sea_green3]" ) console.print( diff --git a/bittensor_cli/src/commands/stake/auto_staking.py b/bittensor_cli/src/commands/stake/auto_staking.py index 9fb10847a..3d7888321 100644 --- a/bittensor_cli/src/commands/stake/auto_staking.py +++ b/bittensor_cli/src/commands/stake/auto_staking.py @@ -11,6 +11,7 @@ confirm_action, console, json_console, + print_success, get_subnet_name, is_valid_ss58_address, print_error, @@ -296,8 +297,8 @@ async def set_auto_stake_destination( if success: await print_extrinsic_id(ext_receipt) - console.print( - f":white_heavy_check_mark: [dark_sea_green3]Auto-stake destination set for netuid {netuid}[/dark_sea_green3]" + print_success( + f"[dark_sea_green3]Auto-stake destination set for netuid {netuid}[/dark_sea_green3]" ) return True diff --git a/bittensor_cli/src/commands/stake/children_hotkeys.py b/bittensor_cli/src/commands/stake/children_hotkeys.py index d4fb17998..69d4083e4 100644 --- a/bittensor_cli/src/commands/stake/children_hotkeys.py +++ b/bittensor_cli/src/commands/stake/children_hotkeys.py @@ -16,6 +16,7 @@ print_error, float_to_u16, float_to_u64, + print_success, u16_to_float, u64_to_float, is_valid_ss58_address, @@ -145,7 +146,7 @@ async def set_children_extrinsic( await print_extrinsic_id(ext_receipt) modifier = "included" if wait_for_finalization: - console.print(":white_heavy_check_mark: [green]Finalized[/green]") + print_success("Finalized") modifier = "finalized" return True, f"{operation} successfully {modifier}.", ext_id else: @@ -236,7 +237,7 @@ async def set_childkey_take_extrinsic( modifier = "included" if wait_for_finalization: modifier = "finalized" - console.print(":white_heavy_check_mark: [green]Finalized[/green]") + print_success("Finalized") return True, f"Successfully {modifier} childkey take", ext_id else: print_error(f"Failed: {error_message}") @@ -569,11 +570,9 @@ async def set_children( f"Your childkey request has been submitted. It will be completed around block {completion_block}. " f"The current block is {current_block}" ) - console.print( - ":white_heavy_check_mark: [green]Set children hotkeys.[/green]" - ) + print_success("Set children hotkeys.") else: - console.print(f"Unable to set children hotkeys. {message}") + print_error(f"Unable to set children hotkeys. {message}") else: # set children on all subnets that parent is registered on netuids = await subtensor.get_all_subnet_netuids() @@ -606,9 +605,7 @@ async def set_children( f"Your childkey request for netuid {netuid_} has been submitted. It will be completed around " f"block {completion_block}. The current block is {current_block}." ) - console.print( - ":white_heavy_check_mark: [green]Sent set children request for all subnets.[/green]" - ) + print_success("Sent set children request for all subnets.") if json_output: json_console.print(json.dumps(successes)) @@ -784,7 +781,7 @@ async def set_chk_take_subnet( ) # Result if success_: - console.print(":white_heavy_check_mark: [green]Set childkey take.[/green]") + print_success("Set childkey take.") console.print( f"The childkey take for {get_hotkey_pub_ss58(wallet)} is now set to {take * 100:.2f}%." ) @@ -864,7 +861,5 @@ async def set_chk_take_subnet( wait_for_finalization=False, ) output_list.append((netuid_, result, ext_id)) - console.print( - f":white_heavy_check_mark: [green]Sent childkey take of {take * 100:.2f}% to all subnets.[/green]" - ) + print_success(f"Sent childkey take of {take * 100:.2f}% to all subnets.") return output_list diff --git a/bittensor_cli/src/commands/stake/claim.py b/bittensor_cli/src/commands/stake/claim.py index d07f4715b..2f225f135 100644 --- a/bittensor_cli/src/commands/stake/claim.py +++ b/bittensor_cli/src/commands/stake/claim.py @@ -15,6 +15,7 @@ confirm_action, console, print_error, + print_success, unlock_key, print_extrinsic_id, json_console, @@ -209,7 +210,7 @@ async def set_claim_type( if success: ext_id = await ext_receipt.get_extrinsic_identifier() msg = "Successfully changed claim type" - console.print(f":white_heavy_check_mark: [green]{msg}[/green]") + print_success(msg) await print_extrinsic_id(ext_receipt) if json_output: json_console.print( diff --git a/bittensor_cli/src/commands/stake/move.py b/bittensor_cli/src/commands/stake/move.py index c106ddfbc..f28d689f2 100644 --- a/bittensor_cli/src/commands/stake/move.py +++ b/bittensor_cli/src/commands/stake/move.py @@ -19,6 +19,7 @@ print_error, group_subnets, get_subnet_name, + print_success, unlock_key, get_hotkey_pub_ss58, print_extrinsic_id, @@ -722,12 +723,10 @@ async def move_stake( return False, "" await print_extrinsic_id(response) if not prompt: - console.print(":white_heavy_check_mark: [green]Sent[/green]") + print_success("Sent") return True, ext_id else: - console.print( - ":white_heavy_check_mark: [dark_sea_green3]Stake moved.[/dark_sea_green3]" - ) + print_success("[dark_sea_green3]Stake moved.[/dark_sea_green3]") block_hash = await subtensor.substrate.get_chain_head() ( new_origin_stake_balance, @@ -945,7 +944,7 @@ async def transfer_stake( await print_extrinsic_id(response) ext_id = await response.get_extrinsic_identifier() if not prompt: - console.print(":white_heavy_check_mark: [green]Sent[/green]") + print_success("Sent") return True, ext_id else: # Get and display new stake balances @@ -1184,7 +1183,7 @@ async def swap_stake( return False, "" await print_extrinsic_id(response) if not prompt: - console.print(":white_heavy_check_mark: [green]Sent[/green]") + print_success("Sent") return True, await response.get_extrinsic_identifier() else: # Get and display new stake balances diff --git a/bittensor_cli/src/commands/stake/remove.py b/bittensor_cli/src/commands/stake/remove.py index 1cc7116a5..eb930a60e 100644 --- a/bittensor_cli/src/commands/stake/remove.py +++ b/bittensor_cli/src/commands/stake/remove.py @@ -18,6 +18,7 @@ from bittensor_cli.src.bittensor.utils import ( confirm_action, console, + print_success, print_verbose, print_error, get_hotkey_wallets_for_wallet, @@ -671,7 +672,7 @@ async def _unstake_extrinsic( ), ) - console.print(":white_heavy_check_mark: [green]Finalized[/green]") + print_success("Finalized") console.print( f"Balance:\n [blue]{current_balance}[/blue] :arrow_right: [{COLOR_PALETTE.S.AMOUNT}]{new_balance}" ) @@ -785,7 +786,7 @@ async def _safe_unstake_extrinsic( ), ) - console.print(":white_heavy_check_mark: [green]Finalized[/green]") + print_success("Finalized") console.print( f"Balance:\n [blue]{current_balance}[/blue] :arrow_right: [{COLOR_PALETTE.S.AMOUNT}]{new_balance}" ) diff --git a/bittensor_cli/src/commands/subnets/mechanisms.py b/bittensor_cli/src/commands/subnets/mechanisms.py index 2400be707..195820c56 100644 --- a/bittensor_cli/src/commands/subnets/mechanisms.py +++ b/bittensor_cli/src/commands/subnets/mechanisms.py @@ -16,6 +16,7 @@ json_console, U16_MAX, print_extrinsic_id, + print_success, ) if TYPE_CHECKING: @@ -294,7 +295,7 @@ async def set_emission_split( return False if normalized_weights == existing_split: - message = ":white_heavy_check_mark: [dark_sea_green3]Emission split unchanged.[/dark_sea_green3]" + message = "[dark_sea_green3]Emission split unchanged.[/dark_sea_green3]" if json_output: json_console.print_json( data={ @@ -306,7 +307,7 @@ async def set_emission_split( } ) else: - console.print(message) + print_success(message) return True if not json_output: @@ -462,8 +463,7 @@ async def set_mechanism_count( if success: await print_extrinsic_id(ext_receipt) - console.print( - ":white_heavy_check_mark: " + print_success( f"[dark_sea_green3]Mechanism count set to {mechanism_count} for subnet {netuid}[/dark_sea_green3]" ) else: @@ -506,8 +506,7 @@ async def set_mechanism_emission( if success: await print_extrinsic_id(ext_receipt) - console.print( - ":white_heavy_check_mark: " + print_success( f"[dark_sea_green3]Emission split updated for subnet {netuid}[/dark_sea_green3]" ) else: diff --git a/bittensor_cli/src/commands/subnets/subnets.py b/bittensor_cli/src/commands/subnets/subnets.py index ad0404b4f..610606ba3 100644 --- a/bittensor_cli/src/commands/subnets/subnets.py +++ b/bittensor_cli/src/commands/subnets/subnets.py @@ -30,6 +30,7 @@ confirm_action, console, create_and_populate_table, + print_success, print_verbose, print_error, get_metadata_table, @@ -297,8 +298,8 @@ async def _find_event_attributes_in_extrinsic_receipt( "" ) else: - console.print( - f":white_heavy_check_mark: [dark_sea_green3]Registered subnetwork with netuid: {attributes[0]}" + print_success( + f"[dark_sea_green3]Registered subnetwork with netuid: {attributes[0]}" ) return True, int(attributes[0]), ext_id @@ -2626,9 +2627,7 @@ async def set_identity( return False, None ext_id = await ext_receipt.get_extrinsic_identifier() await print_extrinsic_id(ext_receipt) - console.print( - ":white_heavy_check_mark: [dark_sea_green3]Successfully set subnet identity\n" - ) + print_success("[dark_sea_green3]Successfully set subnet identity\n") subnet = await subtensor.subnet(netuid) identity = subnet.subnet_identity if subnet else None @@ -2811,9 +2810,7 @@ async def start_subnet( if success: await print_extrinsic_id(response) - console.print( - f":white_heavy_check_mark: [green]Successfully started subnet {netuid}'s emission schedule.[/green]" - ) + print_success(f"Successfully started subnet {netuid}'s emission schedule.") return True else: if "FirstEmissionBlockNumberAlreadySet" in error_msg: @@ -2895,7 +2892,7 @@ async def set_symbol( } ) else: - console.print(f":white_heavy_check_mark:[dark_sea_green3] {message}\n") + print_success(f"[dark_sea_green3] {message}\n") return True else: if json_output: diff --git a/bittensor_cli/src/commands/sudo.py b/bittensor_cli/src/commands/sudo.py index b039ea4f2..ec6d1461c 100644 --- a/bittensor_cli/src/commands/sudo.py +++ b/bittensor_cli/src/commands/sudo.py @@ -23,6 +23,7 @@ confirm_action, console, print_error, + print_success, print_verbose, normalize_hyperparameters, unlock_key, @@ -410,15 +411,13 @@ async def set_hyperparameter_extrinsic( ext_id = await ext_receipt.get_extrinsic_identifier() await print_extrinsic_id(ext_receipt) if arbitrary_extrinsic: - console.print( - f":white_heavy_check_mark: " + print_success( f"[dark_sea_green3]Hyperparameter {parameter} values changed to {call_params}[/dark_sea_green3]" ) return True, "", ext_id # Successful registration, final check for membership else: - console.print( - f":white_heavy_check_mark: " + print_success( f"[dark_sea_green3]Hyperparameter {parameter} changed to {value}[/dark_sea_green3]" ) return True, "", ext_id @@ -641,7 +640,7 @@ async def vote_senate_extrinsic( vote_data.ayes.count(hotkey_ss58) > 0 or vote_data.nays.count(hotkey_ss58) > 0 ): - console.print(":white_heavy_check_mark: [green]Vote cast.[/green]") + print_success("Vote cast.") return True else: # hotkey not found in ayes/nays @@ -729,9 +728,7 @@ async def set_take_extrinsic( print_error(err) ext_id = None else: - console.print( - ":white_heavy_check_mark: [dark_sea_green_3]Success[/dark_sea_green_3]" - ) + print_success("Success") ext_id = await ext_receipt.get_extrinsic_identifier() await print_extrinsic_id(ext_receipt) return success, ext_id @@ -1293,7 +1290,5 @@ async def trim( ) else: await print_extrinsic_id(ext_receipt) - console.print( - f":white_heavy_check_mark: [dark_sea_green3]{msg}[/dark_sea_green3]" - ) + print_success(f"[dark_sea_green3]{msg}[/dark_sea_green3]") return True diff --git a/bittensor_cli/src/commands/wallets.py b/bittensor_cli/src/commands/wallets.py index 96c812be5..216d7bf10 100644 --- a/bittensor_cli/src/commands/wallets.py +++ b/bittensor_cli/src/commands/wallets.py @@ -37,7 +37,6 @@ confirm_action, console, convert_blocks_to_time, - err_console, json_console, print_error, print_verbose, @@ -137,9 +136,7 @@ async def associate_hotkey( ) if not success: - console.print( - f"[red]:cross_mark: Failed to associate hotkey: {err_msg}[/red]" - ) + print_error(f"Failed to associate hotkey: {err_msg}") return False console.print( diff --git a/bittensor_cli/src/commands/weights.py b/bittensor_cli/src/commands/weights.py index a12f5b659..3fa0135c3 100644 --- a/bittensor_cli/src/commands/weights.py +++ b/bittensor_cli/src/commands/weights.py @@ -12,6 +12,7 @@ from bittensor_cli.src.bittensor.utils import ( confirm_action, print_error, + print_success, console, format_error_message, json_console, @@ -181,9 +182,7 @@ async def _commit_reveal( reveal_time = (current_time + timedelta(seconds=interval)).isoformat() cli_retry_cmd = f"--netuid {self.netuid} --uids {weight_uids} --weights {self.weights} --reveal-using-salt {self.salt}" # Print params to screen and notify user this is a blocking operation - console.print( - ":white_heavy_check_mark: [green]Weights hash committed to chain[/green]" - ) + print_success("Weights hash committed to chain") console.print( f":alarm_clock: [dark_orange3]Weights hash will be revealed at {reveal_time}[/dark_orange3]" ) @@ -227,9 +226,7 @@ async def reveal(self, weight_uids, weight_vals) -> tuple[bool, str, Optional[st if not self.wait_for_finalization and not self.wait_for_inclusion: return True, "Not waiting for finalization or inclusion.", ext_id - console.print( - ":white_heavy_check_mark: [green]Weights hash revealed on chain[/green]" - ) + print_success("Weights hash revealed on chain") return ( True, "Successfully revealed previously committed weights hash.", @@ -284,7 +281,7 @@ async def _do_set_weights() -> tuple[bool, str, Optional[str]]: return True, "Not waiting for finalization or inclusion.", None if success: - console.print(":white_heavy_check_mark: [green]Finalized[/green]") + print_success("Finalized") # bittensor.logging.success(prefix="Set weights", suffix="Finalized: " + str(success)) return True, "Successfully set weights and finalized.", ext_id else: From b0a00e0a0c2a1d1ce7c47e1902dab4806b2e85b9 Mon Sep 17 00:00:00 2001 From: calm <148254234+calm329@users.noreply.github.com> Date: Tue, 23 Dec 2025 15:46:38 -0800 Subject: [PATCH 03/33] fix: live display formatting on macOS Terminal.app (#789) --- bittensor_cli/src/commands/stake/list.py | 2 +- bittensor_cli/src/commands/subnets/subnets.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bittensor_cli/src/commands/stake/list.py b/bittensor_cli/src/commands/stake/list.py index d4a087970..a2a554578 100644 --- a/bittensor_cli/src/commands/stake/list.py +++ b/bittensor_cli/src/commands/stake/list.py @@ -539,7 +539,7 @@ def format_cell( current_block = None previous_data = None - with Live(console=console, screen=True, auto_refresh=True) as live: + with Live(console=console, auto_refresh=True) as live: try: while True: block_hash = await subtensor.substrate.get_chain_head() diff --git a/bittensor_cli/src/commands/subnets/subnets.py b/bittensor_cli/src/commands/subnets/subnets.py index 610606ba3..26c662173 100644 --- a/bittensor_cli/src/commands/subnets/subnets.py +++ b/bittensor_cli/src/commands/subnets/subnets.py @@ -934,7 +934,7 @@ def format_liquidity_cell( current_block = None previous_data = None - with Live(console=console, screen=True, auto_refresh=True) as live: + with Live(console=console, auto_refresh=True) as live: try: while True: ( From 1548b145bb74638781ca2800e45c223bd9da3a2e Mon Sep 17 00:00:00 2001 From: Dera Okeke <63825182+chideraao@users.noreply.github.com> Date: Mon, 5 Jan 2026 14:33:48 +0100 Subject: [PATCH 04/33] Update cli.py --- 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 88af300e1..e75b60730 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -9551,7 +9551,7 @@ def proxy_execute_announced( If you do not provide the call hash or call hex of the announced call in the command, you would be prompted to enter details of the call including the module name and call function. - [bold]Note[/bold]: Using the `--call-hash` flag attempts to resolve the call from the proxy announcement address book. Use this flag only if the announcement was created through BTCLI. + [bold]Note[/bold]: Using the `--call-hash` flag attempts to resolve the call from the proxy announcements address book. Use this flag only if the announcement was created through BTCLI. If the announcement was created by any other method, you must provide the call using `--call-hex` or rebuild the call explicitly via the command prompts. [bold]Common Examples:[/bold] From 5bedefb617cf1e2c7f554fc811e526f5b206bc5e Mon Sep 17 00:00:00 2001 From: Dera Okeke <63825182+chideraao@users.noreply.github.com> Date: Mon, 5 Jan 2026 14:42:21 +0100 Subject: [PATCH 05/33] Update cli.py --- 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 e75b60730..06c97822d 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -9552,7 +9552,7 @@ def proxy_execute_announced( If you do not provide the call hash or call hex of the announced call in the command, you would be prompted to enter details of the call including the module name and call function. [bold]Note[/bold]: Using the `--call-hash` flag attempts to resolve the call from the proxy announcements address book. Use this flag only if the announcement was created through BTCLI. - If the announcement was created by any other method, you must provide the call using `--call-hex` or rebuild the call explicitly via the command prompts. + If the announcement was created by any other method, you must provide the call hex using the `--call-hex` flag or rebuild the call explicitly via the command prompts. [bold]Common Examples:[/bold] 1. Using the call hash From bd571f07dac6dbf50a161d6b386fa34d18519325 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Mon, 5 Jan 2026 13:36:46 -0800 Subject: [PATCH 06/33] skip test till its enabled again in-chain --- tests/e2e_tests/test_liquidity.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/e2e_tests/test_liquidity.py b/tests/e2e_tests/test_liquidity.py index e97e1b6b4..a5c38ccc8 100644 --- a/tests/e2e_tests/test_liquidity.py +++ b/tests/e2e_tests/test_liquidity.py @@ -1,3 +1,4 @@ +import pytest import asyncio import json import time @@ -14,6 +15,7 @@ """ +@pytest.mark.skip(reason="User liquidity currently disabled on chain") def test_liquidity(local_chain, wallet_setup): wallet_path_alice = "//Alice" netuid = 2 From fd293de62dabf00a603e20730486bd685ce7968a Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Wed, 7 Jan 2026 12:06:06 -0800 Subject: [PATCH 07/33] ruff --- bittensor_cli/cli.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 06c97822d..5a480d4be 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -2008,7 +2008,7 @@ def config_add_proxy( Adds a new pure proxy to the address book. [bold]Example:[/bold] - [green]$[/green] btcli config add-proxy + [green]$[/green] btcli config add-proxy """ if self.proxies.get(name) is not None: err_console.print( @@ -9548,14 +9548,14 @@ def proxy_execute_announced( Executes a previously announced proxy call. This command submits the inner call on-chain using the proxy relationship. The command will fail if the required delay has not passed or if the call does not match the announcement parameters. - + If you do not provide the call hash or call hex of the announced call in the command, you would be prompted to enter details of the call including the module name and call function. [bold]Note[/bold]: Using the `--call-hash` flag attempts to resolve the call from the proxy announcements address book. Use this flag only if the announcement was created through BTCLI. If the announcement was created by any other method, you must provide the call hex using the `--call-hex` flag or rebuild the call explicitly via the command prompts. [bold]Common Examples:[/bold] - 1. Using the call hash + 1. Using the call hash [green]$[/green] btcli proxy execute --call-hash caf4da69610d379c2e2e5...0cbc6b012f6cff6340c45a1 2. Using the call hex From 96c9e900c5b68fca263e301ecc66c4697cbefe63 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 14:12:28 -0800 Subject: [PATCH 08/33] update DurationOfStartCall -> InitialStartCallDelay --- bittensor_cli/src/commands/subnets/subnets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bittensor_cli/src/commands/subnets/subnets.py b/bittensor_cli/src/commands/subnets/subnets.py index 26c662173..8130421da 100644 --- a/bittensor_cli/src/commands/subnets/subnets.py +++ b/bittensor_cli/src/commands/subnets/subnets.py @@ -2724,7 +2724,7 @@ async def get_start_schedule( ), subtensor.substrate.get_constant( module_name="SubtensorModule", - constant_name="DurationOfStartCall", + constant_name="InitialStartCallDelay", block_hash=block_hash, ), subtensor.substrate.get_block_number(block_hash=block_hash), From c205d266fcfc1dc0f2a08ec334e0a7a9a83363f4 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Wed, 14 Jan 2026 10:58:44 -0800 Subject: [PATCH 09/33] add print_protection_warnings --- bittensor_cli/src/bittensor/utils.py | 36 ++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/bittensor_cli/src/bittensor/utils.py b/bittensor_cli/src/bittensor/utils.py index d63759271..53a4e4191 100644 --- a/bittensor_cli/src/bittensor/utils.py +++ b/bittensor_cli/src/bittensor/utils.py @@ -167,6 +167,42 @@ def print_success(message: str, status=None): print_console(success_message, "green", console) +def print_protection_warnings( + mev_protection: bool, + safe_staking: Optional[bool] = None, + command_name: str = "", +) -> None: + """ + Print warnings about missing MEV protection and/or limit price protection. + + Args: + mev_protection: Whether MEV protection is enabled. + safe_staking: Whether safe staking (limit price protection) is enabled. + None if limit price protection is not available for this command. + command_name: Name of the command (e.g., "stake add") for context. + """ + warnings = [] + + if not mev_protection: + warnings.append( + "⚠️ [dim][yellow]Warning:[/yellow] MEV protection is disabled. " + "This transaction may be exposed to MEV attacks.[/dim]" + ) + + if safe_staking is not None and not safe_staking: + warnings.append( + "⚠️ [dim][yellow]Warning:[/yellow] Limit price protection (safe staking) is disabled. " + "This transaction may be subject to slippage.[/dim]" + ) + + if warnings: + if command_name: + console.print(f"\n[dim]Protection status for '{command_name}':[/dim]") + for warning in warnings: + console.print(warning) + console.print() + + RAO_PER_TAO = 1e9 U16_MAX = 65535 U64_MAX = 18446744073709551615 From 9481b4078fb0bfbd7884d587f7aaad8384435b5b Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Wed, 14 Jan 2026 10:59:07 -0800 Subject: [PATCH 10/33] adds protection warnings to cmds --- bittensor_cli/cli.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index c0a2c155a..380fddebd 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -74,6 +74,7 @@ ProxyAddressBook, ProxyAnnouncements, confirm_action, + print_protection_warnings, ) from bittensor_cli.src.commands import sudo, wallets, view from bittensor_cli.src.commands import weights as weights_cmds @@ -4699,7 +4700,12 @@ def stake_add( if safe_staking: rate_tolerance = self.ask_rate_tolerance(rate_tolerance) allow_partial_stake = self.ask_partial_stake(allow_partial_stake) - console.print("\n") + + print_protection_warnings( + mev_protection=mev_protection, + safe_staking=safe_staking, + command_name="stake add", + ) if netuids: netuids = parse_to_list( @@ -5016,8 +5022,12 @@ def stake_remove( if safe_staking: rate_tolerance = self.ask_rate_tolerance(rate_tolerance) allow_partial_stake = self.ask_partial_stake(allow_partial_stake) - console.print("\n") + print_protection_warnings( + mev_protection=mev_protection, + safe_staking=safe_staking, + command_name="stake remove", + ) if interactive and any( [hotkey_ss58_address, include_hotkeys, exclude_hotkeys, all_hotkeys] ): @@ -5353,6 +5363,11 @@ def stake_move( """ self.verbosity_handler(quiet, verbose, json_output, prompt, decline) proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) + print_protection_warnings( + mev_protection=mev_protection, + safe_staking=None, + command_name="stake move", + ) if prompt: if not confirm_action( "This transaction will [bold]move stake[/bold] to another hotkey while keeping the same " @@ -7598,6 +7613,11 @@ def subnets_create( """ self.verbosity_handler(quiet, verbose, json_output, prompt) proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) + print_protection_warnings( + mev_protection=mev_protection, + safe_staking=None, + command_name="subnets create", + ) wallet = self.wallet_ask( wallet_name, wallet_path, From ec9331ce2dd34f0127a0ccacbeb0bd24e6c8fe4c Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Wed, 14 Jan 2026 11:06:03 -0800 Subject: [PATCH 11/33] add warning to stake swap --- bittensor_cli/cli.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 380fddebd..59e242f09 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -5795,6 +5795,15 @@ def stake_swap( "[dim]This command moves stake from one subnet to another subnet while keeping " "the same coldkey-hotkey pair.[/dim]" ) + safe_staking = self.ask_safe_staking(safe_staking) + if safe_staking: + rate_tolerance = self.ask_rate_tolerance(rate_tolerance) + allow_partial_stake = self.ask_partial_stake(allow_partial_stake) + print_protection_warnings( + mev_protection=mev_protection, + safe_staking=safe_staking, + command_name="stake swap", + ) wallet = self.wallet_ask( wallet_name, @@ -5818,10 +5827,6 @@ def stake_swap( ) if not amount and not swap_all: amount = FloatPrompt.ask("Enter the [blue]amount[/blue] to swap") - safe_staking = self.ask_safe_staking(safe_staking) - if safe_staking: - rate_tolerance = self.ask_rate_tolerance(rate_tolerance) - allow_partial_stake = self.ask_partial_stake(allow_partial_stake) logger.debug( "args:\n" From 575a3a2d91aa60714e35bc55151a711ffaa5e0e5 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Wed, 14 Jan 2026 11:06:35 -0800 Subject: [PATCH 12/33] add to stake transfer --- bittensor_cli/cli.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 59e242f09..302e56beb 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -5589,6 +5589,11 @@ def stake_transfer( """ self.verbosity_handler(quiet, verbose, json_output, prompt, decline) proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) + print_protection_warnings( + mev_protection=mev_protection, + safe_staking=None, + command_name="stake transfer", + ) if prompt: if not confirm_action( "This transaction will [bold]transfer ownership[/bold] from one coldkey to another, in subnets " From e0879e7a3ce2d7faf124a9933c5ef6525c4ad29d Mon Sep 17 00:00:00 2001 From: root Date: Fri, 19 Dec 2025 14:04:12 +0200 Subject: [PATCH 13/33] Added crowdloan enhancements --- bittensor_cli/cli.py | 158 +++++- .../src/commands/crowd/contributors.py | 265 +++++++++ bittensor_cli/src/commands/crowd/create.py | 187 +++++- bittensor_cli/src/commands/crowd/view.py | 533 +++++++++++++++++- pyproject.toml | 3 + tests/e2e_tests/test_crowd_contributors.py | 260 +++++++++ .../e2e_tests/test_crowd_identity_display.py | 150 +++++ tests/unit_tests/test_crowd_contributors.py | 496 ++++++++++++++++ .../test_crowd_create_custom_call.py | 180 ++++++ 9 files changed, 2201 insertions(+), 31 deletions(-) create mode 100644 bittensor_cli/src/commands/crowd/contributors.py create mode 100644 tests/e2e_tests/test_crowd_contributors.py create mode 100644 tests/e2e_tests/test_crowd_identity_display.py create mode 100644 tests/unit_tests/test_crowd_contributors.py create mode 100644 tests/unit_tests/test_crowd_create_custom_call.py diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 302e56beb..0a37364c8 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -86,6 +86,7 @@ view as view_crowdloan, update as crowd_update, refund as crowd_refund, + contributors as crowd_contributors, ) from bittensor_cli.src.commands.liquidity.utils import ( prompt_liquidity, @@ -1334,6 +1335,9 @@ def __init__(self): self.crowd_app.command("info", rich_help_panel=HELP_PANELS["CROWD"]["INFO"])( self.crowd_info ) + self.crowd_app.command( + "contributors", rich_help_panel=HELP_PANELS["CROWD"]["INFO"] + )(self.crowd_contributors) self.crowd_app.command( "create", rich_help_panel=HELP_PANELS["CROWD"]["INITIATOR"] )(self.crowd_create) @@ -2904,6 +2908,7 @@ def wallet_inspect( ), wallet_name: str = Options.wallet_name, wallet_path: str = Options.wallet_path, + wallet_hotkey: str = Options.wallet_hotkey, network: Optional[list[str]] = Options.network, netuids: str = Options.netuids, quiet: bool = Options.quiet, @@ -2911,7 +2916,7 @@ def wallet_inspect( json_output: bool = Options.json_output, ): """ - Displays the details of the user's wallet (coldkey) on the Bittensor network. + Displays the details of the user's wallet pairs (coldkey, hotkey) on the Bittensor network. The output is presented as a table with the below columns: @@ -2956,7 +2961,7 @@ def wallet_inspect( 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, None, ask_for=ask_for, validate=validate + wallet_name, wallet_path, wallet_hotkey, ask_for=ask_for, validate=validate ) self.initialize_chain(network) @@ -8745,6 +8750,36 @@ def crowd_list( quiet: bool = Options.quiet, verbose: bool = Options.verbose, json_output: bool = Options.json_output, + show_identities: Optional[str] = typer.Option( + None, + "--show-identities", + help="Show identity names for creators and target addresses. Use 'true' or 'false', or omit for default (true).", + ), + status: Optional[str] = typer.Option( + None, + "--status", + help="Filter by status: active, funded, closed, finalized", + ), + type_filter: Optional[str] = typer.Option( + None, + "--type", + help="Filter by type: subnet, fundraising", + ), + sort_by: Optional[str] = typer.Option( + None, + "--sort-by", + help="Sort by: raised, end, contributors, id", + ), + sort_order: Optional[str] = typer.Option( + None, + "--sort-order", + help="Sort order: asc, desc (default: desc for raised, asc for id)", + ), + search_creator: Optional[str] = typer.Option( + None, + "--search-creator", + help="Search by creator address or identity name", + ), ): """ List crowdloans together with their funding progress and key metadata. @@ -8754,19 +8789,42 @@ def crowd_list( or a general fundraising crowdloan. Use `--verbose` for full-precision amounts and longer addresses. + Use `--show-identities` to show identity names (default: true). + Use `--status` to filter by status (active, funded, closed, finalized). + Use `--type` to filter by type (subnet, fundraising). + Use `--sort-by` and `--sort-order` to sort results. + Use `--search-creator` to search by creator address or identity name. EXAMPLES [green]$[/green] btcli crowd list [green]$[/green] btcli crowd list --verbose + + [green]$[/green] btcli crowd list --show-identities true + + [green]$[/green] btcli crowd list --status active --type subnet + + [green]$[/green] btcli crowd list --sort-by raised --sort-order desc + + [green]$[/green] btcli crowd list --search-creator "5D..." """ self.verbosity_handler(quiet, verbose, json_output, prompt=False) + # Parse show_identities: None or "true" -> True, "false" -> False + show_identities_bool = True # default + if show_identities is not None: + show_identities_bool = show_identities.lower() in ("true", "1", "yes") return self._run_command( view_crowdloan.list_crowdloans( subtensor=self.initialize_chain(network), verbose=verbose, json_output=json_output, + show_identities=show_identities_bool, + status_filter=status, + type_filter=type_filter, + sort_by=sort_by, + sort_order=sort_order, + search_creator=search_creator, ) ) @@ -8786,17 +8844,33 @@ def crowd_info( quiet: bool = Options.quiet, verbose: bool = Options.verbose, json_output: bool = Options.json_output, + show_identities: Optional[str] = typer.Option( + None, + "--show-identities", + help="Show identity names for creator and target address. Use 'true' or 'false', or omit for default (true).", + ), + show_contributors: Optional[str] = typer.Option( + None, + "--show-contributors", + help="Show contributor list with identities. Use 'true' or 'false', or omit for default (false).", + ), ): """ Display detailed information about a specific crowdloan. Includes funding progress, target account, and call details among other information. + Use `--show-identities` to show identity names (default: true). + Use `--show-contributors` to display the list of contributors (default: false). EXAMPLES [green]$[/green] btcli crowd info --id 0 [green]$[/green] btcli crowd info --id 1 --verbose + + [green]$[/green] btcli crowd info --id 0 --show-identities true + + [green]$[/green] btcli crowd info --id 0 --show-identities true --show-contributors true """ self.verbosity_handler(quiet, verbose, json_output, prompt=False) @@ -8817,6 +8891,16 @@ def crowd_info( validate=WV.WALLET, ) + # Parse show_identities: None or "true" -> True, "false" -> False + show_identities_bool = True # default + if show_identities is not None: + show_identities_bool = show_identities.lower() in ("true", "1", "yes") + + # Parse show_contributors: None or "false" -> False, "true" -> True + show_contributors_bool = False # default + if show_contributors is not None: + show_contributors_bool = show_contributors.lower() in ("true", "1", "yes") + return self._run_command( view_crowdloan.show_crowdloan_details( subtensor=self.initialize_chain(network), @@ -8824,6 +8908,54 @@ def crowd_info( wallet=wallet, verbose=verbose, json_output=json_output, + show_identities=show_identities_bool, + show_contributors=show_contributors_bool, + ) + ) + + def crowd_contributors( + self, + crowdloan_id: Optional[int] = typer.Option( + None, + "--crowdloan-id", + "--crowdloan_id", + "--id", + help="The ID of the crowdloan to list contributors for", + ), + network: Optional[list[str]] = Options.network, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """ + List all contributors to a specific crowdloan. + + Shows contributor addresses, contribution amounts, identity names, and percentages. + Contributors are sorted by contribution amount (highest first). + + EXAMPLES + + [green]$[/green] btcli crowd contributors --id 0 + + [green]$[/green] btcli crowd contributors --id 1 --verbose + + [green]$[/green] btcli crowd contributors --id 2 --json-output + """ + self.verbosity_handler(quiet, verbose, json_output, prompt=False) + + if crowdloan_id is None: + crowdloan_id = IntPrompt.ask( + f"Enter the [{COLORS.G.SUBHEAD_MAIN}]crowdloan id[/{COLORS.G.SUBHEAD_MAIN}]", + default=None, + show_default=False, + ) + + return self._run_command( + crowd_contributors.list_contributors( + subtensor=self.initialize_chain(network), + crowdloan_id=crowdloan_id, + verbose=verbose, + json_output=json_output, ) ) @@ -8886,6 +9018,21 @@ def crowd_create( help="Block number when subnet lease ends (omit for perpetual lease).", min=1, ), + custom_call_pallet: Optional[str] = typer.Option( + None, + "--custom-call-pallet", + help="Pallet name for custom Substrate call to attach to crowdloan.", + ), + custom_call_method: Optional[str] = typer.Option( + None, + "--custom-call-method", + help="Method name for custom Substrate call to attach to crowdloan.", + ), + custom_call_args: Optional[str] = typer.Option( + None, + "--custom-call-args", + help='JSON string of arguments for custom call (e.g., \'{"arg1": "value1", "arg2": 123}\').', + ), prompt: bool = Options.prompt, wait_for_inclusion: bool = Options.wait_for_inclusion, wait_for_finalization: bool = Options.wait_for_finalization, @@ -8898,6 +9045,7 @@ def crowd_create( Create a crowdloan that can either: 1. Raise funds for a specific address (general fundraising) 2. Create a new leased subnet where contributors receive emissions + 3. Attach any custom Substrate call (using --custom-call-pallet, --custom-call-method, --custom-call-args) EXAMPLES @@ -8909,6 +9057,9 @@ def crowd_create( Subnet lease ending at block 500000: [green]$[/green] btcli crowd create --subnet-lease --emissions-share 25 --lease-end-block 500000 + + Custom call: + [green]$[/green] btcli crowd create --deposit 10 --cap 1000 --duration 1000 --min-contribution 1 --custom-call-pallet "SomeModule" --custom-call-method "some_method" --custom-call-args '{"param1": "value", "param2": 42}' """ self.verbosity_handler(quiet, verbose, json_output, prompt) proxy = self.is_valid_proxy_name_or_ss58(proxy, announce_only) @@ -8933,6 +9084,9 @@ def crowd_create( subnet_lease=subnet_lease, emissions_share=emissions_share, lease_end_block=lease_end_block, + custom_call_pallet=custom_call_pallet, + custom_call_method=custom_call_method, + custom_call_args=custom_call_args, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, prompt=prompt, diff --git a/bittensor_cli/src/commands/crowd/contributors.py b/bittensor_cli/src/commands/crowd/contributors.py new file mode 100644 index 000000000..c64151e07 --- /dev/null +++ b/bittensor_cli/src/commands/crowd/contributors.py @@ -0,0 +1,265 @@ +from typing import Optional +import asyncio +import json +from rich.table import Table + +from bittensor_cli.src import COLORS +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, + json_console, + print_error, + millify_tao, + decode_account_id, +) + + +def _shorten(account: Optional[str]) -> str: + """Shorten an account address for display.""" + if not account: + return "-" + return f"{account[:6]}…{account[-6:]}" + + +def _get_identity_name(identity: dict) -> str: + """Extract identity name from identity dict.""" + if not identity: + return "-" + info = identity.get("info", {}) + display = info.get("display", {}) + if isinstance(display, dict): + return display.get("Raw", "") or display.get("value", "") or "-" + return str(display) if display else "-" + + +async def list_contributors( + subtensor: SubtensorInterface, + crowdloan_id: int, + verbose: bool = False, + json_output: bool = False, +) -> bool: + """List all contributors to a specific crowdloan. + + Args: + subtensor: SubtensorInterface object for chain interaction + crowdloan_id: ID of the crowdloan to list contributors for + verbose: Show full addresses and precise amounts + json_output: Output as JSON + + Returns: + bool: True if successful, False otherwise + """ + # First verify the crowdloan exists + crowdloan = await subtensor.get_single_crowdloan(crowdloan_id) + if not crowdloan: + error_msg = f"Crowdloan #{crowdloan_id} not found." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False + + # Query contributors from Contributions storage (double map) + # Query map with first key fixed to crowdloan_id to get all contributors + contributors_data = await subtensor.substrate.query_map( + module="Crowdloan", + storage_function="Contributions", + params=[crowdloan_id], + fully_exhaust=True, + ) + + # Extract contributors and their contributions from the map + contributor_contributions = {} + async for contributor_key, contribution_amount in contributors_data: + # Extract contributor address from the storage key + # For double maps queried with first key fixed, the key is a tuple: ((account_bytes_tuple,),) + # where account_bytes_tuple is a tuple of integers representing the account ID + try: + # The key structure is: ((account_bytes_tuple,),) + # where account_bytes_tuple is a tuple of integers (32 bytes = 32 ints) + if isinstance(contributor_key, tuple) and len(contributor_key) > 0: + inner_tuple = contributor_key[0] + if isinstance(inner_tuple, tuple): + # Decode the account ID from the tuple of integers + # decode_account_id handles both tuple[int] and tuple[tuple[int]] formats + contributor_address = decode_account_id(contributor_key) + else: + # Fallback: try to decode directly + contributor_address = decode_account_id(contributor_key) + else: + # Fallback: try to decode the key directly + contributor_address = decode_account_id(contributor_key) + + # Store contribution amount + # The value is a BittensorScaleType object, access .value to get the integer + contribution_value = ( + contribution_amount.value + if hasattr(contribution_amount, "value") + else contribution_amount + ) + contribution_balance = ( + Balance.from_rao(int(contribution_value)) + if contribution_value + else Balance.from_tao(0) + ) + contributor_contributions[contributor_address] = contribution_balance + except Exception as e: + # Skip invalid entries - uncomment for debugging + # print(f"Error processing contributor: {e}, key: {contributor_key}") + continue + + if not contributor_contributions: + if json_output: + json_console.print( + json.dumps( + { + "success": True, + "error": None, + "data": { + "crowdloan_id": crowdloan_id, + "contributors": [], + "total_count": 0, + "total_contributed": 0, + }, + } + ) + ) + else: + console.print( + f"[yellow]No contributors found for crowdloan #{crowdloan_id}.[/yellow]" + ) + return True + + # Fetch identities for all contributors + contributors_list = list(contributor_contributions.keys()) + identity_tasks = [ + subtensor.query_identity(contributor) for contributor in contributors_list + ] + identities = await asyncio.gather(*identity_tasks) + + # Build contributor data list + contributor_data = [] + total_contributed = Balance.from_tao(0) + + for contributor_address, identity in zip(contributors_list, identities): + contribution_amount = contributor_contributions[contributor_address] + total_contributed += contribution_amount + identity_name = _get_identity_name(identity) + + contributor_data.append( + { + "address": contributor_address, + "identity": identity_name, + "contribution": contribution_amount, + } + ) + + # Sort by contribution amount (descending) + contributor_data.sort(key=lambda x: x["contribution"].rao, reverse=True) + + # Calculate percentages + for data in contributor_data: + if total_contributed.rao > 0: + percentage = (data["contribution"].rao / total_contributed.rao) * 100 + else: + percentage = 0.0 + data["percentage"] = percentage + + if json_output: + contributors_json = [] + for rank, data in enumerate(contributor_data, start=1): + contributors_json.append( + { + "rank": rank, + "address": data["address"], + "identity": data["identity"], + "contribution_tao": data["contribution"].tao, + "contribution_rao": data["contribution"].rao, + "percentage": data["percentage"], + } + ) + + output_dict = { + "success": True, + "error": None, + "data": { + "crowdloan_id": crowdloan_id, + "contributors": contributors_json, + "total_count": len(contributor_data), + "total_contributed_tao": total_contributed.tao, + "total_contributed_rao": total_contributed.rao, + "network": subtensor.network, + }, + } + json_console.print(json.dumps(output_dict)) + return True + + # Display table + table = Table( + title=f"\n[{COLORS.G.HEADER}]Contributors for Crowdloan #{crowdloan_id}" + f"\nNetwork: [{COLORS.G.SUBHEAD}]{subtensor.network}\n\n", + show_footer=True, + show_edge=False, + header_style="bold white", + border_style="bright_black", + style="bold", + title_justify="center", + show_lines=False, + pad_edge=True, + ) + + table.add_column( + "[bold white]Rank", + style="grey89", + justify="center", + footer=str(len(contributor_data)), + ) + table.add_column( + "[bold white]Contributor Address", + style=COLORS.G.TEMPO, + justify="left", + overflow="fold", + ) + table.add_column( + "[bold white]Identity Name", + style=COLORS.G.SUBHEAD, + justify="left", + overflow="fold", + ) + table.add_column( + f"[bold white]Contribution\n({Balance.get_unit(0)})", + style="dark_sea_green2", + justify="right", + footer=f"τ {millify_tao(total_contributed.tao)}" + if not verbose + else f"τ {total_contributed.tao:,.4f}", + ) + table.add_column( + "[bold white]Percentage", + style=COLORS.P.EMISSION, + justify="right", + footer="100.00%", + ) + + for rank, data in enumerate(contributor_data, start=1): + address_cell = data["address"] if verbose else _shorten(data["address"]) + identity_cell = data["identity"] if data["identity"] != "-" else "[dim]-[/dim]" + + if verbose: + contribution_cell = f"τ {data['contribution'].tao:,.4f}" + else: + contribution_cell = f"τ {millify_tao(data['contribution'].tao)}" + + percentage_cell = f"{data['percentage']:.2f}%" + + table.add_row( + str(rank), + address_cell, + identity_cell, + contribution_cell, + percentage_cell, + ) + + console.print(table) + return True diff --git a/bittensor_cli/src/commands/crowd/create.py b/bittensor_cli/src/commands/crowd/create.py index f14d4aae3..5f6ed5208 100644 --- a/bittensor_cli/src/commands/crowd/create.py +++ b/bittensor_cli/src/commands/crowd/create.py @@ -25,6 +25,105 @@ ) +async def validate_and_compose_custom_call( + subtensor: SubtensorInterface, + pallet_name: str, + method_name: str, + args_json: str, +) -> tuple[Optional[GenericCall], Optional[str]]: + """ + Validate and compose a custom Substrate call. + + Args: + subtensor: SubtensorInterface instance + pallet_name: Name of the pallet/module + method_name: Name of the method/function + args_json: JSON string of call arguments + + Returns: + Tuple of (GenericCall or None, error_message or None) + """ + try: + # Parse JSON arguments + try: + call_params = json.loads(args_json) if args_json else {} + except json.JSONDecodeError as e: + return None, f"Invalid JSON in custom call args: {e}" + + # Get metadata to validate call exists + block_hash = await subtensor.substrate.get_chain_head() + runtime = await subtensor.substrate.init_runtime(block_hash=block_hash) + metadata = runtime.metadata + + # Check if pallet exists + try: + # Try using get_metadata_pallet if available (cleaner approach) + if hasattr(metadata, "get_metadata_pallet"): + pallet = metadata.get_metadata_pallet(pallet_name) + else: + # Fallback to iteration + pallet = None + for pallet_item in metadata.pallets: + if pallet_item.name == pallet_name: + pallet = pallet_item + break + except (AttributeError, ValueError): + # Pallet not found + pallet = None + + if pallet is None: + available_pallets = [p.name for p in metadata.pallets] + return None, ( + f"Pallet '{pallet_name}' not found in runtime metadata. " + f"Available pallets: {', '.join(available_pallets[:10])}" + + ( + f" and {len(available_pallets) - 10} more..." + if len(available_pallets) > 10 + else "" + ) + ) + + # Check if method exists in pallet + call_index = None + call_type = None + for call_item in pallet.calls: + if call_item.name == method_name: + call_index = call_item.index + call_type = call_item.type + break + + if call_index is None: + available_methods = [c.name for c in pallet.calls] + return None, ( + f"Method '{method_name}' not found in pallet '{pallet_name}'. " + f"Available methods: {', '.join(available_methods[:10])}" + + ( + f" and {len(available_methods) - 10} more..." + if len(available_methods) > 10 + else "" + ) + ) + + # Validate and compose the call + # The compose_call method will validate the parameters match expected types + try: + call = await subtensor.substrate.compose_call( + call_module=pallet_name, + call_function=method_name, + call_params=call_params, + ) + return call, None + except Exception as e: + error_msg = str(e) + # Try to provide more helpful error messages + if "parameter" in error_msg.lower() or "type" in error_msg.lower(): + return None, f"Invalid call parameters: {error_msg}" + return None, f"Failed to compose call: {error_msg}" + + except Exception as e: + return None, f"Error validating custom call: {str(e)}" + + async def create_crowdloan( subtensor: SubtensorInterface, wallet: Wallet, @@ -37,6 +136,9 @@ async def create_crowdloan( subnet_lease: Optional[bool], emissions_share: Optional[int], lease_end_block: Optional[int], + custom_call_pallet: Optional[str], + custom_call_method: Optional[str], + custom_call_args: Optional[str], wait_for_inclusion: bool, wait_for_finalization: bool, prompt: bool, @@ -59,9 +161,35 @@ async def create_crowdloan( print_error(f"[red]{unlock_status.message}[/red]") return False, unlock_status.message + # Check for custom call options + has_custom_call = any([custom_call_pallet, custom_call_method, custom_call_args]) + if has_custom_call: + if not all([custom_call_pallet, custom_call_method]): + error_msg = "Both --custom-call-pallet and --custom-call-method must be provided when using custom call." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, error_msg + + # Custom call args can be empty JSON object if method has no parameters + if custom_call_args is None: + custom_call_args = "{}" + + # Check mutual exclusivity with subnet_lease + if subnet_lease is not None: + error_msg = "--custom-call-pallet/--custom-call-method cannot be used together with --subnet-lease. Use one or the other." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, error_msg + crowdloan_type: str if subnet_lease is not None: crowdloan_type = "subnet" if subnet_lease else "fundraising" + elif has_custom_call: + crowdloan_type = "custom" elif prompt: type_choice = IntPrompt.ask( "\n[bold cyan]What type of crowdloan would you like to create?[/bold cyan]\n" @@ -80,6 +208,12 @@ async def create_crowdloan( " • You will become the subnet operator\n" f" • [yellow]Note: Ensure cap covers subnet registration cost (currently {current_burn_cost.tao:,.2f} TAO)[/yellow]\n" ) + elif crowdloan_type == "custom": + console.print( + "\n[yellow]Custom Call Crowdloan Selected[/yellow]\n" + " • A custom Substrate call will be executed when the crowdloan is finalized\n" + " • Ensure the call parameters are correct before proceeding\n" + ) else: console.print( "\n[cyan]General Fundraising Crowdloan Selected[/cyan]\n" @@ -218,7 +352,31 @@ async def create_crowdloan( current_block = await subtensor.substrate.get_block_number(None) call_to_attach: Optional[GenericCall] lease_perpetual = None - if crowdloan_type == "subnet": + custom_call_info: Optional[dict] = None + + if crowdloan_type == "custom": + # Validate and compose custom call + call_to_attach, error_msg = await validate_and_compose_custom_call( + subtensor=subtensor, + pallet_name=custom_call_pallet, + method_name=custom_call_method, + args_json=custom_call_args or "{}", + ) + + if call_to_attach is None: + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, error_msg or "Failed to validate custom call." + + custom_call_info = { + "pallet": custom_call_pallet, + "method": custom_call_method, + "args": json.loads(custom_call_args or "{}"), + } + target_address = None # Custom calls don't use target_address + elif crowdloan_type == "subnet": target_address = None if emissions_share is None: @@ -325,6 +483,16 @@ async def create_crowdloan( table.add_row("Lease Ends", f"Block {lease_end_block}") else: table.add_row("Lease Duration", "[green]Perpetual[/green]") + elif crowdloan_type == "custom": + table.add_row("Type", "[yellow]Custom Call[/yellow]") + table.add_row("Pallet", f"[cyan]{custom_call_info['pallet']}[/cyan]") + table.add_row("Method", f"[cyan]{custom_call_info['method']}[/cyan]") + args_str = ( + json.dumps(custom_call_info["args"], indent=2) + if custom_call_info["args"] + else "{}" + ) + table.add_row("Call Arguments", f"[dim]{args_str}[/dim]") else: table.add_row("Type", "[cyan]General Fundraising[/cyan]") target_text = ( @@ -403,6 +571,8 @@ async def create_crowdloan( output_dict["data"]["emissions_share"] = emissions_share output_dict["data"]["lease_end_block"] = lease_end_block output_dict["data"]["perpetual_lease"] = lease_end_block is None + elif crowdloan_type == "custom": + output_dict["data"]["custom_call"] = custom_call_info else: output_dict["data"]["target_address"] = target_address @@ -424,6 +594,21 @@ async def create_crowdloan( console.print(f" Lease ends at block: [bold]{lease_end_block}[/bold]") else: console.print(" Lease: [green]Perpetual[/green]") + elif crowdloan_type == "custom": + message = "Custom call crowdloan created successfully." + console.print( + f"\n:white_check_mark: [green]{message}[/green]\n" + f" Type: [yellow]Custom Call[/yellow]\n" + f" Pallet: [cyan]{custom_call_info['pallet']}[/cyan]\n" + f" Method: [cyan]{custom_call_info['method']}[/cyan]\n" + f" Deposit: [{COLORS.P.TAO}]{deposit}[/{COLORS.P.TAO}]\n" + f" Min contribution: [{COLORS.P.TAO}]{min_contribution}[/{COLORS.P.TAO}]\n" + f" Cap: [{COLORS.P.TAO}]{cap}[/{COLORS.P.TAO}]\n" + f" Ends at block: [bold]{end_block}[/bold]" + ) + if custom_call_info["args"]: + args_str = json.dumps(custom_call_info["args"], indent=2) + console.print(f" Call Arguments:\n{args_str}") else: message = "Fundraising crowdloan created successfully." print_success(message) diff --git a/bittensor_cli/src/commands/crowd/view.py b/bittensor_cli/src/commands/crowd/view.py index 9a248d18f..03932e8eb 100644 --- a/bittensor_cli/src/commands/crowd/view.py +++ b/bittensor_cli/src/commands/crowd/view.py @@ -25,6 +25,47 @@ def _shorten(account: Optional[str]) -> str: return f"{account[:6]}…{account[-6:]}" +def _get_identity_name(identity: dict) -> str: + """Extract identity name from identity dict. + + Handles both flat structure (from decode_hex_identity) and nested structure. + """ + if not identity: + return "" + + # Try direct display/name fields first (flat structure from decode_hex_identity) + if identity.get("display"): + display = identity.get("display") + if isinstance(display, str): + return display + if isinstance(display, dict): + return display.get("Raw", "") or display.get("value", "") or "" + + if identity.get("name"): + name = identity.get("name") + if isinstance(name, str): + return name + if isinstance(name, dict): + return name.get("Raw", "") or name.get("value", "") or "" + + # Try nested structure (info.display.Raw) + info = identity.get("info", {}) + if info: + display = info.get("display", {}) + if isinstance(display, dict): + return display.get("Raw", "") or display.get("value", "") or "" + if isinstance(display, str): + return display + + name = info.get("name", {}) + if isinstance(name, dict): + return name.get("Raw", "") or name.get("value", "") or "" + if isinstance(name, str): + return name + + return "" + + def _status(loan: CrowdloanData, current_block: int) -> str: if loan.finalized: return "Finalized" @@ -44,12 +85,45 @@ def _time_remaining(loan: CrowdloanData, current_block: int) -> str: return f"Closed {blocks_to_duration(abs(diff))} ago" +def _get_loan_type(loan: CrowdloanData) -> str: + """Determine if a loan is subnet leasing or fundraising.""" + if loan.call_details: + pallet = loan.call_details.get("pallet", "") + method = loan.call_details.get("method", "") + if pallet == "SubtensorModule" and method == "register_leased_network": + return "subnet" + # If has_call is True, it likely indicates a subnet loan + # (subnet loans have calls attached, fundraising loans typically don't) + if loan.has_call: + return "subnet" + # Default to fundraising if no call attached + return "fundraising" + + async def list_crowdloans( subtensor: SubtensorInterface, verbose: bool = False, json_output: bool = False, + show_identities: bool = True, + status_filter: Optional[str] = None, + type_filter: Optional[str] = None, + sort_by: Optional[str] = None, + sort_order: Optional[str] = None, + search_creator: Optional[str] = None, ) -> bool: - """List all crowdloans in a tabular format or JSON output.""" + """List all crowdloans in a tabular format or JSON output. + + Args: + subtensor: SubtensorInterface object for chain interaction + verbose: Show full addresses and precise amounts + json_output: Output as JSON + show_identities: Show identity names for creators and targets + status_filter: Filter by status (active, funded, closed, finalized) + type_filter: Filter by type (subnet, fundraising) + sort_by: Sort by field (raised, end, contributors, id) + sort_order: Sort order (asc, desc) + search_creator: Search by creator address or identity name + """ current_block, loans = await asyncio.gather( subtensor.substrate.get_block_number(None), @@ -76,10 +150,80 @@ async def list_crowdloans( console.print("[yellow]No crowdloans found.[/yellow]") return True - total_raised = sum(loan.raised.tao for loan in loans.values()) - total_cap = sum(loan.cap.tao for loan in loans.values()) - total_loans = len(loans) - total_contributors = sum(loan.contributors_count for loan in loans.values()) + # Batch fetch identities early if needed for filtering/searching + identity_map = {} + if show_identities or search_creator: + addresses_to_fetch = set() + for loan in loans.values(): + addresses_to_fetch.add(loan.creator) + if loan.target_address: + addresses_to_fetch.add(loan.target_address) + + identity_tasks = [ + subtensor.query_identity(address) for address in addresses_to_fetch + ] + identities = await asyncio.gather(*identity_tasks) + + for address, identity in zip(addresses_to_fetch, identities): + identity_name = _get_identity_name(identity) + if identity_name: + identity_map[address] = identity_name + + # Apply filters + filtered_loans = {} + for loan_id, loan in loans.items(): + # Filter by status + if status_filter: + loan_status = _status(loan, current_block) + if loan_status.lower() != status_filter.lower(): + continue + + # Filter by type + if type_filter: + loan_type = _get_loan_type(loan) + if loan_type.lower() != type_filter.lower(): + continue + + # Filter by creator search + if search_creator: + search_term = search_creator.lower() + creator_match = loan.creator.lower().find(search_term) != -1 + identity_match = False + if loan.creator in identity_map: + identity_name = identity_map[loan.creator].lower() + identity_match = identity_name.find(search_term) != -1 + if not creator_match and not identity_match: + continue + + filtered_loans[loan_id] = loan + + if not filtered_loans: + if json_output: + json_console.print( + json.dumps( + { + "success": True, + "error": None, + "data": { + "crowdloans": [], + "total_count": 0, + "total_raised": 0, + "total_cap": 0, + "total_contributors": 0, + }, + } + ) + ) + else: + console.print("[yellow]No crowdloans found matching the filters.[/yellow]") + return True + + total_raised = sum(loan.raised.tao for loan in filtered_loans.values()) + total_cap = sum(loan.cap.tao for loan in filtered_loans.values()) + total_loans = len(filtered_loans) + total_contributors = sum( + loan.contributors_count for loan in filtered_loans.values() + ) funding_percentage = (total_raised / total_cap * 100) if total_cap > 0 else 0 percentage_color = "dark_sea_green" if funding_percentage < 100 else "red" @@ -89,7 +233,7 @@ async def list_crowdloans( if json_output: crowdloans_list = [] - for loan_id, loan in loans.items(): + for loan_id, loan in filtered_loans.items(): status = _status(loan, current_block) time_remaining = _time_remaining(loan, current_block) @@ -119,19 +263,47 @@ async def list_crowdloans( "time_remaining": time_remaining, "contributors_count": loan.contributors_count, "creator": loan.creator, + "creator_identity": identity_map.get(loan.creator) + if show_identities + else None, "target_address": loan.target_address, + "target_identity": identity_map.get(loan.target_address) + if show_identities and loan.target_address + else None, "funds_account": loan.funds_account, "call": call_info, "finalized": loan.finalized, } crowdloans_list.append(crowdloan_data) - crowdloans_list.sort( - key=lambda x: ( - x["status"] != "Active", - -x["raised"], + # Apply sorting + if sort_by: + reverse_order = True + if sort_order: + reverse_order = sort_order.lower() == "desc" + elif sort_by.lower() == "id": + reverse_order = False + + if sort_by.lower() == "raised": + crowdloans_list.sort(key=lambda x: x["raised"], reverse=reverse_order) + elif sort_by.lower() == "end": + crowdloans_list.sort( + key=lambda x: x["end_block"], reverse=reverse_order + ) + elif sort_by.lower() == "contributors": + crowdloans_list.sort( + key=lambda x: x["contributors_count"], reverse=reverse_order + ) + elif sort_by.lower() == "id": + crowdloans_list.sort(key=lambda x: x["id"], reverse=reverse_order) + else: + # Default sorting: Active first, then by raised amount descending + crowdloans_list.sort( + key=lambda x: ( + x["status"] != "Active", + -x["raised"], + ) ) - ) output_dict = { "success": True, @@ -221,13 +393,56 @@ async def list_crowdloans( ) table.add_column("[bold white]Call", style="grey89", justify="center") - sorted_loans = sorted( - loans.items(), - key=lambda x: ( - _status(x[1], current_block) != "Active", # Active loans first - -x[1].raised.tao, # Then by raised amount (descending) - ), - ) + # Apply sorting for table display + if sort_by: + reverse_order = True + if sort_order: + reverse_order = sort_order.lower() == "desc" + elif sort_by.lower() == "id": + reverse_order = False + + if sort_by.lower() == "raised": + sorted_loans = sorted( + filtered_loans.items(), + key=lambda x: x[1].raised.tao, + reverse=reverse_order, + ) + elif sort_by.lower() == "end": + sorted_loans = sorted( + filtered_loans.items(), + key=lambda x: x[1].end, + reverse=reverse_order, + ) + elif sort_by.lower() == "contributors": + sorted_loans = sorted( + filtered_loans.items(), + key=lambda x: x[1].contributors_count, + reverse=reverse_order, + ) + elif sort_by.lower() == "id": + sorted_loans = sorted( + filtered_loans.items(), + key=lambda x: x[0], + reverse=reverse_order, + ) + else: + # Default sorting + sorted_loans = sorted( + filtered_loans.items(), + key=lambda x: ( + _status(x[1], current_block) != "Active", + -x[1].raised.tao, + ), + ) + else: + # Default sorting: Active loans first, then by raised amount (descending) + sorted_loans = sorted( + filtered_loans.items(), + key=lambda x: ( + _status(x[1], current_block) != "Active", # Active loans first + -x[1].raised.tao, # Then by raised amount (descending) + ), + ) for loan_id, loan in sorted_loans: status = _status(loan, current_block) @@ -267,14 +482,32 @@ async def list_crowdloans( else: time_cell = time_label - creator_cell = loan.creator if verbose else _shorten(loan.creator) - target_cell = ( - loan.target_address - if loan.target_address - else f"[{COLORS.G.SUBHEAD_MAIN}]Not specified[/{COLORS.G.SUBHEAD_MAIN}]" - ) - if not verbose and loan.target_address: - target_cell = _shorten(loan.target_address) + # Format creator cell with identity if available + if show_identities and loan.creator in identity_map: + creator_identity = identity_map[loan.creator] + if verbose: + creator_cell = f"{creator_identity} ({loan.creator})" + else: + creator_cell = f"{creator_identity} ({_shorten(loan.creator)})" + else: + creator_cell = loan.creator if verbose else _shorten(loan.creator) + + # Format target cell with identity if available + if loan.target_address: + if show_identities and loan.target_address in identity_map: + target_identity = identity_map[loan.target_address] + if verbose: + target_cell = f"{target_identity} ({loan.target_address})" + else: + target_cell = f"{target_identity} ({_shorten(loan.target_address)})" + else: + target_cell = ( + loan.target_address if verbose else _shorten(loan.target_address) + ) + else: + target_cell = ( + f"[{COLORS.G.SUBHEAD_MAIN}]Not specified[/{COLORS.G.SUBHEAD_MAIN}]" + ) funds_account_cell = ( loan.funds_account if verbose else _shorten(loan.funds_account) @@ -327,6 +560,8 @@ async def show_crowdloan_details( wallet: Optional[Wallet] = None, verbose: bool = False, json_output: bool = False, + show_identities: bool = True, + show_contributors: bool = False, ) -> tuple[bool, str]: """Display detailed information about a specific crowdloan.""" @@ -349,6 +584,23 @@ async def show_crowdloan_details( crowdloan_id, wallet.coldkeypub.ss58_address ) + # Fetch identities if show_identities is enabled + identity_map = {} + if show_identities: + addresses_to_fetch = [crowdloan.creator] + if crowdloan.target_address: + addresses_to_fetch.append(crowdloan.target_address) + + identity_tasks = [ + subtensor.query_identity(address) for address in addresses_to_fetch + ] + identities = await asyncio.gather(*identity_tasks) + + for address, identity in zip(addresses_to_fetch, identities): + identity_name = _get_identity_name(identity) + if identity_name: + identity_map[address] = identity_name + status = _status(crowdloan, current_block) status_color_map = { "Finalized": COLORS.G.SUCCESS, @@ -417,6 +669,9 @@ async def show_crowdloan_details( "status": status, "finalized": crowdloan.finalized, "creator": crowdloan.creator, + "creator_identity": identity_map.get(crowdloan.creator) + if show_identities + else None, "funds_account": crowdloan.funds_account, "raised": crowdloan.raised.tao, "cap": crowdloan.cap.tao, @@ -431,12 +686,104 @@ async def show_crowdloan_details( "contributors_count": crowdloan.contributors_count, "average_contribution": avg_contribution, "target_address": crowdloan.target_address, + "target_identity": identity_map.get(crowdloan.target_address) + if show_identities and crowdloan.target_address + else None, "has_call": crowdloan.has_call, "call_details": call_info, "user_contribution": user_contribution_info, "network": subtensor.network, }, } + + # Add contributors list if requested + if show_contributors: + from bittensor_cli.src.commands.crowd.contributors import list_contributors + + # We'll fetch contributors separately and add to output + contributors_data = await subtensor.substrate.query_map( + module="Crowdloan", + storage_function="Contributions", + params=[crowdloan_id], + fully_exhaust=True, + ) + + from bittensor_cli.src.bittensor.utils import decode_account_id + + contributor_contributions = {} + async for contributor_key, contribution_amount in contributors_data: + try: + contributor_address = decode_account_id(contributor_key) + contribution_value = ( + contribution_amount.value + if hasattr(contribution_amount, "value") + else contribution_amount + ) + contribution_balance = ( + Balance.from_rao(int(contribution_value)) + if contribution_value + else Balance.from_tao(0) + ) + contributor_contributions[contributor_address] = ( + contribution_balance + ) + except Exception: + continue + + # Fetch identities for contributors + contributors_list = list(contributor_contributions.keys()) + if contributors_list: + contributor_identity_tasks = [ + subtensor.query_identity(contributor) + for contributor in contributors_list + ] + contributor_identities = await asyncio.gather( + *contributor_identity_tasks + ) + + contributors_json = [] + total_contributed = Balance.from_tao(0) + for ( + contributor_address, + contribution_amount, + ) in contributor_contributions.items(): + total_contributed += contribution_amount + + contributor_data = [] + for contributor_address, identity in zip( + contributors_list, contributor_identities + ): + contribution_amount = contributor_contributions[contributor_address] + identity_name = _get_identity_name(identity) + contributor_data.append( + { + "address": contributor_address, + "identity": identity_name if identity_name else None, + "contribution": contribution_amount, + } + ) + + contributor_data.sort(key=lambda x: x["contribution"].rao, reverse=True) + + for rank, data in enumerate(contributor_data, start=1): + percentage = ( + (data["contribution"].rao / total_contributed.rao * 100) + if total_contributed.rao > 0 + else 0 + ) + contributors_json.append( + { + "rank": rank, + "address": data["address"], + "identity": data["identity"], + "contribution_tao": data["contribution"].tao, + "contribution_rao": data["contribution"].rao, + "percentage": percentage, + } + ) + + output_dict["data"]["contributors"] = contributors_json + json_console.print(json.dumps(output_dict)) return True, f"Displayed info for crowdloan #{crowdloan_id}" @@ -474,9 +821,20 @@ async def show_crowdloan_details( status_detail = " [green](successfully completed)[/green]" table.add_row("Status", f"[{status_color}]{status}[/{status_color}]{status_detail}") + + # Display creator with identity if available + creator_display = crowdloan.creator + if show_identities and crowdloan.creator in identity_map: + creator_identity = identity_map[crowdloan.creator] + if verbose: + creator_display = f"{creator_identity} ({crowdloan.creator})" + else: + creator_display = f"{creator_identity} ({_shorten(crowdloan.creator)})" + elif not verbose: + creator_display = _shorten(crowdloan.creator) table.add_row( "Creator", - f"[{COLORS.G.TEMPO}]{crowdloan.creator}[/{COLORS.G.TEMPO}]", + f"[{COLORS.G.TEMPO}]{creator_display}[/{COLORS.G.TEMPO}]", ) table.add_row( "Funds Account", @@ -582,7 +940,20 @@ async def show_crowdloan_details( table.add_section() if crowdloan.target_address: - target_display = crowdloan.target_address + if show_identities and crowdloan.target_address in identity_map: + target_identity = identity_map[crowdloan.target_address] + if verbose: + target_display = f"{target_identity} ({crowdloan.target_address})" + else: + target_display = ( + f"{target_identity} ({_shorten(crowdloan.target_address)})" + ) + else: + target_display = ( + crowdloan.target_address + if verbose + else _shorten(crowdloan.target_address) + ) else: target_display = ( f"[{COLORS.G.SUBHEAD_MAIN}]Not specified[/{COLORS.G.SUBHEAD_MAIN}]" @@ -637,5 +1008,111 @@ async def show_crowdloan_details( else: table.add_row(arg_name, str(display_value)) + # CONTRIBUTORS Section (if requested) + if show_contributors: + table.add_section() + table.add_row("[cyan underline]CONTRIBUTORS[/cyan underline]", "") + table.add_section() + + # Fetch contributors + contributors_data = await subtensor.substrate.query_map( + module="Crowdloan", + storage_function="Contributions", + params=[crowdloan_id], + fully_exhaust=True, + ) + + from bittensor_cli.src.bittensor.utils import decode_account_id + + contributor_contributions = {} + async for contributor_key, contribution_amount in contributors_data: + try: + contributor_address = decode_account_id(contributor_key) + contribution_value = ( + contribution_amount.value + if hasattr(contribution_amount, "value") + else contribution_amount + ) + contribution_balance = ( + Balance.from_rao(int(contribution_value)) + if contribution_value + else Balance.from_tao(0) + ) + contributor_contributions[contributor_address] = contribution_balance + except Exception: + continue + + if contributor_contributions: + # Fetch identities for contributors + contributors_list = list(contributor_contributions.keys()) + contributor_identity_tasks = [ + subtensor.query_identity(contributor) + for contributor in contributors_list + ] + contributor_identities = await asyncio.gather(*contributor_identity_tasks) + + # Build contributor data list + contributor_data = [] + total_contributed = Balance.from_tao(0) + + for contributor_address, identity in zip( + contributors_list, contributor_identities + ): + contribution_amount = contributor_contributions[contributor_address] + total_contributed += contribution_amount + identity_name = _get_identity_name(identity) + + contributor_data.append( + { + "address": contributor_address, + "identity": identity_name, + "contribution": contribution_amount, + } + ) + + # Sort by contribution amount (descending) + contributor_data.sort(key=lambda x: x["contribution"].rao, reverse=True) + + # Display contributors in table + for rank, data in enumerate(contributor_data[:10], start=1): # Show top 10 + address_display = ( + data["address"] if verbose else _shorten(data["address"]) + ) + identity_display = ( + data["identity"] if data["identity"] else "[dim]-[/dim]" + ) + + if data["identity"]: + if verbose: + contributor_display = f"{identity_display} ({address_display})" + else: + contributor_display = f"{identity_display} ({address_display})" + else: + contributor_display = address_display + + if verbose: + contribution_display = f"τ {data['contribution'].tao:,.4f}" + else: + contribution_display = f"τ {millify_tao(data['contribution'].tao)}" + + percentage = ( + (data["contribution"].rao / total_contributed.rao * 100) + if total_contributed.rao > 0 + else 0 + ) + + table.add_row( + f"#{rank}", + f"{contributor_display} - {contribution_display} ({percentage:.2f}%)", + ) + + if len(contributor_data) > 10: + table.add_row( + "", + f"[dim]... and {len(contributor_data) - 10} more contributors[/dim]", + ) + else: + table.add_row("", "[dim]No contributors yet[/dim]") + console.print(table) return True, f"Displayed info for crowdloan #{crowdloan_id}" diff --git a/pyproject.toml b/pyproject.toml index faa1b37d8..d24cd16aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,3 +65,6 @@ dev = [ # more details can be found here homepage = "https://github.com/opentensor/btcli" Repository = "https://github.com/opentensor/btcli" + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/tests/e2e_tests/test_crowd_contributors.py b/tests/e2e_tests/test_crowd_contributors.py new file mode 100644 index 000000000..ebfb842a9 --- /dev/null +++ b/tests/e2e_tests/test_crowd_contributors.py @@ -0,0 +1,260 @@ +""" +E2E tests for crowd contributors command. + +Verify command: +* btcli crowd contributors --id +""" + +import json +import pytest + + +@pytest.mark.parametrize("local_chain", [False], indirect=True) +def test_crowd_contributors_command(local_chain, wallet_setup): + """ + Test crowd contributors command and inspect its output. + + Steps: + 1. Create a crowdloan (if needed) or use existing one + 2. Make contributions to the crowdloan + 3. Execute contributors command and verify output + 4. Test with --verbose flag + 5. Test with --json-output flag + + Note: This test requires an existing crowdloan with contributors. + For a full e2e test, you would need to: + - Create a crowdloan + - Make contributions + - Then list contributors + """ + wallet_path_alice = "//Alice" + + # Create wallet for Alice + keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( + wallet_path_alice + ) + + # Test 1: List contributors for an existing crowdloan (assuming crowdloan #0 exists) + # This will work if there's a crowdloan with contributors on the test chain + result = exec_command_alice( + command="crowd", + sub_command="contributors", + extra_args=[ + "--id", + "0", + "--network", + "ws://127.0.0.1:9945", + "--json-output", + ], + ) + + # Parse JSON output + try: + result_output = json.loads(result.stdout) + # If crowdloan exists and has contributors + if result_output.get("success") is True: + assert "data" in result_output + assert "contributors" in result_output["data"] + assert "crowdloan_id" in result_output["data"] + assert result_output["data"]["crowdloan_id"] == 0 + assert isinstance(result_output["data"]["contributors"], list) + assert "total_count" in result_output["data"] + assert "total_contributed_tao" in result_output["data"] + + # If there are contributors, verify structure + if result_output["data"]["total_count"] > 0: + contributor = result_output["data"]["contributors"][0] + assert "rank" in contributor + assert "address" in contributor + assert "identity" in contributor + assert "contribution_tao" in contributor + assert "contribution_rao" in contributor + assert "percentage" in contributor + assert contributor["rank"] == 1 # First contributor should be rank 1 + assert contributor["contribution_tao"] >= 0 + assert 0 <= contributor["percentage"] <= 100 + + # If crowdloan doesn't exist or has no contributors + elif result_output.get("success") is False: + assert "error" in result_output + except json.JSONDecodeError: + # If output is not JSON (shouldn't happen with --json-output) + pytest.fail("Expected JSON output but got non-JSON response") + + # Test 2: Test with verbose flag + result_verbose = exec_command_alice( + command="crowd", + sub_command="contributors", + extra_args=[ + "--id", + "0", + "--network", + "ws://127.0.0.1:9945", + "--verbose", + ], + ) + + # Verify verbose output (should show full addresses) + assert result_verbose.exit_code == 0 or result_verbose.exit_code is None + + # Test 3: Test with non-existent crowdloan + result_not_found = exec_command_alice( + command="crowd", + sub_command="contributors", + extra_args=[ + "--id", + "99999", + "--network", + "ws://127.0.0.1:9945", + "--json-output", + ], + ) + + try: + result_output = json.loads(result_not_found.stdout) + # Should return error for non-existent crowdloan + assert result_output.get("success") is False + assert "error" in result_output + assert "not found" in result_output["error"].lower() + except json.JSONDecodeError: + # If output is not JSON, that's also acceptable for error cases + pass + + +@pytest.mark.parametrize("local_chain", [False], indirect=True) +def test_crowd_contributors_with_real_crowdloan(local_chain, wallet_setup): + """ + Full e2e test: Create crowdloan, contribute, then list contributors. + + Steps: + 1. Create a crowdloan + 2. Make contributions from multiple wallets + 3. List contributors and verify all are present + 4. Verify sorting by contribution amount + """ + wallet_path_alice = "//Alice" + wallet_path_bob = "//Bob" + + # Create wallets + keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( + wallet_path_alice + ) + keypair_bob, wallet_bob, wallet_path_bob, exec_command_bob = wallet_setup( + wallet_path_bob + ) + + # Step 1: Create a crowdloan + create_result = exec_command_alice( + command="crowd", + sub_command="create", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--deposit", + "10", + "--cap", + "100", + "--duration", + "10000", + "--min-contribution", + "1", + "--no-prompt", + "--json-output", + ], + ) + + try: + create_output = json.loads(create_result.stdout) + if create_output.get("success") is True: + crowdloan_id = create_output.get("crowdloan_id") or create_output.get( + "data", {} + ).get("crowdloan_id") + + if crowdloan_id is not None: + # Step 2: Make contributions + # Alice contributes + contribute_alice = exec_command_alice( + command="crowd", + sub_command="contribute", + extra_args=[ + "--id", + str(crowdloan_id), + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--amount", + "20", + "--no-prompt", + "--json-output", + ], + ) + + # Bob contributes + contribute_bob = exec_command_bob( + command="crowd", + sub_command="contribute", + extra_args=[ + "--id", + str(crowdloan_id), + "--wallet-path", + wallet_path_bob, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_bob.name, + "--wallet-hotkey", + wallet_bob.hotkey_str, + "--amount", + "30", + "--no-prompt", + "--json-output", + ], + ) + + # Step 3: List contributors + contributors_result = exec_command_alice( + command="crowd", + sub_command="contributors", + extra_args=[ + "--id", + str(crowdloan_id), + "--network", + "ws://127.0.0.1:9945", + "--json-output", + ], + ) + + contributors_output = json.loads(contributors_result.stdout) + assert contributors_output.get("success") is True + assert contributors_output["data"]["crowdloan_id"] == crowdloan_id + assert contributors_output["data"]["total_count"] >= 2 + + # Verify contributors are sorted by contribution (descending) + contributors_list = contributors_output["data"]["contributors"] + if len(contributors_list) >= 2: + # Bob should be first (30 TAO > 20 TAO) + assert ( + contributors_list[0]["contribution_tao"] + >= contributors_list[1]["contribution_tao"] + ) + + # Verify percentages sum to 100% + total_percentage = sum(c["percentage"] for c in contributors_list) + assert ( + abs(total_percentage - 100.0) < 0.01 + ) # Allow small floating point errors + + except (json.JSONDecodeError, KeyError, AssertionError) as e: + # Skip test if prerequisites aren't met (e.g., insufficient balance, chain not ready) + pytest.skip(f"Test prerequisites not met: {e}") diff --git a/tests/e2e_tests/test_crowd_identity_display.py b/tests/e2e_tests/test_crowd_identity_display.py new file mode 100644 index 000000000..7953082d9 --- /dev/null +++ b/tests/e2e_tests/test_crowd_identity_display.py @@ -0,0 +1,150 @@ +""" +E2E tests for crowd identity display functionality. + +Verify commands: +* btcli crowd list --show-identities +* btcli crowd info --id --show-identities --show-contributors +""" + +import json +import pytest + + +@pytest.mark.parametrize("local_chain", [False], indirect=True) +def test_crowd_list_with_identities(local_chain, wallet_setup): + """ + Test crowd list command with identity display. + + Steps: + 1. Execute crowd list with --show-identities (default) + 2. Execute crowd list with --no-show-identities + 3. Verify identity information is displayed when enabled + """ + wallet_path_alice = "//Alice" + + # Create wallet for Alice + keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( + wallet_path_alice + ) + + # Test 1: List with identities (default) + result = exec_command_alice( + command="crowd", + sub_command="list", + extra_args=[ + "--network", + "ws://127.0.0.1:9945", + "--json-output", + ], + ) + + try: + result_output = json.loads(result.stdout) + if result_output.get("success") is True: + assert "data" in result_output + assert "crowdloans" in result_output["data"] + + # Check if identity fields are present + if result_output["data"]["crowdloans"]: + crowdloan = result_output["data"]["crowdloans"][0] + # Identity fields should be present (may be None if no identity) + assert "creator_identity" in crowdloan + assert "target_identity" in crowdloan + except json.JSONDecodeError: + pytest.skip("Could not parse JSON output") + + # Test 2: List without identities + result_no_identities = exec_command_alice( + command="crowd", + sub_command="list", + extra_args=[ + "--network", + "ws://127.0.0.1:9945", + "--show-identities", + "false", + "--json-output", + ], + ) + + try: + result_output = json.loads(result_no_identities.stdout) + if result_output.get("success") is True: + if result_output["data"]["crowdloans"]: + crowdloan = result_output["data"]["crowdloans"][0] + # Identity fields should still be present but None + assert "creator_identity" in crowdloan + assert crowdloan.get("creator_identity") is None + except json.JSONDecodeError: + pytest.skip("Could not parse JSON output") + + +@pytest.mark.parametrize("local_chain", [False], indirect=True) +def test_crowd_info_with_identities(local_chain, wallet_setup): + """ + Test crowd info command with identity display and contributors. + + Steps: + 1. Execute crowd info with --show-identities + 2. Execute crowd info with --show-contributors + 3. Verify identity and contributor information is displayed + """ + wallet_path_alice = "//Alice" + + # Create wallet for Alice + keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( + wallet_path_alice + ) + + # Test 1: Info with identities (default) + result = exec_command_alice( + command="crowd", + sub_command="info", + extra_args=[ + "--id", + "0", + "--network", + "ws://127.0.0.1:9945", + "--json-output", + ], + ) + + try: + result_output = json.loads(result.stdout) + if result_output.get("success") is True: + assert "data" in result_output + # Identity fields should be present + assert "creator_identity" in result_output["data"] + assert "target_identity" in result_output["data"] + except json.JSONDecodeError: + pytest.skip("Could not parse JSON output or crowdloan not found") + + # Test 2: Info with identities and contributors + result_with_contributors = exec_command_alice( + command="crowd", + sub_command="info", + extra_args=[ + "--id", + "0", + "--network", + "ws://127.0.0.1:9945", + "--show-identities", + "true", + "--show-contributors", + "true", + "--json-output", + ], + ) + + try: + result_output = json.loads(result_with_contributors.stdout) + if result_output.get("success") is True: + assert "data" in result_output + # Contributors should be present if flag is set + assert "contributors" in result_output["data"] + if result_output["data"]["contributors"]: + contributor = result_output["data"]["contributors"][0] + assert "identity" in contributor + assert "address" in contributor + assert "contribution_tao" in contributor + except json.JSONDecodeError: + pytest.skip("Could not parse JSON output or crowdloan not found") diff --git a/tests/unit_tests/test_crowd_contributors.py b/tests/unit_tests/test_crowd_contributors.py new file mode 100644 index 000000000..201164420 --- /dev/null +++ b/tests/unit_tests/test_crowd_contributors.py @@ -0,0 +1,496 @@ +""" +Unit tests for crowd contributors command. +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, Mock, patch +from bittensor_cli.src.bittensor.balances import Balance +from bittensor_cli.src.bittensor.chain_data import CrowdloanData +from bittensor_cli.src.commands.crowd.contributors import list_contributors + + +class TestListContributors: + """Tests for list_contributors function.""" + + @pytest.mark.asyncio + async def test_list_contributors_success(self): + """Test successful listing of contributors.""" + # Setup mocks + mock_subtensor = MagicMock() + mock_subtensor.network = "finney" + + # Mock crowdloan exists + mock_crowdloan = CrowdloanData( + creator="5DjzesT8f6Td8", + funds_account="5EYCAeX97cWb", + deposit=Balance.from_tao(10.0), + min_contribution=Balance.from_tao(0.1), + cap=Balance.from_tao(30.0), + raised=Balance.from_tao(30.0), + end=1000000, + finalized=False, + contributors_count=3, + target_address="5GduHCP9UdBY", + has_call=False, + call_details=None, + ) + mock_subtensor.get_single_crowdloan = AsyncMock(return_value=mock_crowdloan) + + # Mock contributors data from query_map + # The key structure is ((account_bytes_tuple,),) where account_bytes_tuple is tuple of ints + mock_contributor1_key = ( + ( + 74, + 51, + 88, + 161, + 161, + 215, + 144, + 145, + 231, + 175, + 227, + 146, + 149, + 109, + 220, + 180, + 12, + 58, + 121, + 233, + 152, + 50, + 211, + 15, + 242, + 187, + 103, + 2, + 198, + 131, + 177, + 118, + ), + ) + mock_contributor2_key = ( + ( + 202, + 66, + 124, + 47, + 131, + 219, + 1, + 26, + 137, + 169, + 17, + 112, + 182, + 39, + 163, + 162, + 72, + 150, + 208, + 58, + 179, + 235, + 238, + 242, + 150, + 177, + 219, + 0, + 2, + 76, + 172, + 171, + ), + ) + mock_contributor3_key = ( + ( + 224, + 56, + 146, + 238, + 201, + 170, + 157, + 255, + 58, + 77, + 190, + 94, + 17, + 231, + 15, + 217, + 15, + 134, + 147, + 100, + 174, + 45, + 31, + 132, + 21, + 200, + 40, + 185, + 176, + 209, + 247, + 54, + ), + ) + + mock_contribution1 = MagicMock() + mock_contribution1.value = 10000000000 # 10 TAO in rao + mock_contribution2 = MagicMock() + mock_contribution2.value = 10000000000 # 10 TAO in rao + mock_contribution3 = MagicMock() + mock_contribution3.value = 10000000000 # 10 TAO in rao + + # Create async generator for query_map results + async def mock_query_map_generator(): + yield (mock_contributor1_key, mock_contribution1) + yield (mock_contributor2_key, mock_contribution2) + yield (mock_contributor3_key, mock_contribution3) + + # Create a proper async iterable + class MockQueryMapResult: + def __aiter__(self): + return mock_query_map_generator() + + mock_subtensor.substrate.query_map = AsyncMock( + return_value=MockQueryMapResult() + ) + + # Mock identities + mock_subtensor.query_identity = AsyncMock( + side_effect=[ + {"info": {"display": {"Raw": "Alice"}}}, # Contributor 1 + {"info": {"display": {"Raw": "Bob"}}}, # Contributor 2 + {}, # Contributor 3 (no identity) + ] + ) + + # Execute + result = await list_contributors( + subtensor=mock_subtensor, + crowdloan_id=0, + verbose=False, + json_output=False, + ) + + # Verify + assert result is True + mock_subtensor.get_single_crowdloan.assert_called_once_with(0) + mock_subtensor.substrate.query_map.assert_called_once_with( + module="Crowdloan", + storage_function="Contributions", + params=[0], + fully_exhaust=True, + ) + assert mock_subtensor.query_identity.call_count == 3 + + @pytest.mark.asyncio + async def test_list_contributors_crowdloan_not_found(self): + """Test listing contributors when crowdloan doesn't exist.""" + mock_subtensor = MagicMock() + mock_subtensor.get_single_crowdloan = AsyncMock(return_value=None) + + # Execute + result = await list_contributors( + subtensor=mock_subtensor, + crowdloan_id=999, + verbose=False, + json_output=False, + ) + + # Verify + assert result is False + mock_subtensor.get_single_crowdloan.assert_called_once_with(999) + mock_subtensor.substrate.query_map.assert_not_called() + + @pytest.mark.asyncio + async def test_list_contributors_no_contributors(self): + """Test listing contributors when there are no contributors.""" + mock_subtensor = MagicMock() + mock_subtensor.network = "finney" + + mock_crowdloan = CrowdloanData( + creator="5DjzesT8f6Td8", + funds_account="5EYCAeX97cWb", + deposit=Balance.from_tao(10.0), + min_contribution=Balance.from_tao(0.1), + cap=Balance.from_tao(100.0), + raised=Balance.from_tao(10.0), + end=1000000, + finalized=False, + contributors_count=0, + target_address=None, + has_call=False, + call_details=None, + ) + mock_subtensor.get_single_crowdloan = AsyncMock(return_value=mock_crowdloan) + + # Mock empty contributors data + async def mock_empty_query_map(): + if False: # Never yield anything + yield + + class MockEmptyQueryMapResult: + def __aiter__(self): + return mock_empty_query_map() + + mock_subtensor.substrate.query_map = AsyncMock( + return_value=MockEmptyQueryMapResult() + ) + + # Execute + result = await list_contributors( + subtensor=mock_subtensor, + crowdloan_id=0, + verbose=False, + json_output=False, + ) + + # Verify + assert result is True + mock_subtensor.query_identity.assert_not_called() + + @pytest.mark.asyncio + async def test_list_contributors_json_output(self): + """Test listing contributors with JSON output.""" + mock_subtensor = MagicMock() + mock_subtensor.network = "finney" + + mock_crowdloan = CrowdloanData( + creator="5DjzesT8f6Td8", + funds_account="5EYCAeX97cWb", + deposit=Balance.from_tao(10.0), + min_contribution=Balance.from_tao(0.1), + cap=Balance.from_tao(20.0), + raised=Balance.from_tao(20.0), + end=1000000, + finalized=False, + contributors_count=2, + target_address=None, + has_call=False, + call_details=None, + ) + mock_subtensor.get_single_crowdloan = AsyncMock(return_value=mock_crowdloan) + + # Mock contributors data + mock_contributor1_key = ( + ( + 74, + 51, + 88, + 161, + 161, + 215, + 144, + 145, + 231, + 175, + 227, + 146, + 149, + 109, + 220, + 180, + 12, + 58, + 121, + 233, + 152, + 50, + 211, + 15, + 242, + 187, + 103, + 2, + 198, + 131, + 177, + 118, + ), + ) + mock_contributor2_key = ( + ( + 202, + 66, + 124, + 47, + 131, + 219, + 1, + 26, + 137, + 169, + 17, + 112, + 182, + 39, + 163, + 162, + 72, + 150, + 208, + 58, + 179, + 235, + 238, + 242, + 150, + 177, + 219, + 0, + 2, + 76, + 172, + 171, + ), + ) + + mock_contribution1 = MagicMock() + mock_contribution1.value = 10000000000 # 10 TAO + mock_contribution2 = MagicMock() + mock_contribution2.value = 10000000000 # 10 TAO + + async def mock_query_map_generator(): + yield (mock_contributor1_key, mock_contribution1) + yield (mock_contributor2_key, mock_contribution2) + + class MockQueryMapResult: + def __aiter__(self): + return mock_query_map_generator() + + mock_subtensor.substrate.query_map = AsyncMock( + return_value=MockQueryMapResult() + ) + mock_subtensor.query_identity = AsyncMock( + side_effect=[ + {"info": {"display": {"Raw": "Alice"}}}, + {"info": {"display": {"Raw": "Bob"}}}, + ] + ) + + # Mock json_console + with patch( + "bittensor_cli.src.commands.crowd.contributors.json_console" + ) as mock_json_console: + # Execute + result = await list_contributors( + subtensor=mock_subtensor, + crowdloan_id=0, + verbose=False, + json_output=True, + ) + + # Verify + assert result is True + mock_json_console.print.assert_called_once() + call_args = mock_json_console.print.call_args[0][0] + import json + + output_data = json.loads(call_args) + assert output_data["success"] is True + assert output_data["data"]["crowdloan_id"] == 0 + assert len(output_data["data"]["contributors"]) == 2 + assert output_data["data"]["total_count"] == 2 + assert output_data["data"]["total_contributed_tao"] == 20.0 + assert output_data["data"]["network"] == "finney" + # Verify contributors are sorted by rank + assert output_data["data"]["contributors"][0]["rank"] == 1 + assert output_data["data"]["contributors"][1]["rank"] == 2 + + @pytest.mark.asyncio + async def test_list_contributors_verbose_mode(self): + """Test listing contributors with verbose mode.""" + mock_subtensor = MagicMock() + mock_subtensor.network = "finney" + + mock_crowdloan = CrowdloanData( + creator="5DjzesT8f6Td8", + funds_account="5EYCAeX97cWb", + deposit=Balance.from_tao(10.0), + min_contribution=Balance.from_tao(0.1), + cap=Balance.from_tao(10.0), + raised=Balance.from_tao(10.0), + end=1000000, + finalized=False, + contributors_count=1, + target_address=None, + has_call=False, + call_details=None, + ) + mock_subtensor.get_single_crowdloan = AsyncMock(return_value=mock_crowdloan) + + mock_contributor_key = ( + ( + 74, + 51, + 88, + 161, + 161, + 215, + 144, + 145, + 231, + 175, + 227, + 146, + 149, + 109, + 220, + 180, + 12, + 58, + 121, + 233, + 152, + 50, + 211, + 15, + 242, + 187, + 103, + 2, + 198, + 131, + 177, + 118, + ), + ) + mock_contribution = MagicMock() + mock_contribution.value = 10000000000 # 10 TAO + + async def mock_query_map_generator(): + yield (mock_contributor_key, mock_contribution) + + class MockQueryMapResult: + def __aiter__(self): + return mock_query_map_generator() + + mock_subtensor.substrate.query_map = AsyncMock( + return_value=MockQueryMapResult() + ) + mock_subtensor.query_identity = AsyncMock(return_value={}) + + # Execute + result = await list_contributors( + subtensor=mock_subtensor, + crowdloan_id=0, + verbose=True, + json_output=False, + ) + + # Verify + assert result is True diff --git a/tests/unit_tests/test_crowd_create_custom_call.py b/tests/unit_tests/test_crowd_create_custom_call.py new file mode 100644 index 000000000..8aa0fddfa --- /dev/null +++ b/tests/unit_tests/test_crowd_create_custom_call.py @@ -0,0 +1,180 @@ +""" +Unit tests for crowd create custom call functionality. +""" + +import json +import pytest +from unittest.mock import AsyncMock, MagicMock, Mock, patch +from scalecodec import GenericCall + +from bittensor_cli.src.commands.crowd.create import validate_and_compose_custom_call + + +class TestValidateAndComposeCustomCall: + """Tests for validate_and_compose_custom_call function.""" + + @pytest.mark.asyncio + async def test_invalid_json_args(self): + """Test that invalid JSON in args is caught.""" + mock_subtensor = MagicMock() + mock_subtensor.substrate = MagicMock() + + result_call, error_msg = await validate_and_compose_custom_call( + subtensor=mock_subtensor, + pallet_name="TestPallet", + method_name="test_method", + args_json='{"invalid": json}', + ) + + assert result_call is None + assert "Invalid JSON" in error_msg + + @pytest.mark.asyncio + async def test_pallet_not_found(self): + """Test that missing pallet is detected.""" + mock_subtensor = MagicMock() + mock_subtensor.substrate = MagicMock() + + # Mock metadata structure + mock_pallet = MagicMock() + mock_pallet.name = "OtherPallet" + + mock_metadata = MagicMock() + mock_metadata.pallets = [mock_pallet] + mock_metadata.get_metadata_pallet = Mock( + side_effect=ValueError("Pallet not found") + ) + + mock_runtime = MagicMock() + mock_runtime.metadata = mock_metadata + + mock_subtensor.substrate.get_chain_head = AsyncMock(return_value="0x1234") + mock_subtensor.substrate.init_runtime = AsyncMock(return_value=mock_runtime) + + result_call, error_msg = await validate_and_compose_custom_call( + subtensor=mock_subtensor, + pallet_name="NonExistentPallet", + method_name="test_method", + args_json="{}", + ) + + assert result_call is None + assert "not found" in error_msg.lower() + + @pytest.mark.asyncio + async def test_method_not_found(self): + """Test that missing method is detected.""" + mock_subtensor = MagicMock() + mock_subtensor.substrate = MagicMock() + + # Mock metadata structure + mock_call = MagicMock() + mock_call.name = "other_method" + + mock_pallet = MagicMock() + mock_pallet.name = "TestPallet" + mock_pallet.calls = [mock_call] + + mock_metadata = MagicMock() + mock_metadata.pallets = [mock_pallet] + mock_metadata.get_metadata_pallet = Mock(return_value=mock_pallet) + + mock_runtime = MagicMock() + mock_runtime.metadata = mock_metadata + + mock_subtensor.substrate.get_chain_head = AsyncMock(return_value="0x1234") + mock_subtensor.substrate.init_runtime = AsyncMock(return_value=mock_runtime) + + result_call, error_msg = await validate_and_compose_custom_call( + subtensor=mock_subtensor, + pallet_name="TestPallet", + method_name="non_existent_method", + args_json="{}", + ) + + assert result_call is None + assert "not found" in error_msg.lower() + + @pytest.mark.asyncio + async def test_successful_validation(self): + """Test successful validation and call composition.""" + mock_subtensor = MagicMock() + mock_subtensor.substrate = MagicMock() + + # Mock metadata structure + mock_call = MagicMock() + mock_call.name = "test_method" + mock_call.index = 0 + + mock_pallet = MagicMock() + mock_pallet.name = "TestPallet" + mock_pallet.calls = [mock_call] + + mock_metadata = MagicMock() + mock_metadata.pallets = [mock_pallet] + mock_metadata.get_metadata_pallet = Mock(return_value=mock_pallet) + + mock_runtime = MagicMock() + mock_runtime.metadata = mock_metadata + + # Mock compose_call to return a GenericCall + mock_generic_call = MagicMock(spec=GenericCall) + mock_subtensor.substrate.compose_call = AsyncMock( + return_value=mock_generic_call + ) + mock_subtensor.substrate.get_chain_head = AsyncMock(return_value="0x1234") + mock_subtensor.substrate.init_runtime = AsyncMock(return_value=mock_runtime) + + result_call, error_msg = await validate_and_compose_custom_call( + subtensor=mock_subtensor, + pallet_name="TestPallet", + method_name="test_method", + args_json='{"param1": "value1"}', + ) + + assert result_call is not None + assert error_msg is None + mock_subtensor.substrate.compose_call.assert_called_once_with( + call_module="TestPallet", + call_function="test_method", + call_params={"param1": "value1"}, + ) + + @pytest.mark.asyncio + async def test_compose_call_failure(self): + """Test handling of compose_call failures.""" + mock_subtensor = MagicMock() + mock_subtensor.substrate = MagicMock() + + # Mock metadata structure + mock_call = MagicMock() + mock_call.name = "test_method" + + mock_pallet = MagicMock() + mock_pallet.name = "TestPallet" + mock_pallet.calls = [mock_call] + + mock_metadata = MagicMock() + mock_metadata.pallets = [mock_pallet] + mock_metadata.get_metadata_pallet = Mock(return_value=mock_pallet) + + mock_runtime = MagicMock() + mock_runtime.metadata = mock_metadata + + # Mock compose_call to raise an error + mock_subtensor.substrate.compose_call = AsyncMock( + side_effect=Exception("Invalid parameter type") + ) + mock_subtensor.substrate.get_chain_head = AsyncMock(return_value="0x1234") + mock_subtensor.substrate.init_runtime = AsyncMock(return_value=mock_runtime) + + result_call, error_msg = await validate_and_compose_custom_call( + subtensor=mock_subtensor, + pallet_name="TestPallet", + method_name="test_method", + args_json='{"param1": "value1"}', + ) + + assert result_call is None + assert error_msg is not None + assert "Invalid parameter" in error_msg or "Failed to compose" in error_msg From ea504bc286faa77e676d287859908cb33d74f480 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 16:37:27 -0800 Subject: [PATCH 14/33] remove identity args --- bittensor_cli/cli.py | 29 +---------------------------- 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 0a37364c8..3bf6e65c4 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -8750,11 +8750,6 @@ def crowd_list( quiet: bool = Options.quiet, verbose: bool = Options.verbose, json_output: bool = Options.json_output, - show_identities: Optional[str] = typer.Option( - None, - "--show-identities", - help="Show identity names for creators and target addresses. Use 'true' or 'false', or omit for default (true).", - ), status: Optional[str] = typer.Option( None, "--status", @@ -8789,7 +8784,6 @@ def crowd_list( or a general fundraising crowdloan. Use `--verbose` for full-precision amounts and longer addresses. - Use `--show-identities` to show identity names (default: true). Use `--status` to filter by status (active, funded, closed, finalized). Use `--type` to filter by type (subnet, fundraising). Use `--sort-by` and `--sort-order` to sort results. @@ -8801,8 +8795,6 @@ def crowd_list( [green]$[/green] btcli crowd list --verbose - [green]$[/green] btcli crowd list --show-identities true - [green]$[/green] btcli crowd list --status active --type subnet [green]$[/green] btcli crowd list --sort-by raised --sort-order desc @@ -8810,16 +8802,11 @@ def crowd_list( [green]$[/green] btcli crowd list --search-creator "5D..." """ self.verbosity_handler(quiet, verbose, json_output, prompt=False) - # Parse show_identities: None or "true" -> True, "false" -> False - show_identities_bool = True # default - if show_identities is not None: - show_identities_bool = show_identities.lower() in ("true", "1", "yes") return self._run_command( view_crowdloan.list_crowdloans( subtensor=self.initialize_chain(network), verbose=verbose, json_output=json_output, - show_identities=show_identities_bool, status_filter=status, type_filter=type_filter, sort_by=sort_by, @@ -8844,11 +8831,6 @@ def crowd_info( quiet: bool = Options.quiet, verbose: bool = Options.verbose, json_output: bool = Options.json_output, - show_identities: Optional[str] = typer.Option( - None, - "--show-identities", - help="Show identity names for creator and target address. Use 'true' or 'false', or omit for default (true).", - ), show_contributors: Optional[str] = typer.Option( None, "--show-contributors", @@ -8859,7 +8841,6 @@ def crowd_info( Display detailed information about a specific crowdloan. Includes funding progress, target account, and call details among other information. - Use `--show-identities` to show identity names (default: true). Use `--show-contributors` to display the list of contributors (default: false). EXAMPLES @@ -8868,9 +8849,7 @@ def crowd_info( [green]$[/green] btcli crowd info --id 1 --verbose - [green]$[/green] btcli crowd info --id 0 --show-identities true - - [green]$[/green] btcli crowd info --id 0 --show-identities true --show-contributors true + [green]$[/green] btcli crowd info --id 0 --show-contributors true """ self.verbosity_handler(quiet, verbose, json_output, prompt=False) @@ -8891,11 +8870,6 @@ def crowd_info( validate=WV.WALLET, ) - # Parse show_identities: None or "true" -> True, "false" -> False - show_identities_bool = True # default - if show_identities is not None: - show_identities_bool = show_identities.lower() in ("true", "1", "yes") - # Parse show_contributors: None or "false" -> False, "true" -> True show_contributors_bool = False # default if show_contributors is not None: @@ -8908,7 +8882,6 @@ def crowd_info( wallet=wallet, verbose=verbose, json_output=json_output, - show_identities=show_identities_bool, show_contributors=show_contributors_bool, ) ) From 21c8fae57baab05de1bfbd35759e6f1b8a1daaec Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 16:38:22 -0800 Subject: [PATCH 15/33] update list contributors --- .../src/commands/crowd/contributors.py | 24 ++++++------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/bittensor_cli/src/commands/crowd/contributors.py b/bittensor_cli/src/commands/crowd/contributors.py index c64151e07..24eb6eab3 100644 --- a/bittensor_cli/src/commands/crowd/contributors.py +++ b/bittensor_cli/src/commands/crowd/contributors.py @@ -22,15 +22,6 @@ def _shorten(account: Optional[str]) -> str: return f"{account[:6]}…{account[-6:]}" -def _get_identity_name(identity: dict) -> str: - """Extract identity name from identity dict.""" - if not identity: - return "-" - info = identity.get("info", {}) - display = info.get("display", {}) - if isinstance(display, dict): - return display.get("Raw", "") or display.get("value", "") or "-" - return str(display) if display else "-" async def list_contributors( @@ -131,21 +122,20 @@ async def list_contributors( ) return True - # Fetch identities for all contributors - contributors_list = list(contributor_contributions.keys()) - identity_tasks = [ - subtensor.query_identity(contributor) for contributor in contributors_list - ] - identities = await asyncio.gather(*identity_tasks) + all_identities = await subtensor.query_all_identities() # Build contributor data list + contributors_list = list(contributor_contributions.keys()) contributor_data = [] total_contributed = Balance.from_tao(0) - for contributor_address, identity in zip(contributors_list, identities): + for contributor_address in contributors_list: contribution_amount = contributor_contributions[contributor_address] total_contributed += contribution_amount - identity_name = _get_identity_name(identity) + identity = all_identities.get(contributor_address) + identity_name = None + if identity: + identity_name = identity.get("name") or identity.get("display") contributor_data.append( { From 0f8fdcbfcdef0980083a4c7b8c0409f2e48a998c Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 16:40:04 -0800 Subject: [PATCH 16/33] update view cmd --- bittensor_cli/src/commands/crowd/view.py | 165 +++++++---------------- 1 file changed, 49 insertions(+), 116 deletions(-) diff --git a/bittensor_cli/src/commands/crowd/view.py b/bittensor_cli/src/commands/crowd/view.py index 03932e8eb..28ae014d0 100644 --- a/bittensor_cli/src/commands/crowd/view.py +++ b/bittensor_cli/src/commands/crowd/view.py @@ -25,47 +25,6 @@ def _shorten(account: Optional[str]) -> str: return f"{account[:6]}…{account[-6:]}" -def _get_identity_name(identity: dict) -> str: - """Extract identity name from identity dict. - - Handles both flat structure (from decode_hex_identity) and nested structure. - """ - if not identity: - return "" - - # Try direct display/name fields first (flat structure from decode_hex_identity) - if identity.get("display"): - display = identity.get("display") - if isinstance(display, str): - return display - if isinstance(display, dict): - return display.get("Raw", "") or display.get("value", "") or "" - - if identity.get("name"): - name = identity.get("name") - if isinstance(name, str): - return name - if isinstance(name, dict): - return name.get("Raw", "") or name.get("value", "") or "" - - # Try nested structure (info.display.Raw) - info = identity.get("info", {}) - if info: - display = info.get("display", {}) - if isinstance(display, dict): - return display.get("Raw", "") or display.get("value", "") or "" - if isinstance(display, str): - return display - - name = info.get("name", {}) - if isinstance(name, dict): - return name.get("Raw", "") or name.get("value", "") or "" - if isinstance(name, str): - return name - - return "" - - def _status(loan: CrowdloanData, current_block: int) -> str: if loan.finalized: return "Finalized" @@ -104,7 +63,6 @@ async def list_crowdloans( subtensor: SubtensorInterface, verbose: bool = False, json_output: bool = False, - show_identities: bool = True, status_filter: Optional[str] = None, type_filter: Optional[str] = None, sort_by: Optional[str] = None, @@ -117,7 +75,6 @@ async def list_crowdloans( subtensor: SubtensorInterface object for chain interaction verbose: Show full addresses and precise amounts json_output: Output as JSON - show_identities: Show identity names for creators and targets status_filter: Filter by status (active, funded, closed, finalized) type_filter: Filter by type (subnet, fundraising) sort_by: Sort by field (raised, end, contributors, id) @@ -125,9 +82,10 @@ async def list_crowdloans( search_creator: Search by creator address or identity name """ - current_block, loans = await asyncio.gather( + current_block, loans, all_identities = await asyncio.gather( subtensor.substrate.get_block_number(None), subtensor.get_crowdloans(), + subtensor.query_all_identities(), ) if not loans: if json_output: @@ -150,22 +108,18 @@ async def list_crowdloans( console.print("[yellow]No crowdloans found.[/yellow]") return True - # Batch fetch identities early if needed for filtering/searching + # Build identity map from all identities identity_map = {} - if show_identities or search_creator: - addresses_to_fetch = set() - for loan in loans.values(): - addresses_to_fetch.add(loan.creator) - if loan.target_address: - addresses_to_fetch.add(loan.target_address) - - identity_tasks = [ - subtensor.query_identity(address) for address in addresses_to_fetch - ] - identities = await asyncio.gather(*identity_tasks) - - for address, identity in zip(addresses_to_fetch, identities): - identity_name = _get_identity_name(identity) + addresses_to_check = set() + for loan in loans.values(): + addresses_to_check.add(loan.creator) + if loan.target_address: + addresses_to_check.add(loan.target_address) + + for address in addresses_to_check: + identity = all_identities.get(address) + if identity: + identity_name = identity.get("name") or identity.get("display") if identity_name: identity_map[address] = identity_name @@ -263,12 +217,10 @@ async def list_crowdloans( "time_remaining": time_remaining, "contributors_count": loan.contributors_count, "creator": loan.creator, - "creator_identity": identity_map.get(loan.creator) - if show_identities - else None, + "creator_identity": identity_map.get(loan.creator), "target_address": loan.target_address, "target_identity": identity_map.get(loan.target_address) - if show_identities and loan.target_address + if loan.target_address else None, "funds_account": loan.funds_account, "call": call_info, @@ -483,7 +435,7 @@ async def list_crowdloans( time_cell = time_label # Format creator cell with identity if available - if show_identities and loan.creator in identity_map: + if loan.creator in identity_map: creator_identity = identity_map[loan.creator] if verbose: creator_cell = f"{creator_identity} ({loan.creator})" @@ -494,7 +446,7 @@ async def list_crowdloans( # Format target cell with identity if available if loan.target_address: - if show_identities and loan.target_address in identity_map: + if loan.target_address in identity_map: target_identity = identity_map[loan.target_address] if verbose: target_cell = f"{target_identity} ({loan.target_address})" @@ -560,16 +512,19 @@ async def show_crowdloan_details( wallet: Optional[Wallet] = None, verbose: bool = False, json_output: bool = False, - show_identities: bool = True, show_contributors: bool = False, ) -> tuple[bool, str]: """Display detailed information about a specific crowdloan.""" if not crowdloan or not current_block: - current_block, crowdloan = await asyncio.gather( + current_block, crowdloan, all_identities = await asyncio.gather( subtensor.substrate.get_block_number(None), subtensor.get_single_crowdloan(crowdloan_id), + subtensor.query_all_identities(), ) + else: + all_identities = await subtensor.query_all_identities() + if not crowdloan: error_msg = f"Crowdloan #{crowdloan_id} not found." if json_output: @@ -584,20 +539,16 @@ async def show_crowdloan_details( crowdloan_id, wallet.coldkeypub.ss58_address ) - # Fetch identities if show_identities is enabled + # Build identity map from all identities identity_map = {} - if show_identities: - addresses_to_fetch = [crowdloan.creator] - if crowdloan.target_address: - addresses_to_fetch.append(crowdloan.target_address) - - identity_tasks = [ - subtensor.query_identity(address) for address in addresses_to_fetch - ] - identities = await asyncio.gather(*identity_tasks) - - for address, identity in zip(addresses_to_fetch, identities): - identity_name = _get_identity_name(identity) + addresses_to_check = [crowdloan.creator] + if crowdloan.target_address: + addresses_to_check.append(crowdloan.target_address) + + for address in addresses_to_check: + identity = all_identities.get(address) + if identity: + identity_name = identity.get("name") or identity.get("display") if identity_name: identity_map[address] = identity_name @@ -669,9 +620,7 @@ async def show_crowdloan_details( "status": status, "finalized": crowdloan.finalized, "creator": crowdloan.creator, - "creator_identity": identity_map.get(crowdloan.creator) - if show_identities - else None, + "creator_identity": identity_map.get(crowdloan.creator), "funds_account": crowdloan.funds_account, "raised": crowdloan.raised.tao, "cap": crowdloan.cap.tao, @@ -687,7 +636,7 @@ async def show_crowdloan_details( "average_contribution": avg_contribution, "target_address": crowdloan.target_address, "target_identity": identity_map.get(crowdloan.target_address) - if show_identities and crowdloan.target_address + if crowdloan.target_address else None, "has_call": crowdloan.has_call, "call_details": call_info, @@ -698,8 +647,6 @@ async def show_crowdloan_details( # Add contributors list if requested if show_contributors: - from bittensor_cli.src.commands.crowd.contributors import list_contributors - # We'll fetch contributors separately and add to output contributors_data = await subtensor.substrate.query_map( module="Crowdloan", @@ -730,17 +677,8 @@ async def show_crowdloan_details( except Exception: continue - # Fetch identities for contributors contributors_list = list(contributor_contributions.keys()) if contributors_list: - contributor_identity_tasks = [ - subtensor.query_identity(contributor) - for contributor in contributors_list - ] - contributor_identities = await asyncio.gather( - *contributor_identity_tasks - ) - contributors_json = [] total_contributed = Balance.from_tao(0) for ( @@ -750,15 +688,16 @@ async def show_crowdloan_details( total_contributed += contribution_amount contributor_data = [] - for contributor_address, identity in zip( - contributors_list, contributor_identities - ): + for contributor_address in contributors_list: contribution_amount = contributor_contributions[contributor_address] - identity_name = _get_identity_name(identity) + identity = all_identities.get(contributor_address) + identity_name = None + if identity: + identity_name = identity.get("name") or identity.get("display") contributor_data.append( { "address": contributor_address, - "identity": identity_name if identity_name else None, + "identity": identity_name, "contribution": contribution_amount, } ) @@ -823,14 +762,15 @@ async def show_crowdloan_details( table.add_row("Status", f"[{status_color}]{status}[/{status_color}]{status_detail}") # Display creator with identity if available - creator_display = crowdloan.creator - if show_identities and crowdloan.creator in identity_map: + if crowdloan.creator in identity_map: creator_identity = identity_map[crowdloan.creator] if verbose: creator_display = f"{creator_identity} ({crowdloan.creator})" else: creator_display = f"{creator_identity} ({_shorten(crowdloan.creator)})" - elif not verbose: + elif verbose: + creator_display = crowdloan.creator + else: creator_display = _shorten(crowdloan.creator) table.add_row( "Creator", @@ -940,7 +880,7 @@ async def show_crowdloan_details( table.add_section() if crowdloan.target_address: - if show_identities and crowdloan.target_address in identity_map: + if crowdloan.target_address in identity_map: target_identity = identity_map[crowdloan.target_address] if verbose: target_display = f"{target_identity} ({crowdloan.target_address})" @@ -1043,24 +983,17 @@ async def show_crowdloan_details( continue if contributor_contributions: - # Fetch identities for contributors contributors_list = list(contributor_contributions.keys()) - contributor_identity_tasks = [ - subtensor.query_identity(contributor) - for contributor in contributors_list - ] - contributor_identities = await asyncio.gather(*contributor_identity_tasks) - - # Build contributor data list contributor_data = [] total_contributed = Balance.from_tao(0) - for contributor_address, identity in zip( - contributors_list, contributor_identities - ): + for contributor_address in contributors_list: contribution_amount = contributor_contributions[contributor_address] total_contributed += contribution_amount - identity_name = _get_identity_name(identity) + identity = all_identities.get(contributor_address) + identity_name = None + if identity: + identity_name = identity.get("name") or identity.get("display") contributor_data.append( { From 058b2837839b8198b54910f5b989e3b83b354307 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 16:50:48 -0800 Subject: [PATCH 17/33] adds get_crowdloan_contributors method --- .../src/bittensor/subtensor_interface.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index bf2f91a23..49be1bf70 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -1925,6 +1925,43 @@ async def get_crowdloan_contribution( return Balance.from_rao(contribution) return None + async def get_crowdloan_contributors( + self, + crowdloan_id: int, + block_hash: Optional[str] = None, + ) -> dict[str, Balance]: + """Retrieves all contributors and their contributions for a specific crowdloan. + + Args: + crowdloan_id (int): The ID of the crowdloan. + block_hash (Optional[str]): The blockchain block hash at which to perform the query. + + Returns: + dict[str, Balance]: A dictionary mapping contributor SS58 addresses to their + contribution amounts as Balance objects. + + This function queries the Contributions storage map with the crowdloan_id as the first key + to retrieve all contributors and their contribution amounts. + """ + contributors_data = await self.substrate.query_map( + module="Crowdloan", + storage_function="Contributions", + params=[crowdloan_id], + block_hash=block_hash, + fully_exhaust=True, + ) + + contributor_contributions = {} + async for contributor_key, contribution_amount in contributors_data: + try: + contributor_address = decode_account_id(contributor_key[0]) + contribution_balance = Balance.from_rao(contribution_amount.value) + contributor_contributions[contributor_address] = contribution_balance + except Exception: + continue + + return contributor_contributions + async def get_coldkey_swap_schedule_duration( self, block_hash: Optional[str] = None, From d6d0ce9c5127cda0a34774f74dcf0c2fa5ecf187 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 16:51:07 -0800 Subject: [PATCH 18/33] replace manually fetching and parsing contributors --- .../src/commands/crowd/contributors.py | 53 +------------------ 1 file changed, 1 insertion(+), 52 deletions(-) diff --git a/bittensor_cli/src/commands/crowd/contributors.py b/bittensor_cli/src/commands/crowd/contributors.py index 24eb6eab3..89dc9130e 100644 --- a/bittensor_cli/src/commands/crowd/contributors.py +++ b/bittensor_cli/src/commands/crowd/contributors.py @@ -1,5 +1,4 @@ from typing import Optional -import asyncio import json from rich.table import Table @@ -11,7 +10,6 @@ json_console, print_error, millify_tao, - decode_account_id, ) @@ -22,8 +20,6 @@ def _shorten(account: Optional[str]) -> str: return f"{account[:6]}…{account[-6:]}" - - async def list_contributors( subtensor: SubtensorInterface, crowdloan_id: int, @@ -51,54 +47,7 @@ async def list_contributors( print_error(f"[red]{error_msg}[/red]") return False - # Query contributors from Contributions storage (double map) - # Query map with first key fixed to crowdloan_id to get all contributors - contributors_data = await subtensor.substrate.query_map( - module="Crowdloan", - storage_function="Contributions", - params=[crowdloan_id], - fully_exhaust=True, - ) - - # Extract contributors and their contributions from the map - contributor_contributions = {} - async for contributor_key, contribution_amount in contributors_data: - # Extract contributor address from the storage key - # For double maps queried with first key fixed, the key is a tuple: ((account_bytes_tuple,),) - # where account_bytes_tuple is a tuple of integers representing the account ID - try: - # The key structure is: ((account_bytes_tuple,),) - # where account_bytes_tuple is a tuple of integers (32 bytes = 32 ints) - if isinstance(contributor_key, tuple) and len(contributor_key) > 0: - inner_tuple = contributor_key[0] - if isinstance(inner_tuple, tuple): - # Decode the account ID from the tuple of integers - # decode_account_id handles both tuple[int] and tuple[tuple[int]] formats - contributor_address = decode_account_id(contributor_key) - else: - # Fallback: try to decode directly - contributor_address = decode_account_id(contributor_key) - else: - # Fallback: try to decode the key directly - contributor_address = decode_account_id(contributor_key) - - # Store contribution amount - # The value is a BittensorScaleType object, access .value to get the integer - contribution_value = ( - contribution_amount.value - if hasattr(contribution_amount, "value") - else contribution_amount - ) - contribution_balance = ( - Balance.from_rao(int(contribution_value)) - if contribution_value - else Balance.from_tao(0) - ) - contributor_contributions[contributor_address] = contribution_balance - except Exception as e: - # Skip invalid entries - uncomment for debugging - # print(f"Error processing contributor: {e}, key: {contributor_key}") - continue + contributor_contributions = await subtensor.get_crowdloan_contributors(crowdloan_id) if not contributor_contributions: if json_output: From dc6997fff83c3fee75a01109e379edef35dd000d Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 16:51:25 -0800 Subject: [PATCH 19/33] replace manually parsing contributors list --- bittensor_cli/src/commands/crowd/view.py | 58 ++---------------------- 1 file changed, 4 insertions(+), 54 deletions(-) diff --git a/bittensor_cli/src/commands/crowd/view.py b/bittensor_cli/src/commands/crowd/view.py index 28ae014d0..defc9e428 100644 --- a/bittensor_cli/src/commands/crowd/view.py +++ b/bittensor_cli/src/commands/crowd/view.py @@ -647,36 +647,9 @@ async def show_crowdloan_details( # Add contributors list if requested if show_contributors: - # We'll fetch contributors separately and add to output - contributors_data = await subtensor.substrate.query_map( - module="Crowdloan", - storage_function="Contributions", - params=[crowdloan_id], - fully_exhaust=True, + contributor_contributions = await subtensor.get_crowdloan_contributors( + crowdloan_id ) - - from bittensor_cli.src.bittensor.utils import decode_account_id - - contributor_contributions = {} - async for contributor_key, contribution_amount in contributors_data: - try: - contributor_address = decode_account_id(contributor_key) - contribution_value = ( - contribution_amount.value - if hasattr(contribution_amount, "value") - else contribution_amount - ) - contribution_balance = ( - Balance.from_rao(int(contribution_value)) - if contribution_value - else Balance.from_tao(0) - ) - contributor_contributions[contributor_address] = ( - contribution_balance - ) - except Exception: - continue - contributors_list = list(contributor_contributions.keys()) if contributors_list: contributors_json = [] @@ -955,33 +928,10 @@ async def show_crowdloan_details( table.add_section() # Fetch contributors - contributors_data = await subtensor.substrate.query_map( - module="Crowdloan", - storage_function="Contributions", - params=[crowdloan_id], - fully_exhaust=True, + contributor_contributions = await subtensor.get_crowdloan_contributors( + crowdloan_id ) - from bittensor_cli.src.bittensor.utils import decode_account_id - - contributor_contributions = {} - async for contributor_key, contribution_amount in contributors_data: - try: - contributor_address = decode_account_id(contributor_key) - contribution_value = ( - contribution_amount.value - if hasattr(contribution_amount, "value") - else contribution_amount - ) - contribution_balance = ( - Balance.from_rao(int(contribution_value)) - if contribution_value - else Balance.from_tao(0) - ) - contributor_contributions[contributor_address] = contribution_balance - except Exception: - continue - if contributor_contributions: contributors_list = list(contributor_contributions.keys()) contributor_data = [] From 887bb5a66bb148e9e249ea6cf36b78e030e8db90 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 17:12:39 -0800 Subject: [PATCH 20/33] simplify contributors list --- .../src/commands/crowd/contributors.py | 82 +++++++++---------- 1 file changed, 37 insertions(+), 45 deletions(-) diff --git a/bittensor_cli/src/commands/crowd/contributors.py b/bittensor_cli/src/commands/crowd/contributors.py index 89dc9130e..f1bbf1557 100644 --- a/bittensor_cli/src/commands/crowd/contributors.py +++ b/bittensor_cli/src/commands/crowd/contributors.py @@ -37,14 +37,13 @@ async def list_contributors( Returns: bool: True if successful, False otherwise """ - # First verify the crowdloan exists crowdloan = await subtensor.get_single_crowdloan(crowdloan_id) if not crowdloan: error_msg = f"Crowdloan #{crowdloan_id} not found." if json_output: json_console.print(json.dumps({"success": False, "error": error_msg})) else: - print_error(f"[red]{error_msg}[/red]") + print_error(f"{error_msg}") return False contributor_contributions = await subtensor.get_crowdloan_contributors(crowdloan_id) @@ -73,51 +72,45 @@ async def list_contributors( all_identities = await subtensor.query_all_identities() - # Build contributor data list - contributors_list = list(contributor_contributions.keys()) - contributor_data = [] - total_contributed = Balance.from_tao(0) + total_contributed = sum( + contributor_contributions.values(), start=Balance.from_tao(0) + ) - for contributor_address in contributors_list: - contribution_amount = contributor_contributions[contributor_address] - total_contributed += contribution_amount - identity = all_identities.get(contributor_address) - identity_name = None - if identity: - identity_name = identity.get("name") or identity.get("display") + contributor_data = [] + for address, amount in sorted( + contributor_contributions.items(), key=lambda x: x[1].rao, reverse=True + ): + identity = all_identities.get(address) + identity_name = ( + identity.get("name") or identity.get("display") if identity else None + ) + percentage = ( + (amount.rao / total_contributed.rao * 100) + if total_contributed.rao > 0 + else 0.0 + ) contributor_data.append( { - "address": contributor_address, + "address": address, "identity": identity_name, - "contribution": contribution_amount, + "contribution": amount, + "percentage": percentage, } ) - # Sort by contribution amount (descending) - contributor_data.sort(key=lambda x: x["contribution"].rao, reverse=True) - - # Calculate percentages - for data in contributor_data: - if total_contributed.rao > 0: - percentage = (data["contribution"].rao / total_contributed.rao) * 100 - else: - percentage = 0.0 - data["percentage"] = percentage - if json_output: - contributors_json = [] - for rank, data in enumerate(contributor_data, start=1): - contributors_json.append( - { - "rank": rank, - "address": data["address"], - "identity": data["identity"], - "contribution_tao": data["contribution"].tao, - "contribution_rao": data["contribution"].rao, - "percentage": data["percentage"], - } - ) + contributors_json = [ + { + "rank": rank, + "address": data["address"], + "identity": data["identity"], + "contribution_tao": data["contribution"].tao, + "contribution_rao": data["contribution"].rao, + "percentage": data["percentage"], + } + for rank, data in enumerate(contributor_data, start=1) + ] output_dict = { "success": True, @@ -183,13 +176,12 @@ async def list_contributors( for rank, data in enumerate(contributor_data, start=1): address_cell = data["address"] if verbose else _shorten(data["address"]) - identity_cell = data["identity"] if data["identity"] != "-" else "[dim]-[/dim]" - - if verbose: - contribution_cell = f"τ {data['contribution'].tao:,.4f}" - else: - contribution_cell = f"τ {millify_tao(data['contribution'].tao)}" - + identity_cell = data["identity"] if data["identity"] else "[dim]-[/dim]" + contribution_cell = ( + f"τ {data['contribution'].tao:,.4f}" + if verbose + else f"τ {millify_tao(data['contribution'].tao)}" + ) percentage_cell = f"{data['percentage']:.2f}%" table.add_row( From af8831ead5adfba37f54689e31f999ed89266dd8 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 17:16:16 -0800 Subject: [PATCH 21/33] add console statuses --- bittensor_cli/src/commands/crowd/contributors.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/bittensor_cli/src/commands/crowd/contributors.py b/bittensor_cli/src/commands/crowd/contributors.py index f1bbf1557..a46db1073 100644 --- a/bittensor_cli/src/commands/crowd/contributors.py +++ b/bittensor_cli/src/commands/crowd/contributors.py @@ -1,6 +1,7 @@ from typing import Optional import json from rich.table import Table +import asyncio from bittensor_cli.src import COLORS from bittensor_cli.src.bittensor.balances import Balance @@ -37,7 +38,8 @@ async def list_contributors( Returns: bool: True if successful, False otherwise """ - crowdloan = await subtensor.get_single_crowdloan(crowdloan_id) + with console.status(":satellite: Fetching crowdloan details..."): + crowdloan = await subtensor.get_single_crowdloan(crowdloan_id) if not crowdloan: error_msg = f"Crowdloan #{crowdloan_id} not found." if json_output: @@ -46,7 +48,11 @@ async def list_contributors( print_error(f"{error_msg}") return False - contributor_contributions = await subtensor.get_crowdloan_contributors(crowdloan_id) + with console.status(":satellite: Fetching contributors and identities..."): + contributor_contributions, all_identities = await asyncio.gather( + subtensor.get_crowdloan_contributors(crowdloan_id), + subtensor.query_all_identities(), + ) if not contributor_contributions: if json_output: @@ -70,8 +76,6 @@ async def list_contributors( ) return True - all_identities = await subtensor.query_all_identities() - total_contributed = sum( contributor_contributions.values(), start=Balance.from_tao(0) ) From a27e36d81354a0a472f51a1f284b24530ec9b7c5 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 18:12:12 -0800 Subject: [PATCH 22/33] add compose_custom_crowdloan_call --- .../src/bittensor/subtensor_interface.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index 49be1bf70..bb249be23 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -2538,6 +2538,36 @@ async def get_mev_shield_current_key( return public_key_bytes + async def compose_custom_crowdloan_call( + self, + pallet_name: str, + method_name: str, + call_params: dict, + block_hash: Optional[str] = None, + ) -> tuple[Optional[GenericCall], Optional[str]]: + """ + Compose a custom Substrate call. + + Args: + pallet_name: Name of the pallet/module + method_name: Name of the method/function + call_params: Dictionary of call parameters + block_hash: Optional block hash for the query + + Returns: + Tuple of (GenericCall or None, error_message or None) + """ + try: + call = await self.substrate.compose_call( + call_module=pallet_name, + call_function=method_name, + call_params=call_params, + block_hash=block_hash, + ) + return call, None + except Exception as e: + return None, f"Failed to compose call: {str(e)}" + async def best_connection(networks: list[str]): """ From 69ac2c1921029fb647a08d7e78c7cd664bb3a059 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 18:12:54 -0800 Subject: [PATCH 23/33] improve custom call creation --- bittensor_cli/src/commands/crowd/create.py | 168 +++++---------------- 1 file changed, 40 insertions(+), 128 deletions(-) diff --git a/bittensor_cli/src/commands/crowd/create.py b/bittensor_cli/src/commands/crowd/create.py index 5f6ed5208..2ce1dc9df 100644 --- a/bittensor_cli/src/commands/crowd/create.py +++ b/bittensor_cli/src/commands/crowd/create.py @@ -6,12 +6,14 @@ from rich.prompt import IntPrompt, Prompt, FloatPrompt from rich.table import Table, Column, box from scalecodec import GenericCall - from bittensor_cli.src import COLORS from bittensor_cli.src.commands.crowd.view import show_crowdloan_details from bittensor_cli.src.bittensor.balances import Balance from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface -from bittensor_cli.src.commands.crowd.utils import get_constant +from bittensor_cli.src.commands.crowd.utils import ( + get_constant, + prompt_custom_call_params, +) from bittensor_cli.src.bittensor.utils import ( blocks_to_duration, confirm_action, @@ -25,105 +27,6 @@ ) -async def validate_and_compose_custom_call( - subtensor: SubtensorInterface, - pallet_name: str, - method_name: str, - args_json: str, -) -> tuple[Optional[GenericCall], Optional[str]]: - """ - Validate and compose a custom Substrate call. - - Args: - subtensor: SubtensorInterface instance - pallet_name: Name of the pallet/module - method_name: Name of the method/function - args_json: JSON string of call arguments - - Returns: - Tuple of (GenericCall or None, error_message or None) - """ - try: - # Parse JSON arguments - try: - call_params = json.loads(args_json) if args_json else {} - except json.JSONDecodeError as e: - return None, f"Invalid JSON in custom call args: {e}" - - # Get metadata to validate call exists - block_hash = await subtensor.substrate.get_chain_head() - runtime = await subtensor.substrate.init_runtime(block_hash=block_hash) - metadata = runtime.metadata - - # Check if pallet exists - try: - # Try using get_metadata_pallet if available (cleaner approach) - if hasattr(metadata, "get_metadata_pallet"): - pallet = metadata.get_metadata_pallet(pallet_name) - else: - # Fallback to iteration - pallet = None - for pallet_item in metadata.pallets: - if pallet_item.name == pallet_name: - pallet = pallet_item - break - except (AttributeError, ValueError): - # Pallet not found - pallet = None - - if pallet is None: - available_pallets = [p.name for p in metadata.pallets] - return None, ( - f"Pallet '{pallet_name}' not found in runtime metadata. " - f"Available pallets: {', '.join(available_pallets[:10])}" - + ( - f" and {len(available_pallets) - 10} more..." - if len(available_pallets) > 10 - else "" - ) - ) - - # Check if method exists in pallet - call_index = None - call_type = None - for call_item in pallet.calls: - if call_item.name == method_name: - call_index = call_item.index - call_type = call_item.type - break - - if call_index is None: - available_methods = [c.name for c in pallet.calls] - return None, ( - f"Method '{method_name}' not found in pallet '{pallet_name}'. " - f"Available methods: {', '.join(available_methods[:10])}" - + ( - f" and {len(available_methods) - 10} more..." - if len(available_methods) > 10 - else "" - ) - ) - - # Validate and compose the call - # The compose_call method will validate the parameters match expected types - try: - call = await subtensor.substrate.compose_call( - call_module=pallet_name, - call_function=method_name, - call_params=call_params, - ) - return call, None - except Exception as e: - error_msg = str(e) - # Try to provide more helpful error messages - if "parameter" in error_msg.lower() or "type" in error_msg.lower(): - return None, f"Invalid call parameters: {error_msg}" - return None, f"Failed to compose call: {error_msg}" - - except Exception as e: - return None, f"Error validating custom call: {str(e)}" - - async def create_crowdloan( subtensor: SubtensorInterface, wallet: Wallet, @@ -161,43 +64,53 @@ async def create_crowdloan( print_error(f"[red]{unlock_status.message}[/red]") return False, unlock_status.message - # Check for custom call options - has_custom_call = any([custom_call_pallet, custom_call_method, custom_call_args]) - if has_custom_call: - if not all([custom_call_pallet, custom_call_method]): - error_msg = "Both --custom-call-pallet and --custom-call-method must be provided when using custom call." + # Determine crowdloan type and validate + crowdloan_type: str + if subnet_lease is not None: + if custom_call_pallet or custom_call_method or custom_call_args: + error_msg = "--custom-call-* cannot be used with --subnet-lease." if json_output: json_console.print(json.dumps({"success": False, "error": error_msg})) else: print_error(f"[red]{error_msg}[/red]") return False, error_msg - - # Custom call args can be empty JSON object if method has no parameters - if custom_call_args is None: - custom_call_args = "{}" - - # Check mutual exclusivity with subnet_lease - if subnet_lease is not None: - error_msg = "--custom-call-pallet/--custom-call-method cannot be used together with --subnet-lease. Use one or the other." + crowdloan_type = "subnet" if subnet_lease else "fundraising" + elif custom_call_pallet or custom_call_method or custom_call_args: + if not (custom_call_pallet and custom_call_method): + error_msg = ( + "Both --custom-call-pallet and --custom-call-method must be provided." + ) if json_output: json_console.print(json.dumps({"success": False, "error": error_msg})) else: print_error(f"[red]{error_msg}[/red]") return False, error_msg - - crowdloan_type: str - if subnet_lease is not None: - crowdloan_type = "subnet" if subnet_lease else "fundraising" - elif has_custom_call: crowdloan_type = "custom" elif prompt: type_choice = IntPrompt.ask( "\n[bold cyan]What type of crowdloan would you like to create?[/bold cyan]\n" "[cyan][1][/cyan] General Fundraising (funds go to address)\n" - "[cyan][2][/cyan] Subnet Leasing (create new subnet)", - choices=["1", "2"], + "[cyan][2][/cyan] Subnet Leasing (create new subnet)\n" + "[cyan][3][/cyan] Custom Call (attach custom Substrate call)", + choices=["1", "2", "3"], ) - crowdloan_type = "subnet" if type_choice == 2 else "fundraising" + + if type_choice == 2: + crowdloan_type = "subnet" + elif type_choice == 3: + crowdloan_type = "custom" + success, pallet, method, args, error_msg = await prompt_custom_call_params( + subtensor=subtensor, json_output=json_output + ) + if not success: + return False, error_msg or "Failed to get custom call parameters." + custom_call_pallet, custom_call_method, custom_call_args = ( + pallet, + method, + args, + ) + else: + crowdloan_type = "fundraising" if crowdloan_type == "subnet": current_burn_cost = await subtensor.burn_cost() @@ -355,12 +268,11 @@ async def create_crowdloan( custom_call_info: Optional[dict] = None if crowdloan_type == "custom": - # Validate and compose custom call - call_to_attach, error_msg = await validate_and_compose_custom_call( - subtensor=subtensor, + call_params = json.loads(custom_call_args or "{}") + call_to_attach, error_msg = await subtensor.compose_custom_crowdloan_call( pallet_name=custom_call_pallet, method_name=custom_call_method, - args_json=custom_call_args or "{}", + call_params=call_params, ) if call_to_attach is None: @@ -368,12 +280,12 @@ async def create_crowdloan( json_console.print(json.dumps({"success": False, "error": error_msg})) else: print_error(f"[red]{error_msg}[/red]") - return False, error_msg or "Failed to validate custom call." + return False, error_msg or "Failed to compose custom call." custom_call_info = { "pallet": custom_call_pallet, "method": custom_call_method, - "args": json.loads(custom_call_args or "{}"), + "args": call_params, } target_address = None # Custom calls don't use target_address elif crowdloan_type == "subnet": From b46dfe6c6cf59c31fee988e5569cf12d77ced20a Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 18:15:04 -0800 Subject: [PATCH 24/33] add prompt_custom_call_params --- bittensor_cli/src/commands/crowd/utils.py | 89 +++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/bittensor_cli/src/commands/crowd/utils.py b/bittensor_cli/src/commands/crowd/utils.py index 4ad7895e5..22aa109c4 100644 --- a/bittensor_cli/src/commands/crowd/utils.py +++ b/bittensor_cli/src/commands/crowd/utils.py @@ -1,8 +1,97 @@ +import json from typing import Optional from async_substrate_interface.types import Runtime +from rich.prompt import Prompt from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface +from bittensor_cli.src.bittensor.utils import console, json_console, print_error + + +async def prompt_custom_call_params( + subtensor: SubtensorInterface, + json_output: bool = False, +) -> tuple[bool, Optional[str], Optional[str], Optional[str], Optional[str]]: + """ + Prompt user for custom call parameters (pallet, method, and JSON args) + and validate that the call can be composed. + + Args: + subtensor: SubtensorInterface instance for call validation + json_output: Whether to output errors as JSON + + Returns: + Tuple of (success, pallet_name, method_name, args_json, error_msg) + On success: (True, pallet, method, args, None) + On failure: (False, None, None, None, error_msg) + """ + if not json_output: + console.print( + "\n[bold cyan]Custom Call Parameters[/bold cyan]\n" + "[dim]You'll need to provide a pallet (module) name, method name, and optional JSON arguments.\n\n" + "[yellow]Examples:[/yellow]\n" + " • Pallet: [cyan]SubtensorModule[/cyan], [cyan]Balances[/cyan], [cyan]System[/cyan]\n" + " • Method: [cyan]transfer_allow_death[/cyan], [cyan]transfer_keep_alive[/cyan], [cyan]transfer_all[/cyan]\n" + ' • Args: [cyan]{"dest": "5D...", "value": 1000000000}[/cyan] or [cyan]{}[/cyan] for empty\n' + ) + + pallet = Prompt.ask("Enter pallet name") + if not pallet.strip(): + if json_output: + json_console.print( + json.dumps({"success": False, "error": "Pallet name cannot be empty."}) + ) + else: + print_error("[red]Pallet name cannot be empty.[/red]") + return await prompt_custom_call_params(subtensor, json_output) + + method = Prompt.ask("Enter method name") + if not method.strip(): + if json_output: + json_console.print( + json.dumps({"success": False, "error": "Method name cannot be empty."}) + ) + else: + print_error("[red]Method name cannot be empty.[/red]") + return await prompt_custom_call_params(subtensor, json_output) + + args_input = Prompt.ask( + "Enter custom call arguments as JSON [dim](or press Enter for empty: {})[/dim]", + default="{}", + ) + + try: + call_params = json.loads(args_input) + except json.JSONDecodeError as e: + error_msg = f"Invalid JSON: {e}" + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + print_error( + '[yellow]Please try again. Example: {"param1": "value", "param2": 123}[/yellow]' + ) + return await prompt_custom_call_params(subtensor, json_output) + + call, error_msg = await subtensor.compose_custom_crowdloan_call( + pallet_name=pallet, + method_name=method, + call_params=call_params, + ) + if call is None: + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]Failed to compose call: {error_msg}[/red]") + console.print( + "[yellow]Please check:\n" + " • Pallet name exists in runtime\n" + " • Method name exists in the pallet\n" + " • Arguments match the method's expected parameters[/yellow]\n" + ) + return await prompt_custom_call_params(subtensor, json_output) + + return True, pallet, method, args_input, None async def get_constant( From d8436851de8b112932215156efa3b730ac2f6cae Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 18:32:25 -0800 Subject: [PATCH 25/33] make show_contributors a bool --- bittensor_cli/cli.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 3bf6e65c4..5431ca397 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -8831,10 +8831,10 @@ def crowd_info( quiet: bool = Options.quiet, verbose: bool = Options.verbose, json_output: bool = Options.json_output, - show_contributors: Optional[str] = typer.Option( - None, + show_contributors: bool = typer.Option( + False, "--show-contributors", - help="Show contributor list with identities. Use 'true' or 'false', or omit for default (false).", + help="Show contributor list with identities.", ), ): """ @@ -8870,11 +8870,6 @@ def crowd_info( validate=WV.WALLET, ) - # Parse show_contributors: None or "false" -> False, "true" -> True - show_contributors_bool = False # default - if show_contributors is not None: - show_contributors_bool = show_contributors.lower() in ("true", "1", "yes") - return self._run_command( view_crowdloan.show_crowdloan_details( subtensor=self.initialize_chain(network), @@ -8882,7 +8877,7 @@ def crowd_info( wallet=wallet, verbose=verbose, json_output=json_output, - show_contributors=show_contributors_bool, + show_contributors=show_contributors, ) ) From a58f810c87f03f0d392805e3a09bfe8c20ccbcc6 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 18:32:42 -0800 Subject: [PATCH 26/33] make contributors display consistent --- bittensor_cli/src/commands/crowd/view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bittensor_cli/src/commands/crowd/view.py b/bittensor_cli/src/commands/crowd/view.py index defc9e428..813dd07ee 100644 --- a/bittensor_cli/src/commands/crowd/view.py +++ b/bittensor_cli/src/commands/crowd/view.py @@ -986,7 +986,7 @@ async def show_crowdloan_details( table.add_row( f"#{rank}", - f"{contributor_display} - {contribution_display} ({percentage:.2f}%)", + f"{contributor_display:<70} - {contribution_display} ({percentage:.2f}%)", ) if len(contributor_data) > 10: From 2ca50c45ed5181aa7b7adfcb72914fe892fa8051 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 18:36:21 -0800 Subject: [PATCH 27/33] remove outdated test --- tests/unit_tests/test_crowd_contributors.py | 496 -------------------- 1 file changed, 496 deletions(-) delete mode 100644 tests/unit_tests/test_crowd_contributors.py diff --git a/tests/unit_tests/test_crowd_contributors.py b/tests/unit_tests/test_crowd_contributors.py deleted file mode 100644 index 201164420..000000000 --- a/tests/unit_tests/test_crowd_contributors.py +++ /dev/null @@ -1,496 +0,0 @@ -""" -Unit tests for crowd contributors command. -""" - -import pytest -from unittest.mock import AsyncMock, MagicMock, Mock, patch -from bittensor_cli.src.bittensor.balances import Balance -from bittensor_cli.src.bittensor.chain_data import CrowdloanData -from bittensor_cli.src.commands.crowd.contributors import list_contributors - - -class TestListContributors: - """Tests for list_contributors function.""" - - @pytest.mark.asyncio - async def test_list_contributors_success(self): - """Test successful listing of contributors.""" - # Setup mocks - mock_subtensor = MagicMock() - mock_subtensor.network = "finney" - - # Mock crowdloan exists - mock_crowdloan = CrowdloanData( - creator="5DjzesT8f6Td8", - funds_account="5EYCAeX97cWb", - deposit=Balance.from_tao(10.0), - min_contribution=Balance.from_tao(0.1), - cap=Balance.from_tao(30.0), - raised=Balance.from_tao(30.0), - end=1000000, - finalized=False, - contributors_count=3, - target_address="5GduHCP9UdBY", - has_call=False, - call_details=None, - ) - mock_subtensor.get_single_crowdloan = AsyncMock(return_value=mock_crowdloan) - - # Mock contributors data from query_map - # The key structure is ((account_bytes_tuple,),) where account_bytes_tuple is tuple of ints - mock_contributor1_key = ( - ( - 74, - 51, - 88, - 161, - 161, - 215, - 144, - 145, - 231, - 175, - 227, - 146, - 149, - 109, - 220, - 180, - 12, - 58, - 121, - 233, - 152, - 50, - 211, - 15, - 242, - 187, - 103, - 2, - 198, - 131, - 177, - 118, - ), - ) - mock_contributor2_key = ( - ( - 202, - 66, - 124, - 47, - 131, - 219, - 1, - 26, - 137, - 169, - 17, - 112, - 182, - 39, - 163, - 162, - 72, - 150, - 208, - 58, - 179, - 235, - 238, - 242, - 150, - 177, - 219, - 0, - 2, - 76, - 172, - 171, - ), - ) - mock_contributor3_key = ( - ( - 224, - 56, - 146, - 238, - 201, - 170, - 157, - 255, - 58, - 77, - 190, - 94, - 17, - 231, - 15, - 217, - 15, - 134, - 147, - 100, - 174, - 45, - 31, - 132, - 21, - 200, - 40, - 185, - 176, - 209, - 247, - 54, - ), - ) - - mock_contribution1 = MagicMock() - mock_contribution1.value = 10000000000 # 10 TAO in rao - mock_contribution2 = MagicMock() - mock_contribution2.value = 10000000000 # 10 TAO in rao - mock_contribution3 = MagicMock() - mock_contribution3.value = 10000000000 # 10 TAO in rao - - # Create async generator for query_map results - async def mock_query_map_generator(): - yield (mock_contributor1_key, mock_contribution1) - yield (mock_contributor2_key, mock_contribution2) - yield (mock_contributor3_key, mock_contribution3) - - # Create a proper async iterable - class MockQueryMapResult: - def __aiter__(self): - return mock_query_map_generator() - - mock_subtensor.substrate.query_map = AsyncMock( - return_value=MockQueryMapResult() - ) - - # Mock identities - mock_subtensor.query_identity = AsyncMock( - side_effect=[ - {"info": {"display": {"Raw": "Alice"}}}, # Contributor 1 - {"info": {"display": {"Raw": "Bob"}}}, # Contributor 2 - {}, # Contributor 3 (no identity) - ] - ) - - # Execute - result = await list_contributors( - subtensor=mock_subtensor, - crowdloan_id=0, - verbose=False, - json_output=False, - ) - - # Verify - assert result is True - mock_subtensor.get_single_crowdloan.assert_called_once_with(0) - mock_subtensor.substrate.query_map.assert_called_once_with( - module="Crowdloan", - storage_function="Contributions", - params=[0], - fully_exhaust=True, - ) - assert mock_subtensor.query_identity.call_count == 3 - - @pytest.mark.asyncio - async def test_list_contributors_crowdloan_not_found(self): - """Test listing contributors when crowdloan doesn't exist.""" - mock_subtensor = MagicMock() - mock_subtensor.get_single_crowdloan = AsyncMock(return_value=None) - - # Execute - result = await list_contributors( - subtensor=mock_subtensor, - crowdloan_id=999, - verbose=False, - json_output=False, - ) - - # Verify - assert result is False - mock_subtensor.get_single_crowdloan.assert_called_once_with(999) - mock_subtensor.substrate.query_map.assert_not_called() - - @pytest.mark.asyncio - async def test_list_contributors_no_contributors(self): - """Test listing contributors when there are no contributors.""" - mock_subtensor = MagicMock() - mock_subtensor.network = "finney" - - mock_crowdloan = CrowdloanData( - creator="5DjzesT8f6Td8", - funds_account="5EYCAeX97cWb", - deposit=Balance.from_tao(10.0), - min_contribution=Balance.from_tao(0.1), - cap=Balance.from_tao(100.0), - raised=Balance.from_tao(10.0), - end=1000000, - finalized=False, - contributors_count=0, - target_address=None, - has_call=False, - call_details=None, - ) - mock_subtensor.get_single_crowdloan = AsyncMock(return_value=mock_crowdloan) - - # Mock empty contributors data - async def mock_empty_query_map(): - if False: # Never yield anything - yield - - class MockEmptyQueryMapResult: - def __aiter__(self): - return mock_empty_query_map() - - mock_subtensor.substrate.query_map = AsyncMock( - return_value=MockEmptyQueryMapResult() - ) - - # Execute - result = await list_contributors( - subtensor=mock_subtensor, - crowdloan_id=0, - verbose=False, - json_output=False, - ) - - # Verify - assert result is True - mock_subtensor.query_identity.assert_not_called() - - @pytest.mark.asyncio - async def test_list_contributors_json_output(self): - """Test listing contributors with JSON output.""" - mock_subtensor = MagicMock() - mock_subtensor.network = "finney" - - mock_crowdloan = CrowdloanData( - creator="5DjzesT8f6Td8", - funds_account="5EYCAeX97cWb", - deposit=Balance.from_tao(10.0), - min_contribution=Balance.from_tao(0.1), - cap=Balance.from_tao(20.0), - raised=Balance.from_tao(20.0), - end=1000000, - finalized=False, - contributors_count=2, - target_address=None, - has_call=False, - call_details=None, - ) - mock_subtensor.get_single_crowdloan = AsyncMock(return_value=mock_crowdloan) - - # Mock contributors data - mock_contributor1_key = ( - ( - 74, - 51, - 88, - 161, - 161, - 215, - 144, - 145, - 231, - 175, - 227, - 146, - 149, - 109, - 220, - 180, - 12, - 58, - 121, - 233, - 152, - 50, - 211, - 15, - 242, - 187, - 103, - 2, - 198, - 131, - 177, - 118, - ), - ) - mock_contributor2_key = ( - ( - 202, - 66, - 124, - 47, - 131, - 219, - 1, - 26, - 137, - 169, - 17, - 112, - 182, - 39, - 163, - 162, - 72, - 150, - 208, - 58, - 179, - 235, - 238, - 242, - 150, - 177, - 219, - 0, - 2, - 76, - 172, - 171, - ), - ) - - mock_contribution1 = MagicMock() - mock_contribution1.value = 10000000000 # 10 TAO - mock_contribution2 = MagicMock() - mock_contribution2.value = 10000000000 # 10 TAO - - async def mock_query_map_generator(): - yield (mock_contributor1_key, mock_contribution1) - yield (mock_contributor2_key, mock_contribution2) - - class MockQueryMapResult: - def __aiter__(self): - return mock_query_map_generator() - - mock_subtensor.substrate.query_map = AsyncMock( - return_value=MockQueryMapResult() - ) - mock_subtensor.query_identity = AsyncMock( - side_effect=[ - {"info": {"display": {"Raw": "Alice"}}}, - {"info": {"display": {"Raw": "Bob"}}}, - ] - ) - - # Mock json_console - with patch( - "bittensor_cli.src.commands.crowd.contributors.json_console" - ) as mock_json_console: - # Execute - result = await list_contributors( - subtensor=mock_subtensor, - crowdloan_id=0, - verbose=False, - json_output=True, - ) - - # Verify - assert result is True - mock_json_console.print.assert_called_once() - call_args = mock_json_console.print.call_args[0][0] - import json - - output_data = json.loads(call_args) - assert output_data["success"] is True - assert output_data["data"]["crowdloan_id"] == 0 - assert len(output_data["data"]["contributors"]) == 2 - assert output_data["data"]["total_count"] == 2 - assert output_data["data"]["total_contributed_tao"] == 20.0 - assert output_data["data"]["network"] == "finney" - # Verify contributors are sorted by rank - assert output_data["data"]["contributors"][0]["rank"] == 1 - assert output_data["data"]["contributors"][1]["rank"] == 2 - - @pytest.mark.asyncio - async def test_list_contributors_verbose_mode(self): - """Test listing contributors with verbose mode.""" - mock_subtensor = MagicMock() - mock_subtensor.network = "finney" - - mock_crowdloan = CrowdloanData( - creator="5DjzesT8f6Td8", - funds_account="5EYCAeX97cWb", - deposit=Balance.from_tao(10.0), - min_contribution=Balance.from_tao(0.1), - cap=Balance.from_tao(10.0), - raised=Balance.from_tao(10.0), - end=1000000, - finalized=False, - contributors_count=1, - target_address=None, - has_call=False, - call_details=None, - ) - mock_subtensor.get_single_crowdloan = AsyncMock(return_value=mock_crowdloan) - - mock_contributor_key = ( - ( - 74, - 51, - 88, - 161, - 161, - 215, - 144, - 145, - 231, - 175, - 227, - 146, - 149, - 109, - 220, - 180, - 12, - 58, - 121, - 233, - 152, - 50, - 211, - 15, - 242, - 187, - 103, - 2, - 198, - 131, - 177, - 118, - ), - ) - mock_contribution = MagicMock() - mock_contribution.value = 10000000000 # 10 TAO - - async def mock_query_map_generator(): - yield (mock_contributor_key, mock_contribution) - - class MockQueryMapResult: - def __aiter__(self): - return mock_query_map_generator() - - mock_subtensor.substrate.query_map = AsyncMock( - return_value=MockQueryMapResult() - ) - mock_subtensor.query_identity = AsyncMock(return_value={}) - - # Execute - result = await list_contributors( - subtensor=mock_subtensor, - crowdloan_id=0, - verbose=True, - json_output=False, - ) - - # Verify - assert result is True From 2d13058a83bbfd29c1767f8b6aa485bd86da8de9 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 18:37:54 -0800 Subject: [PATCH 28/33] remove outdated test --- .../test_crowd_create_custom_call.py | 180 ------------------ 1 file changed, 180 deletions(-) delete mode 100644 tests/unit_tests/test_crowd_create_custom_call.py diff --git a/tests/unit_tests/test_crowd_create_custom_call.py b/tests/unit_tests/test_crowd_create_custom_call.py deleted file mode 100644 index 8aa0fddfa..000000000 --- a/tests/unit_tests/test_crowd_create_custom_call.py +++ /dev/null @@ -1,180 +0,0 @@ -""" -Unit tests for crowd create custom call functionality. -""" - -import json -import pytest -from unittest.mock import AsyncMock, MagicMock, Mock, patch -from scalecodec import GenericCall - -from bittensor_cli.src.commands.crowd.create import validate_and_compose_custom_call - - -class TestValidateAndComposeCustomCall: - """Tests for validate_and_compose_custom_call function.""" - - @pytest.mark.asyncio - async def test_invalid_json_args(self): - """Test that invalid JSON in args is caught.""" - mock_subtensor = MagicMock() - mock_subtensor.substrate = MagicMock() - - result_call, error_msg = await validate_and_compose_custom_call( - subtensor=mock_subtensor, - pallet_name="TestPallet", - method_name="test_method", - args_json='{"invalid": json}', - ) - - assert result_call is None - assert "Invalid JSON" in error_msg - - @pytest.mark.asyncio - async def test_pallet_not_found(self): - """Test that missing pallet is detected.""" - mock_subtensor = MagicMock() - mock_subtensor.substrate = MagicMock() - - # Mock metadata structure - mock_pallet = MagicMock() - mock_pallet.name = "OtherPallet" - - mock_metadata = MagicMock() - mock_metadata.pallets = [mock_pallet] - mock_metadata.get_metadata_pallet = Mock( - side_effect=ValueError("Pallet not found") - ) - - mock_runtime = MagicMock() - mock_runtime.metadata = mock_metadata - - mock_subtensor.substrate.get_chain_head = AsyncMock(return_value="0x1234") - mock_subtensor.substrate.init_runtime = AsyncMock(return_value=mock_runtime) - - result_call, error_msg = await validate_and_compose_custom_call( - subtensor=mock_subtensor, - pallet_name="NonExistentPallet", - method_name="test_method", - args_json="{}", - ) - - assert result_call is None - assert "not found" in error_msg.lower() - - @pytest.mark.asyncio - async def test_method_not_found(self): - """Test that missing method is detected.""" - mock_subtensor = MagicMock() - mock_subtensor.substrate = MagicMock() - - # Mock metadata structure - mock_call = MagicMock() - mock_call.name = "other_method" - - mock_pallet = MagicMock() - mock_pallet.name = "TestPallet" - mock_pallet.calls = [mock_call] - - mock_metadata = MagicMock() - mock_metadata.pallets = [mock_pallet] - mock_metadata.get_metadata_pallet = Mock(return_value=mock_pallet) - - mock_runtime = MagicMock() - mock_runtime.metadata = mock_metadata - - mock_subtensor.substrate.get_chain_head = AsyncMock(return_value="0x1234") - mock_subtensor.substrate.init_runtime = AsyncMock(return_value=mock_runtime) - - result_call, error_msg = await validate_and_compose_custom_call( - subtensor=mock_subtensor, - pallet_name="TestPallet", - method_name="non_existent_method", - args_json="{}", - ) - - assert result_call is None - assert "not found" in error_msg.lower() - - @pytest.mark.asyncio - async def test_successful_validation(self): - """Test successful validation and call composition.""" - mock_subtensor = MagicMock() - mock_subtensor.substrate = MagicMock() - - # Mock metadata structure - mock_call = MagicMock() - mock_call.name = "test_method" - mock_call.index = 0 - - mock_pallet = MagicMock() - mock_pallet.name = "TestPallet" - mock_pallet.calls = [mock_call] - - mock_metadata = MagicMock() - mock_metadata.pallets = [mock_pallet] - mock_metadata.get_metadata_pallet = Mock(return_value=mock_pallet) - - mock_runtime = MagicMock() - mock_runtime.metadata = mock_metadata - - # Mock compose_call to return a GenericCall - mock_generic_call = MagicMock(spec=GenericCall) - mock_subtensor.substrate.compose_call = AsyncMock( - return_value=mock_generic_call - ) - mock_subtensor.substrate.get_chain_head = AsyncMock(return_value="0x1234") - mock_subtensor.substrate.init_runtime = AsyncMock(return_value=mock_runtime) - - result_call, error_msg = await validate_and_compose_custom_call( - subtensor=mock_subtensor, - pallet_name="TestPallet", - method_name="test_method", - args_json='{"param1": "value1"}', - ) - - assert result_call is not None - assert error_msg is None - mock_subtensor.substrate.compose_call.assert_called_once_with( - call_module="TestPallet", - call_function="test_method", - call_params={"param1": "value1"}, - ) - - @pytest.mark.asyncio - async def test_compose_call_failure(self): - """Test handling of compose_call failures.""" - mock_subtensor = MagicMock() - mock_subtensor.substrate = MagicMock() - - # Mock metadata structure - mock_call = MagicMock() - mock_call.name = "test_method" - - mock_pallet = MagicMock() - mock_pallet.name = "TestPallet" - mock_pallet.calls = [mock_call] - - mock_metadata = MagicMock() - mock_metadata.pallets = [mock_pallet] - mock_metadata.get_metadata_pallet = Mock(return_value=mock_pallet) - - mock_runtime = MagicMock() - mock_runtime.metadata = mock_metadata - - # Mock compose_call to raise an error - mock_subtensor.substrate.compose_call = AsyncMock( - side_effect=Exception("Invalid parameter type") - ) - mock_subtensor.substrate.get_chain_head = AsyncMock(return_value="0x1234") - mock_subtensor.substrate.init_runtime = AsyncMock(return_value=mock_runtime) - - result_call, error_msg = await validate_and_compose_custom_call( - subtensor=mock_subtensor, - pallet_name="TestPallet", - method_name="test_method", - args_json='{"param1": "value1"}', - ) - - assert result_call is None - assert error_msg is not None - assert "Invalid parameter" in error_msg or "Failed to compose" in error_msg From 2a2fea94de3e42efea28b559ddc53c18e60022af Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 18:40:32 -0800 Subject: [PATCH 29/33] useless test. First one is a dummy, second one masks errors --- tests/e2e_tests/test_crowd_contributors.py | 260 --------------------- 1 file changed, 260 deletions(-) delete mode 100644 tests/e2e_tests/test_crowd_contributors.py diff --git a/tests/e2e_tests/test_crowd_contributors.py b/tests/e2e_tests/test_crowd_contributors.py deleted file mode 100644 index ebfb842a9..000000000 --- a/tests/e2e_tests/test_crowd_contributors.py +++ /dev/null @@ -1,260 +0,0 @@ -""" -E2E tests for crowd contributors command. - -Verify command: -* btcli crowd contributors --id -""" - -import json -import pytest - - -@pytest.mark.parametrize("local_chain", [False], indirect=True) -def test_crowd_contributors_command(local_chain, wallet_setup): - """ - Test crowd contributors command and inspect its output. - - Steps: - 1. Create a crowdloan (if needed) or use existing one - 2. Make contributions to the crowdloan - 3. Execute contributors command and verify output - 4. Test with --verbose flag - 5. Test with --json-output flag - - Note: This test requires an existing crowdloan with contributors. - For a full e2e test, you would need to: - - Create a crowdloan - - Make contributions - - Then list contributors - """ - wallet_path_alice = "//Alice" - - # Create wallet for Alice - keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( - wallet_path_alice - ) - - # Test 1: List contributors for an existing crowdloan (assuming crowdloan #0 exists) - # This will work if there's a crowdloan with contributors on the test chain - result = exec_command_alice( - command="crowd", - sub_command="contributors", - extra_args=[ - "--id", - "0", - "--network", - "ws://127.0.0.1:9945", - "--json-output", - ], - ) - - # Parse JSON output - try: - result_output = json.loads(result.stdout) - # If crowdloan exists and has contributors - if result_output.get("success") is True: - assert "data" in result_output - assert "contributors" in result_output["data"] - assert "crowdloan_id" in result_output["data"] - assert result_output["data"]["crowdloan_id"] == 0 - assert isinstance(result_output["data"]["contributors"], list) - assert "total_count" in result_output["data"] - assert "total_contributed_tao" in result_output["data"] - - # If there are contributors, verify structure - if result_output["data"]["total_count"] > 0: - contributor = result_output["data"]["contributors"][0] - assert "rank" in contributor - assert "address" in contributor - assert "identity" in contributor - assert "contribution_tao" in contributor - assert "contribution_rao" in contributor - assert "percentage" in contributor - assert contributor["rank"] == 1 # First contributor should be rank 1 - assert contributor["contribution_tao"] >= 0 - assert 0 <= contributor["percentage"] <= 100 - - # If crowdloan doesn't exist or has no contributors - elif result_output.get("success") is False: - assert "error" in result_output - except json.JSONDecodeError: - # If output is not JSON (shouldn't happen with --json-output) - pytest.fail("Expected JSON output but got non-JSON response") - - # Test 2: Test with verbose flag - result_verbose = exec_command_alice( - command="crowd", - sub_command="contributors", - extra_args=[ - "--id", - "0", - "--network", - "ws://127.0.0.1:9945", - "--verbose", - ], - ) - - # Verify verbose output (should show full addresses) - assert result_verbose.exit_code == 0 or result_verbose.exit_code is None - - # Test 3: Test with non-existent crowdloan - result_not_found = exec_command_alice( - command="crowd", - sub_command="contributors", - extra_args=[ - "--id", - "99999", - "--network", - "ws://127.0.0.1:9945", - "--json-output", - ], - ) - - try: - result_output = json.loads(result_not_found.stdout) - # Should return error for non-existent crowdloan - assert result_output.get("success") is False - assert "error" in result_output - assert "not found" in result_output["error"].lower() - except json.JSONDecodeError: - # If output is not JSON, that's also acceptable for error cases - pass - - -@pytest.mark.parametrize("local_chain", [False], indirect=True) -def test_crowd_contributors_with_real_crowdloan(local_chain, wallet_setup): - """ - Full e2e test: Create crowdloan, contribute, then list contributors. - - Steps: - 1. Create a crowdloan - 2. Make contributions from multiple wallets - 3. List contributors and verify all are present - 4. Verify sorting by contribution amount - """ - wallet_path_alice = "//Alice" - wallet_path_bob = "//Bob" - - # Create wallets - keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( - wallet_path_alice - ) - keypair_bob, wallet_bob, wallet_path_bob, exec_command_bob = wallet_setup( - wallet_path_bob - ) - - # Step 1: Create a crowdloan - create_result = exec_command_alice( - command="crowd", - sub_command="create", - extra_args=[ - "--wallet-path", - wallet_path_alice, - "--network", - "ws://127.0.0.1:9945", - "--wallet-name", - wallet_alice.name, - "--wallet-hotkey", - wallet_alice.hotkey_str, - "--deposit", - "10", - "--cap", - "100", - "--duration", - "10000", - "--min-contribution", - "1", - "--no-prompt", - "--json-output", - ], - ) - - try: - create_output = json.loads(create_result.stdout) - if create_output.get("success") is True: - crowdloan_id = create_output.get("crowdloan_id") or create_output.get( - "data", {} - ).get("crowdloan_id") - - if crowdloan_id is not None: - # Step 2: Make contributions - # Alice contributes - contribute_alice = exec_command_alice( - command="crowd", - sub_command="contribute", - extra_args=[ - "--id", - str(crowdloan_id), - "--wallet-path", - wallet_path_alice, - "--network", - "ws://127.0.0.1:9945", - "--wallet-name", - wallet_alice.name, - "--wallet-hotkey", - wallet_alice.hotkey_str, - "--amount", - "20", - "--no-prompt", - "--json-output", - ], - ) - - # Bob contributes - contribute_bob = exec_command_bob( - command="crowd", - sub_command="contribute", - extra_args=[ - "--id", - str(crowdloan_id), - "--wallet-path", - wallet_path_bob, - "--network", - "ws://127.0.0.1:9945", - "--wallet-name", - wallet_bob.name, - "--wallet-hotkey", - wallet_bob.hotkey_str, - "--amount", - "30", - "--no-prompt", - "--json-output", - ], - ) - - # Step 3: List contributors - contributors_result = exec_command_alice( - command="crowd", - sub_command="contributors", - extra_args=[ - "--id", - str(crowdloan_id), - "--network", - "ws://127.0.0.1:9945", - "--json-output", - ], - ) - - contributors_output = json.loads(contributors_result.stdout) - assert contributors_output.get("success") is True - assert contributors_output["data"]["crowdloan_id"] == crowdloan_id - assert contributors_output["data"]["total_count"] >= 2 - - # Verify contributors are sorted by contribution (descending) - contributors_list = contributors_output["data"]["contributors"] - if len(contributors_list) >= 2: - # Bob should be first (30 TAO > 20 TAO) - assert ( - contributors_list[0]["contribution_tao"] - >= contributors_list[1]["contribution_tao"] - ) - - # Verify percentages sum to 100% - total_percentage = sum(c["percentage"] for c in contributors_list) - assert ( - abs(total_percentage - 100.0) < 0.01 - ) # Allow small floating point errors - - except (json.JSONDecodeError, KeyError, AssertionError) as e: - # Skip test if prerequisites aren't met (e.g., insufficient balance, chain not ready) - pytest.skip(f"Test prerequisites not met: {e}") From 6d9cf47068d9f33c3e1dfdf0b6c34d89edb77b0a Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 18:41:02 -0800 Subject: [PATCH 30/33] outdated test. Identities are now fetched by default --- .../e2e_tests/test_crowd_identity_display.py | 150 ------------------ 1 file changed, 150 deletions(-) delete mode 100644 tests/e2e_tests/test_crowd_identity_display.py diff --git a/tests/e2e_tests/test_crowd_identity_display.py b/tests/e2e_tests/test_crowd_identity_display.py deleted file mode 100644 index 7953082d9..000000000 --- a/tests/e2e_tests/test_crowd_identity_display.py +++ /dev/null @@ -1,150 +0,0 @@ -""" -E2E tests for crowd identity display functionality. - -Verify commands: -* btcli crowd list --show-identities -* btcli crowd info --id --show-identities --show-contributors -""" - -import json -import pytest - - -@pytest.mark.parametrize("local_chain", [False], indirect=True) -def test_crowd_list_with_identities(local_chain, wallet_setup): - """ - Test crowd list command with identity display. - - Steps: - 1. Execute crowd list with --show-identities (default) - 2. Execute crowd list with --no-show-identities - 3. Verify identity information is displayed when enabled - """ - wallet_path_alice = "//Alice" - - # Create wallet for Alice - keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( - wallet_path_alice - ) - - # Test 1: List with identities (default) - result = exec_command_alice( - command="crowd", - sub_command="list", - extra_args=[ - "--network", - "ws://127.0.0.1:9945", - "--json-output", - ], - ) - - try: - result_output = json.loads(result.stdout) - if result_output.get("success") is True: - assert "data" in result_output - assert "crowdloans" in result_output["data"] - - # Check if identity fields are present - if result_output["data"]["crowdloans"]: - crowdloan = result_output["data"]["crowdloans"][0] - # Identity fields should be present (may be None if no identity) - assert "creator_identity" in crowdloan - assert "target_identity" in crowdloan - except json.JSONDecodeError: - pytest.skip("Could not parse JSON output") - - # Test 2: List without identities - result_no_identities = exec_command_alice( - command="crowd", - sub_command="list", - extra_args=[ - "--network", - "ws://127.0.0.1:9945", - "--show-identities", - "false", - "--json-output", - ], - ) - - try: - result_output = json.loads(result_no_identities.stdout) - if result_output.get("success") is True: - if result_output["data"]["crowdloans"]: - crowdloan = result_output["data"]["crowdloans"][0] - # Identity fields should still be present but None - assert "creator_identity" in crowdloan - assert crowdloan.get("creator_identity") is None - except json.JSONDecodeError: - pytest.skip("Could not parse JSON output") - - -@pytest.mark.parametrize("local_chain", [False], indirect=True) -def test_crowd_info_with_identities(local_chain, wallet_setup): - """ - Test crowd info command with identity display and contributors. - - Steps: - 1. Execute crowd info with --show-identities - 2. Execute crowd info with --show-contributors - 3. Verify identity and contributor information is displayed - """ - wallet_path_alice = "//Alice" - - # Create wallet for Alice - keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( - wallet_path_alice - ) - - # Test 1: Info with identities (default) - result = exec_command_alice( - command="crowd", - sub_command="info", - extra_args=[ - "--id", - "0", - "--network", - "ws://127.0.0.1:9945", - "--json-output", - ], - ) - - try: - result_output = json.loads(result.stdout) - if result_output.get("success") is True: - assert "data" in result_output - # Identity fields should be present - assert "creator_identity" in result_output["data"] - assert "target_identity" in result_output["data"] - except json.JSONDecodeError: - pytest.skip("Could not parse JSON output or crowdloan not found") - - # Test 2: Info with identities and contributors - result_with_contributors = exec_command_alice( - command="crowd", - sub_command="info", - extra_args=[ - "--id", - "0", - "--network", - "ws://127.0.0.1:9945", - "--show-identities", - "true", - "--show-contributors", - "true", - "--json-output", - ], - ) - - try: - result_output = json.loads(result_with_contributors.stdout) - if result_output.get("success") is True: - assert "data" in result_output - # Contributors should be present if flag is set - assert "contributors" in result_output["data"] - if result_output["data"]["contributors"]: - contributor = result_output["data"]["contributors"][0] - assert "identity" in contributor - assert "address" in contributor - assert "contribution_tao" in contributor - except json.JSONDecodeError: - pytest.skip("Could not parse JSON output or crowdloan not found") From b1207a4551f4b7457b7d5c84d669ad6fc1a03448 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Tue, 13 Jan 2026 20:13:17 -0800 Subject: [PATCH 31/33] simplify display formatting --- bittensor_cli/src/commands/crowd/view.py | 80 +++++++++++------------- 1 file changed, 35 insertions(+), 45 deletions(-) diff --git a/bittensor_cli/src/commands/crowd/view.py b/bittensor_cli/src/commands/crowd/view.py index 813dd07ee..20ed82935 100644 --- a/bittensor_cli/src/commands/crowd/view.py +++ b/bittensor_cli/src/commands/crowd/view.py @@ -434,28 +434,26 @@ async def list_crowdloans( else: time_cell = time_label - # Format creator cell with identity if available - if loan.creator in identity_map: - creator_identity = identity_map[loan.creator] - if verbose: - creator_cell = f"{creator_identity} ({loan.creator})" - else: - creator_cell = f"{creator_identity} ({_shorten(loan.creator)})" - else: - creator_cell = loan.creator if verbose else _shorten(loan.creator) + # Format creator cell + creator_identity = identity_map.get(loan.creator) + address_display = loan.creator if verbose else _shorten(loan.creator) + creator_cell = ( + f"{creator_identity} ({address_display})" + if creator_identity + else address_display + ) - # Format target cell with identity if available + # Format target cell if loan.target_address: - if loan.target_address in identity_map: - target_identity = identity_map[loan.target_address] - if verbose: - target_cell = f"{target_identity} ({loan.target_address})" - else: - target_cell = f"{target_identity} ({_shorten(loan.target_address)})" - else: - target_cell = ( - loan.target_address if verbose else _shorten(loan.target_address) - ) + target_identity = identity_map.get(loan.target_address) + address_display = ( + loan.target_address if verbose else _shorten(loan.target_address) + ) + target_cell = ( + f"{target_identity} ({address_display})" + if target_identity + else address_display + ) else: target_cell = ( f"[{COLORS.G.SUBHEAD_MAIN}]Not specified[/{COLORS.G.SUBHEAD_MAIN}]" @@ -734,17 +732,14 @@ async def show_crowdloan_details( table.add_row("Status", f"[{status_color}]{status}[/{status_color}]{status_detail}") - # Display creator with identity if available - if crowdloan.creator in identity_map: - creator_identity = identity_map[crowdloan.creator] - if verbose: - creator_display = f"{creator_identity} ({crowdloan.creator})" - else: - creator_display = f"{creator_identity} ({_shorten(crowdloan.creator)})" - elif verbose: - creator_display = crowdloan.creator - else: - creator_display = _shorten(crowdloan.creator) + # Display creator + creator_identity = identity_map.get(crowdloan.creator) + address_display = crowdloan.creator if verbose else _shorten(crowdloan.creator) + creator_display = ( + f"{creator_identity} ({address_display})" + if creator_identity + else address_display + ) table.add_row( "Creator", f"[{COLORS.G.TEMPO}]{creator_display}[/{COLORS.G.TEMPO}]", @@ -853,20 +848,15 @@ async def show_crowdloan_details( table.add_section() if crowdloan.target_address: - if crowdloan.target_address in identity_map: - target_identity = identity_map[crowdloan.target_address] - if verbose: - target_display = f"{target_identity} ({crowdloan.target_address})" - else: - target_display = ( - f"{target_identity} ({_shorten(crowdloan.target_address)})" - ) - else: - target_display = ( - crowdloan.target_address - if verbose - else _shorten(crowdloan.target_address) - ) + target_identity = identity_map.get(crowdloan.target_address) + address_display = ( + crowdloan.target_address if verbose else _shorten(crowdloan.target_address) + ) + target_display = ( + f"{target_identity} ({address_display})" + if target_identity + else address_display + ) else: target_display = ( f"[{COLORS.G.SUBHEAD_MAIN}]Not specified[/{COLORS.G.SUBHEAD_MAIN}]" From 5428c023b9a3795a84176a6904b9f60905d133b0 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Thu, 15 Jan 2026 14:06:16 -0800 Subject: [PATCH 32/33] update changelog --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99297d0a7..75a7b67a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 9.18.0 /2026-01-15 + +## What's Changed +* Standardize Success Message Printing with print.success by @leonace924 in https://github.com/opentensor/btcli/pull/786 +* Fix live display formatting on macOS Terminal.app by @calm329 in https://github.com/opentensor/btcli/pull/789 +* Update User Liquidity E2E test by @ibraheem-abe in https://github.com/opentensor/btcli/pull/794 +* updated proxy help text by @chideraao in https://github.com/opentensor/btcli/pull/788 +* Update DurationOfStartCall -> InitialStartCallDelay by @ibraheem-abe in https://github.com/opentensor/btcli/pull/797 +* Feat: Add protection warnings by @ibraheem-abe in https://github.com/opentensor/btcli/pull/799 +* feat: Add crowdloan contributors command and enhance create/view functionality by @circlecrystalin & @ibraheem-abe in https://github.com/opentensor/btcli/pull/776 + +## New Contributors +* @circlecrystalin made their first contribution in https://github.com/opentensor/btcli/pull/776 + +**Full Changelog**: https://github.com/opentensor/btcli/compare/v9.17.0...v9.18.0 + ## 9.17.0 /2025-12-22 ## What's Changed From f779221fbdec6746f7f5fa2520f98007feb91385 Mon Sep 17 00:00:00 2001 From: ibraheem-latent Date: Thu, 15 Jan 2026 14:06:27 -0800 Subject: [PATCH 33/33] bumps version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d24cd16aa..3ec9af78e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "flit_core.buildapi" [project] name = "bittensor-cli" -version = "9.17.0" +version = "9.18.0" description = "Bittensor CLI" readme = "README.md" authors = [