diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 6ff53d7cf..143189d17 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -2,6 +2,7 @@ import asyncio import curses import importlib +import json import os.path import re import ssl @@ -14,11 +15,16 @@ import rich import typer import numpy as np +from async_substrate_interface.errors import SubstrateRequestException from bittensor_wallet import Wallet from rich import box from rich.prompt import Confirm, FloatPrompt, Prompt, IntPrompt from rich.table import Column, Table from rich.tree import Tree +from typing_extensions import Annotated +from websockets import ConnectionClosed, InvalidHandshake +from yaml import safe_dump, safe_load + from bittensor_cli.src import ( defaults, HELP_PANELS, @@ -31,7 +37,6 @@ from bittensor_cli.version import __version__, __version_as_int__ from bittensor_cli.src.bittensor import utils from bittensor_cli.src.bittensor.balances import Balance -from async_substrate_interface.errors import SubstrateRequestException from bittensor_cli.src.commands import sudo, wallets, view from bittensor_cli.src.commands import weights as weights_cmds from bittensor_cli.src.commands.subnets import price, subnets @@ -48,6 +53,7 @@ console, err_console, verbose_console, + json_console, is_valid_ss58_address, print_error, validate_chain_endpoint, @@ -61,9 +67,6 @@ is_linux, validate_rate_tolerance, ) -from typing_extensions import Annotated -from websockets import ConnectionClosed, InvalidHandshake -from yaml import safe_dump, safe_load try: from git import Repo, GitError @@ -279,6 +282,12 @@ class Options: "--dashboard.path", help="Path to save the dashboard HTML file. For example: `~/.bittensor/dashboard`.", ) + json_output = typer.Option( + False, + "--json-output", + "--json-out", + help="Outputs the result of the command as JSON.", + ) era: int = typer.Option( 3, help="Length (in blocks) for which the transaction should be valid." ) @@ -327,22 +336,31 @@ def verbosity_console_handler(verbosity_level: int = 1) -> None: :param verbosity_level: int corresponding to verbosity level of console output (0 is quiet, 1 is normal, 2 is verbose) """ - if verbosity_level not in range(3): + if verbosity_level not in range(4): raise ValueError( - f"Invalid verbosity level: {verbosity_level}. Must be one of: 0 (quiet), 1 (normal), 2 (verbose)" + f"Invalid verbosity level: {verbosity_level}. " + f"Must be one of: 0 (quiet + json output), 1 (normal), 2 (verbose), 3 (json output + verbose)" ) if verbosity_level == 0: console.quiet = True err_console.quiet = True verbose_console.quiet = True + json_console.quiet = False elif verbosity_level == 1: console.quiet = False err_console.quiet = False verbose_console.quiet = True + json_console.quiet = True elif verbosity_level == 2: console.quiet = False err_console.quiet = False verbose_console.quiet = False + json_console.quiet = True + elif verbosity_level == 3: + console.quiet = True + err_console.quiet = True + verbose_console.quiet = False + json_console.quiet = False def get_optional_netuid(netuid: Optional[int], all_netuids: bool) -> Optional[int]: @@ -957,6 +975,7 @@ def initialize_chain( """ if not self.subtensor: if network: + network_ = None for item in network: if item.startswith("ws"): network_ = item @@ -1091,12 +1110,15 @@ def main_callback( except ModuleNotFoundError: self.asyncio_runner = asyncio.run - def verbosity_handler(self, quiet: bool, verbose: bool): + def verbosity_handler( + self, quiet: bool, verbose: bool, json_output: bool = False + ) -> None: if quiet and verbose: err_console.print("Cannot specify both `--quiet` and `--verbose`") raise typer.Exit() - - if quiet: + if json_output and verbose: + verbosity_console_handler(3) + elif json_output or quiet: verbosity_console_handler(0) elif verbose: verbosity_console_handler(2) @@ -1226,7 +1248,8 @@ def set_config( elif arg == "rate_tolerance": while True: val = FloatPrompt.ask( - f"What percentage would you like to set for [red]{arg}[/red]?\nValues are percentages (e.g. 0.05 for 5%)", + f"What percentage would you like to set for [red]{arg}[/red]?\n" + f"Values are percentages (e.g. 0.05 for 5%)", default=0.05, ) try: @@ -1526,7 +1549,7 @@ def wallet_ask( wallet_name: Optional[str], wallet_path: Optional[str], wallet_hotkey: Optional[str], - ask_for: list[str] = [], + ask_for: Optional[list[str]] = None, validate: WV = WV.WALLET, ) -> Wallet: """ @@ -1535,9 +1558,10 @@ def wallet_ask( :param wallet_path: root path of the wallets :param wallet_hotkey: name of the wallet hotkey file :param validate: flag whether to check for the wallet's validity - :param ask_type: aspect of the wallet (name, path, hotkey) to prompt the user for + :param ask_for: aspect of the wallet (name, path, hotkey) to prompt the user for :return: created Wallet object """ + ask_for = ask_for or [] # Prompt for missing attributes specified in ask_for if WO.NAME in ask_for and not wallet_name: if self.config.get("wallet_name"): @@ -1610,6 +1634,7 @@ def wallet_list( wallet_path: str = Options.wallet_path, quiet: bool = Options.quiet, verbose: bool = Options.verbose, + json_output: bool = Options.json_output, ): """ Displays all the wallets and their corresponding hotkeys that are located in the wallet path specified in the config. @@ -1625,11 +1650,11 @@ def wallet_list( [bold]NOTE[/bold]: This command is read-only and does not modify the filesystem or the blockchain state. It is intended for use with the Bittensor CLI to provide a quick overview of the user's wallets. """ - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, json_output) wallet = self.wallet_ask( None, wallet_path, None, ask_for=[WO.PATH], validate=WV.NONE ) - return self._run_command(wallets.wallet_list(wallet.path)) + return self._run_command(wallets.wallet_list(wallet.path, json_output)) def wallet_overview( self, @@ -1669,6 +1694,7 @@ def wallet_overview( network: Optional[list[str]] = Options.network, quiet: bool = Options.quiet, verbose: bool = Options.verbose, + json_output: bool = Options.json_output, ): """ Displays a detailed overview of the user's registered accounts on the Bittensor network. @@ -1685,7 +1711,7 @@ def wallet_overview( It provides a quick and comprehensive view of the user's network presence, making it useful for monitoring account status, stake distribution, and overall contribution to the Bittensor network. """ - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, json_output) if include_hotkeys and exclude_hotkeys: utils.err_console.print( "[red]You have specified both the inclusion and exclusion options. Only one of these options is allowed currently." @@ -1730,6 +1756,7 @@ def wallet_overview( exclude_hotkeys, netuids_filter=netuids, verbose=verbose, + json_output=json_output, ) ) @@ -1760,6 +1787,7 @@ def wallet_transfer( prompt: bool = Options.prompt, quiet: bool = Options.quiet, verbose: bool = Options.verbose, + json_output: bool = Options.json_output, ): """ Send TAO tokens from one wallet to another wallet on the Bittensor network. @@ -1783,7 +1811,7 @@ def wallet_transfer( print_error("You have entered an incorrect ss58 address. Please try again.") raise typer.Exit() - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, json_output) wallet = self.wallet_ask( wallet_name, wallet_path, @@ -1808,6 +1836,7 @@ def wallet_transfer( transfer_all=transfer_all, era=era, prompt=prompt, + json_output=json_output, ) ) @@ -1823,6 +1852,7 @@ def wallet_swap_hotkey( quiet: bool = Options.quiet, verbose: bool = Options.verbose, prompt: bool = Options.prompt, + json_output: bool = Options.json_output, ): """ Swap hotkeys of a given wallet on the blockchain. For a registered key pair, for example, a (coldkeyA, hotkeyA) pair, this command swaps the hotkeyA with a new, unregistered, hotkeyB to move the original registration to the (coldkeyA, hotkeyB) pair. @@ -1841,7 +1871,7 @@ def wallet_swap_hotkey( [green]$[/green] btcli wallet swap_hotkey destination_hotkey_name --wallet-name your_wallet_name --wallet-hotkey original_hotkey """ - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, json_output) original_wallet = self.wallet_ask( wallet_name, wallet_path, @@ -1863,7 +1893,9 @@ def wallet_swap_hotkey( ) self.initialize_chain(network) return self._run_command( - wallets.swap_hotkey(original_wallet, new_wallet, self.subtensor, prompt) + wallets.swap_hotkey( + original_wallet, new_wallet, self.subtensor, prompt, json_output + ) ) def wallet_inspect( @@ -1882,6 +1914,7 @@ def wallet_inspect( netuids: str = Options.netuids, quiet: bool = Options.quiet, verbose: bool = Options.verbose, + json_output: bool = Options.json_output, ): """ Displays the details of the user's wallet pairs (coldkey, hotkey) on the Bittensor network. @@ -1916,7 +1949,7 @@ def wallet_inspect( """ print_error("This command is disabled on the 'rao' network.") raise typer.Exit() - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, json_output) if netuids: netuids = parse_to_list( @@ -2013,6 +2046,7 @@ def wallet_faucet( [bold]Note[/bold]: This command is meant for used in local environments where users can experiment with the blockchain without using real TAO tokens. Users must have the necessary hardware setup, especially when opting for CUDA-based GPU calculations. It is currently disabled on testnet and mainnet (finney). You can only use this command on a local blockchain. """ + # TODO should we add json_output? wallet = self.wallet_ask( wallet_name, wallet_path, @@ -2049,6 +2083,7 @@ def wallet_regen_coldkey( overwrite: bool = Options.overwrite, quiet: bool = Options.quiet, verbose: bool = Options.verbose, + json_output: bool = Options.json_output, ): """ Regenerate a coldkey for a wallet on the Bittensor blockchain network. @@ -2066,7 +2101,7 @@ def wallet_regen_coldkey( [bold]Note[/bold]: This command is critical for users who need to regenerate their coldkey either for recovery or for security reasons. """ - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, json_output) if not wallet_path: wallet_path = Prompt.ask( @@ -2094,6 +2129,7 @@ def wallet_regen_coldkey( json_password, use_password, overwrite, + json_output, ) ) @@ -2107,6 +2143,7 @@ def wallet_regen_coldkey_pub( overwrite: bool = Options.overwrite, quiet: bool = Options.quiet, verbose: bool = Options.verbose, + json_output: bool = Options.json_output, ): """ Regenerates the public part of a coldkey (coldkeypub.txt) for a wallet. @@ -2123,7 +2160,7 @@ def wallet_regen_coldkey_pub( [bold]Note[/bold]: This command is particularly useful for users who need to regenerate their coldkeypub, perhaps due to file corruption or loss. You will need either ss58 address or public hex key from your old coldkeypub.txt for the wallet. It is a recovery-focused utility that ensures continued access to your wallet functionalities. """ - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, json_output) if not wallet_path: wallet_path = Prompt.ask( @@ -2152,7 +2189,9 @@ def wallet_regen_coldkey_pub( rich.print("[red]Error: Invalid SS58 address or public key![/red]") raise typer.Exit() return self._run_command( - wallets.regen_coldkey_pub(wallet, ss58_address, public_key_hex, overwrite) + wallets.regen_coldkey_pub( + wallet, ss58_address, public_key_hex, overwrite, json_output + ) ) def wallet_regen_hotkey( @@ -2171,6 +2210,7 @@ def wallet_regen_hotkey( overwrite: bool = Options.overwrite, quiet: bool = Options.quiet, verbose: bool = Options.verbose, + json_output: bool = Options.json_output, ): """ Regenerates a hotkey for a wallet. @@ -2189,7 +2229,7 @@ def wallet_regen_hotkey( [bold]Note[/bold]: This command is essential for users who need to regenerate their hotkey, possibly for security upgrades or key recovery. It should be used with caution to avoid accidental overwriting of existing keys. """ - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, json_output) wallet = self.wallet_ask( wallet_name, wallet_path, @@ -2209,6 +2249,7 @@ def wallet_regen_hotkey( json_password, use_password, overwrite, + json_output, ) ) @@ -2231,6 +2272,7 @@ def wallet_new_hotkey( overwrite: bool = Options.overwrite, quiet: bool = Options.quiet, verbose: bool = Options.verbose, + json_output: bool = Options.json_output, ): """ Create a new hotkey for a wallet. @@ -2246,7 +2288,7 @@ def wallet_new_hotkey( [italic]Note[/italic]: This command is useful to create additional hotkeys for different purposes, such as running multiple subnet miners or subnet validators or separating operational roles within the Bittensor network. """ - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, json_output) if not wallet_name: wallet_name = Prompt.ask( @@ -2270,7 +2312,9 @@ def wallet_new_hotkey( if not uri: n_words = get_n_words(n_words) return self._run_command( - wallets.new_hotkey(wallet, n_words, use_password, uri, overwrite) + wallets.new_hotkey( + wallet, n_words, use_password, uri, overwrite, json_output + ) ) def wallet_associate_hotkey( @@ -2357,6 +2401,7 @@ def wallet_new_coldkey( overwrite: bool = Options.overwrite, quiet: bool = Options.quiet, verbose: bool = Options.verbose, + json_output: bool = Options.json_output, ): """ Create a new coldkey. A coldkey is required for holding TAO balances and performing high-value transactions. @@ -2371,7 +2416,7 @@ def wallet_new_coldkey( [bold]Note[/bold]: This command is crucial for users who need to create a new coldkey for enhanced security or as part of setting up a new wallet. It is a foundational step in establishing a secure presence on the Bittensor network. """ - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, json_output) if not wallet_path: wallet_path = Prompt.ask( @@ -2394,7 +2439,9 @@ def wallet_new_coldkey( if not uri: n_words = get_n_words(n_words) return self._run_command( - wallets.new_coldkey(wallet, n_words, use_password, uri, overwrite) + wallets.new_coldkey( + wallet, n_words, use_password, uri, overwrite, json_output + ) ) def wallet_check_ck_swap( @@ -2441,6 +2488,7 @@ def wallet_check_ck_swap( Check swap details with block number: [green]$[/green] btcli wallet swap-check --wallet-name my_wallet --block 12345 """ + # TODO add json_output if this ever gets used again (doubtful) self.verbosity_handler(quiet, verbose) self.initialize_chain(network) @@ -2497,6 +2545,7 @@ def wallet_create_wallet( overwrite: bool = Options.overwrite, quiet: bool = Options.quiet, verbose: bool = Options.verbose, + json_output: bool = Options.json_output, ): """ Create a complete wallet by setting up both coldkey and hotkeys. @@ -2511,6 +2560,7 @@ def wallet_create_wallet( [bold]Note[/bold]: This command is for new users setting up their wallet for the first time, or for those who wish to completely renew their wallet keys. It ensures a fresh start with new keys for secure and effective participation in the Bittensor network. """ + self.verbosity_handler(quiet, verbose, json_output) if not wallet_path: wallet_path = Prompt.ask( "Enter the path of wallets directory", default=defaults.wallet.path @@ -2527,7 +2577,6 @@ def wallet_create_wallet( default=defaults.wallet.hotkey, ) - self.verbosity_handler(quiet, verbose) wallet = self.wallet_ask( wallet_name, wallet_path, @@ -2538,7 +2587,9 @@ def wallet_create_wallet( if not uri: n_words = get_n_words(n_words) return self._run_command( - wallets.wallet_create(wallet, n_words, use_password, uri, overwrite) + wallets.wallet_create( + wallet, n_words, use_password, uri, overwrite, json_output + ) ) def wallet_balance( @@ -2556,6 +2607,7 @@ def wallet_balance( network: Optional[list[str]] = Options.network, quiet: bool = Options.quiet, verbose: bool = Options.verbose, + json_output: bool = Options.json_output, ): """ Check the balance of the wallet. This command shows a detailed view of the wallet's coldkey balances, including free and staked balances. @@ -2581,7 +2633,7 @@ def wallet_balance( [green]$[/green] btcli w balance --ss58 --ss58 """ - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, json_output) wallet = None if all_balances: ask_for = [WO.PATH] @@ -2644,7 +2696,9 @@ def wallet_balance( ) subtensor = self.initialize_chain(network) return self._run_command( - wallets.wallet_balance(wallet, subtensor, all_balances, ss58_addresses) + wallets.wallet_balance( + wallet, subtensor, all_balances, ss58_addresses, json_output + ) ) def wallet_history( @@ -2668,6 +2722,7 @@ def wallet_history( """ # TODO: Fetch effective network and redirect users accordingly - this only works on finney + # TODO: Add json_output if this gets re-enabled # no_use_config_str = "Using the network [dark_orange]finney[/dark_orange] and ignoring network/chain configs" # if self.config.get("network"): @@ -2734,6 +2789,7 @@ def wallet_set_id( quiet: bool = Options.quiet, verbose: bool = Options.verbose, prompt: bool = Options.prompt, + json_output: bool = Options.json_output, ): """ Create or update the on-chain identity of a coldkey or a hotkey on the Bittensor network. [bold]Incurs a 1 TAO transaction fee.[/bold] @@ -2752,7 +2808,7 @@ def wallet_set_id( [bold]Note[/bold]: This command should only be used if the user is willing to incur the a recycle fee associated with setting an identity on the blockchain. It is a high-level command that makes changes to the blockchain state and should not be used programmatically as part of other scripts or applications. """ - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, json_output) wallet = self.wallet_ask( wallet_name, wallet_path, @@ -2801,6 +2857,7 @@ def wallet_set_id( identity["additional"], identity["github_repo"], prompt, + json_output, ) ) @@ -2822,6 +2879,7 @@ def wallet_get_id( network: Optional[list[str]] = Options.network, quiet: bool = Options.quiet, verbose: bool = Options.verbose, + json_output: bool = Options.json_output, ): """ Shows the identity details of a user's coldkey or hotkey. @@ -2840,7 +2898,7 @@ def wallet_get_id( [bold]Note[/bold]: This command is primarily used for informational purposes and has no side effects on the blockchain network state. """ - wallet = None + self.verbosity_handler(quiet, verbose, json_output) if not wallet_name: if coldkey_ss58: if not is_valid_ss58_address(coldkey_ss58): @@ -2865,9 +2923,8 @@ def wallet_get_id( ) coldkey_ss58 = wallet.coldkeypub.ss58_address - self.verbosity_handler(quiet, verbose) return self._run_command( - wallets.get_id(self.initialize_chain(network), coldkey_ss58) + wallets.get_id(self.initialize_chain(network), coldkey_ss58, json_output) ) def wallet_sign( @@ -2883,6 +2940,7 @@ def wallet_sign( message: str = typer.Option("", help="The message to encode and sign"), quiet: bool = Options.quiet, verbose: bool = Options.verbose, + json_output: bool = Options.json_output, ): """ Allows users to sign a message with the provided wallet or wallet hotkey. Use this command to easily prove your ownership of a coldkey or a hotkey. @@ -2898,7 +2956,7 @@ def wallet_sign( [green]$[/green] btcli wallet sign --wallet-name default --wallet-hotkey hotkey --message '{"something": "here", "timestamp": 1719908486}' """ - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, json_output) if use_hotkey is None: use_hotkey = Confirm.ask( f"Would you like to sign the transaction using your [{COLORS.G.HK}]hotkey[/{COLORS.G.HK}]?" @@ -2917,7 +2975,7 @@ def wallet_sign( if not message: message = Prompt.ask("Enter the [blue]message[/blue] to encode and sign") - return self._run_command(wallets.sign(wallet, message, use_hotkey)) + return self._run_command(wallets.sign(wallet, message, use_hotkey, json_output)) def wallet_swap_coldkey( self, @@ -3022,6 +3080,7 @@ def stake_list( quiet: bool = Options.quiet, verbose: bool = Options.verbose, no_prompt: bool = Options.prompt, + json_output: bool = Options.json_output, # TODO add: all-wallets, reuse_last, html_output ): """ @@ -3043,7 +3102,7 @@ def stake_list( 4. Verbose output with full values: [green]$[/green] btcli stake list --wallet.name my_wallet --verbose """ - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, json_output) wallet = None if coldkey_ss58: @@ -3074,6 +3133,7 @@ def stake_list( live, verbose, no_prompt, + json_output, ) ) @@ -3121,6 +3181,7 @@ def stake_add( prompt: bool = Options.prompt, quiet: bool = Options.quiet, verbose: bool = Options.verbose, + json_output: bool = Options.json_output, ): """ Stake TAO to one or more hotkeys on specific netuids with your coldkey. @@ -3153,7 +3214,7 @@ def stake_add( • [blue]--partial[/blue]: Complete partial stake if rates exceed tolerance """ - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, json_output) safe_staking = self.ask_safe_staking(safe_staking) if safe_staking: rate_tolerance = self.ask_rate_tolerance(rate_tolerance) @@ -3311,6 +3372,7 @@ def stake_add( safe_staking, rate_tolerance, allow_partial_stake, + json_output, era, ) ) @@ -3373,6 +3435,7 @@ def stake_remove( ), quiet: bool = Options.quiet, verbose: bool = Options.verbose, + json_output: bool = Options.json_output, ): """ Unstake TAO from one or more hotkeys and transfer them back to the user's coldkey wallet. @@ -3404,7 +3467,7 @@ def stake_remove( • [blue]--tolerance[/blue]: Max allowed rate change (0.05 = 5%) • [blue]--partial[/blue]: Complete partial unstake if rates exceed tolerance """ - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, json_output) if not unstake_all and not unstake_all_alpha: safe_staking = self.ask_safe_staking(safe_staking) if safe_staking: @@ -3416,7 +3479,8 @@ def stake_remove( [hotkey_ss58_address, include_hotkeys, exclude_hotkeys, all_hotkeys] ): print_error( - "Interactive mode cannot be used with hotkey selection options like --include-hotkeys, --exclude-hotkeys, --all-hotkeys, or --hotkey." + "Interactive mode cannot be used with hotkey selection options like " + "--include-hotkeys, --exclude-hotkeys, --all-hotkeys, or --hotkey." ) raise typer.Exit() @@ -3553,6 +3617,7 @@ def stake_remove( include_hotkeys=include_hotkeys, exclude_hotkeys=exclude_hotkeys, prompt=prompt, + json_output=json_output, era=era, ) ) @@ -3608,6 +3673,7 @@ def stake_remove( safe_staking=safe_staking, rate_tolerance=rate_tolerance, allow_partial_stake=allow_partial_stake, + json_output=json_output, era=era, ) ) @@ -3640,6 +3706,7 @@ def stake_move( prompt: bool = Options.prompt, quiet: bool = Options.quiet, verbose: bool = Options.verbose, + json_output: bool = Options.json_output, ): """ Move staked TAO between hotkeys while keeping the same coldkey ownership. @@ -3661,13 +3728,14 @@ def stake_move( [green]$[/green] btcli stake move """ - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, json_output) console.print( "[dim]This command moves stake from one hotkey to another hotkey while keeping the same coldkey.[/dim]" ) if not destination_hotkey: dest_wallet_or_ss58 = Prompt.ask( - "Enter the [blue]destination wallet[/blue] where destination hotkey is located or [blue]ss58 address[/blue]" + "Enter the [blue]destination wallet[/blue] where destination hotkey is located or " + "[blue]ss58 address[/blue]" ) if is_valid_ss58_address(dest_wallet_or_ss58): destination_hotkey = dest_wallet_or_ss58 @@ -3754,7 +3822,7 @@ def stake_move( "Enter the [blue]destination subnet[/blue] (netuid) to move stake to" ) - return self._run_command( + result = self._run_command( move_stake.move_stake( subtensor=self.initialize_chain(network), wallet=wallet, @@ -3769,6 +3837,9 @@ def stake_move( prompt=prompt, ) ) + if json_output: + json_console.print(json.dumps({"success": result})) + return result def stake_transfer( self, @@ -3806,6 +3877,7 @@ def stake_transfer( prompt: bool = Options.prompt, quiet: bool = Options.quiet, verbose: bool = Options.verbose, + json_output: bool = Options.json_output, ): """ Transfer stake between coldkeys while keeping the same hotkey ownership. @@ -3839,10 +3911,10 @@ def stake_transfer( Transfer all available stake from origin hotkey: [green]$[/green] btcli stake transfer --all --origin-netuid 1 --dest-netuid 2 """ + self.verbosity_handler(quiet, verbose, json_output) console.print( "[dim]This command transfers stake from one coldkey to another while keeping the same hotkey.[/dim]" ) - self.verbosity_handler(quiet, verbose) if not dest_ss58: dest_ss58 = Prompt.ask( @@ -3914,7 +3986,7 @@ def stake_transfer( "Enter the [blue]destination subnet[/blue] (netuid)" ) - return self._run_command( + result = self._run_command( move_stake.transfer_stake( wallet=wallet, subtensor=self.initialize_chain(network), @@ -3929,6 +4001,9 @@ def stake_transfer( prompt=prompt, ) ) + if json_output: + json_console.print(json.dumps({"success": result})) + return result def stake_swap( self, @@ -3968,6 +4043,7 @@ def stake_swap( wait_for_finalization: bool = Options.wait_for_finalization, quiet: bool = Options.quiet, verbose: bool = Options.verbose, + json_output: bool = Options.json_output, ): """ Swap stake between different subnets while keeping the same coldkey-hotkey pair ownership. @@ -3989,10 +4065,10 @@ def stake_swap( Swap 100 TAO from subnet 1 to subnet 2: [green]$[/green] btcli stake swap --wallet-name default --wallet-hotkey default --origin-netuid 1 --dest-netuid 2 --amount 100 """ + self.verbosity_handler(quiet, verbose, json_output) console.print( "[dim]This command moves stake from one subnet to another subnet while keeping the same coldkey-hotkey pair.[/dim]" ) - self.verbosity_handler(quiet, verbose) wallet = self.wallet_ask( wallet_name, @@ -4017,7 +4093,7 @@ def stake_swap( if not amount and not swap_all: amount = FloatPrompt.ask("Enter the [blue]amount[/blue] to swap") - return self._run_command( + result = self._run_command( move_stake.swap_stake( wallet=wallet, subtensor=self.initialize_chain(network), @@ -4032,6 +4108,9 @@ def stake_swap( wait_for_finalization=wait_for_finalization, ) ) + if json_output: + json_console.print(json.dumps({"success": result})) + return result def stake_get_children( self, @@ -4053,6 +4132,7 @@ def stake_get_children( ), quiet: bool = Options.quiet, verbose: bool = Options.verbose, + json_output: bool = Options.json_output, ): """ Get all the child hotkeys on a specified subnet. @@ -4064,7 +4144,7 @@ def stake_get_children( [green]$[/green] btcli stake child get --netuid 1 [green]$[/green] btcli stake child get --all-netuids """ - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, json_output) wallet = self.wallet_ask( wallet_name, wallet_path, @@ -4085,11 +4165,14 @@ def stake_get_children( "Enter a netuid (leave blank for all)", default=None, show_default=True ) - return self._run_command( + result = self._run_command( children_hotkeys.get_children( wallet, self.initialize_chain(network), netuid ) ) + if json_output: + json_console.print(json.dumps(result)) + return result def stake_set_children( self, @@ -4114,6 +4197,7 @@ def stake_set_children( quiet: bool = Options.quiet, verbose: bool = Options.verbose, prompt: bool = Options.prompt, + json_output: bool = Options.json_output, ): """ Set child hotkeys on a specified subnet (or all). Overrides currently set children. @@ -4126,7 +4210,7 @@ def stake_set_children( [green]$[/green] btcli stake child set -c 5FCL3gmjtQV4xxxxuEPEFQVhyyyyqYgNwX7drFLw7MSdBnxP -c 5Hp5dxxxxtGg7pu8dN2btyyyyVA1vELmM9dy8KQv3LxV8PA7 --hotkey default --netuid 1 -p 0.3 -p 0.7 """ - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, json_output) netuid = get_optional_netuid(netuid, all_netuids) children = list_prompt( @@ -4166,6 +4250,7 @@ def stake_set_children( wait_for_finalization=wait_for_finalization, wait_for_inclusion=wait_for_inclusion, prompt=prompt, + json_output=json_output, ) ) @@ -4192,6 +4277,7 @@ def stake_revoke_children( quiet: bool = Options.quiet, verbose: bool = Options.verbose, prompt: bool = Options.prompt, + json_output: bool = Options.json_output, ): """ Remove all children hotkeys on a specified subnet (or all). @@ -4202,7 +4288,7 @@ def stake_revoke_children( [green]$[/green] btcli stake child revoke --hotkey --netuid 1 """ - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, json_output) wallet = self.wallet_ask( wallet_name, wallet_path, @@ -4227,6 +4313,7 @@ def stake_revoke_children( wait_for_inclusion, wait_for_finalization, prompt=prompt, + json_output=json_output, ) ) @@ -4261,6 +4348,7 @@ def stake_childkey_take( prompt: bool = Options.prompt, quiet: bool = Options.quiet, verbose: bool = Options.verbose, + json_output: bool = Options.json_output, ): """ Get and set your child hotkey take on a specified subnet. @@ -4277,7 +4365,7 @@ def stake_childkey_take( [green]$[/green] btcli stake child take --hotkey --take 0.12 --netuid 1 """ - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, json_output) wallet = self.wallet_ask( wallet_name, wallet_path, @@ -4294,7 +4382,7 @@ def stake_childkey_take( netuid = IntPrompt.ask( "Enter netuid (leave blank for all)", default=None, show_default=True ) - return self._run_command( + results: list[tuple[Optional[int], bool]] = self._run_command( children_hotkeys.childkey_take( wallet=wallet, subtensor=self.initialize_chain(network), @@ -4306,6 +4394,12 @@ def stake_childkey_take( prompt=prompt, ) ) + if json_output: + output = {} + for netuid_, success in results: + output[netuid_] = success + json_console.print(json.dumps(output)) + return results def sudo_set( self, @@ -4322,6 +4416,7 @@ def sudo_set( ), quiet: bool = Options.quiet, verbose: bool = Options.verbose, + json_output: bool = Options.json_output, ): """ Used to set hyperparameters for a specific subnet. @@ -4332,7 +4427,7 @@ def sudo_set( [green]$[/green] btcli sudo set --netuid 1 --param tempo --value 400 """ - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, json_output) if not param_name or not param_value: hyperparams = self._run_command( @@ -4377,15 +4472,19 @@ def sudo_set( wallet = self.wallet_ask( wallet_name, wallet_path, wallet_hotkey, ask_for=[WO.NAME, WO.PATH] ) - return self._run_command( + result = self._run_command( sudo.sudo_set_hyperparameter( wallet, self.initialize_chain(network), netuid, param_name, param_value, + json_output, ) ) + if json_output: + json_console.print(json.dumps({"success": result})) + return result def sudo_get( self, @@ -4393,6 +4492,7 @@ def sudo_get( netuid: int = Options.netuid, quiet: bool = Options.quiet, verbose: bool = Options.verbose, + json_output: bool = Options.json_output, ): """ Shows a list of the hyperparameters for the specified subnet. @@ -4401,9 +4501,11 @@ def sudo_get( [green]$[/green] btcli sudo get --netuid 1 """ - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, json_output) return self._run_command( - sudo.get_hyperparameters(self.initialize_chain(network), netuid) + sudo.get_hyperparameters( + self.initialize_chain(network), netuid, json_output + ) ) def sudo_senate( @@ -4411,6 +4513,7 @@ def sudo_senate( network: Optional[list[str]] = Options.network, quiet: bool = Options.quiet, verbose: bool = Options.verbose, + json_output: bool = Options.json_output, ): """ Shows the Senate members of the Bittensor's governance protocol. @@ -4420,14 +4523,17 @@ def sudo_senate( EXAMPLE [green]$[/green] btcli sudo senate """ - self.verbosity_handler(quiet, verbose) - return self._run_command(sudo.get_senate(self.initialize_chain(network))) + self.verbosity_handler(quiet, verbose, json_output) + return self._run_command( + sudo.get_senate(self.initialize_chain(network), json_output) + ) def sudo_proposals( self, network: Optional[list[str]] = Options.network, quiet: bool = Options.quiet, verbose: bool = Options.verbose, + json_output: bool = Options.json_output, ): """ View active proposals for the senate in the Bittensor's governance protocol. @@ -4437,9 +4543,9 @@ def sudo_proposals( EXAMPLE [green]$[/green] btcli sudo proposals """ - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, json_output) return self._run_command( - sudo.proposals(self.initialize_chain(network), verbose) + sudo.proposals(self.initialize_chain(network), verbose, json_output) ) def sudo_senate_vote( @@ -4476,6 +4582,7 @@ def sudo_senate_vote( EXAMPLE [green]$[/green] btcli sudo senate_vote --proposal """ + # TODO discuss whether this should receive json_output. I don't think it should. self.verbosity_handler(quiet, verbose) wallet = self.wallet_ask( wallet_name, @@ -4499,6 +4606,7 @@ def sudo_set_take( take: float = typer.Option(None, help="The new take value."), quiet: bool = Options.quiet, verbose: bool = Options.verbose, + json_output: bool = Options.json_output, ): """ Allows users to change their delegate take percentage. @@ -4511,7 +4619,7 @@ def sudo_set_take( """ max_value = 0.18 min_value = 0.00 - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, json_output) wallet = self.wallet_ask( wallet_name, @@ -4536,9 +4644,12 @@ def sudo_set_take( ) raise typer.Exit() - return self._run_command( + result = self._run_command( sudo.set_take(wallet, self.initialize_chain(network), take) ) + if json_output: + json_console.print(json.dumps({"success": result})) + return result def sudo_get_take( self, @@ -4548,6 +4659,7 @@ def sudo_get_take( wallet_hotkey: Optional[str] = Options.wallet_hotkey, quiet: bool = Options.quiet, verbose: bool = Options.verbose, + json_output: bool = Options.json_output, ): """ Allows users to check their delegate take percentage. @@ -4557,7 +4669,7 @@ def sudo_get_take( EXAMPLE [green]$[/green] btcli sudo get-take --wallet-name my_wallet --wallet-hotkey my_hotkey """ - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, json_output) wallet = self.wallet_ask( wallet_name, @@ -4566,10 +4678,15 @@ def sudo_get_take( ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], validate=WV.WALLET_AND_HOTKEY, ) - - self._run_command( - sudo.display_current_take(self.initialize_chain(network), wallet) - ) + if json_output: + result = self._run_command( + sudo.get_current_take(self.initialize_chain(network), wallet) + ) + json_console.print(json.dumps({"current_take": result})) + else: + self._run_command( + sudo.display_current_take(self.initialize_chain(network), wallet) + ) def subnets_list( self, @@ -4577,6 +4694,7 @@ def subnets_list( quiet: bool = Options.quiet, verbose: bool = Options.verbose, live_mode: bool = Options.live, + json_output: bool = Options.json_output, ): """ List all subnets and their detailed information. @@ -4604,7 +4722,10 @@ def subnets_list( [green]$[/green] btcli subnets list """ - self.verbosity_handler(quiet, verbose) + if json_output and live_mode: + print_error("Cannot use `--json-output` and `--live` at the same time.") + return + self.verbosity_handler(quiet, verbose, json_output) subtensor = self.initialize_chain(network) return self._run_command( subnets.subnets_list( @@ -4614,6 +4735,7 @@ def subnets_list( not self.config.get("use_cache", True), verbose, live_mode, + json_output, ) ) @@ -4646,6 +4768,9 @@ def subnets_price( help="Show the price in log scale.", ), html_output: bool = Options.html_output, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, ): """ Shows the historical price of a subnet for the past 24 hours. @@ -4663,6 +4788,10 @@ def subnets_price( [green]$[/green] btcli subnets price --all --html [green]$[/green] btcli subnets price --netuids 1,2,3,4 --html """ + if json_output and html_output: + print_error("Cannot specify both `--json-output` and `--html`") + return + self.verbosity_handler(quiet=quiet, verbose=verbose, json_output=json_output) if netuids: netuids = parse_to_list( netuids, @@ -4671,15 +4800,15 @@ def subnets_price( ) if all_netuids and netuids: print_error("Cannot specify both --netuid and --all-netuids") - raise typer.Exit() + return if not netuids and not all_netuids: netuids = Prompt.ask( - "Enter the [blue]netuid(s)[/blue] to view the price of in comma-separated format [dim](or Press Enter to view all subnets)[/dim]", + "Enter the [blue]netuid(s)[/blue] to view the price of in comma-separated format [dim]" + "(or Press Enter to view all subnets)[/dim]", ) if not netuids: all_netuids = True - html_output = True else: netuids = parse_to_list( netuids, @@ -4687,7 +4816,7 @@ def subnets_price( "Netuids must be a comma-separated list of ints, e.g., `--netuids 1,2,3,4`.", ) - if all_netuids: + if all_netuids and not json_output: html_output = True if html_output and is_linux(): @@ -4701,6 +4830,7 @@ def subnets_price( interval_hours, html_output, log_scale, + json_output, ) ) @@ -4716,6 +4846,7 @@ def subnets_show( quiet: bool = Options.quiet, verbose: bool = Options.verbose, prompt: bool = Options.prompt, + json_output: bool = Options.json_output, ): """ Displays detailed information about a subnet including participants and their state. @@ -4724,7 +4855,7 @@ def subnets_show( [green]$[/green] btcli subnets list """ - self.verbosity_handler(quiet, verbose) + self.verbosity_handler(quiet, verbose, json_output) subtensor = self.initialize_chain(network) return self._run_command( subnets.show( @@ -4735,6 +4866,7 @@ def subnets_show( delegate_selection=False, verbose=verbose, prompt=prompt, + json_output=json_output, ) ) diff --git a/bittensor_cli/src/bittensor/utils.py b/bittensor_cli/src/bittensor/utils.py index 318847c85..aedfe2ef3 100644 --- a/bittensor_cli/src/bittensor/utils.py +++ b/bittensor_cli/src/bittensor/utils.py @@ -33,6 +33,7 @@ from bittensor_cli.src.bittensor.chain_data import SubnetHyperparameters console = Console() +json_console = Console() err_console = Console(stderr=True) verbose_console = Console(quiet=True) diff --git a/bittensor_cli/src/commands/stake/add.py b/bittensor_cli/src/commands/stake/add.py index 79d6fd3cb..a4af67fff 100644 --- a/bittensor_cli/src/commands/stake/add.py +++ b/bittensor_cli/src/commands/stake/add.py @@ -1,4 +1,6 @@ import asyncio +import json +from collections import defaultdict from functools import partial from typing import TYPE_CHECKING, Optional @@ -17,6 +19,7 @@ print_error, print_verbose, unlock_key, + json_console, ) from bittensor_wallet import Wallet @@ -38,6 +41,7 @@ async def stake_add( safe_staking: bool, rate_tolerance: float, allow_partial_stake: bool, + json_output: bool, era: int, ): """ @@ -54,6 +58,7 @@ async def stake_add( safe_staking: whether to use safe staking rate_tolerance: rate tolerance percentage for stake operations allow_partial_stake: whether to allow partial stake + json_output: whether to output stake info in JSON format era: Blocks for which the transaction should be valid. Returns: @@ -67,25 +72,25 @@ async def safe_stake_extrinsic( hotkey_ss58_: str, price_limit: Balance, status=None, - ) -> None: + ) -> bool: err_out = partial(print_error, status=status) failure_prelude = ( f":cross_mark: [red]Failed[/red] to stake {amount_} on Netuid {netuid_}" ) - current_balance = await subtensor.get_balance(wallet.coldkeypub.ss58_address) - next_nonce = await subtensor.substrate.get_account_next_index( - wallet.coldkeypub.ss58_address - ) - call = await subtensor.substrate.compose_call( - call_module="SubtensorModule", - call_function="add_stake_limit", - call_params={ - "hotkey": hotkey_ss58_, - "netuid": netuid_, - "amount_staked": amount_.rao, - "limit_price": price_limit, - "allow_partial": allow_partial_stake, - }, + current_balance, next_nonce, call = await asyncio.gather( + subtensor.get_balance(wallet.coldkeypub.ss58_address), + subtensor.substrate.get_account_next_index(wallet.coldkeypub.ss58_address), + subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="add_stake_limit", + call_params={ + "hotkey": hotkey_ss58_, + "netuid": netuid_, + "amount_staked": amount_.rao, + "limit_price": price_limit, + "allow_partial": allow_partial_stake, + }, + ), ) extrinsic = await subtensor.substrate.create_signed_extrinsic( call=call, @@ -105,70 +110,78 @@ async def safe_stake_extrinsic( f"Either increase price tolerance or enable partial staking.", status=status, ) - return + return False else: err_out(f"\n{failure_prelude} with error: {format_error_message(e)}") - return + return False + if not await response.is_success: + err_out( + f"\n{failure_prelude} with error: {format_error_message(await response.error_message)}" + ) + return False else: - if not await response.is_success: - err_out( - f"\n{failure_prelude} with error: {format_error_message(await response.error_message)}" - ) - else: - block_hash = await subtensor.substrate.get_chain_head() - new_balance, new_stake = await asyncio.gather( - subtensor.get_balance(wallet.coldkeypub.ss58_address, block_hash), - subtensor.get_stake( - hotkey_ss58=hotkey_ss58_, - coldkey_ss58=wallet.coldkeypub.ss58_address, - netuid=netuid_, - block_hash=block_hash, - ), - ) - console.print( - f":white_heavy_check_mark: [dark_sea_green3]Finalized. Stake added to netuid: {netuid_}[/dark_sea_green3]" - ) - console.print( - f"Balance:\n [blue]{current_balance}[/blue] :arrow_right: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_balance}" - ) - - amount_staked = current_balance - new_balance - if allow_partial_stake and (amount_staked != amount_): - console.print( - "Partial stake transaction. Staked:\n" - f" [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{amount_staked}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}] " - f"instead of " - f"[blue]{amount_}[/blue]" - ) + if json_output: + # the rest of this checking is not necessary if using json_output + return True + block_hash = await subtensor.substrate.get_chain_head() + new_balance, new_stake = await asyncio.gather( + subtensor.get_balance(wallet.coldkeypub.ss58_address, block_hash), + subtensor.get_stake( + hotkey_ss58=hotkey_ss58_, + coldkey_ss58=wallet.coldkeypub.ss58_address, + netuid=netuid_, + block_hash=block_hash, + ), + ) + console.print( + f":white_heavy_check_mark: [dark_sea_green3]Finalized. " + f"Stake added to netuid: {netuid_}[/dark_sea_green3]" + ) + console.print( + f"Balance:\n [blue]{current_balance}[/blue] :arrow_right: " + f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_balance}" + ) + amount_staked = current_balance - new_balance + if allow_partial_stake and (amount_staked != amount_): console.print( - f"Subnet: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{netuid_}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}] " - f"Stake:\n" - f" [blue]{current_stake}[/blue] " - f":arrow_right: " - f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_stake}\n" + "Partial stake transaction. Staked:\n" + f" [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{amount_staked}" + f"[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}] " + f"instead of " + f"[blue]{amount_}[/blue]" ) + console.print( + f"Subnet: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]" + f"{netuid_}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}] " + f"Stake:\n" + f" [blue]{current_stake}[/blue] " + f":arrow_right: " + f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_stake}\n" + ) + return True + async def stake_extrinsic( netuid_i, amount_, current, staking_address_ss58, status=None - ): + ) -> bool: err_out = partial(print_error, status=status) - current_balance = await subtensor.get_balance(wallet.coldkeypub.ss58_address) + current_balance, next_nonce, call = await asyncio.gather( + subtensor.get_balance(wallet.coldkeypub.ss58_address), + subtensor.substrate.get_account_next_index(wallet.coldkeypub.ss58_address), + subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="add_stake", + call_params={ + "hotkey": staking_address_ss58, + "netuid": netuid_i, + "amount_staked": amount_.rao, + }, + ), + ) failure_prelude = ( f":cross_mark: [red]Failed[/red] to stake {amount} on Netuid {netuid_i}" ) - next_nonce = await subtensor.substrate.get_account_next_index( - wallet.coldkeypub.ss58_address - ) - call = await subtensor.substrate.compose_call( - call_module="SubtensorModule", - call_function="add_stake", - call_params={ - "hotkey": staking_address_ss58, - "netuid": netuid_i, - "amount_staked": amount_.rao, - }, - ) extrinsic = await subtensor.substrate.create_signed_extrinsic( call=call, keypair=wallet.coldkey, nonce=next_nonce, era={"period": era} ) @@ -178,35 +191,46 @@ async def stake_extrinsic( ) except SubstrateRequestException as e: err_out(f"\n{failure_prelude} with error: {format_error_message(e)}") - return + return False else: - await response.process_events() if not await response.is_success: err_out( f"\n{failure_prelude} with error: {format_error_message(await response.error_message)}" ) + return False else: + if json_output: + # the rest of this is not necessary if using json_output + return True + new_block_hash = await subtensor.substrate.get_chain_head() new_balance, new_stake = await asyncio.gather( - subtensor.get_balance(wallet.coldkeypub.ss58_address), + subtensor.get_balance( + wallet.coldkeypub.ss58_address, block_hash=new_block_hash + ), subtensor.get_stake( hotkey_ss58=staking_address_ss58, coldkey_ss58=wallet.coldkeypub.ss58_address, netuid=netuid_i, + block_hash=new_block_hash, ), ) console.print( - f":white_heavy_check_mark: [dark_sea_green3]Finalized. Stake added to netuid: {netuid_i}[/dark_sea_green3]" + f":white_heavy_check_mark: " + f"[dark_sea_green3]Finalized. Stake added to netuid: {netuid_i}[/dark_sea_green3]" ) console.print( - f"Balance:\n [blue]{current_balance}[/blue] :arrow_right: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_balance}" + f"Balance:\n [blue]{current_balance}[/blue] :arrow_right: " + f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_balance}" ) console.print( - f"Subnet: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{netuid_i}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}] " + f"Subnet: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]" + f"{netuid_i}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}] " f"Stake:\n" f" [blue]{current}[/blue] " f":arrow_right: " f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_stake}\n" ) + return True netuids = ( [int(netuid)] @@ -337,7 +361,9 @@ async def stake_extrinsic( base_row.extend( [ f"{rate_with_tolerance} {Balance.get_unit(netuid)}/{Balance.get_unit(0)} ", - f"[{'dark_sea_green3' if allow_partial_stake else 'red'}]{allow_partial_stake}[/{'dark_sea_green3' if allow_partial_stake else 'red'}]", # safe staking + f"[{'dark_sea_green3' if allow_partial_stake else 'red'}]" + # safe staking + f"{allow_partial_stake}[/{'dark_sea_green3' if allow_partial_stake else 'red'}]", ] ) @@ -356,7 +382,7 @@ async def stake_extrinsic( return False if safe_staking: - stake_coroutines = [] + stake_coroutines = {} for i, (ni, am, curr, price_with_tolerance) in enumerate( zip( netuids, amounts_to_stake, current_stake_balances, prices_with_tolerance @@ -365,27 +391,23 @@ async def stake_extrinsic( for _, staking_address in hotkeys_to_stake_to: # Regular extrinsic for root subnet if ni == 0: - stake_coroutines.append( - stake_extrinsic( - netuid_i=ni, - amount_=am, - current=curr, - staking_address_ss58=staking_address, - ) + stake_coroutines[(ni, staking_address)] = stake_extrinsic( + netuid_i=ni, + amount_=am, + current=curr, + staking_address_ss58=staking_address, ) else: - stake_coroutines.append( - safe_stake_extrinsic( - netuid_=ni, - amount_=am, - current_stake=curr, - hotkey_ss58_=staking_address, - price_limit=price_with_tolerance, - ) + stake_coroutines[(ni, staking_address)] = safe_stake_extrinsic( + netuid_=ni, + amount_=am, + current_stake=curr, + hotkey_ss58_=staking_address, + price_limit=price_with_tolerance, ) else: - stake_coroutines = [ - stake_extrinsic( + stake_coroutines = { + (ni, staking_address): stake_extrinsic( netuid_i=ni, amount_=am, current=curr, @@ -395,12 +417,15 @@ async def stake_extrinsic( zip(netuids, amounts_to_stake, current_stake_balances) ) for _, staking_address in hotkeys_to_stake_to - ] - + } + successes = defaultdict(dict) with console.status(f"\n:satellite: Staking on netuid(s): {netuids} ..."): # We can gather them all at once but balance reporting will be in race-condition. - for coroutine in stake_coroutines: - await coroutine + for (ni, staking_address), coroutine in stake_coroutines.items(): + success = await coroutine + successes[ni][staking_address] = success + if json_output: + json_console.print(json.dumps({"staking_success": successes})) # Helper functions diff --git a/bittensor_cli/src/commands/stake/children_hotkeys.py b/bittensor_cli/src/commands/stake/children_hotkeys.py index fff84c65d..12f52658c 100644 --- a/bittensor_cli/src/commands/stake/children_hotkeys.py +++ b/bittensor_cli/src/commands/stake/children_hotkeys.py @@ -1,8 +1,9 @@ import asyncio +import json from typing import Optional from bittensor_wallet import Wallet -from rich.prompt import Confirm, Prompt, IntPrompt +from rich.prompt import Confirm, IntPrompt, FloatPrompt from rich.table import Table from rich.text import Text from async_substrate_interface.errors import SubstrateRequestException @@ -19,6 +20,7 @@ is_valid_ss58_address, format_error_message, unlock_key, + json_console, ) @@ -503,6 +505,7 @@ async def set_children( wait_for_inclusion: bool = True, wait_for_finalization: bool = True, prompt: bool = True, + json_output: bool = False, ): """Set children hotkeys.""" # Validate children SS58 addresses @@ -523,6 +526,7 @@ async def set_children( f"Proposed sum of proportions is {total_proposed}." ) children_with_proportions = list(zip(proportions, children)) + successes = {} if netuid is not None: success, message = await set_children_extrinsic( subtensor=subtensor, @@ -534,12 +538,20 @@ async def set_children( wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, ) + successes[netuid] = { + "success": success, + "error": message, + "completion_block": None, + "set_block": None, + } # Result if success: if wait_for_inclusion and wait_for_finalization: current_block, completion_block = await get_childkey_completion_block( subtensor, netuid ) + successes[netuid]["completion_block"] = completion_block + successes[netuid]["set_block"] = current_block console.print( f"Your childkey request has been submitted. It will be completed around block {completion_block}. " f"The current block is {current_block}" @@ -558,7 +570,7 @@ async def set_children( if netuid_ == 0: # dont include root network continue console.print(f"Setting children on netuid {netuid_}.") - await set_children_extrinsic( + success, message = await set_children_extrinsic( subtensor=subtensor, wallet=wallet, netuid=netuid_, @@ -571,6 +583,12 @@ async def set_children( current_block, completion_block = await get_childkey_completion_block( subtensor, netuid_ ) + successes[netuid_] = { + "success": success, + "error": message, + "completion_block": completion_block, + "set_block": current_block, + } console.print( 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}." @@ -578,6 +596,8 @@ async def set_children( console.print( ":white_heavy_check_mark: [green]Sent set children request for all subnets.[/green]" ) + if json_output: + json_console.print(json.dumps(successes)) async def revoke_children( @@ -587,10 +607,12 @@ async def revoke_children( wait_for_inclusion: bool = True, wait_for_finalization: bool = True, prompt: bool = True, + json_output: bool = False, ): """ Revokes the children hotkeys associated with a given network identifier (netuid). """ + dict_output = {} if netuid: success, message = await set_children_extrinsic( subtensor=subtensor, @@ -602,6 +624,7 @@ async def revoke_children( wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, ) + dict_output[netuid] = {"success": success, "error": message} # Result if success: @@ -621,7 +644,7 @@ async def revoke_children( if netuid == 0: # dont include root network continue console.print(f"Revoking children from netuid {netuid}.") - await set_children_extrinsic( + success, message = await set_children_extrinsic( subtensor=subtensor, wallet=wallet, netuid=netuid, @@ -631,9 +654,12 @@ async def revoke_children( wait_for_inclusion=True, wait_for_finalization=False, ) + dict_output[netuid] = {"success": success, "error": message} console.print( ":white_heavy_check_mark: [green]Sent revoke children command. Finalization may take a few minutes.[/green]" ) + if json_output: + json_console.print(json.dumps(dict_output)) async def childkey_take( @@ -645,8 +671,13 @@ async def childkey_take( wait_for_inclusion: bool = True, wait_for_finalization: bool = True, prompt: bool = True, -): - """Get or Set childkey take.""" +) -> list[tuple[Optional[int], bool]]: + """ + Get or Set childkey take. + + Returns: + List of (netuid, success) for specified netuid (or all) and their success in setting take + """ def validate_take_value(take_value: float) -> bool: if not (0 <= take_value <= 0.18): @@ -656,20 +687,7 @@ def validate_take_value(take_value: float) -> bool: return False return True - def print_all_takes(takes: list[tuple[int, float]], ss58: str): - """Print table with netuids and Takes""" - table = Table( - title=f"Current Child Takes for [bright_magenta]{ss58}[/bright_magenta]" - ) - table.add_column("Netuid", justify="center", style="cyan") - table.add_column("Take (%)", justify="right", style="magenta") - - for take_netuid, take_value in takes: - table.add_row(str(take_netuid), f"{take_value:.2f}%") - - console.print(table) - - async def display_chk_take(ss58, take_netuid): + async def display_chk_take(ss58, take_netuid) -> float: """Print single key take for hotkey and netuid""" chk_take = await get_childkey_take( subtensor=subtensor, netuid=take_netuid, hotkey=ss58 @@ -680,6 +698,7 @@ async def display_chk_take(ss58, take_netuid): console.print( f"Child take for {ss58} is: {chk_take * 100:.2f}% on netuid {take_netuid}." ) + return chk_take async def chk_all_subnets(ss58): """Aggregate data for childkey take from all subnets""" @@ -694,10 +713,18 @@ async def chk_all_subnets(ss58): if curr_take is not None: take_value = u16_to_float(curr_take) takes.append((subnet, take_value * 100)) + table = Table( + title=f"Current Child Takes for [bright_magenta]{ss58}[/bright_magenta]" + ) + table.add_column("Netuid", justify="center", style="cyan") + table.add_column("Take (%)", justify="right", style="magenta") + + for take_netuid, take_value in takes: + table.add_row(str(take_netuid), f"{take_value:.2f}%") - print_all_takes(takes, ss58) + console.print(table) - async def set_chk_take_subnet(subnet, chk_take): + async def set_chk_take_subnet(subnet: int, chk_take: float) -> bool: """Set the childkey take for a single subnet""" success, message = await set_childkey_take_extrinsic( subtensor=subtensor, @@ -715,13 +742,17 @@ async def set_chk_take_subnet(subnet, chk_take): console.print( f"The childkey take for {wallet.hotkey.ss58_address} is now set to {take * 100:.2f}%." ) + return True else: console.print( f":cross_mark:[red] Unable to set childkey take.[/red] {message}" ) + return False # Print childkey take for other user and return (dont offer to change take rate) - if hotkey and hotkey != wallet.hotkey.ss58_address: + if not hotkey or hotkey == wallet.hotkey.ss58_address: + hotkey = wallet.hotkey.ss58_address + if hotkey != wallet.hotkey.ss58_address or not take: # display childkey take for other users if netuid: await display_chk_take(hotkey, netuid) @@ -729,70 +760,64 @@ async def set_chk_take_subnet(subnet, chk_take): console.print( f"Hotkey {hotkey} not associated with wallet {wallet.name}." ) - return + return [(netuid, False)] else: - # show childhotkey take on all subnets + # show child hotkey take on all subnets await chk_all_subnets(hotkey) if take: console.print( f"Hotkey {hotkey} not associated with wallet {wallet.name}." ) - return + return [(netuid, False)] # Validate child SS58 addresses if not take: - # print current Take, ask if change - if netuid: - await display_chk_take(wallet.hotkey.ss58_address, netuid) - else: - # print take from all netuids - await chk_all_subnets(wallet.hotkey.ss58_address) - if not Confirm.ask("Would you like to change the child take?"): - return - new_take_str = Prompt.ask("Enter the new take value (between 0 and 0.18)") - try: - new_take_value = float(new_take_str) - if not validate_take_value(new_take_value): - return - except ValueError: - err_console.print( - ":cross_mark:[red] Invalid input. Please enter a number between 0 and 0.18.[/red]" + return [(netuid, False)] + new_take_value = -1.0 + while not validate_take_value(new_take_value): + new_take_value = FloatPrompt.ask( + "Enter the new take value (between 0 and 0.18)" ) - return take = new_take_value else: if not validate_take_value(take): - return + return [(netuid, False)] if netuid: - await set_chk_take_subnet(subnet=netuid, chk_take=take) - return + return [(netuid, await set_chk_take_subnet(subnet=netuid, chk_take=take))] else: new_take_netuids = IntPrompt.ask( "Enter netuid (leave blank for all)", default=None, show_default=True ) if new_take_netuids: - await set_chk_take_subnet(subnet=new_take_netuids, chk_take=take) - return + return [ + ( + new_take_netuids, + await set_chk_take_subnet(subnet=new_take_netuids, chk_take=take), + ) + ] else: netuids = await subtensor.get_all_subnet_netuids() - for netuid in netuids: - if netuid == 0: + output_list = [] + for netuid_ in netuids: + if netuid_ == 0: continue - console.print(f"Sending to netuid {netuid} take of {take * 100:.2f}%") - await set_childkey_take_extrinsic( + console.print(f"Sending to netuid {netuid_} take of {take * 100:.2f}%") + result = await set_childkey_take_extrinsic( subtensor=subtensor, wallet=wallet, - netuid=netuid, + netuid=netuid_, hotkey=wallet.hotkey.ss58_address, take=take, prompt=prompt, wait_for_inclusion=True, wait_for_finalization=False, ) + output_list.append((netuid_, result)) console.print( f":white_heavy_check_mark: [green]Sent childkey take of {take * 100:.2f}% to all subnets.[/green]" ) + return output_list diff --git a/bittensor_cli/src/commands/stake/list.py b/bittensor_cli/src/commands/stake/list.py index eb5340fcd..2e6d39d76 100644 --- a/bittensor_cli/src/commands/stake/list.py +++ b/bittensor_cli/src/commands/stake/list.py @@ -1,5 +1,6 @@ import asyncio - +import json +from collections import defaultdict from typing import TYPE_CHECKING, Optional from bittensor_wallet import Wallet @@ -18,6 +19,7 @@ print_error, millify_tao, get_subnet_name, + json_console, ) if TYPE_CHECKING: @@ -31,6 +33,7 @@ async def stake_list( live: bool = False, verbose: bool = False, prompt: bool = False, + json_output: bool = False, ): coldkey_address = coldkey_ss58 if coldkey_ss58 else wallet.coldkeypub.ss58_address @@ -152,6 +155,7 @@ def create_table(hotkey_: str, substakes: list[StakeInfo]): reverse=True, ) sorted_substakes = root_stakes + other_stakes + substakes_values = [] for substake_ in sorted_substakes: netuid = substake_.netuid pool = dynamic_info[netuid] @@ -195,7 +199,8 @@ def create_table(hotkey_: str, substakes: list[StakeInfo]): if not verbose else f"{substake_.stake.tao:,.4f}" ) - subnet_name_cell = f"[{COLOR_PALETTE['GENERAL']['SYMBOL']}]{symbol if netuid != 0 else 'τ'}[/{COLOR_PALETTE['GENERAL']['SYMBOL']}] {get_subnet_name(dynamic_info[netuid])}" + subnet_name = get_subnet_name(dynamic_info[netuid]) + subnet_name_cell = f"[{COLOR_PALETTE['GENERAL']['SYMBOL']}]{symbol if netuid != 0 else 'τ'}[/{COLOR_PALETTE['GENERAL']['SYMBOL']}] {subnet_name}" rows.append( [ @@ -220,13 +225,28 @@ def create_table(hotkey_: str, substakes: list[StakeInfo]): str(Balance.from_tao(per_block_tao_emission)), ] ) + substakes_values.append( + { + "netuid": netuid, + "subnet_name": subnet_name, + "value": tao_value_.tao, + "stake_value": substake_.stake.tao, + "rate": pool.price.tao, + "swap_value": swap_value, + "registered": True if substake_.is_registered else False, + "emission": { + "alpha": per_block_emission, + "tao": per_block_tao_emission, + }, + } + ) created_table = define_table( name_, rows, total_tao_value_, total_swapped_tao_value_ ) for row in rows: created_table.add_row(*row) console.print(created_table) - return total_tao_value_, total_swapped_tao_value_ + return total_tao_value_, total_swapped_tao_value_, substakes_values def create_live_table( substakes: list, @@ -409,22 +429,23 @@ def format_cell( # Main execution block_hash = await subtensor.substrate.get_chain_head() ( - sub_stakes, - registered_delegate_info, - dynamic_info, - ) = await get_stake_data(block_hash) - balance = await subtensor.get_balance(coldkey_address) + ( + sub_stakes, + registered_delegate_info, + dynamic_info, + ), + balance, + ) = await asyncio.gather( + get_stake_data(block_hash), + subtensor.get_balance(coldkey_address, block_hash=block_hash), + ) # Iterate over substakes and aggregate them by hotkey. - hotkeys_to_substakes: dict[str, list[StakeInfo]] = {} + hotkeys_to_substakes: dict[str, list[StakeInfo]] = defaultdict(list) for substake in sub_stakes: - hotkey = substake.hotkey_ss58 - if substake.stake.rao == 0: - continue - if hotkey not in hotkeys_to_substakes: - hotkeys_to_substakes[hotkey] = [] - hotkeys_to_substakes[hotkey].append(substake) + if substake.stake.rao != 0: + hotkeys_to_substakes[substake.hotkey_ss58].append(substake) if not hotkeys_to_substakes: print_error(f"No stakes found for coldkey ss58: ({coldkey_address})") @@ -534,15 +555,24 @@ def format_cell( num_hotkeys = len(hotkeys_to_substakes) all_hks_swapped_tao_value = Balance(0) all_hks_tao_value = Balance(0) - for hotkey in hotkeys_to_substakes.keys(): + dict_output = { + "stake_info": {}, + "coldkey_address": coldkey_address, + "network": subtensor.network, + "free_balance": 0.0, + "total_tao_value": 0.0, + "total_swapped_tao_value": 0.0, + } + for hotkey, substakes in hotkeys_to_substakes.items(): counter += 1 - tao_value, swapped_tao_value = create_table( - hotkey, hotkeys_to_substakes[hotkey] + tao_value, swapped_tao_value, substake_values_ = create_table( + hotkey, substakes ) + dict_output["stake_info"][hotkey] = substake_values_ all_hks_tao_value += tao_value all_hks_swapped_tao_value += swapped_tao_value - if num_hotkeys > 1 and counter < num_hotkeys and prompt: + if num_hotkeys > 1 and counter < num_hotkeys and prompt and not json_output: console.print("\nPress Enter to continue to the next hotkey...") input() @@ -556,7 +586,6 @@ def format_cell( if not verbose else all_hks_swapped_tao_value ) - console.print("\n\n") console.print( f"Wallet:\n" @@ -565,6 +594,11 @@ def format_cell( f" Total TAO Value ({Balance.unit}): [{COLOR_PALETTE['GENERAL']['BALANCE']}]{total_tao_value}[/{COLOR_PALETTE['GENERAL']['BALANCE']}]\n" f" Total TAO Swapped Value ({Balance.unit}): [{COLOR_PALETTE['GENERAL']['BALANCE']}]{total_swapped_tao_value}[/{COLOR_PALETTE['GENERAL']['BALANCE']}]" ) + dict_output["free_balance"] = balance.tao + dict_output["total_tao_value"] = all_hks_tao_value.tao + dict_output["total_swapped_tao_value"] = all_hks_swapped_tao_value.tao + if json_output: + json_console.print(json.dumps(dict_output)) if not sub_stakes: console.print( f"\n[blue]No stakes found for coldkey ss58: ({coldkey_address})" diff --git a/bittensor_cli/src/commands/stake/move.py b/bittensor_cli/src/commands/stake/move.py index f67ac305f..42f61934b 100644 --- a/bittensor_cli/src/commands/stake/move.py +++ b/bittensor_cli/src/commands/stake/move.py @@ -447,7 +447,7 @@ async def move_stake( era: int, interactive_selection: bool = False, prompt: bool = True, -): +) -> bool: if interactive_selection: try: selection = await stake_move_transfer_selection(subtensor, wallet) @@ -513,8 +513,10 @@ async def move_stake( if amount_to_move_as_balance > origin_stake_balance: err_console.print( f"[red]Not enough stake[/red]:\n" - f" Stake balance: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{origin_stake_balance}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]" - f" < Moving amount: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{amount_to_move_as_balance}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]" + f" Stake balance: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]" + f"{origin_stake_balance}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]" + f" < Moving amount: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]" + f"{amount_to_move_as_balance}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]" ) return False @@ -574,13 +576,12 @@ async def move_stake( console.print(":white_heavy_check_mark: [green]Sent[/green]") return True else: - await response.process_events() if not await response.is_success: err_console.print( f"\n:cross_mark: [red]Failed[/red] with error:" f" {format_error_message(await response.error_message)}" ) - return + return False else: console.print( ":white_heavy_check_mark: [dark_sea_green3]Stake moved.[/dark_sea_green3]" @@ -612,7 +613,7 @@ async def move_stake( f"Destination Stake:\n [blue]{destination_stake_balance}[/blue] :arrow_right: " f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_destination_stake_balance}" ) - return + return True async def transfer_stake( @@ -760,7 +761,6 @@ async def transfer_stake( console.print(":white_heavy_check_mark: [green]Sent[/green]") return True - await response.process_events() if not await response.is_success: err_console.print( f":cross_mark: [red]Failed[/red] with error: " @@ -906,7 +906,8 @@ async def swap_stake( return False with console.status( - f"\n:satellite: Swapping stake from netuid [blue]{origin_netuid}[/blue] to netuid [blue]{destination_netuid}[/blue]..." + f"\n:satellite: Swapping stake from netuid [blue]{origin_netuid}[/blue] " + f"to netuid [blue]{destination_netuid}[/blue]..." ): call = await subtensor.substrate.compose_call( call_module="SubtensorModule", @@ -933,7 +934,6 @@ async def swap_stake( console.print(":white_heavy_check_mark: [green]Sent[/green]") return True - await response.process_events() if not await response.is_success: err_console.print( f":cross_mark: [red]Failed[/red] with error: " diff --git a/bittensor_cli/src/commands/stake/remove.py b/bittensor_cli/src/commands/stake/remove.py index 3488546a7..9130e6c52 100644 --- a/bittensor_cli/src/commands/stake/remove.py +++ b/bittensor_cli/src/commands/stake/remove.py @@ -1,4 +1,5 @@ import asyncio +import json from functools import partial from typing import TYPE_CHECKING, Optional @@ -20,6 +21,7 @@ format_error_message, group_subnets, unlock_key, + json_console, ) if TYPE_CHECKING: @@ -41,6 +43,7 @@ async def unstake( safe_staking: bool, rate_tolerance: float, allow_partial_stake: bool, + json_output: bool, era: int, ): """Unstake from hotkey(s).""" @@ -259,8 +262,11 @@ async def unstake( base_unstake_op["price_with_tolerance"] = price_with_tolerance base_table_row.extend( [ - f"{rate_with_tolerance:.4f} {Balance.get_unit(0)}/{Balance.get_unit(netuid)}", # Rate with tolerance - f"[{'dark_sea_green3' if allow_partial_stake else 'red'}]{allow_partial_stake}[/{'dark_sea_green3' if allow_partial_stake else 'red'}]", # Partial unstake + # Rate with tolerance + f"{rate_with_tolerance:.4f} {Balance.get_unit(0)}/{Balance.get_unit(netuid)}", + # Partial unstake + f"[{'dark_sea_green3' if allow_partial_stake else 'red'}]" + f"{allow_partial_stake}[/{'dark_sea_green3' if allow_partial_stake else 'red'}]", ] ) @@ -291,47 +297,45 @@ async def unstake( if not unlock_key(wallet).success: return False + successes = [] with console.status("\n:satellite: Performing unstaking operations...") as status: - if safe_staking: - for op in unstake_operations: - if op["netuid"] == 0: - await _unstake_extrinsic( - wallet=wallet, - subtensor=subtensor, - netuid=op["netuid"], - amount=op["amount_to_unstake"], - current_stake=op["current_stake_balance"], - hotkey_ss58=op["hotkey_ss58"], - status=status, - era=era, - ) - else: - await _safe_unstake_extrinsic( - wallet=wallet, - subtensor=subtensor, - netuid=op["netuid"], - amount=op["amount_to_unstake"], - hotkey_ss58=op["hotkey_ss58"], - price_limit=op["price_with_tolerance"], - allow_partial_stake=allow_partial_stake, - status=status, - era=era, - ) - else: - for op in unstake_operations: - await _unstake_extrinsic( - wallet=wallet, - subtensor=subtensor, - netuid=op["netuid"], - amount=op["amount_to_unstake"], - current_stake=op["current_stake_balance"], - hotkey_ss58=op["hotkey_ss58"], - status=status, - era=era, - ) + for op in unstake_operations: + common_args = { + "wallet": wallet, + "subtensor": subtensor, + "netuid": op["netuid"], + "amount": op["amount_to_unstake"], + "hotkey_ss58": op["hotkey_ss58"], + "status": status, + "era": era, + } + + if safe_staking and op["netuid"] != 0: + func = _safe_unstake_extrinsic + specific_args = { + "price_limit": op["price_with_tolerance"], + "allow_partial_stake": allow_partial_stake, + } + else: + func = _unstake_extrinsic + specific_args = {"current_stake": op["current_stake_balance"]} + + suc = await func(**common_args, **specific_args) + + successes.append( + { + "netuid": op["netuid"], + "hotkey_ss58": op["hotkey_ss58"], + "unstake_amount": op["amount_to_unstake"].tao, + "success": suc, + } + ) + console.print( f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]Unstaking operations completed." ) + if json_output: + json_console.print(json.dumps(successes)) async def unstake_all( @@ -344,6 +348,7 @@ async def unstake_all( exclude_hotkeys: Optional[list[str]] = None, era: int = 3, prompt: bool = True, + json_output: bool = False, ) -> bool: """Unstakes all stakes from all hotkeys in all subnets.""" include_hotkeys = include_hotkeys or [] @@ -489,11 +494,16 @@ async def unstake_all( slippage_pct, ) console.print(table) - message = "" if max_slippage > 5: - message += f"[{COLOR_PALETTE['STAKE']['SLIPPAGE_TEXT']}]-------------------------------------------------------------------------------------------------------------------\n" - message += f"[bold]WARNING:[/bold] The slippage on one of your operations is high: [{COLOR_PALETTE['STAKE']['SLIPPAGE_PERCENT']}]{max_slippage:.4f}%[/{COLOR_PALETTE['STAKE']['SLIPPAGE_PERCENT']}], this may result in a loss of funds.\n" - message += "-------------------------------------------------------------------------------------------------------------------\n" + message = ( + f"[{COLOR_PALETTE['STAKE']['SLIPPAGE_TEXT']}]--------------------------------------------------------------" + f"-----------------------------------------------------\n" + f"[bold]WARNING:[/bold] The slippage on one of your operations is high: " + f"[{COLOR_PALETTE['STAKE']['SLIPPAGE_PERCENT']}]{max_slippage:.4f}%" + f"[/{COLOR_PALETTE['STAKE']['SLIPPAGE_PERCENT']}], this may result in a loss of funds.\n" + "----------------------------------------------------------------------------------------------------------" + "---------\n" + ) console.print(message) console.print( @@ -507,10 +517,10 @@ async def unstake_all( if not unlock_key(wallet).success: return False - + successes = {} with console.status("Unstaking all stakes...") as status: for hotkey_ss58 in hotkey_ss58s: - await _unstake_all_extrinsic( + successes[hotkey_ss58] = await _unstake_all_extrinsic( wallet=wallet, subtensor=subtensor, hotkey_ss58=hotkey_ss58, @@ -519,6 +529,8 @@ async def unstake_all( status=status, era=era, ) + if json_output: + return json_console.print(json.dumps({"success": successes})) # Extrinsics @@ -531,7 +543,7 @@ async def _unstake_extrinsic( hotkey_ss58: str, status=None, era: int = 3, -) -> None: +) -> bool: """Execute a standard unstake extrinsic. Args: @@ -554,15 +566,17 @@ async def _unstake_extrinsic( f"\n:satellite: Unstaking {amount} from {hotkey_ss58} on netuid: {netuid} ..." ) - current_balance = await subtensor.get_balance(wallet.coldkeypub.ss58_address) - call = await subtensor.substrate.compose_call( - call_module="SubtensorModule", - call_function="remove_stake", - call_params={ - "hotkey": hotkey_ss58, - "netuid": netuid, - "amount_unstaked": amount.rao, - }, + current_balance, call = await asyncio.gather( + subtensor.get_balance(wallet.coldkeypub.ss58_address), + subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="remove_stake", + call_params={ + "hotkey": hotkey_ss58, + "netuid": netuid, + "amount_unstaked": amount.rao, + }, + ), ) extrinsic = await subtensor.substrate.create_signed_extrinsic( call=call, keypair=wallet.coldkey, era={"period": era} @@ -572,15 +586,12 @@ async def _unstake_extrinsic( response = await subtensor.substrate.submit_extrinsic( extrinsic, wait_for_inclusion=True, wait_for_finalization=False ) - await response.process_events() - if not await response.is_success: err_out( f"{failure_prelude} with error: " f"{format_error_message(await response.error_message)}" ) - return - + return False # Fetch latest balance and stake block_hash = await subtensor.substrate.get_chain_head() new_balance, new_stake = await asyncio.gather( @@ -601,9 +612,11 @@ async def _unstake_extrinsic( f"Subnet: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{netuid}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}]" f" Stake:\n [blue]{current_stake}[/blue] :arrow_right: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_stake}" ) + return True except Exception as e: err_out(f"{failure_prelude} with error: {str(e)}") + return False async def _safe_unstake_extrinsic( @@ -616,7 +629,7 @@ async def _safe_unstake_extrinsic( allow_partial_stake: bool, status=None, era: int = 3, -) -> None: +) -> bool: """Execute a safe unstake extrinsic with price limit. Args: @@ -641,26 +654,27 @@ async def _safe_unstake_extrinsic( block_hash = await subtensor.substrate.get_chain_head() - current_balance, next_nonce, current_stake = await asyncio.gather( + current_balance, next_nonce, current_stake, call = await asyncio.gather( subtensor.get_balance(wallet.coldkeypub.ss58_address, block_hash), subtensor.substrate.get_account_next_index(wallet.coldkeypub.ss58_address), subtensor.get_stake( hotkey_ss58=hotkey_ss58, coldkey_ss58=wallet.coldkeypub.ss58_address, netuid=netuid, + block_hash=block_hash, + ), + subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="remove_stake_limit", + call_params={ + "hotkey": hotkey_ss58, + "netuid": netuid, + "amount_unstaked": amount.rao, + "limit_price": price_limit, + "allow_partial": allow_partial_stake, + }, + block_hash=block_hash, ), - ) - - call = await subtensor.substrate.compose_call( - call_module="SubtensorModule", - call_function="remove_stake_limit", - call_params={ - "hotkey": hotkey_ss58, - "netuid": netuid, - "amount_unstaked": amount.rao, - "limit_price": price_limit, - "allow_partial": allow_partial_stake, - }, ) extrinsic = await subtensor.substrate.create_signed_extrinsic( @@ -679,17 +693,15 @@ async def _safe_unstake_extrinsic( f"Either increase price tolerance or enable partial unstaking.", status=status, ) - return else: err_out(f"\n{failure_prelude} with error: {format_error_message(e)}") - return + return False - await response.process_events() if not await response.is_success: err_out( f"\n{failure_prelude} with error: {format_error_message(await response.error_message)}" ) - return + return False block_hash = await subtensor.substrate.get_chain_head() new_balance, new_stake = await asyncio.gather( @@ -720,6 +732,7 @@ async def _safe_unstake_extrinsic( f"Subnet: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{netuid}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}] " f"Stake:\n [blue]{current_stake}[/blue] :arrow_right: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_stake}" ) + return True async def _unstake_all_extrinsic( diff --git a/bittensor_cli/src/commands/subnets/price.py b/bittensor_cli/src/commands/subnets/price.py index eb6b7b5c6..fa05b9462 100644 --- a/bittensor_cli/src/commands/subnets/price.py +++ b/bittensor_cli/src/commands/subnets/price.py @@ -13,6 +13,7 @@ err_console, get_subnet_name, print_error, + json_console, ) if TYPE_CHECKING: @@ -26,6 +27,7 @@ async def price( interval_hours: int = 24, html_output: bool = False, log_scale: bool = False, + json_output: bool = False, ): """ Fetch historical price data for subnets and display it in a chart. @@ -60,7 +62,7 @@ async def price( all_subnet_infos = await asyncio.gather(*subnet_info_cors) subnet_data = _process_subnet_data( - block_numbers, all_subnet_infos, netuids, all_netuids, interval_hours + block_numbers, all_subnet_infos, netuids, all_netuids ) if not subnet_data: @@ -71,17 +73,13 @@ async def price( await _generate_html_output( subnet_data, block_numbers, interval_hours, log_scale ) + elif json_output: + json_console.print(json.dumps(_generate_json_output(subnet_data))) else: _generate_cli_output(subnet_data, block_numbers, interval_hours, log_scale) -def _process_subnet_data( - block_numbers, - all_subnet_infos, - netuids, - all_netuids, - interval_hours, -): +def _process_subnet_data(block_numbers, all_subnet_infos, netuids, all_netuids): """ Process subnet data into a structured format for price analysis. """ @@ -772,6 +770,10 @@ async def _generate_html_output( print_error(f"Error generating price chart: {e}") +def _generate_json_output(subnet_data): + return {netuid: data for netuid, data in subnet_data.items()} + + def _generate_cli_output(subnet_data, block_numbers, interval_hours, log_scale): """ Render the price data in a textual CLI style with plotille ASCII charts. @@ -802,7 +804,7 @@ def color_label(text): fig.plot( block_numbers, - data["prices"], + prices, label=f"Subnet {netuid} Price", interp="linear", lc="bae98f", diff --git a/bittensor_cli/src/commands/subnets/subnets.py b/bittensor_cli/src/commands/subnets/subnets.py index 174622a3d..a856304c2 100644 --- a/bittensor_cli/src/commands/subnets/subnets.py +++ b/bittensor_cli/src/commands/subnets/subnets.py @@ -34,6 +34,7 @@ prompt_for_identity, get_subnet_name, unlock_key, + json_console, ) if TYPE_CHECKING: @@ -206,40 +207,41 @@ async def subnets_list( no_cache: bool, verbose: bool, live: bool, + json_output: bool, ): """List all subnet netuids in the network.""" async def fetch_subnet_data(): - block_number = await subtensor.substrate.get_block_number(None) - subnets = await subtensor.all_subnets() + block_number_ = await subtensor.substrate.get_block_number(None) + subnets_ = await subtensor.all_subnets() # Sort subnets by market cap, keeping the root subnet in the first position - root_subnet = next(s for s in subnets if s.netuid == 0) + root_subnet = next(s for s in subnets_ if s.netuid == 0) other_subnets = sorted( - [s for s in subnets if s.netuid != 0], + [s for s in subnets_ if s.netuid != 0], key=lambda x: (x.alpha_in.tao + x.alpha_out.tao) * x.price.tao, reverse=True, ) sorted_subnets = [root_subnet] + other_subnets - return sorted_subnets, block_number + return sorted_subnets, block_number_ def calculate_emission_stats( - subnets: list, block_number: int + subnets_: list, block_number_: int ) -> tuple[Balance, str]: # We do not include the root subnet in the emission calculation total_tao_emitted = sum( - subnet.tao_in.tao for subnet in subnets if subnet.netuid != 0 + subnet.tao_in.tao for subnet in subnets_ if subnet.netuid != 0 ) - emission_percentage = (total_tao_emitted / block_number) * 100 + emission_percentage = (total_tao_emitted / block_number_) * 100 percentage_color = "dark_sea_green" if emission_percentage < 100 else "red" formatted_percentage = ( f"[{percentage_color}]{emission_percentage:.2f}%[/{percentage_color}]" ) if not verbose: - percentage_string = f"τ {millify_tao(total_tao_emitted)}/{millify_tao(block_number)} ({formatted_percentage})" + percentage_string = f"τ {millify_tao(total_tao_emitted)}/{millify_tao(block_number_)} ({formatted_percentage})" else: percentage_string = ( - f"τ {total_tao_emitted:.1f}/{block_number} ({formatted_percentage})" + f"τ {total_tao_emitted:.1f}/{block_number_} ({formatted_percentage})" ) return total_tao_emitted, percentage_string @@ -249,7 +251,7 @@ def define_table( total_netuids: int, tao_emission_percentage: str, ): - table = Table( + defined_table = Table( title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Subnets" f"\nNetwork: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{subtensor.network}\n\n", show_footer=True, @@ -262,61 +264,61 @@ def define_table( pad_edge=True, ) - table.add_column( + defined_table.add_column( "[bold white]Netuid", style="grey89", justify="center", footer=str(total_netuids), ) - table.add_column("[bold white]Name", style="cyan", justify="left") - table.add_column( + defined_table.add_column("[bold white]Name", style="cyan", justify="left") + defined_table.add_column( f"[bold white]Price \n({Balance.get_unit(0)}_in/{Balance.get_unit(1)}_in)", style="dark_sea_green2", justify="left", footer=f"τ {total_rate}", ) - table.add_column( + defined_table.add_column( f"[bold white]Market Cap \n({Balance.get_unit(1)} * Price)", style="steel_blue3", justify="left", ) - table.add_column( + defined_table.add_column( f"[bold white]Emission ({Balance.get_unit(0)})", style=COLOR_PALETTE["POOLS"]["EMISSION"], justify="left", footer=f"τ {total_emissions}", ) - table.add_column( + defined_table.add_column( f"[bold white]P ({Balance.get_unit(0)}_in, {Balance.get_unit(1)}_in)", style=COLOR_PALETTE["STAKE"]["TAO"], justify="left", footer=f"{tao_emission_percentage}", ) - table.add_column( + defined_table.add_column( f"[bold white]Stake ({Balance.get_unit(1)}_out)", style=COLOR_PALETTE["STAKE"]["STAKE_ALPHA"], justify="left", ) - table.add_column( + defined_table.add_column( f"[bold white]Supply ({Balance.get_unit(1)})", style=COLOR_PALETTE["POOLS"]["ALPHA_IN"], justify="left", ) - table.add_column( + defined_table.add_column( "[bold white]Tempo (k/n)", style=COLOR_PALETTE["GENERAL"]["TEMPO"], justify="left", overflow="fold", ) - return table + return defined_table # Non-live mode - def create_table(subnets, block_number): + def _create_table(subnets_, block_number_): rows = [] - _, percentage_string = calculate_emission_stats(subnets, block_number) + _, percentage_string = calculate_emission_stats(subnets_, block_number_) - for subnet in subnets: + for subnet in subnets_: netuid = subnet.netuid symbol = f"{subnet.symbol}\u200e" @@ -363,7 +365,7 @@ def create_table(subnets, block_number): # Prepare cells netuid_cell = str(netuid) subnet_name_cell = ( - f"[{COLOR_PALETTE['GENERAL']['SYMBOL']}]{subnet.symbol if netuid != 0 else 'τ'}[/{COLOR_PALETTE['GENERAL']['SYMBOL']}]" + f"[{COLOR_PALETTE.G.SYM}]{subnet.symbol if netuid != 0 else 'τ'}[/{COLOR_PALETTE.G.SYM}]" f" {get_subnet_name(subnet)}" ) emission_cell = f"τ {emission_tao:,.4f}" @@ -396,23 +398,76 @@ def create_table(subnets, block_number): ) total_emissions = round( - sum(subnet.tao_in_emission.tao for subnet in subnets if subnet.netuid != 0), + sum( + subnet.tao_in_emission.tao for subnet in subnets_ if subnet.netuid != 0 + ), 4, ) total_rate = round( - sum(float(subnet.price.tao) for subnet in subnets if subnet.netuid != 0), 4 + sum(float(subnet.price.tao) for subnet in subnets_ if subnet.netuid != 0), 4 ) - total_netuids = len(subnets) - table = define_table( + total_netuids = len(subnets_) + defined_table = define_table( total_emissions, total_rate, total_netuids, percentage_string ) for row in rows: - table.add_row(*row) - return table + defined_table.add_row(*row) + return defined_table + + def dict_table(subnets_, block_number_) -> dict: + subnet_rows = {} + total_tao_emitted, _ = calculate_emission_stats(subnets_, block_number_) + total_emissions = 0.0 + total_rate = 0.0 + total_netuids = len(subnets_) + emission_percentage = (total_tao_emitted / block_number_) * 100 + for subnet in subnets_: + total_emissions += subnet.tao_in_emission.tao + total_rate += subnet.price.tao + netuid = subnet.netuid + if netuid == 0: + emission_tao = 0.0 + else: + emission_tao = subnet.tao_in_emission.tao + alpha_in_value = subnet.alpha_in.tao + alpha_out_value = subnet.alpha_out.tao + price_value = subnet.price.tao + market_cap = (subnet.alpha_in.tao + subnet.alpha_out.tao) * subnet.price.tao + tao_in = subnet.tao_in.tao if netuid != 0 else None + alpha_in = alpha_in_value if netuid != 0 else None + alpha_out = alpha_out_value if netuid != 0 else None + supply = subnet.alpha_in.tao + subnet.alpha_out.tao + subnet_name = get_subnet_name(subnet) + tempo = { + "blocks_since_last_step": ( + subnet.blocks_since_last_step if netuid != 0 else None + ), + "sn_tempo": (subnet.tempo if netuid != 0 else None), + } + subnet_rows[netuid] = { + "netuid": netuid, + "subnet_name": subnet_name, + "price": price_value, + "market_cap": market_cap, + "emission": emission_tao, + "liquidity": {"tao_in": tao_in, "alpha_in": alpha_in}, + "alpha_out": alpha_out, + "supply": supply, + "tempo": tempo, + } + output = { + "total_tao_emitted": total_tao_emitted, + "total_emissions": total_emissions, + "total_rate": total_rate, + "total_netuids": total_netuids, + "emission_percentage": emission_percentage, + "subnets": subnet_rows, + } + return output # Live mode - def create_table_live(subnets, previous_data, block_number): + def create_table_live(subnets_, previous_data_, block_number_): def format_cell( value, previous_value, unit="", unit_first=False, precision=4, millify=False ): @@ -516,9 +571,9 @@ def format_liquidity_cell( rows = [] current_data = {} # To store current values for comparison in the next update - _, percentage_string = calculate_emission_stats(subnets, block_number) + _, percentage_string = calculate_emission_stats(subnets_, block_number_) - for subnet in subnets: + for subnet in subnets_: netuid = subnet.netuid symbol = f"{subnet.symbol}\u200e" @@ -541,7 +596,7 @@ def format_liquidity_cell( "supply": supply, "blocks_since_last_step": subnet.blocks_since_last_step, } - prev = previous_data.get(netuid, {}) if previous_data else {} + prev = previous_data_.get(netuid, {}) if previous_data_ else {} # Prepare cells if netuid == 0: @@ -652,9 +707,9 @@ def format_liquidity_cell( ) # Calculate totals - total_netuids = len(subnets) + total_netuids = len(subnets_) _total_emissions = sum( - subnet.tao_in_emission.tao for subnet in subnets if subnet.netuid != 0 + subnet.tao_in_emission.tao for subnet in subnets_ if subnet.netuid != 0 ) total_emissions = ( f"{millify_tao(_total_emissions)}" @@ -662,7 +717,7 @@ def format_liquidity_cell( else f"{_total_emissions:,.2f}" ) - total_rate = sum(subnet.price.tao for subnet in subnets if subnet.netuid != 0) + total_rate = sum(subnet.price.tao for subnet in subnets_ if subnet.netuid != 0) total_rate = ( f"{millify_tao(total_rate)}" if not verbose else f"{total_rate:,.2f}" ) @@ -733,8 +788,11 @@ def format_liquidity_cell( else: # Non-live mode subnets, block_number = await fetch_subnet_data() - table = create_table(subnets, block_number) - console.print(table) + if json_output: + json_console.print(json.dumps(dict_table(subnets, block_number))) + else: + table = _create_table(subnets, block_number) + console.print(table) return # TODO: Temporarily returning till we update docs @@ -804,20 +862,22 @@ async def show( delegate_selection: bool = False, verbose: bool = False, prompt: bool = True, + json_output: bool = False, ) -> Optional[str]: async def show_root(): + # TODO json_output for this, don't forget block_hash = await subtensor.substrate.get_chain_head() - all_subnets = await subtensor.all_subnets(block_hash=block_hash) - root_info = next((s for s in all_subnets if s.netuid == 0), None) - if root_info is None: - print_error("The root subnet does not exist") - return False - root_state, identities, old_identities = await asyncio.gather( + all_subnets, root_state, identities, old_identities = await asyncio.gather( + subtensor.all_subnets(block_hash=block_hash), subtensor.get_subnet_state(netuid=0, block_hash=block_hash), subtensor.query_all_identities(block_hash=block_hash), subtensor.get_delegate_identities(block_hash=block_hash), ) + root_info = next((s for s in all_subnets if s.netuid == 0), None) + if root_info is None: + print_error("The root subnet does not exist") + return False if root_state is None: err_console.print("The root subnet does not exist") @@ -829,12 +889,11 @@ async def show_root(): ) return - tao_sum = sum( - [root_state.tao_stake[idx].tao for idx in range(len(root_state.tao_stake))] - ) + tao_sum = sum(root_state.tao_stake).tao table = Table( - title=f"[{COLOR_PALETTE['GENERAL']['HEADER']}]Root Network\n[{COLOR_PALETTE['GENERAL']['SUBHEADING']}]Network: {subtensor.network}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}]\n", + title=f"[{COLOR_PALETTE.G.HEADER}]Root Network\n[{COLOR_PALETTE.G.SUBHEAD}]" + f"Network: {subtensor.network}[/{COLOR_PALETTE.G.SUBHEAD}]\n", show_footer=True, show_edge=False, header_style="bold white", @@ -1119,6 +1178,7 @@ async def show_subnet(netuid_: int): ) rows = [] + json_out_rows = [] for idx in sorted_indices: # Get identity for this uid coldkey_identity = identities.get(subnet_state.coldkeys[idx], {}).get( @@ -1170,6 +1230,22 @@ async def show_subnet(netuid_: int): uid_identity, # Identity ) ) + json_out_rows.append( + { + "uid": idx, + "stake": subnet_state.total_stake[idx].tao, + "alpha_stake": subnet_state.alpha_stake[idx].tao, + "tao_stake": tao_stake.tao, + "dividends": subnet_state.dividends[idx], + "incentive": subnet_state.incentives[idx], + "emissions": Balance.from_tao(subnet_state.emission[idx].tao) + .set_unit(netuid_) + .tao, + "hotkey": subnet_state.hotkeys[idx], + "coldkey": subnet_state.coldkeys[idx], + "identity": uid_identity, + } + ) # Add columns to the table table.add_column("UID", style="grey89", no_wrap=True, justify="center") @@ -1262,6 +1338,24 @@ async def show_subnet(netuid_: int): if current_burn_cost else Balance(0) ) + output_dict = { + "netuid": netuid_, + "name": subnet_name_display, + "owner": subnet_info.owner_coldkey, + "owner_identity": owner_identity, + "rate": subnet_info.price.tao, + "emission": subnet_info.emission.tao, + "tao_pool": subnet_info.tao_in.tao, + "alpha_pool": subnet_info.alpha_in.tao, + "tempo": { + "block_since_last_step": subnet_info.blocks_since_last_step, + "tempo": subnet_info.tempo, + }, + "registration_cost": current_registration_burn.tao, + "uids": json_out_rows, + } + if json_output: + json_console.print(json.dumps(output_dict)) console.print( f"[{COLOR_PALETTE['GENERAL']['SUBHEADING']}]Subnet {netuid_}{subnet_name_display}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}]" diff --git a/bittensor_cli/src/commands/sudo.py b/bittensor_cli/src/commands/sudo.py index 8184bd793..e5502714a 100644 --- a/bittensor_cli/src/commands/sudo.py +++ b/bittensor_cli/src/commands/sudo.py @@ -1,4 +1,5 @@ import asyncio +import json from typing import TYPE_CHECKING, Union, Optional from bittensor_wallet import Wallet @@ -19,6 +20,7 @@ blocks_to_duration, float_to_u64, float_to_u16, + json_console, ) if TYPE_CHECKING: @@ -350,6 +352,19 @@ def display_votes( return "\n".join(vote_list) +def serialize_vote_data( + vote_data: "ProposalVoteData", delegate_info: dict[str, DelegatesDetails] +) -> list[dict[str, bool]]: + vote_list = {} + for address in vote_data.ayes: + f_add = delegate_info[address].display if address in delegate_info else address + vote_list[f_add] = True + for address in vote_data.nays: + f_add = delegate_info[address].display if address in delegate_info else address + vote_list[f_add] = False + return vote_list + + def format_call_data(call_data: dict) -> str: # Extract the module and call details module, call_details = next(iter(call_data.items())) @@ -559,6 +574,7 @@ async def sudo_set_hyperparameter( netuid: int, param_name: str, param_value: Optional[str], + json_output: bool, ): """Set subnet hyperparameters.""" @@ -584,17 +600,22 @@ async def sudo_set_hyperparameter( f"Hyperparameter [dark_orange]{param_name}[/dark_orange] value is not within bounds. " f"Value is {normalized_value} but must be {value}" ) - return + return False success = await set_hyperparameter_extrinsic( subtensor, wallet, netuid, param_name, value ) + if json_output: + return success if success: console.print("\n") print_verbose("Fetching hyperparameters") - return await get_hyperparameters(subtensor, netuid=netuid) + await get_hyperparameters(subtensor, netuid=netuid) + return success -async def get_hyperparameters(subtensor: "SubtensorInterface", netuid: int): +async def get_hyperparameters( + subtensor: "SubtensorInterface", netuid: int, json_output: bool = False +) -> bool: """View hyperparameters of a subnetwork.""" print_verbose("Fetching hyperparameters") if not await subtensor.subnet_exists(netuid): @@ -607,32 +628,44 @@ async def get_hyperparameters(subtensor: "SubtensorInterface", netuid: int): return False table = Table( - Column("[white]HYPERPARAMETER", style=COLOR_PALETTE["SUDO"]["HYPERPARAMETER"]), - Column("[white]VALUE", style=COLOR_PALETTE["SUDO"]["VALUE"]), - Column("[white]NORMALIZED", style=COLOR_PALETTE["SUDO"]["NORMALIZED"]), - title=f"[{COLOR_PALETTE['GENERAL']['HEADER']}]\nSubnet Hyperparameters\n NETUID: " - f"[{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{netuid}" + Column("[white]HYPERPARAMETER", style=COLOR_PALETTE.SU.HYPERPARAMETER), + Column("[white]VALUE", style=COLOR_PALETTE.SU.VALUE), + Column("[white]NORMALIZED", style=COLOR_PALETTE.SU.NORMAL), + title=f"[{COLOR_PALETTE.G.HEADER}]\nSubnet Hyperparameters\n NETUID: " + f"[{COLOR_PALETTE.G.SUBHEAD}]{netuid}" f"{f' ({subnet_info.subnet_name})' if subnet_info.subnet_name is not None else ''}" - f"[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}]" - f" - Network: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{subtensor.network}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}]\n", + f"[/{COLOR_PALETTE.G.SUBHEAD}]" + f" - Network: [{COLOR_PALETTE.G.SUBHEAD}]{subtensor.network}[/{COLOR_PALETTE.G.SUBHEAD}]\n", show_footer=True, width=None, pad_edge=False, box=box.SIMPLE, show_edge=True, ) + dict_out = [] normalized_values = normalize_hyperparameters(subnet) for param, value, norm_value in normalized_values: table.add_row(" " + param, value, norm_value) - - console.print(table) + dict_out.append( + { + "hyperparameter": param, + "value": value, + "normalized_value": norm_value, + } + ) + if json_output: + json_console.print(json.dumps(dict_out)) + else: + console.print(table) return True -async def get_senate(subtensor: "SubtensorInterface"): - """View Bittensor's senate memebers""" +async def get_senate( + subtensor: "SubtensorInterface", json_output: bool = False +) -> None: + """View Bittensor's senate members""" with console.status( f":satellite: Syncing with chain: [white]{subtensor}[/white] ...", spinner="aesthetic", @@ -663,21 +696,27 @@ async def get_senate(subtensor: "SubtensorInterface"): border_style="bright_black", leading=True, ) + dict_output = [] for ss58_address in senate_members: + member_name = ( + delegate_info[ss58_address].display + if ss58_address in delegate_info + else "~" + ) table.add_row( - ( - delegate_info[ss58_address].display - if ss58_address in delegate_info - else "~" - ), + member_name, ss58_address, ) - + dict_output.append({"name": member_name, "ss58_address": ss58_address}) + if json_output: + json_console.print(json.dumps(dict_output)) return console.print(table) -async def proposals(subtensor: "SubtensorInterface", verbose: bool): +async def proposals( + subtensor: "SubtensorInterface", verbose: bool, json_output: bool = False +) -> None: console.print( ":satellite: Syncing with chain: [white]{}[/white] ...".format( subtensor.network @@ -723,6 +762,7 @@ async def proposals(subtensor: "SubtensorInterface", verbose: bool): width=None, border_style="bright_black", ) + dict_output = [] for hash_, (call_data, vote_data) in all_proposals.items(): blocks_remaining = vote_data.end - current_block if blocks_remaining > 0: @@ -741,6 +781,7 @@ async def proposals(subtensor: "SubtensorInterface", verbose: bool): if vote_data.threshold > 0 else 0 ) + f_call_data = format_call_data(call_data) table.add_row( hash_ if verbose else f"{hash_[:4]}...{hash_[-4:]}", str(vote_data.threshold), @@ -748,8 +789,21 @@ async def proposals(subtensor: "SubtensorInterface", verbose: bool): f"{len(vote_data.nays)} ({nays_threshold:.2f}%)", display_votes(vote_data, registered_delegate_info), vote_end_cell, - format_call_data(call_data), + f_call_data, + ) + dict_output.append( + { + "hash": hash_, + "threshold": vote_data.threshold, + "ayes": len(vote_data.ayes), + "nays": len(vote_data.nays), + "votes": serialize_vote_data(vote_data, registered_delegate_info), + "end": vote_data.end, + "call_data": f_call_data, + } ) + if json_output: + json_console.print(json.dumps(dict_output)) console.print(table) console.print( "\n[dim]* Both Ayes and Nays percentages are calculated relative to the proposal's threshold.[/dim]" diff --git a/bittensor_cli/src/commands/wallets.py b/bittensor_cli/src/commands/wallets.py index 28cb71304..006fb5119 100644 --- a/bittensor_cli/src/commands/wallets.py +++ b/bittensor_cli/src/commands/wallets.py @@ -1,5 +1,6 @@ import asyncio import itertools +import json import os from collections import defaultdict from typing import Generator, Optional @@ -35,6 +36,7 @@ console, convert_blocks_to_time, err_console, + json_console, print_error, print_verbose, get_all_wallets_for_path, @@ -125,6 +127,7 @@ async def regen_coldkey( json_password: Optional[str] = "", use_password: Optional[bool] = True, overwrite: Optional[bool] = False, + json_output: bool = False, ): """Creates a new coldkey under this wallet""" json_str: Optional[str] = None @@ -141,16 +144,41 @@ async def regen_coldkey( use_password=use_password, overwrite=overwrite, ) - if isinstance(new_wallet, Wallet): console.print( "\n✅ [dark_sea_green]Regenerated coldkey successfully!\n", - f"[dark_sea_green]Wallet name: ({new_wallet.name}), path: ({new_wallet.path}), coldkey ss58: ({new_wallet.coldkeypub.ss58_address})", + f"[dark_sea_green]Wallet name: ({new_wallet.name}), " + f"path: ({new_wallet.path}), " + f"coldkey ss58: ({new_wallet.coldkeypub.ss58_address})", ) + if json_output: + json_console.print( + json.dumps( + { + "success": True, + "data": { + "name": new_wallet.name, + "path": new_wallet.path, + "hotkey": new_wallet.hotkey_str, + "hotkey_ss58": new_wallet.hotkey.ss58_address, + "coldkey_ss58": new_wallet.coldkeypub.ss58_address, + }, + "error": "", + } + ) + ) except ValueError: print_error("Mnemonic phrase is invalid") + if json_output: + json_console.print( + '{"success": false, "error": "Mnemonic phrase is invalid", "data": null}' + ) except KeyFileError: print_error("KeyFileError: File is not writable") + if json_output: + json_console.print( + '{"success": false, "error": "Keyfile is not writable", "data": null}' + ) async def regen_coldkey_pub( @@ -158,6 +186,7 @@ async def regen_coldkey_pub( ss58_address: str, public_key_hex: str, overwrite: Optional[bool] = False, + json_output: bool = False, ): """Creates a new coldkeypub under this wallet.""" try: @@ -169,10 +198,31 @@ async def regen_coldkey_pub( if isinstance(new_coldkeypub, Wallet): console.print( "\n✅ [dark_sea_green]Regenerated coldkeypub successfully!\n", - f"[dark_sea_green]Wallet name: ({new_coldkeypub.name}), path: ({new_coldkeypub.path}), coldkey ss58: ({new_coldkeypub.coldkeypub.ss58_address})", + f"[dark_sea_green]Wallet name: ({new_coldkeypub.name}), path: ({new_coldkeypub.path}), " + f"coldkey ss58: ({new_coldkeypub.coldkeypub.ss58_address})", ) + if json_output: + json_console.print( + json.dumps( + { + "success": True, + "data": { + "name": new_coldkeypub.name, + "path": new_coldkeypub.path, + "hotkey": new_coldkeypub.hotkey_str, + "hotkey_ss58": new_coldkeypub.hotkey.ss58_address, + "coldkey_ss58": new_coldkeypub.coldkeypub.ss58_address, + }, + "error": "", + } + ) + ) except KeyFileError: print_error("KeyFileError: File is not writable") + if json_output: + json_console.print( + '{"success": false, "error": "Keyfile is not writable", "data": null}' + ) async def regen_hotkey( @@ -183,6 +233,7 @@ async def regen_hotkey( json_password: Optional[str] = "", use_password: Optional[bool] = False, overwrite: Optional[bool] = False, + json_output: bool = False, ): """Creates a new hotkey under this wallet.""" json_str: Optional[str] = None @@ -204,13 +255,37 @@ async def regen_hotkey( if isinstance(new_hotkey_, Wallet): console.print( "\n✅ [dark_sea_green]Regenerated hotkey successfully!\n", - f"[dark_sea_green]Wallet name: " - f"({new_hotkey_.name}), path: ({new_hotkey_.path}), hotkey ss58: ({new_hotkey_.hotkey.ss58_address})", + f"[dark_sea_green]Wallet name: ({new_hotkey_.name}), path: ({new_hotkey_.path}), " + f"hotkey ss58: ({new_hotkey_.hotkey.ss58_address})", ) + if json_output: + json_console.print( + json.dumps( + { + "success": True, + "data": { + "name": new_hotkey_.name, + "path": new_hotkey_.path, + "hotkey": new_hotkey_.hotkey_str, + "hotkey_ss58": new_hotkey_.hotkey.ss58_address, + "coldkey_ss58": new_hotkey_.coldkeypub.ss58_address, + }, + "error": "", + } + ) + ) except ValueError: print_error("Mnemonic phrase is invalid") + if json_output: + json_console.print( + '{"success": false, "error": "Mnemonic phrase is invalid", "data": null}' + ) except KeyFileError: print_error("KeyFileError: File is not writable") + if json_output: + json_console.print( + '{"success": false, "error": "Keyfile is not writable", "data": null}' + ) async def new_hotkey( @@ -219,6 +294,7 @@ async def new_hotkey( use_password: bool, uri: Optional[str] = None, overwrite: Optional[bool] = False, + json_output: bool = False, ): """Creates a new hotkey under this wallet.""" try: @@ -227,6 +303,7 @@ async def new_hotkey( keypair = Keypair.create_from_uri(uri) except Exception as e: print_error(f"Failed to create keypair from URI {uri}: {str(e)}") + return wallet.set_hotkey(keypair=keypair, encrypt=use_password) console.print( f"[dark_sea_green]Hotkey created from URI: {uri}[/dark_sea_green]" @@ -238,8 +315,28 @@ async def new_hotkey( overwrite=overwrite, ) console.print("[dark_sea_green]Hotkey created[/dark_sea_green]") + if json_output: + json_console.print( + json.dumps( + { + "success": True, + "data": { + "name": wallet.name, + "path": wallet.path, + "hotkey": wallet.hotkey_str, + "hotkey_ss58": wallet.hotkey.ss58_address, + "coldkey_ss58": wallet.coldkeypub.ss58_address, + }, + "error": "", + } + ) + ) except KeyFileError: print_error("KeyFileError: File is not writable") + if json_output: + json_console.print( + '{"success": false, "error": "Keyfile is not writable", "data": null}' + ) async def new_coldkey( @@ -248,6 +345,7 @@ async def new_coldkey( use_password: bool, uri: Optional[str] = None, overwrite: Optional[bool] = False, + json_output: bool = False, ): """Creates a new coldkey under this wallet.""" try: @@ -268,8 +366,32 @@ async def new_coldkey( overwrite=overwrite, ) console.print("[dark_sea_green]Coldkey created[/dark_sea_green]") - except KeyFileError: + if json_output: + json_console.print( + json.dumps( + { + "success": True, + "data": { + "name": wallet.name, + "path": wallet.path, + "coldkey_ss58": wallet.coldkeypub.ss58_address, + }, + "error": "", + } + ) + ) + except KeyFileError as e: print_error("KeyFileError: File is not writable") + if json_output: + json_console.print( + json.dumps( + { + "success": False, + "error": f"Keyfile is not writable: {e}", + "data": None, + } + ) + ) async def wallet_create( @@ -278,16 +400,28 @@ async def wallet_create( use_password: bool = True, uri: Optional[str] = None, overwrite: Optional[bool] = False, + json_output: bool = False, ): """Creates a new wallet.""" + output_dict = {"success": False, "error": "", "data": None} if uri: try: keypair = Keypair.create_from_uri(uri) wallet.set_coldkey(keypair=keypair, encrypt=False, overwrite=False) wallet.set_coldkeypub(keypair=keypair, encrypt=False, overwrite=False) wallet.set_hotkey(keypair=keypair, encrypt=False, overwrite=False) + output_dict["success"] = True + output_dict["data"] = { + "name": wallet.name, + "path": wallet.path, + "hotkey": wallet.hotkey_str, + "hotkey_ss58": wallet.hotkey.ss58_address, + "coldkey_ss58": wallet.coldkeypub.ss58_address, + } except Exception as e: - print_error(f"Failed to create keypair from URI: {str(e)}") + err = f"Failed to create keypair from URI: {str(e)}" + print_error(err) + output_dict["error"] = err console.print( f"[dark_sea_green]Wallet created from URI: {uri}[/dark_sea_green]" ) @@ -299,9 +433,18 @@ async def wallet_create( overwrite=overwrite, ) console.print("[dark_sea_green]Coldkey created[/dark_sea_green]") + output_dict["success"] = True + output_dict["data"] = { + "name": wallet.name, + "path": wallet.path, + "hotkey": wallet.hotkey_str, + "hotkey_ss58": wallet.hotkey.ss58_address, + "coldkey_ss58": wallet.coldkeypub.ss58_address, + } except KeyFileError: - print_error("KeyFileError: File is not writable") - + err = "KeyFileError: File is not writable" + print_error(err) + output_dict["error"] = err try: wallet.create_new_hotkey( n_words=n_words, @@ -309,8 +452,20 @@ async def wallet_create( overwrite=overwrite, ) console.print("[dark_sea_green]Hotkey created[/dark_sea_green]") + output_dict["success"] = True + output_dict["data"] = { + "name": wallet.name, + "path": wallet.path, + "hotkey": wallet.hotkey_str, + "hotkey_ss58": wallet.hotkey.ss58_address, + "coldkey_ss58": wallet.coldkeypub.ss58_address, + } except KeyFileError: - print_error("KeyFileError: File is not writable") + err = "KeyFileError: File is not writable" + print_error(err) + output_dict["error"] = err + if json_output: + json_console.print(json.dumps(output_dict)) def get_coldkey_wallets_for_path(path: str) -> list[Wallet]: @@ -359,6 +514,7 @@ async def wallet_balance( subtensor: SubtensorInterface, all_balances: bool, ss58_addresses: Optional[str] = None, + json_output: bool = False, ): """Retrieves the current balance of the specified wallet""" if ss58_addresses: @@ -474,6 +630,31 @@ async def wallet_balance( ) console.print(Padding(table, (0, 0, 0, 4))) await subtensor.substrate.close() + if json_output: + output_balances = { + key: { + "coldkey": value[0], + "free": value[1].tao, + "staked": value[2].tao, + "staked_with_slippage": value[3].tao, + "total": (value[1] + value[2]).tao, + "total_with_slippage": (value[1] + value[3]).tao, + } + for (key, value) in balances.items() + } + output_dict = { + "balances": output_balances, + "totals": { + "free": total_free_balance.tao, + "staked": total_staked_balance.tao, + "staked_with_slippage": total_staked_with_slippage.tao, + "total": (total_free_balance + total_staked_balance).tao, + "total_with_slippage": ( + total_free_balance + total_staked_with_slippage + ).tao, + }, + } + json_console.print(json.dumps(output_dict)) return total_free_balance @@ -605,7 +786,7 @@ async def wallet_history(wallet: Wallet): console.print(table) -async def wallet_list(wallet_path: str): +async def wallet_list(wallet_path: str, json_output: bool): """Lists wallets.""" wallets = utils.get_coldkey_wallets_for_path(wallet_path) print_verbose(f"Using wallets path: {wallet_path}") @@ -613,6 +794,7 @@ async def wallet_list(wallet_path: str): err_console.print(f"[red]No wallets found in dir: {wallet_path}[/red]") root = Tree("Wallets") + main_data_dict = {"wallets": []} for wallet in wallets: if ( wallet.coldkeypub_file.exists_on_device() @@ -625,23 +807,39 @@ async def wallet_list(wallet_path: str): wallet_tree = root.add( f"[bold blue]Coldkey[/bold blue] [green]{wallet.name}[/green] ss58_address [green]{coldkeypub_str}[/green]" ) + wallet_hotkeys = [] + wallet_dict = { + "name": wallet.name, + "ss58_address": coldkeypub_str, + "hotkeys": wallet_hotkeys, + } + main_data_dict["wallets"].append(wallet_dict) hotkeys = utils.get_hotkey_wallets_for_wallet( wallet, show_nulls=True, show_encrypted=True ) for hkey in hotkeys: data = f"[bold red]Hotkey[/bold red][green] {hkey}[/green] (?)" + hk_data = {"name": hkey.name, "ss58_address": "?"} if hkey: try: - data = f"[bold red]Hotkey[/bold red] [green]{hkey.hotkey_str}[/green] ss58_address [green]{hkey.hotkey.ss58_address}[/green]\n" + data = ( + f"[bold red]Hotkey[/bold red] [green]{hkey.hotkey_str}[/green] " + f"ss58_address [green]{hkey.hotkey.ss58_address}[/green]\n" + ) + hk_data["name"] = hkey.hotkey_str + hk_data["ss58_address"] = hkey.hotkey.ss58_address except UnicodeDecodeError: pass wallet_tree.add(data) + wallet_hotkeys.append(hk_data) if not wallets: print_verbose(f"No wallets found in path: {wallet_path}") root.add("[bold red]No wallets found.") - - console.print(root) + if json_output: + json_console.print(json.dumps(main_data_dict)) + else: + console.print(root) async def _get_total_balance( @@ -718,23 +916,33 @@ async def overview( exclude_hotkeys: Optional[list[str]] = None, netuids_filter: Optional[list[int]] = None, verbose: bool = False, + json_output: bool = False, ): """Prints an overview for the wallet's coldkey.""" total_balance = Balance(0) - # We are printing for every coldkey. - block_hash = await subtensor.substrate.get_chain_head() - all_hotkeys, total_balance = await _get_total_balance( - total_balance, subtensor, wallet, all_wallets, block_hash=block_hash - ) - _dynamic_info = await subtensor.all_subnets() - dynamic_info = {info.netuid: info for info in _dynamic_info} - with console.status( f":satellite: Synchronizing with chain [white]{subtensor.network}[/white]", spinner="aesthetic", ) as status: + # We are printing for every coldkey. + block_hash = await subtensor.substrate.get_chain_head() + ( + (all_hotkeys, total_balance), + _dynamic_info, + block, + all_netuids, + ) = await asyncio.gather( + _get_total_balance( + total_balance, subtensor, wallet, all_wallets, block_hash=block_hash + ), + subtensor.all_subnets(block_hash=block_hash), + subtensor.substrate.get_block_number(block_hash=block_hash), + subtensor.get_all_subnet_netuids(block_hash=block_hash), + ) + dynamic_info = {info.netuid: info for info in _dynamic_info} + # We are printing for a select number of hotkeys from all_hotkeys. if include_hotkeys or exclude_hotkeys: all_hotkeys = _get_hotkeys(include_hotkeys, exclude_hotkeys, all_hotkeys) @@ -746,10 +954,6 @@ async def overview( # Pull neuron info for all keys. neurons: dict[str, list[NeuronInfoLite]] = {} - block, all_netuids = await asyncio.gather( - subtensor.substrate.get_block_number(None), - subtensor.get_all_subnet_netuids(), - ) netuids = await subtensor.filter_netuids_by_registered_hotkeys( all_netuids, netuids_filter, all_hotkeys, reuse_block=True @@ -784,16 +988,27 @@ async def overview( neurons = _process_neuron_results(results, neurons, netuids) # Setup outer table. grid = Table.grid(pad_edge=True) + data_dict = { + "wallet": "", + "network": subtensor.network, + "subnets": [], + "total_balance": 0.0, + } # Add title if not all_wallets: title = "[underline dark_orange]Wallet[/underline dark_orange]\n" - details = f"[bright_cyan]{wallet.name}[/bright_cyan] : [bright_magenta]{wallet.coldkeypub.ss58_address}[/bright_magenta]" + details = ( + f"[bright_cyan]{wallet.name}[/bright_cyan] : " + f"[bright_magenta]{wallet.coldkeypub.ss58_address}[/bright_magenta]" + ) grid.add_row(Align(title, vertical="middle", align="center")) grid.add_row(Align(details, vertical="middle", align="center")) + data_dict["wallet"] = f"{wallet.name}|{wallet.coldkeypub.ss58_address}" else: title = "[underline dark_orange]All Wallets:[/underline dark_orange]" grid.add_row(Align(title, vertical="middle", align="center")) + data_dict["wallet"] = "All" grid.add_row( Align( @@ -811,6 +1026,14 @@ async def overview( ) for netuid, subnet_tempo in zip(netuids, tempos): table_data = [] + subnet_dict = { + "netuid": netuid, + "tempo": subnet_tempo, + "neurons": [], + "name": "", + "symbol": "", + } + data_dict["subnets"].append(subnet_dict) total_rank = 0.0 total_trust = 0.0 total_consensus = 0.0 @@ -865,6 +1088,26 @@ async def overview( ), nn.hotkey[:10], ] + neuron_dict = { + "coldkey": hotwallet.name, + "hotkey": hotwallet.hotkey_str, + "uid": uid, + "active": active, + "stake": stake, + "rank": rank, + "trust": trust, + "consensus": consensus, + "incentive": incentive, + "dividends": dividends, + "emission": emission, + "validator_trust": validator_trust, + "validator_permit": validator_permit, + "last_update": last_update, + "axon": int_to_ip(nn.axon_info.ip) + ":" + str(nn.axon_info.port) + if nn.axon_info.port != 0 + else None, + "hotkey_ss58": nn.hotkey, + } total_rank += rank total_trust += trust @@ -877,11 +1120,16 @@ async def overview( total_neurons += 1 table_data.append(row) + subnet_dict["neurons"].append(neuron_dict) # Add subnet header + sn_name = get_subnet_name(dynamic_info[netuid]) + sn_symbol = dynamic_info[netuid].symbol grid.add_row( - f"Subnet: [dark_orange]{netuid}: {get_subnet_name(dynamic_info[netuid])} {dynamic_info[netuid].symbol}[/dark_orange]" + f"Subnet: [dark_orange]{netuid}: {sn_name} {sn_symbol}[/dark_orange]" ) + subnet_dict["name"] = sn_name + subnet_dict["symbol"] = sn_symbol width = console.width table = Table( show_footer=False, @@ -1016,6 +1264,7 @@ def overview_sort_function(row_): caption = "\n[italic][dim][bright_cyan]Wallet balance: [dark_orange]\u03c4" + str( total_balance.tao ) + data_dict["total_balance"] = total_balance.tao grid.add_row(Align(caption, vertical="middle", align="center")) if console.width < 150: @@ -1023,7 +1272,10 @@ def overview_sort_function(row_): "[yellow]Warning: Your terminal width might be too small to view all information clearly" ) # Print the entire table/grid - console.print(grid, width=None) + if not json_output: + console.print(grid, width=None) + else: + json_console.print(json.dumps(data_dict)) def _get_hotkeys( @@ -1190,9 +1442,10 @@ async def transfer( transfer_all: bool, era: int, prompt: bool, + json_output: bool, ): """Transfer token of amount to destination.""" - await transfer_extrinsic( + result = await transfer_extrinsic( subtensor=subtensor, wallet=wallet, destination=destination, @@ -1201,6 +1454,9 @@ async def transfer( era=era, prompt=prompt, ) + if json_output: + json_console.print(json.dumps({"success": result})) + return result async def inspect( @@ -1209,6 +1465,7 @@ async def inspect( netuids_filter: list[int], all_wallets: bool = False, ): + # TODO add json_output when this is re-enabled and updated for dTAO def delegate_row_maker( delegates_: list[tuple[DelegateInfo, Balance]], ) -> Generator[list[str], None, None]: @@ -1384,14 +1641,18 @@ async def swap_hotkey( new_wallet: Wallet, subtensor: SubtensorInterface, prompt: bool, + json_output: bool, ): """Swap your hotkey for all registered axons on the network.""" - return await swap_hotkey_extrinsic( + result = await swap_hotkey_extrinsic( subtensor, original_wallet, new_wallet, prompt=prompt, ) + if json_output: + json_console.print(json.dumps({"success": result})) + return result def create_identity_table(title: str = None): @@ -1430,9 +1691,10 @@ async def set_id( additional: str, github_repo: str, prompt: bool, + json_output: bool = False, ): """Create a new or update existing identity on-chain.""" - + output_dict = {"success": False, "identity": None, "error": ""} identity_data = { "name": name.encode(), "url": web_url.encode(), @@ -1459,20 +1721,31 @@ async def set_id( if not success: err_console.print(f"[red]:cross_mark: Failed![/red] {err_msg}") + output_dict["error"] = err_msg + if json_output: + json_console.print(json.dumps(output_dict)) return - - console.print(":white_heavy_check_mark: [dark_sea_green3]Success!") - identity = await subtensor.query_identity(wallet.coldkeypub.ss58_address) + else: + console.print(":white_heavy_check_mark: [dark_sea_green3]Success!") + output_dict["success"] = True + identity = await subtensor.query_identity(wallet.coldkeypub.ss58_address) table = create_identity_table(title="New on-chain Identity") table.add_row("Address", wallet.coldkeypub.ss58_address) for key, value in identity.items(): table.add_row(key, str(value) if value else "~") - - return console.print(table) + output_dict["identity"] = identity + console.print(table) + if json_output: + json_console.print(json.dumps(output_dict)) -async def get_id(subtensor: SubtensorInterface, ss58_address: str, title: str = None): +async def get_id( + subtensor: SubtensorInterface, + ss58_address: str, + title: str = None, + json_output: bool = False, +): with console.status( ":satellite: [bold green]Querying chain identity...", spinner="earth" ): @@ -1484,6 +1757,8 @@ async def get_id(subtensor: SubtensorInterface, ss58_address: str, title: str = f" for [{COLOR_PALETTE['GENERAL']['COLDKEY']}]{ss58_address}[/{COLOR_PALETTE['GENERAL']['COLDKEY']}]" f" on {subtensor}" ) + if json_output: + json_console.print("{}") return {} table = create_identity_table(title) @@ -1492,6 +1767,8 @@ async def get_id(subtensor: SubtensorInterface, ss58_address: str, title: str = table.add_row(key, str(value) if value else "~") console.print(table) + if json_output: + json_console.print(json.dumps(identity)) return identity @@ -1532,7 +1809,9 @@ async def check_coldkey_swap(wallet: Wallet, subtensor: SubtensorInterface): ) -async def sign(wallet: Wallet, message: str, use_hotkey: str): +async def sign( + wallet: Wallet, message: str, use_hotkey: str, json_output: bool = False +): """Sign a message using the provided wallet or hotkey.""" if not use_hotkey: @@ -1552,6 +1831,8 @@ async def sign(wallet: Wallet, message: str, use_hotkey: str): signed_message = keypair.sign(message.encode("utf-8")).hex() console.print("[dark_sea_green3]Message signed successfully:") + if json_output: + json_console.print(json.dumps({"signed_message": signed_message})) console.print(signed_message) diff --git a/pyproject.toml b/pyproject.toml index 2652c869a..7dd501e3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ dependencies = [ "scalecodec==1.2.11", "typer~=0.12", "websockets>=14.1", - "bittensor-wallet>=3.0.4", + "bittensor-wallet>=3.0.5", "plotille>=5.0.0", "pywry>=0.6.2", "plotly>=6.0.0", diff --git a/tests/e2e_tests/test_senate.py b/tests/e2e_tests/test_senate.py index d2badc696..9a2955575 100644 --- a/tests/e2e_tests/test_senate.py +++ b/tests/e2e_tests/test_senate.py @@ -8,6 +8,8 @@ """ import asyncio +import json + from .utils import call_add_proposal @@ -112,12 +114,26 @@ def test_senate(local_chain, wallet_setup): # 0 Ayes for the proposal assert proposals_output[2] == "0" - # 0 Nayes for the proposal + # 0 Nays for the proposal assert proposals_output[4] == "0" # Assert initial threshold is 3 assert proposals_output[1] == "3" + json_proposals = exec_command_bob( + command="sudo", + sub_command="proposals", + extra_args=["--chain", "ws://127.0.0.1:9945", "--json-output"], + ) + json_proposals_output = json.loads(json_proposals.stdout) + + assert len(json_proposals_output) == 1 + assert json_proposals_output[0]["threshold"] == 3 + assert json_proposals_output[0]["ayes"] == 0 + assert json_proposals_output[0]["nays"] == 0 + assert json_proposals_output[0]["votes"] == {} + assert json_proposals_output[0]["call_data"] == "System.remark(remark: (0,))" + # Vote on the proposal by Bob (vote aye) vote_aye = exec_command_bob( command="sudo", @@ -161,6 +177,26 @@ def test_senate(local_chain, wallet_setup): # Nay votes remain 0 assert proposals_after_aye_output[4] == "0" + proposals_after_aye_json = exec_command_bob( + command="sudo", + sub_command="proposals", + extra_args=[ + "--chain", + "ws://127.0.0.1:9945", + "--json-output", + ], + ) + proposals_after_aye_json_output = json.loads(proposals_after_aye_json.stdout) + assert len(proposals_after_aye_json_output) == 1 + assert proposals_after_aye_json_output[0]["threshold"] == 3 + assert proposals_after_aye_json_output[0]["ayes"] == 1 + assert proposals_after_aye_json_output[0]["nays"] == 0 + assert len(proposals_after_aye_json_output[0]["votes"]) == 1 + assert proposals_after_aye_json_output[0]["votes"][keypair_bob.ss58_address] is True + assert ( + proposals_after_aye_json_output[0]["call_data"] == "System.remark(remark: (0,))" + ) + # Register Alice to the root network (0) # Registering to root automatically makes you a senator if eligible root_register = exec_command_alice( @@ -230,4 +266,20 @@ def test_senate(local_chain, wallet_setup): # Assert vote casted as Nay assert proposals_after_nay_output[10].split()[1] == "Nay" + proposals_after_nay_json = exec_command_bob( + command="sudo", + sub_command="proposals", + extra_args=[ + "--chain", + "ws://127.0.0.1:9945", + "--json-output", + ], + ) + proposals_after_nay_json_output = json.loads(proposals_after_nay_json.stdout) + assert len(proposals_after_nay_json_output) == 1 + assert proposals_after_nay_json_output[0]["nays"] == 1 + assert ( + proposals_after_nay_json_output[0]["votes"][keypair_alice.ss58_address] is False + ) + print("✅ Passed senate commands") diff --git a/tests/e2e_tests/test_staking_sudo.py b/tests/e2e_tests/test_staking_sudo.py index a3c5dae2e..ac82817d0 100644 --- a/tests/e2e_tests/test_staking_sudo.py +++ b/tests/e2e_tests/test_staking_sudo.py @@ -1,3 +1,4 @@ +import json import re from bittensor_cli.src.bittensor.balances import Balance @@ -129,8 +130,10 @@ def test_staking(local_chain, wallet_setup): wallet_alice.name, "--chain", "ws://127.0.0.1:9945", + "--verbose", ], ) + # Assert correct stake is added cleaned_stake = [ re.sub(r"\s+", " ", line) for line in show_stake.stdout.splitlines() @@ -138,6 +141,23 @@ def test_staking(local_chain, wallet_setup): stake_added = cleaned_stake[8].split("│")[3].strip().split()[0] assert Balance.from_tao(float(stake_added)) >= Balance.from_tao(90) + show_stake_json = exec_command_alice( + command="stake", + sub_command="list", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--chain", + "ws://127.0.0.1:9945", + "--json-output", + ], + ) + show_stake_json_output = json.loads(show_stake_json.stdout) + alice_stake = show_stake_json_output["stake_info"][keypair_alice.ss58_address][0] + assert Balance.from_tao(alice_stake["stake_value"]) > Balance.from_tao(90.0) + # Execute remove_stake command and remove all alpha stakes from Alice remove_stake = exec_command_alice( command="stake", @@ -182,7 +202,24 @@ def test_staking(local_chain, wallet_setup): max_burn_tao = all_hyperparams[22].split()[3] # Assert max_burn is 100 TAO from default - assert Balance.from_tao(float(max_burn_tao)) == Balance.from_tao(100) + assert Balance.from_tao(float(max_burn_tao)) == Balance.from_tao(100.0) + + hyperparams_json = exec_command_alice( + command="sudo", + sub_command="get", + extra_args=[ + "--chain", + "ws://127.0.0.1:9945", + "--netuid", + netuid, + "--json-output", + ], + ) + hyperparams_json_output = json.loads(hyperparams_json.stdout) + max_burn_tao_from_json = next( + filter(lambda x: x["hyperparameter"] == "max_burn", hyperparams_json_output) + )["value"] + assert Balance.from_rao(max_burn_tao_from_json) == Balance.from_tao(100.0) # Change max_burn hyperparameter to 10 TAO change_hyperparams = exec_command_alice( @@ -227,4 +264,24 @@ def test_staking(local_chain, wallet_setup): # Assert max_burn is now 10 TAO assert Balance.from_tao(float(updated_max_burn_tao)) == Balance.from_tao(10) + + updated_hyperparams_json = exec_command_alice( + command="sudo", + sub_command="get", + extra_args=[ + "--chain", + "ws://127.0.0.1:9945", + "--netuid", + netuid, + "--json-output", + ], + ) + updated_hyperparams_json_output = json.loads(updated_hyperparams_json.stdout) + max_burn_tao_from_json = next( + filter( + lambda x: x["hyperparameter"] == "max_burn", updated_hyperparams_json_output + ) + )["value"] + assert Balance.from_rao(max_burn_tao_from_json) == Balance.from_tao(10.0) + print("✅ Passed staking and sudo commands") diff --git a/tests/e2e_tests/test_unstaking.py b/tests/e2e_tests/test_unstaking.py index 96fd25587..c9f99796c 100644 --- a/tests/e2e_tests/test_unstaking.py +++ b/tests/e2e_tests/test_unstaking.py @@ -1,3 +1,4 @@ +import json import re from bittensor_cli.src.bittensor.balances import Balance @@ -217,6 +218,25 @@ def test_unstaking(local_chain, wallet_setup): float(inital_stake_netuid_2) ) + show_stake_json = exec_command_alice( + command="stake", + sub_command="list", + extra_args=[ + "--wallet-path", + wallet_path_bob, + "--wallet-name", + wallet_bob.name, + "--chain", + "ws://127.0.0.1:9945", + "--json-output", + ], + ) + show_stake_json_output = json.loads(show_stake_json.stdout) + bob_stake = show_stake_json_output["stake_info"][keypair_bob.ss58_address] + assert Balance.from_tao( + next(filter(lambda x: x["netuid"] == 2, bob_stake))["stake_value"] + ) <= Balance.from_tao(float(inital_stake_netuid_2)) + # Remove all alpha stakes unstake_alpha = exec_command_bob( command="stake", diff --git a/tests/e2e_tests/test_wallet_creations.py b/tests/e2e_tests/test_wallet_creations.py index ea0959985..5dd3bd63c 100644 --- a/tests/e2e_tests/test_wallet_creations.py +++ b/tests/e2e_tests/test_wallet_creations.py @@ -1,8 +1,11 @@ +import json import os import re import time from typing import Dict, Optional, Tuple +from bittensor_wallet import Wallet + """ Verify commands: @@ -188,13 +191,22 @@ def test_wallet_creations(wallet_setup): ) # Assert default keys are present before proceeding - f"default ss58_address {wallet.coldkeypub.ss58_address}" in result.stdout - f"default ss58_address {wallet.hotkey.ss58_address}" in result.stdout + assert f"default ss58_address {wallet.coldkeypub.ss58_address}" in result.stdout + assert f"default ss58_address {wallet.hotkey.ss58_address}" in result.stdout wallet_status, message = verify_wallet_dir( wallet_path, "default", hotkey_name="default" ) assert wallet_status, message + json_result = exec_command( + command="wallet", + sub_command="list", + extra_args=["--wallet-path", wallet_path, "--json-output"], + ) + json_wallet = json.loads(json_result.stdout)["wallets"][0] + assert json_wallet["ss58_address"] == wallet.coldkey.ss58_address + assert json_wallet["hotkeys"][0]["ss58_address"] == wallet.hotkey.ss58_address + # ----------------------------- # Command 1: # ----------------------------- @@ -267,6 +279,27 @@ def test_wallet_creations(wallet_setup): wallet_status, message = verify_wallet_dir(wallet_path, "new_coldkey") assert wallet_status, message + json_creation = exec_command( + "wallet", + "new-coldkey", + extra_args=[ + "--wallet-name", + "new_json_coldkey", + "--wallet-path", + wallet_path, + "--n-words", + "12", + "--no-use-password", + "--json-output", + ], + ) + json_creation_output = json.loads(json_creation.stdout) + assert json_creation_output["success"] is True + assert json_creation_output["data"]["name"] == "new_json_coldkey" + assert "coldkey_ss58" in json_creation_output["data"] + assert json_creation_output["error"] == "" + new_json_coldkey_ss58 = json_creation_output["data"]["coldkey_ss58"] + # ----------------------------- # Command 3: # ----------------------------- @@ -303,6 +336,29 @@ def test_wallet_creations(wallet_setup): ) assert wallet_status, message + new_hotkey_json = exec_command( + "wallet", + sub_command="new-hotkey", + extra_args=[ + "--wallet-name", + "new_json_coldkey", + "--hotkey", + "new_json_hotkey", + "--wallet-path", + wallet_path, + "--n-words", + "12", + "--no-use-password", + "--json-output", + ], + ) + new_hotkey_json_output = json.loads(new_hotkey_json.stdout) + assert new_hotkey_json_output["success"] is True + assert new_hotkey_json_output["data"]["name"] == "new_json_coldkey" + assert new_hotkey_json_output["data"]["hotkey"] == "new_json_hotkey" + assert new_hotkey_json_output["data"]["coldkey_ss58"] == new_json_coldkey_ss58 + assert new_hotkey_json_output["error"] == "" + def test_wallet_regen(wallet_setup, capfd): """ @@ -358,6 +414,9 @@ def test_wallet_regen(wallet_setup, capfd): # ----------------------------- print("Testing wallet regen_coldkey command 🧪") coldkey_path = os.path.join(wallet_path, "new_wallet", "coldkey") + initial_coldkey_ss58 = Wallet( + name="new_wallet", path=wallet_path + ).coldkey.ss58_address initial_coldkey_mod_time = os.path.getmtime(coldkey_path) result = exec_command( @@ -385,7 +444,28 @@ def test_wallet_regen(wallet_setup, capfd): assert ( initial_coldkey_mod_time != new_coldkey_mod_time ), "Coldkey file was not regenerated as expected" - print("Passed wallet regen_coldkey command ✅") + json_result = exec_command( + command="wallet", + sub_command="regen-coldkey", + extra_args=[ + "--wallet-name", + "new_wallet", + "--hotkey", + "new_hotkey", + "--wallet-path", + wallet_path, + "--mnemonic", + mnemonics["coldkey"], + "--no-use-password", + "--overwrite", + "--json-output", + ], + ) + + json_result_out = json.loads(json_result.stdout) + assert json_result_out["success"] is True + assert json_result_out["data"]["name"] == "new_wallet" + assert json_result_out["data"]["coldkey_ss58"] == initial_coldkey_ss58 # ----------------------------- # Command 2: @@ -517,4 +597,15 @@ def test_wallet_balance_all(local_chain, wallet_setup, capfd): wallet_name in output ), f"Wallet {wallet_name} not found in balance --all output" + json_results = exec_command( + "wallet", + "balance", + extra_args=["--wallet-path", wallet_path, "--all", "--json-output"], + ) + json_results_output = json.loads(json_results.stdout) + for wallet_name in wallet_names: + assert wallet_name in json_results_output["balances"].keys() + assert json_results_output["balances"][wallet_name]["total"] == 0.0 + assert "coldkey" in json_results_output["balances"][wallet_name] + print("Passed wallet balance --all command with 100 wallets ✅")