diff --git a/.github/workflows/e2e-subtensor-tests.yml b/.github/workflows/e2e-subtensor-tests.yml index 6b0db3f26..6c71b18bb 100644 --- a/.github/workflows/e2e-subtensor-tests.yml +++ b/.github/workflows/e2e-subtensor-tests.yml @@ -27,7 +27,7 @@ jobs: find-tests: runs-on: ubuntu-latest - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} + if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && !startsWith(github.head_ref, 'changelog/')) }} outputs: test-files: ${{ steps.get-tests.outputs.test-files }} steps: @@ -43,6 +43,7 @@ jobs: pull-docker-image: runs-on: ubuntu-latest + if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && !startsWith(github.head_ref, 'changelog/')) }} steps: - name: Log in to GitHub Container Registry run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u $GITHUB_ACTOR --password-stdin @@ -73,7 +74,7 @@ jobs: os: - ubuntu-latest test-file: ${{ fromJson(needs.find-tests.outputs.test-files) }} - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] steps: - name: Check-out repository uses: actions/checkout@v4 @@ -83,12 +84,6 @@ jobs: with: python-version: 3.13 - - name: install dependencies - run: | - uv venv .venv - source .venv/bin/activate - uv pip install .[dev] - - name: Download Cached Docker Image uses: actions/download-artifact@v4 with: @@ -99,5 +94,4 @@ jobs: - name: Run tests run: | - source .venv/bin/activate uv run pytest ${{ matrix.test-file }} -s diff --git a/CHANGELOG.md b/CHANGELOG.md index b0200574a..2447096f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,175 @@ # Changelog +## 9.15.3 /2025-11-17 + +## What's Changed +* fix missing classifiers, migrate to flit by @branchvincent in https://github.com/opentensor/btcli/pull/698 +* Removes subvortex as the project is gone. by @thewhaleking in https://github.com/opentensor/btcli/pull/708 +* Small Bug Fixes by @thewhaleking in https://github.com/opentensor/btcli/pull/712: + * Ensures that net_claimable is always a Balance object in get_claimable_stakes_for_coldkey + * Checks that the HK owner isn't the genesis address + * Clearly show the totals in st list as representing staked vs total incl free + * Prevents a race condition which could freeze the console for extrinsic submission +* docs: fix typos in some files by @Edge-Seven in https://github.com/opentensor/btcli/pull/711 +* Fix table width visual bug in stake move command by @BANADDA in https://github.com/opentensor/btcli/pull/709 +* Fix: Decode account id in `btcli swap-check` by @ibraheem-abe in https://github.com/opentensor/btcli/pull/714 + +## New Contributors +* @branchvincent made their first contribution in https://github.com/opentensor/btcli/pull/698 +* @Edge-Seven made their first contribution in https://github.com/opentensor/btcli/pull/711 +* @BANADDA made their first contribution in https://github.com/opentensor/btcli/pull/709 + +**Full Changelog**: https://github.com/opentensor/btcli/compare/v9.15.2...v9.15.3 + +## 9.15.2 /2025-11-05 + +## What's Changed +* Update/btcli stake claim args by @ibraheem-abe in https://github.com/opentensor/btcli/pull/701 +* Update metagraph symbols thru subnet info by @ibraheem-abe in https://github.com/opentensor/btcli/pull/703 + +**Full Changelog**: https://github.com/opentensor/btcli/compare/v9.15.1...v9.15.2 + +## 9.15.1 /2025-11-04 + +* Update/Subnet list ema by @ibraheem-abe in https://github.com/opentensor/btcli/pull/699 + +**Full Changelog**: https://github.com/opentensor/btcli/compare/v9.15.0...v9.15.1 + +## 9.15.0 /2025-11-04 + +* Stop running e2e tests on changelog branches by @thewhaleking in https://github.com/opentensor/btcli/pull/691 +* Feat/root claim by @ibraheem-abe in https://github.com/opentensor/btcli/pull/692 + +**Full Changelog**: https://github.com/opentensor/btcli/compare/v9.14.3...v9.15.0 + +## 9.14.3 /2025-10-30 +* Allows for installing on Py 3.14 by @thewhaleking in https://github.com/opentensor/btcli/pull/688 + * corrects `--name` param in `wallet set-identity` and `subnets set-identity` which was a duplicate param alias of `--wallet-name` + +**Full Changelog**: https://github.com/opentensor/btcli/compare/v9.14.2...v9.14.3 + +## 9.14.2 /2025-10-28 +* `stake remove --all` fails when unsuccessful by @thewhaleking in https://github.com/opentensor/btcli/pull/679 +* check subnet logo url by @thewhaleking in https://github.com/opentensor/btcli/pull/681 +* `st transfer` extrinsic id fix by @thewhaleking in https://github.com/opentensor/btcli/pull/685 + +**Full Changelog**: https://github.com/opentensor/btcli/compare/v9.14.1...v9.14.2 + +## 9.14.1 /2025-10-23 +* Updates kappa to root sudo only in-line with devnet-ready by @thewhaleking in https://github.com/opentensor/btcli/pull/668 +* Adds additional warnings for move vs transfer by @thewhaleking in https://github.com/opentensor/btcli/pull/672 +* Childkey take was incorrectly labeled. by @thewhaleking in https://github.com/opentensor/btcli/pull/669 +* Updates the help text of crownloan refund by @thewhaleking in https://github.com/opentensor/btcli/pull/674 +* Add a warn flag when --netuid 0 is used for btcli hotkey swap by @nstankov-stkd in https://github.com/opentensor/btcli/pull/666 + * Add warning and confirmation for `wallet swap_hotkey --netuid 0` to prevent accidental misuse. Using `--netuid 0` only swaps the hotkey on the root network (netuid 0) and does NOT move child hotkey delegation mappings. This is not a full swap across all subnets. Updated documentation and added comprehensive unit tests to clarify proper usage. +* Edge case bug fixes by @thewhaleking in https://github.com/opentensor/btcli/pull/675 +* Adds wallet balance sorting by @thewhaleking in https://github.com/opentensor/btcli/pull/676 + +## New Contributors +* @nstankov-stkd made their first contribution in https://github.com/opentensor/btcli/pull/666 + +**Full Changelog**: https://github.com/opentensor/btcli/compare/v9.14.0...v9.14.1 + +## 9.14.0 /2025-10-20 +* Skips senate tests by @thewhaleking in https://github.com/opentensor/btcli/pull/658 +* Feat/crowdloans by @ibraheem-abe in https://github.com/opentensor/btcli/pull/657 +* Removes the fetching of identities in GitHub by @thewhaleking in https://github.com/opentensor/btcli/pull/659 +* Fixes the double `--hotkey` arg in set childkey take by @thewhaleking in https://github.com/opentensor/btcli/pull/662 +* Updates the help text for coldkey regen by @thewhaleking in https://github.com/opentensor/btcli/pull/663 + +**Full Changelog**: https://github.com/opentensor/btcli/compare/v9.13.1...v9.14.0 + +# 9.13.1 /2025-10-14 +* Fix for complicated (user_liquidity_enabled) hyperparams by @thewhaleking in https://github.com/opentensor/btcli/pull/652 +* Fixes a number of type annotations by @thewhaleking in https://github.com/opentensor/btcli/pull/653 + +**Full Changelog**: https://github.com/opentensor/btcli/compare/v9.13.0...v9.13.1 + +## 9.13.0 /2025-10-09 + +## What's Changed +* Updates wording in transfer confirm by @thewhaleking in https://github.com/opentensor/btcli/pull/642 +* Update/split emissions by @ibraheem-abe in https://github.com/opentensor/btcli/pull/643 +* Feat/auto staking by @ibraheem-abe in https://github.com/opentensor/btcli/pull/632 +* Updates wording on origin/destination hotkey in `st move` by @thewhaleking in https://github.com/opentensor/btcli/pull/647 +* commands list fixes by @thewhaleking in https://github.com/opentensor/btcli/pull/648 + +**Full Changelog**: https://github.com/opentensor/btcli/compare/v9.12.0...v9.13.0 + +## 9.12.0 /2025-09-25 +* Removes warning icon in transfer by @ibraheem-abe in https://github.com/opentensor/btcli/pull/634 +* Add Extrinsic Identifier Output by @thewhaleking in https://github.com/opentensor/btcli/pull/633 +* Update the example text for sudo trim by @thewhaleking in https://github.com/opentensor/btcli/pull/636 +* Feat/Individual wallet list by @ibraheem-abe in https://github.com/opentensor/btcli/pull/638 +* Feat/ subnet mechanisms by @ibraheem-abe in https://github.com/opentensor/btcli/pull/627 + +**Full Changelog**: https://github.com/opentensor/btcli/compare/v9.11.2...v9.12.0 + +## 9.11.2 /2025-09-19 +* Fix: Stake movement between non-root sns by @ibraheem-abe in https://github.com/opentensor/btcli/pull/629 + +**Full Changelog**: https://github.com/opentensor/btcli/compare/v9.11.1...v9.11.2 + +## 9.11.1 /2025-09-16 + +* Transfer not staking warning by @thewhaleking in https://github.com/opentensor/btcli/pull/618 +* update e2e tests for hyperparam freeze window by @thewhaleking in https://github.com/opentensor/btcli/pull/620 +* Corrects the stake fee calculation by @thewhaleking in https://github.com/opentensor/btcli/pull/621 +* Fix: Handle encrypted wallet hotkeys by @ibraheem-abe in https://github.com/opentensor/btcli/pull/622 +* Fix: Handle malformed wallets/files by @ibraheem-abe in https://github.com/opentensor/btcli/pull/623 +* `min_burn` now not root sudo only by @thewhaleking in https://github.com/opentensor/btcli/pull/624 + +**Full Changelog**: https://github.com/opentensor/btcli/compare/v9.11.0...v9.11.1 + +## 9.11.0 /2025-09-05 +* Better arg naming + type annotations by @thewhaleking in https://github.com/opentensor/btcli/pull/590 +* disk cache in config by @thewhaleking in https://github.com/opentensor/btcli/pull/588 +* Unstake no prompts by @thewhaleking in https://github.com/opentensor/btcli/pull/591 +* expand readme by @thewhaleking in https://github.com/opentensor/btcli/pull/598 +* Better arg formatting for readability by @thewhaleking in https://github.com/opentensor/btcli/pull/592 +* Update childkey proportion CLI argument in docs by @HudsonGraeme in https://github.com/opentensor/btcli/pull/602 +* Update example for show command by @HudsonGraeme in https://github.com/opentensor/btcli/pull/604 +* New cmd: `btcli utils latency` by @thewhaleking in https://github.com/opentensor/btcli/pull/599 +* Fix: Swap hotkey - new hotkey reg check by @ibraheem-abe in https://github.com/opentensor/btcli/pull/608 +* Update cli.py to remove double negative typo by @calebcgates in https://github.com/opentensor/btcli/pull/606 +* Better shows hotkeypubs in `w list` by @thewhaleking in https://github.com/opentensor/btcli/pull/611 +* subnet symbol set command by @thewhaleking in https://github.com/opentensor/btcli/pull/613 +* Debug log by @thewhaleking in https://github.com/opentensor/btcli/pull/597 +* Debug log additional by @thewhaleking in https://github.com/opentensor/btcli/pull/615 + +### New Contributors +* @calebcgates made their first contribution in https://github.com/opentensor/btcli/pull/606 + +**Full Changelog**: https://github.com/opentensor/btcli/compare/v9.10.1...v9.11.0 + +## 9.10.2 /2025-09-05 +* Fixes swap-hotkey in by @ibraheem-abe in https://github.com/opentensor/btcli/commit/aec630ec06fb679957c1c2940f37a28751fd427f + +**Full Changelog**: https://github.com/opentensor/btcli/compare/v9.10.1...v9.10.2 + +## 9.10.1 /2025-08-12 +* Removes double param for `--cache` in `config set` by @thewhaleking in https://github.com/opentensor/btcli/pull/579 +* change root only sudo hyperparams by @thewhaleking in https://github.com/opentensor/btcli/pull/580 +* Better error formatting by @thewhaleking in https://github.com/opentensor/btcli/pull/581 +* Handle optional netuid better by @thewhaleking in https://github.com/opentensor/btcli/pull/582 +* wallet fixes by @thewhaleking in https://github.com/opentensor/btcli/pull/585 +* Adds `moving_price` attr to DynamicInfo by @thewhaleking in https://github.com/opentensor/btcli/pull/583 + +**Full Changelog**: https://github.com/opentensor/btcli/compare/v9.10.0...v9.10.1 + +## 9.10.0 /2025-08-06 +* Sets default interval hours for subnets price to 4, bc of rate limiting. by @thewhaleking in https://github.com/opentensor/btcli/pull/568 +* Subnets Price --current + improvements by @thewhaleking in https://github.com/opentensor/btcli/pull/569 +* Reconfig Asyncio Runner by @thewhaleking in https://github.com/opentensor/btcli/pull/570 +* Show amount on `transfer --all` by @thewhaleking in https://github.com/opentensor/btcli/pull/571 +* Allows for typer>=0.16 and Click 8.2+ by @thewhaleking in https://github.com/opentensor/btcli/pull/572 +* BTCLI Config Updates by @thewhaleking in https://github.com/opentensor/btcli/pull/573 +* Added info about preinstalled macOS CPython by @thewhaleking in https://github.com/opentensor/btcli/pull/574 +* Click 8.2+/- compatibility by @thewhaleking in https://github.com/opentensor/btcli/pull/576 +* New command: `btcli w regen-hotkeypub` by @thewhaleking in https://github.com/opentensor/btcli/pull/575 + +**Full Changelog**: https://github.com/opentensor/btcli/compare/v9.9.0...v9.10.0 + ## 9.9.0 /2025-07-28 * Feat/wallet verify by @ibraheem-abe in https://github.com/opentensor/btcli/pull/561 * Improved speed of query_all_identities and fetch_coldkey_hotkey_identities by @thewhaleking in https://github.com/opentensor/btcli/pull/560 diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 867b5bcef..000000000 --- a/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -recursive-include bittensor_cli/src/bittensor/templates * diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index a11b2ed18..73e77736d 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -4,6 +4,7 @@ import curses import importlib import json +import logging import os.path import re import ssl @@ -12,7 +13,7 @@ import warnings from dataclasses import fields from pathlib import Path -from typing import Coroutine, Optional, Union +from typing import Coroutine, Optional, Union, Literal import numpy as np import rich @@ -38,11 +39,15 @@ Constants, COLORS, HYPERPARAMS, + WalletOptions, ) from bittensor_cli.src.bittensor import utils from bittensor_cli.src.bittensor.balances import Balance from bittensor_cli.src.bittensor.chain_data import SubnetHyperparameters -from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface +from bittensor_cli.src.bittensor.subtensor_interface import ( + SubtensorInterface, + best_connection, +) from bittensor_cli.src.bittensor.utils import ( console, err_console, @@ -58,22 +63,38 @@ validate_uri, prompt_for_subnet_identity, validate_rate_tolerance, + get_hotkey_pub_ss58, ) from bittensor_cli.src.commands import sudo, wallets, view from bittensor_cli.src.commands import weights as weights_cmds from bittensor_cli.src.commands.liquidity import liquidity +from bittensor_cli.src.commands.crowd import ( + contribute as crowd_contribute, + create as create_crowdloan, + dissolve as crowd_dissolve, + view as view_crowdloan, + update as crowd_update, + refund as crowd_refund, +) from bittensor_cli.src.commands.liquidity.utils import ( prompt_liquidity, prompt_position_id, ) from bittensor_cli.src.commands.stake import ( + auto_staking as auto_stake, children_hotkeys, list as list_stake, move as move_stake, add as add_stake, remove as remove_stake, + claim as claim_stake, ) -from bittensor_cli.src.commands.subnets import price, subnets +from bittensor_cli.src.commands.subnets import ( + price, + subnets, + mechanisms as subnet_mechanisms, +) +from bittensor_cli.src.commands.wallets import SortByBalance from bittensor_cli.version import __version__, __version_as_int__ try: @@ -85,11 +106,19 @@ class GitError(Exception): pass +logger = logging.getLogger("btcli") _epilog = "Made with [bold red]:heart:[/bold red] by The Openτensor Foundaτion" np.set_printoptions(precision=8, suppress=True, floatmode="fixed") +def arg__(arg_name: str) -> str: + """ + Helper function to 'arg' format a string for rich console + """ + return f"[{COLORS.G.ARG}]{arg_name}[/{COLORS.G.ARG}]" + + class Options: """ Re-usable typer args @@ -216,6 +245,15 @@ def edit_help(cls, option_name: str, help_text: str): help="The netuid of the subnet in the network, (e.g. 1).", prompt=False, ) + mechanism_id = typer.Option( + None, + "--mechid", + "--mech-id", + "--mech_id", + "--mechanism_id", + "--mechanism-id", + help="Mechanism ID within the subnet (defaults to 0).", + ) all_netuids = typer.Option( False, help="Use all netuids", @@ -250,7 +288,7 @@ def edit_help(cls, option_name: str, help_text: str): True, "--prompt/--no-prompt", " /--yes", - "--prompt/--no_prompt", + " /--no_prompt", " /-y", help="Enable or disable interactive prompts.", ) @@ -409,10 +447,15 @@ def get_optional_netuid(netuid: Optional[int], all_netuids: bool) -> Optional[in ) if answer is None: return None + answer = answer.strip() if answer.lower() == "all": return None else: - return int(answer) + try: + return int(answer) + except ValueError: + err_console.print(f"Invalid netuid: {answer}") + return get_optional_netuid(None, False) else: return netuid @@ -589,6 +632,43 @@ def commands_callback(value: bool): raise typer.Exit() +def debug_callback(value: bool): + if value: + debug_file_loc = Path( + os.getenv("BTCLI_DEBUG_FILE") + or os.path.expanduser(defaults.config.debug_file_path) + ) + if not debug_file_loc.exists(): + err_console.print( + f"[red]Error: The debug file '{arg__(str(debug_file_loc))}' does not exist. This indicates that you have" + f" not run a command which has logged debug output, or you deleted this file. Debug logging only occurs" + f" if {arg__('use_cache')} is set to True in your config ({arg__('btcli config set')}). If the debug " + f"file was created using the {arg__('BTCLI_DEBUG_FILE')} environment variable, please set the value for" + f" the same location, and re-run this {arg__('btcli --debug')} command.[/red]" + ) + raise typer.Exit() + save_file_loc_ = Prompt.ask( + "Enter the file location to save the debug log for the previous command.", + default="~/.bittensor/debug-export", + ).strip() + save_file_loc = Path(os.path.expanduser(save_file_loc_)) + if not save_file_loc.parent.exists(): + if Confirm.ask( + f"The directory '{save_file_loc.parent}' does not exist. Would you like to create it?" + ): + save_file_loc.parent.mkdir(parents=True, exist_ok=True) + try: + with ( + open(save_file_loc, "w+") as save_file, + open(debug_file_loc, "r") as current_file, + ): + save_file.write(current_file.read()) + console.print(f"Saved debug log to {save_file_loc}") + except FileNotFoundError as e: + print_error(str(e)) + raise typer.Exit() + + class CLIManager: """ :var app: the main CLI Typer app @@ -596,6 +676,7 @@ class CLIManager: :var wallet_app: the Typer app as it relates to wallet commands :var stake_app: the Typer app as it relates to stake commands :var sudo_app: the Typer app as it relates to sudo commands + :var subnet_mechanisms_app: the Typer app for subnet mechanism commands :var subnets_app: the Typer app as it relates to subnets commands :var subtensor: the `SubtensorInterface` object passed to the various commands that require it """ @@ -604,9 +685,12 @@ class CLIManager: app: typer.Typer config_app: typer.Typer wallet_app: typer.Typer + sudo_app: typer.Typer subnets_app: typer.Typer + subnet_mechanisms_app: typer.Typer weights_app: typer.Typer - utils_app = typer.Typer(epilog=_epilog) + crowd_app: typer.Typer + utils_app: typer.Typer view_app: typer.Typer asyncio_runner = asyncio @@ -617,6 +701,7 @@ def __init__(self): "wallet_hotkey": None, "network": None, "use_cache": True, + "disk_cache": False, "rate_tolerance": None, "safe_staking": True, "allow_partial_stake": False, @@ -643,8 +728,24 @@ def __init__(self): # }, } self.subtensor = None + + if sys.version_info < (3, 10): + # For Python 3.9 or lower + self.event_loop = asyncio.new_event_loop() + else: + try: + uvloop = importlib.import_module("uvloop") + self.event_loop = uvloop.new_event_loop() + except ModuleNotFoundError: + self.event_loop = asyncio.new_event_loop() + self.config_base_path = os.path.expanduser(defaults.config.base_path) - self.config_path = os.path.expanduser(defaults.config.path) + self.config_path = os.getenv("BTCLI_CONFIG_PATH") or os.path.expanduser( + defaults.config.path + ) + self.debug_file_path = os.getenv("BTCLI_DEBUG_FILE") or os.path.expanduser( + defaults.config.debug_file_path + ) self.app = typer.Typer( rich_markup_mode="rich", @@ -652,14 +753,22 @@ def __init__(self): epilog=_epilog, no_args_is_help=True, ) - self.config_app = typer.Typer(epilog=_epilog) + self.config_app = typer.Typer( + epilog=_epilog, + help=f"Allows for getting/setting the config. " + f"Default path for the config file is {arg__(defaults.config.path)}. " + f"You can set your own with the env var {arg__('BTCLI_CONFIG_PATH')}", + ) self.wallet_app = typer.Typer(epilog=_epilog) self.stake_app = typer.Typer(epilog=_epilog) self.sudo_app = typer.Typer(epilog=_epilog) self.subnets_app = typer.Typer(epilog=_epilog) + self.subnet_mechanisms_app = typer.Typer(epilog=_epilog) self.weights_app = typer.Typer(epilog=_epilog) self.view_app = typer.Typer(epilog=_epilog) self.liquidity_app = typer.Typer(epilog=_epilog) + self.crowd_app = typer.Typer(epilog=_epilog) + self.utils_app = typer.Typer(epilog=_epilog) # config alias self.app.add_typer( @@ -717,6 +826,19 @@ def __init__(self): self.subnets_app, name="subnet", hidden=True, no_args_is_help=True ) + # subnet mechanisms aliases + self.subnets_app.add_typer( + self.subnet_mechanisms_app, + name="mechanisms", + short_help="Subnet mechanism commands, alias: `mech`", + no_args_is_help=True, + ) + self.subnets_app.add_typer( + self.subnet_mechanisms_app, + name="mech", + hidden=True, + no_args_is_help=True, + ) # weights aliases self.app.add_typer( self.weights_app, @@ -734,7 +856,7 @@ def __init__(self): # utils app self.app.add_typer( - self.utils_app, name="utils", no_args_is_help=True, hidden=True + self.utils_app, name="utils", no_args_is_help=True, hidden=False ) # view app @@ -773,6 +895,9 @@ def __init__(self): self.wallet_app.command( "regen-hotkey", rich_help_panel=HELP_PANELS["WALLET"]["SECURITY"] )(self.wallet_regen_hotkey) + self.wallet_app.command( + "regen-hotkeypub", rich_help_panel=HELP_PANELS["WALLET"]["SECURITY"] + )(self.wallet_regen_hotkey_pub) self.wallet_app.command( "new-hotkey", rich_help_panel=HELP_PANELS["WALLET"]["MANAGEMENT"] )(self.wallet_new_hotkey) @@ -825,6 +950,12 @@ def __init__(self): self.stake_app.command( "add", rich_help_panel=HELP_PANELS["STAKE"]["STAKE_MGMT"] )(self.stake_add) + self.stake_app.command( + "auto", rich_help_panel=HELP_PANELS["STAKE"]["STAKE_MGMT"] + )(self.get_auto_stake) + self.stake_app.command( + "set-auto", rich_help_panel=HELP_PANELS["STAKE"]["STAKE_MGMT"] + )(self.set_auto_stake) self.stake_app.command( "remove", rich_help_panel=HELP_PANELS["STAKE"]["STAKE_MGMT"] )(self.stake_remove) @@ -840,6 +971,12 @@ def __init__(self): self.stake_app.command( "swap", rich_help_panel=HELP_PANELS["STAKE"]["MOVEMENT"] )(self.stake_swap) + self.stake_app.command( + "set-claim", rich_help_panel=HELP_PANELS["STAKE"]["CLAIM"] + )(self.stake_set_claim_type) + self.stake_app.command( + "process-claim", rich_help_panel=HELP_PANELS["STAKE"]["CLAIM"] + )(self.stake_process_claim) # stake-children commands children_app = typer.Typer() @@ -858,6 +995,20 @@ def __init__(self): children_app.command("revoke")(self.stake_revoke_children) children_app.command("take")(self.stake_childkey_take) + # subnet mechanism commands + self.subnet_mechanisms_app.command( + "count", rich_help_panel=HELP_PANELS["MECHANISMS"]["CONFIG"] + )(self.mechanism_count_get) + self.subnet_mechanisms_app.command( + "set", rich_help_panel=HELP_PANELS["MECHANISMS"]["CONFIG"] + )(self.mechanism_count_set) + self.subnet_mechanisms_app.command( + "emissions", rich_help_panel=HELP_PANELS["MECHANISMS"]["EMISSION"] + )(self.mechanism_emission_get) + self.subnet_mechanisms_app.command( + "split-emissions", rich_help_panel=HELP_PANELS["MECHANISMS"]["EMISSION"] + )(self.mechanism_emission_set) + # sudo commands self.sudo_app.command("set", rich_help_panel=HELP_PANELS["SUDO"]["CONFIG"])( self.sudo_set @@ -880,6 +1031,9 @@ def __init__(self): self.sudo_app.command("get-take", rich_help_panel=HELP_PANELS["SUDO"]["TAKE"])( self.sudo_get_take ) + self.sudo_app.command("trim", rich_help_panel=HELP_PANELS["SUDO"]["CONFIG"])( + self.sudo_trim + ) # subnets commands self.subnets_app.command( @@ -921,6 +1075,9 @@ def __init__(self): self.subnets_app.command( "check-start", rich_help_panel=HELP_PANELS["SUBNETS"]["INFO"] )(self.subnets_check_start) + self.subnets_app.command( + "set-symbol", rich_help_panel=HELP_PANELS["SUBNETS"]["IDENTITY"] + )(self.subnets_set_symbol) # weights commands self.weights_app.command( @@ -955,6 +1112,10 @@ def __init__(self): "regen_hotkey", hidden=True, )(self.wallet_regen_hotkey) + self.wallet_app.command( + "regen_hotkeypub", + hidden=True, + )(self.wallet_regen_hotkey_pub) self.wallet_app.command( "new_hotkey", hidden=True, @@ -971,7 +1132,9 @@ def __init__(self): "get_identity", hidden=True, )(self.wallet_get_id) - self.wallet_app.command("associate_hotkey")(self.wallet_associate_hotkey) + self.wallet_app.command("associate_hotkey", hidden=True)( + self.wallet_associate_hotkey + ) # Subnets self.subnets_app.command("burn_cost", hidden=True)(self.subnets_burn_cost) @@ -979,12 +1142,64 @@ def __init__(self): self.subnets_app.command("set_identity", hidden=True)(self.subnets_set_identity) self.subnets_app.command("get_identity", hidden=True)(self.subnets_get_identity) self.subnets_app.command("check_start", hidden=True)(self.subnets_check_start) + self.subnet_mechanisms_app.command("emissions-split", hidden=True)( + self.mechanism_emission_set + ) # Sudo self.sudo_app.command("senate_vote", hidden=True)(self.sudo_senate_vote) self.sudo_app.command("get_take", hidden=True)(self.sudo_get_take) self.sudo_app.command("set_take", hidden=True)(self.sudo_set_take) + # Stake + self.stake_app.command( + "claim", + hidden=True, + )(self.stake_set_claim_type) + self.stake_app.command( + "unclaim", + hidden=True, + )(self.stake_set_claim_type) + + # Crowdloan + self.app.add_typer( + self.crowd_app, + name="crowd", + short_help="Crowdloan commands, aliases: `cr`, `crowdloan`", + no_args_is_help=True, + ) + self.app.add_typer(self.crowd_app, name="cr", hidden=True, no_args_is_help=True) + self.app.add_typer( + self.crowd_app, name="crowdloan", hidden=True, no_args_is_help=True + ) + self.crowd_app.command( + "contribute", rich_help_panel=HELP_PANELS["CROWD"]["PARTICIPANT"] + )(self.crowd_contribute) + self.crowd_app.command( + "withdraw", rich_help_panel=HELP_PANELS["CROWD"]["PARTICIPANT"] + )(self.crowd_withdraw) + self.crowd_app.command( + "finalize", rich_help_panel=HELP_PANELS["CROWD"]["INITIATOR"] + )(self.crowd_finalize) + self.crowd_app.command("list", rich_help_panel=HELP_PANELS["CROWD"]["INFO"])( + self.crowd_list + ) + self.crowd_app.command("info", rich_help_panel=HELP_PANELS["CROWD"]["INFO"])( + self.crowd_info + ) + self.crowd_app.command( + "create", rich_help_panel=HELP_PANELS["CROWD"]["INITIATOR"] + )(self.crowd_create) + self.crowd_app.command( + "update", rich_help_panel=HELP_PANELS["CROWD"]["INITIATOR"] + )(self.crowd_update) + self.crowd_app.command( + "refund", rich_help_panel=HELP_PANELS["CROWD"]["INITIATOR"] + )(self.crowd_refund) + self.crowd_app.command( + "dissolve", rich_help_panel=HELP_PANELS["CROWD"]["INITIATOR"] + )(self.crowd_dissolve) + # Liquidity self.app.add_typer( self.liquidity_app, @@ -1009,6 +1224,10 @@ def __init__(self): "remove", rich_help_panel=HELP_PANELS["LIQUIDITY"]["LIQUIDITY_MGMT"] )(self.liquidity_remove) + # utils app + self.utils_app.command("convert")(self.convert) + self.utils_app.command("latency")(self.best_connection) + def generate_command_tree(self) -> Tree: """ Generates a rich.Tree of the commands, subcommands, and groups of this app @@ -1061,6 +1280,7 @@ def initialize_chain( "Verify this is intended.", ) if not self.subtensor: + use_disk_cache = self.config.get("disk_cache", False) if network: network_ = None for item in network: @@ -1074,18 +1294,24 @@ def initialize_chain( if not_selected_networks: console.print( f"Networks not selected: " - f"[{COLORS.G.ARG}]{', '.join(not_selected_networks)}[/{COLORS.G.ARG}]" + f"{arg__(', '.join(not_selected_networks))}" ) - self.subtensor = SubtensorInterface(network_) + self.subtensor = SubtensorInterface( + network_, use_disk_cache=use_disk_cache + ) elif self.config["network"]: - self.subtensor = SubtensorInterface(self.config["network"]) console.print( f"Using the specified network [{COLORS.G.LINKS}]{self.config['network']}" f"[/{COLORS.G.LINKS}] from config" ) + self.subtensor = SubtensorInterface( + self.config["network"], use_disk_cache=use_disk_cache + ) else: - self.subtensor = SubtensorInterface(defaults.subtensor.network) + self.subtensor = SubtensorInterface( + defaults.subtensor.network, use_disk_cache=use_disk_cache + ) return self.subtensor def _run_command(self, cmd: Coroutine, exit_early: bool = True): @@ -1095,18 +1321,17 @@ def _run_command(self, cmd: Coroutine, exit_early: bool = True): async def _run(): initiated = False + exception_occurred = False try: if self.subtensor: - async with self.subtensor: - initiated = True - result = await cmd - else: - initiated = True - result = await cmd + await self.subtensor.substrate.initialize() + initiated = True + result = await cmd return result except (ConnectionRefusedError, ssl.SSLError, InvalidHandshake): err_console.print(f"Unable to connect to the chain: {self.subtensor}") verbose_console.print(traceback.format_exc()) + exception_occurred = True except ( ConnectionClosed, SubstrateRequestException, @@ -1118,22 +1343,27 @@ async def _run(): elif isinstance(e, RuntimeError): pass # Temporarily to handle loop bound issues verbose_console.print(traceback.format_exc()) + exception_occurred = True except Exception as e: err_console.print(f"An unknown error has occurred: {e}") verbose_console.print(traceback.format_exc()) + exception_occurred = True finally: if initiated is False: asyncio.create_task(cmd).cancel() if ( exit_early is True ): # temporarily to handle multiple run commands in one session - try: + if self.subtensor: + try: + await self.subtensor.substrate.close() + except Exception as e: # ensures we always exit cleanly + if not isinstance(e, (typer.Exit, RuntimeError)): + err_console.print(f"An unknown error has occurred: {e}") + if exception_occurred: raise typer.Exit() - except Exception as e: # ensures we always exit cleanly - if not isinstance(e, (typer.Exit, RuntimeError)): - err_console.print(f"An unknown error has occurred: {e}") - return self.asyncio_runner(_run()) + return self.event_loop.run_until_complete(_run()) def main_callback( self, @@ -1149,10 +1379,18 @@ def main_callback( "--commands", callback=commands_callback, help="Show BTCLI commands" ), ] = None, + debug_log: Annotated[ + Optional[bool], + typer.Option( + "--debug", + callback=debug_callback, + help="Saves the debug log from the last used command", + ), + ] = None, ): """ Command line interface (CLI) for Bittensor. Uses the values in the configuration file. These values can be - overriden by passing them explicitly in the command line. + overridden by passing them explicitly in the command line. """ # Load or create the config file if os.path.exists(self.config_path): @@ -1176,6 +1414,9 @@ def main_callback( if sub_key not in config[key]: config[key][sub_key] = sub_value updated = True + elif isinstance(value, bool) and config[key] is None: + config[key] = value + updated = True if updated: with open(self.config_path, "w") as f: safe_dump(config, f) @@ -1183,20 +1424,27 @@ def main_callback( for k, v in config.items(): if k in self.config.keys(): self.config[k] = v - - if sys.version_info < (3, 10): - # For Python 3.9 or lower - self.asyncio_runner = asyncio.get_event_loop().run_until_complete - else: - try: - uvloop = importlib.import_module("uvloop") - if sys.version_info >= (3, 11): - self.asyncio_runner = uvloop.run - else: - uvloop.install() - self.asyncio_runner = asyncio.run - except ModuleNotFoundError: - self.asyncio_runner = asyncio.run + if self.config.get("use_cache", False): + with open(self.debug_file_path, "w+") as f: + f.write( + f"BTCLI {__version__}\n" + f"Async-Substrate-Interface: {importlib.metadata.version('async-substrate-interface')}\n" + f"Bittensor-Wallet: {importlib.metadata.version('bittensor-wallet')}\n" + f"Command: {' '.join(sys.argv)}\n" + f"Config: {self.config}\n" + f"Python: {sys.version}\n" + f"System: {sys.platform}\n\n" + ) + asi_logger = logging.getLogger("async_substrate_interface") + asi_logger.setLevel(logging.DEBUG) + logger.setLevel(logging.DEBUG) + formatter = logging.Formatter( + "%(asctime)s - %(levelname)s - %(name)s - %(module)s:%(lineno)d - %(message)s" + ) + handler = logging.FileHandler(self.debug_file_path) + handler.setFormatter(formatter) + asi_logger.addHandler(handler) + logger.addHandler(handler) def verbosity_handler( self, quiet: bool, verbose: bool, json_output: bool = False @@ -1253,10 +1501,17 @@ def set_config( use_cache: Optional[bool] = typer.Option( None, "--cache/--no-cache", - "--cache/--no_cache", + " /--no_cache", help="Disable caching of some commands. This will disable the `--reuse-last` and `--html` flags on " "commands such as `subnets metagraph`, `stake show` and `subnets list`.", ), + disk_cache: Optional[bool] = typer.Option( + None, + "--disk-cache/--no-disk-cache", + " /--no-disk-cache", + help="Enables or disables the caching on disk. Enabling this can significantly speed up commands run " + "sequentially", + ), rate_tolerance: Optional[float] = typer.Option( None, "--tolerance", @@ -1303,12 +1558,13 @@ def set_config( "wallet_hotkey": wallet_hotkey, "network": network, "use_cache": use_cache, + "disk_cache": disk_cache, "rate_tolerance": rate_tolerance, "safe_staking": safe_staking, "allow_partial_stake": allow_partial_stake, "dashboard_path": dashboard_path, } - bools = ["use_cache", "safe_staking", "allow_partial_stake"] + bools = ["use_cache", "disk_cache", "safe_staking", "allow_partial_stake"] if all(v is None for v in args.values()): # Print existing configs self.get_config() @@ -1357,8 +1613,7 @@ def set_config( if n := args.get("network"): if n in Constants.networks: if not Confirm.ask( - f"You provided a network [{COLORS.G.ARG}]{n}[/{COLORS.G.ARG}] which is mapped to " - f"[{COLORS.G.ARG}]{Constants.network_map[n]}[/{COLORS.G.ARG}]\n" + f"You provided a network {arg__(n)} which is mapped to {arg__(Constants.network_map[n])}\n" "Do you want to continue?" ): typer.Exit() @@ -1373,14 +1628,13 @@ def set_config( ) args["network"] = known_network if not Confirm.ask( - f"You provided an endpoint [{COLORS.G.ARG}]{n}[/{COLORS.G.ARG}] which is mapped to " - f"[{COLORS.G.ARG}]{known_network}[/{COLORS.G.ARG}]\n" + f"You provided an endpoint {arg__(n)} which is mapped to {arg__(known_network)}\n" "Do you want to continue?" ): raise typer.Exit() else: if not Confirm.ask( - f"You provided a chain endpoint URL [{COLORS.G.ARG}]{n}[/{COLORS.G.ARG}]\n" + f"You provided a chain endpoint URL {arg__(n)}\n" "Do you want to continue?" ): raise typer.Exit() @@ -1390,6 +1644,7 @@ def set_config( for arg, val in args.items(): if val is not None: + logger.debug(f"Config: setting {arg} to {val}") self.config[arg] = val with open(self.config_path, "w") as f: safe_dump(self.config, f) @@ -1454,17 +1709,12 @@ def del_config( if not any(args.values()): for arg in args.keys(): if self.config.get(arg) is not None: - if Confirm.ask( - f"Do you want to clear the [{COLORS.G.ARG}]{arg}[/{COLORS.G.ARG}] config?" - ): + if Confirm.ask(f"Do you want to clear the {arg__(arg)} config?"): + logger.debug(f"Config: clearing {arg}.") self.config[arg] = None - console.print( - f"Cleared [{COLORS.G.ARG}]{arg}[/{COLORS.G.ARG}] config and set to 'None'." - ) + console.print(f"Cleared {arg__(arg)} config and set to 'None'.") else: - console.print( - f"Skipped clearing [{COLORS.G.ARG}]{arg}[/{COLORS.G.ARG}] config." - ) + console.print(f"Skipped clearing {arg__(arg)} config.") else: # Check each specified argument @@ -1472,21 +1722,19 @@ def del_config( if should_clear: if self.config.get(arg) is not None: if Confirm.ask( - f"Do you want to clear the [{COLORS.G.ARG}]{arg}[/{COLORS.G.ARG}]" + f"Do you want to clear the {arg__(arg)}" f" [bold cyan]({self.config.get(arg)})[/bold cyan] config?" ): self.config[arg] = None + logger.debug(f"Config: clearing {arg}.") console.print( - f"Cleared [{COLORS.G.ARG}]{arg}[/{COLORS.G.ARG}] config and set to 'None'." + f"Cleared {arg__(arg)} config and set to 'None'." ) else: - console.print( - f"Skipped clearing [{COLORS.G.ARG}]{arg}[/{COLORS.G.ARG}] config." - ) + console.print(f"Skipped clearing {arg__(arg)} config.") else: console.print( - f"No config set for [{COLORS.G.ARG}]{arg}[/{COLORS.G.ARG}]." - f" Use [{COLORS.G.ARG}]`btcli config set`[/{COLORS.G.ARG}] to set it." + f"No config set for {arg__(arg)}. Use {arg__('btcli config set')} to set it." ) with open(self.config_path, "w") as f: safe_dump(self.config, f) @@ -1502,6 +1750,7 @@ def get_config(self): Column("[bold white]Value", style="gold1"), Column("", style="medium_purple"), box=box.SIMPLE_HEAD, + title=f"[{COLORS.G.HEADER}]BTCLI Config[/{COLORS.G.HEADER}]: {arg__(self.config_path)}", ) for key, value in self.config.items(): @@ -1575,26 +1824,31 @@ def ask_safe_staking( bool: Safe staking setting """ if safe_staking is not None: + enabled = "enabled" if safe_staking else "disabled" console.print( - f"[dim][blue]Safe staking[/blue]: [bold cyan]{'enabled' if safe_staking else 'disabled'}[/bold cyan]." + f"[dim][blue]Safe staking[/blue]: [bold cyan]{enabled}[/bold cyan]." ) + logger.debug(f"Safe staking {enabled}") return safe_staking elif self.config.get("safe_staking") is not None: safe_staking = self.config["safe_staking"] + enabled = "enabled" if safe_staking else "disabled" console.print( - f"[dim][blue]Safe staking[/blue]: [bold cyan]{'enabled' if safe_staking else 'disabled'}[/bold cyan] (from config)." + f"[dim][blue]Safe staking[/blue]: [bold cyan]{enabled}[/bold cyan] (from config)." ) + logger.debug(f"Safe staking {enabled}") return safe_staking else: safe_staking = True console.print( "[dim][blue]Safe staking[/blue]: " - + f"[bold cyan]{'enabled' if safe_staking else 'disabled'}[/bold cyan] " - + "by default. Set this using " - + "[dark_sea_green3 italic]`btcli config set`[/dark_sea_green3 italic] " - + "or " - + "[dark_sea_green3 italic]`--safe/--unsafe`[/dark_sea_green3 italic] flag[/dim]" + f"[bold cyan]enabled[/bold cyan] " + "by default. Set this using " + "[dark_sea_green3 italic]`btcli config set`[/dark_sea_green3 italic] " + "or " + "[dark_sea_green3 italic]`--safe/--unsafe`[/dark_sea_green3 italic] flag[/dim]" ) + logger.debug(f"Safe staking enabled.") return safe_staking def ask_partial_stake( @@ -1611,33 +1865,76 @@ def ask_partial_stake( bool: Partial stake setting """ if allow_partial_stake is not None: + partial_staking = "enabled" if allow_partial_stake else "disabled" console.print( - f"[dim][blue]Partial staking[/blue]: [bold cyan]{'enabled' if allow_partial_stake else 'disabled'}[/bold cyan]." + f"[dim][blue]Partial staking[/blue]: [bold cyan]{partial_staking}[/bold cyan]." ) + logger.debug(f"Partial staking {partial_staking}") return allow_partial_stake elif self.config.get("allow_partial_stake") is not None: config_partial = self.config["allow_partial_stake"] + partial_staking = "enabled" if allow_partial_stake else "disabled" console.print( - f"[dim][blue]Partial staking[/blue]: [bold cyan]{'enabled' if config_partial else 'disabled'}[/bold cyan] (from config)." + f"[dim][blue]Partial staking[/blue]: [bold cyan]{partial_staking}[/bold cyan] (from config)." ) + logger.debug(f"Partial staking {partial_staking}") return config_partial else: + partial_staking = "enabled" if allow_partial_stake else "disabled" console.print( "[dim][blue]Partial staking[/blue]: " - + f"[bold cyan]{'enabled' if allow_partial_stake else 'disabled'}[/bold cyan] " - + "by default. Set this using " - + "[dark_sea_green3 italic]`btcli config set`[/dark_sea_green3 italic] " - + "or " - + "[dark_sea_green3 italic]`--partial/--no-partial`[/dark_sea_green3 italic] flag[/dim]" + f"[bold cyan]{partial_staking}[/bold cyan] " + "by default. Set this using " + "[dark_sea_green3 italic]`btcli config set`[/dark_sea_green3 italic] " + "or " + "[dark_sea_green3 italic]`--partial/--no-partial`[/dark_sea_green3 italic] flag[/dim]" ) + logger.debug(f"Partial staking {partial_staking}") return False + def ask_subnet_mechanism( + self, + mechanism_id: Optional[int], + mechanism_count: int, + netuid: int, + ) -> int: + """Resolve the mechanism ID to use.""" + + if mechanism_count is None or mechanism_count <= 0: + err_console.print(f"Subnet {netuid} does not exist.") + raise typer.Exit() + + if mechanism_id is not None: + if mechanism_id < 0 or mechanism_id >= mechanism_count: + err_console.print( + f"Mechanism ID {mechanism_id} is out of range for subnet {netuid}. " + f"Valid range: [bold cyan]0 to {mechanism_count - 1}[/bold cyan]." + ) + raise typer.Exit() + return mechanism_id + + if mechanism_count == 1: + return 0 + + while True: + selected_mechanism_id = IntPrompt.ask( + f"Select mechanism ID for subnet {netuid} " + f"([bold cyan]0 to {mechanism_count - 1}[/bold cyan])", + default=0, + ) + if 0 <= selected_mechanism_id < mechanism_count: + return selected_mechanism_id + err_console.print( + f"Mechanism ID {selected_mechanism_id} is out of range for subnet {netuid}. " + f"Valid range: [bold cyan]0 to {mechanism_count - 1}[/bold cyan]." + ) + def wallet_ask( self, wallet_name: Optional[str], wallet_path: Optional[str], wallet_hotkey: Optional[str], - ask_for: Optional[list[str]] = None, + ask_for: Optional[list[WalletOptions]] = None, validate: WV = WV.WALLET, return_wallet_and_hotkey: bool = False, ) -> Union[Wallet, tuple[Wallet, str]]: @@ -1702,13 +1999,14 @@ def wallet_ask( if wallet_path: wallet_path = os.path.expanduser(wallet_path) wallet = Wallet(name=wallet_name, path=wallet_path, hotkey=wallet_hotkey) + logger.debug(f"Using wallet {wallet}") # Validate the wallet if required if validate == WV.WALLET or validate == WV.WALLET_AND_HOTKEY: valid = utils.is_valid_wallet(wallet) if not valid[0]: utils.err_console.print( - f"[red]Error: Wallet does not not exist. \n" + f"[red]Error: Wallet does not exist. \n" f"Please verify your wallet information: {wallet}[/red]" ) raise typer.Exit() @@ -1722,7 +2020,7 @@ def wallet_ask( if return_wallet_and_hotkey: valid = utils.is_valid_wallet(wallet) if valid[1]: - return wallet, wallet.hotkey.ss58_address + return wallet, get_hotkey_pub_ss58(wallet) else: if wallet_hotkey and is_valid_ss58_address(wallet_hotkey): return wallet, wallet_hotkey @@ -1744,6 +2042,7 @@ def wallet_ask( def wallet_list( self, + wallet_name: Optional[str] = Options.wallet_name, wallet_path: str = Options.wallet_path, quiet: bool = Options.quiet, verbose: bool = Options.verbose, @@ -1767,7 +2066,13 @@ def wallet_list( wallet = self.wallet_ask( None, wallet_path, None, ask_for=[WO.PATH], validate=WV.NONE ) - return self._run_command(wallets.wallet_list(wallet.path, json_output)) + return self._run_command( + wallets.wallet_list( + wallet.path, + json_output, + wallet_name=wallet_name, + ) + ) def wallet_overview( self, @@ -1857,6 +2162,15 @@ def wallet_overview( str, "Hotkeys names must be a comma-separated list, e.g., `--exclude-hotkeys hk1,hk2`.", ) + logger.debug( + "args:\n" + f"all_wallets: {all_wallets}\n" + f"sort_by: {sort_by}\n" + f"sort_order: {sort_order}\n" + f"include_hotkeys: {include_hotkeys}\n" + f"exclude_hotkeys: {exclude_hotkeys}\n" + f"netuids: {netuids}\n" + ) return self._run_command( wallets.overview( @@ -1946,6 +2260,15 @@ def wallet_transfer( amount = 0 elif not amount: amount = FloatPrompt.ask("Enter amount (in TAO) to transfer.") + logger.debug( + "args:\n" + f"destination: {destination_ss58_address}\n" + f"amount: {amount}\n" + f"transfer_all: {transfer_all}\n" + f"allow_death: {allow_death}\n" + f"period: {period}\n" + f"prompt: {prompt}\n" + ) return self._run_command( wallets.transfer( wallet=wallet, @@ -1987,15 +2310,44 @@ def wallet_swap_hotkey( - Make sure that your original key pair (coldkeyA, hotkeyA) is already registered. - Make sure that you use a newly created hotkeyB in this command. A hotkeyB that is already registered cannot be used in this command. - - You can specify the netuid for which you want to swap the hotkey for. If it is not defined, the swap will be initiated for all subnets. + - If NO netuid is specified, the swap will be initiated for ALL subnets (recommended for most users). + - If a SPECIFIC netuid is specified (e.g., --netuid 1), the swap will only affect that particular subnet. + - WARNING: Using --netuid 0 will ONLY swap on the root network (netuid 0), NOT a full swap across all subnets. Use without --netuid for full swap. - Finally, note that this command requires a fee of 1 TAO for recycling and this fee is taken from your wallet (coldkeyA). EXAMPLE + Full swap across all subnets (recommended): + [green]$[/green] btcli wallet swap_hotkey destination_hotkey_name --wallet-name your_wallet_name --wallet-hotkey original_hotkey + + Swap for a specific subnet only: [green]$[/green] btcli wallet swap_hotkey destination_hotkey_name --wallet-name your_wallet_name --wallet-hotkey original_hotkey --netuid 1 """ netuid = get_optional_netuid(netuid, all_netuids) self.verbosity_handler(quiet, verbose, json_output) + + # Warning for netuid 0 - only swaps on root network, not a full swap + if netuid == 0 and prompt: + console.print( + "\n[bold yellow]⚠️ WARNING: Using --netuid 0 for swap_hotkey[/bold yellow]\n" + ) + console.print( + "[yellow]Specifying --netuid 0 will ONLY swap the hotkey on the root network (netuid 0).[/yellow]\n" + ) + console.print( + "[yellow]It will NOT move child hotkey delegation mappings on root.[/yellow]\n" + ) + console.print( + f"[bold green]btcli wallet swap_hotkey {destination_hotkey_name or ''} " + f"--wallet-name {wallet_name or ''} " + f"--wallet-hotkey {wallet_hotkey or ''}[/bold green]\n" + ) + + if not Confirm.ask( + "Are you SURE you want to proceed with --netuid 0 (only root network swap)?", + default=False, + ): + return original_wallet = self.wallet_ask( wallet_name, wallet_path, @@ -2015,6 +2367,13 @@ def wallet_swap_hotkey( ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], validate=WV.WALLET_AND_HOTKEY, ) + logger.debug( + "args:\n" + f"original_wallet: {original_wallet}\n" + f"new_wallet: {new_wallet}\n" + f"netuid: {netuid}\n" + f"prompt: {prompt}\n" + ) self.initialize_chain(network) return self._run_command( wallets.swap_hotkey( @@ -2178,6 +2537,17 @@ def wallet_faucet( ask_for=[WO.NAME, WO.PATH], validate=WV.WALLET, ) + logger.debug( + "args:\n" + f"network {network}\n" + f"threads_per_block {threads_per_block}\n" + f"update_interval {update_interval}\n" + f"processors {processors}\n" + f"use_cuda {use_cuda}\n" + f"dev_id {dev_id}\n" + f"output_in_place {output_in_place}\n" + f"max_successes {max_successes}\n" + ) return self._run_command( wallets.faucet( wallet, @@ -2212,7 +2582,7 @@ def wallet_regen_coldkey( """ Regenerate a coldkey for a wallet on the Bittensor blockchain network. - This command is used to create a new coldkey from an existing mnemonic, seed, or JSON file. + This command is used to create a new instance of a coldkey from an existing mnemonic, seed, or JSON file. USAGE @@ -2229,14 +2599,15 @@ def wallet_regen_coldkey( if not wallet_path: wallet_path = Prompt.ask( - "Enter the path for the wallets directory", default=defaults.wallet.path + "Enter the path for the wallets directory", + default=self.config.get("wallet_path") or defaults.wallet.path, ) wallet_path = os.path.expanduser(wallet_path) if not wallet_name: wallet_name = Prompt.ask( f"Enter the name of the [{COLORS.G.CK}]new wallet (coldkey)", - default=defaults.wallet.name, + default=self.config.get("wallet_name") or defaults.wallet.name, ) wallet = Wallet(wallet_name, wallet_hotkey, wallet_path) @@ -2244,6 +2615,7 @@ def wallet_regen_coldkey( mnemonic, seed, json_path, json_password = get_creation_data( mnemonic, seed, json_path, json_password ) + # logger.debug should NOT be used here, it's simply too risky return self._run_command( wallets.regen_coldkey( wallet, @@ -2280,7 +2652,7 @@ def wallet_regen_coldkey_pub( EXAMPLE - [green]$[/green] btcli wallet regen_coldkeypub --ss58_address 5DkQ4... + [green]$[/green] btcli wallet regen-coldkeypub --ss58_address 5DkQ4... [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. """ @@ -2288,13 +2660,14 @@ def wallet_regen_coldkey_pub( if not wallet_path: wallet_path = Prompt.ask( - "Enter the path to the wallets directory", default=defaults.wallet.path + "Enter the path to the wallets directory", + default=self.config.get("wallet_path") or defaults.wallet.path, ) wallet_path = os.path.expanduser(wallet_path) if not wallet_name: wallet_name = Prompt.ask( - f"Enter the name of the [{COLORS.G.CK}]new wallet (coldkey)", + f"Enter the name of the [{COLORS.G.CK}]wallet for the new coldkeypub", default=defaults.wallet.name, ) wallet = Wallet(wallet_name, wallet_hotkey, wallet_path) @@ -2311,7 +2684,8 @@ def wallet_regen_coldkey_pub( address=ss58_address if ss58_address else public_key_hex ): rich.print("[red]Error: Invalid SS58 address or public key![/red]") - raise typer.Exit() + return + # do not logger.debug any creation cmds return self._run_command( wallets.regen_coldkey_pub( wallet, ss58_address, public_key_hex, overwrite, json_output @@ -2364,6 +2738,7 @@ def wallet_regen_hotkey( mnemonic, seed, json_path, json_password = get_creation_data( mnemonic, seed, json_path, json_password ) + # do not logger.debug any creation cmds return self._run_command( wallets.regen_hotkey( wallet, @@ -2377,6 +2752,69 @@ def wallet_regen_hotkey( ) ) + def wallet_regen_hotkey_pub( + self, + wallet_name: Optional[str] = Options.wallet_name, + wallet_path: Optional[str] = Options.wallet_path, + wallet_hotkey: Optional[str] = Options.wallet_hotkey, + public_key_hex: Optional[str] = Options.public_hex_key, + ss58_address: Optional[str] = Options.ss58_address, + overwrite: bool = Options.overwrite, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """ + Regenerates the public part of a hotkey (hotkeypub.txt) for a wallet. + + Use this command when you need to move machine for subnet mining. Use the public key or SS58 address from your hotkeypub.txt that you have on another machine to regenerate the hotkeypub.txt on this new machine. + + USAGE + + The command requires either a public key in hexadecimal format or an ``SS58`` address from the existing hotkeypub.txt from old machine to regenerate the coldkeypub on the new machine. + + EXAMPLE + + [green]$[/green] btcli wallet regen-hotkeypub --ss58_address 5DkQ4... + + [bold]Note[/bold]: This command is particularly useful for users who need to regenerate their hotkeypub, perhaps due to file corruption or loss. You will need either ss58 address or public hex key from your old hotkeypub.txt for the wallet. It is a recovery-focused utility that ensures continued access to your wallet functionalities. + """ + self.verbosity_handler(quiet, verbose, json_output) + + if not wallet_path: + wallet_path = Prompt.ask( + "Enter the path to the wallets directory", + default=self.config.get("wallet_path") or defaults.wallet.path, + ) + wallet_path = os.path.expanduser(wallet_path) + + if not wallet_name: + wallet_name = Prompt.ask( + f"Enter the name of the [{COLORS.G.CK}]wallet for the new hotkeypub", + default=defaults.wallet.name, + ) + wallet = Wallet(wallet_name, wallet_hotkey, wallet_path) + + if not ss58_address and not public_key_hex: + prompt_answer = typer.prompt( + "Enter the ss58_address or the public key in hex" + ) + if prompt_answer.startswith("0x"): + public_key_hex = prompt_answer + else: + ss58_address = prompt_answer + if not utils.is_valid_bittensor_address_or_public_key( + address=ss58_address if ss58_address else public_key_hex + ): + rich.print("[red]Error: Invalid SS58 address or public key![/red]") + return False + # do not logger.debug any creation cmds + return self._run_command( + wallets.regen_hotkey_pub( + wallet, ss58_address, public_key_hex, overwrite, json_output + ) + ) + def wallet_new_hotkey( self, wallet_name: Optional[str] = Options.wallet_name, @@ -2417,7 +2855,7 @@ def wallet_new_hotkey( if not wallet_name: wallet_name = Prompt.ask( f"Enter the [{COLORS.G.CK}]wallet name", - default=defaults.wallet.name, + default=self.config.get("wallet_name") or defaults.wallet.name, ) if not wallet_hotkey: @@ -2435,6 +2873,7 @@ def wallet_new_hotkey( ) if not uri: n_words = get_n_words(n_words) + # do not logger.debug any creation cmds return self._run_command( wallets.new_hotkey( wallet, n_words, use_password, uri, overwrite, json_output @@ -2472,11 +2911,11 @@ def wallet_associate_hotkey( if not wallet_hotkey: wallet_hotkey = Prompt.ask( "Enter the [blue]hotkey[/blue] name or " - "[blue]hotkey ss58 address[/blue] [dim](to associate with your coldkey)[/dim]" + "[blue]hotkey ss58 address[/blue] [dim](to associate with your coldkey)[/dim]", + default=self.config.get("wallet_hotkey") or defaults.wallet.hotkey, ) - hotkey_display = None - if is_valid_ss58_address(wallet_hotkey): + if wallet_hotkey and is_valid_ss58_address(wallet_hotkey): hotkey_ss58 = wallet_hotkey wallet = self.wallet_ask( wallet_name, @@ -2496,9 +2935,18 @@ def wallet_associate_hotkey( ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], validate=WV.WALLET_AND_HOTKEY, ) - hotkey_ss58 = wallet.hotkey.ss58_address - hotkey_display = f"hotkey [blue]{wallet_hotkey}[/blue] [{COLORS.GENERAL.HK}]({hotkey_ss58})[/{COLORS.GENERAL.HK}]" - + hotkey_ss58 = get_hotkey_pub_ss58(wallet) + hotkey_display = ( + f"hotkey [blue]{wallet_hotkey}[/blue] " + f"[{COLORS.GENERAL.HK}]({hotkey_ss58})[/{COLORS.GENERAL.HK}]" + ) + logger.debug( + "args:\n" + f"network {network}\n" + f"hotkey_ss58 {hotkey_ss58}\n" + f"hotkey_display {hotkey_display}\n" + f"prompt {prompt}\n" + ) return self._run_command( wallets.associate_hotkey( wallet, @@ -2544,13 +2992,14 @@ def wallet_new_coldkey( if not wallet_path: wallet_path = Prompt.ask( - "Enter the path to the wallets directory", default=defaults.wallet.path + "Enter the path to the wallets directory", + default=self.config.get("wallet_path") or defaults.wallet.path, ) if not wallet_name: wallet_name = Prompt.ask( f"Enter the name of the [{COLORS.G.CK}]new wallet (coldkey)", - default=defaults.wallet.name, + default=self.config.get("wallet_name") or defaults.wallet.name, ) wallet = self.wallet_ask( @@ -2623,7 +3072,8 @@ def wallet_check_ck_swap( if not wallet_ss58_address: wallet_ss58_address = Prompt.ask( - "Enter [blue]wallet name[/blue] or [blue]SS58 address[/blue] [dim](leave blank to show all pending swaps)[/dim]" + "Enter [blue]wallet name[/blue] or [blue]SS58 address[/blue] [dim]" + "(leave blank to show all pending swaps)[/dim]" ) if not wallet_ss58_address: return self._run_command( @@ -2644,7 +3094,8 @@ def wallet_check_ck_swap( if not scheduled_block: block_input = Prompt.ask( - "[blue]Enter the block number[/blue] where the swap was scheduled [dim](optional, press enter to skip)[/dim]", + "[blue]Enter the block number[/blue] where the swap was scheduled " + "[dim](optional, press enter to skip)[/dim]", default="", ) if block_input: @@ -2653,7 +3104,12 @@ def wallet_check_ck_swap( except ValueError: print_error("Invalid block number") raise typer.Exit() - + logger.debug( + "args:\n" + f"scheduled_block {scheduled_block}\n" + f"ss58_address {ss58_address}\n" + f"network {network}\n" + ) return self._run_command( wallets.check_swap_status(self.subtensor, ss58_address, scheduled_block) ) @@ -2687,18 +3143,18 @@ def wallet_create_wallet( 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 + "Enter the path of wallets directory", + default=self.config.get("wallet_path") or defaults.wallet.path, ) if not wallet_name: wallet_name = Prompt.ask( f"Enter the name of the [{COLORS.G.CK}]new wallet (coldkey)", - default=defaults.wallet.name, ) if not wallet_hotkey: wallet_hotkey = Prompt.ask( f"Enter the the name of the [{COLORS.G.HK}]new hotkey", - default=defaults.wallet.hotkey, + default=self.config.get("wallet_hotkey") or defaults.wallet.hotkey, ) wallet = self.wallet_ask( @@ -2710,6 +3166,7 @@ def wallet_create_wallet( ) if not uri: n_words = get_n_words(n_words) + # do not logger.debug any creation commands return self._run_command( wallets.wallet_create( wallet, n_words, use_password, uri, overwrite, json_output @@ -2728,6 +3185,11 @@ def wallet_balance( "-a", help="Whether to display the balances for all the wallets.", ), + sort_by: Optional[wallets.SortByBalance] = typer.Option( + None, + "--sort", + help="When using `--all`, sorts the wallets by a given column", + ), network: Optional[list[str]] = Options.network, quiet: bool = Options.quiet, verbose: bool = Options.verbose, @@ -2818,10 +3280,16 @@ def wallet_balance( ask_for=ask_for, validate=validate, ) + logger.debug( + "args:\n" + f"all_balances {all_balances}\n" + f"ss58_addresses {ss58_addresses}\n" + f"network {network}" + ) subtensor = self.initialize_chain(network) return self._run_command( wallets.wallet_balance( - wallet, subtensor, all_balances, ss58_addresses, json_output + wallet, subtensor, all_balances, ss58_addresses, sort_by, json_output ) ) @@ -2875,7 +3343,7 @@ def wallet_set_id( network: Optional[list[str]] = Options.network, name: str = typer.Option( "", - "--name", + "--id-name", help="The display name for the identity.", ), web_url: str = typer.Option( @@ -2968,6 +3436,7 @@ def wallet_set_id( additional, github_repo, ) + logger.debug(f"args:\nidentity {identity}\nnetwork {network}\n") return self._run_command( wallets.set_id( @@ -3229,7 +3698,12 @@ def wallet_swap_coldkey( f"[dark_sea_green3]{new_wallet}[/dark_sea_green3]\n" ) new_wallet_coldkey_ss58 = new_wallet.coldkeypub.ss58_address - + logger.debug( + "args:\n" + f"network {network}\n" + f"new_coldkey_ss58 {new_wallet_coldkey_ss58}\n" + f"force_swap {force_swap}" + ) return self._run_command( wallets.schedule_coldkey_swap( wallet=wallet, @@ -3239,11 +3713,10 @@ def wallet_swap_coldkey( ) ) - def stake_list( + def get_auto_stake( self, network: Optional[list[str]] = Options.network, wallet_name: Optional[str] = Options.wallet_name, - wallet_hotkey: Optional[str] = Options.wallet_hotkey, wallet_path: Optional[str] = Options.wallet_path, coldkey_ss58=typer.Option( None, @@ -3253,32 +3726,12 @@ def stake_list( "--coldkey.ss58", help="Coldkey address of the wallet", ), - live: bool = Options.live, 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 ): - """ - Display detailed stake information for a wallet across all subnets. - - Shows stake allocations, exchange rates, and emissions for each hotkey. - - [bold]Common Examples:[/bold] - - 1. Basic stake overview: - [green]$[/green] btcli stake list --wallet.name my_wallet + """Display auto-stake destinations for a wallet across all subnets.""" - 2. Live updating view with refresh: - [green]$[/green] btcli stake list --wallet.name my_wallet --live - - 3. View specific coldkey by address: - [green]$[/green] btcli stake list --ss58 5Dk...X3q - - 4. Verbose output with full values: - [green]$[/green] btcli stake list --wallet.name my_wallet --verbose - """ self.verbosity_handler(quiet, verbose, json_output) wallet = None @@ -3299,31 +3752,188 @@ def stake_list( else: wallet_name = coldkey_or_ss58 if coldkey_or_ss58 else wallet_name wallet = self.wallet_ask( - wallet_name, wallet_path, wallet_hotkey, ask_for=[WO.NAME, WO.PATH] + wallet_name, + wallet_path, + None, + ask_for=[WO.NAME, WO.PATH], + validate=WV.WALLET, ) return self._run_command( - list_stake.stake_list( + auto_stake.show_auto_stake_destinations( wallet, - coldkey_ss58, self.initialize_chain(network), - live, - verbose, - no_prompt, - json_output, + coldkey_ss58=coldkey_ss58, + json_output=json_output, ) ) - def stake_add( + def set_auto_stake( self, - stake_all: bool = typer.Option( - False, - "--all-tokens", - "--all", - "-a", - help="When set, the command stakes all the available TAO from the coldkey.", - ), - amount: float = typer.Option( + network: Optional[list[str]] = Options.network, + wallet_name: Optional[str] = Options.wallet_name, + wallet_path: Optional[str] = Options.wallet_path, + netuid: Optional[int] = Options.netuid_not_req, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + prompt: bool = Options.prompt, + wait_for_inclusion: bool = Options.wait_for_inclusion, + wait_for_finalization: bool = Options.wait_for_finalization, + json_output: bool = Options.json_output, + ): + """Set the auto-stake destination hotkey for a coldkey.""" + + self.verbosity_handler(quiet, verbose, json_output) + + wallet = self.wallet_ask( + wallet_name, + wallet_path, + None, + ask_for=[WO.NAME, WO.PATH], + validate=WV.WALLET, + ) + + if netuid is None: + netuid = IntPrompt.ask( + "Enter the [blue]netuid[/blue] to configure", + default=defaults.netuid, + ) + validate_netuid(netuid) + + hotkey_prompt = Prompt.ask( + "Enter the [blue]hotkey ss58 address[/blue] to auto-stake to " + "[dim](Press Enter to view delegates)[/dim]", + default="", + show_default=False, + ).strip() + + if not hotkey_prompt: + selected_hotkey = self._run_command( + subnets.show( + subtensor=self.initialize_chain(network), + netuid=netuid, + sort=False, + max_rows=20, + prompt=False, + delegate_selection=True, + ), + exit_early=False, + ) + if not selected_hotkey: + print_error("No delegate selected. Exiting.") + return + hotkey_ss58 = selected_hotkey + else: + hotkey_ss58 = hotkey_prompt + + if not is_valid_ss58_address(hotkey_ss58): + print_error("You entered an invalid hotkey ss58 address") + return + + return self._run_command( + auto_stake.set_auto_stake_destination( + wallet, + self.initialize_chain(network), + netuid, + hotkey_ss58, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + prompt_user=prompt, + json_output=json_output, + ) + ) + + def stake_list( + self, + network: Optional[list[str]] = Options.network, + wallet_name: Optional[str] = Options.wallet_name, + wallet_hotkey: Optional[str] = Options.wallet_hotkey, + wallet_path: Optional[str] = Options.wallet_path, + coldkey_ss58=typer.Option( + None, + "--ss58", + "--coldkey_ss58", + "--coldkey.ss58_address", + "--coldkey.ss58", + help="Coldkey address of the wallet", + ), + live: bool = Options.live, + 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 + ): + """ + Display detailed stake information for a wallet across all subnets. + + Shows stake allocations, exchange rates, and emissions for each hotkey. + + [bold]Common Examples:[/bold] + + 1. Basic stake overview: + [green]$[/green] btcli stake list --wallet.name my_wallet + + 2. Live updating view with refresh: + [green]$[/green] btcli stake list --wallet.name my_wallet --live + + 3. View specific coldkey by address: + [green]$[/green] btcli stake list --ss58 5Dk...X3q + + 4. Verbose output with full values: + [green]$[/green] btcli stake list --wallet.name my_wallet --verbose + """ + self.verbosity_handler(quiet, verbose, json_output) + + wallet = None + if coldkey_ss58: + if not is_valid_ss58_address(coldkey_ss58): + print_error("You entered an invalid ss58 address") + raise typer.Exit() + else: + if wallet_name: + coldkey_or_ss58 = wallet_name + else: + coldkey_or_ss58 = Prompt.ask( + "Enter the [blue]wallet name[/blue] or [blue]coldkey ss58 address[/blue]", + default=self.config.get("wallet_name") or defaults.wallet.name, + ) + if is_valid_ss58_address(coldkey_or_ss58): + coldkey_ss58 = coldkey_or_ss58 + else: + wallet_name = coldkey_or_ss58 if coldkey_or_ss58 else wallet_name + wallet = self.wallet_ask( + wallet_name, wallet_path, wallet_hotkey, ask_for=[WO.NAME, WO.PATH] + ) + logger.debug( + "args:\n" + f"coldkey_ss58 {coldkey_ss58}\n" + f"network {network}\n" + f"live: {live}\n" + f"no_prompt: {no_prompt}\n" + ) + return self._run_command( + list_stake.stake_list( + wallet, + coldkey_ss58, + self.initialize_chain(network), + live, + verbose, + no_prompt, + json_output, + ) + ) + + def stake_add( + self, + stake_all: bool = typer.Option( + False, + "--all-tokens", + "--all", + "-a", + help="When set, the command stakes all the available TAO from the coldkey.", + ), + amount: float = typer.Option( 0.0, "--amount", help="The amount of TAO to stake" ), include_hotkeys: str = typer.Option( @@ -3482,6 +4092,8 @@ def stake_add( subnets.show( subtensor=self.initialize_chain(network), netuid=netuid_, + mechanism_id=0, + mechanism_count=1, sort=False, max_rows=12, prompt=False, @@ -3507,7 +4119,7 @@ def stake_add( ask_for=[WO.NAME, WO.HOTKEY, WO.PATH], validate=WV.WALLET_AND_HOTKEY, ) - include_hotkeys = wallet.hotkey.ss58_address + include_hotkeys = get_hotkey_pub_ss58(wallet) elif all_hotkeys or include_hotkeys or exclude_hotkeys: wallet = self.wallet_ask( @@ -3550,6 +4162,7 @@ def stake_add( ), exit_early=False, ) + logger.debug(f"Free balance: {free_balance}") if free_balance == Balance.from_tao(0): print_error("You dont have any balance to stake.") return @@ -3570,7 +4183,21 @@ def stake_add( f"You dont have enough balance to stake. Current free Balance: {free_balance}." ) raise typer.Exit() - + logger.debug( + "args:\n" + f"network: {network}\n" + f"netuids: {netuids}\n" + f"stake_all: {stake_all}\n" + f"amount: {amount}\n" + f"prompt: {prompt}\n" + f"all_hotkeys: {all_hotkeys}\n" + f"include_hotkeys: {include_hotkeys}\n" + f"exclude_hotkeys: {exclude_hotkeys}\n" + f"safe_staking: {safe_staking}\n" + f"rate_tolerance: {rate_tolerance}\n" + f"allow_partial_stake: {allow_partial_stake}\n" + f"period: {period}\n" + ) return self._run_command( add_stake.stake_add( wallet, @@ -3695,11 +4322,11 @@ def stake_remove( "Interactive mode cannot be used with hotkey selection options like " "--include-hotkeys, --exclude-hotkeys, --all-hotkeys, or --hotkey." ) - raise typer.Exit() + return False if unstake_all and unstake_all_alpha: print_error("Cannot specify both unstake-all and unstake-all-alpha.") - raise typer.Exit() + return False if not interactive and not unstake_all and not unstake_all_alpha: netuid = get_optional_netuid(netuid, all_netuids) @@ -3708,23 +4335,39 @@ def stake_remove( "You have specified hotkeys to include and also the `--all-hotkeys` flag. The flag" " should only be used standalone (to use all hotkeys) or with `--exclude-hotkeys`." ) - raise typer.Exit() + return False if include_hotkeys and exclude_hotkeys: print_error( "You have specified both including and excluding hotkeys options. Select one or the other." ) - raise typer.Exit() + return False if unstake_all and amount: print_error( "Cannot specify both a specific amount and 'unstake-all'. Choose one or the other." ) - raise typer.Exit() + return False if amount and amount <= 0: print_error(f"You entered an incorrect unstake amount: {amount}") - raise typer.Exit() + return False + + if include_hotkeys: + include_hotkeys = parse_to_list( + include_hotkeys, + str, + "Hotkeys must be a comma-separated list of ss58s or names, e.g., `--include-hotkeys hk1,hk2`.", + is_ss58=False, + ) + + if exclude_hotkeys: + exclude_hotkeys = parse_to_list( + exclude_hotkeys, + str, + "Hotkeys must be a comma-separated list of ss58s or names, e.g., `--exclude-hotkeys hk3,hk4`.", + is_ss58=False, + ) if ( not wallet_hotkey @@ -3741,7 +4384,8 @@ def stake_remove( default=self.config.get("wallet_name") or defaults.wallet.name, ) hotkey_or_ss58 = Prompt.ask( - "Enter the [blue]hotkey[/blue] name or [blue]ss58 address[/blue] to unstake from [dim](or Press Enter to view existing staked hotkeys)[/dim]", + "Enter the [blue]hotkey[/blue] name or [blue]ss58 address[/blue] to unstake from [dim]" + "(or Press Enter to view existing staked hotkeys)[/dim]", ) if hotkey_or_ss58 == "": wallet = self.wallet_ask( @@ -3772,12 +4416,12 @@ def stake_remove( if include_hotkeys: if len(include_hotkeys) > 1: print_error("Cannot unstake_all from multiple hotkeys at once.") - raise typer.Exit() + return False elif is_valid_ss58_address(include_hotkeys[0]): hotkey_ss58_address = include_hotkeys[0] else: print_error("Invalid hotkey ss58 address.") - raise typer.Exit() + return False elif all_hotkeys: wallet = self.wallet_ask( wallet_name, @@ -3788,7 +4432,8 @@ def stake_remove( else: if not hotkey_ss58_address and not wallet_hotkey: hotkey_or_ss58 = Prompt.ask( - "Enter the [blue]hotkey[/blue] name or [blue]ss58 address[/blue] to unstake all from [dim](or enter 'all' to unstake from all hotkeys)[/dim]", + "Enter the [blue]hotkey[/blue] name or [blue]ss58 address[/blue] to unstake all from [dim]" + "(or enter 'all' to unstake from all hotkeys)[/dim]", default=self.config.get("wallet_hotkey") or defaults.wallet.hotkey, ) @@ -3820,6 +4465,17 @@ def stake_remove( ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], validate=WV.WALLET_AND_HOTKEY, ) + logger.debug( + "args:\n" + f"network: {network}\n" + f"hotkey_ss58_address: {hotkey_ss58_address}\n" + f"unstake_all: {unstake_all}\n" + f"unstake_all_alpha: {unstake_all_alpha}\n" + f"all_hotkeys: {all_hotkeys}\n" + f"include_hotkeys: {include_hotkeys}\n" + f"exclude_hotkeys: {exclude_hotkeys}\n" + f"era: {period}" + ) return self._run_command( remove_stake.unstake_all( wallet=wallet, @@ -3854,22 +4510,38 @@ def stake_remove( ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], validate=WV.WALLET_AND_HOTKEY, ) - - if include_hotkeys: - include_hotkeys = parse_to_list( - include_hotkeys, - str, - "Hotkeys must be a comma-separated list of ss58s or names, e.g., `--include-hotkeys hk1,hk2`.", - is_ss58=False, + if not amount and not prompt: + print_error( + f"Ambiguous request! Specify {arg__('--amount')}, {arg__('--all')}, or {arg__('--all-alpha')} " + f"to use {arg__('--no-prompt')}" ) + return False - if exclude_hotkeys: - exclude_hotkeys = parse_to_list( - exclude_hotkeys, - str, - "Hotkeys must be a comma-separated list of ss58s or names, e.g., `--exclude-hotkeys hk3,hk4`.", - is_ss58=False, + if not amount and json_output: + json_console.print_json( + data={ + "success": False, + "err_msg": "Ambiguous request! Specify '--amount', '--all', " + "or '--all-alpha' to use '--json-output'", + } ) + return False + logger.debug( + "args:\n" + f"network: {network}\n" + f"hotkey_ss58_address: {hotkey_ss58_address}\n" + f"all_hotkeys: {all_hotkeys}\n" + f"include_hotkeys: {include_hotkeys}\n" + f"exclude_hotkeys: {exclude_hotkeys}\n" + f"amount: {amount}\n" + f"prompt: {prompt}\n" + f"interactive: {interactive}\n" + f"netuid: {netuid}\n" + f"safe_staking: {safe_staking}\n" + f"rate_tolerance: {rate_tolerance}\n" + f"allow_partial_stake: {allow_partial_stake}\n" + f"era: {period}" + ) return self._run_command( remove_stake.unstake( @@ -3896,7 +4568,19 @@ def stake_move( network: Optional[list[str]] = Options.network, wallet_name: Optional[str] = Options.wallet_name, wallet_path: Optional[str] = Options.wallet_path, - wallet_hotkey: Optional[str] = Options.wallet_hotkey_ss58, + wallet_hotkey: Optional[str] = typer.Option( + None, + "--from", + "--hotkey", + "--hotkey-ss58", + "-H", + "--wallet_hotkey", + "--wallet_hotkey_ss58", + "--wallet-hotkey", + "--wallet-hotkey-ss58", + "--wallet.hotkey", + help="Validator hotkey or SS58 where the stake is currently located.", + ), origin_netuid: Optional[int] = typer.Option( None, "--origin-netuid", help="Origin netuid" ), @@ -3904,7 +4588,12 @@ def stake_move( None, "--dest-netuid", help="Destination netuid" ), destination_hotkey: Optional[str] = typer.Option( - None, "--dest-ss58", "--dest", help="Destination hotkey", prompt=False + None, + "--to", + "--dest-ss58", + "--dest", + help="Destination validator hotkey SS58", + prompt=False, ), amount: float = typer.Option( None, @@ -3942,9 +4631,17 @@ def stake_move( [green]$[/green] btcli stake move """ 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 prompt: + if not Confirm.ask( + "This transaction will [bold]move stake[/bold] to another hotkey while keeping the same " + "coldkey ownership. Do you wish to continue? ", + default=False, + ): + raise typer.Exit() + else: + 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 " @@ -3971,7 +4668,7 @@ def stake_move( ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], validate=WV.WALLET_AND_HOTKEY, ) - destination_hotkey = destination_wallet.hotkey.ss58_address + destination_hotkey = get_hotkey_pub_ss58(destination_wallet) else: if is_valid_ss58_address(destination_hotkey): destination_hotkey = destination_hotkey @@ -4010,7 +4707,7 @@ def stake_move( ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], validate=WV.WALLET_AND_HOTKEY, ) - origin_hotkey = wallet.hotkey.ss58_address + origin_hotkey = get_hotkey_pub_ss58(wallet) else: if is_valid_ss58_address(wallet_hotkey): origin_hotkey = wallet_hotkey @@ -4022,7 +4719,7 @@ def stake_move( ask_for=[], validate=WV.WALLET_AND_HOTKEY, ) - origin_hotkey = wallet.hotkey.ss58_address + origin_hotkey = get_hotkey_pub_ss58(wallet) if not interactive_selection: if origin_netuid is None: @@ -4034,8 +4731,20 @@ def stake_move( destination_netuid = IntPrompt.ask( "Enter the [blue]destination subnet[/blue] (netuid) to move stake to" ) - - result = self._run_command( + logger.debug( + "args:\n" + f"network: {network}\n" + f"origin_netuid: {origin_netuid}\n" + f"origin_hotkey: {origin_hotkey}\n" + f"destination_hotkey: {destination_hotkey}\n" + f"destination_netuid: {destination_netuid}\n" + f"amount: {amount}\n" + f"stake_all: {stake_all}\n" + f"era: {period}\n" + f"interactive_selection: {interactive_selection}\n" + f"prompt: {prompt}\n" + ) + result, ext_id = self._run_command( move_stake.move_stake( subtensor=self.initialize_chain(network), wallet=wallet, @@ -4051,7 +4760,9 @@ def stake_move( ) ) if json_output: - json_console.print(json.dumps({"success": result})) + json_console.print( + json.dumps({"success": result, "extrinsic_identifier": ext_id or None}) + ) return result def stake_transfer( @@ -4125,9 +4836,18 @@ def stake_transfer( [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]" - ) + if prompt: + if not Confirm.ask( + "This transaction will [bold]transfer ownership[/bold] from one coldkey to another, in subnets " + "which have enabled it. You should ensure that the destination coldkey is " + "[bold]not a validator hotkey[/bold] before continuing. Do you wish to continue?", + default=False, + ): + raise typer.Exit() + else: + console.print( + "[dim]This command transfers stake from one coldkey to another while keeping the same hotkey.[/dim]" + ) if not dest_ss58: dest_ss58 = Prompt.ask( @@ -4158,7 +4878,8 @@ def stake_transfer( interactive_selection = False if not wallet_hotkey: origin_hotkey = Prompt.ask( - "Enter the [blue]origin hotkey[/blue] name or ss58 address [bold](stake will be transferred FROM here)[/bold] " + "Enter the [blue]origin hotkey[/blue] name or ss58 address [bold]" + "(stake will be transferred FROM here)[/bold] " "[dim](or press Enter to select from existing stakes)[/dim]" ) if origin_hotkey == "": @@ -4174,7 +4895,7 @@ def stake_transfer( ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], validate=WV.WALLET_AND_HOTKEY, ) - origin_hotkey = wallet.hotkey.ss58_address + origin_hotkey = get_hotkey_pub_ss58(wallet) else: if is_valid_ss58_address(wallet_hotkey): origin_hotkey = wallet_hotkey @@ -4186,7 +4907,7 @@ def stake_transfer( ask_for=[], validate=WV.WALLET_AND_HOTKEY, ) - origin_hotkey = wallet.hotkey.ss58_address + origin_hotkey = get_hotkey_pub_ss58(wallet) if not interactive_selection: if origin_netuid is None: @@ -4198,8 +4919,19 @@ def stake_transfer( dest_netuid = IntPrompt.ask( "Enter the [blue]destination subnet[/blue] (netuid)" ) - - result = self._run_command( + logger.debug( + "args:\n" + f"network: {network}\n" + f"origin_hotkey: {origin_hotkey}\n" + f"origin_netuid: {origin_netuid}\n" + f"dest_netuid: {dest_netuid}\n" + f"dest_hotkey: {origin_hotkey}\n" + f"dest_coldkey_ss58: {dest_ss58}\n" + f"amount: {amount}\n" + f"era: {period}\n" + f"stake_all: {stake_all}" + ) + result, ext_id = self._run_command( move_stake.transfer_stake( wallet=wallet, subtensor=self.initialize_chain(network), @@ -4215,7 +4947,9 @@ def stake_transfer( ) ) if json_output: - json_console.print(json.dumps({"success": result})) + json_console.print( + json.dumps({"success": result, "extrinsic_identifier": ext_id or None}) + ) return result def stake_swap( @@ -4306,8 +5040,20 @@ def stake_swap( ) if not amount and not swap_all: amount = FloatPrompt.ask("Enter the [blue]amount[/blue] to swap") - - result = self._run_command( + logger.debug( + "args:\n" + f"network: {network}\n" + f"origin_netuid: {origin_netuid}\n" + f"dest_netuid: {dest_netuid}\n" + f"amount: {amount}\n" + f"swap_all: {swap_all}\n" + f"era: {period}\n" + f"interactive_selection: {interactive_selection}\n" + f"prompt: {prompt}\n" + f"wait_for_inclusion: {wait_for_inclusion}\n" + f"wait_for_finalization: {wait_for_finalization}\n" + ) + result, ext_id = self._run_command( move_stake.swap_stake( wallet=wallet, subtensor=self.initialize_chain(network), @@ -4323,7 +5069,9 @@ def stake_swap( ) ) if json_output: - json_console.print(json.dumps({"success": result})) + json_console.print( + json.dumps({"success": result, "extrinsic_identifier": ext_id or None}) + ) return result def stake_get_children( @@ -4422,7 +5170,7 @@ def stake_set_children( EXAMPLE - [green]$[/green] btcli stake child set -c 5FCL3gmjtQV4xxxxuEPEFQVhyyyyqYgNwX7drFLw7MSdBnxP -c 5Hp5dxxxxtGg7pu8dN2btyyyyVA1vELmM9dy8KQv3LxV8PA7 --hotkey default --netuid 1 -p 0.3 -p 0.7 + [green]$[/green] btcli stake child set -c 5FCL3gmjtQV4xxxxuEPEFQVhyyyyqYgNwX7drFLw7MSdBnxP -c 5Hp5dxxxxtGg7pu8dN2btyyyyVA1vELmM9dy8KQv3LxV8PA7 --hotkey default --netuid 1 --prop 0.3 --prop 0.7 """ self.verbosity_handler(quiet, verbose, json_output) netuid = get_optional_netuid(netuid, all_netuids) @@ -4455,6 +5203,15 @@ def stake_set_children( ask_for=[WO.NAME, WO.HOTKEY], validate=WV.WALLET_AND_HOTKEY, ) + logger.debug( + "args:\n" + f"network: {network}\n" + f"netuid: {netuid}\n" + f"children: {children}\n" + f"proportions: {proportions}\n" + f"wait_for_inclusion: {wait_for_inclusion}\n" + f"wait_for_finalization: {wait_for_finalization}\n" + ) return self._run_command( children_hotkeys.set_children( wallet=wallet, @@ -4520,6 +5277,13 @@ def stake_revoke_children( netuid = IntPrompt.ask( "Enter netuid (leave blank for all)", default=None, show_default=True ) + logger.debug( + "args:\n" + f"network: {network}\n" + f"netuid: {netuid}\n" + f"wait_for_inclusion: {wait_for_inclusion}\n" + f"wait_for_finalization: {wait_for_finalization}\n" + ) return self._run_command( children_hotkeys.revoke_children( wallet, @@ -4538,7 +5302,12 @@ def stake_childkey_take( wallet_hotkey: Optional[str] = Options.wallet_hotkey, wallet_path: Optional[str] = Options.wallet_path, network: Optional[list[str]] = Options.network, - hotkey: Optional[str] = None, + child_hotkey_ss58: Optional[str] = typer.Option( + None, + "--child-hotkey-ss58", + help="The hotkey SS58 to designate as child (not specifying will use the provided wallet's hotkey)", + prompt=False, + ), netuid: Optional[int] = typer.Option( None, help="The netuid of the subnet, (e.g. 23)", @@ -4575,11 +5344,11 @@ def stake_childkey_take( To get the current take value, do not use the '--take' option: - [green]$[/green] btcli stake child take --hotkey --netuid 1 + [green]$[/green] btcli stake child take --child-hotkey-ss58 --netuid 1 To set a new take value, use the '--take' option: - [green]$[/green] btcli stake child take --hotkey --take 0.12 --netuid 1 + [green]$[/green] btcli stake child take --child-hotkey-ss58 --take 0.12 --netuid 1 """ self.verbosity_handler(quiet, verbose, json_output) wallet = self.wallet_ask( @@ -4598,13 +5367,21 @@ def stake_childkey_take( netuid = IntPrompt.ask( "Enter netuid (leave blank for all)", default=None, show_default=True ) - results: list[tuple[Optional[int], bool]] = self._run_command( + logger.debug( + "args:\n" + f"network: {network}\n" + f"netuid: {netuid}\n" + f"take: {take}\n" + f"wait_for_inclusion: {wait_for_inclusion}\n" + f"wait_for_finalization: {wait_for_finalization}\n" + ) + results: list[tuple[Optional[int], bool, Optional[str]]] = self._run_command( children_hotkeys.childkey_take( wallet=wallet, subtensor=self.initialize_chain(network), netuid=netuid, take=take, - hotkey=hotkey, + hotkey=child_hotkey_ss58, wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, prompt=prompt, @@ -4612,56 +5389,288 @@ def stake_childkey_take( ) if json_output: output = {} - for netuid_, success in results: - output[netuid_] = success + for netuid_, success, ext_id in results: + output[netuid_] = {"success": success, "extrinsic_identifier": ext_id} json_console.print(json.dumps(output)) return results - def sudo_set( + def mechanism_count_set( self, network: Optional[list[str]] = Options.network, wallet_name: str = Options.wallet_name, wallet_path: str = Options.wallet_path, wallet_hotkey: str = Options.wallet_hotkey, netuid: int = Options.netuid, - param_name: str = typer.Option( - "", "--param", "--parameter", help="The subnet hyperparameter to set" - ), - param_value: Optional[str] = typer.Option( - "", "--value", help="Value to set the hyperparameter to." + mechanism_count: Optional[int] = typer.Option( + None, + "--count", + "--mech-count", + help="Number of mechanisms to set for the subnet.", ), + wait_for_inclusion: bool = Options.wait_for_inclusion, + wait_for_finalization: bool = Options.wait_for_finalization, prompt: bool = Options.prompt, quiet: bool = Options.quiet, verbose: bool = Options.verbose, json_output: bool = Options.json_output, ): """ - Used to set hyperparameters for a specific subnet. + Configure how many mechanisms are registered for a subnet. - This command allows subnet owners to modify hyperparameters such as its tempo, emission rates, and other hyperparameters. + The base mechanism at index 0 and new ones are incremented by 1. - EXAMPLE + [bold]Common Examples:[/bold] + + 1. Prompt for the new mechanism count interactively: + [green]$[/green] btcli subnet mech set --netuid 12 + + 2. Set the count to 2 using a specific wallet: + [green]$[/green] btcli subnet mech set --netuid 12 --count 2 --wallet.name my_wallet --wallet.hotkey admin - [green]$[/green] btcli sudo set --netuid 1 --param tempo --value 400 """ + self.verbosity_handler(quiet, verbose, json_output) + subtensor = self.initialize_chain(network) - if not param_name or not param_value: - hyperparams = self._run_command( - sudo.get_hyperparameters(self.initialize_chain(network), netuid), + if not json_output: + current_count = self._run_command( + subnet_mechanisms.count( + subtensor=subtensor, + netuid=netuid, + json_output=False, + ), + exit_early=False, + ) + else: + current_count = self._run_command( + subtensor.get_subnet_mechanisms(netuid), exit_early=False, ) - if not hyperparams: - raise typer.Exit() - if not param_name: + if mechanism_count is None: if not prompt: err_console.print( - "Param name not supplied with `--no-prompt` flag. Cannot continue" + "Mechanism count not supplied with `--no-prompt` flag. Cannot continue." ) return False - hyperparam_list = sorted( - [field.name for field in fields(SubnetHyperparameters)] + prompt_text = "\n\nEnter the [blue]number of mechanisms[/blue] to set" + mechanism_count = IntPrompt.ask(prompt_text) + + if mechanism_count == current_count: + visible_count = max(mechanism_count - 1, 0) + message = ( + ":white_heavy_check_mark: " + f"[dark_sea_green3]Subnet {netuid} already has {visible_count} mechanism" + f"{'s' if visible_count != 1 else ''}.[/dark_sea_green3]" + ) + if json_output: + json_console.print( + json.dumps( + { + "success": True, + "message": f"Subnet {netuid} already has {visible_count} mechanisms.", + "extrinsic_identifier": None, + } + ) + ) + else: + console.print(message) + return True + + wallet = self.wallet_ask( + wallet_name, + wallet_path, + wallet_hotkey, + ask_for=[WO.NAME, WO.PATH], + ) + + logger.debug( + "args:\n" + f"network: {network}\n" + f"netuid: {netuid}\n" + f"mechanism_count: {mechanism_count}\n" + ) + + result, err_msg, ext_id = self._run_command( + subnet_mechanisms.set_mechanism_count( + wallet=wallet, + subtensor=subtensor, + netuid=netuid, + mechanism_count=mechanism_count, + previous_count=current_count or 0, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + json_output=json_output, + ) + ) + + if json_output: + json_console.print_json( + data={ + "success": result, + "message": err_msg, + "extrinsic_identifier": ext_id, + } + ) + + return result + + def mechanism_count_get( + self, + network: Optional[list[str]] = Options.network, + netuid: int = Options.netuid, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """ + Display how many mechanisms are registered under a subnet. + + Includes the base mechanism (index 0). Helpful for verifying the active + mechanism counts in a subnet. + + [green]$[/green] btcli subnet mech count --netuid 12 + """ + + self.verbosity_handler(quiet, verbose, json_output) + subtensor = self.initialize_chain(network) + return self._run_command( + subnet_mechanisms.count( + subtensor=subtensor, + netuid=netuid, + json_output=json_output, + ) + ) + + def mechanism_emission_set( + self, + network: Optional[list[str]] = Options.network, + wallet_name: str = Options.wallet_name, + wallet_path: str = Options.wallet_path, + wallet_hotkey: str = Options.wallet_hotkey, + netuid: int = Options.netuid, + split: Optional[str] = typer.Option( + None, + "--split", + help="Comma-separated relative weights for each mechanism (normalised automatically).", + ), + wait_for_inclusion: bool = Options.wait_for_inclusion, + wait_for_finalization: bool = Options.wait_for_finalization, + prompt: bool = Options.prompt, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """ + Update the emission split across mechanisms for a subnet. + + Accepts comma-separated weights (U16 values or percentages). When `--split` + is omitted and prompts remain enabled, you will be guided interactively and + the CLI automatically normalises the weights. + + [bold]Common Examples:[/bold] + + 1. Configure the split interactively: + [green]$[/green] btcli subnet mech emissions-split --netuid 12 + + 2. Apply a 70/30 distribution in one command: + [green]$[/green] btcli subnet mech emissions-split --netuid 12 --split 70,30 --wallet.name my_wallet --wallet.hotkey admin + """ + + self.verbosity_handler(quiet, verbose, json_output) + subtensor = self.initialize_chain(network) + wallet = self.wallet_ask( + wallet_name, + wallet_path, + wallet_hotkey, + ask_for=[WO.NAME, WO.PATH], + ) + + return self._run_command( + subnet_mechanisms.set_emission_split( + subtensor=subtensor, + wallet=wallet, + netuid=netuid, + new_emission_split=split, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + prompt=prompt, + json_output=json_output, + ) + ) + + def mechanism_emission_get( + self, + network: Optional[list[str]] = Options.network, + netuid: int = Options.netuid, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """ + Display the current emission split across mechanisms for a subnet. + + Shows raw U16 weights alongside percentage shares for each mechanism. Useful + for verifying the emission split in a subnet. + + [green]$[/green] btcli subnet mech emissions --netuid 12 + """ + + self.verbosity_handler(quiet, verbose, json_output) + subtensor = self.initialize_chain(network) + return self._run_command( + subnet_mechanisms.get_emission_split( + subtensor=subtensor, + netuid=netuid, + json_output=json_output, + ) + ) + + def sudo_set( + self, + network: Optional[list[str]] = Options.network, + wallet_name: str = Options.wallet_name, + wallet_path: str = Options.wallet_path, + wallet_hotkey: str = Options.wallet_hotkey, + netuid: int = Options.netuid, + param_name: str = typer.Option( + "", "--param", "--parameter", help="The subnet hyperparameter to set" + ), + param_value: Optional[str] = typer.Option( + "", "--value", help="Value to set the hyperparameter to." + ), + prompt: bool = Options.prompt, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """ + Used to set hyperparameters for a specific subnet. + + This command allows subnet owners to modify hyperparameters such as its tempo, emission rates, and other hyperparameters. + + EXAMPLE + + [green]$[/green] btcli sudo set --netuid 1 --param tempo --value 400 + """ + self.verbosity_handler(quiet, verbose, json_output) + + if not param_name or not param_value: + hyperparams = self._run_command( + sudo.get_hyperparameters(self.initialize_chain(network), netuid), + exit_early=False, + ) + if not hyperparams: + raise typer.Exit() + + if not param_name: + if not prompt: + err_console.print( + "Param name not supplied with `--no-prompt` flag. Cannot continue" + ) + return False + hyperparam_list = sorted( + [field.name for field in fields(SubnetHyperparameters)] ) console.print("Available hyperparameters:\n") for idx, param in enumerate(hyperparam_list, start=1): @@ -4683,12 +5692,8 @@ def sudo_set( ) return False param_name = "alpha_values" - low_val = FloatPrompt.ask( - f"Enter the new value for [{COLORS.G.ARG}]alpha_low[/{COLORS.G.ARG}]" - ) - high_val = FloatPrompt.ask( - f"Enter the new value for [{COLORS.G.ARG}]alpha_high[/{COLORS.G.ARG}]" - ) + low_val = FloatPrompt.ask(f"Enter the new value for {arg__('alpha_low')}") + high_val = FloatPrompt.ask(f"Enter the new value for {arg__('alpha_high')}") param_value = f"{low_val},{high_val}" if param_name == "yuma_version": if not prompt: @@ -4712,7 +5717,7 @@ def sudo_set( if param_name == "subnet_is_active": err_console.print( f"[{COLORS.SU.HYPERPARAM}]subnet_is_active[/{COLORS.SU.HYPERPARAM}] " - f"is set by using [{COLORS.G.ARG}]`btcli subnets start`[/{COLORS.G.ARG}] command." + f"is set by using {arg__('btcli subnets start')} command." ) return False @@ -4733,7 +5738,14 @@ def sudo_set( wallet = self.wallet_ask( wallet_name, wallet_path, wallet_hotkey, ask_for=[WO.NAME, WO.PATH] ) - result = self._run_command( + logger.debug( + "args:\n" + f"network: {network}\n" + f"netuid: {netuid}\n" + f"param_name: {param_name}\n" + f"param_value: {param_value}" + ) + result, err_msg, ext_id = self._run_command( sudo.sudo_set_hyperparameter( wallet, self.initialize_chain(network), @@ -4745,7 +5757,15 @@ def sudo_set( ) ) if json_output: - json_console.print(json.dumps({"success": result})) + json_console.print( + json.dumps( + { + "success": result, + "err_msg": err_msg, + "extrinsic_identifier": ext_id, + } + ) + ) return result def sudo_get( @@ -4830,7 +5850,7 @@ def sudo_senate_vote( None, "--vote-aye/--vote-nay", prompt="Enter y to vote Aye, or enter n to vote Nay", - help="The vote casted on the proposal", + help="The vote cast on the proposal", ), ): """ @@ -4853,6 +5873,7 @@ def sudo_senate_vote( ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], validate=WV.WALLET_AND_HOTKEY, ) + logger.debug(f"args:\nnetwork: {network}\nproposal: {proposal}\nvote: {vote}\n") return self._run_command( sudo.senate_vote( wallet, self.initialize_chain(network), proposal, vote, prompt @@ -4905,12 +5926,14 @@ def sudo_set_take( f"Take value must be between {min_value} and {max_value}. Provided value: {take}" ) raise typer.Exit() - - result = self._run_command( + logger.debug(f"args:\nnetwork: {network}\ntake: {take}") + result, ext_id = self._run_command( sudo.set_take(wallet, self.initialize_chain(network), take) ) if json_output: - json_console.print(json.dumps({"success": result})) + json_console.print( + json.dumps({"success": result, "extrinsic_identifier": ext_id}) + ) return result def sudo_get_take( @@ -4950,6 +5973,53 @@ def sudo_get_take( sudo.display_current_take(self.initialize_chain(network), wallet) ) + def sudo_trim( + self, + network: Optional[list[str]] = Options.network, + wallet_name: Optional[str] = Options.wallet_name, + wallet_path: Optional[str] = Options.wallet_path, + wallet_hotkey: Optional[str] = Options.wallet_hotkey, + netuid: int = Options.netuid, + max_uids: int = typer.Option( + None, + "--max", + "--max-uids", + help="The maximum number of allowed uids to which to trim", + prompt="Max UIDs", + ), + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + prompt: bool = Options.prompt, + period: int = Options.period, + ): + """ + Allows subnet owners to trim UIDs on their subnet to a specified max number of netuids. + + EXAMPLE + [green]$[/green] btcli sudo trim --netuid 95 --wallet-name my_wallet --wallet-hotkey my_hotkey --max 64 + """ + self.verbosity_handler(quiet, verbose, json_output) + + wallet = self.wallet_ask( + wallet_name, + wallet_path, + wallet_hotkey, + ask_for=[WO.NAME, WO.PATH], + validate=WV.WALLET, + ) + self._run_command( + sudo.trim( + subtensor=self.initialize_chain(network), + wallet=wallet, + netuid=netuid, + max_n=max_uids, + period=period, + json_output=json_output, + prompt=prompt, + ) + ) + def subnets_list( self, network: Optional[list[str]] = Options.network, @@ -5009,7 +6079,7 @@ def subnets_price( "Netuids to show the price for. Separate multiple netuids with a comma, for example: `-n 0,1,2`.", ), interval_hours: int = typer.Option( - 24, + 4, "--interval-hours", "--interval", help="The number of hours to show the historical price for.", @@ -5026,6 +6096,11 @@ def subnets_price( "--log", help="Show the price in log scale.", ), + current_only: bool = typer.Option( + False, + "--current", + help="Show only the current data, and no historical data.", + ), html_output: bool = Options.html_output, quiet: bool = Options.quiet, verbose: bool = Options.verbose, @@ -5048,9 +6123,28 @@ def subnets_price( [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`") + print_error( + f"Cannot specify both {arg__('--json-output')} and {arg__('--html')}" + ) + return + if current_only and html_output: + print_error( + f"Cannot specify both {arg__('--current')} and {arg__('--html')}" + ) return self.verbosity_handler(quiet=quiet, verbose=verbose, json_output=json_output) + + subtensor = self.initialize_chain(network) + non_archives = ["finney", "latent-lite"] + if not current_only and subtensor.network in non_archives + [ + Constants.network_map[x] for x in non_archives + ]: + err_console.print( + f"[red]Error[/red] Running this command without {arg__('--current')} requires use of an archive node. " + f"Try running again with the {arg__('--network archive')} flag." + ) + return False + if netuids: netuids = parse_to_list( netuids, @@ -5080,10 +6174,11 @@ def subnets_price( return self._run_command( price.price( - self.initialize_chain(network), + subtensor, netuids, all_netuids, interval_hours, + current_only, html_output, log_scale, json_output, @@ -5094,6 +6189,7 @@ def subnets_show( self, network: Optional[list[str]] = Options.network, netuid: int = Options.netuid, + mechanism_id: Optional[int] = Options.mechanism_id, sort: bool = typer.Option( False, "--sort", @@ -5105,18 +6201,43 @@ def subnets_show( json_output: bool = Options.json_output, ): """ - Displays detailed information about a subnet including participants and their state. + Inspect the metagraph for a subnet. - EXAMPLE + Shows miners, validators, stake, ranks, emissions, and other runtime stats. + When multiple mechanisms exist, the CLI prompts for one unless `--mechid` + is supplied. Netuid 0 always uses mechid 0. - [green]$[/green] btcli subnets list + [bold]Common Examples:[/bold] + + 1. Inspect the mechanism with prompts for selection: + [green]$[/green] btcli subnets show --netuid 12 + + 2. Pick mechanism 1 explicitly: + [green]$[/green] btcli subnets show --netuid 12 --mechid 1 """ self.verbosity_handler(quiet, verbose, json_output) subtensor = self.initialize_chain(network) + if netuid == 0: + mechanism_count = 1 + selected_mechanism_id = 0 + if mechanism_id not in (None, 0): + console.print( + "[dim]Mechanism selection ignored for the root subnet (only mechanism 0 exists).[/dim]" + ) + else: + mechanism_count = self._run_command( + subtensor.get_subnet_mechanisms(netuid), exit_early=False + ) + selected_mechanism_id = self.ask_subnet_mechanism( + mechanism_id, mechanism_count, netuid + ) + return self._run_command( subnets.show( subtensor=subtensor, netuid=netuid, + mechanism_id=selected_mechanism_id, + mechanism_count=mechanism_count, sort=sort, max_rows=None, delegate_selection=False, @@ -5221,6 +6342,7 @@ def subnets_create( logo_url=logo_url, additional=additional_info, ) + logger.debug(f"args:\nnetwork: {network}\nidentity: {identity}\n") self._run_command( subnets.create( wallet, self.initialize_chain(network), identity, json_output, prompt @@ -5282,6 +6404,7 @@ def subnets_start( ], validate=WV.WALLET, ) + logger.debug(f"args:\nnetwork: {network}\nnetuid: {netuid}\n") return self._run_command( subnets.start_subnet( wallet, @@ -5321,7 +6444,7 @@ def subnets_set_identity( network: Optional[list[str]] = Options.network, netuid: int = Options.netuid, subnet_name: Optional[str] = typer.Option( - None, "--subnet-name", "--name", help="Name of the subnet" + None, "--subnet-name", "--sn-name", help="Name of the subnet" ), github_repo: Optional[str] = typer.Option( None, "--github-repo", "--repo", help="GitHub repository URL" @@ -5397,14 +6520,18 @@ def subnets_set_identity( logo_url=logo_url, additional=additional_info, ) - - success = self._run_command( + logger.debug( + f"args:\nnetwork: {network}\nnetuid: {netuid}\nidentity: {identity}" + ) + success, ext_id = self._run_command( subnets.set_identity( wallet, self.initialize_chain(network), netuid, identity, prompt ) ) if json_output: - json_console.print(json.dumps({"success": success})) + json_console.print( + json.dumps({"success": success, "extrinsic_identifier": ext_id}) + ) def subnets_pow_register( self, @@ -5535,6 +6662,7 @@ def subnets_register( ask_for=[WO.NAME, WO.HOTKEY], validate=WV.WALLET_AND_HOTKEY, ) + logger.debug(f"args:\nnetwork: {network}\nnetuid: {netuid}\nperiod: {period}\n") return self._run_command( subnets.register( wallet, @@ -5647,42 +6775,92 @@ def subnets_metagraph( ) ) - def weights_reveal( + def subnets_set_symbol( self, - network: Optional[list[str]] = Options.network, wallet_name: str = Options.wallet_name, wallet_path: str = Options.wallet_path, wallet_hotkey: str = Options.wallet_hotkey, + network: Optional[list[str]] = Options.network, netuid: int = Options.netuid, - uids: str = typer.Option( - None, - "--uids", - "-u", - help="Corresponding UIDs for the specified netuid, e.g. -u 1,2,3 ...", - ), - weights: str = Options.weights, - salt: str = typer.Option( - None, - "--salt", - "-s", - help="Corresponding salt for the hash function, e.g. -s 163,241,217 ...", - ), json_output: bool = Options.json_output, + prompt: bool = Options.prompt, quiet: bool = Options.quiet, verbose: bool = Options.verbose, - prompt: bool = Options.prompt, + symbol: str = typer.Argument(help="The symbol to set for your subnet."), ): """ - Reveal weights for a specific subnet. + Allows the user to update their subnet symbol to a different available symbol. The full list of available symbols can be found here: + [#8CB9E9]https://github.com/opentensor/subtensor/blob/main/pallets/subtensor/src/subnets/symbols.rs#L8[/#8CB9E9] - You must specify the netuid, the UIDs you are interested in, and weights you wish to reveal. EXAMPLE - [green]$[/green] btcli wt reveal --netuid 1 --uids 1,2,3,4 --weights 0.1,0.2,0.3,0.4 --salt 163,241,217,11,161,142,147,189 + [green]$[/green] btcli subnets set-symbol [dark_orange]--netuid 1 シ[/dark_orange] + + + JSON OUTPUT: + If --json-output is used, the output will be in the following schema: + [#AFEFFF]{success: [dark_orange]bool[/dark_orange], message: [dark_orange]str[/dark_orange]}[/#AFEFFF] """ self.verbosity_handler(quiet, verbose, json_output) - uids = list_prompt(uids, int, "UIDs of interest for the specified netuid") + if len(symbol) > 1: + err_console.print("Your symbol must be a single character.") + return False + wallet = self.wallet_ask( + wallet_name, + wallet_path, + wallet_hotkey, + ask_for=[WO.NAME, WO.HOTKEY], + validate=WV.WALLET_AND_HOTKEY, + ) + return self._run_command( + subnets.set_symbol( + wallet=wallet, + subtensor=self.initialize_chain(network), + netuid=netuid, + symbol=symbol, + prompt=prompt, + json_output=json_output, + ) + ) + + def weights_reveal( + self, + network: Optional[list[str]] = Options.network, + wallet_name: str = Options.wallet_name, + wallet_path: str = Options.wallet_path, + wallet_hotkey: str = Options.wallet_hotkey, + netuid: int = Options.netuid, + uids: str = typer.Option( + None, + "--uids", + "-u", + help="Corresponding UIDs for the specified netuid, e.g. -u 1,2,3 ...", + ), + weights: str = Options.weights, + salt: str = typer.Option( + None, + "--salt", + "-s", + help="Corresponding salt for the hash function, e.g. -s 163,241,217 ...", + ), + json_output: bool = Options.json_output, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + prompt: bool = Options.prompt, + ): + """ + Reveal weights for a specific subnet. + + You must specify the netuid, the UIDs you are interested in, and weights you wish to reveal. + + EXAMPLE + + [green]$[/green] btcli wt reveal --netuid 1 --uids 1,2,3,4 --weights 0.1,0.2,0.3,0.4 --salt 163,241,217,11,161,142,147,189 + """ + self.verbosity_handler(quiet, verbose, json_output) + # TODO think we need to ','.split uids and weights ? + uids = list_prompt(uids, int, "UIDs of interest for the specified netuid") weights = list_prompt( weights, float, "Corresponding weights for the specified UIDs" ) @@ -5732,7 +6910,6 @@ def weights_reveal( ask_for=[WO.NAME, WO.PATH, WO.HOTKEY], validate=WV.WALLET_AND_HOTKEY, ) - return self._run_command( weights_cmds.reveal_weights( self.initialize_chain(network), @@ -5906,6 +7083,133 @@ def view_dashboard( ) ) + def stake_set_claim_type( + self, + claim_type: Optional[str] = typer.Argument( + None, + help="Claim type: 'keep' or 'swap'. If not provided, you'll be prompted to choose.", + ), + wallet_name: Optional[str] = Options.wallet_name, + wallet_path: Optional[str] = Options.wallet_path, + wallet_hotkey: Optional[str] = Options.wallet_hotkey, + network: Optional[list[str]] = Options.network, + prompt: bool = Options.prompt, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """ + Set the root claim type for your coldkey. + + Root claim types control how staking emissions are handled on the ROOT network (subnet 0): + + [bold]Claim Types:[/bold] + • [green]Swap[/green]: Future Root Alpha Emissions are swapped to TAO and added to root stake (default) + • [yellow]Keep[/yellow]: Future Root Alpha Emissions are kept as Alpha tokens + + USAGE: + + [green]$[/green] btcli stake claim + [green]$[/green] btcli stake claim keep + [green]$[/green] btcli stake claim swap + + With specific wallet: + + [green]$[/green] btcli stake claim swap --wallet-name my_wallet + """ + self.verbosity_handler(quiet, verbose, json_output) + + if claim_type is not None: + claim_type_normalized = claim_type.capitalize() + if claim_type_normalized not in ["Keep", "Swap"]: + err_console.print( + f":cross_mark: [red]Invalid claim type '{claim_type}'. Must be 'keep' or 'swap'.[/red]" + ) + raise typer.Exit() + else: + claim_type_normalized = None + + wallet = self.wallet_ask( + wallet_name, + wallet_path, + wallet_hotkey, + ask_for=[WO.NAME], + ) + return self._run_command( + claim_stake.set_claim_type( + wallet=wallet, + subtensor=self.initialize_chain(network), + claim_type=claim_type_normalized, + prompt=prompt, + json_output=json_output, + ) + ) + + def stake_process_claim( + self, + netuids: Optional[str] = Options.netuids, + wallet_name: Optional[str] = Options.wallet_name, + wallet_path: Optional[str] = Options.wallet_path, + wallet_hotkey: Optional[str] = Options.wallet_hotkey, + network: Optional[list[str]] = Options.network, + prompt: bool = Options.prompt, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """ + Manually claim accumulated root network emissions for your coldkey. + + [bold]Note:[/bold] The network will eventually process your pending emissions automatically. + However, you can choose to manually claim your emissions with a small extrinsic fee. + + A maximum of 5 netuids can be processed in one call. + + USAGE: + + [green]$[/green] btcli stake process-claim + + Claim from specific netuids: + + [green]$[/green] btcli stake process-claim --netuids 1,2,3 + + Claim with specific wallet: + + [green]$[/green] btcli stake process-claim --netuids 1,2 --wallet-name my_wallet + + """ + self.verbosity_handler(quiet, verbose, json_output) + + parsed_netuids = None + if netuids: + parsed_netuids = parse_to_list( + netuids, + int, + "Netuids must be a comma-separated list of ints, e.g., `--netuids 1,2,3`.", + ) + + if len(parsed_netuids) > 5: + print_error("Maximum 5 netuids allowed per claim") + return + + wallet = self.wallet_ask( + wallet_name, + wallet_path, + wallet_hotkey, + ask_for=[WO.NAME], + ) + + return self._run_command( + claim_stake.process_pending_claims( + wallet=wallet, + subtensor=self.initialize_chain(network), + netuids=parsed_netuids, + prompt=prompt, + json_output=json_output, + verbose=verbose, + ) + ) + def liquidity_add( self, network: Optional[list[str]] = Options.network, @@ -5978,7 +7282,14 @@ def liquidity_add( if price_low >= price_high: err_console.print("The low price must be lower than the high price.") return False - + logger.debug( + f"args:\n" + f"hotkey: {hotkey}\n" + f"netuid: {netuid}\n" + f"liquidity: {liquidity_}\n" + f"price_low: {price_low}\n" + f"price_high: {price_high}\n" + ) return self._run_command( liquidity.add_liquidity( subtensor=self.initialize_chain(network), @@ -6079,6 +7390,14 @@ def liquidity_remove( validate=WV.WALLET, return_wallet_and_hotkey=True, ) + logger.debug( + f"args:\n" + f"network: {network}\n" + f"hotkey: {hotkey}\n" + f"netuid: {netuid}\n" + f"position_id: {position_id}\n" + f"all_liquidity_ids: {all_liquidity_ids}\n" + ) return self._run_command( liquidity.remove_liquidity( subtensor=self.initialize_chain(network), @@ -6143,6 +7462,14 @@ def liquidity_modify( f"[blue]{position_id}[/blue] (can be positive or negative)", negative_allowed=True, ) + logger.debug( + f"args:\n" + f"network: {network}\n" + f"hotkey: {hotkey}\n" + f"netuid: {netuid}\n" + f"position_id: {position_id}\n" + f"liquidity_delta: {liquidity_delta}" + ) return self._run_command( liquidity.modify_liquidity( @@ -6157,36 +7484,671 @@ def liquidity_modify( ) ) - @staticmethod - @utils_app.command("convert") - def convert( - from_rao: Optional[str] = typer.Option( - None, "--rao", help="Convert amount from Rao" - ), - from_tao: Optional[float] = typer.Option( - None, "--tao", help="Convert amount from Tao" + def crowd_list( + self, + network: Optional[list[str]] = Options.network, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """ + List crowdloans together with their funding progress and key metadata. + + Shows every crowdloan on the selected network, including current status + (Active, Funded, Closed, Finalized), whether it is a subnet leasing crowdloan, + or a general fundraising crowdloan. + + Use `--verbose` for full-precision amounts and longer addresses. + + EXAMPLES + + [green]$[/green] btcli crowd list + + [green]$[/green] btcli crowd list --verbose + """ + self.verbosity_handler(quiet, verbose, json_output) + return self._run_command( + view_crowdloan.list_crowdloans( + subtensor=self.initialize_chain(network), + verbose=verbose, + json_output=json_output, + ) + ) + + def crowd_info( + self, + crowdloan_id: Optional[int] = typer.Option( + None, + "--crowdloan-id", + "--crowdloan_id", + "--id", + help="The ID of the crowdloan to display", ), + network: Optional[list[str]] = Options.network, + wallet_name: Optional[str] = Options.wallet_name, + wallet_path: Optional[str] = Options.wallet_path, + wallet_hotkey: Optional[str] = Options.wallet_hotkey, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, ): """ - Allows for converting between tao and rao using the specified flags + Display detailed information about a specific crowdloan. + + Includes funding progress, target account, and call details among other information. + + EXAMPLES + + [green]$[/green] btcli crowd info --id 0 + + [green]$[/green] btcli crowd info --id 1 --verbose """ - if from_tao is None and from_rao is None: - err_console.print("Specify `--rao` and/or `--tao`.") - raise typer.Exit() - if from_rao is not None: - rao = int(float(from_rao)) - console.print( - f"{rao}{Balance.rao_unit}", - "=", - Balance.from_rao(rao), + self.verbosity_handler(quiet, verbose, json_output) + + if crowdloan_id is None: + crowdloan_id = IntPrompt.ask( + f"Enter the [{COLORS.G.SUBHEAD_MAIN}]crowdloan id[/{COLORS.G.SUBHEAD_MAIN}]", + default=None, + show_default=False, ) - if from_tao is not None: - tao = float(from_tao) - console.print( - f"{Balance.unit}{tao}", - "=", - f"{Balance.from_tao(tao).rao}{Balance.rao_unit}", + + wallet = None + if wallet_name or wallet_path or wallet_hotkey: + wallet = self.wallet_ask( + wallet_name=wallet_name, + wallet_path=wallet_path, + wallet_hotkey=wallet_hotkey, + ask_for=[], + validate=WV.WALLET, + ) + + return self._run_command( + view_crowdloan.show_crowdloan_details( + subtensor=self.initialize_chain(network), + crowdloan_id=crowdloan_id, + wallet=wallet, + verbose=verbose, + json_output=json_output, ) + ) + + def crowd_create( + self, + network: Optional[list[str]] = Options.network, + wallet_name: str = Options.wallet_name, + wallet_path: str = Options.wallet_path, + wallet_hotkey: str = Options.wallet_hotkey, + deposit: Optional[float] = typer.Option( + None, + "--deposit", + help="Initial deposit in TAO to secure the crowdloan.", + min=1, + ), + min_contribution: Optional[float] = typer.Option( + None, + "--min-contribution", + "--min_contribution", + help="Minimum contribution amount in TAO.", + min=0.1, + ), + cap: Optional[int] = typer.Option( + None, + "--cap", + help="Maximum amount in TAO the crowdloan will raise.", + min=1, + ), + duration: Optional[int] = typer.Option( + None, + "--duration", + help="Crowdloan duration in blocks.", + min=1, + ), + target_address: Optional[str] = typer.Option( + None, + "--target-address", + "--target", + help="Optional target SS58 address to receive the raised funds (for fundraising type).", + ), + subnet_lease: Optional[bool] = typer.Option( + None, + "--subnet-lease/--fundraising", + help="Create a subnet leasing crowdloan (True) or general fundraising (False).", + ), + emissions_share: Optional[int] = typer.Option( + None, + "--emissions-share", + "--emissions", + help="Percentage of emissions for contributors (0-100) for subnet leasing.", + min=0, + max=100, + ), + lease_end_block: Optional[int] = typer.Option( + None, + "--lease-end-block", + "--lease-end", + help="Block number when subnet lease ends (omit for perpetual lease).", + min=1, + ), + prompt: bool = Options.prompt, + wait_for_inclusion: bool = Options.wait_for_inclusion, + wait_for_finalization: bool = Options.wait_for_finalization, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """Start a new crowdloan campaign for fundraising or subnet leasing. + + Create a crowdloan that can either: + 1. Raise funds for a specific address (general fundraising) + 2. Create a new leased subnet where contributors receive emissions + + EXAMPLES + + General fundraising: + [green]$[/green] btcli crowd create --deposit 10 --cap 1000 --target-address 5D... + + Subnet leasing with 30% emissions for contributors: + [green]$[/green] btcli crowd create --subnet-lease --emissions-share 30 + + Subnet lease ending at block 500000: + [green]$[/green] btcli crowd create --subnet-lease --emissions-share 25 --lease-end-block 500000 + """ + self.verbosity_handler(quiet, verbose, json_output) + + wallet = self.wallet_ask( + wallet_name=wallet_name, + wallet_path=wallet_path, + wallet_hotkey=wallet_hotkey, + ask_for=[WO.NAME, WO.PATH], + validate=WV.WALLET, + ) + + return self._run_command( + create_crowdloan.create_crowdloan( + subtensor=self.initialize_chain(network), + wallet=wallet, + deposit_tao=deposit, + min_contribution_tao=min_contribution, + cap_tao=cap, + duration_blocks=duration, + target_address=target_address, + subnet_lease=subnet_lease, + emissions_share=emissions_share, + lease_end_block=lease_end_block, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + prompt=prompt, + json_output=json_output, + ) + ) + + def crowd_contribute( + self, + crowdloan_id: Optional[int] = typer.Option( + None, + "--crowdloan-id", + "--crowdloan_id", + "--id", + help="The ID of the crowdloan to display", + ), + amount: Optional[float] = typer.Option( + None, + "--amount", + "-a", + help="Amount to contribute in TAO", + min=0.001, + ), + network: Optional[list[str]] = Options.network, + wallet_name: str = Options.wallet_name, + wallet_path: str = Options.wallet_path, + wallet_hotkey: str = Options.wallet_hotkey, + prompt: bool = Options.prompt, + wait_for_inclusion: bool = Options.wait_for_inclusion, + wait_for_finalization: bool = Options.wait_for_finalization, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """Contribute TAO to an active crowdloan. + + This command allows you to contribute TAO to a crowdloan that is currently accepting contributions. + The contribution will be automatically adjusted if it would exceed the crowdloan's cap. + + EXAMPLES + + [green]$[/green] btcli crowd contribute --id 0 --amount 100 + + [green]$[/green] btcli crowd contribute --id 1 + """ + self.verbosity_handler(quiet, verbose, json_output) + + if crowdloan_id is None: + crowdloan_id = IntPrompt.ask( + f"Enter the [{COLORS.G.SUBHEAD_MAIN}]crowdloan id[/{COLORS.G.SUBHEAD_MAIN}]", + default=None, + show_default=False, + ) + + wallet = self.wallet_ask( + wallet_name=wallet_name, + wallet_path=wallet_path, + wallet_hotkey=wallet_hotkey, + ask_for=[WO.NAME, WO.PATH], + validate=WV.WALLET, + ) + + return self._run_command( + crowd_contribute.contribute_to_crowdloan( + subtensor=self.initialize_chain(network), + wallet=wallet, + crowdloan_id=crowdloan_id, + amount=amount, + prompt=prompt, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + json_output=json_output, + ) + ) + + def crowd_withdraw( + self, + crowdloan_id: Optional[int] = typer.Option( + None, + "--crowdloan-id", + "--crowdloan_id", + "--id", + help="The ID of the crowdloan to withdraw from", + ), + network: Optional[list[str]] = Options.network, + wallet_name: str = Options.wallet_name, + wallet_path: str = Options.wallet_path, + wallet_hotkey: str = Options.wallet_hotkey, + prompt: bool = Options.prompt, + wait_for_inclusion: bool = Options.wait_for_inclusion, + wait_for_finalization: bool = Options.wait_for_finalization, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """ + Withdraw contributions from a non-finalized crowdloan. + + Non-creators can withdraw their full contribution. + Creators can only withdraw amounts above their initial deposit. + """ + self.verbosity_handler(quiet, verbose, json_output) + + if crowdloan_id is None: + crowdloan_id = IntPrompt.ask( + f"Enter the [{COLORS.G.SUBHEAD_MAIN}]crowdloan id[/{COLORS.G.SUBHEAD_MAIN}]", + default=None, + show_default=False, + ) + + wallet = self.wallet_ask( + wallet_name=wallet_name, + wallet_path=wallet_path, + wallet_hotkey=wallet_hotkey, + ask_for=[WO.NAME, WO.PATH], + validate=WV.WALLET, + ) + + return self._run_command( + crowd_contribute.withdraw_from_crowdloan( + subtensor=self.initialize_chain(network), + wallet=wallet, + crowdloan_id=crowdloan_id, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + prompt=prompt, + json_output=json_output, + ) + ) + + def crowd_finalize( + self, + crowdloan_id: Optional[int] = typer.Option( + None, + "--crowdloan-id", + "--crowdloan_id", + "--id", + help="The ID of the crowdloan to finalize", + ), + network: Optional[list[str]] = Options.network, + wallet_name: str = Options.wallet_name, + wallet_path: str = Options.wallet_path, + wallet_hotkey: str = Options.wallet_hotkey, + prompt: bool = Options.prompt, + wait_for_inclusion: bool = Options.wait_for_inclusion, + wait_for_finalization: bool = Options.wait_for_finalization, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """ + Finalize a successful crowdloan that has reached its cap. + + Only the creator can finalize. This will transfer funds to the target + address (if specified) and execute any attached call (e.g., subnet creation). + """ + self.verbosity_handler(quiet, verbose, json_output) + + if crowdloan_id is None: + crowdloan_id = IntPrompt.ask( + f"Enter the [{COLORS.G.SUBHEAD_MAIN}]crowdloan id[/{COLORS.G.SUBHEAD_MAIN}]", + default=None, + show_default=False, + ) + + wallet = self.wallet_ask( + wallet_name=wallet_name, + wallet_path=wallet_path, + wallet_hotkey=wallet_hotkey, + ask_for=[WO.NAME, WO.PATH], + validate=WV.WALLET, + ) + + return self._run_command( + create_crowdloan.finalize_crowdloan( + subtensor=self.initialize_chain(network), + wallet=wallet, + crowdloan_id=crowdloan_id, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + prompt=prompt, + json_output=json_output, + ) + ) + + def crowd_update( + self, + crowdloan_id: Optional[int] = typer.Option( + None, + "--crowdloan-id", + "--crowdloan_id", + "--id", + help="The ID of the crowdloan to update", + ), + min_contribution: Optional[float] = typer.Option( + None, + "--min-contribution", + "--min", + help="Update the minimum contribution amount (in TAO)", + ), + end: Optional[int] = typer.Option( + None, + "--end", + "--end-block", + help="Update the end block number", + ), + cap: Optional[float] = typer.Option( + None, + "--cap", + help="Update the cap amount (in TAO)", + ), + network: Optional[list[str]] = Options.network, + wallet_name: str = Options.wallet_name, + wallet_path: str = Options.wallet_path, + wallet_hotkey: str = Options.wallet_hotkey, + prompt: bool = Options.prompt, + wait_for_inclusion: bool = Options.wait_for_inclusion, + wait_for_finalization: bool = Options.wait_for_finalization, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """ + Update one mutable field on a non-finalized crowdloan. + + Only the creator can invoke this. You may change the minimum contribution, + the end block, or the cap in a single call. When no flag is provided an + interactive prompt guides you through the update and validates the input + against the chain constants (absolute minimum contribution, block-duration + bounds, etc.). + """ + self.verbosity_handler(quiet, verbose, json_output) + + if crowdloan_id is None: + crowdloan_id = IntPrompt.ask( + f"Enter the [{COLORS.G.SUBHEAD_MAIN}]crowdloan id[/{COLORS.G.SUBHEAD_MAIN}]", + default=None, + show_default=False, + ) + + wallet = self.wallet_ask( + wallet_name=wallet_name, + wallet_path=wallet_path, + wallet_hotkey=wallet_hotkey, + ask_for=[WO.NAME, WO.PATH], + validate=WV.WALLET, + ) + + min_contribution_balance = ( + Balance.from_tao(min_contribution) if min_contribution is not None else None + ) + cap_balance = Balance.from_tao(cap) if cap is not None else None + + return self._run_command( + crowd_update.update_crowdloan( + subtensor=self.initialize_chain(network), + wallet=wallet, + crowdloan_id=crowdloan_id, + min_contribution=min_contribution_balance, + end=end, + cap=cap_balance, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + prompt=prompt, + json_output=json_output, + ) + ) + + def crowd_refund( + self, + crowdloan_id: Optional[int] = typer.Option( + None, + "--crowdloan-id", + "--crowdloan_id", + "--id", + help="The ID of the crowdloan to refund", + ), + network: Optional[list[str]] = Options.network, + wallet_name: str = Options.wallet_name, + wallet_path: str = Options.wallet_path, + wallet_hotkey: str = Options.wallet_hotkey, + prompt: bool = Options.prompt, + wait_for_inclusion: bool = Options.wait_for_inclusion, + wait_for_finalization: bool = Options.wait_for_finalization, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """ + Refund contributors of a non-finalized crowdloan. + + Only the creator may call this. Each call refunds up to the on-chain `RefundContributorsLimit` contributors + (currently 50) excluding the creator. Run it repeatedly until everyone except the creator has been reimbursed. + + Contributors can call `btcli crowdloan withdraw` at will. + """ + self.verbosity_handler(quiet, verbose, json_output) + + if crowdloan_id is None: + crowdloan_id = IntPrompt.ask( + f"Enter the [{COLORS.G.SUBHEAD_MAIN}]crowdloan id[/{COLORS.G.SUBHEAD_MAIN}]", + default=None, + show_default=False, + ) + + wallet = self.wallet_ask( + wallet_name=wallet_name, + wallet_path=wallet_path, + wallet_hotkey=wallet_hotkey, + ask_for=[WO.NAME, WO.PATH], + validate=WV.WALLET, + ) + + return self._run_command( + crowd_refund.refund_crowdloan( + subtensor=self.initialize_chain(network), + wallet=wallet, + crowdloan_id=crowdloan_id, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + prompt=prompt, + json_output=json_output, + ) + ) + + def crowd_dissolve( + self, + crowdloan_id: Optional[int] = typer.Option( + None, + "--crowdloan-id", + "--crowdloan_id", + "--id", + help="The ID of the crowdloan to dissolve", + ), + network: Optional[list[str]] = Options.network, + wallet_name: str = Options.wallet_name, + wallet_path: str = Options.wallet_path, + wallet_hotkey: str = Options.wallet_hotkey, + prompt: bool = Options.prompt, + wait_for_inclusion: bool = Options.wait_for_inclusion, + wait_for_finalization: bool = Options.wait_for_finalization, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """ + Dissolve a crowdloan after all contributors have been refunded. + + Only the creator can dissolve. The crowdloan must be non-finalized and the + raised balance must equal the creator's own contribution (i.e., all other + contributions have been withdrawn or refunded). Dissolving returns the + creator's deposit and removes the crowdloan from storage. + + If there are funds still available other than the creator's contribution, + you can run `btcli crowd refund` to refund the remaining contributors. + """ + self.verbosity_handler(quiet, verbose, json_output) + + if crowdloan_id is None: + crowdloan_id = IntPrompt.ask( + f"Enter the [{COLORS.G.SUBHEAD_MAIN}]crowdloan id[/{COLORS.G.SUBHEAD_MAIN}]", + default=None, + show_default=False, + ) + + wallet = self.wallet_ask( + wallet_name=wallet_name, + wallet_path=wallet_path, + wallet_hotkey=wallet_hotkey, + ask_for=[WO.NAME, WO.PATH], + validate=WV.WALLET, + ) + + return self._run_command( + crowd_dissolve.dissolve_crowdloan( + subtensor=self.initialize_chain(network), + wallet=wallet, + crowdloan_id=crowdloan_id, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + prompt=prompt, + json_output=json_output, + ) + ) + + @staticmethod + def convert( + from_rao: Optional[str] = typer.Option( + None, "--rao", help="Convert amount from Rao" + ), + from_tao: Optional[float] = typer.Option( + None, "--tao", help="Convert amount from Tao" + ), + ): + """ + Allows for converting between tao and rao using the specified flags + """ + if from_tao is None and from_rao is None: + err_console.print("Specify `--rao` and/or `--tao`.") + raise typer.Exit() + if from_rao is not None: + rao = int(float(from_rao)) + console.print( + f"{rao}{Balance.rao_unit}", + "=", + Balance.from_rao(rao), + ) + if from_tao is not None: + tao = float(from_tao) + console.print( + f"{Balance.unit}{tao}", + "=", + f"{Balance.from_tao(tao).rao}{Balance.rao_unit}", + ) + + def best_connection( + self, + additional_networks: Optional[list[str]] = typer.Option( + None, + "--network", + help="Network(s) to test for the best connection", + ), + ): + """ + This command will give you the latency of all finney-like network in additional to any additional networks you specify via the '--network' flag + + The results are three-fold. One column is the overall time to initialise a connection, send the requests, and wait for the results. The second column measures single ping-pong speed once connected. The third makes a real world call to fetch the chain head. + + EXAMPLE + + [green]$[/green] btcli utils latency --network ws://189.234.12.45 --network wss://mysubtensor.duckdns.org + + """ + additional_networks = additional_networks or [] + if any(not x.startswith("ws") for x in additional_networks): + err_console.print( + "Invalid network endpoint. Ensure you are specifying a valid websocket endpoint" + f" (starting with [{COLORS.G.LINKS}]ws://[/{COLORS.G.LINKS}] or " + f"[{COLORS.G.LINKS}]wss://[/{COLORS.G.LINKS}]).", + ) + return False + results: dict[str, list[float]] = self._run_command( + best_connection(Constants.lite_nodes + additional_networks) + ) + sorted_results = { + k: v for k, v in sorted(results.items(), key=lambda item: item[1][0]) + } + table = Table( + Column("Network"), + Column("End to End Latency", style="cyan"), + Column("Single Request Ping", style="cyan"), + Column("Chain Head Request Latency", style="cyan"), + title="Connection Latencies (seconds)", + caption="lower value is faster", + ) + for n_name, ( + overall_latency, + single_request, + chain_head, + ) in sorted_results.items(): + table.add_row( + n_name, str(overall_latency), str(single_request), str(chain_head) + ) + console.print(table) + fastest = next(iter(sorted_results.keys())) + if conf_net := self.config.get("network", ""): + if not conf_net.startswith("ws") and conf_net in Constants.networks: + conf_net = Constants.network_map[conf_net] + if conf_net != fastest: + console.print( + f"The fastest network is {fastest}. You currently have {conf_net} selected as your default network." + f"\nYou can update this with {arg__(f'btcli config set --network {fastest}')}" + ) + return True def run(self): self.app() diff --git a/bittensor_cli/src/__init__.py b/bittensor_cli/src/__init__.py index ebb01b433..cc27ad38a 100644 --- a/bittensor_cli/src/__init__.py +++ b/bittensor_cli/src/__init__.py @@ -9,7 +9,6 @@ class Constants: "finney", "test", "archive", - "subvortex", "rao", "dev", "latent-lite", @@ -17,12 +16,11 @@ class Constants: finney_entrypoint = "wss://entrypoint-finney.opentensor.ai:443" finney_test_entrypoint = "wss://test.finney.opentensor.ai:443" archive_entrypoint = "wss://archive.chain.opentensor.ai:443" - subvortex_entrypoint = "ws://subvortex.info:9944" local_entrypoint = "ws://127.0.0.1:9944" rao_entrypoint = "wss://rao.chain.opentensor.ai:443" dev_entrypoint = "wss://dev.chain.opentensor.ai:443" - local_entrypoint = "ws://127.0.0.1:9944" latent_lite_entrypoint = "wss://lite.sub.latent.to:443" + lite_nodes = [finney_entrypoint, latent_lite_entrypoint] network_map = { "finney": finney_entrypoint, "test": finney_test_entrypoint, @@ -31,7 +29,6 @@ class Constants: "dev": dev_entrypoint, "rao": rao_entrypoint, "latent-lite": latent_lite_entrypoint, - "subvortex": subvortex_entrypoint, } genesis_block_hash_map = { "finney": "0x2f0555cc76fc2840a25a6ea3b9637146806f1f44b090c175ffde2a7e5ab36c03", @@ -88,12 +85,14 @@ class Defaults: class config: base_path = "~/.bittensor" path = "~/.bittensor/config.yml" + debug_file_path = "~/.bittensor/debug.txt" dictionary = { "network": None, "wallet_path": None, "wallet_name": None, "wallet_hotkey": None, "use_cache": True, + "disk_cache": False, "metagraph_cols": { "UID": True, "GLOBAL_STAKE": True, @@ -623,46 +622,62 @@ class WalletValidationTypes(Enum): } +class RootSudoOnly(Enum): + FALSE = 0 + TRUE = 1 + COMPLICATED = 2 + + HYPERPARAMS = { - # btcli name: (subtensor method, root-only bool) - "rho": ("sudo_set_rho", False), - "kappa": ("sudo_set_kappa", False), - "immunity_period": ("sudo_set_immunity_period", False), - "min_allowed_weights": ("sudo_set_min_allowed_weights", False), - "max_weights_limit": ("sudo_set_max_weight_limit", False), - "tempo": ("sudo_set_tempo", True), - "min_difficulty": ("sudo_set_min_difficulty", False), - "max_difficulty": ("sudo_set_max_difficulty", False), - "weights_version": ("sudo_set_weights_version_key", False), - "weights_rate_limit": ("sudo_set_weights_set_rate_limit", False), - "adjustment_interval": ("sudo_set_adjustment_interval", True), - "activity_cutoff": ("sudo_set_activity_cutoff", False), - "target_regs_per_interval": ("sudo_set_target_registrations_per_interval", True), - "min_burn": ("sudo_set_min_burn", True), - "max_burn": ("sudo_set_max_burn", True), - "bonds_moving_avg": ("sudo_set_bonds_moving_average", False), - "max_regs_per_block": ("sudo_set_max_registrations_per_block", True), - "serving_rate_limit": ("sudo_set_serving_rate_limit", False), - "max_validators": ("sudo_set_max_allowed_validators", True), - "adjustment_alpha": ("sudo_set_adjustment_alpha", False), - "difficulty": ("sudo_set_difficulty", False), + # btcli name: (subtensor method, root-only enum) + "rho": ("sudo_set_rho", RootSudoOnly.FALSE), + "kappa": ("sudo_set_kappa", RootSudoOnly.TRUE), + "immunity_period": ("sudo_set_immunity_period", RootSudoOnly.FALSE), + "min_allowed_weights": ("sudo_set_min_allowed_weights", RootSudoOnly.FALSE), + "max_weights_limit": ("sudo_set_max_weight_limit", RootSudoOnly.FALSE), + "tempo": ("sudo_set_tempo", RootSudoOnly.TRUE), + "min_difficulty": ("sudo_set_min_difficulty", RootSudoOnly.TRUE), + "max_difficulty": ("sudo_set_max_difficulty", RootSudoOnly.FALSE), + "weights_version": ("sudo_set_weights_version_key", RootSudoOnly.FALSE), + "weights_rate_limit": ("sudo_set_weights_set_rate_limit", RootSudoOnly.TRUE), + "adjustment_interval": ("sudo_set_adjustment_interval", RootSudoOnly.TRUE), + "activity_cutoff": ("sudo_set_activity_cutoff", RootSudoOnly.FALSE), + "target_regs_per_interval": ( + "sudo_set_target_registrations_per_interval", + RootSudoOnly.TRUE, + ), + "min_burn": ("sudo_set_min_burn", RootSudoOnly.FALSE), + "max_burn": ("sudo_set_max_burn", RootSudoOnly.TRUE), + "bonds_moving_avg": ("sudo_set_bonds_moving_average", RootSudoOnly.FALSE), + "max_regs_per_block": ("sudo_set_max_registrations_per_block", RootSudoOnly.TRUE), + "serving_rate_limit": ("sudo_set_serving_rate_limit", RootSudoOnly.FALSE), + "max_validators": ("sudo_set_max_allowed_validators", RootSudoOnly.TRUE), + "adjustment_alpha": ("sudo_set_adjustment_alpha", RootSudoOnly.FALSE), + "difficulty": ("sudo_set_difficulty", RootSudoOnly.TRUE), "commit_reveal_period": ( "sudo_set_commit_reveal_weights_interval", - False, + RootSudoOnly.FALSE, + ), + "commit_reveal_weights_enabled": ( + "sudo_set_commit_reveal_weights_enabled", + RootSudoOnly.FALSE, + ), + "alpha_values": ("sudo_set_alpha_values", RootSudoOnly.FALSE), + "liquid_alpha_enabled": ("sudo_set_liquid_alpha_enabled", RootSudoOnly.FALSE), + "registration_allowed": ( + "sudo_set_network_registration_allowed", + RootSudoOnly.TRUE, ), - "commit_reveal_weights_enabled": ("sudo_set_commit_reveal_weights_enabled", False), - "alpha_values": ("sudo_set_alpha_values", False), - "liquid_alpha_enabled": ("sudo_set_liquid_alpha_enabled", False), - "registration_allowed": ("sudo_set_network_registration_allowed", False), "network_pow_registration_allowed": ( "sudo_set_network_pow_registration_allowed", - False, + RootSudoOnly.FALSE, ), - "yuma3_enabled": ("sudo_set_yuma3_enabled", False), - "alpha_sigmoid_steepness": ("sudo_set_alpha_sigmoid_steepness", True), - "user_liquidity_enabled": ("toggle_user_liquidity", False), - "bonds_reset_enabled": ("sudo_set_bonds_reset_enabled", False), - "transfers_enabled": ("sudo_set_toggle_transfer", False), + "yuma3_enabled": ("sudo_set_yuma3_enabled", RootSudoOnly.FALSE), + "alpha_sigmoid_steepness": ("sudo_set_alpha_sigmoid_steepness", RootSudoOnly.TRUE), + "user_liquidity_enabled": ("toggle_user_liquidity", RootSudoOnly.COMPLICATED), + "bonds_reset_enabled": ("sudo_set_bonds_reset_enabled", RootSudoOnly.FALSE), + "transfers_enabled": ("sudo_set_toggle_transfer", RootSudoOnly.FALSE), + "min_allowed_uids": ("sudo_set_min_allowed_uids", RootSudoOnly.TRUE), } HYPERPARAMS_MODULE = { @@ -690,12 +705,17 @@ class WalletValidationTypes(Enum): "STAKE_MGMT": "Stake Management", "CHILD": "Child Hotkeys", "MOVEMENT": "Stake Movement", + "CLAIM": "Root Claim Management", }, "SUDO": { "CONFIG": "Subnet Configuration", "GOVERNANCE": "Governance", "TAKE": "Delegate take configuration", }, + "MECHANISMS": { + "CONFIG": "Mechanism Configuration", + "EMISSION": "Mechanism Emission", + }, "SUBNETS": { "INFO": "Subnet Information", "CREATION": "Subnet Creation & Management", @@ -709,6 +729,11 @@ class WalletValidationTypes(Enum): "LIQUIDITY": { "LIQUIDITY_MGMT": "Liquidity Management", }, + "CROWD": { + "INITIATOR": "Crowdloan Creation & Management", + "PARTICIPANT": "Crowdloan Participation", + "INFO": "Crowdloan Information", + }, } diff --git a/bittensor_cli/src/bittensor/chain_data.py b/bittensor_cli/src/bittensor/chain_data.py index 6fe7b3da7..cfcc699f5 100644 --- a/bittensor_cli/src/bittensor/chain_data.py +++ b/bittensor_cli/src/bittensor/chain_data.py @@ -13,6 +13,7 @@ u16_normalized_float as u16tf, u64_normalized_float as u64tf, decode_account_id, + get_netuid_and_subuid_by_storage_index, ) @@ -718,6 +719,7 @@ class DynamicInfo(InfoBase): network_registered_at: int subnet_identity: Optional[SubnetIdentity] subnet_volume: Balance + moving_price: float @classmethod def _fix_decoded(cls, decoded: Any) -> "DynamicInfo": @@ -786,6 +788,7 @@ def _fix_decoded(cls, decoded: Any) -> "DynamicInfo": network_registered_at=int(decoded.get("network_registered_at")), subnet_identity=subnet_identity, subnet_volume=subnet_volume, + moving_price=fixed_to_float(decoded["moving_price"], 32), ) def tao_to_alpha(self, tao: Balance) -> Balance: @@ -1082,12 +1085,13 @@ class MetagraphInfo(InfoBase): alpha_dividends_per_hotkey: list[ tuple[str, Balance] ] # List of dividend payout in alpha via subnet. + subuid: int = 0 @classmethod def _fix_decoded(cls, decoded: dict) -> "MetagraphInfo": """Returns a MetagraphInfo object from decoded chain data.""" # Subnet index - _netuid = decoded["netuid"] + _netuid, _subuid = get_netuid_and_subuid_by_storage_index(decoded["netuid"]) # Name and symbol decoded.update({"name": bytes(decoded.get("name")).decode()}) @@ -1100,6 +1104,7 @@ def _fix_decoded(cls, decoded: dict) -> "MetagraphInfo": return cls( # Subnet index netuid=_netuid, + subuid=_subuid, # Name and symbol name=decoded["name"], symbol=decoded["symbol"], @@ -1191,3 +1196,68 @@ def _fix_decoded(cls, decoded: dict) -> "MetagraphInfo": for adphk in decoded["alpha_dividends_per_hotkey"] ], ) + + +@dataclass +class SimSwapResult: + tao_amount: Balance + alpha_amount: Balance + tao_fee: Balance + alpha_fee: Balance + + @classmethod + def from_dict(cls, d: dict, netuid: int) -> "SimSwapResult": + return cls( + tao_amount=Balance.from_rao(d["tao_amount"]).set_unit(0), + alpha_amount=Balance.from_rao(d["alpha_amount"]).set_unit(netuid), + tao_fee=Balance.from_rao(d["tao_fee"]).set_unit(0), + alpha_fee=Balance.from_rao(d["alpha_fee"]).set_unit(netuid), + ) + + +@dataclass +class CrowdloanData(InfoBase): + creator: Optional[str] + funds_account: Optional[str] + deposit: Balance + min_contribution: Balance + cap: Balance + raised: Balance + end: int + finalized: bool + contributors_count: int + target_address: Optional[str] + has_call: bool + call_details: Optional[dict] = None + + @classmethod + def _fix_decoded(cls, decoded: dict[str, Any]) -> "CrowdloanData": + creator = ( + decode_account_id(creator_raw) + if (creator_raw := decoded.get("creator")) + else None + ) + funds_account = ( + decode_account_id(funds_raw) + if (funds_raw := decoded.get("funds_account")) + else None + ) + target_address = ( + decode_account_id(target_raw) + if (target_raw := decoded.get("target_address")) + else None + ) + return cls( + creator=creator, + funds_account=funds_account, + deposit=Balance.from_rao(int(decoded["deposit"])), + min_contribution=Balance.from_rao(int(decoded["min_contribution"])), + cap=Balance.from_rao(int(decoded["cap"])), + raised=Balance.from_rao(int(decoded["raised"])), + end=int(decoded["end"]), + finalized=bool(decoded["finalized"]), + contributors_count=int(decoded["contributors_count"]), + target_address=target_address, + has_call=bool(decoded["call"]), + call_details=decoded["call_details"], + ) diff --git a/bittensor_cli/src/bittensor/extrinsics/registration.py b/bittensor_cli/src/bittensor/extrinsics/registration.py index 8bbc8064f..a32bc1c3d 100644 --- a/bittensor_cli/src/bittensor/extrinsics/registration.py +++ b/bittensor_cli/src/bittensor/extrinsics/registration.py @@ -18,6 +18,7 @@ from typing import Optional import subprocess +from async_substrate_interface import AsyncExtrinsicReceipt from bittensor_wallet import Wallet from Crypto.Hash import keccak import numpy as np @@ -39,6 +40,8 @@ print_error, unlock_key, hex_to_bytes, + get_hotkey_pub_ss58, + print_extrinsic_id, ) if typing.TYPE_CHECKING: @@ -490,7 +493,7 @@ async def register_extrinsic( async def get_neuron_for_pubkey_and_subnet(): uid = await subtensor.query( - "SubtensorModule", "Uids", [netuid, wallet.hotkey.ss58_address] + "SubtensorModule", "Uids", [netuid, get_hotkey_pub_ss58(wallet)] ) if uid is None: return NeuronInfo.get_null_neuron() @@ -525,7 +528,7 @@ async def get_neuron_for_pubkey_and_subnet(): if not Confirm.ask( f"Continue Registration?\n" f" hotkey [{COLOR_PALETTE.G.HK}]({wallet.hotkey_str})[/{COLOR_PALETTE.G.HK}]:" - f"\t[{COLOR_PALETTE.G.HK}]{wallet.hotkey.ss58_address}[/{COLOR_PALETTE.G.HK}]\n" + f"\t[{COLOR_PALETTE.G.HK}]{get_hotkey_pub_ss58(wallet)}[/{COLOR_PALETTE.G.HK}]\n" f" coldkey [{COLOR_PALETTE.G.CK}]({wallet.name})[/{COLOR_PALETTE.G.CK}]:" f"\t[{COLOR_PALETTE.G.CK}]{wallet.coldkeypub.ss58_address}[/{COLOR_PALETTE.G.CK}]\n" f" network:\t\t[{COLOR_PALETTE.G.LINKS}]{subtensor.network}[/{COLOR_PALETTE.G.LINKS}]\n" @@ -577,7 +580,7 @@ async def get_neuron_for_pubkey_and_subnet(): if not pow_result: # might be registered already on this subnet is_registered = await is_hotkey_registered( - subtensor, netuid=netuid, hotkey_ss58=wallet.hotkey.ss58_address + subtensor, netuid=netuid, hotkey_ss58=get_hotkey_pub_ss58(wallet) ) if is_registered: err_console.print( @@ -598,7 +601,7 @@ async def get_neuron_for_pubkey_and_subnet(): "block_number": pow_result.block_number, "nonce": pow_result.nonce, "work": [int(byte_) for byte_ in pow_result.seal], - "hotkey": wallet.hotkey.ss58_address, + "hotkey": get_hotkey_pub_ss58(wallet), "coldkey": wallet.coldkeypub.ss58_address, }, ) @@ -639,7 +642,7 @@ async def get_neuron_for_pubkey_and_subnet(): is_registered = await is_hotkey_registered( subtensor, netuid=netuid, - hotkey_ss58=wallet.hotkey.ss58_address, + hotkey_ss58=get_hotkey_pub_ss58(wallet), ) if is_registered: console.print( @@ -678,7 +681,7 @@ async def burned_register_extrinsic( wait_for_inclusion: bool = True, wait_for_finalization: bool = True, era: Optional[int] = None, -) -> tuple[bool, str]: +) -> tuple[bool, str, Optional[str]]: """Registers the wallet to chain by recycling TAO. :param subtensor: The SubtensorInterface object to use for the call, initialized @@ -697,14 +700,14 @@ async def burned_register_extrinsic( """ if not (unlock_status := unlock_key(wallet, print_out=False)).success: - return False, unlock_status.message + return False, unlock_status.message, None with console.status( f":satellite: Checking Account on [bold]subnet:{netuid}[/bold]...", spinner="aesthetic", ) as status: my_uid = await subtensor.query( - "SubtensorModule", "Uids", [netuid, wallet.hotkey.ss58_address] + "SubtensorModule", "Uids", [netuid, get_hotkey_pub_ss58(wallet)] ) block_hash = await subtensor.substrate.get_chain_head() @@ -741,7 +744,7 @@ async def burned_register_extrinsic( f"hotkey: [{COLOR_PALETTE.G.HK}]{neuron.hotkey}[/{COLOR_PALETTE.G.HK}]\n" f"coldkey: [{COLOR_PALETTE.G.CK}]{neuron.coldkey}[/{COLOR_PALETTE.G.CK}]" ) - return True, "Already registered" + return True, "Already registered", None with console.status( ":satellite: Recycling TAO for Registration...", spinner="aesthetic" @@ -751,19 +754,21 @@ async def burned_register_extrinsic( call_function="burned_register", call_params={ "netuid": netuid, - "hotkey": wallet.hotkey.ss58_address, + "hotkey": get_hotkey_pub_ss58(wallet), }, ) - success, err_msg = await subtensor.sign_and_send_extrinsic( + success, err_msg, ext_receipt = await subtensor.sign_and_send_extrinsic( call, wallet, wait_for_inclusion, wait_for_finalization, era=era_ ) if not success: err_console.print(f":cross_mark: [red]Failed[/red]: {err_msg}") await asyncio.sleep(0.5) - return False, err_msg + return False, err_msg, None # Successful registration, final check for neuron and pubkey else: + ext_id = await ext_receipt.get_extrinsic_identifier() + await print_extrinsic_id(ext_receipt) with console.status(":satellite: Checking Balance...", spinner="aesthetic"): block_hash = await subtensor.substrate.get_chain_head() new_balance, netuids_for_hotkey, my_uid = await asyncio.gather( @@ -773,10 +778,10 @@ async def burned_register_extrinsic( reuse_block=False, ), subtensor.get_netuids_for_hotkey( - wallet.hotkey.ss58_address, block_hash=block_hash + get_hotkey_pub_ss58(wallet), block_hash=block_hash ), subtensor.query( - "SubtensorModule", "Uids", [netuid, wallet.hotkey.ss58_address] + "SubtensorModule", "Uids", [netuid, get_hotkey_pub_ss58(wallet)] ), ) @@ -790,13 +795,13 @@ async def burned_register_extrinsic( console.print( f":white_heavy_check_mark: [green]Registered on netuid {netuid} with UID {my_uid}[/green]" ) - return True, f"Registered on {netuid} with UID {my_uid}" + return True, f"Registered on {netuid} with UID {my_uid}", ext_id else: # neuron not found, try again err_console.print( ":cross_mark: [red]Unknown error. Neuron not found.[/red]" ) - return False, "Unknown error. Neuron not found." + return False, "Unknown error. Neuron not found.", ext_id async def run_faucet_extrinsic( @@ -1146,7 +1151,7 @@ async def _block_solver( timeout = 0.15 if cuda else 0.15 while netuid == -1 or not await is_hotkey_registered( - subtensor, netuid, wallet.hotkey.ss58_address + subtensor, netuid, get_hotkey_pub_ss58(wallet) ): # Wait until a solver finds a solution try: @@ -1748,78 +1753,81 @@ async def swap_hotkey_extrinsic( new_wallet: Wallet, netuid: Optional[int] = None, prompt: bool = False, -) -> bool: +) -> tuple[bool, Optional[AsyncExtrinsicReceipt]]: """ Performs an extrinsic update for swapping two hotkeys on the chain :return: Success """ block_hash = await subtensor.substrate.get_chain_head() + hk_ss58 = get_hotkey_pub_ss58(wallet) + new_hk_ss58 = get_hotkey_pub_ss58(new_wallet) + netuids_registered = await subtensor.get_netuids_for_hotkey( - wallet.hotkey.ss58_address, block_hash=block_hash + hk_ss58, block_hash=block_hash ) netuids_registered_new_hotkey = await subtensor.get_netuids_for_hotkey( - new_wallet.hotkey.ss58_address, block_hash=block_hash + new_hk_ss58, block_hash=block_hash ) if netuid is not None and netuid not in netuids_registered: err_console.print( - f":cross_mark: [red]Failed[/red]: Original hotkey {wallet.hotkey.ss58_address} is not registered on subnet {netuid}" + f":cross_mark: [red]Failed[/red]: Original hotkey {hk_ss58} is not registered on subnet {netuid}" ) - return False + return False, None elif not len(netuids_registered) > 0: err_console.print( - f"Original hotkey [dark_orange]{wallet.hotkey.ss58_address}[/dark_orange] is not registered on any subnet. " + f"Original hotkey [dark_orange]{hk_ss58}[/dark_orange] is not registered on any subnet. " f"Please register and try again" ) - return False + return False, None if netuid is not None: if netuid in netuids_registered_new_hotkey: err_console.print( - f":cross_mark: [red]Failed[/red]: New hotkey {new_wallet.hotkey.ss58_address} " + f":cross_mark: [red]Failed[/red]: New hotkey {new_hk_ss58} " f"is already registered on subnet {netuid}" ) - return False + return False, None else: if len(netuids_registered_new_hotkey) > 0: err_console.print( - f":cross_mark: [red]Failed[/red]: New hotkey {new_wallet.hotkey.ss58_address} " + f":cross_mark: [red]Failed[/red]: New hotkey {new_hk_ss58} " f"is already registered on subnet(s) {netuids_registered_new_hotkey}" ) - return False + return False, None if not unlock_key(wallet).success: - return False + return False, None if prompt: # Prompt user for confirmation. if netuid is not None: confirm_message = ( f"Do you want to swap [dark_orange]{wallet.name}[/dark_orange] hotkey \n\t" - f"[dark_orange]{wallet.hotkey.ss58_address} ({wallet.hotkey_str})[/dark_orange] with hotkey \n\t" - f"[dark_orange]{new_wallet.hotkey.ss58_address} ({new_wallet.hotkey_str})[/dark_orange] on subnet {netuid}\n" + f"[dark_orange]{hk_ss58} ({wallet.hotkey_str})[/dark_orange] with hotkey \n\t" + f"[dark_orange]{new_hk_ss58} ({new_wallet.hotkey_str})[/dark_orange] on subnet {netuid}\n" "This operation will cost [bold cyan]1 TAO (recycled)[/bold cyan]" ) else: confirm_message = ( f"Do you want to swap [dark_orange]{wallet.name}[/dark_orange] hotkey \n\t" - f"[dark_orange]{wallet.hotkey.ss58_address} ({wallet.hotkey_str})[/dark_orange] with hotkey \n\t" - f"[dark_orange]{new_wallet.hotkey.ss58_address} ({new_wallet.hotkey_str})[/dark_orange] on all subnets\n" + f"[dark_orange]{hk_ss58} ({wallet.hotkey_str})[/dark_orange] with hotkey \n\t" + f"[dark_orange]{new_hk_ss58} ({new_wallet.hotkey_str})[/dark_orange] on all subnets\n" "This operation will cost [bold cyan]1 TAO (recycled)[/bold cyan]" ) if not Confirm.ask(confirm_message): - return False + return False, None print_verbose( - f"Swapping {wallet.name}'s hotkey ({wallet.hotkey.ss58_address} - {wallet.hotkey_str}) with " - f"{new_wallet.name}'s hotkey ({new_wallet.hotkey.ss58_address} - {new_wallet.hotkey_str})" + f"Swapping {wallet.name}'s hotkey ({hk_ss58} - {wallet.hotkey_str}) with " + f"{new_wallet.name}'s hotkey ({new_hk_ss58} - {new_wallet.hotkey_str})" ) with console.status(":satellite: Swapping hotkeys...", spinner="aesthetic"): call_params = { - "hotkey": wallet.hotkey.ss58_address, - "new_hotkey": new_wallet.hotkey.ss58_address, + "hotkey": hk_ss58, + "new_hotkey": new_hk_ss58, "netuid": netuid, } @@ -1828,14 +1836,17 @@ async def swap_hotkey_extrinsic( call_function="swap_hotkey", call_params=call_params, ) - success, err_msg = await subtensor.sign_and_send_extrinsic(call, wallet) + success, err_msg, ext_receipt = await subtensor.sign_and_send_extrinsic( + call, wallet + ) if success: console.print( - f"Hotkey {wallet.hotkey.ss58_address} ({wallet.hotkey_str}) swapped for new hotkey: {new_wallet.hotkey.ss58_address} ({new_wallet.hotkey_str})" + f"Hotkey {hk_ss58} ({wallet.hotkey_str}) swapped for new hotkey: " + f"{new_hk_ss58} ({new_wallet.hotkey_str})" ) - return True + return True, ext_receipt else: err_console.print(f":cross_mark: [red]Failed[/red]: {err_msg}") time.sleep(0.5) - return False + return False, ext_receipt diff --git a/bittensor_cli/src/bittensor/extrinsics/root.py b/bittensor_cli/src/bittensor/extrinsics/root.py index d8d4900aa..ea515ed1a 100644 --- a/bittensor_cli/src/bittensor/extrinsics/root.py +++ b/bittensor_cli/src/bittensor/extrinsics/root.py @@ -18,7 +18,7 @@ import asyncio import hashlib import time -from typing import Union, List, TYPE_CHECKING +from typing import Union, List, TYPE_CHECKING, Optional from bittensor_wallet import Wallet, Keypair import numpy as np @@ -37,6 +37,8 @@ print_verbose, format_error_message, unlock_key, + get_hotkey_pub_ss58, + print_extrinsic_id, ) if TYPE_CHECKING: @@ -290,7 +292,7 @@ async def root_register_extrinsic( wallet: Wallet, wait_for_inclusion: bool = True, wait_for_finalization: bool = True, -) -> tuple[bool, str]: +) -> tuple[bool, str, Optional[str]]: r"""Registers the wallet to root network. :param subtensor: The SubtensorInterface object @@ -306,25 +308,25 @@ async def root_register_extrinsic( """ if not (unlock := unlock_key(wallet)).success: - return False, unlock.message + return False, unlock.message, None print_verbose(f"Checking if hotkey ({wallet.hotkey_str}) is registered on root") is_registered = await is_hotkey_registered( - subtensor, netuid=0, hotkey_ss58=wallet.hotkey.ss58_address + subtensor, netuid=0, hotkey_ss58=get_hotkey_pub_ss58(wallet) ) if is_registered: console.print( ":white_heavy_check_mark: [green]Already registered on root network.[/green]" ) - return True, "Already registered on root network" + return True, "Already registered on root network", None with console.status(":satellite: Registering to root network...", spinner="earth"): call = await subtensor.substrate.compose_call( call_module="SubtensorModule", call_function="root_register", - call_params={"hotkey": wallet.hotkey.ss58_address}, + call_params={"hotkey": get_hotkey_pub_ss58(wallet)}, ) - success, err_msg = await subtensor.sign_and_send_extrinsic( + success, err_msg, ext_receipt = await subtensor.sign_and_send_extrinsic( call, wallet=wallet, wait_for_inclusion=wait_for_inclusion, @@ -334,26 +336,28 @@ async def root_register_extrinsic( if not success: err_console.print(f":cross_mark: [red]Failed[/red]: {err_msg}") await asyncio.sleep(0.5) - return False, err_msg + return False, err_msg, None # Successful registration, final check for neuron and pubkey else: + ext_id = await ext_receipt.get_extrinsic_identifier() + await print_extrinsic_id(ext_receipt) uid = await subtensor.query( module="SubtensorModule", storage_function="Uids", - params=[0, wallet.hotkey.ss58_address], + params=[0, get_hotkey_pub_ss58(wallet)], ) if uid is not None: console.print( f":white_heavy_check_mark: [green]Registered with UID {uid}[/green]" ) - return True, f"Registered with UID {uid}" + return True, f"Registered with UID {uid}", ext_id else: # neuron not found, try again err_console.print( ":cross_mark: [red]Unknown error. Neuron not found.[/red]" ) - return False, "Unknown error. Neuron not found." + return False, "Unknown error. Neuron not found.", ext_id async def set_root_weights_extrinsic( @@ -391,7 +395,7 @@ async def _do_set_weights(): "weights": weight_vals, "netuid": 0, "version_key": version_key, - "hotkey": wallet.hotkey.ss58_address, + "hotkey": get_hotkey_pub_ss58(wallet), }, ) # Period dictates how long the extrinsic will stay as part of waiting pool @@ -415,7 +419,7 @@ async def _do_set_weights(): return False, await response.error_message my_uid = await subtensor.query( - "SubtensorModule", "Uids", [0, wallet.hotkey.ss58_address] + "SubtensorModule", "Uids", [0, get_hotkey_pub_ss58(wallet)] ) if my_uid is None: diff --git a/bittensor_cli/src/bittensor/extrinsics/transfer.py b/bittensor_cli/src/bittensor/extrinsics/transfer.py index 5302a33d0..6a7976d11 100644 --- a/bittensor_cli/src/bittensor/extrinsics/transfer.py +++ b/bittensor_cli/src/bittensor/extrinsics/transfer.py @@ -1,18 +1,21 @@ import asyncio +from typing import Optional, Union +from async_substrate_interface import AsyncExtrinsicReceipt from bittensor_wallet import Wallet from rich.prompt import Confirm from async_substrate_interface.errors import SubstrateRequestException -from bittensor_cli.src import NETWORK_EXPLORER_MAP from bittensor_cli.src.bittensor.balances import Balance -from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface +from bittensor_cli.src.bittensor.subtensor_interface import ( + SubtensorInterface, + GENESIS_ADDRESS, +) from bittensor_cli.src.bittensor.utils import ( console, err_console, print_verbose, format_error_message, - get_explorer_url_for_network, is_valid_bittensor_address_or_public_key, print_error, unlock_key, @@ -30,7 +33,7 @@ async def transfer_extrinsic( wait_for_inclusion: bool = True, wait_for_finalization: bool = False, prompt: bool = False, -) -> bool: +) -> tuple[bool, Optional[AsyncExtrinsicReceipt]]: """Transfers funds from this wallet to the destination public key address. :param subtensor: initialized SubtensorInterface object used for transfer @@ -75,7 +78,7 @@ async def get_transfer_fee() -> Balance: return Balance.from_rao(payment_info["partial_fee"]) - async def do_transfer() -> tuple[bool, str, str]: + async def do_transfer() -> tuple[bool, str, str, AsyncExtrinsicReceipt]: """ Makes transfer from wallet to destination public key address. :return: success, block hash, formatted error message @@ -95,27 +98,29 @@ async def do_transfer() -> tuple[bool, str, str]: ) # We only wait here if we expect finalization. if not wait_for_finalization and not wait_for_inclusion: - return True, "", "" + return True, "", "", response # Otherwise continue with finalization. if await response.is_success: block_hash_ = response.block_hash - return True, block_hash_, "" + return True, block_hash_, "", response else: - return False, "", format_error_message(await response.error_message) + return ( + False, + "", + format_error_message(await response.error_message), + response, + ) # Validate destination address. if not is_valid_bittensor_address_or_public_key(destination): err_console.print( f":cross_mark: [red]Invalid destination SS58 address[/red]:[bold white]\n {destination}[/bold white]" ) - return False + return False, None console.print(f"[dark_orange]Initiating transfer on network: {subtensor.network}") - # Unlock wallet coldkey. - if not unlock_key(wallet).success: - return False - call_params = {"dest": destination} + call_params: dict[str, Optional[Union[str, int]]] = {"dest": destination} if transfer_all: call_function = "transfer_all" if allow_death: @@ -158,7 +163,7 @@ async def do_transfer() -> tuple[bool, str, str]: f" would bring you under the existential deposit: [bright_cyan]{existential_deposit}[/bright_cyan].\n" f"You can try again with `--allow-death`." ) - return False + return False, None elif account_balance < (amount + fee) and allow_death: print_error( ":cross_mark: [bold red]Not enough balance[/bold red]:\n\n" @@ -166,38 +171,42 @@ async def do_transfer() -> tuple[bool, str, str]: f" amount: [bright_red]{amount}[/bright_red]\n" f" for fee: [bright_red]{fee}[/bright_red]" ) - return False + return False, None # Ask before moving on. if prompt: + hk_owner = await subtensor.get_hotkey_owner(destination, check_exists=False) + if hk_owner and hk_owner not in (destination, GENESIS_ADDRESS): + if not Confirm.ask( + f"The destination appears to be a hotkey, owned by [bright_magenta]{hk_owner}[/bright_magenta]. " + f"Only proceed if you are absolutely sure that [bright_magenta]{destination}[/bright_magenta] is the " + f"correct destination.", + default=False, + ): + return False, None if not Confirm.ask( "Do you want to transfer:[bold white]\n" - f" amount: [bright_cyan]{amount}[/bright_cyan]\n" + f" amount: [bright_cyan]{amount if not transfer_all else account_balance}[/bright_cyan]\n" f" from: [light_goldenrod2]{wallet.name}[/light_goldenrod2] : " - f"[bright_magenta]{wallet.coldkey.ss58_address}\n[/bright_magenta]" - f" to: [bright_magenta]{destination}[/bright_magenta]\n for fee: [bright_cyan]{fee}[/bright_cyan]" + f"[bright_magenta]{wallet.coldkeypub.ss58_address}\n[/bright_magenta]" + f" to: [bright_magenta]{destination}[/bright_magenta]\n for fee: [bright_cyan]{fee}[/bright_cyan]\n" + f"[bright_yellow]Transferring is not the same as staking. To instead stake, use " + f"[dark_orange]btcli stake add[/dark_orange] instead[/bright_yellow].\n" + f"Proceed with transfer?" ): - return False + return False, None + + # Unlock wallet coldkey. + if not unlock_key(wallet).success: + return False, None - with console.status(":satellite: Transferring...", spinner="earth") as status: - success, block_hash, err_msg = await do_transfer() + with console.status(":satellite: Transferring...", spinner="earth"): + success, block_hash, err_msg, ext_receipt = await do_transfer() if success: console.print(":white_heavy_check_mark: [green]Finalized[/green]") console.print(f"[green]Block Hash: {block_hash}[/green]") - if subtensor.network == "finney": - print_verbose("Fetching explorer URLs", status) - explorer_urls = get_explorer_url_for_network( - subtensor.network, block_hash, NETWORK_EXPLORER_MAP - ) - if explorer_urls != {} and explorer_urls: - console.print( - f"[green]Opentensor Explorer Link: {explorer_urls.get('opentensor')}[/green]" - ) - console.print( - f"[green]Taostats Explorer Link: {explorer_urls.get('taostats')}[/green]" - ) else: console.print(f":cross_mark: [red]Failed[/red]: {err_msg}") @@ -210,6 +219,6 @@ async def do_transfer() -> tuple[bool, str, str]: f"Balance:\n" f" [blue]{account_balance}[/blue] :arrow_right: [green]{new_balance}[/green]" ) - return True + return True, ext_receipt - return False + return False, None diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index 3d8a632d5..054d67f7a 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -1,21 +1,22 @@ import asyncio import os +import time from typing import Optional, Any, Union, TypedDict, Iterable -import aiohttp +from async_substrate_interface import AsyncExtrinsicReceipt +from async_substrate_interface.async_substrate import ( + DiskCachedAsyncSubstrateInterface, + AsyncSubstrateInterface, +) +from async_substrate_interface.errors import SubstrateRequestException from async_substrate_interface.utils.storage import StorageKey from bittensor_wallet import Wallet from bittensor_wallet.bittensor_wallet import Keypair from bittensor_wallet.utils import SS58_FORMAT -from scalecodec import GenericCall -from async_substrate_interface.errors import SubstrateRequestException +from scalecodec import GenericCall, ScaleBytes import typer +import websockets - -from async_substrate_interface.async_substrate import ( - DiskCachedAsyncSubstrateInterface, - AsyncSubstrateInterface, -) from bittensor_cli.src.bittensor.chain_data import ( DelegateInfo, StakeInfo, @@ -27,6 +28,8 @@ DynamicInfo, SubnetState, MetagraphInfo, + SimSwapResult, + CrowdloanData, ) from bittensor_cli.src import DelegatesDetails from bittensor_cli.src.bittensor.balances import Balance, fixed_to_float @@ -39,13 +42,10 @@ validate_chain_endpoint, u16_normalized_float, U16_MAX, + get_hotkey_pub_ss58, ) -SubstrateClass = ( - DiskCachedAsyncSubstrateInterface - if os.getenv("DISK_CACHE", "0") == "1" - else AsyncSubstrateInterface -) +GENESIS_ADDRESS = "5C4hrfjw9DjXZTzV3MwzrrAr9P1MJhSrvWGWqi1eSuyUpnhM" class ParamWithTypes(TypedDict): @@ -80,7 +80,7 @@ class SubtensorInterface: Thin layer for interacting with Substrate Interface. Mostly a collection of frequently-used calls. """ - def __init__(self, network): + def __init__(self, network, use_disk_cache: bool = False): if network in Constants.network_map: self.chain_endpoint = Constants.network_map[network] self.network = network @@ -110,12 +110,17 @@ def __init__(self, network): ) self.chain_endpoint = Constants.network_map[defaults.subtensor.network] self.network = defaults.subtensor.network - - self.substrate = SubstrateClass( + substrate_class = ( + DiskCachedAsyncSubstrateInterface + if (use_disk_cache or os.getenv("DISK_CACHE", "0") == "1") + else AsyncSubstrateInterface + ) + self.substrate = substrate_class( url=self.chain_endpoint, ss58_format=SS58_FORMAT, type_registry=TYPE_REGISTRY, chain_name="Bittensor", + ws_shutdown_timer=None, ) def __str__(self): @@ -165,6 +170,44 @@ async def query( else: return result + async def _decode_inline_call( + self, + call_option: Any, + block_hash: Optional[str] = None, + ) -> Optional[dict[str, Any]]: + """ + Decode an `Option` returned from storage into a structured dictionary. + """ + if not call_option or "Inline" not in call_option: + return None + inline_bytes = bytes(call_option["Inline"][0][0]) + call_obj = await self.substrate.create_scale_object( + "Call", + data=ScaleBytes(inline_bytes), + block_hash=block_hash, + ) + call_value = call_obj.decode() + + if not isinstance(call_value, dict): + return None + + call_args = call_value.get("call_args") or [] + args_map: dict[str, dict[str, Any]] = {} + for arg in call_args: + if isinstance(arg, dict) and arg.get("name"): + args_map[arg["name"]] = { + "type": arg.get("type"), + "value": arg.get("value"), + } + + return { + "call_index": call_value.get("call_index"), + "pallet": call_value.get("call_module"), + "method": call_value.get("call_function"), + "args": args_map, + "hash": call_value.get("call_hash"), + } + async def get_all_subnet_netuids( self, block_hash: Optional[str] = None ) -> list[int]: @@ -223,6 +266,30 @@ async def get_stake_for_coldkey( stakes: list[StakeInfo] = StakeInfo.list_from_any(result) return [stake for stake in stakes if stake.stake > 0] + async def get_auto_stake_destinations( + self, + coldkey_ss58: str, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> dict[int, str]: + """Retrieve auto-stake destinations configured for a coldkey.""" + + query = await self.substrate.query_map( + module="SubtensorModule", + storage_function="AutoStakeDestination", + params=[coldkey_ss58], + block_hash=block_hash, + reuse_block_hash=reuse_block, + ) + + destinations: dict[int, str] = {} + async for netuid, destination in query: + hotkey_ss58 = decode_account_id(destination.value[0]) + if hotkey_ss58: + destinations[int(netuid)] = hotkey_ss58 + + return destinations + async def get_stake_for_coldkey_and_hotkey( self, hotkey_ss58: str, @@ -474,7 +541,7 @@ async def get_total_stake_for_hotkey( async def current_take( self, - hotkey_ss58: int, + hotkey_ss58: str, block_hash: Optional[str] = None, reuse_block: bool = False, ) -> Optional[float]: @@ -666,7 +733,7 @@ async def filter_netuids_by_registered_hotkeys( for sublist in await asyncio.gather( *[ self.get_netuids_for_hotkey( - wallet.hotkey.ss58_address, + get_hotkey_pub_ss58(wallet), reuse_block=reuse_block, block_hash=block_hash, ) @@ -1052,13 +1119,14 @@ async def does_hotkey_exist( block_hash=block_hash, reuse_block_hash=reuse_block, ) - return_val = result != "5C4hrfjw9DjXZTzV3MwzrrAr9P1MJhSrvWGWqi1eSuyUpnhM" + return_val = result != GENESIS_ADDRESS return return_val async def get_hotkey_owner( self, hotkey_ss58: str, block_hash: Optional[str] = None, + check_exists: bool = True, ) -> Optional[str]: val = await self.query( module="SubtensorModule", @@ -1066,10 +1134,15 @@ async def get_hotkey_owner( params=[hotkey_ss58], block_hash=block_hash, ) - if val: - exists = await self.does_hotkey_exist(hotkey_ss58, block_hash=block_hash) + if check_exists: + if val: + exists = await self.does_hotkey_exist( + hotkey_ss58, block_hash=block_hash + ) + else: + exists = False else: - exists = False + exists = True hotkey_owner = val if exists else None return hotkey_owner @@ -1080,7 +1153,7 @@ async def sign_and_send_extrinsic( wait_for_inclusion: bool = True, wait_for_finalization: bool = False, era: Optional[dict[str, int]] = None, - ) -> tuple[bool, str]: + ) -> tuple[bool, str, Optional[AsyncExtrinsicReceipt]]: """ Helper method to sign and submit an extrinsic call to chain. @@ -1092,7 +1165,10 @@ async def sign_and_send_extrinsic( :return: (success, error message) """ - call_args = {"call": call, "keypair": wallet.coldkey} + call_args: dict[str, Union[GenericCall, Keypair, dict[str, int]]] = { + "call": call, + "keypair": wallet.coldkey, + } if era is not None: call_args["era"] = era extrinsic = await self.substrate.create_signed_extrinsic( @@ -1106,13 +1182,13 @@ async def sign_and_send_extrinsic( ) # We only wait here if we expect finalization. if not wait_for_finalization and not wait_for_inclusion: - return True, "" + return True, "", response if await response.is_success: - return True, "" + return True, "", response else: - return False, format_error_message(await response.error_message) + return False, format_error_message(await response.error_message), None except SubstrateRequestException as e: - return False, format_error_message(e) + return False, format_error_message(e), None async def get_children(self, hotkey, netuid) -> tuple[bool, list, str]: """ @@ -1170,6 +1246,55 @@ async def get_subnet_hyperparameters( return SubnetHyperparameters.from_any(result) + async def get_subnet_mechanisms( + self, netuid: int, block_hash: Optional[str] = None + ) -> int: + """Return the number of mechanisms that belong to the provided subnet.""" + + result = await self.query( + module="SubtensorModule", + storage_function="MechanismCountCurrent", + params=[netuid], + block_hash=block_hash, + ) + + if result is None: + return 0 + return int(result) + + async def get_all_subnet_mechanisms( + self, block_hash: Optional[str] = None + ) -> dict[int, int]: + """Return mechanism counts for every subnet with a recorded value.""" + + results = await self.substrate.query_map( + module="SubtensorModule", + storage_function="MechanismCountCurrent", + params=[], + block_hash=block_hash, + ) + res = {} + async for netuid, count in results: + res[int(netuid)] = int(count.value) + return res + + async def get_mechanism_emission_split( + self, netuid: int, block_hash: Optional[str] = None + ) -> list[int]: + """Return the emission split configured for the provided subnet.""" + + result = await self.query( + module="SubtensorModule", + storage_function="MechanismEmissionSplit", + params=[netuid], + block_hash=block_hash, + ) + + if not result: + return [] + + return [int(value) for value in result] + async def burn_cost(self, block_hash: Optional[str] = None) -> Optional[Balance]: result = await self.query_runtime_api( runtime_api="SubnetRegistrationRuntimeApi", @@ -1223,56 +1348,23 @@ async def get_delegate_identities( :return: {ss58: DelegatesDetails, ...} """ - timeout = aiohttp.ClientTimeout(10.0) - async with aiohttp.ClientSession(timeout=timeout) as session: - identities_info, response = await asyncio.gather( - self.substrate.query_map( - module="Registry", - storage_function="IdentityOf", - block_hash=block_hash, - ), - session.get(Constants.delegates_detail_url), - ) - - all_delegates_details = {} - async for ss58_address, identity in identities_info: - all_delegates_details.update( - { - decode_account_id( - ss58_address[0] - ): DelegatesDetails.from_chain_data( - decode_hex_identity_dict(identity.value["info"]) - ) - } - ) + identities_info = await self.substrate.query_map( + module="Registry", + storage_function="IdentityOf", + block_hash=block_hash, + ) - if response.ok: - all_delegates: dict[str, Any] = await response.json(content_type=None) - - for delegate_hotkey, delegate_details in all_delegates.items(): - delegate_info = all_delegates_details.setdefault( - delegate_hotkey, - DelegatesDetails( - display=delegate_details.get("name", ""), - web=delegate_details.get("url", ""), - additional=delegate_details.get("description", ""), - pgp_fingerprint=delegate_details.get("fingerprint", ""), - ), - ) - delegate_info.display = ( - delegate_info.display or delegate_details.get("name", "") - ) - delegate_info.web = delegate_info.web or delegate_details.get( - "url", "" - ) - delegate_info.additional = ( - delegate_info.additional - or delegate_details.get("description", "") - ) - delegate_info.pgp_fingerprint = ( - delegate_info.pgp_fingerprint - or delegate_details.get("fingerprint", "") + all_delegates_details = {} + async for ss58_address, identity in identities_info: + all_delegates_details.update( + { + decode_account_id( + ss58_address[0] + ): DelegatesDetails.from_chain_data( + decode_hex_identity_dict(identity.value["info"]) ) + } + ) return all_delegates_details @@ -1295,37 +1387,51 @@ async def get_stake_for_coldkey_and_hotkey_on_netuid( else: return Balance.from_rao(fixed_to_float(_result)).set_unit(int(netuid)) + async def get_mechagraph_info( + self, netuid: int, mech_id: int, block_hash: Optional[str] = None + ) -> Optional[MetagraphInfo]: + """ + Returns the metagraph info for a given subnet and mechanism id. + And yes, it is indeed 'mecha'graph + """ + query = await self.query_runtime_api( + runtime_api="SubnetInfoRuntimeApi", + method="get_mechagraph", + params=[netuid, mech_id], + block_hash=block_hash, + ) + + if query is None: + return None + + return MetagraphInfo.from_any(query) + async def get_metagraph_info( self, netuid: int, block_hash: Optional[str] = None ) -> Optional[MetagraphInfo]: - hex_bytes_result = await self.query_runtime_api( + query = await self.query_runtime_api( runtime_api="SubnetInfoRuntimeApi", method="get_metagraph", params=[netuid], block_hash=block_hash, ) - if hex_bytes_result is None: + if query is None: return None - try: - bytes_result = bytes.fromhex(hex_bytes_result[2:]) - except ValueError: - bytes_result = bytes.fromhex(hex_bytes_result) - - return MetagraphInfo.from_any(bytes_result) + return MetagraphInfo.from_any(query) async def get_all_metagraphs_info( self, block_hash: Optional[str] = None ) -> list[MetagraphInfo]: - hex_bytes_result = await self.query_runtime_api( + query = await self.query_runtime_api( runtime_api="SubnetInfoRuntimeApi", method="get_all_metagraphs", params=[], block_hash=block_hash, ) - return MetagraphInfo.list_from_any(hex_bytes_result) + return MetagraphInfo.list_from_any(query) async def multi_get_stake_for_coldkey_and_hotkey_on_netuid( self, @@ -1461,7 +1567,7 @@ async def subnet( if not result: raise ValueError(f"Subnet {netuid} not found") subnet_ = DynamicInfo.from_any(result) - subnet_.price = price + subnet_.price = price if netuid != 0 else Balance.from_tao(1.0) return subnet_ async def get_owned_hotkeys( @@ -1502,59 +1608,79 @@ async def get_extrinsic_fee(self, call: GenericCall, keypair: Keypair) -> Balanc fee_dict = await self.substrate.get_payment_info(call, keypair) return Balance.from_rao(fee_dict["partial_fee"]) - async def get_stake_fee( + async def sim_swap( self, - origin_hotkey_ss58: Optional[str], - origin_netuid: Optional[int], - origin_coldkey_ss58: str, - destination_hotkey_ss58: Optional[str], - destination_netuid: Optional[int], - destination_coldkey_ss58: str, + origin_netuid: int, + destination_netuid: int, amount: int, block_hash: Optional[str] = None, - ) -> Balance: + ) -> SimSwapResult: """ - Calculates the fee for a staking operation. - - :param origin_hotkey_ss58: SS58 address of source hotkey (None for new stake) - :param origin_netuid: Netuid of source subnet (None for new stake) - :param origin_coldkey_ss58: SS58 address of source coldkey - :param destination_hotkey_ss58: SS58 address of destination hotkey (None for removing stake) - :param destination_netuid: Netuid of destination subnet (None for removing stake) - :param destination_coldkey_ss58: SS58 address of destination coldkey - :param amount: Amount of stake to transfer in RAO - :param block_hash: Optional block hash at which to perform the calculation - - :return: The calculated stake fee as a Balance object - - When to use None: - - 1. Adding new stake (default fee): - - origin_hotkey_ss58 = None - - origin_netuid = None - - All other fields required + Hits the SimSwap Runtime API to calculate the fee and result for a given transaction. This should be used + instead of get_stake_fee for staking fee calculations. The SimSwapResult contains the staking fees and expected + returned amounts of a given transaction. This does not include the transaction (extrinsic) fee. - 2. Removing stake (default fee): - - destination_hotkey_ss58 = None - - destination_netuid = None - - All other fields required + Args: + origin_netuid: Netuid of the source subnet (0 if new stake) + destination_netuid: Netuid of the destination subnet + amount: Amount to transfer in Rao + block_hash: The hash of the blockchain block number for the query. - For all other operations, no None values - provide all parameters: - 3. Moving between subnets - 4. Moving between hotkeys - 5. Moving between coldkeys + Returns: + SimSwapResult object representing the result """ - - if origin_netuid is None: - origin_netuid = 0 - - fee_rate = await self.query("Swap", "FeeRate", [origin_netuid]) - fee = amount * (fee_rate / U16_MAX) - - result = Balance.from_rao(fee) - result.set_unit(origin_netuid) - - return result + block_hash = block_hash or await self.substrate.get_chain_head() + if origin_netuid > 0 and destination_netuid > 0: + # for cross-subnet moves where neither origin nor destination is root + intermediate_result_, sn_price = await asyncio.gather( + self.query_runtime_api( + "SwapRuntimeApi", + "sim_swap_alpha_for_tao", + params={"netuid": origin_netuid, "alpha": amount}, + block_hash=block_hash, + ), + self.get_subnet_price(origin_netuid, block_hash=block_hash), + ) + intermediate_result = SimSwapResult.from_dict( + intermediate_result_, origin_netuid + ) + result = SimSwapResult.from_dict( + await self.query_runtime_api( + "SwapRuntimeApi", + "sim_swap_tao_for_alpha", + params={ + "netuid": destination_netuid, + "tao": intermediate_result.tao_amount.rao, + }, + block_hash=block_hash, + ), + destination_netuid, + ) + secondary_fee = (result.tao_fee / sn_price.tao).set_unit(origin_netuid) + result.alpha_fee = result.alpha_fee + secondary_fee + return result + elif origin_netuid > 0: + # dynamic to tao + return SimSwapResult.from_dict( + await self.query_runtime_api( + "SwapRuntimeApi", + "sim_swap_alpha_for_tao", + params={"netuid": origin_netuid, "alpha": amount}, + block_hash=block_hash, + ), + origin_netuid, + ) + else: + # tao to dynamic or unstaked to staked tao (SN0) + return SimSwapResult.from_dict( + await self.query_runtime_api( + "SwapRuntimeApi", + "sim_swap_tao_for_alpha", + params={"netuid": destination_netuid, "tao": amount}, + block_hash=block_hash, + ), + destination_netuid, + ) async def get_scheduled_coldkey_swap( self, @@ -1581,6 +1707,101 @@ async def get_scheduled_coldkey_swap( keys_pending_swap.append(decode_account_id(ss58)) return keys_pending_swap + async def get_crowdloans( + self, block_hash: Optional[str] = None + ) -> list[CrowdloanData]: + """Retrieves all crowdloans from the network. + + Args: + block_hash (Optional[str]): The blockchain block hash at which to perform the query. + + Returns: + dict[int, CrowdloanData]: A dictionary mapping crowdloan IDs to CrowdloanData objects + containing details such as creator, deposit, cap, raised amount, and finalization status. + + This function fetches information about all crowdloans + """ + crowdloans_data = await self.substrate.query_map( + module="Crowdloan", + storage_function="Crowdloans", + block_hash=block_hash, + fully_exhaust=True, + ) + crowdloans = {} + async for fund_id, fund_info in crowdloans_data: + decoded_call = await self._decode_inline_call( + fund_info["call"], + block_hash=block_hash, + ) + info_dict = dict(fund_info.value) + info_dict["call_details"] = decoded_call + crowdloans[fund_id] = CrowdloanData.from_any(info_dict) + + return crowdloans + + async def get_single_crowdloan( + self, + crowdloan_id: int, + block_hash: Optional[str] = None, + ) -> Optional[CrowdloanData]: + """Retrieves detailed information about a specific crowdloan. + + Args: + crowdloan_id (int): The unique identifier of the crowdloan to retrieve. + block_hash (Optional[str]): The blockchain block hash at which to perform the query. + + Returns: + Optional[CrowdloanData]: A CrowdloanData object containing the crowdloan's details if found, + None if the crowdloan does not exist. + + The returned data includes crowdloan details such as funding targets, + contribution minimums, timeline, and current funding status + """ + crowdloan_info = await self.query( + module="Crowdloan", + storage_function="Crowdloans", + params=[crowdloan_id], + block_hash=block_hash, + ) + if crowdloan_info: + decoded_call = await self._decode_inline_call( + crowdloan_info.get("call"), + block_hash=block_hash, + ) + crowdloan_info["call_details"] = decoded_call + return CrowdloanData.from_any(crowdloan_info) + return None + + async def get_crowdloan_contribution( + self, + crowdloan_id: int, + contributor: str, + block_hash: Optional[str] = None, + ) -> Optional[Balance]: + """Retrieves a user's contribution to a specific crowdloan. + + Args: + crowdloan_id (int): The ID of the crowdloan. + contributor (str): The SS58 address of the contributor. + block_hash (Optional[str]): The blockchain block hash at which to perform the query. + + Returns: + Optional[Balance]: The contribution amount as a Balance object if found, None otherwise. + + This function queries the Contributions storage to find the amount a specific address + has contributed to a given crowdloan. + """ + contribution = await self.query( + module="Crowdloan", + storage_function="Contributions", + params=[crowdloan_id, contributor], + block_hash=block_hash, + ) + + if contribution: + return Balance.from_rao(contribution) + return None + async def get_coldkey_swap_schedule_duration( self, block_hash: Optional[str] = None, @@ -1606,6 +1827,364 @@ async def get_coldkey_swap_schedule_duration( return result + async def get_coldkey_claim_type( + self, + coldkey_ss58: str, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> str: + """ + Retrieves the root claim type for a specific coldkey. + + Root claim types control how staking emissions are handled on the ROOT network (subnet 0): + - "Swap": Future Root Alpha Emissions are swapped to TAO at claim time and added to your root stake + - "Keep": Future Root Alpha Emissions are kept as Alpha + + Args: + coldkey_ss58: The SS58 address of the coldkey to query. + block_hash: The hash of the blockchain block number for the query. + reuse_block: Whether to reuse the last-used blockchain block hash. + + Returns: + str: The root claim type for the coldkey ("Swap" or "Keep"). + """ + result = await self.query( + module="SubtensorModule", + storage_function="RootClaimType", + params=[coldkey_ss58], + block_hash=block_hash, + reuse_block_hash=reuse_block, + ) + + if result is None: + return "Swap" + return next(iter(result.keys())) + + async def get_all_coldkeys_claim_type( + self, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> dict[str, str]: + """ + Retrieves all root claim types for all coldkeys in the network. + + Args: + block_hash: The hash of the blockchain block number for the query. + reuse_block: Whether to reuse the last-used blockchain block hash. + + Returns: + dict[str, str]: A dictionary mapping coldkey SS58 addresses to their root claim type ("Keep" or "Swap"). + """ + result = await self.substrate.query_map( + module="SubtensorModule", + storage_function="RootClaimType", + params=[], + block_hash=block_hash, + reuse_block_hash=reuse_block, + ) + + root_claim_types = {} + async for coldkey, claim_type in result: + coldkey_ss58 = decode_account_id(coldkey[0]) + claim_type = next(iter(claim_type.value.keys())) + root_claim_types[coldkey_ss58] = claim_type + + return root_claim_types + + async def get_staking_hotkeys( + self, + coldkey_ss58: str, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> list[str]: + """Retrieves all hotkeys that a coldkey is staking to. + + Args: + coldkey_ss58: The SS58 address of the coldkey. + block_hash: The hash of the blockchain block for the query. + reuse_block: Whether to reuse the last-used blockchain block hash. + + Returns: + list[str]: A list of hotkey SS58 addresses that the coldkey has staked to. + """ + result = await self.query( + module="SubtensorModule", + storage_function="StakingHotkeys", + params=[coldkey_ss58], + block_hash=block_hash, + reuse_block_hash=reuse_block, + ) + staked_hotkeys = [decode_account_id(hotkey) for hotkey in result] + return staked_hotkeys + + async def get_claimed_amount( + self, + coldkey_ss58: str, + hotkey_ss58: str, + netuid: int, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> Balance: + """Retrieves the root claimed Alpha shares for coldkey from hotkey in provided subnet. + + Args: + coldkey_ss58: The SS58 address of the staker. + hotkey_ss58: The SS58 address of the root validator. + netuid: The unique identifier of the subnet. + block_hash: The blockchain block hash for the query. + reuse_block: Whether to reuse the last-used blockchain block hash. + + Returns: + Balance: The number of Alpha stake claimed from the root validator. + """ + query = await self.query( + module="SubtensorModule", + storage_function="RootClaimed", + params=[netuid, hotkey_ss58, coldkey_ss58], + block_hash=block_hash, + reuse_block_hash=reuse_block, + ) + return Balance.from_rao(query).set_unit(netuid=netuid) + + async def get_claimed_amount_all_netuids( + self, + coldkey_ss58: str, + hotkey_ss58: str, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> dict[int, Balance]: + """Retrieves the root claimed Alpha shares for coldkey from hotkey in all subnets. + + Args: + coldkey_ss58: The SS58 address of the staker. + hotkey_ss58: The SS58 address of the root validator. + block_hash: The blockchain block hash for the query. + reuse_block: Whether to reuse the last-used blockchain block hash. + + Returns: + dict[int, Balance]: Dictionary mapping netuid to claimed stake. + """ + query = await self.substrate.query_map( + module="SubtensorModule", + storage_function="RootClaimed", + params=[hotkey_ss58, coldkey_ss58], + block_hash=block_hash, + reuse_block_hash=reuse_block, + ) + total_claimed = {} + async for netuid, claimed in query: + total_claimed[netuid] = Balance.from_rao(claimed.value).set_unit( + netuid=netuid + ) + return total_claimed + + async def get_claimable_rate_all_netuids( + self, + hotkey_ss58: str, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> dict[int, float]: + """Retrieves all root claimable rates from a given hotkey address for all subnets with this validator. + + Args: + hotkey_ss58: The SS58 address of the root validator. + block_hash: The blockchain block hash for the query. + reuse_block: Whether to reuse the last-used blockchain block hash. + + Returns: + dict[int, float]: Dictionary mapping netuid to claimable rate. + """ + query = await self.query( + module="SubtensorModule", + storage_function="RootClaimable", + params=[hotkey_ss58], + block_hash=block_hash, + reuse_block_hash=reuse_block, + ) + + if not query: + return {} + + bits_list = next(iter(query)) + return {bits[0]: fixed_to_float(bits[1], frac_bits=32) for bits in bits_list} + + async def get_claimable_rate_netuid( + self, + hotkey_ss58: str, + netuid: int, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> float: + """Retrieves the root claimable rate from a given hotkey address for provided netuid. + + Args: + hotkey_ss58: The SS58 address of the root validator. + netuid: The unique identifier of the subnet to get the rate. + block_hash: The blockchain block hash for the query. + reuse_block: Whether to reuse the last-used blockchain block hash. + + Returns: + float: The rate of claimable stake from validator's hotkey for provided subnet. + """ + all_rates = await self.get_claimable_rate_all_netuids( + hotkey_ss58=hotkey_ss58, + block_hash=block_hash, + reuse_block=reuse_block, + ) + return all_rates.get(netuid, 0.0) + + async def get_claimable_stake_for_netuid( + self, + coldkey_ss58: str, + hotkey_ss58: str, + netuid: int, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> Balance: + """Retrieves the root claimable stake for a given coldkey address. + + Args: + coldkey_ss58: Delegate's ColdKey SS58 address. + hotkey_ss58: The root validator hotkey SS58 address. + netuid: Delegate's netuid where stake will be claimed. + block_hash: The blockchain block hash for the query. + reuse_block: Whether to reuse the last-used blockchain block hash. + + Returns: + Balance: Available for claiming root stake. + + Note: + After manual claim, claimable (available) stake will be added to subnet stake. + """ + root_stake, root_claimable_rate, root_claimed = await asyncio.gather( + self.get_stake_for_coldkey_and_hotkey_on_netuid( + coldkey_ss58=coldkey_ss58, + hotkey_ss58=hotkey_ss58, + netuid=0, + block_hash=block_hash, + ), + self.get_claimable_rate_netuid( + hotkey_ss58=hotkey_ss58, + netuid=netuid, + block_hash=block_hash, + reuse_block=reuse_block, + ), + self.get_claimed_amount( + coldkey_ss58=coldkey_ss58, + hotkey_ss58=hotkey_ss58, + netuid=netuid, + block_hash=block_hash, + reuse_block=reuse_block, + ), + ) + + root_claimable_stake = (root_claimable_rate * root_stake).set_unit( + netuid=netuid + ) + # Return the difference (what's left to claim) + return max( + root_claimable_stake - root_claimed, + Balance.from_rao(0).set_unit(netuid=netuid), + ) + + async def get_claimable_stakes_for_coldkey( + self, + coldkey_ss58: str, + stakes_info: list["StakeInfo"], + block_hash: Optional[str] = None, + ) -> dict[str, dict[int, "Balance"]]: + """Batch query claimable stakes for multiple hotkey-netuid pairs. + + Args: + coldkey_ss58: The coldkey SS58 address. + stakes_info: List of StakeInfo objects containing stake data. + block_hash: Optional block hash for the query. + + Returns: + dict[str, dict[int, Balance]]: Mapping of hotkey to netuid to claimable Balance. + """ + if not stakes_info: + return {} + + root_stakes: dict[str, Balance] = {} + for stake_info in stakes_info: + if stake_info.netuid == 0 and stake_info.stake.rao > 0: + root_stakes[stake_info.hotkey_ss58] = stake_info.stake + + target_pairs = [] + for s in stakes_info: + if s.netuid != 0 and s.stake.rao > 0 and s.hotkey_ss58 in root_stakes: + pair = (s.hotkey_ss58, s.netuid) + target_pairs.append(pair) + + if not target_pairs: + return {} + + unique_hotkeys = list(set(h for h, _ in target_pairs)) + if not unique_hotkeys: + return {} + + batch_claimable_calls = [] + batch_claimed_calls = [] + + # Get the claimable rate + for hotkey in unique_hotkeys: + batch_claimable_calls.append( + await self.substrate.create_storage_key( + "SubtensorModule", "RootClaimable", [hotkey], block_hash=block_hash + ) + ) + + # Get already claimed + claimed_pairs = target_pairs + for hotkey, netuid in claimed_pairs: + batch_claimed_calls.append( + await self.substrate.create_storage_key( + "SubtensorModule", + "RootClaimed", + [netuid, hotkey, coldkey_ss58], + block_hash=block_hash, + ) + ) + + batch_claimable, batch_claimed = await asyncio.gather( + self.substrate.query_multi(batch_claimable_calls, block_hash=block_hash), + self.substrate.query_multi(batch_claimed_calls, block_hash=block_hash), + ) + + claimable_rates: dict[str, dict[int, float]] = {} + claimed_amounts: dict[tuple[str, int], Balance] = {} + for idx, (_, result) in enumerate(batch_claimable): + hotkey = unique_hotkeys[idx] + if result: + for netuid, rate in result: + if hotkey not in claimable_rates: + claimable_rates[hotkey] = {} + claimable_rates[hotkey][netuid] = fixed_to_float(rate, frac_bits=32) + + for idx, (_, result) in enumerate(batch_claimed): + hotkey, netuid = claimed_pairs[idx] + value = result or 0 + claimed_amounts[(hotkey, netuid)] = Balance.from_rao(value).set_unit(netuid) + + # Calculate the claimable stake for each pair + results = {} + already_claimed: Balance + net_claimable: Balance + rate: float + root_stake: Balance + claimable_stake: Balance + for hotkey, netuid in target_pairs: + root_stake = root_stakes[hotkey] + rate = claimable_rates[hotkey].get(netuid, 0.0) + claimable_stake = rate * root_stake + already_claimed = claimed_amounts.get((hotkey, netuid), Balance(0)) + net_claimable = max(claimable_stake - already_claimed, Balance(0)) + if hotkey not in results: + results[hotkey] = {} + results[hotkey][netuid] = net_claimable.set_unit(netuid) + return results + async def get_subnet_price( self, netuid: int = None, @@ -1655,3 +2234,96 @@ async def get_subnet_prices( map_[netuid_] = Balance.from_rao(int(current_price * 1e9)) return map_ + + async def get_all_subnet_ema_tao_inflow( + self, + block_hash: Optional[str] = None, + page_size: int = 100, + ) -> dict[int, Balance]: + """ + Query EMA TAO inflow for all subnets. + + This represents the exponential moving average of TAO flowing + into or out of a subnet. Negative values indicate net outflow. + + Args: + block_hash: Optional block hash to query at. + page_size: The page size for batch queries (default: 100). + + Returns: + Dict mapping netuid -> Balance(EMA TAO inflow). + """ + query = await self.substrate.query_map( + module="SubtensorModule", + storage_function="SubnetEmaTaoFlow", + page_size=page_size, + block_hash=block_hash, + ) + ema_map = {} + async for netuid, value in query: + if not value: + ema_map[netuid] = Balance.from_rao(0) + else: + _, raw_ema_value = value + ema_value = fixed_to_float(raw_ema_value) + ema_map[netuid] = Balance.from_rao(ema_value) + return ema_map + + async def get_subnet_ema_tao_inflow( + self, + netuid: int, + block_hash: Optional[str] = None, + ) -> Balance: + """ + Query EMA TAO inflow for a specific subnet. + + This represents the exponential moving average of TAO flowing + into or out of a subnet. Negative values indicate net outflow. + + Args: + netuid: The unique identifier of the subnet. + block_hash: Optional block hash to query at. + + Returns: + Balance(EMA TAO inflow). + """ + value = await self.substrate.query( + module="SubtensorModule", + storage_function="SubnetEmaTaoFlow", + params=[netuid], + block_hash=block_hash, + ) + if not value: + return Balance.from_rao(0) + _, raw_ema_value = value + ema_value = fixed_to_float(raw_ema_value) + return Balance.from_rao(ema_value) + + +async def best_connection(networks: list[str]): + """ + Basic function to compare the latency of a given list of websocket endpoints + Args: + networks: list of network URIs + + Returns: + {network_name: [end_to_end_latency, single_request_latency, chain_head_request_latency]} + + """ + results = {} + for network in networks: + try: + t1 = time.monotonic() + async with websockets.connect(network) as websocket: + pong = await websocket.ping() + latency = await pong + pt1 = time.monotonic() + await websocket.send( + "{'jsonrpc': '2.0', 'method': 'chain_getHead', 'params': [], 'id': '82'}" + ) + await websocket.recv() + t2 = time.monotonic() + results[network] = [t2 - t1, latency, t2 - pt1] + except Exception as e: + err_console.print(f"Error attempting network {network}: {e}") + return results diff --git a/bittensor_cli/src/bittensor/utils.py b/bittensor_cli/src/bittensor/utils.py index ef5f2e4b1..f8e322f01 100644 --- a/bittensor_cli/src/bittensor/utils.py +++ b/bittensor_cli/src/bittensor/utils.py @@ -10,6 +10,8 @@ from functools import partial import re +import aiohttp +from async_substrate_interface import AsyncExtrinsicReceipt from bittensor_wallet import Wallet, Keypair from bittensor_wallet.utils import SS58_FORMAT from bittensor_wallet.errors import KeyFileError, PasswordError @@ -34,6 +36,7 @@ BT_DOCS_LINK = "https://docs.learnbittensor.org" +GLOBAL_MAX_SUBNET_COUNT = 4096 console = Console() json_console = Console() @@ -266,16 +269,32 @@ def get_hotkey_wallets_for_wallet( hotkeys_path = wallet_path / wallet.name / "hotkeys" try: hotkeys = [entry.name for entry in hotkeys_path.iterdir()] - except FileNotFoundError: + except (FileNotFoundError, NotADirectoryError): hotkeys = [] for h_name in hotkeys: - hotkey_for_name = Wallet(path=str(wallet_path), name=wallet.name, hotkey=h_name) + if h_name.endswith("pub.txt"): + if h_name.split("pub.txt")[0] in hotkeys: + continue + else: + hotkey_for_name = Wallet( + path=str(wallet_path), + name=wallet.name, + hotkey=h_name.split("pub.txt")[0], + ) + else: + hotkey_for_name = Wallet( + path=str(wallet_path), name=wallet.name, hotkey=h_name + ) try: + exists = ( + hotkey_for_name.hotkey_file.exists_on_device() + or hotkey_for_name.hotkeypub_file.exists_on_device() + ) if ( - (exists := hotkey_for_name.hotkey_file.exists_on_device()) + exists and not hotkey_for_name.hotkey_file.is_encrypted() # and hotkey_for_name.coldkeypub.ss58_address - and hotkey_for_name.hotkey.ss58_address + and get_hotkey_pub_ss58(hotkey_for_name) ): hotkey_wallets.append(hotkey_for_name) elif ( @@ -291,6 +310,7 @@ def get_hotkey_wallets_for_wallet( AttributeError, TypeError, KeyFileError, + ValueError, ): # usually an unrelated file like .DS_Store continue @@ -490,6 +510,7 @@ def get_explorer_url_for_network( :return: The explorer url for the given block hash and network """ + # TODO remove explorer_urls: dict[str, str] = {} # Will be None if the network is not known. i.e. not in network_map @@ -575,7 +596,9 @@ def format_error_message(error_message: Union[dict, Exception]) -> str: err_type = error_message.get("type", err_type) err_name = error_message.get("name", err_name) err_docs = error_message.get("docs", [err_description]) - err_description = " ".join(err_docs) + err_description = ( + " ".join(err_docs) if not isinstance(err_docs, str) else err_docs + ) err_description += ( f" | Please consult {BT_DOCS_LINK}/errors/subtensor#{err_name.lower()}" ) @@ -1431,3 +1454,89 @@ def blocks_to_duration(blocks: int) -> str: results.append(f"{unit_count}{unit}") # Return only the first two non-zero units return " ".join(results[:2]) or "0s" + + +def get_hotkey_pub_ss58(wallet: Wallet) -> str: + """ + Helper fn to retrieve the hotkeypub ss58 of a wallet that may have been created before + bt-wallet 3.1.1 and thus not have a wallet hotkeypub. In this case, it will return the hotkey + SS58. + """ + try: + return wallet.hotkeypub.ss58_address + except (KeyFileError, AttributeError): + return wallet.hotkey.ss58_address + + +def get_netuid_and_subuid_by_storage_index(storage_index: int) -> tuple[int, int]: + """Returns the netuid and subuid from the storage index. + + Chain APIs (e.g., SubMetagraph response) returns netuid which is storage index that encodes both the netuid and + subuid. This function reverses the encoding to extract these components. + + Parameters: + storage_index: The storage index of the subnet. + + Returns: + tuple[int, int]: + - netuid subnet identifier. + - subuid identifier. + """ + return ( + storage_index % GLOBAL_MAX_SUBNET_COUNT, + storage_index // GLOBAL_MAX_SUBNET_COUNT, + ) + + +async def print_extrinsic_id( + extrinsic_receipt: Optional[AsyncExtrinsicReceipt], +) -> None: + """ + Prints the extrinsic identifier to the console. If the substrate attached to the extrinsic receipt is on a finney + node, it will also include a link to browse the extrinsic in tao dot app. + Args: + extrinsic_receipt: AsyncExtrinsicReceipt object from a successful extrinsic submission. + """ + if extrinsic_receipt is None or not (await extrinsic_receipt.is_success): + return + substrate = extrinsic_receipt.substrate + ext_id = await extrinsic_receipt.get_extrinsic_identifier() + if substrate: + query = await substrate.rpc_request("system_chainType", []) + if query.get("result") == "Live": + console.print( + f":white_heavy_check_mark: Your extrinsic has been included as {ext_id}: " + f"[blue]https://tao.app/extrinsic/{ext_id}[/blue]" + ) + return + console.print( + f":white_heavy_check_mark: Your extrinsic has been included as {ext_id}" + ) + return + + +async def check_img_mimetype(img_url: str) -> tuple[bool, str, str]: + """ + Checks to see if the given URL is an image, as defined by its mimetype. + + Args: + img_url: the URL to check + + Returns: + tuple: + bool: True if the URL has a MIME type indicating image (e.g. 'image/...'), False otherwise. + str: MIME type of the URL. + str: error message if the URL could not be retrieved + + """ + async with aiohttp.ClientSession() as session: + try: + async with session.get(img_url) as response: + if response.status != 200: + return False, "", "Could not fetch image" + elif "image/" not in response.content_type: + return False, response.content_type, "" + else: + return True, response.content_type, "" + except aiohttp.ClientError: + return False, "", "Could not fetch image" diff --git a/bittensor_cli/src/commands/crowd/__init__.py b/bittensor_cli/src/commands/crowd/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bittensor_cli/src/commands/crowd/contribute.py b/bittensor_cli/src/commands/crowd/contribute.py new file mode 100644 index 000000000..480f6a7fa --- /dev/null +++ b/bittensor_cli/src/commands/crowd/contribute.py @@ -0,0 +1,597 @@ +import json +from typing import Optional + +from async_substrate_interface.utils.cache import asyncio +from bittensor_wallet import Wallet +from rich import box +from rich.prompt import Confirm, FloatPrompt +from rich.table import Column, Table + +from bittensor_cli.src import COLORS +from bittensor_cli.src.bittensor.balances import Balance +from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface +from bittensor_cli.src.bittensor.utils import ( + console, + json_console, + print_error, + print_extrinsic_id, + unlock_key, +) +from bittensor_cli.src.commands.crowd.view import show_crowdloan_details +from bittensor_cli.src.bittensor.chain_data import CrowdloanData + + +def validate_for_contribution( + crowdloan: CrowdloanData, + crowdloan_id: int, + current_block: int, +) -> tuple[bool, Optional[str]]: + """Validate if a crowdloan can accept contributions. + + Args: + crowdloan: The crowdloan data object + crowdloan_id: The ID of the crowdloan + current_block: Current blockchain block number + + Returns: + tuple[bool, Optional[str]]: (is_valid, error_message) + - If valid: (True, None) + - If invalid: (False, error_message) + """ + if crowdloan.finalized: + return False, f"Crowdloan #{crowdloan_id} is already finalized." + + if current_block >= crowdloan.end: + return False, f"Crowdloan #{crowdloan_id} has ended." + + if crowdloan.raised >= crowdloan.cap: + return False, f"Crowdloan #{crowdloan_id} has reached its cap." + + return True, None + + +async def contribute_to_crowdloan( + subtensor: SubtensorInterface, + wallet: Wallet, + crowdloan_id: int, + amount: Optional[float], + prompt: bool, + wait_for_inclusion: bool, + wait_for_finalization: bool, + json_output: bool = False, +) -> tuple[bool, str]: + """Contribute TAO to an active crowdloan. + + Args: + subtensor: SubtensorInterface object for chain interaction + wallet: Wallet object containing coldkey for contribution + crowdloan_id: ID of the crowdloan to contribute to + amount: Amount to contribute in TAO (None to prompt) + prompt: Whether to prompt for confirmation + wait_for_inclusion: Wait for transaction inclusion + wait_for_finalization: Wait for transaction finalization + + Returns: + tuple[bool, str]: Success status and message + """ + crowdloan, current_block = await asyncio.gather( + subtensor.get_single_crowdloan(crowdloan_id), + subtensor.substrate.get_block_number(None), + ) + if not crowdloan: + error_msg = f"Crowdloan #{crowdloan_id} not found." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, error_msg + + is_valid, error_message = validate_for_contribution( + crowdloan, crowdloan_id, current_block + ) + if not is_valid: + if json_output: + json_console.print(json.dumps({"success": False, "error": error_message})) + else: + print_error(f"[red]{error_message}[/red]") + return False, error_message + + contributor_address = wallet.coldkeypub.ss58_address + current_contribution, user_balance, _ = await asyncio.gather( + subtensor.get_crowdloan_contribution(crowdloan_id, contributor_address), + subtensor.get_balance(contributor_address), + show_crowdloan_details( + subtensor=subtensor, + crowdloan_id=crowdloan_id, + wallet=wallet, + verbose=False, + crowdloan=crowdloan, + current_block=current_block, + ), + ) + + if amount is None: + left_to_raise = crowdloan.cap - crowdloan.raised + max_contribution = min(user_balance, left_to_raise) + + console.print( + f"\n[bold cyan]Contribution Options:[/bold cyan]\n" + f" Your Balance: {user_balance}\n" + f" Maximum You Can Contribute: [{COLORS.S.AMOUNT}]{max_contribution}[/{COLORS.S.AMOUNT}]" + ) + amount = FloatPrompt.ask( + f"\nEnter contribution amount in {Balance.unit}", + default=float(crowdloan.min_contribution.tao), + ) + + contribution_amount = Balance.from_tao(amount) + if contribution_amount < crowdloan.min_contribution: + error_msg = f"Contribution amount ({contribution_amount}) is below minimum ({crowdloan.min_contribution})." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, "Contribution below minimum requirement." + + if contribution_amount > user_balance: + error_msg = f"Insufficient balance. You have {user_balance} but trying to contribute {contribution_amount}." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, "Insufficient balance." + + # Auto-adjustment + left_to_raise = crowdloan.cap - crowdloan.raised + actual_contribution = contribution_amount + will_be_adjusted = False + + if contribution_amount > left_to_raise: + actual_contribution = left_to_raise + will_be_adjusted = True + + # Extrinsic fee + call = await subtensor.substrate.compose_call( + call_module="Crowdloan", + call_function="contribute", + call_params={ + "crowdloan_id": crowdloan_id, + "amount": contribution_amount.rao, + }, + ) + extrinsic_fee = await subtensor.get_extrinsic_fee(call, wallet.coldkeypub) + updated_balance = user_balance - actual_contribution - extrinsic_fee + + table = Table( + Column("[bold white]Field", style=COLORS.G.SUBHEAD), + Column("[bold white]Value", style=COLORS.G.TEMPO), + title="\n[bold cyan]Contribution Summary[/bold cyan]", + show_footer=False, + width=None, + pad_edge=False, + box=box.SIMPLE, + show_edge=True, + border_style="bright_black", + ) + + table.add_row("Crowdloan ID", str(crowdloan_id)) + table.add_row("Creator", crowdloan.creator) + table.add_row( + "Current Progress", + f"{crowdloan.raised} / {crowdloan.cap} ({(crowdloan.raised.tao / crowdloan.cap.tao * 100):.2f}%)", + ) + + if current_contribution: + table.add_row("Your Current Contribution", str(current_contribution)) + table.add_row("New Contribution", str(actual_contribution)) + table.add_row( + "Total After Contribution", + f"[{COLORS.S.AMOUNT}]{Balance.from_rao(current_contribution.rao + actual_contribution.rao)}[/{COLORS.S.AMOUNT}]", + ) + else: + table.add_row( + "Contribution Amount", + f"[{COLORS.S.AMOUNT}]{actual_contribution}[/{COLORS.S.AMOUNT}]", + ) + + if will_be_adjusted: + table.add_row( + "Note", + f"[yellow]Amount adjusted from {contribution_amount} to {actual_contribution} (cap limit)[/yellow]", + ) + + table.add_row("Transaction Fee", str(extrinsic_fee)) + table.add_row( + "Balance After", + f"[blue]{user_balance}[/blue] → [{COLORS.S.AMOUNT}]{updated_balance}[/{COLORS.S.AMOUNT}]", + ) + console.print(table) + + if will_be_adjusted: + console.print( + f"\n[yellow] Your contribution will be automatically adjusted to {actual_contribution} " + f"because the crowdloan only needs {left_to_raise} more to reach its cap.[/yellow]" + ) + + if prompt: + if not Confirm.ask("\nProceed with contribution?"): + if json_output: + json_console.print( + json.dumps( + {"success": False, "error": "Contribution cancelled by user."} + ) + ) + else: + console.print("[yellow]Contribution cancelled.[/yellow]") + return False, "Contribution cancelled by user." + + unlock_status = unlock_key(wallet) + if not unlock_status.success: + if json_output: + json_console.print( + json.dumps({"success": False, "error": unlock_status.message}) + ) + else: + print_error(f"[red]{unlock_status.message}[/red]") + return False, unlock_status.message + + with console.status(f"\n:satellite: Contributing to crowdloan #{crowdloan_id}..."): + ( + success, + error_message, + extrinsic_receipt, + ) = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + if not success: + if json_output: + json_console.print( + json.dumps( + { + "success": False, + "error": error_message or "Failed to contribute.", + } + ) + ) + else: + print_error(f"[red]Failed to contribute: {error_message}[/red]") + return False, error_message or "Failed to contribute." + + new_balance, new_contribution, updated_crowdloan = await asyncio.gather( + subtensor.get_balance(contributor_address), + subtensor.get_crowdloan_contribution(crowdloan_id, contributor_address), + subtensor.get_single_crowdloan(crowdloan_id), + ) + + if json_output: + extrinsic_id = await extrinsic_receipt.get_extrinsic_identifier() + output_dict = { + "success": True, + "error": None, + "extrinsic_identifier": extrinsic_id, + "data": { + "crowdloan_id": crowdloan_id, + "contributor": contributor_address, + "contribution_amount": actual_contribution.tao, + "previous_contribution": current_contribution.tao + if current_contribution + else 0.0, + "total_contribution": new_contribution.tao if new_contribution else 0.0, + "balance": { + "before": user_balance.tao, + "after": new_balance.tao, + "fee": extrinsic_fee.tao, + }, + "crowdloan": { + "raised_before": crowdloan.raised.tao, + "raised_after": updated_crowdloan.raised.tao + if updated_crowdloan + else crowdloan.raised.tao, + "cap": crowdloan.cap.tao, + "percentage": ( + updated_crowdloan.raised.tao / updated_crowdloan.cap.tao * 100 + ) + if updated_crowdloan + else 0.0, + }, + "adjusted": will_be_adjusted, + "cap_reached": updated_crowdloan.raised >= updated_crowdloan.cap + if updated_crowdloan + else False, + }, + } + json_console.print(json.dumps(output_dict)) + else: + console.print( + f"\n[dark_sea_green3]Successfully contributed to crowdloan #{crowdloan_id}![/dark_sea_green3]" + ) + + console.print( + f"Balance:\n [blue]{user_balance}[/blue] → " + f"[{COLORS.S.AMOUNT}]{new_balance}[/{COLORS.S.AMOUNT}]" + ) + + if new_contribution: + if current_contribution: + console.print( + f"Your Contribution:\n [blue]{current_contribution}[/blue] → " + f"[{COLORS.S.AMOUNT}]{new_contribution}[/{COLORS.S.AMOUNT}]" + ) + else: + console.print( + f"Your Contribution: [{COLORS.S.AMOUNT}]{new_contribution}[/{COLORS.S.AMOUNT}]" + ) + + if updated_crowdloan: + console.print( + f"Crowdloan Progress:\n [blue]{crowdloan.raised}[/blue] → " + f"[{COLORS.S.AMOUNT}]{updated_crowdloan.raised}[/{COLORS.S.AMOUNT}] / {updated_crowdloan.cap}" + ) + + if updated_crowdloan.raised >= updated_crowdloan.cap: + console.print( + "\n[bold green]🎉 Crowdloan has reached its funding cap![/bold green]" + ) + + await print_extrinsic_id(extrinsic_receipt) + + return True, "Successfully contributed to crowdloan." + + +async def withdraw_from_crowdloan( + subtensor: SubtensorInterface, + wallet: Wallet, + crowdloan_id: int, + wait_for_inclusion: bool, + wait_for_finalization: bool, + prompt: bool, + json_output: bool = False, +) -> tuple[bool, str]: + """ + Withdraw contributions from a non-finalized crowdloan. + + Non-creators can withdraw their full contribution. + Creators can only withdraw amounts above their initial deposit. + + Args: + subtensor: SubtensorInterface instance for blockchain interaction + wallet: Wallet instance containing the user's keys + crowdloan_id: The ID of the crowdloan to withdraw from + wait_for_inclusion: Whether to wait for transaction inclusion + wait_for_finalization: Whether to wait for transaction finalization + prompt: Whether to prompt for user confirmation + + Returns: + Tuple of (success, message) indicating the result + """ + + crowdloan, current_block = await asyncio.gather( + subtensor.get_single_crowdloan(crowdloan_id), + subtensor.substrate.get_block_number(None), + ) + + if not crowdloan: + error_msg = f"Crowdloan #{crowdloan_id} does not exist." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, error_msg + + if crowdloan.finalized: + error_msg = f"Crowdloan #{crowdloan_id} is already finalized. Withdrawals are not allowed." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, "Cannot withdraw from finalized crowdloan." + + user_contribution, user_balance = await asyncio.gather( + subtensor.get_crowdloan_contribution( + crowdloan_id, wallet.coldkeypub.ss58_address + ), + subtensor.get_balance(wallet.coldkeypub.ss58_address), + ) + + if user_contribution == Balance.from_tao(0): + error_msg = ( + f"You have no contribution to withdraw from crowdloan #{crowdloan_id}." + ) + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, "No contribution to withdraw." + + is_creator = wallet.coldkeypub.ss58_address == crowdloan.creator + if is_creator: + withdrawable = user_contribution - crowdloan.deposit + if withdrawable <= 0: + error_msg = f"As the creator, you cannot withdraw your deposit of {crowdloan.deposit}. Only contributions above the deposit can be withdrawn." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, "Creator cannot withdraw deposit amount." + remaining_contribution = crowdloan.deposit + else: + withdrawable = user_contribution + remaining_contribution = Balance.from_tao(0) + + call = await subtensor.substrate.compose_call( + call_module="Crowdloan", + call_function="withdraw", + call_params={ + "crowdloan_id": crowdloan_id, + }, + ) + extrinsic_fee = await subtensor.get_extrinsic_fee(call, wallet.coldkeypub) + await show_crowdloan_details( + subtensor=subtensor, + crowdloan_id=crowdloan_id, + wallet=wallet, + verbose=False, + crowdloan=crowdloan, + current_block=current_block, + ) + + if prompt: + new_balance = user_balance + withdrawable - extrinsic_fee + new_raised = crowdloan.raised - withdrawable + table = Table( + Column("[bold white]Field", style=COLORS.G.SUBHEAD), + Column("[bold white]Value", style=COLORS.G.TEMPO), + title="\n[bold cyan]Withdrawal Summary[/bold cyan]", + show_footer=False, + show_header=False, + width=None, + pad_edge=False, + box=box.SIMPLE, + show_edge=True, + border_style="bright_black", + ) + + table.add_row("Crowdloan ID", str(crowdloan_id)) + + if is_creator: + table.add_row("Role", "[yellow]Creator[/yellow]") + table.add_row("Current Contribution", str(user_contribution)) + table.add_row("Deposit (Locked)", f"[yellow]{crowdloan.deposit}[/yellow]") + table.add_row( + "Withdrawable Amount", + f"[{COLORS.S.AMOUNT}]{withdrawable}[/{COLORS.S.AMOUNT}]", + ) + table.add_row( + "Remaining After Withdrawal", + f"[yellow]{remaining_contribution}[/yellow] (deposit)", + ) + else: + table.add_row("Current Contribution", str(user_contribution)) + table.add_row( + "Withdrawal Amount", + f"[{COLORS.S.AMOUNT}]{withdrawable}[/{COLORS.S.AMOUNT}]", + ) + + table.add_row("Transaction Fee", str(extrinsic_fee)) + table.add_row( + "Balance After", + f"[blue]{user_balance}[/blue] → [{COLORS.S.AMOUNT}]{new_balance}[/{COLORS.S.AMOUNT}]", + ) + + table.add_row( + "Crowdloan Total After", + f"[blue]{crowdloan.raised}[/blue] → [{COLORS.S.AMOUNT}]{new_raised}[/{COLORS.S.AMOUNT}]", + ) + + console.print(table) + + if not Confirm.ask("\nProceed with withdrawal?"): + if json_output: + json_console.print( + json.dumps( + {"success": False, "error": "Withdrawal cancelled by user."} + ) + ) + else: + console.print("[yellow]Withdrawal cancelled.[/yellow]") + return False, "Withdrawal cancelled by user." + + unlock_status = unlock_key(wallet) + if not unlock_status.success: + if json_output: + json_console.print( + json.dumps({"success": False, "error": unlock_status.message}) + ) + else: + print_error(f"[red]{unlock_status.message}[/red]") + return False, unlock_status.message + + with console.status(f"\n:satellite: Withdrawing from crowdloan #{crowdloan_id}..."): + ( + success, + error_message, + extrinsic_receipt, + ) = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + if not success: + if json_output: + json_console.print( + json.dumps( + { + "success": False, + "error": error_message or "Failed to withdraw from crowdloan.", + } + ) + ) + else: + print_error( + f"[red]Failed to withdraw: {error_message or 'Unknown error'}[/red]" + ) + return False, error_message or "Failed to withdraw from crowdloan." + + new_balance, updated_contribution, updated_crowdloan = await asyncio.gather( + subtensor.get_balance(wallet.coldkeypub.ss58_address), + subtensor.get_crowdloan_contribution( + crowdloan_id, wallet.coldkeypub.ss58_address + ), + subtensor.get_single_crowdloan(crowdloan_id), + ) + + if json_output: + extrinsic_id = await extrinsic_receipt.get_extrinsic_identifier() + output_dict = { + "success": True, + "error": None, + "extrinsic_identifier": extrinsic_id, + "data": { + "crowdloan_id": crowdloan_id, + "is_creator": is_creator, + "withdrawal_amount": withdrawable.tao, + "previous_contribution": user_contribution.tao, + "remaining_contribution": updated_contribution.tao + if updated_contribution + else 0.0, + "deposit_locked": crowdloan.deposit.tao if is_creator else None, + "balance": { + "before": user_balance.tao, + "after": new_balance.tao, + "fee": extrinsic_fee.tao, + }, + "crowdloan": { + "raised_before": crowdloan.raised.tao, + "raised_after": updated_crowdloan.raised.tao + if updated_crowdloan + else (crowdloan.raised.tao - withdrawable.tao), + }, + }, + } + json_console.print(json.dumps(output_dict)) + else: + console.print( + f"\n✅ [green]Successfully withdrew from crowdloan #{crowdloan_id}![/green]\n" + ) + + console.print( + f"Amount Withdrawn: [{COLORS.S.AMOUNT}]{withdrawable}[/{COLORS.S.AMOUNT}]\n" + f"Balance:\n [blue]{user_balance}[/blue] → [{COLORS.S.AMOUNT}]{new_balance}[/{COLORS.S.AMOUNT}]" + f"Crowdloan raised before: [{COLORS.S.AMOUNT}]{crowdloan.raised}[/{COLORS.S.AMOUNT}]" + f"Crowdloan raised after: [{COLORS.S.AMOUNT}]{updated_crowdloan.raised}[/{COLORS.S.AMOUNT}]" + ) + + if is_creator and updated_contribution: + console.print( + f"Remaining Contribution: [{COLORS.S.AMOUNT}]{updated_contribution}[/{COLORS.S.AMOUNT}] (deposit locked)" + ) + + await print_extrinsic_id(extrinsic_receipt) + + return True, "Successfully withdrew from crowdloan." diff --git a/bittensor_cli/src/commands/crowd/create.py b/bittensor_cli/src/commands/crowd/create.py new file mode 100644 index 000000000..2c2625b2f --- /dev/null +++ b/bittensor_cli/src/commands/crowd/create.py @@ -0,0 +1,659 @@ +import asyncio +import json +from typing import Optional + +from bittensor_wallet import Wallet +from rich.prompt import Confirm, IntPrompt, Prompt, FloatPrompt +from rich.table import Table, Column, box + +from bittensor_cli.src import COLORS +from bittensor_cli.src.commands.crowd.view import show_crowdloan_details +from bittensor_cli.src.bittensor.balances import Balance +from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface +from bittensor_cli.src.commands.crowd.utils import get_constant +from bittensor_cli.src.bittensor.utils import ( + blocks_to_duration, + console, + json_console, + print_error, + is_valid_ss58_address, + unlock_key, + print_extrinsic_id, +) + + +async def create_crowdloan( + subtensor: SubtensorInterface, + wallet: Wallet, + deposit_tao: Optional[int], + min_contribution_tao: Optional[int], + cap_tao: Optional[int], + duration_blocks: Optional[int], + target_address: Optional[str], + subnet_lease: Optional[bool], + emissions_share: Optional[int], + lease_end_block: Optional[int], + wait_for_inclusion: bool, + wait_for_finalization: bool, + prompt: bool, + json_output: bool, +) -> tuple[bool, str]: + """ + Create a new crowdloan with the given parameters. + Prompts for missing parameters if not provided. + """ + + unlock_status = unlock_key(wallet) + if not unlock_status.success: + if json_output: + json_console.print( + json.dumps({"success": False, "error": unlock_status.message}) + ) + else: + print_error(f"[red]{unlock_status.message}[/red]") + return False, unlock_status.message + + crowdloan_type = None + if subnet_lease is not None: + crowdloan_type = "subnet" if subnet_lease else "fundraising" + elif prompt: + type_choice = IntPrompt.ask( + "\n[bold cyan]What type of crowdloan would you like to create?[/bold cyan]\n" + "[cyan][1][/cyan] General Fundraising (funds go to address)\n" + "[cyan][2][/cyan] Subnet Leasing (create new subnet)", + choices=["1", "2"], + ) + crowdloan_type = "subnet" if type_choice == 2 else "fundraising" + + if crowdloan_type == "subnet": + current_burn_cost = await subtensor.burn_cost() + console.print( + "\n[magenta]Subnet Lease Crowdloan Selected[/magenta]\n" + " • A new subnet will be created when the crowdloan is finalized\n" + " • Contributors will receive emissions as dividends\n" + " • You will become the subnet operator\n" + f" • [yellow]Note: Ensure cap covers subnet registration cost (currently {current_burn_cost.tao:,.2f} TAO)[/yellow]\n" + ) + else: + console.print( + "\n[cyan]General Fundraising Crowdloan Selected[/cyan]\n" + " • Funds will be transferred to a target address when finalized\n" + " • Contributors can withdraw if the cap is not reached\n" + ) + else: + error_msg = "Crowdloan type not specified and no prompt provided." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(error_msg) + return False, error_msg + + block_hash = await subtensor.substrate.get_chain_head() + runtime = await subtensor.substrate.init_runtime(block_hash=block_hash) + ( + minimum_deposit_raw, + min_contribution_raw, + min_duration, + max_duration, + ) = await asyncio.gather( + get_constant(subtensor, "MinimumDeposit", runtime=runtime), + get_constant(subtensor, "AbsoluteMinimumContribution", runtime=runtime), + get_constant(subtensor, "MinimumBlockDuration", runtime=runtime), + get_constant(subtensor, "MaximumBlockDuration", runtime=runtime), + ) + + minimum_deposit = Balance.from_rao(minimum_deposit_raw) + min_contribution = Balance.from_rao(min_contribution_raw) + + if not prompt: + missing_fields = [] + if deposit_tao is None: + missing_fields.append("--deposit") + if min_contribution_tao is None: + missing_fields.append("--min-contribution") + if cap_tao is None: + missing_fields.append("--cap") + if duration_blocks is None: + missing_fields.append("--duration") + if missing_fields: + error_msg = ( + "The following options must be provided when prompts are disabled: " + + ", ".join(missing_fields) + ) + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, "Missing required options when prompts are disabled." + + deposit_value = deposit_tao + while True: + if deposit_value is None: + deposit_value = FloatPrompt.ask( + f"Enter the deposit amount in TAO " + f"[blue](>= {minimum_deposit.tao:,.4f})[/blue]" + ) + deposit = Balance.from_tao(deposit_value) + if deposit < minimum_deposit: + if prompt: + print_error( + f"[red]Deposit must be at least {minimum_deposit.tao:,.4f} TAO.[/red]" + ) + deposit_value = None + continue + error_msg = f"Deposit is below the minimum required deposit ({minimum_deposit.tao} TAO)." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, "Deposit is below the minimum required deposit." + break + + min_contribution_value = min_contribution_tao + while True: + if min_contribution_value is None: + min_contribution_value = FloatPrompt.ask( + f"Enter the minimum contribution amount in TAO " + f"[blue](>= {min_contribution.tao:,.4f})[/blue]" + ) + min_contribution = Balance.from_tao(min_contribution_value) + if min_contribution < min_contribution: + if prompt: + print_error( + f"[red]Minimum contribution must be at least " + f"{min_contribution.tao:,.4f} TAO.[/red]" + ) + min_contribution_value = None + continue + print_error( + "[red]Minimum contribution is below the chain's absolute minimum.[/red]" + ) + return False, "Minimum contribution is below the chain's absolute minimum." + break + + cap_value = cap_tao + while True: + if cap_value is None: + cap_value = FloatPrompt.ask( + f"Enter the cap amount in TAO [blue](> deposit of {deposit.tao:,.4f})[/blue]" + ) + cap = Balance.from_tao(cap_value) + if cap <= deposit: + if prompt: + print_error( + f"[red]Cap must be greater than the deposit ({deposit.tao:,.4f} TAO).[/red]" + ) + cap_value = None + continue + print_error("[red]Cap must be greater than the initial deposit.[/red]") + return False, "Cap must be greater than the initial deposit." + break + + duration_value = duration_blocks + while True: + if duration_value is None: + duration_value = IntPrompt.ask( + f"Enter the crowdloan duration in blocks " + f"[blue]({min_duration} - {max_duration})[/blue]" + ) + if duration_value < min_duration or duration_value > max_duration: + if prompt: + print_error( + f"[red]Duration must be between {min_duration} and " + f"{max_duration} blocks.[/red]" + ) + duration_value = None + continue + print_error("[red]Crowdloan duration is outside the allowed range.[/red]") + return False, "Crowdloan duration is outside the allowed range." + duration = duration_value + break + + current_block = await subtensor.substrate.get_block_number(None) + call_to_attach = None + + if crowdloan_type == "subnet": + target_address = None + + if emissions_share is None: + emissions_share = IntPrompt.ask( + "Enter emissions share percentage for contributors [blue](0-100)[/blue]" + ) + + if not 0 <= emissions_share <= 100: + print_error( + f"[red]Emissions share must be between 0 and 100, got {emissions_share}[/red]" + ) + return False, "Invalid emissions share percentage." + + if lease_end_block is None: + lease_perpetual = Confirm.ask( + "Should the subnet lease be perpetual?", + default=True, + ) + if not lease_perpetual: + lease_end_block = IntPrompt.ask( + f"Enter the block number when the lease should end. Current block is [bold]{current_block}[/bold]." + ) + register_lease_call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="register_leased_network", + call_params={ + "emissions_share": emissions_share, + "end_block": None if lease_perpetual else lease_end_block, + }, + ) + call_to_attach = register_lease_call + else: + if target_address: + target_address = target_address.strip() + if not is_valid_ss58_address(target_address): + print_error( + f"[red]Invalid target SS58 address provided: {target_address}[/red]" + ) + return False, "Invalid target SS58 address provided." + elif prompt: + target_input = Prompt.ask( + "Enter a target SS58 address", + ) + target_address = target_input.strip() or None + + if not is_valid_ss58_address(target_address): + print_error( + f"[red]Invalid target SS58 address provided: {target_address}[/red]" + ) + return False, "Invalid target SS58 address provided." + + call_to_attach = None + + creator_balance = await subtensor.get_balance(wallet.coldkeypub.ss58_address) + if deposit > creator_balance: + print_error( + f"[red]Insufficient balance to cover the deposit. " + f"Available: {creator_balance}, required: {deposit}[/red]" + ) + return False, "Insufficient balance to cover the deposit." + + end_block = current_block + duration + + call = await subtensor.substrate.compose_call( + call_module="Crowdloan", + call_function="create", + call_params={ + "deposit": deposit.rao, + "min_contribution": min_contribution.rao, + "cap": cap.rao, + "end": end_block, + "call": call_to_attach, + "target_address": target_address, + }, + ) + + extrinsic_fee = await subtensor.get_extrinsic_fee(call, wallet.coldkeypub) + + if prompt: + duration_text = blocks_to_duration(duration) + + table = Table( + Column("[bold white]Field", style=COLORS.G.SUBHEAD), + Column("[bold white]Value", style=COLORS.G.TEMPO), + title=f"\n[bold cyan]Crowdloan Creation Summary[/bold cyan]\n" + f"Network: [{COLORS.G.SUBHEAD_MAIN}]{subtensor.network}[/{COLORS.G.SUBHEAD_MAIN}]", + show_footer=False, + show_header=False, + width=None, + pad_edge=False, + box=box.SIMPLE, + show_edge=True, + border_style="bright_black", + ) + + if crowdloan_type == "subnet": + table.add_row("Type", "[magenta]Subnet Leasing[/magenta]") + table.add_row( + "Emissions Share", f"[cyan]{emissions_share}%[/cyan] for contributors" + ) + if lease_end_block: + table.add_row("Lease Ends", f"Block {lease_end_block}") + else: + table.add_row("Lease Duration", "[green]Perpetual[/green]") + else: + table.add_row("Type", "[cyan]General Fundraising[/cyan]") + target_text = ( + target_address + if target_address + else f"[{COLORS.G.SUBHEAD_MAIN}]Not specified[/{COLORS.G.SUBHEAD_MAIN}]" + ) + table.add_row("Target address", target_text) + + table.add_row("Deposit", f"[{COLORS.P.TAO}]{deposit}[/{COLORS.P.TAO}]") + table.add_row( + "Min contribution", f"[{COLORS.P.TAO}]{min_contribution}[/{COLORS.P.TAO}]" + ) + table.add_row("Cap", f"[{COLORS.P.TAO}]{cap}[/{COLORS.P.TAO}]") + table.add_row("Duration", f"[bold]{duration}[/bold] blocks (~{duration_text})") + table.add_row("Ends at block", f"[bold]{end_block}[/bold]") + table.add_row( + "Estimated fee", f"[{COLORS.P.TAO}]{extrinsic_fee}[/{COLORS.P.TAO}]" + ) + console.print(table) + + if not Confirm.ask("Proceed with creating the crowdloan?"): + if json_output: + json_console.print( + json.dumps( + {"success": False, "error": "Cancelled crowdloan creation."} + ) + ) + else: + console.print("[yellow]Cancelled crowdloan creation.[/yellow]") + return False, "Cancelled crowdloan creation." + + success, error_message, extrinsic_receipt = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + if not success: + if json_output: + json_console.print( + json.dumps( + { + "success": False, + "error": error_message or "Failed to create crowdloan.", + } + ) + ) + else: + print_error(f"[red]{error_message or 'Failed to create crowdloan.'}[/red]") + return False, error_message or "Failed to create crowdloan." + + if json_output: + extrinsic_id = await extrinsic_receipt.get_extrinsic_identifier() + output_dict = { + "success": True, + "error": None, + "data": { + "type": crowdloan_type, + "deposit": deposit.tao, + "min_contribution": min_contribution.tao, + "cap": cap.tao, + "duration": duration, + "end_block": end_block, + "extrinsic_id": extrinsic_id, + }, + } + + if crowdloan_type == "subnet": + output_dict["data"]["emissions_share"] = emissions_share + output_dict["data"]["lease_end_block"] = lease_end_block + output_dict["data"]["perpetual_lease"] = lease_end_block is None + else: + output_dict["data"]["target_address"] = target_address + + json_console.print(json.dumps(output_dict)) + message = f"{crowdloan_type.capitalize()} crowdloan created successfully." + else: + if crowdloan_type == "subnet": + message = "Subnet lease crowdloan created successfully." + console.print( + f"\n:white_check_mark: [green]{message}[/green]\n" + f" Type: [magenta]Subnet Leasing[/magenta]\n" + f" Emissions Share: [cyan]{emissions_share}%[/cyan]\n" + f" Deposit: [{COLORS.P.TAO}]{deposit}[/{COLORS.P.TAO}]\n" + f" Min contribution: [{COLORS.P.TAO}]{min_contribution}[/{COLORS.P.TAO}]\n" + f" Cap: [{COLORS.P.TAO}]{cap}[/{COLORS.P.TAO}]\n" + f" Ends at block: [bold]{end_block}[/bold]" + ) + if lease_end_block: + console.print(f" Lease ends at block: [bold]{lease_end_block}[/bold]") + else: + console.print(" Lease: [green]Perpetual[/green]") + else: + message = "Fundraising crowdloan created successfully." + console.print( + f"\n:white_check_mark: [green]{message}[/green]\n" + f" Type: [cyan]General Fundraising[/cyan]\n" + f" Deposit: [{COLORS.P.TAO}]{deposit}[/{COLORS.P.TAO}]\n" + f" Min contribution: [{COLORS.P.TAO}]{min_contribution}[/{COLORS.P.TAO}]\n" + f" Cap: [{COLORS.P.TAO}]{cap}[/{COLORS.P.TAO}]\n" + f" Ends at block: [bold]{end_block}[/bold]" + ) + if target_address: + console.print(f" Target address: {target_address}") + + await print_extrinsic_id(extrinsic_receipt) + + return True, message + + +async def finalize_crowdloan( + subtensor: SubtensorInterface, + wallet: Wallet, + crowdloan_id: int, + wait_for_inclusion: bool, + wait_for_finalization: bool, + prompt: bool, + json_output: bool = False, +) -> tuple[bool, str]: + """ + Finalize a successful crowdloan that has reached its cap. + + Only the creator can finalize a crowdloan. Finalization will: + - Transfer funds to the target address (if specified) + - Execute the attached call (if any, e.g., subnet creation) + - Mark the crowdloan as finalized + + Args: + subtensor: SubtensorInterface instance for blockchain interaction + wallet: Wallet instance containing the user's keys + crowdloan_id: The ID of the crowdloan to finalize + wait_for_inclusion: Whether to wait for transaction inclusion + wait_for_finalization: Whether to wait for transaction finalization + prompt: Whether to prompt for user confirmation + + Returns: + Tuple of (success, message) indicating the result + """ + + crowdloan, current_block = await asyncio.gather( + subtensor.get_single_crowdloan(crowdloan_id), + subtensor.substrate.get_block_number(None), + ) + + if not crowdloan: + error_msg = f"Crowdloan #{crowdloan_id} does not exist." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, error_msg + + if wallet.coldkeypub.ss58_address != crowdloan.creator: + error_msg = ( + f"Only the creator can finalize a crowdloan. Creator: {crowdloan.creator}" + ) + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, "Only the creator can finalize a crowdloan." + + if crowdloan.finalized: + error_msg = f"Crowdloan #{crowdloan_id} is already finalized." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, "Crowdloan is already finalized." + + if crowdloan.raised < crowdloan.cap: + still_needed = crowdloan.cap - crowdloan.raised + error_msg = ( + f"Crowdloan #{crowdloan_id} has not reached its cap. Raised: {crowdloan.raised.tao}, " + f"Cap: {crowdloan.cap.tao}, Still needed: {still_needed.tao}" + ) + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error( + f"[red]Crowdloan #{crowdloan_id} has not reached its cap.\n" + f"Raised: {crowdloan.raised}, Cap: {crowdloan.cap}\n" + f"Still needed: {still_needed.tao}[/red]" + ) + return False, "Crowdloan has not reached its cap." + + call = await subtensor.substrate.compose_call( + call_module="Crowdloan", + call_function="finalize", + call_params={ + "crowdloan_id": crowdloan_id, + }, + ) + extrinsic_fee = await subtensor.get_extrinsic_fee(call, wallet.coldkeypub) + + await show_crowdloan_details( + subtensor=subtensor, + crowdloan_id=crowdloan_id, + wallet=wallet, + verbose=False, + crowdloan=crowdloan, + current_block=current_block, + ) + + if prompt: + console.print() + table = Table( + Column("[bold white]Field", style=COLORS.G.SUBHEAD), + Column("[bold white]Value", style=COLORS.G.TEMPO), + title="\n[bold cyan]Crowdloan Finalization Summary[/bold cyan]", + show_footer=False, + show_header=False, + width=None, + pad_edge=False, + box=box.SIMPLE, + show_edge=True, + border_style="bright_black", + ) + + table.add_row("Crowdloan ID", str(crowdloan_id)) + table.add_row("Status", "[green]Ready to Finalize[/green]") + table.add_row( + "Total Raised", f"[{COLORS.S.AMOUNT}]{crowdloan.raised}[/{COLORS.S.AMOUNT}]" + ) + table.add_row("Contributors", str(crowdloan.contributors_count)) + + if crowdloan.target_address: + table.add_row( + "Funds Will Go To", + f"[{COLORS.G.SUBHEAD_EX_1}]{crowdloan.target_address}[/{COLORS.G.SUBHEAD_EX_1}]", + ) + + if crowdloan.has_call: + table.add_row( + "Call to Execute", "[yellow]Yes (e.g., subnet registration)[/yellow]" + ) + else: + table.add_row("Call to Execute", "[dim]None[/dim]") + + table.add_row("Transaction Fee", str(extrinsic_fee)) + + table.add_section() + table.add_row( + "[bold red]WARNING[/bold red]", + "[yellow]This action is IRREVERSIBLE![/yellow]", + ) + + console.print(table) + + console.print( + "\n[bold yellow]Important:[/bold yellow]\n" + "• Finalization will transfer all raised funds\n" + "• Any attached call will be executed immediately\n" + "• This action cannot be undone\n" + ) + + if not Confirm.ask("\nProceed with finalization?"): + if json_output: + json_console.print( + json.dumps( + {"success": False, "error": "Finalization cancelled by user."} + ) + ) + else: + console.print("[yellow]Finalization cancelled.[/yellow]") + return False, "Finalization cancelled by user." + + unlock_status = unlock_key(wallet) + if not unlock_status.success: + if json_output: + json_console.print( + json.dumps({"success": False, "error": unlock_status.message}) + ) + else: + print_error(f"[red]{unlock_status.message}[/red]") + return False, unlock_status.message + + success, error_message, extrinsic_receipt = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + if not success: + if json_output: + json_console.print( + json.dumps( + { + "success": False, + "error": error_message or "Failed to finalize crowdloan.", + } + ) + ) + else: + print_error( + f"[red]Failed to finalize: {error_message or 'Unknown error'}[/red]" + ) + return False, error_message or "Failed to finalize crowdloan." + + if json_output: + extrinsic_id = await extrinsic_receipt.get_extrinsic_identifier() + output_dict = { + "success": True, + "error": None, + "extrinsic_identifier": extrinsic_id, + "data": { + "crowdloan_id": crowdloan_id, + "total_raised": crowdloan.raised.tao, + "contributors_count": crowdloan.contributors_count, + "target_address": crowdloan.target_address, + "has_call": crowdloan.has_call, + "call_executed": crowdloan.has_call, + }, + } + json_console.print(json.dumps(output_dict)) + else: + console.print( + f"\n[dark_sea_green3]Successfully finalized crowdloan #{crowdloan_id}![/dark_sea_green3]\n" + ) + + console.print( + f"[bold]Finalization Complete:[/bold]\n" + f"\t• Total Raised: [{COLORS.S.AMOUNT}]{crowdloan.raised}[/{COLORS.S.AMOUNT}]\n" + f"\t• Contributors: {crowdloan.contributors_count}" + ) + + if crowdloan.target_address: + console.print( + f"\t• Funds transferred to: [{COLORS.G.SUBHEAD_EX_1}]{crowdloan.target_address}[/{COLORS.G.SUBHEAD_EX_1}]" + ) + + if crowdloan.has_call: + console.print("\t• [green]Associated call has been executed[/green]") + + await print_extrinsic_id(extrinsic_receipt) + + return True, "Successfully finalized crowdloan." diff --git a/bittensor_cli/src/commands/crowd/dissolve.py b/bittensor_cli/src/commands/crowd/dissolve.py new file mode 100644 index 000000000..b7513fb19 --- /dev/null +++ b/bittensor_cli/src/commands/crowd/dissolve.py @@ -0,0 +1,210 @@ +import asyncio +import json + +from bittensor_wallet import Wallet +from rich.prompt import Confirm +from rich.table import Column, Table, box + +from bittensor_cli.src import COLORS +from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface +from bittensor_cli.src.commands.crowd.view import show_crowdloan_details +from bittensor_cli.src.bittensor.utils import ( + blocks_to_duration, + console, + json_console, + print_extrinsic_id, + print_error, + unlock_key, +) + + +async def dissolve_crowdloan( + subtensor: SubtensorInterface, + wallet: Wallet, + crowdloan_id: int, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, + prompt: bool = True, + json_output: bool = False, +) -> tuple[bool, str]: + """Dissolve a non-finalized crowdloan after refunding contributors. + + The creator can reclaim their deposit once every other contribution has been + refunded (i.e., the raised amount equals the creator's contribution). + + Args: + subtensor: SubtensorInterface object for chain interaction. + wallet: Wallet object containing the creator's coldkey. + crowdloan_id: ID of the crowdloan to dissolve. + wait_for_inclusion: Wait for transaction inclusion. + wait_for_finalization: Wait for transaction finalization. + prompt: Whether to prompt for confirmation. + + Returns: + tuple[bool, str]: Success status and message. + """ + + creator_ss58 = wallet.coldkeypub.ss58_address + + crowdloan, current_block = await asyncio.gather( + subtensor.get_single_crowdloan(crowdloan_id), + subtensor.substrate.get_block_number(None), + ) + + if not crowdloan: + error_msg = f"Crowdloan #{crowdloan_id} not found." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, error_msg + + if crowdloan.finalized: + error_msg = ( + f"Crowdloan #{crowdloan_id} is already finalized and cannot be dissolved." + ) + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, f"Crowdloan #{crowdloan_id} is finalized." + + if creator_ss58 != crowdloan.creator: + error_msg = f"Only the creator can dissolve this crowdloan. Creator: {crowdloan.creator}, Your address: {creator_ss58}" + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error( + f"[red]Only the creator can dissolve this crowdloan.[/red]\n" + f"Creator: [blue]{crowdloan.creator}[/blue]\n" + f"Your address: [blue]{creator_ss58}[/blue]" + ) + return False, "Only the creator can dissolve this crowdloan." + + creator_contribution = await subtensor.get_crowdloan_contribution( + crowdloan_id, crowdloan.creator + ) + + if creator_contribution != crowdloan.raised: + error_msg = ( + f"Crowdloan still holds funds from other contributors. " + f"Raised: {crowdloan.raised.tao}, Creator's contribution: {creator_contribution.tao}. " + "Run 'btcli crowd refund' until only the creator's funds remain." + ) + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error( + f"[red]Crowdloan still holds funds from other contributors.[/red]\n" + f"Raised amount: [yellow]{crowdloan.raised}[/yellow]\n" + f"Creator's contribution: [yellow]{creator_contribution}[/yellow]\n" + "Run [cyan]btcli crowd refund[/cyan] until only the creator's funds remain." + ) + return False, "Crowdloan not ready to dissolve." + + await show_crowdloan_details( + subtensor=subtensor, + crowdloan_id=crowdloan_id, + wallet=wallet, + verbose=False, + crowdloan=crowdloan, + current_block=current_block, + ) + + summary = Table( + Column("Field", style=COLORS.G.SUBHEAD), + Column("Value", style=COLORS.G.TEMPO), + box=box.SIMPLE, + show_header=False, + ) + summary.add_row("Crowdloan ID", f"#{crowdloan_id}") + summary.add_row("Raised", str(crowdloan.raised)) + summary.add_row("Creator Contribution", str(creator_contribution)) + summary.add_row( + "Remaining Contributors", + str(max(0, crowdloan.contributors_count - 1)), + ) + time_remaining = crowdloan.end - current_block + summary.add_row( + "Time Remaining", + blocks_to_duration(time_remaining) if time_remaining > 0 else "Ended", + ) + + console.print("\n[bold cyan]Crowdloan Dissolution Summary[/bold cyan]") + console.print(summary) + + if prompt and not Confirm.ask( + f"\n[bold]Proceed with dissolving crowdloan #{crowdloan_id}?[/bold]", + default=False, + ): + if json_output: + json_console.print( + json.dumps( + {"success": False, "error": "Dissolution cancelled by user."} + ) + ) + else: + console.print("[yellow]Dissolution cancelled.[/yellow]") + return False, "Dissolution cancelled by user." + + unlock_status = unlock_key(wallet) + if not unlock_status.success: + if json_output: + json_console.print( + json.dumps({"success": False, "error": unlock_status.message}) + ) + else: + print_error(f"[red]{unlock_status.message}[/red]") + return False, unlock_status.message + + with console.status( + ":satellite: Submitting dissolve transaction...", spinner="aesthetic" + ): + call = await subtensor.substrate.compose_call( + call_module="Crowdloan", + call_function="dissolve", + call_params={"crowdloan_id": crowdloan_id}, + ) + ( + success, + error_message, + extrinsic_receipt, + ) = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + if not success: + if json_output: + json_console.print( + json.dumps( + { + "success": False, + "error": error_message or "Failed to dissolve crowdloan.", + } + ) + ) + else: + print_error(f"[red]Failed to dissolve crowdloan.[/red]\n{error_message}") + return False, error_message + + if json_output: + extrinsic_id = await extrinsic_receipt.get_extrinsic_identifier() + output_dict = { + "success": True, + "error": None, + "extrinsic_identifier": extrinsic_id, + "data": { + "crowdloan_id": crowdloan_id, + "creator": crowdloan.creator, + "total_dissolved": creator_contribution.tao, + }, + } + json_console.print(json.dumps(output_dict)) + else: + await print_extrinsic_id(extrinsic_receipt) + console.print("[green]Crowdloan dissolved successfully![/green]") + + return True, "Crowdloan dissolved successfully." diff --git a/bittensor_cli/src/commands/crowd/refund.py b/bittensor_cli/src/commands/crowd/refund.py new file mode 100644 index 000000000..d08f91291 --- /dev/null +++ b/bittensor_cli/src/commands/crowd/refund.py @@ -0,0 +1,225 @@ +import asyncio +import json + +from bittensor_wallet import Wallet +from rich.prompt import Confirm +from rich.table import Table, Column, box + +from bittensor_cli.src import COLORS +from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface +from bittensor_cli.src.bittensor.utils import ( + blocks_to_duration, + console, + json_console, + print_extrinsic_id, + print_error, + unlock_key, +) +from bittensor_cli.src.commands.crowd.view import show_crowdloan_details +from bittensor_cli.src.commands.crowd.utils import get_constant + + +async def refund_crowdloan( + subtensor: SubtensorInterface, + wallet: Wallet, + crowdloan_id: int, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, + prompt: bool = True, + json_output: bool = False, +) -> tuple[bool, str]: + """Refund contributors of a non-finalized crowdloan. + + This extrinsic refunds all contributors (excluding the creator) up to the + RefundContributorsLimit. If there are more contributors than the limit, + this call may need to be executed multiple times until all contributors + are refunded. + + Anyone can call this function - it does not need to be the creator. + + Args: + subtensor: SubtensorInterface object for chain interaction + wallet: Wallet object containing coldkey (any wallet can call this) + crowdloan_id: ID of the crowdloan to refund + wait_for_inclusion: Wait for transaction inclusion + wait_for_finalization: Wait for transaction finalization + prompt: Whether to prompt for confirmation + + Returns: + tuple[bool, str]: Success status and message + """ + creator_ss58 = wallet.coldkeypub.ss58_address + crowdloan, current_block = await asyncio.gather( + subtensor.get_single_crowdloan(crowdloan_id), + subtensor.substrate.get_block_number(None), + ) + + if not crowdloan: + error_msg = f"Crowdloan #{crowdloan_id} not found." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, error_msg + + if crowdloan.finalized: + error_msg = f"Crowdloan #{crowdloan_id} is already finalized. Finalized crowdloans cannot be refunded." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, f"Crowdloan #{crowdloan_id} is already finalized." + + if creator_ss58 != crowdloan.creator: + error_msg = f"Only the creator can refund this crowdloan. Creator: {crowdloan.creator}, Your address: {creator_ss58}" + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error( + f"[red]Only the creator can refund this crowdloan.[/red]\n" + f"Creator: [blue]{crowdloan.creator}[/blue]\n" + f"Your address: [blue]{creator_ss58}[/blue]" + ) + return False, "Only the creator can refund this crowdloan." + + await show_crowdloan_details( + subtensor=subtensor, + crowdloan_id=crowdloan_id, + wallet=wallet, + verbose=False, + crowdloan=crowdloan, + current_block=current_block, + ) + + refund_limit = await get_constant(subtensor, "RefundContributorsLimit") + + console.print("\n[bold cyan]Crowdloan Refund Information[/bold cyan]\n") + + info_table = Table( + Column("[bold white]Property", style=COLORS.G.SUBHEAD), + Column("[bold white]Value", style=COLORS.G.TEMPO), + show_footer=False, + show_header=False, + width=None, + pad_edge=False, + box=box.SIMPLE, + show_edge=True, + border_style="bright_black", + ) + + info_table.add_row("Crowdloan ID", f"#{crowdloan_id}") + info_table.add_row("Total Contributors", f"{crowdloan.contributors_count:,}") + info_table.add_row("Refund Limit (per call)", f"{refund_limit:,} contributors") + info_table.add_row("Amount to Refund", crowdloan.raised - crowdloan.deposit) + + if current_block >= crowdloan.end: + if crowdloan.raised < crowdloan.cap: + status = "[red]Failed[/red] (Cap not reached)" + else: + status = "[yellow]Ended but not finalized[/yellow]" + else: + status = "[green]Active[/green] (Still accepting contributions)" + + info_table.add_row("Status", status) + + refundable_contributors = max(0, crowdloan.contributors_count) + estimated_calls = ( + (refundable_contributors + refund_limit) // refund_limit + if refund_limit > 0 + else 0 + ) + + if estimated_calls > 1: + info_table.add_row( + "Estimated Calls Needed", + f"[yellow]~{estimated_calls}[/yellow] (due to contributor limit)", + ) + + console.print(info_table) + + if estimated_calls > 1: + console.print( + f"\n[yellow]Note:[/yellow] Due to the [cyan]Refund Contributors Limit[/cyan] of {refund_limit:,} contributors per call,\n" + f" you may need to execute this command [yellow]{estimated_calls} times[/yellow] to refund all contributors.\n" + f" Each call will refund up to {refund_limit:,} contributors until all are processed.\n" + ) + + if prompt and not Confirm.ask( + f"\n[bold]Proceed with refunding contributors of Crowdloan #{crowdloan_id}?[/bold]", + default=False, + ): + if json_output: + json_console.print( + json.dumps({"success": False, "error": "Refund cancelled by user."}) + ) + else: + console.print("[yellow]Refund cancelled.[/yellow]") + return False, "Refund cancelled by user." + + unlock_status = unlock_key(wallet) + if not unlock_status.success: + if json_output: + json_console.print( + json.dumps({"success": False, "error": unlock_status.message}) + ) + else: + print_error(f"[red]{unlock_status.message}[/red]") + return False, unlock_status.message + + with console.status( + ":satellite: Submitting refund transaction...", spinner="aesthetic" + ): + call = await subtensor.substrate.compose_call( + call_module="Crowdloan", + call_function="refund", + call_params={ + "crowdloan_id": crowdloan_id, + }, + ) + ( + success, + error_message, + extrinsic_receipt, + ) = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + if not success: + if json_output: + json_console.print( + json.dumps( + { + "success": False, + "error": error_message or "Failed to refund contributors.", + } + ) + ) + else: + print_error(f"[red]Failed to refund contributors.[/red]\n{error_message}") + return False, error_message + + if json_output: + extrinsic_id = await extrinsic_receipt.get_extrinsic_identifier() + output_dict = { + "success": True, + "error": None, + "extrinsic_identifier": extrinsic_id, + "data": { + "crowdloan_id": crowdloan_id, + "refund_limit_per_call": refund_limit, + "total_contributors": crowdloan.contributors_count, + "estimated_calls_remaining": max(0, estimated_calls - 1), + "amount_refunded": (crowdloan.raised - crowdloan.deposit).tao, + }, + } + json_console.print(json.dumps(output_dict)) + else: + console.print( + f"[green]Contributors have been refunded for Crowdloan #{crowdloan_id}.[/green]" + ) + await print_extrinsic_id(extrinsic_receipt) + + return True, f"Contributors have been refunded for Crowdloan #{crowdloan_id}." diff --git a/bittensor_cli/src/commands/crowd/update.py b/bittensor_cli/src/commands/crowd/update.py new file mode 100644 index 000000000..2b2ee04f1 --- /dev/null +++ b/bittensor_cli/src/commands/crowd/update.py @@ -0,0 +1,408 @@ +import asyncio +import json +from typing import Optional + +from bittensor_wallet import Wallet +from rich.prompt import Confirm, IntPrompt, FloatPrompt +from rich.table import Table, Column, box + +from bittensor_cli.src import COLORS +from bittensor_cli.src.bittensor.balances import Balance +from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface +from bittensor_cli.src.bittensor.utils import ( + blocks_to_duration, + console, + json_console, + print_error, + unlock_key, + print_extrinsic_id, +) +from bittensor_cli.src.commands.crowd.view import show_crowdloan_details +from bittensor_cli.src.commands.crowd.utils import get_constant + + +async def update_crowdloan( + subtensor: SubtensorInterface, + wallet: Wallet, + crowdloan_id: int, + min_contribution: Optional[Balance] = None, + end: Optional[int] = None, + cap: Optional[Balance] = None, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, + prompt: bool = True, + json_output: bool = False, +) -> tuple[bool, str]: + """Update parameters of a non-finalized crowdloan. + + Args: + subtensor: SubtensorInterface object for chain interaction + wallet: Wallet object containing coldkey (must be creator) + crowdloan_id: ID of the crowdloan to update + min_contribution: New minimum contribution in TAO (None to prompt) + end: New end block (None to prompt) + cap: New cap in TAO (None to prompt) + wait_for_inclusion: Wait for transaction inclusion + wait_for_finalization: Wait for transaction finalization + prompt: Whether to prompt for values + + Returns: + tuple[bool, str]: Success status and message + """ + + block_hash = await subtensor.substrate.get_chain_head() + crowdloan, current_block = await asyncio.gather( + subtensor.get_single_crowdloan(crowdloan_id, block_hash=block_hash), + subtensor.substrate.get_block_number(block_hash=block_hash), + ) + + runtime = await subtensor.substrate.init_runtime(block_hash=block_hash) + absolute_min_rao, min_duration, max_duration = await asyncio.gather( + get_constant(subtensor, "AbsoluteMinimumContribution", runtime=runtime), + get_constant(subtensor, "MinimumBlockDuration", runtime=runtime), + get_constant(subtensor, "MaximumBlockDuration", runtime=runtime), + ) + absolute_min = Balance.from_rao(absolute_min_rao) + + if not crowdloan: + error_msg = f"Crowdloan #{crowdloan_id} not found." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, error_msg + + if crowdloan.finalized: + error_msg = ( + f"Crowdloan #{crowdloan_id} is already finalized and cannot be updated." + ) + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, f"Crowdloan #{crowdloan_id} is already finalized." + + creator_address = wallet.coldkeypub.ss58_address + if creator_address != crowdloan.creator: + error_msg = "Only the creator can update this crowdloan." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error( + f"[red]Only the creator can update this crowdloan.[/red]\n" + f"Creator: [blue]{crowdloan.creator}[/blue]\n" + f"Your address: [blue]{creator_address}[/blue]" + ) + return False, error_msg + + await show_crowdloan_details( + subtensor=subtensor, + crowdloan_id=crowdloan_id, + wallet=wallet, + verbose=False, + crowdloan=crowdloan, + current_block=current_block, + ) + + if all(x is None for x in [min_contribution, end, cap]) and prompt: + console.print( + f"\n[bold cyan]What would you like to update for Crowdloan #{crowdloan_id}?[/bold cyan]\n" + ) + time_left = blocks_to_duration(crowdloan.end - current_block) + choice = IntPrompt.ask( + f"[cyan][1][/cyan] Minimum Contribution (current: [yellow]{crowdloan.min_contribution}[/yellow])\n" + f"[cyan][2][/cyan] End Block (current: [yellow]block {crowdloan.end:,}[/yellow], {time_left} remaining)\n" + f"[cyan][3][/cyan] Cap (current: [yellow]{crowdloan.cap}[/yellow])\n" + f"[cyan][4][/cyan] Cancel\n\n" + f"Enter your choice", + choices=["1", "2", "3", "4"], + default=4, + ) + + if choice == 4: + if json_output: + json_console.print( + json.dumps({"success": False, "error": "Update cancelled by user."}) + ) + else: + console.print("[yellow]Update cancelled.[/yellow]") + return False, "Update cancelled by user." + + if choice == 1: + console.print( + f"\n[cyan]Update Minimum Contribution[/cyan]" + f"\n • Current: [yellow]{crowdloan.min_contribution}[/yellow]" + f"\n • Absolute minimum: [dim]{absolute_min}[/dim]\n" + ) + + while True: + new_value = FloatPrompt.ask( + "Enter new minimum contribution (TAO)", + default=float(crowdloan.min_contribution.tao), + ) + candidate = Balance.from_tao(new_value) + if candidate.rao < absolute_min.rao: + print_error( + f"[red]Minimum contribution must be at least {absolute_min}. Try again.[/red]" + ) + continue + min_contribution = candidate + break + + elif choice == 2: + min_end_block = current_block + min_duration + max_end_block = current_block + max_duration + duration_remaining = blocks_to_duration(crowdloan.end - current_block) + console.print( + f"\n[cyan]Update End Block[/cyan]" + f"\n • Current: [yellow]block {crowdloan.end:,}[/yellow] ({duration_remaining} remaining)" + f"\n • Current block: [dim]{current_block:,}[/dim]" + f"\n • Valid range: [dim]{min_end_block:,} - {max_end_block:,}[/dim]" + f"\n • Duration range: [dim]{blocks_to_duration(min_duration)} - {blocks_to_duration(max_duration)}[/dim]\n" + ) + + while True: + candidate_end = IntPrompt.ask( + "Enter new end block", + default=crowdloan.end, + ) + + if candidate_end <= current_block: + print_error( + f"[red]End block must be after current block ({current_block:,}). Try again.[/red]" + ) + continue + + duration = candidate_end - current_block + if duration < min_duration: + duration_range = f"[dim]{min_end_block} - {blocks_to_duration(min_duration)}[/dim]" + print_error( + f"[red]Duration is too short. Minimum: {duration_range}. Try again.[/red]" + ) + continue + if duration > max_duration: + duration_range = f"[dim]{max_end_block} - {blocks_to_duration(max_duration)}[/dim]" + print_error( + f"[red]Duration is too long. Maximum: {duration_range}. Try again.[/red]" + ) + continue + + end = candidate_end + break + + elif choice == 3: + console.print( + f"\n[cyan]Update Cap[/cyan]" + f"\n • Current cap: [yellow]{crowdloan.cap}[/yellow]" + f"\n • Already raised: [green]{crowdloan.raised}[/green]" + f"\n • Remaining to raise: [dim]{(crowdloan.cap.rao - crowdloan.raised.rao) / 1e9:.9f} TAO[/dim]" + f"\n • New cap must be >= raised amount\n" + ) + + while True: + new_value = FloatPrompt.ask( + "Enter new cap (TAO)", + default=float(crowdloan.cap.tao), + ) + candidate_cap = Balance.from_tao(new_value) + if candidate_cap.rao < crowdloan.raised.rao: + print_error( + f"[red]Cap must be >= amount already raised ({crowdloan.raised}). Try again.[/red]" + ) + continue + cap = candidate_cap + break + + value: Optional[Balance | int] = None + call_function: Optional[str] = None + param_name: Optional[str] = None + update_type: Optional[str] = None + + if min_contribution is not None: + value = min_contribution + call_function = "update_min_contribution" + param_name = "new_min_contribution" + update_type = "Minimum Contribution" + elif cap is not None: + value = cap + call_function = "update_cap" + param_name = "new_cap" + update_type = "Cap" + elif end is not None: + value = end + call_function = "update_end" + param_name = "new_end" + update_type = "End Block" + + if call_function is None or value is None or param_name is None: + error_msg = "No update parameter specified." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, error_msg + + # Validation + if call_function == "update_min_contribution": + if value.rao < absolute_min.rao: + error_msg = f"Minimum contribution must be at least {absolute_min}." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error( + f"[red]Minimum contribution ({value}) must be at least {absolute_min}.[/red]" + ) + return False, error_msg + + elif call_function == "update_end": + if value <= current_block: + error_msg = "End block must be in the future." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error( + f"[red]End block ({value:,}) must be after current block ({current_block:,}).[/red]" + ) + return False, error_msg + + block_duration = value - current_block + if block_duration < min_duration: + error_msg = "Block duration too short." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error( + f"[red]Duration ({blocks_to_duration(block_duration)}) is too short. " + f"Minimum: [dim]{min_end_block} - {blocks_to_duration(min_duration)}[/dim][/red]" + ) + return False, error_msg + + if block_duration > max_duration: + error_msg = "Block duration too long." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error( + f"[red]Duration ({blocks_to_duration(block_duration)}) is too long. " + f"Maximum: [dim]{max_end_block} - {blocks_to_duration(max_duration)}[/dim][/red]" + ) + return False, error_msg + + elif call_function == "update_cap": + if value < crowdloan.raised: + error_msg = "Cap must be >= raised amount." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error( + f"[red]New cap ({value}) must be at least the amount already raised ({crowdloan.raised}).[/red]" + ) + return False, error_msg + + # Update summary + table = Table( + Column("[bold white]Parameter", style=COLORS.G.SUBHEAD), + Column("[bold white]Current Value", style=COLORS.G.TEMPO), + Column("[bold white]New Value", style=COLORS.G.TEMPO), + title="\n[bold cyan]Update Summary[/bold cyan]", + show_footer=False, + width=None, + pad_edge=False, + box=box.SIMPLE, + show_edge=True, + border_style="bright_black", + ) + + if call_function == "update_min_contribution": + table.add_row( + "Minimum Contribution", str(crowdloan.min_contribution), str(value) + ) + elif call_function == "update_end": + table.add_row( + "End Block", + f"{crowdloan.end:,} ({blocks_to_duration(crowdloan.end - current_block)} remaining)", + f"{value:,} ({blocks_to_duration(value - current_block)} remaining)", + ) + elif call_function == "update_cap": + table.add_row("Cap", str(crowdloan.cap), str(value)) + + console.print(table) + + if prompt and not Confirm.ask( + f"\n[bold]Proceed with updating {update_type}?[/bold]", default=False + ): + if json_output: + json_console.print( + json.dumps({"success": False, "error": "Update cancelled by user."}) + ) + else: + console.print("[yellow]Update cancelled.[/yellow]") + return False, "Update cancelled by user." + + unlock_status = unlock_key(wallet) + if not unlock_status.success: + if json_output: + json_console.print( + json.dumps({"success": False, "error": unlock_status.message}) + ) + else: + print_error(f"[red]{unlock_status.message}[/red]") + return False, unlock_status.message + + if call_function != "update_end": + value = value.rao + + with console.status( + ":satellite: Submitting update transaction...", spinner="aesthetic" + ): + call = await subtensor.substrate.compose_call( + call_module="Crowdloan", + call_function=call_function, + call_params={"crowdloan_id": crowdloan_id, param_name: value}, + ) + + ( + success, + error_message, + extrinsic_receipt, + ) = await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + if not success: + if json_output: + json_console.print( + json.dumps( + { + "success": False, + "error": error_message or f"Failed to update {update_type}.", + } + ) + ) + else: + print_error(f"[red]Failed to update {update_type}.[/red]\n{error_message}") + return False, error_message + + if json_output: + extrinsic_id = await extrinsic_receipt.get_extrinsic_identifier() + output_dict = { + "success": True, + "error": None, + "extrinsic_identifier": extrinsic_id, + "data": { + "crowdloan_id": crowdloan_id, + "update_type": update_type, + }, + } + json_console.print(json.dumps(output_dict)) + else: + console.print( + f"[green]{update_type} updated successfully![/green]\n" + f"Crowdloan #{crowdloan_id} has been updated." + ) + await print_extrinsic_id(extrinsic_receipt) + + return True, f"{update_type} updated successfully." diff --git a/bittensor_cli/src/commands/crowd/utils.py b/bittensor_cli/src/commands/crowd/utils.py new file mode 100644 index 000000000..4ad7895e5 --- /dev/null +++ b/bittensor_cli/src/commands/crowd/utils.py @@ -0,0 +1,35 @@ +from typing import Optional + +from async_substrate_interface.types import Runtime + +from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface + + +async def get_constant( + subtensor: SubtensorInterface, + constant_name: str, + runtime: Optional[Runtime] = None, + block_hash: Optional[str] = None, +) -> int: + """ + Get a constant from the Crowdloan pallet. + + Args: + subtensor: SubtensorInterface object for chain interaction + constant_name: Name of the constant to get + runtime: Runtime object + block_hash: Block hash + + Returns: + The value of the constant + """ + + runtime = runtime or await subtensor.substrate.init_runtime(block_hash=block_hash) + + result = await subtensor.substrate.get_constant( + module_name="Crowdloan", + constant_name=constant_name, + block_hash=block_hash, + runtime=runtime, + ) + return getattr(result, "value", result) diff --git a/bittensor_cli/src/commands/crowd/view.py b/bittensor_cli/src/commands/crowd/view.py new file mode 100644 index 000000000..ba7657dd5 --- /dev/null +++ b/bittensor_cli/src/commands/crowd/view.py @@ -0,0 +1,641 @@ +from typing import Optional + +import asyncio +import json +from bittensor_wallet import Wallet +from rich import box +from rich.table import Column, Table + +from bittensor_cli.src import COLORS +from bittensor_cli.src.bittensor.balances import Balance +from bittensor_cli.src.bittensor.chain_data import CrowdloanData +from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface +from bittensor_cli.src.bittensor.utils import ( + blocks_to_duration, + console, + json_console, + print_error, + millify_tao, +) + + +def _shorten(account: str | None) -> str: + if not account: + return "-" + return f"{account[:6]}…{account[-6:]}" + + +def _status(loan: CrowdloanData, current_block: int) -> str: + if loan.finalized: + return "Finalized" + if loan.raised >= loan.cap: + return "Funded" + if current_block >= loan.end: + return "Closed" + return "Active" + + +def _time_remaining(loan: CrowdloanData, current_block: int) -> str: + diff = loan.end - current_block + if diff > 0: + return blocks_to_duration(diff) + if diff == 0: + return "due" + return f"Closed {blocks_to_duration(abs(diff))} ago" + + +async def list_crowdloans( + subtensor: SubtensorInterface, + verbose: bool = False, + json_output: bool = False, +) -> bool: + """List all crowdloans in a tabular format or JSON output.""" + + current_block, loans = await asyncio.gather( + subtensor.substrate.get_block_number(None), + subtensor.get_crowdloans(), + ) + if not loans: + if json_output: + json_console.print( + json.dumps( + { + "success": True, + "error": None, + "data": { + "crowdloans": [], + "total_count": 0, + "total_raised": 0, + "total_cap": 0, + "total_contributors": 0, + }, + } + ) + ) + else: + console.print("[yellow]No crowdloans found.[/yellow]") + return True + + total_raised = sum(loan.raised.tao for loan in loans.values()) + total_cap = sum(loan.cap.tao for loan in loans.values()) + total_loans = len(loans) + total_contributors = sum(loan.contributors_count for loan in loans.values()) + + funding_percentage = (total_raised / total_cap * 100) if total_cap > 0 else 0 + percentage_color = "dark_sea_green" if funding_percentage < 100 else "red" + formatted_percentage = ( + f"[{percentage_color}]{funding_percentage:.2f}%[/{percentage_color}]" + ) + + if json_output: + crowdloans_list = [] + for loan_id, loan in loans.items(): + status = _status(loan, current_block) + time_remaining = _time_remaining(loan, current_block) + + call_info = None + if loan.call_details: + pallet = loan.call_details.get("pallet", "") + method = loan.call_details.get("method", "") + if pallet == "SubtensorModule" and method == "register_leased_network": + call_info = "Subnet Leasing" + else: + call_info = ( + f"{pallet}.{method}" + if pallet and method + else method or pallet or "Unknown" + ) + elif loan.has_call: + call_info = "Unknown" + + crowdloan_data = { + "id": loan_id, + "status": status, + "raised": loan.raised.tao, + "cap": loan.cap.tao, + "deposit": loan.deposit.tao, + "min_contribution": loan.min_contribution.tao, + "end_block": loan.end, + "time_remaining": time_remaining, + "contributors_count": loan.contributors_count, + "creator": loan.creator, + "target_address": loan.target_address, + "funds_account": loan.funds_account, + "call": call_info, + "finalized": loan.finalized, + } + crowdloans_list.append(crowdloan_data) + + crowdloans_list.sort( + key=lambda x: ( + x["status"] != "Active", + -x["raised"], + ) + ) + + output_dict = { + "success": True, + "error": None, + "data": { + "crowdloans": crowdloans_list, + "total_count": total_loans, + "total_raised": total_raised, + "total_cap": total_cap, + "total_contributors": total_contributors, + "funding_percentage": funding_percentage, + "current_block": current_block, + "network": subtensor.network, + }, + } + json_console.print(json.dumps(output_dict)) + return True + + if not verbose: + funding_string = f"τ {millify_tao(total_raised)}/{millify_tao(total_cap)} ({formatted_percentage})" + else: + funding_string = ( + f"τ {total_raised:.1f}/{total_cap:.1f} ({formatted_percentage})" + ) + + table = Table( + title=f"\n[{COLORS.G.HEADER}]Crowdloans" + f"\nNetwork: [{COLORS.G.SUBHEAD}]{subtensor.network}\n\n", + show_footer=True, + show_edge=False, + header_style="bold white", + border_style="bright_black", + style="bold", + title_justify="center", + show_lines=False, + pad_edge=True, + ) + + table.add_column( + "[bold white]ID", style="grey89", justify="center", footer=str(total_loans) + ) + table.add_column("[bold white]Status", style="cyan", justify="center") + table.add_column( + f"[bold white]Raised / Cap\n({Balance.get_unit(0)})", + style="dark_sea_green2", + justify="left", + footer=funding_string, + ) + table.add_column( + f"[bold white]Deposit\n({Balance.get_unit(0)})", + style="steel_blue3", + justify="left", + ) + table.add_column( + f"[bold white]Min Contribution\n({Balance.get_unit(0)})", + style=COLORS.P.EMISSION, + justify="left", + ) + table.add_column("[bold white]Ends (Block)", style=COLORS.S.TAO, justify="left") + table.add_column( + "[bold white]Time Remaining", + style=COLORS.S.ALPHA, + justify="left", + ) + table.add_column( + "[bold white]Contributors", + style=COLORS.P.ALPHA_IN, + justify="center", + footer=str(total_contributors), + ) + table.add_column( + "[bold white]Creator", + style=COLORS.G.TEMPO, + justify="left", + overflow="fold", + ) + table.add_column( + "[bold white]Target", + style=COLORS.G.SUBHEAD_EX_1, + justify="center", + ) + table.add_column( + "[bold white]Funds Account", + style=COLORS.G.SUBHEAD_EX_2, + justify="left", + overflow="fold", + ) + table.add_column("[bold white]Call", style="grey89", justify="center") + + sorted_loans = sorted( + loans.items(), + key=lambda x: ( + _status(x[1], current_block) != "Active", # Active loans first + -x[1].raised.tao, # Then by raised amount (descending) + ), + ) + + for loan_id, loan in sorted_loans: + status = _status(loan, current_block) + time_label = _time_remaining(loan, current_block) + + raised_cell = ( + f"τ {loan.raised.tao:,.4f} / τ {loan.cap.tao:,.4f}" + if verbose + else f"τ {millify_tao(loan.raised.tao)} / τ {millify_tao(loan.cap.tao)}" + ) + + deposit_cell = ( + f"τ {loan.deposit.tao:,.4f}" + if verbose + else f"τ {millify_tao(loan.deposit.tao)}" + ) + + min_contrib_cell = ( + f"τ {loan.min_contribution.tao:,.4f}" + if verbose + else f"τ {millify_tao(loan.min_contribution.tao)}" + ) + + status_color_map = { + "Finalized": COLORS.G.SUCCESS, + "Funded": COLORS.P.EMISSION, + "Closed": COLORS.G.SYM, + "Active": COLORS.G.HINT, + } + status_color = status_color_map.get(status, "white") + status_cell = f"[{status_color}]{status}[/{status_color}]" + + if "Closed" in time_label: + time_cell = f"[{COLORS.G.SYM}]{time_label}[/{COLORS.G.SYM}]" + elif time_label == "due": + time_cell = f"[red]{time_label}[/red]" + else: + time_cell = time_label + + creator_cell = loan.creator if verbose else _shorten(loan.creator) + target_cell = ( + loan.target_address + if loan.target_address + else f"[{COLORS.G.SUBHEAD_MAIN}]Not specified[/{COLORS.G.SUBHEAD_MAIN}]" + ) + if not verbose and loan.target_address: + target_cell = _shorten(loan.target_address) + + funds_account_cell = ( + loan.funds_account if verbose else _shorten(loan.funds_account) + ) + + if loan.call_details: + pallet = loan.call_details.get("pallet", "") + method = loan.call_details.get("method", "") + + if pallet == "SubtensorModule" and method == "register_leased_network": + call_label = "[magenta]Subnet Leasing[/magenta]" + else: + call_label = ( + f"{pallet}.{method}" + if pallet and method + else method or pallet or "Unknown" + ) + + call_cell = call_label + elif loan.has_call: + call_cell = f"[{COLORS.G.SYM}]Unknown[/{COLORS.G.SYM}]" + else: + call_cell = "-" + + table.add_row( + str(loan_id), + status_cell, + raised_cell, + deposit_cell, + min_contrib_cell, + str(loan.end), + time_cell, + str(loan.contributors_count), + creator_cell, + target_cell, + funds_account_cell, + call_cell, + ) + + console.print(table) + + return True + + +async def show_crowdloan_details( + subtensor: SubtensorInterface, + crowdloan_id: int, + crowdloan: Optional[CrowdloanData] = None, + current_block: Optional[int] = None, + wallet: Optional[Wallet] = None, + verbose: bool = False, + json_output: bool = False, +) -> tuple[bool, str]: + """Display detailed information about a specific crowdloan.""" + + if not crowdloan or not current_block: + current_block, crowdloan = await asyncio.gather( + subtensor.substrate.get_block_number(None), + subtensor.get_single_crowdloan(crowdloan_id), + ) + if not crowdloan: + error_msg = f"Crowdloan #{crowdloan_id} not found." + if json_output: + json_console.print(json.dumps({"success": False, "error": error_msg})) + else: + print_error(f"[red]{error_msg}[/red]") + return False, error_msg + + user_contribution = None + if wallet and wallet.coldkeypub: + user_contribution = await subtensor.get_crowdloan_contribution( + crowdloan_id, wallet.coldkeypub.ss58_address + ) + + status = _status(crowdloan, current_block) + status_color_map = { + "Finalized": COLORS.G.SUCCESS, + "Funded": COLORS.P.EMISSION, + "Closed": COLORS.G.SYM, + "Active": COLORS.G.HINT, + } + status_color = status_color_map.get(status, "white") + + if json_output: + time_remaining = _time_remaining(crowdloan, current_block) + + avg_contribution = None + if crowdloan.contributors_count > 0: + net_contributions = crowdloan.raised.tao - crowdloan.deposit.tao + avg_contribution = ( + net_contributions / (crowdloan.contributors_count - 1) + if crowdloan.contributors_count > 1 + else crowdloan.deposit.tao + ) + + call_info = None + if crowdloan.has_call and crowdloan.call_details: + pallet = crowdloan.call_details.get("pallet", "Unknown") + method = crowdloan.call_details.get("method", "Unknown") + args = crowdloan.call_details.get("args", {}) + + if pallet == "SubtensorModule" and method == "register_leased_network": + call_info = { + "type": "Subnet Leasing", + "pallet": pallet, + "method": method, + "emissions_share": args.get("emissions_share", {}).get("value"), + "end_block": args.get("end_block", {}).get("value"), + } + else: + call_info = {"pallet": pallet, "method": method, "args": args} + + user_contribution_info = None + if user_contribution: + is_creator = ( + wallet + and wallet.coldkeypub + and wallet.coldkeypub.ss58_address == crowdloan.creator + ) + withdrawable_amount = None + + if status == "Active" and not crowdloan.finalized: + if is_creator and user_contribution.tao > crowdloan.deposit.tao: + withdrawable_amount = user_contribution.tao - crowdloan.deposit.tao + elif not is_creator: + withdrawable_amount = user_contribution.tao + + user_contribution_info = { + "amount": user_contribution.tao, + "is_creator": is_creator, + "withdrawable": withdrawable_amount, + "refundable": status == "Closed", + } + + output_dict = { + "success": True, + "error": None, + "data": { + "crowdloan_id": crowdloan_id, + "status": status, + "finalized": crowdloan.finalized, + "creator": crowdloan.creator, + "funds_account": crowdloan.funds_account, + "raised": crowdloan.raised.tao, + "cap": crowdloan.cap.tao, + "raised_percentage": (crowdloan.raised.tao / crowdloan.cap.tao * 100) + if crowdloan.cap.tao > 0 + else 0, + "deposit": crowdloan.deposit.tao, + "min_contribution": crowdloan.min_contribution.tao, + "end_block": crowdloan.end, + "current_block": current_block, + "time_remaining": time_remaining, + "contributors_count": crowdloan.contributors_count, + "average_contribution": avg_contribution, + "target_address": crowdloan.target_address, + "has_call": crowdloan.has_call, + "call_details": call_info, + "user_contribution": user_contribution_info, + "network": subtensor.network, + }, + } + json_console.print(json.dumps(output_dict)) + return True, f"Displayed info for crowdloan #{crowdloan_id}" + + table = Table( + Column( + "Field", + style=COLORS.G.SUBHEAD, + min_width=20, + no_wrap=True, + ), + Column("Value", style=COLORS.G.TEMPO), + title=f"\n[underline][{COLORS.G.HEADER}]CROWDLOAN #{crowdloan_id}[/underline][/{COLORS.G.HEADER}] - [{status_color} underline]{status.upper()}[/{status_color} underline]", + show_header=False, + show_footer=False, + width=None, + pad_edge=False, + box=box.SIMPLE, + show_edge=True, + border_style="bright_black", + expand=False, + ) + + # OVERVIEW Section + table.add_row("[cyan underline]OVERVIEW[/cyan underline]", "") + table.add_section() + + status_detail = "" + if status == "Active": + status_detail = " [dim](accepting contributions)[/dim]" + elif status == "Funded": + status_detail = " [yellow](awaiting finalization)[/yellow]" + elif status == "Closed": + status_detail = " [dim](failed to reach cap)[/dim]" + elif status == "Finalized": + status_detail = " [green](successfully completed)[/green]" + + table.add_row("Status", f"[{status_color}]{status}[/{status_color}]{status_detail}") + table.add_row( + "Creator", + f"[{COLORS.G.TEMPO}]{crowdloan.creator}[/{COLORS.G.TEMPO}]", + ) + table.add_row( + "Funds Account", + f"[{COLORS.G.SUBHEAD_EX_2}]{crowdloan.funds_account}[/{COLORS.G.SUBHEAD_EX_2}]", + ) + + # FUNDING PROGRESS Section + table.add_section() + table.add_row("[cyan underline]FUNDING PROGRESS[/cyan underline]", "") + table.add_section() + + raised_pct = ( + (crowdloan.raised.tao / crowdloan.cap.tao * 100) if crowdloan.cap.tao > 0 else 0 + ) + progress_filled = int(raised_pct / 100 * 16) + progress_empty = 16 - progress_filled + progress_bar = f"[dark_sea_green]{'█' * progress_filled}[/dark_sea_green][grey35]{'░' * progress_empty}[/grey35]" + + if verbose: + raised_str = f"τ {crowdloan.raised.tao:,.4f} / τ {crowdloan.cap.tao:,.4f}" + deposit_str = f"τ {crowdloan.deposit.tao:,.4f}" + min_contrib_str = f"τ {crowdloan.min_contribution.tao:,.4f}" + else: + raised_str = f"τ {millify_tao(crowdloan.raised.tao)} / τ {millify_tao(crowdloan.cap.tao)}" + deposit_str = f"τ {millify_tao(crowdloan.deposit.tao)}" + min_contrib_str = f"τ {millify_tao(crowdloan.min_contribution.tao)}" + + table.add_row("Raised/Cap", raised_str) + table.add_row( + "Progress", f"{progress_bar} [dark_sea_green]{raised_pct:.2f}%[/dark_sea_green]" + ) + table.add_row("Deposit", deposit_str) + table.add_row("Min Contribution", min_contrib_str) + + # TIMELINE Section + table.add_section() + table.add_row("[cyan underline]TIMELINE[/cyan underline]", "") + table.add_section() + + time_label = _time_remaining(crowdloan, current_block) + if "Closed" in time_label: + time_display = f"[{COLORS.G.SYM}]{time_label}[/{COLORS.G.SYM}]" + elif time_label == "due": + time_display = "[red]Due now[/red]" + else: + time_display = f"[{COLORS.S.ALPHA}]{time_label}[/{COLORS.S.ALPHA}]" + + table.add_row("Ends at Block", f"{crowdloan.end}") + table.add_row("Current Block", f"{current_block}") + table.add_row("Time Remaining", time_display) + + # PARTICIPATION Section + table.add_section() + table.add_row("[cyan underline]PARTICIPATION[/cyan underline]", "") + table.add_section() + + table.add_row("Contributors", f"{crowdloan.contributors_count}") + + if crowdloan.contributors_count > 0: + net_contributions = crowdloan.raised.tao - crowdloan.deposit.tao + avg_contribution = ( + net_contributions / (crowdloan.contributors_count - 1) + if crowdloan.contributors_count > 1 + else crowdloan.deposit.tao + ) + if verbose: + avg_contrib_str = f"τ {avg_contribution:,.4f}" + else: + avg_contrib_str = f"τ {millify_tao(avg_contribution)}" + table.add_row("Avg Contribution", avg_contrib_str) + + if user_contribution: + is_creator = wallet.coldkeypub.ss58_address == crowdloan.creator + if verbose: + user_contrib_str = f"τ {user_contribution.tao:,.4f}" + else: + user_contrib_str = f"τ {millify_tao(user_contribution.tao)}" + + contrib_status = "" + if status == "Active" and not crowdloan.finalized: + if is_creator and user_contribution.tao > crowdloan.deposit.tao: + withdrawable = user_contribution.tao - crowdloan.deposit.tao + if verbose: + withdrawable_str = f"{withdrawable:,.4f}" + else: + withdrawable_str = f"{millify_tao(withdrawable)}" + contrib_status = ( + f" [yellow](τ {withdrawable_str} withdrawable)[/yellow]" + ) + elif not is_creator: + contrib_status = " [yellow](withdrawable)[/yellow]" + elif status == "Closed": + contrib_status = " [green](refundable)[/green]" + + your_contrib_value = f"{user_contrib_str}{contrib_status}" + if is_creator: + your_contrib_value += " [dim](You are the creator)[/dim]" + table.add_row("Your Contribution", your_contrib_value) + + # TARGET Section + table.add_section() + table.add_row("[cyan underline]TARGET[/cyan underline]", "") + table.add_section() + + if crowdloan.target_address: + target_display = crowdloan.target_address + else: + target_display = ( + f"[{COLORS.G.SUBHEAD_MAIN}]Not specified[/{COLORS.G.SUBHEAD_MAIN}]" + ) + + table.add_row("Address", target_display) + + table.add_section() + table.add_row("[cyan underline]CALL DETAILS[/cyan underline]", "") + table.add_section() + + has_call_display = ( + f"[{COLORS.G.SUCCESS}]Yes[/{COLORS.G.SUCCESS}]" + if crowdloan.has_call + else f"[{COLORS.G.SYM}]No[/{COLORS.G.SYM}]" + ) + table.add_row("Has Call", has_call_display) + + if crowdloan.has_call and crowdloan.call_details: + pallet = crowdloan.call_details.get("pallet", "Unknown") + method = crowdloan.call_details.get("method", "Unknown") + args = crowdloan.call_details.get("args", {}) + + if pallet == "SubtensorModule" and method == "register_leased_network": + table.add_row("Type", "[magenta]Subnet Leasing[/magenta]") + emissions_share = args.get("emissions_share", {}).get("value") + if emissions_share is not None: + table.add_row("Emissions Share", f"[cyan]{emissions_share}%[/cyan]") + + end_block = args.get("end_block", {}).get("value") + if end_block: + table.add_row("Lease Ends", f"Block {end_block}") + else: + table.add_row("Lease Duration", "[green]Perpetual[/green]") + else: + table.add_row("Pallet", pallet) + table.add_row("Method", method) + if args: + for arg_name, arg_data in args.items(): + if isinstance(arg_data, dict): + display_value = arg_data.get("value") + arg_type = arg_data.get("type") + else: + display_value = arg_data + arg_type = None + + if arg_type: + table.add_row( + f"{arg_name} [{arg_type}]", + str(display_value), + ) + else: + table.add_row(arg_name, str(display_value)) + + console.print(table) + return True, f"Displayed info for crowdloan #{crowdloan_id}" diff --git a/bittensor_cli/src/commands/liquidity/liquidity.py b/bittensor_cli/src/commands/liquidity/liquidity.py index 60f5c6529..a262e8874 100644 --- a/bittensor_cli/src/commands/liquidity/liquidity.py +++ b/bittensor_cli/src/commands/liquidity/liquidity.py @@ -2,6 +2,7 @@ import json from typing import TYPE_CHECKING, Optional +from async_substrate_interface import AsyncExtrinsicReceipt from rich.prompt import Confirm from rich.table import Column, Table @@ -11,6 +12,7 @@ console, err_console, json_console, + print_extrinsic_id, ) from bittensor_cli.src.bittensor.balances import Balance, fixed_to_float from bittensor_cli.src.commands.liquidity.utils import ( @@ -36,7 +38,7 @@ async def add_liquidity_extrinsic( price_high: Balance, wait_for_inclusion: bool = True, wait_for_finalization: bool = False, -) -> tuple[bool, str]: +) -> tuple[bool, str, Optional[AsyncExtrinsicReceipt]]: """ Adds liquidity to the specified price range. @@ -60,7 +62,7 @@ async def add_liquidity_extrinsic( `toggle_user_liquidity_extrinsic` to enable/disable user liquidity. """ if not (unlock := unlock_key(wallet)).success: - return False, unlock.message + return False, unlock.message, None tick_low = price_to_tick(price_low.tao) tick_high = price_to_tick(price_high.tao) @@ -94,7 +96,7 @@ async def modify_liquidity_extrinsic( liquidity_delta: Balance, wait_for_inclusion: bool = True, wait_for_finalization: bool = False, -) -> tuple[bool, str]: +) -> tuple[bool, str, Optional[AsyncExtrinsicReceipt]]: """Modifies liquidity in liquidity position by adding or removing liquidity from it. Arguments: @@ -116,7 +118,7 @@ async def modify_liquidity_extrinsic( Call `toggle_user_liquidity_extrinsic` to enable/disable user liquidity. """ if not (unlock := unlock_key(wallet)).success: - return False, unlock.message + return False, unlock.message, None call = await subtensor.substrate.compose_call( call_module="Swap", @@ -145,7 +147,7 @@ async def remove_liquidity_extrinsic( position_id: int, wait_for_inclusion: bool = True, wait_for_finalization: bool = False, -) -> tuple[bool, str]: +) -> tuple[bool, str, Optional[AsyncExtrinsicReceipt]]: """Remove liquidity and credit balances back to wallet's hotkey stake. Arguments: @@ -166,7 +168,7 @@ async def remove_liquidity_extrinsic( Call `toggle_user_liquidity_extrinsic` to enable/disable user liquidity. """ if not (unlock := unlock_key(wallet)).success: - return False, unlock.message + return False, unlock.message, None call = await subtensor.substrate.compose_call( call_module="Swap", @@ -193,7 +195,7 @@ async def toggle_user_liquidity_extrinsic( enable: bool, wait_for_inclusion: bool = True, wait_for_finalization: bool = False, -) -> tuple[bool, str]: +) -> tuple[bool, str, Optional[AsyncExtrinsicReceipt]]: """Allow to toggle user liquidity for specified subnet. Arguments: @@ -210,7 +212,7 @@ async def toggle_user_liquidity_extrinsic( - False and an error message if the submission fails or the wallet cannot be unlocked. """ if not (unlock := unlock_key(wallet)).success: - return False, unlock.message + return False, unlock.message, None call = await subtensor.substrate.compose_call( call_module="Swap", @@ -232,16 +234,16 @@ async def add_liquidity( wallet: "Wallet", hotkey_ss58: str, netuid: Optional[int], - liquidity: Optional[float], - price_low: Optional[float], - price_high: Optional[float], + liquidity: Balance, + price_low: Balance, + price_high: Balance, prompt: bool, json_output: bool, ) -> tuple[bool, str]: """Add liquidity position to provided subnet.""" # Check wallet access - if not unlock_key(wallet).success: - return False + if not (ulw := unlock_key(wallet)).success: + return False, ulw.message # Check that the subnet exists. if not await subtensor.subnet_exists(netuid=netuid): @@ -260,7 +262,7 @@ async def add_liquidity( if not Confirm.ask("Would you like to continue?"): return False, "User cancelled operation." - success, message = await add_liquidity_extrinsic( + success, message, ext_receipt = await add_liquidity_extrinsic( subtensor=subtensor, wallet=wallet, hotkey_ss58=hotkey_ss58, @@ -269,8 +271,14 @@ async def add_liquidity( price_low=price_low, price_high=price_high, ) + await print_extrinsic_id(ext_receipt) + ext_id = await ext_receipt.get_extrinsic_identifier() if json_output: - json_console.print(json.dumps({"success": success, "message": message})) + json_console.print( + json.dumps( + {"success": success, "message": message, "extrinsic_identifier": ext_id} + ) + ) else: if success: console.print( @@ -278,6 +286,7 @@ async def add_liquidity( ) else: err_console.print(f"[red]Error: {message}[/red]") + return success, message async def get_liquidity_list( @@ -333,6 +342,8 @@ async def get_liquidity_list( block_hash=block_hash, ), ) + if len(positions_response.records) == 0: + return False, "No liquidity positions found.", [] current_sqrt_price = fixed_to_float(current_sqrt_price) fee_global_tao = fixed_to_float(fee_global_tao) @@ -449,19 +460,31 @@ async def show_liquidity_list( wallet: "Wallet", netuid: int, json_output: bool = False, -): - current_price_, (success, err_msg, positions) = await asyncio.gather( - subtensor.subnet(netuid=netuid), get_liquidity_list(subtensor, wallet, netuid) +) -> None: + current_price_, liquidity_list_ = await asyncio.gather( + subtensor.subnet(netuid=netuid), + get_liquidity_list(subtensor, wallet, netuid), + return_exceptions=True, ) + if isinstance(current_price_, Exception): + success = False + err_msg = str(current_price_) + positions = [] + elif isinstance(liquidity_list_, Exception): + success = False + err_msg = str(liquidity_list_) + positions = [] + else: + (success, err_msg, positions) = liquidity_list_ if not success: if json_output: json_console.print( json.dumps({"success": success, "err_msg": err_msg, "positions": []}) ) - return False + return else: err_console.print(f"Error: {err_msg}") - return False + return liquidity_table = Table( Column("ID", justify="center"), Column("Liquidity", justify="center"), @@ -526,20 +549,21 @@ async def remove_liquidity( prompt: Optional[bool] = None, all_liquidity_ids: Optional[bool] = None, json_output: bool = False, -) -> tuple[bool, str]: +) -> None: """Remove liquidity position from provided subnet.""" if not await subtensor.subnet_exists(netuid=netuid): - return False, f"Subnet with netuid: {netuid} does not exist in {subtensor}." + return None if all_liquidity_ids: success, msg, positions = await get_liquidity_list(subtensor, wallet, netuid) if not success: if json_output: - return json_console.print( - {"success": False, "err_msg": msg, "positions": positions} + json_console.print_json( + data={"success": False, "err_msg": msg, "positions": positions} ) else: return err_console.print(f"Error: {msg}") + return None else: position_ids = [p.id for p in positions] else: @@ -553,7 +577,7 @@ async def remove_liquidity( console.print(f"\tPosition id: {pos}") if not Confirm.ask("Would you like to continue?"): - return False, "User cancelled operation." + return None results = await asyncio.gather( *[ @@ -568,16 +592,22 @@ async def remove_liquidity( ] ) if not json_output: - for (success, msg), posid in zip(results, position_ids): + for (success, msg, ext_receipt), posid in zip(results, position_ids): if success: + await print_extrinsic_id(ext_receipt) console.print(f"[green] Position {posid} has been removed.") else: err_console.print(f"[red] Error removing {posid}: {msg}") else: json_table = {} - for (success, msg), posid in zip(results, position_ids): - json_table[posid] = {"success": success, "err_msg": msg} - json_console.print(json.dumps(json_table)) + for (success, msg, ext_receipt), posid in zip(results, position_ids): + json_table[posid] = { + "success": success, + "err_msg": msg, + "extrinsic_identifier": await ext_receipt.get_extrinsic_identifier(), + } + json_console.print_json(data=json_table) + return None async def modify_liquidity( @@ -586,7 +616,7 @@ async def modify_liquidity( hotkey_ss58: str, netuid: int, position_id: int, - liquidity_delta: Optional[float], + liquidity_delta: Balance, prompt: Optional[bool] = None, json_output: bool = False, ) -> bool: @@ -611,7 +641,7 @@ async def modify_liquidity( if not Confirm.ask("Would you like to continue?"): return False - success, msg = await modify_liquidity_extrinsic( + success, msg, ext_receipt = await modify_liquidity_extrinsic( subtensor=subtensor, wallet=wallet, hotkey_ss58=hotkey_ss58, @@ -620,9 +650,14 @@ async def modify_liquidity( liquidity_delta=liquidity_delta, ) if json_output: - json_console.print(json.dumps({"success": success, "err_msg": msg})) + ext_id = await ext_receipt.get_extrinsic_identifier() if success else None + json_console.print_json( + data={"success": success, "err_msg": msg, "extrinsic_identifier": ext_id} + ) else: if success: + await print_extrinsic_id(ext_receipt) console.print(f"[green] Position {position_id} has been modified.") else: err_console.print(f"[red] Error modifying {position_id}: {msg}") + return success diff --git a/bittensor_cli/src/commands/liquidity/utils.py b/bittensor_cli/src/commands/liquidity/utils.py index 76f7ea8a7..f364a64e4 100644 --- a/bittensor_cli/src/commands/liquidity/utils.py +++ b/bittensor_cli/src/commands/liquidity/utils.py @@ -198,3 +198,5 @@ def prompt_position_id() -> int: return position_id except ValueError: console.print("[red]Please enter a valid number[/red].") + # will never return this, but fixes the type checker + return 0 diff --git a/bittensor_cli/src/commands/stake/add.py b/bittensor_cli/src/commands/stake/add.py index b223eaf2e..5046981da 100644 --- a/bittensor_cli/src/commands/stake/add.py +++ b/bittensor_cli/src/commands/stake/add.py @@ -1,9 +1,10 @@ import asyncio -import json from collections import defaultdict from functools import partial from typing import TYPE_CHECKING, Optional + +from async_substrate_interface import AsyncExtrinsicReceipt from rich.table import Table from rich.prompt import Confirm, Prompt @@ -20,6 +21,8 @@ print_verbose, unlock_key, json_console, + get_hotkey_pub_ss58, + print_extrinsic_id, ) from bittensor_wallet import Wallet @@ -111,7 +114,7 @@ async def safe_stake_extrinsic( hotkey_ss58_: str, price_limit: Balance, status=None, - ) -> tuple[bool, str]: + ) -> tuple[bool, str, Optional[AsyncExtrinsicReceipt]]: err_out = partial(print_error, status=status) failure_prelude = ( f":cross_mark: [red]Failed[/red] to stake {amount_} on Netuid {netuid_}" @@ -152,15 +155,16 @@ async def safe_stake_extrinsic( else: err_msg = f"{failure_prelude} with error: {format_error_message(e)}" err_out("\n" + err_msg) - return False, err_msg + return False, err_msg, None if not await response.is_success: err_msg = f"{failure_prelude} with error: {format_error_message(await response.error_message)}" err_out("\n" + err_msg) - return False, err_msg + return False, err_msg, None else: if json_output: # the rest of this checking is not necessary if using json_output - return True, "" + return True, "", response + await print_extrinsic_id(response) block_hash = await subtensor.substrate.get_chain_head() new_balance, new_stake = await asyncio.gather( subtensor.get_balance(wallet.coldkeypub.ss58_address, block_hash), @@ -198,11 +202,11 @@ async def safe_stake_extrinsic( f":arrow_right: " f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_stake}\n" ) - return True, "" + return True, "", response async def stake_extrinsic( netuid_i, amount_, current, staking_address_ss58, status=None - ) -> tuple[bool, str]: + ) -> tuple[bool, str, Optional[AsyncExtrinsicReceipt]]: err_out = partial(print_error, status=status) current_balance, next_nonce, call = await asyncio.gather( subtensor.get_balance(wallet.coldkeypub.ss58_address), @@ -230,16 +234,17 @@ async def stake_extrinsic( except SubstrateRequestException as e: err_msg = f"{failure_prelude} with error: {format_error_message(e)}" err_out("\n" + err_msg) - return False, err_msg + return False, err_msg, None else: if not await response.is_success: err_msg = f"{failure_prelude} with error: {format_error_message(await response.error_message)}" err_out("\n" + err_msg) - return False, err_msg + return False, err_msg, None else: if json_output: # the rest of this is not necessary if using json_output - return True, "" + return True, "", response + await print_extrinsic_id(response) new_block_hash = await subtensor.substrate.get_chain_head() new_balance, new_stake = await asyncio.gather( subtensor.get_balance( @@ -268,7 +273,7 @@ async def stake_extrinsic( f":arrow_right: " f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_stake}\n" ) - return True, "" + return True, "", response netuids = ( netuids if netuids is not None else await subtensor.get_all_subnet_netuids() @@ -343,20 +348,9 @@ async def stake_extrinsic( f"[red]Not enough stake[/red]:[bold white]\n wallet balance:{remaining_wallet_balance} < " f"staking amount: {amount_to_stake}[/bold white]" ) - return False + return remaining_wallet_balance -= amount_to_stake - # TODO this should be asyncio gathered before the for loop - stake_fee = await subtensor.get_stake_fee( - origin_hotkey_ss58=None, - origin_netuid=None, - origin_coldkey_ss58=wallet.coldkeypub.ss58_address, - destination_hotkey_ss58=hotkey[1], - destination_netuid=netuid, - destination_coldkey_ss58=wallet.coldkeypub.ss58_address, - amount=amount_to_stake.rao, - ) - # Calculate slippage # TODO: Update for V3, slippage calculation is significantly different in v3 # try: @@ -408,7 +402,13 @@ async def stake_extrinsic( safe_staking_=safe_staking, ) row_extension = [] - received_amount = rate * (amount_to_stake - stake_fee - extrinsic_fee) + # TODO this should be asyncio gathered before the for loop + sim_swap = await subtensor.sim_swap( + origin_netuid=0, + destination_netuid=netuid, + amount=(amount_to_stake - extrinsic_fee).rao, + ) + received_amount = sim_swap.alpha_amount # Add rows for the table base_row = [ str(netuid), # netuid @@ -417,7 +417,7 @@ async def stake_extrinsic( str(rate) + f" {Balance.get_unit(netuid)}/{Balance.get_unit(0)} ", # rate str(received_amount.set_unit(netuid)), # received - str(stake_fee), # fee + str(sim_swap.tao_fee), # fee str(extrinsic_fee), # str(slippage_pct), # slippage ] + row_extension @@ -431,9 +431,9 @@ async def stake_extrinsic( if prompt: if not Confirm.ask("Would you like to continue?"): - return False + return if not unlock_key(wallet).success: - return False + return if safe_staking: stake_coroutines = {} @@ -474,15 +474,24 @@ async def stake_extrinsic( } successes = defaultdict(dict) error_messages = defaultdict(dict) + extrinsic_ids = 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 (ni, staking_address), coroutine in stake_coroutines.items(): - success, er_msg = await coroutine + success, er_msg, ext_receipt = await coroutine successes[ni][staking_address] = success error_messages[ni][staking_address] = er_msg + if success: + extrinsic_ids[ni][ + staking_address + ] = await ext_receipt.get_extrinsic_identifier() if json_output: - json_console.print( - json.dumps({"staking_success": successes, "error_messages": error_messages}) + json_console.print_json( + data={ + "staking_success": successes, + "error_messages": error_messages, + "extrinsic_ids": extrinsic_ids, + } ) @@ -528,6 +537,8 @@ def _prompt_stake_amount( return Balance.from_tao(amount), False except ValueError: console.print("[red]Please enter a valid number or 'all'[/red]") + # will never return this, but fixes the type checker + return Balance(0), False def _get_hotkeys_to_stake_to( @@ -552,7 +563,7 @@ def _get_hotkeys_to_stake_to( # Stake to all hotkeys except excluded ones all_hotkeys_: list[Wallet] = get_hotkey_wallets_for_wallet(wallet=wallet) return [ - (wallet.hotkey_str, wallet.hotkey.ss58_address) + (wallet.hotkey_str, get_hotkey_pub_ss58(wallet)) for wallet in all_hotkeys_ if wallet.hotkey_str not in (exclude_hotkeys or []) ] @@ -572,7 +583,7 @@ def _get_hotkeys_to_stake_to( name=wallet.name, hotkey=hotkey_ss58_or_hotkey_name, ) - hotkeys.append((wallet_.hotkey_str, wallet_.hotkey.ss58_address)) + hotkeys.append((wallet_.hotkey_str, get_hotkey_pub_ss58(wallet_))) return hotkeys @@ -581,7 +592,7 @@ def _get_hotkeys_to_stake_to( f"Staking to hotkey: ({wallet.hotkey_str}) in wallet: ({wallet.name})" ) assert wallet.hotkey is not None - return [(None, wallet.hotkey.ss58_address)] + return [(None, get_hotkey_pub_ss58(wallet))] def _define_stake_table( diff --git a/bittensor_cli/src/commands/stake/auto_staking.py b/bittensor_cli/src/commands/stake/auto_staking.py new file mode 100644 index 000000000..6e8bf3632 --- /dev/null +++ b/bittensor_cli/src/commands/stake/auto_staking.py @@ -0,0 +1,297 @@ +import asyncio +import json +from typing import Optional, TYPE_CHECKING + +from bittensor_wallet import Wallet +from rich import box +from rich.table import Table +from rich.prompt import Confirm + +from bittensor_cli.src import COLOR_PALETTE +from bittensor_cli.src.bittensor.utils import ( + console, + json_console, + get_subnet_name, + is_valid_ss58_address, + print_error, + err_console, + unlock_key, + print_extrinsic_id, +) + +if TYPE_CHECKING: + from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface + + +async def show_auto_stake_destinations( + wallet: Optional[Wallet], + subtensor: "SubtensorInterface", + coldkey_ss58: Optional[str] = None, + json_output: bool = False, +) -> Optional[dict[int, dict[str, Optional[str]]]]: + """Display auto-stake destinations for the supplied wallet.""" + + wallet_name: Optional[str] = wallet.name if wallet else None + coldkey_ss58 = coldkey_ss58 or (wallet.coldkeypub.ss58_address if wallet else None) + if not coldkey_ss58: + raise ValueError("A wallet or coldkey SS58 address must be provided") + + with console.status( + f"Retrieving auto-stake configuration from {subtensor.network}...", + spinner="earth", + ): + chain_head = await subtensor.substrate.get_chain_head() + ( + subnet_info, + auto_destinations, + identities, + delegate_identities, + ) = await asyncio.gather( + subtensor.all_subnets(block_hash=chain_head), + subtensor.get_auto_stake_destinations( + coldkey_ss58=coldkey_ss58, + block_hash=chain_head, + reuse_block=True, + ), + subtensor.fetch_coldkey_hotkey_identities(block_hash=chain_head), + subtensor.get_delegate_identities(block_hash=chain_head), + ) + + subnet_map = {info.netuid: info for info in subnet_info} + auto_destinations = auto_destinations or {} + identities = identities or {} + delegate_identities = delegate_identities or {} + hotkey_identities = identities.get("hotkeys", {}) + + def resolve_identity(hotkey: str) -> Optional[str]: + if not hotkey: + return None + + identity_entry = hotkey_identities.get(hotkey, {}).get("identity") + if identity_entry: + display_name = identity_entry.get("name") or identity_entry.get("display") + if display_name: + return display_name + + delegate_info = delegate_identities.get(hotkey) + if delegate_info and getattr(delegate_info, "display", ""): + return delegate_info.display + + return None + + coldkey_display = wallet_name + if not coldkey_display: + coldkey_identity = identities.get("coldkeys", {}).get(coldkey_ss58, {}) + if identity_data := coldkey_identity.get("identity"): + coldkey_display = identity_data.get("name") or identity_data.get("display") + if not coldkey_display: + coldkey_display = f"{coldkey_ss58[:6]}...{coldkey_ss58[-6:]}" + + rows = [] + data_output: dict[int, dict[str, Optional[str]]] = {} + + for netuid in sorted(subnet_map): + subnet = subnet_map[netuid] + subnet_name = get_subnet_name(subnet) + hotkey_ss58 = auto_destinations.get(netuid) + identity_str = resolve_identity(hotkey_ss58) if hotkey_ss58 else None + is_custom = hotkey_ss58 is not None + + data_output[netuid] = { + "subnet_name": subnet_name, + "status": "custom" if is_custom else "default", + "destination": hotkey_ss58, + "identity": identity_str, + } + + if json_output: + continue + + status_text = ( + f"[{COLOR_PALETTE['STAKE']['STAKE_ALPHA']}]Custom[/{COLOR_PALETTE['STAKE']['STAKE_ALPHA']}]" + if is_custom + else f"[{COLOR_PALETTE['GENERAL']['HINT']}]Default[/{COLOR_PALETTE['GENERAL']['HINT']}]" + ) + + rows.append( + ( + str(netuid), + subnet_name, + status_text, + hotkey_ss58, + identity_str or "", + ) + ) + + if json_output: + json_console.print(json.dumps(data_output)) + return data_output + + table = Table( + title=( + f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Auto Stake Destinations" + f" for [bold]{coldkey_display}[/bold]\n" + f"Network: {subtensor.network}\n" + f"Coldkey: {coldkey_ss58}\n" + f"[/{COLOR_PALETTE['GENERAL']['HEADER']}]" + ), + show_edge=False, + header_style="bold white", + border_style="bright_black", + style="bold", + title_justify="center", + show_lines=False, + pad_edge=True, + box=box.SIMPLE_HEAD, + ) + + table.add_column( + "Netuid", style=COLOR_PALETTE["GENERAL"]["SYMBOL"], justify="center" + ) + table.add_column("Subnet", style="cyan", justify="left") + table.add_column("Status", style="white", justify="center") + table.add_column( + "Destination Hotkey", style=COLOR_PALETTE["GENERAL"]["HOTKEY"], justify="center" + ) + table.add_column( + "Identity", style=COLOR_PALETTE["GENERAL"]["SUBHEADING"], justify="left" + ) + + for row in rows: + table.add_row(*row) + + console.print(table) + console.print( + f"\n[{COLOR_PALETTE['GENERAL']['SUBHEADING']}]Total subnets:[/] {len(subnet_map)} " + f"[{COLOR_PALETTE['GENERAL']['SUBHEADING']}]Custom destinations:[/] {len(auto_destinations)}" + ) + + return None + + +async def set_auto_stake_destination( + wallet: Wallet, + subtensor: "SubtensorInterface", + netuid: int, + hotkey_ss58: str, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, + prompt_user: bool = True, + json_output: bool = False, +) -> bool: + """Set the auto-stake destination hotkey for a coldkey on a subnet.""" + + if not is_valid_ss58_address(hotkey_ss58): + print_error("You entered an invalid hotkey ss58 address") + return False + + try: + chain_head = await subtensor.substrate.get_chain_head() + subnet_info, identities, delegate_identities = await asyncio.gather( + subtensor.subnet(netuid, block_hash=chain_head), + subtensor.fetch_coldkey_hotkey_identities(block_hash=chain_head), + subtensor.get_delegate_identities(block_hash=chain_head), + ) + except ValueError: + print_error(f"Subnet with netuid {netuid} does not exist") + return False + + hotkey_identity = "" + identities = identities or {} + delegate_identities = delegate_identities or {} + + hotkey_identity_entry = identities.get("hotkeys", {}).get(hotkey_ss58, {}) + if identity_data := hotkey_identity_entry.get("identity"): + hotkey_identity = ( + identity_data.get("name") or identity_data.get("display") or "" + ) + if not hotkey_identity: + delegate_info = delegate_identities.get(hotkey_ss58) + if delegate_info and getattr(delegate_info, "display", ""): + hotkey_identity = delegate_info.display + + if prompt_user and not json_output: + table = Table( + title=( + f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Confirm Auto-Stake Destination" + f"[/{COLOR_PALETTE['GENERAL']['HEADER']}]" + ), + show_edge=False, + header_style="bold white", + border_style="bright_black", + style="bold", + title_justify="center", + show_lines=False, + pad_edge=True, + box=box.SIMPLE_HEAD, + ) + table.add_column( + "Netuid", justify="center", style=COLOR_PALETTE["GENERAL"]["SYMBOL"] + ) + table.add_column("Subnet", style="cyan", justify="left") + table.add_column( + "Destination Hotkey", + style=COLOR_PALETTE["GENERAL"]["HOTKEY"], + justify="center", + ) + table.add_column( + "Identity", style=COLOR_PALETTE["GENERAL"]["SUBHEADING"], justify="left" + ) + table.add_row( + str(netuid), + get_subnet_name(subnet_info), + hotkey_ss58, + hotkey_identity or "", + ) + console.print(table) + + if not Confirm.ask("\nSet this auto-stake destination?", default=True): + return False + + if not unlock_key(wallet).success: + return False + + call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="set_coldkey_auto_stake_hotkey", + call_params={ + "netuid": netuid, + "hotkey": hotkey_ss58, + }, + ) + + with console.status( + f":satellite: Setting auto-stake destination on [white]{subtensor.network}[/white]...", + spinner="earth", + ): + success, error_message, ext_receipt = await subtensor.sign_and_send_extrinsic( + call, + wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + ext_id = await ext_receipt.get_extrinsic_identifier() if success else None + + if json_output: + json_console.print( + json.dumps( + { + "success": success, + "error": error_message, + "netuid": netuid, + "hotkey": hotkey_ss58, + "extrinsic_identifier": ext_id, + } + ) + ) + + if success: + await print_extrinsic_id(ext_receipt) + console.print( + f":white_heavy_check_mark: [dark_sea_green3]Auto-stake destination set for netuid {netuid}[/dark_sea_green3]" + ) + return True + + err_console.print(f":cross_mark: [red]Failed[/red]: {error_message}") + return False diff --git a/bittensor_cli/src/commands/stake/children_hotkeys.py b/bittensor_cli/src/commands/stake/children_hotkeys.py index ef16823e4..d50ecc65a 100644 --- a/bittensor_cli/src/commands/stake/children_hotkeys.py +++ b/bittensor_cli/src/commands/stake/children_hotkeys.py @@ -21,6 +21,8 @@ format_error_message, unlock_key, json_console, + get_hotkey_pub_ss58, + print_extrinsic_id, ) @@ -58,7 +60,7 @@ async def set_children_extrinsic( wait_for_inclusion: bool = True, wait_for_finalization: bool = False, prompt: bool = False, -) -> tuple[bool, str]: +) -> tuple[bool, str, Optional[str]]: """ Sets children hotkeys with proportions assigned from the parent. @@ -73,7 +75,7 @@ async def set_children_extrinsic( `True`, or returns `False` if the extrinsic fails to be finalized within the timeout. :param: prompt: If `True`, the call waits for confirmation from the user before proceeding. - :return: A tuple containing a success flag and an optional error message. + :return: A tuple containing a success flag, an optional error message, and the extrinsic identifier """ # Check if all children are being revoked all_revoked = len(children_with_proportions) == 0 @@ -86,7 +88,7 @@ async def set_children_extrinsic( if not Confirm.ask( f"Do you want to revoke all children hotkeys for hotkey {hotkey} on netuid {netuid}?" ): - return False, "Operation Cancelled" + return False, "Operation Cancelled", None else: if not Confirm.ask( "Do you want to set children hotkeys:\n[bold white]{}[/bold white]?".format( @@ -96,11 +98,11 @@ async def set_children_extrinsic( ) ) ): - return False, "Operation Cancelled" + return False, "Operation Cancelled", None # Decrypt coldkey. if not (unlock_status := unlock_key(wallet, print_out=False)).success: - return False, unlock_status.message + return False, unlock_status.message, "" with console.status( f":satellite: {operation} on [white]{subtensor.network}[/white] ..." @@ -119,7 +121,7 @@ async def set_children_extrinsic( "netuid": netuid, }, ) - success, error_message = await subtensor.sign_and_send_extrinsic( + success, error_message, ext_receipt = await subtensor.sign_and_send_extrinsic( call, wallet, wait_for_inclusion, wait_for_finalization ) @@ -127,17 +129,20 @@ async def set_children_extrinsic( return ( True, f"Not waiting for finalization or inclusion. {operation} initiated.", + None, ) if success: - if wait_for_inclusion: - console.print(":white_heavy_check_mark: [green]Included[/green]") + ext_id = await ext_receipt.get_extrinsic_identifier() + await print_extrinsic_id(ext_receipt) + modifier = "included" if wait_for_finalization: console.print(":white_heavy_check_mark: [green]Finalized[/green]") - return True, f"Successfully {operation.lower()} and Finalized." + modifier = "finalized" + return True, f"{operation} successfully {modifier}.", ext_id else: err_console.print(f":cross_mark: [red]Failed[/red]: {error_message}") - return False, error_message + return False, error_message, None async def set_childkey_take_extrinsic( @@ -149,7 +154,7 @@ async def set_childkey_take_extrinsic( wait_for_inclusion: bool = True, wait_for_finalization: bool = False, prompt: bool = True, -) -> tuple[bool, str]: +) -> tuple[bool, str, Optional[str]]: """ Sets childkey take. @@ -164,7 +169,7 @@ async def set_childkey_take_extrinsic( `True`, or returns `False` if the extrinsic fails to be finalized within the timeout. :param: prompt: If `True`, the call waits for confirmation from the user before proceeding. - :return: A tuple containing a success flag and an optional error message. + :return: A tuple containing a success flag, an optional error message, and an optional extrinsic identifier """ # Ask before moving on. @@ -172,11 +177,11 @@ async def set_childkey_take_extrinsic( if not Confirm.ask( f"Do you want to set childkey take to: [bold white]{take * 100}%[/bold white]?" ): - return False, "Operation Cancelled" + return False, "Operation Cancelled", None # Decrypt coldkey. if not (unlock_status := unlock_key(wallet, print_out=False)).success: - return False, unlock_status.message + return False, unlock_status.message, None with console.status( f":satellite: Setting childkey take on [white]{subtensor.network}[/white] ..." @@ -185,7 +190,7 @@ async def set_childkey_take_extrinsic( if 0 <= take <= 0.18: take_u16 = float_to_u16(take) else: - return False, "Invalid take value" + return False, "Invalid take value", None call = await subtensor.substrate.compose_call( call_module="SubtensorModule", @@ -196,7 +201,11 @@ async def set_childkey_take_extrinsic( "netuid": netuid, }, ) - success, error_message = await subtensor.sign_and_send_extrinsic( + ( + success, + error_message, + ext_receipt, + ) = await subtensor.sign_and_send_extrinsic( call, wallet, wait_for_inclusion, wait_for_finalization ) @@ -204,30 +213,34 @@ async def set_childkey_take_extrinsic( return ( True, "Not waiting for finalization or inclusion. Set childkey take initiated.", + None, ) if success: - if wait_for_inclusion: - console.print(":white_heavy_check_mark: [green]Included[/green]") + ext_id = await ext_receipt.get_extrinsic_identifier() + await print_extrinsic_id(ext_receipt) + modifier = "included" if wait_for_finalization: + modifier = "finalized" console.print(":white_heavy_check_mark: [green]Finalized[/green]") # bittensor.logging.success( # prefix="Setting childkey take", # suffix="Finalized: " + str(success), # ) - return True, "Successfully set childkey take and Finalized." + return True, f"Successfully {modifier} childkey take", ext_id else: console.print(f":cross_mark: [red]Failed[/red]: {error_message}") # bittensor.logging.warning( # prefix="Setting childkey take", # suffix="Failed: " + str(error_message), # ) - return False, error_message + return False, error_message, None except SubstrateRequestException as e: return ( False, f"Exception occurred while setting childkey take: {format_error_message(e)}", + None, ) @@ -464,7 +477,7 @@ async def _render_table( netuid_children_tuples = [] for netuid_ in netuids: success, children, err_mg = await subtensor.get_children( - wallet.hotkey.ss58_address, netuid_ + get_hotkey_pub_ss58(wallet), netuid_ ) if children: netuid_children_tuples.append((netuid_, children)) @@ -472,16 +485,16 @@ async def _render_table( err_console.print( f"Failed to get children from subtensor {netuid_}: {err_mg}" ) - await _render_table(wallet.hotkey.ss58_address, netuid_children_tuples) + await _render_table(get_hotkey_pub_ss58(wallet), netuid_children_tuples) else: success, children, err_mg = await subtensor.get_children( - wallet.hotkey.ss58_address, netuid + get_hotkey_pub_ss58(wallet), netuid ) if not success: err_console.print(f"Failed to get children from subtensor: {err_mg}") if children: netuid_children_tuples = [(netuid, children)] - await _render_table(wallet.hotkey.ss58_address, netuid_children_tuples) + await _render_table(get_hotkey_pub_ss58(wallet), netuid_children_tuples) return children @@ -500,12 +513,12 @@ async def set_children( """Set children hotkeys.""" # Validate children SS58 addresses # TODO check to see if this should be allowed to be specified by user instead of pulling from wallet - hotkey = wallet.hotkey.ss58_address + hotkey = get_hotkey_pub_ss58(wallet) for child in children: if not is_valid_ss58_address(child): err_console.print(f":cross_mark:[red] Invalid SS58 address: {child}[/red]") return - if child == wallet.hotkey.ss58_address: + if child == hotkey: err_console.print(":cross_mark:[red] Cannot set yourself as a child.[/red]") return @@ -518,7 +531,7 @@ async def set_children( children_with_proportions = list(zip(proportions, children)) successes = {} if netuid is not None: - success, message = await set_children_extrinsic( + success, message, ext_id = await set_children_extrinsic( subtensor=subtensor, wallet=wallet, netuid=netuid, @@ -533,6 +546,7 @@ async def set_children( "error": message, "completion_block": None, "set_block": None, + "extrinsic_identifier": ext_id, } # Result if success: @@ -560,7 +574,7 @@ async def set_children( if netuid_ == 0: # dont include root network continue console.print(f"Setting children on netuid {netuid_}.") - success, message = await set_children_extrinsic( + success, message, ext_id = await set_children_extrinsic( subtensor=subtensor, wallet=wallet, netuid=netuid_, @@ -578,6 +592,7 @@ async def set_children( "error": message, "completion_block": completion_block, "set_block": current_block, + "extrinsic_identifier": ext_id, } console.print( f"Your childkey request for netuid {netuid_} has been submitted. It will be completed around " @@ -604,11 +619,11 @@ async def revoke_children( """ dict_output = {} if netuid is not None: - success, message = await set_children_extrinsic( + success, message, ext_id = await set_children_extrinsic( subtensor=subtensor, wallet=wallet, netuid=netuid, - hotkey=wallet.hotkey.ss58_address, + hotkey=get_hotkey_pub_ss58(wallet), children_with_proportions=[], prompt=prompt, wait_for_inclusion=wait_for_inclusion, @@ -619,6 +634,7 @@ async def revoke_children( "error": message, "set_block": None, "completion_block": None, + "extrinsic_identifier": ext_id, } # Result @@ -643,11 +659,11 @@ async def revoke_children( if netuid_ == 0: # dont include root network continue console.print(f"Revoking children from netuid {netuid_}.") - success, message = await set_children_extrinsic( + success, message, ext_id = await set_children_extrinsic( subtensor=subtensor, wallet=wallet, - netuid=netuid, - hotkey=wallet.hotkey.ss58_address, + netuid=netuid, # TODO should this be able to allow netuid = None ? + hotkey=get_hotkey_pub_ss58(wallet), children_with_proportions=[], prompt=prompt, wait_for_inclusion=True, @@ -658,6 +674,7 @@ async def revoke_children( "error": message, "set_block": None, "completion_block": None, + "extrinsic_identifier": ext_id, } if success: current_block, completion_block = await get_childkey_completion_block( @@ -687,12 +704,12 @@ async def childkey_take( wait_for_inclusion: bool = True, wait_for_finalization: bool = True, prompt: bool = True, -) -> list[tuple[Optional[int], bool]]: +) -> list[tuple[Optional[int], bool, Optional[str]]]: """ Get or Set childkey take. Returns: - List of (netuid, success) for specified netuid (or all) and their success in setting take + List of (netuid, success, extrinsic identifier) for specified netuid (or all) and their success in setting take """ def validate_take_value(take_value: float) -> bool: @@ -740,13 +757,15 @@ async def chk_all_subnets(ss58): console.print(table) - async def set_chk_take_subnet(subnet: int, chk_take: float) -> bool: + async def set_chk_take_subnet( + subnet: int, chk_take: float + ) -> tuple[bool, Optional[str]]: """Set the childkey take for a single subnet""" - success, message = await set_childkey_take_extrinsic( + success, message, ext_id = await set_childkey_take_extrinsic( subtensor=subtensor, wallet=wallet, netuid=subnet, - hotkey=wallet.hotkey.ss58_address, + hotkey=get_hotkey_pub_ss58(wallet), take=chk_take, prompt=prompt, wait_for_inclusion=wait_for_inclusion, @@ -756,19 +775,20 @@ async def set_chk_take_subnet(subnet: int, chk_take: float) -> bool: if success: console.print(":white_heavy_check_mark: [green]Set childkey take.[/green]") console.print( - f"The childkey take for {wallet.hotkey.ss58_address} is now set to {take * 100:.2f}%." + f"The childkey take for {get_hotkey_pub_ss58(wallet)} is now set to {take * 100:.2f}%." ) - return True + return True, ext_id else: console.print( f":cross_mark:[red] Unable to set childkey take.[/red] {message}" ) - return False + return False, ext_id # Print childkey take for other user and return (dont offer to change take rate) - if not hotkey or hotkey == wallet.hotkey.ss58_address: - hotkey = wallet.hotkey.ss58_address - if hotkey != wallet.hotkey.ss58_address or not take: + wallet_hk = get_hotkey_pub_ss58(wallet) + if not hotkey or hotkey == wallet_hk: + hotkey = wallet_hk + if hotkey != wallet_hk or not take: # display childkey take for other users if netuid: await display_chk_take(hotkey, netuid) @@ -776,7 +796,7 @@ async def set_chk_take_subnet(subnet: int, chk_take: float) -> bool: console.print( f"Hotkey {hotkey} not associated with wallet {wallet.name}." ) - return [(netuid, False)] + return [(netuid, False, None)] else: # show child hotkey take on all subnets await chk_all_subnets(hotkey) @@ -784,12 +804,12 @@ async def set_chk_take_subnet(subnet: int, chk_take: float) -> bool: console.print( f"Hotkey {hotkey} not associated with wallet {wallet.name}." ) - return [(netuid, False)] + return [(netuid, False, None)] # Validate child SS58 addresses if not take: if not Confirm.ask("Would you like to change the child take?"): - return [(netuid, False)] + return [(netuid, False, None)] new_take_value = -1.0 while not validate_take_value(new_take_value): new_take_value = FloatPrompt.ask( @@ -798,22 +818,21 @@ async def set_chk_take_subnet(subnet: int, chk_take: float) -> bool: take = new_take_value else: if not validate_take_value(take): - return [(netuid, False)] + return [(netuid, False, None)] if netuid: - return [(netuid, await set_chk_take_subnet(subnet=netuid, chk_take=take))] + success, ext_id = await set_chk_take_subnet(subnet=netuid, chk_take=take) + return [(netuid, success, ext_id)] else: new_take_netuids = IntPrompt.ask( "Enter netuid (leave blank for all)", default=None, show_default=True ) if new_take_netuids: - return [ - ( - new_take_netuids, - await set_chk_take_subnet(subnet=new_take_netuids, chk_take=take), - ) - ] + success, ext_id = await set_chk_take_subnet( + subnet=new_take_netuids, chk_take=take + ) + return [(new_take_netuids, success, ext_id)] else: netuids = await subtensor.get_all_subnet_netuids() @@ -821,18 +840,18 @@ async def set_chk_take_subnet(subnet: int, chk_take: float) -> bool: for netuid_ in netuids: if netuid_ == 0: continue - console.print(f"Sending to netuid {netuid_} take of {take * 100:.2f}%") - result = await set_childkey_take_extrinsic( + console.print(f"Setting take of {take * 100:.2f}% on netuid {netuid_}.") + result, _, ext_id = await set_childkey_take_extrinsic( subtensor=subtensor, wallet=wallet, netuid=netuid_, - hotkey=wallet.hotkey.ss58_address, + hotkey=wallet_hk, take=take, prompt=prompt, wait_for_inclusion=True, wait_for_finalization=False, ) - output_list.append((netuid_, result)) + output_list.append((netuid_, result, ext_id)) console.print( f":white_heavy_check_mark: [green]Sent childkey take of {take * 100:.2f}% to all subnets.[/green]" ) diff --git a/bittensor_cli/src/commands/stake/claim.py b/bittensor_cli/src/commands/stake/claim.py new file mode 100644 index 000000000..67147a82c --- /dev/null +++ b/bittensor_cli/src/commands/stake/claim.py @@ -0,0 +1,481 @@ +import asyncio +import json +from typing import TYPE_CHECKING, Optional + +from bittensor_wallet import Wallet +from rich.prompt import Confirm, Prompt +from rich.table import Table, Column +from rich import box + +from bittensor_cli.src import COLORS +from bittensor_cli.src.bittensor.balances import Balance +from bittensor_cli.src.bittensor.utils import ( + console, + err_console, + unlock_key, + print_extrinsic_id, + json_console, + millify_tao, +) + +if TYPE_CHECKING: + from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface + + +async def set_claim_type( + wallet: Wallet, + subtensor: "SubtensorInterface", + claim_type: Optional[str] = None, + prompt: bool = True, + json_output: bool = False, +) -> tuple[bool, str, Optional[str]]: + """ + Sets the root claim type for the coldkey. + + Root claim types control how staking emissions are handled on the ROOT network (subnet 0): + - "Swap": Future Root Alpha Emissions are swapped to TAO at claim time and added to root stake + - "Keep": Future Root Alpha Emissions are kept as Alpha tokens + + Args: + wallet: Bittensor wallet object + subtensor: SubtensorInterface object + claim_type: Optional claim type ("Keep" or "Swap"). If None, user will be prompted. + prompt: Whether to prompt for user confirmation + json_output: Whether to output JSON + + Returns: + tuple[bool, str, Optional[str]]: Tuple containing: + - bool: True if successful, False otherwise + - str: Error message if failed + - Optional[str]: Extrinsic identifier if successful + """ + + current_type = await subtensor.get_coldkey_claim_type( + coldkey_ss58=wallet.coldkeypub.ss58_address + ) + + claim_table = Table( + Column( + "[bold white]Coldkey", + style=COLORS.GENERAL.COLDKEY, + justify="left", + ), + Column( + "[bold white]Root Claim Type", + style=COLORS.GENERAL.SUBHEADING, + justify="center", + ), + show_header=True, + show_footer=False, + show_edge=True, + border_style="bright_black", + box=box.SIMPLE, + pad_edge=False, + width=None, + title=f"\n[{COLORS.GENERAL.HEADER}]Current root claim type:[/{COLORS.GENERAL.HEADER}]", + ) + claim_table.add_row( + wallet.coldkeypub.ss58_address, f"[yellow]{current_type}[/yellow]" + ) + console.print(claim_table) + + new_type = ( + claim_type + if claim_type + else Prompt.ask( + "Select new root claim type", choices=["Swap", "Keep"], default=current_type + ) + ) + if new_type == current_type: + msg = f"Root claim type is already set to '{current_type}'. No change needed." + console.print(f"[yellow]{msg}[/yellow]") + if json_output: + json_console.print( + json.dumps( + { + "success": True, + "message": msg, + "extrinsic_identifier": None, + "old_type": current_type, + "new_type": current_type, + } + ) + ) + return True, msg, None + + if prompt: + console.print( + f"\n[bold]Changing root claim type from '{current_type}' -> '{new_type}'[/bold]\n" + ) + + if new_type == "Swap": + console.print( + "[yellow]Note:[/yellow] With 'Swap', future root alpha emissions will be swapped to TAO and added to root stake." + ) + else: + console.print( + "[yellow]Note:[/yellow] With 'Keep', future root alpha emissions will be kept as Alpha tokens." + ) + + if not Confirm.ask("\nDo you want to proceed?"): + msg = "Operation cancelled." + console.print(f"[yellow]{msg}[/yellow]") + if json_output: + json_console.print( + json.dumps( + { + "success": False, + "message": msg, + "extrinsic_identifier": None, + "old_type": current_type, + "new_type": new_type, + } + ) + ) + return False, msg, None + + if not (unlock := unlock_key(wallet)).success: + msg = f"Failed to unlock wallet: {unlock.message}" + err_console.print(f":cross_mark: [red]{msg}[/red]") + if json_output: + json_console.print( + json.dumps( + { + "success": False, + "message": msg, + "extrinsic_identifier": None, + "old_type": current_type, + "new_type": new_type, + } + ) + ) + return False, msg, None + + with console.status( + f":satellite: Setting root claim type to '{new_type}'...", spinner="earth" + ): + call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="set_root_claim_type", + call_params={"new_root_claim_type": new_type}, + ) + success, err_msg, ext_receipt = await subtensor.sign_and_send_extrinsic( + call, wallet + ) + + if success: + ext_id = await ext_receipt.get_extrinsic_identifier() + msg = f"Successfully set root claim type to '{new_type}'" + console.print(f":white_heavy_check_mark: [green]{msg}[/green]") + await print_extrinsic_id(ext_receipt) + if json_output: + json_console.print( + json.dumps( + { + "success": True, + "message": msg, + "extrinsic_identifier": ext_id, + "old_type": current_type, + "new_type": new_type, + } + ) + ) + return True, msg, ext_id + + else: + msg = f"Failed to set root claim type: {err_msg}" + err_console.print(f":cross_mark: [red]{msg}[/red]") + if json_output: + json_console.print( + json.dumps( + { + "success": False, + "message": msg, + "extrinsic_identifier": None, + "old_type": current_type, + "new_type": new_type, + } + ) + ) + return False, msg, None + + +async def process_pending_claims( + wallet: Wallet, + subtensor: "SubtensorInterface", + netuids: Optional[list[int]] = None, + prompt: bool = True, + json_output: bool = False, + verbose: bool = False, +) -> tuple[bool, str, Optional[str]]: + """Claims root network emissions for the coldkey across specified subnets""" + + with console.status(":satellite: Discovering claimable emissions..."): + block_hash = await subtensor.substrate.get_chain_head() + all_stakes, identities = await asyncio.gather( + subtensor.get_stake_for_coldkey( + coldkey_ss58=wallet.coldkeypub.ss58_address, block_hash=block_hash + ), + subtensor.query_all_identities(block_hash=block_hash), + ) + if not all_stakes: + msg = "No stakes found for this coldkey" + console.print(f"[yellow]{msg}[/yellow]") + if json_output: + json_console.print( + json.dumps( + { + "success": True, + "message": msg, + "extrinsic_identifier": None, + "netuids": [], + } + ) + ) + return True, msg, None + + current_stakes = { + (stake.hotkey_ss58, stake.netuid): stake for stake in all_stakes + } + claimable_by_hotkey = await subtensor.get_claimable_stakes_for_coldkey( + coldkey_ss58=wallet.coldkeypub.ss58_address, + stakes_info=all_stakes, + block_hash=block_hash, + ) + hotkey_owner_tasks = [ + subtensor.get_hotkey_owner( + hotkey, check_exists=False, block_hash=block_hash + ) + for hotkey in claimable_by_hotkey.keys() + ] + hotkey_owners = await asyncio.gather(*hotkey_owner_tasks) + hotkey_to_owner = dict(zip(claimable_by_hotkey.keys(), hotkey_owners)) + + # Consolidate data + claimable_stake_info = {} + for vali_hotkey, claimable_stakes in claimable_by_hotkey.items(): + vali_coldkey = hotkey_to_owner.get(vali_hotkey, "~") + vali_identity = identities.get(vali_coldkey, {}).get("name", "~") + for netuid, claimable_stake in claimable_stakes.items(): + if claimable_stake.rao > 0: + if netuid not in claimable_stake_info: + claimable_stake_info[netuid] = {} + current_stake = ( + stake_info.stake + if (stake_info := current_stakes.get((vali_hotkey, netuid))) + else Balance.from_rao(0).set_unit(netuid) + ) + claimable_stake_info[netuid][vali_hotkey] = { + "claimable": claimable_stake, + "stake": current_stake, + "coldkey": vali_coldkey, + "identity": vali_identity, + } + + if netuids: + claimable_stake_info = { + netuid: hotkeys_info + for netuid, hotkeys_info in claimable_stake_info.items() + if netuid in netuids + } + + if not claimable_stake_info: + msg = "No claimable emissions found" + console.print(f"[yellow]{msg}[/yellow]") + if json_output: + json_console.print( + json.dumps( + { + "success": True, + "message": msg, + "extrinsic_identifier": None, + "netuids": netuids, + } + ) + ) + return True, msg, None + + _print_claimable_table(wallet, claimable_stake_info, verbose) + selected_netuids = ( + netuids if netuids else _prompt_claim_selection(claimable_stake_info) + ) + + call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="claim_root", + call_params={"subnets": selected_netuids}, + ) + extrinsic_fee = await subtensor.get_extrinsic_fee(call, wallet.coldkeypub) + console.print(f"\n[dim]Estimated extrinsic fee: {extrinsic_fee.tao:.9f} τ[/dim]") + + if prompt: + if not Confirm.ask("Do you want to proceed?"): + msg = "Operation cancelled by user" + console.print(f"[yellow]{msg}[/yellow]") + if json_output: + json_console.print( + json.dumps( + { + "success": False, + "message": msg, + "extrinsic_identifier": None, + "netuids": selected_netuids, + } + ) + ) + return False, msg, None + + if not (unlock := unlock_key(wallet)).success: + msg = f"Failed to unlock wallet: {unlock.message}" + err_console.print(f":cross_mark: [red]{msg}[/red]") + if json_output: + json_console.print( + json.dumps( + { + "success": False, + "message": msg, + "extrinsic_identifier": None, + "netuids": selected_netuids, + } + ) + ) + return False, msg, None + + with console.status( + f":satellite: Claiming root emissions for {len(selected_netuids)} subnet(s)...", + spinner="earth", + ): + success, err_msg, ext_receipt = await subtensor.sign_and_send_extrinsic( + call, wallet + ) + if success: + ext_id = await ext_receipt.get_extrinsic_identifier() + msg = f"Successfully claimed root emissions for {len(selected_netuids)} subnet(s)" + console.print(f"[dark_sea_green3]{msg}[/dark_sea_green3]") + await print_extrinsic_id(ext_receipt) + if json_output: + json_console.print( + json.dumps( + { + "success": True, + "message": msg, + "extrinsic_identifier": ext_id, + "netuids": selected_netuids, + } + ) + ) + return True, msg, ext_id + else: + msg = f"Failed to claim root emissions: {err_msg}" + err_console.print(f":cross_mark: [red]{msg}[/red]") + if json_output: + json_console.print( + json.dumps( + { + "success": False, + "message": msg, + "extrinsic_identifier": None, + "netuids": selected_netuids, + } + ) + ) + return False, msg, None + + +def _prompt_claim_selection(claimable_stake: dict) -> Optional[list[int]]: + """Prompts user to select up to 5 netuids to claim from""" + + available_netuids = sorted(claimable_stake.keys()) + while True: + netuid_input = Prompt.ask( + "Enter up to 5 netuids to claim from (comma-separated)", + default=",".join(str(n) for n in available_netuids), + ) + + try: + if "," in netuid_input: + selected = [int(n.strip()) for n in netuid_input.split(",")] + else: + selected = [int(netuid_input.strip())] + except ValueError: + err_console.print( + ":cross_mark: [red]Invalid input. Please enter numbers only.[/red]" + ) + continue + + if len(selected) > 5: + err_console.print( + f":cross_mark: [red]You selected {len(selected)} netuids. Maximum is 5. Please try again.[/red]" + ) + continue + + if len(selected) == 0: + err_console.print( + ":cross_mark: [red]Please select at least one netuid.[/red]" + ) + continue + + invalid_netuids = [n for n in selected if n not in available_netuids] + if invalid_netuids: + err_console.print( + f":cross_mark: [red]Invalid netuids: {', '.join(map(str, invalid_netuids))}[/red]" + ) + continue + + selected = list(dict.fromkeys(selected)) + + return selected + + +def _print_claimable_table( + wallet: Wallet, claimable_stake: dict, verbose: bool = False +): + """Prints claimable stakes table grouped by netuid""" + + table = Table( + show_header=True, + show_footer=False, + show_edge=True, + border_style="bright_black", + box=box.SIMPLE, + pad_edge=False, + title=f"\n[{COLORS.GENERAL.HEADER}]Claimable emissions for coldkey: {wallet.coldkeypub.ss58_address}", + ) + + table.add_column("Netuid", style=COLORS.GENERAL.NETUID, justify="center") + table.add_column("Current Stake", style=COLORS.GENERAL.SUBHEADING, justify="right") + table.add_column("Claimable", style=COLORS.GENERAL.SUCCESS, justify="right") + table.add_column("Hotkey", style=COLORS.GENERAL.HOTKEY, justify="left") + table.add_column("Identity", style=COLORS.GENERAL.SUBHEADING, justify="left") + + for netuid in sorted(claimable_stake.keys()): + hotkeys_info = claimable_stake[netuid] + first_row = True + + for hotkey, info in hotkeys_info.items(): + hotkey_display = hotkey if verbose else f"{hotkey[:8]}...{hotkey[-8:]}" + netuid_display = str(netuid) if first_row else "" + + stake_display = info["stake"] + stake_formatted = ( + f"{stake_display.tao:.4f} {stake_display.unit}" + if verbose + else f"{millify_tao(stake_display.tao)} {stake_display.unit}" + ) + + claimable_display = info["claimable"] + claimable_formatted = ( + f"{claimable_display.tao:.4f} {claimable_display.unit}" + if verbose + else f"{millify_tao(claimable_display.tao)} {claimable_display.unit}" + ) + table.add_row( + netuid_display, + stake_formatted, + claimable_formatted, + hotkey_display, + info.get("identity", "~"), + ) + first_row = False + + console.print(table) diff --git a/bittensor_cli/src/commands/stake/list.py b/bittensor_cli/src/commands/stake/list.py index 4a8e17145..b2407bab7 100644 --- a/bittensor_cli/src/commands/stake/list.py +++ b/bittensor_cli/src/commands/stake/list.py @@ -49,12 +49,21 @@ async def get_stake_data(block_hash_: str = None): subtensor.get_delegate_identities(block_hash=block_hash_), subtensor.all_subnets(block_hash=block_hash_), ) + + claimable_amounts = {} + if sub_stakes_: + claimable_amounts = await subtensor.get_claimable_stakes_for_coldkey( + coldkey_ss58=coldkey_address, + stakes_info=sub_stakes_, + block_hash=block_hash_, + ) # sub_stakes = substakes[coldkey_address] dynamic_info__ = {info.netuid: info for info in _dynamic_info} return ( sub_stakes_, registered_delegate_info_, dynamic_info__, + claimable_amounts, ) def define_table( @@ -135,9 +144,18 @@ def define_table( style=COLOR_PALETTE["POOLS"]["EMISSION"], justify="right", ) + defined_table.add_column( + f"[white]Claimable \n({Balance.get_unit(1)})", + style=COLOR_PALETTE["STAKE"]["STAKE_ALPHA"], + justify="right", + ) return defined_table - def create_table(hotkey_: str, substakes: list[StakeInfo]): + def create_table( + hotkey_: str, + substakes_: list[StakeInfo], + claimable_amounts_: dict[str, dict[int, Balance]], + ): name_ = ( f"{registered_delegate_info[hotkey_].display} ({hotkey_})" if hotkey_ in registered_delegate_info @@ -146,9 +164,9 @@ def create_table(hotkey_: str, substakes: list[StakeInfo]): rows = [] total_tao_value_ = Balance(0) total_swapped_tao_value_ = Balance(0) - root_stakes = [s for s in substakes if s.netuid == 0] + root_stakes = [s for s in substakes_ if s.netuid == 0] other_stakes = sorted( - [s for s in substakes if s.netuid != 0], + [s for s in substakes_ if s.netuid != 0], key=lambda x: dynamic_info[x.netuid] .alpha_to_tao(Balance.from_rao(int(x.stake.rao)).set_unit(x.netuid)) .tao, @@ -194,6 +212,23 @@ def create_table(hotkey_: str, substakes: list[StakeInfo]): 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}" + # Claimable amount cell + claimable_amount = Balance.from_rao(0) + if ( + hotkey_ in claimable_amounts_ + and netuid in claimable_amounts_[hotkey_] + ): + claimable_amount = claimable_amounts_[hotkey_][netuid] + + if claimable_amount.tao > 0.00001: + claimable_cell = ( + f"{claimable_amount.tao:.5f} {symbol}" + if not verbose + else f"{claimable_amount}" + ) + else: + claimable_cell = "-" + rows.append( [ str(netuid), # Number @@ -215,6 +250,7 @@ def create_table(hotkey_: str, substakes: list[StakeInfo]): # if substake_.is_registered # else f"[{COLOR_PALETTE['STAKE']['NOT_REGISTERED']}]N/A", # Emission(α/block) str(Balance.from_tao(per_block_tao_emission)), + claimable_cell, # Claimable amount ] ) substakes_values.append( @@ -230,6 +266,7 @@ def create_table(hotkey_: str, substakes: list[StakeInfo]): "alpha": per_block_emission, "tao": per_block_tao_emission, }, + "claimable": claimable_amount.tao, } ) created_table = define_table( @@ -244,6 +281,7 @@ def create_live_table( substakes: list, dynamic_info_for_lt: dict, hotkey_name_: str, + claimable_amounts_: dict, previous_data_: Optional[dict] = None, ) -> tuple[Table, dict]: rows = [] @@ -388,6 +426,26 @@ def format_cell( f" {get_subnet_name(dynamic_info_for_lt[netuid])}" ) + # Claimable amount cell + hotkey_ss58 = substake_.hotkey_ss58 + claimable_amount = Balance.from_rao(0) + if ( + hotkey_ss58 in claimable_amounts_ + and netuid in claimable_amounts_[hotkey_ss58] + ): + claimable_amount = claimable_amounts_[hotkey_ss58][netuid] + + current_data_[netuid]["claimable"] = claimable_amount.tao + + claimable_cell = format_cell( + claimable_amount.tao, + prev.get("claimable"), + unit=symbol, + unit_first_=unit_first, + precision=5, + millify=True if not verbose else False, + ) + rows.append( [ str(netuid), # Netuid @@ -401,6 +459,7 @@ def format_cell( else f"[{COLOR_PALETTE['STAKE']['NOT_REGISTERED']}]NO", # Registration status emission_cell, # Emission rate tao_emission_cell, # TAO emission rate + claimable_cell, # Claimable amount ] ) @@ -420,6 +479,7 @@ def format_cell( sub_stakes, registered_delegate_info, dynamic_info, + claimable_amounts, ), balance, ) = await asyncio.gather( @@ -487,6 +547,7 @@ def format_cell( sub_stakes, registered_delegate_info, dynamic_info_, + claimable_amounts_live, ) = await get_stake_data(block_hash) selected_stakes = [ stake @@ -508,6 +569,7 @@ def format_cell( selected_stakes, dynamic_info_, hotkey_name, + claimable_amounts_live, previous_data, ) @@ -553,7 +615,7 @@ def format_cell( for hotkey, substakes in hotkeys_to_substakes.items(): counter += 1 tao_value, swapped_tao_value, substake_values_ = create_table( - hotkey, substakes + hotkey, substakes, claimable_amounts ) dict_output["stake_info"][hotkey] = substake_values_ all_hks_tao_value += tao_value @@ -564,7 +626,7 @@ def format_cell( input() total_tao_value = ( - f"τ {millify_tao(all_hks_tao_value.tao)}" + f"τ {millify_tao(all_hks_tao_value.tao + balance.tao)}" if not verbose else all_hks_tao_value ) @@ -576,10 +638,14 @@ def format_cell( console.print("\n\n") console.print( f"Wallet:\n" - f" Coldkey SS58: [{COLOR_PALETTE['GENERAL']['COLDKEY']}]{coldkey_address}[/{COLOR_PALETTE['GENERAL']['COLDKEY']}]\n" - f" Free Balance: [{COLOR_PALETTE['GENERAL']['BALANCE']}]{balance}[/{COLOR_PALETTE['GENERAL']['BALANCE']}]\n" - f" Total TAO Value ({Balance.unit}): [{COLOR_PALETTE['GENERAL']['BALANCE']}]{total_tao_value}[/{COLOR_PALETTE['GENERAL']['BALANCE']}]" - # f"\n Total TAO Swapped Value ({Balance.unit}): [{COLOR_PALETTE['GENERAL']['BALANCE']}]{total_swapped_tao_value}[/{COLOR_PALETTE['GENERAL']['BALANCE']}]" + f" Coldkey SS58: " + f"[{COLOR_PALETTE.G.CK}]{coldkey_address}[/{COLOR_PALETTE.G.CK}]\n" + f" Free Balance: " + f"[{COLOR_PALETTE.G.BALANCE}]{balance}[/{COLOR_PALETTE.G.BALANCE}]\n" + f" Total TAO Swapped Value ({Balance.unit}): " + f"[{COLOR_PALETTE.G.BALANCE}]{total_swapped_tao_value}[/{COLOR_PALETTE.G.BALANCE}]\n" + f" Total TAO Value (including free balance) ({Balance.unit}): " + f"[{COLOR_PALETTE.G.BALANCE}]{total_tao_value}[/{COLOR_PALETTE.G.BALANCE}]\n" ) dict_output["free_balance"] = balance.tao dict_output["total_tao_value"] = all_hks_tao_value.tao diff --git a/bittensor_cli/src/commands/stake/move.py b/bittensor_cli/src/commands/stake/move.py index b4efbd127..99a0b79ac 100644 --- a/bittensor_cli/src/commands/stake/move.py +++ b/bittensor_cli/src/commands/stake/move.py @@ -16,6 +16,8 @@ group_subnets, get_subnet_name, unlock_key, + get_hotkey_pub_ss58, + print_extrinsic_id, ) if TYPE_CHECKING: @@ -106,39 +108,58 @@ async def display_stake_movement_cross_subnets( ) table.add_column( - "origin netuid", justify="center", style=COLOR_PALETTE["GENERAL"]["SYMBOL"] + "origin netuid", + justify="center", + style=COLOR_PALETTE["GENERAL"]["SYMBOL"], + max_width=14, ) table.add_column( - "origin hotkey", justify="center", style=COLOR_PALETTE["GENERAL"]["HOTKEY"] + "origin hotkey", + justify="center", + style=COLOR_PALETTE["GENERAL"]["HOTKEY"], + max_width=15, ) table.add_column( - "dest netuid", justify="center", style=COLOR_PALETTE["GENERAL"]["SYMBOL"] + "dest netuid", + justify="center", + style=COLOR_PALETTE["GENERAL"]["SYMBOL"], + max_width=12, ) table.add_column( - "dest hotkey", justify="center", style=COLOR_PALETTE["GENERAL"]["HOTKEY"] + "dest hotkey", + justify="center", + style=COLOR_PALETTE["GENERAL"]["HOTKEY"], + max_width=15, ) table.add_column( f"amount ({Balance.get_unit(origin_netuid)})", justify="center", style=COLOR_PALETTE["STAKE"]["TAO"], + max_width=18, ) table.add_column( f"rate ({Balance.get_unit(destination_netuid)}/{Balance.get_unit(origin_netuid)})", justify="center", style=COLOR_PALETTE["POOLS"]["RATE"], + max_width=20, ) table.add_column( f"received ({Balance.get_unit(destination_netuid)})", justify="center", style=COLOR_PALETTE["POOLS"]["TAO_EQUIV"], + max_width=18, ) table.add_column( f"Fee ({Balance.get_unit(origin_netuid)})", justify="center", style=COLOR_PALETTE["STAKE"]["STAKE_AMOUNT"], + max_width=15, ) table.add_column( - "Extrinsic Fee (τ)", justify="center", style=COLOR_PALETTE.STAKE.TAO + "Extrinsic Fee (τ)", + justify="center", + style=COLOR_PALETTE.STAKE.TAO, + max_width=18, ) table.add_row( @@ -199,6 +220,8 @@ def prompt_stake_amount( return Balance.from_tao(amount), False except ValueError: console.print("[red]Please enter a valid number or 'all'[/red]") + # can never return this, but fixes the type checker + return Balance(0), False async def stake_move_transfer_selection( @@ -343,8 +366,9 @@ async def stake_swap_selection( # Filter stakes for this hotkey hotkey_stakes = {} + hotkey_ss58 = get_hotkey_pub_ss58(wallet) for stake in stakes: - if stake.hotkey_ss58 == wallet.hotkey.ss58_address and stake.stake.tao > 0: + if stake.hotkey_ss58 == hotkey_ss58 and stake.stake.tao > 0: hotkey_stakes[stake.netuid] = { "stake": stake.stake, "is_registered": stake.is_registered, @@ -357,12 +381,12 @@ async def stake_swap_selection( # Display available stakes table = Table( title=f"\n[{COLOR_PALETTE.G.HEADER}]Available Stakes for Hotkey\n[/{COLOR_PALETTE.G.HEADER}]" - f"[{COLOR_PALETTE.G.HK}]{wallet.hotkey_str}: {wallet.hotkey.ss58_address}[/{COLOR_PALETTE.G.HK}]\n", + f"[{COLOR_PALETTE.G.HK}]{wallet.hotkey_str}: {hotkey_ss58}[/{COLOR_PALETTE.G.HK}]\n", show_edge=False, header_style="bold white", border_style="bright_black", title_justify="center", - width=len(wallet.hotkey.ss58_address) + 20, + width=len(hotkey_ss58) + 20, ) table.add_column("Index", justify="right", style="cyan") @@ -434,12 +458,12 @@ async def move_stake( era: int, interactive_selection: bool = False, prompt: bool = True, -) -> bool: +) -> tuple[bool, str]: if interactive_selection: try: selection = await stake_move_transfer_selection(subtensor, wallet) except ValueError: - return False + return False, "" origin_hotkey = selection["origin_hotkey"] origin_netuid = selection["origin_netuid"] amount = selection["amount"] @@ -470,7 +494,7 @@ async def move_stake( f"in Netuid: " f"[{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{origin_netuid}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}]" ) - return False + return False, "" console.print( f"\nOrigin Netuid: " @@ -505,7 +529,7 @@ async def move_stake( f" < Moving amount: [{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]" f"{amount_to_move_as_balance}[/{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]" ) - return False + return False, "" call = await subtensor.substrate.compose_call( call_module="SubtensorModule", @@ -518,14 +542,10 @@ async def move_stake( "alpha_amount": amount_to_move_as_balance.rao, }, ) - stake_fee, extrinsic_fee = await asyncio.gather( - subtensor.get_stake_fee( - origin_hotkey_ss58=origin_hotkey, + sim_swap, extrinsic_fee = await asyncio.gather( + subtensor.sim_swap( origin_netuid=origin_netuid, - origin_coldkey_ss58=wallet.coldkeypub.ss58_address, - destination_hotkey_ss58=destination_hotkey, destination_netuid=destination_netuid, - destination_coldkey_ss58=wallet.coldkeypub.ss58_address, amount=amount_to_move_as_balance.rao, ), subtensor.get_extrinsic_fee(call, wallet.coldkeypub), @@ -541,17 +561,19 @@ async def move_stake( origin_hotkey=origin_hotkey, destination_hotkey=destination_hotkey, amount_to_move=amount_to_move_as_balance, - stake_fee=stake_fee, + stake_fee=sim_swap.alpha_fee + if origin_netuid != 0 + else sim_swap.tao_fee, extrinsic_fee=extrinsic_fee, ) except ValueError: - return False + return False, "" if not Confirm.ask("Would you like to continue?"): - return False + return False, "" # Perform moving operation. if not unlock_key(wallet).success: - return False + return False, "" with console.status( f"\n:satellite: Moving [blue]{amount_to_move_as_balance}[/blue] from [blue]{origin_hotkey}[/blue] on netuid: " f"[blue]{origin_netuid}[/blue] \nto " @@ -563,18 +585,20 @@ async def move_stake( response = await subtensor.substrate.submit_extrinsic( extrinsic, wait_for_inclusion=True, wait_for_finalization=False ) + ext_id = await response.get_extrinsic_identifier() if not prompt: console.print(":white_heavy_check_mark: [green]Sent[/green]") - return True + return True, ext_id else: 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 False + return False, "" else: + await print_extrinsic_id(response) console.print( ":white_heavy_check_mark: [dark_sea_green3]Stake moved.[/dark_sea_green3]" ) @@ -605,7 +629,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 True + return True, ext_id async def transfer_stake( @@ -620,7 +644,7 @@ async def transfer_stake( interactive_selection: bool = False, stake_all: bool = False, prompt: bool = True, -) -> bool: +) -> tuple[bool, str]: """Transfers stake from one network to another. Args: @@ -653,11 +677,11 @@ async def transfer_stake( ) if not dest_exists: err_console.print(f"[red]Subnet {dest_netuid} does not exist[/red]") - return False + return False, "" if not origin_exists: err_console.print(f"[red]Subnet {origin_netuid} does not exist[/red]") - return False + return False, "" # Get current stake balances with console.status(f"Retrieving stake data from {subtensor.network}..."): @@ -676,7 +700,7 @@ async def transfer_stake( err_console.print( f"[red]No stake found for hotkey: {origin_hotkey} on netuid: {origin_netuid}[/red]" ) - return False + return False, "" if amount: amount_to_transfer = Balance.from_tao(amount).set_unit(origin_netuid) @@ -694,7 +718,7 @@ async def transfer_stake( f"Stake balance: [{COLOR_PALETTE.S.STAKE_AMOUNT}]{current_stake}[/{COLOR_PALETTE.S.STAKE_AMOUNT}] < " f"Transfer amount: [{COLOR_PALETTE.S.STAKE_AMOUNT}]{amount_to_transfer}[/{COLOR_PALETTE.S.STAKE_AMOUNT}]" ) - return False + return False, "" call = await subtensor.substrate.compose_call( call_module="SubtensorModule", @@ -707,14 +731,10 @@ async def transfer_stake( "alpha_amount": amount_to_transfer.rao, }, ) - stake_fee, extrinsic_fee = await asyncio.gather( - subtensor.get_stake_fee( - origin_hotkey_ss58=origin_hotkey, + sim_swap, extrinsic_fee = await asyncio.gather( + subtensor.sim_swap( origin_netuid=origin_netuid, - origin_coldkey_ss58=wallet.coldkeypub.ss58_address, - destination_hotkey_ss58=origin_hotkey, destination_netuid=dest_netuid, - destination_coldkey_ss58=dest_coldkey_ss58, amount=amount_to_transfer.rao, ), subtensor.get_extrinsic_fee(call, wallet.coldkeypub), @@ -730,18 +750,20 @@ async def transfer_stake( origin_hotkey=origin_hotkey, destination_hotkey=origin_hotkey, amount_to_move=amount_to_transfer, - stake_fee=stake_fee, + stake_fee=sim_swap.alpha_fee + if origin_netuid != 0 + else sim_swap.tao_fee, extrinsic_fee=extrinsic_fee, ) except ValueError: - return False + return False, "" if not Confirm.ask("Would you like to continue?"): - return False + return False, "" # Perform transfer operation if not unlock_key(wallet).success: - return False + return False, "" with console.status("\n:satellite: Transferring stake ..."): extrinsic = await subtensor.substrate.create_signed_extrinsic( @@ -751,18 +773,19 @@ async def transfer_stake( response = await subtensor.substrate.submit_extrinsic( extrinsic, wait_for_inclusion=True, wait_for_finalization=False ) + ext_id = await response.get_extrinsic_identifier() if not prompt: console.print(":white_heavy_check_mark: [green]Sent[/green]") - return True + return True, ext_id if not await response.is_success: err_console.print( f":cross_mark: [red]Failed[/red] with error: " f"{format_error_message(await response.error_message)}" ) - return False - + return False, "" + await print_extrinsic_id(response) # Get and display new stake balances new_stake, new_dest_stake = await asyncio.gather( subtensor.get_stake( @@ -785,7 +808,7 @@ async def transfer_stake( f"Destination Stake:\n [blue]{current_dest_stake}[/blue] :arrow_right: " f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_dest_stake}" ) - return True + return True, ext_id async def swap_stake( @@ -800,7 +823,7 @@ async def swap_stake( prompt: bool = True, wait_for_inclusion: bool = True, wait_for_finalization: bool = False, -) -> bool: +) -> tuple[bool, str]: """Swaps stake between subnets while keeping the same coldkey-hotkey pair ownership. Args: @@ -815,14 +838,16 @@ async def swap_stake( wait_for_finalization (bool): If true, waits for the transaction to be finalized. Returns: - bool: True if the swap was successful, False otherwise. + (success, extrinsic_identifier): + success is True if the swap was successful, False otherwise. + extrinsic_identifier if the extrinsic was successfully included """ - hotkey_ss58 = wallet.hotkey.ss58_address + hotkey_ss58 = get_hotkey_pub_ss58(wallet) if interactive_selection: try: selection = await stake_swap_selection(subtensor, wallet) except ValueError: - return False + return False, "" origin_netuid = selection["origin_netuid"] amount = selection["amount"] destination_netuid = selection["destination_netuid"] @@ -835,11 +860,11 @@ async def swap_stake( ) if not dest_exists: err_console.print(f"[red]Subnet {destination_netuid} does not exist[/red]") - return False + return False, "" if not origin_exists: err_console.print(f"[red]Subnet {origin_netuid} does not exist[/red]") - return False + return False, "" # Get current stake balances with console.status(f"Retrieving stake data from {subtensor.network}..."): @@ -866,7 +891,7 @@ async def swap_stake( f"Stake balance: [{COLOR_PALETTE.S.STAKE_AMOUNT}]{current_stake}[/{COLOR_PALETTE.S.STAKE_AMOUNT}] < " f"Swap amount: [{COLOR_PALETTE.S.STAKE_AMOUNT}]{amount_to_swap}[/{COLOR_PALETTE.S.STAKE_AMOUNT}]" ) - return False + return False, "" call = await subtensor.substrate.compose_call( call_module="SubtensorModule", @@ -878,14 +903,10 @@ async def swap_stake( "alpha_amount": amount_to_swap.rao, }, ) - stake_fee, extrinsic_fee = await asyncio.gather( - subtensor.get_stake_fee( - origin_hotkey_ss58=hotkey_ss58, + sim_swap, extrinsic_fee = await asyncio.gather( + subtensor.sim_swap( origin_netuid=origin_netuid, - origin_coldkey_ss58=wallet.coldkeypub.ss58_address, - destination_hotkey_ss58=hotkey_ss58, destination_netuid=destination_netuid, - destination_coldkey_ss58=wallet.coldkeypub.ss58_address, amount=amount_to_swap.rao, ), subtensor.get_extrinsic_fee(call, wallet.coldkeypub), @@ -901,18 +922,20 @@ async def swap_stake( origin_hotkey=hotkey_ss58, destination_hotkey=hotkey_ss58, amount_to_move=amount_to_swap, - stake_fee=stake_fee, + stake_fee=sim_swap.alpha_fee + if origin_netuid != 0 + else sim_swap.tao_fee, extrinsic_fee=extrinsic_fee, ) except ValueError: - return False + return False, "" if not Confirm.ask("Would you like to continue?"): - return False + return False, "" # Perform swap operation if not unlock_key(wallet).success: - return False + return False, "" with console.status( f"\n:satellite: Swapping stake from netuid [blue]{origin_netuid}[/blue] " @@ -927,18 +950,19 @@ async def swap_stake( wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, ) + ext_id = await response.get_extrinsic_identifier() if not prompt: console.print(":white_heavy_check_mark: [green]Sent[/green]") - return True + return True, ext_id if not await response.is_success: err_console.print( f":cross_mark: [red]Failed[/red] with error: " f"{format_error_message(await response.error_message)}" ) - return False - + return False, "" + await print_extrinsic_id(response) # Get and display new stake balances new_stake, new_dest_stake = await asyncio.gather( subtensor.get_stake( @@ -961,4 +985,4 @@ async def swap_stake( f"Destination Stake:\n [blue]{current_dest_stake}[/blue] :arrow_right: " f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_dest_stake}" ) - return True + return True, ext_id diff --git a/bittensor_cli/src/commands/stake/remove.py b/bittensor_cli/src/commands/stake/remove.py index a28254e32..5d125cc16 100644 --- a/bittensor_cli/src/commands/stake/remove.py +++ b/bittensor_cli/src/commands/stake/remove.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Optional +from async_substrate_interface import AsyncExtrinsicReceipt from bittensor_wallet import Wallet from rich.prompt import Confirm, Prompt from rich.table import Table @@ -22,6 +23,8 @@ group_subnets, unlock_key, json_console, + get_hotkey_pub_ss58, + print_extrinsic_id, ) if TYPE_CHECKING: @@ -133,7 +136,8 @@ async def unstake( skip_remaining_subnets = False if len(netuids) > 1 and not amount: console.print( - "[dark_sea_green3]Tip: Enter 'q' any time to stop going over remaining subnets and process current unstakes.\n" + "[dark_sea_green3]Tip: Enter 'q' any time to stop going over " + "remaining subnets and process current unstakes.\n" ) # Iterate over hotkeys and netuids to collect unstake operations @@ -199,16 +203,6 @@ async def unstake( ) continue # Skip to the next subnet - useful when single amount is specified for all subnets - stake_fee = await subtensor.get_stake_fee( - origin_hotkey_ss58=staking_address_ss58, - origin_netuid=netuid, - origin_coldkey_ss58=wallet.coldkeypub.ss58_address, - destination_hotkey_ss58=None, - destination_netuid=None, - destination_coldkey_ss58=wallet.coldkeypub.ss58_address, - amount=amount_to_unstake_as_balance.rao, - ) - try: current_price = subnet_info.price.tao if safe_staking: @@ -239,10 +233,10 @@ async def unstake( netuid=netuid, amount=amount_to_unstake_as_balance, ) - rate = current_price - received_amount = ( - (amount_to_unstake_as_balance - stake_fee) * rate - ) - extrinsic_fee + sim_swap = await subtensor.sim_swap( + netuid, 0, amount_to_unstake_as_balance.rao + ) + received_amount = sim_swap.tao_amount - extrinsic_fee except ValueError: continue total_received_amount += received_amount @@ -265,7 +259,7 @@ async def unstake( str(amount_to_unstake_as_balance), # Amount to Unstake f"{subnet_info.price.tao:.6f}" + f"(τ/{Balance.get_unit(netuid)})", # Rate - str(stake_fee.set_unit(netuid)), # Fee + str(sim_swap.alpha_fee), # Fee str(extrinsic_fee), # Extrinsic fee str(received_amount), # Received Amount # slippage_pct, # Slippage Percent @@ -344,7 +338,8 @@ async def unstake( func = _unstake_extrinsic specific_args = {"current_stake": op["current_stake_balance"]} - suc = await func(**common_args, **specific_args) + suc, ext_receipt = await func(**common_args, **specific_args) + ext_id = await ext_receipt.get_extrinsic_identifier() if suc else None successes.append( { @@ -352,6 +347,7 @@ async def unstake( "hotkey_ss58": op["hotkey_ss58"], "unstake_amount": op["amount_to_unstake"].tao, "success": suc, + "extrinsic_identifier": ext_id, } ) @@ -359,7 +355,8 @@ async def unstake( f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]Unstaking operations completed." ) if json_output: - json_console.print(json.dumps(successes)) + json_console.print_json(data=successes) + return True async def unstake_all( @@ -373,7 +370,7 @@ async def unstake_all( era: int = 3, prompt: bool = True, json_output: bool = False, -) -> bool: +) -> None: """Unstakes all stakes from all hotkeys in all subnets.""" include_hotkeys = include_hotkeys or [] exclude_hotkeys = exclude_hotkeys or [] @@ -407,7 +404,7 @@ async def unstake_all( old_identities=old_identities, ) elif not hotkey_ss58_address: - hotkeys = [(wallet.hotkey_str, wallet.hotkey.ss58_address, None)] + hotkeys = [(wallet.hotkey_str, get_hotkey_pub_ss58(wallet), None)] else: hotkeys = [(None, hotkey_ss58_address, None)] @@ -422,7 +419,7 @@ async def unstake_all( if not stake_info: console.print("[red]No stakes found to unstake[/red]") - return False + return all_sn_dynamic_info = {info.netuid: info for info in all_sn_dynamic_info_} @@ -492,15 +489,6 @@ async def unstake_all( hotkey_display = hotkey_names.get(stake.hotkey_ss58, stake.hotkey_ss58) subnet_info = all_sn_dynamic_info.get(stake.netuid) stake_amount = stake.stake - stake_fee = await subtensor.get_stake_fee( - origin_hotkey_ss58=stake.hotkey_ss58, - origin_netuid=stake.netuid, - origin_coldkey_ss58=wallet.coldkeypub.ss58_address, - destination_hotkey_ss58=None, - destination_netuid=None, - destination_coldkey_ss58=wallet.coldkeypub.ss58_address, - amount=stake_amount.rao, - ) try: current_price = subnet_info.price.tao @@ -513,8 +501,8 @@ async def unstake_all( subtensor, hotkey_ss58=stake.hotkey_ss58, ) - rate = current_price - received_amount = ((stake_amount - stake_fee) * rate) - extrinsic_fee + sim_swap = await subtensor.sim_swap(stake.netuid, 0, stake_amount.rao) + received_amount = sim_swap.tao_amount - extrinsic_fee if received_amount < Balance.from_tao(0): print_error("Not enough Alpha to pay the transaction fee.") @@ -530,7 +518,7 @@ async def unstake_all( str(stake_amount), f"{float(subnet_info.price):.6f}" + f"({Balance.get_unit(0)}/{Balance.get_unit(stake.netuid)})", - str(stake_fee), + str(sim_swap.alpha_fee), str(extrinsic_fee), str(received_amount), ) @@ -543,14 +531,14 @@ async def unstake_all( if prompt and not Confirm.ask( "\nDo you want to proceed with unstaking everything?" ): - return False + return if not unlock_key(wallet).success: - return False + return successes = {} with console.status("Unstaking all stakes...") as status: for hotkey_ss58 in hotkey_ss58s: - successes[hotkey_ss58] = await _unstake_all_extrinsic( + success, ext_receipt = await _unstake_all_extrinsic( wallet=wallet, subtensor=subtensor, hotkey_ss58=hotkey_ss58, @@ -559,8 +547,13 @@ async def unstake_all( status=status, era=era, ) + ext_id = await ext_receipt.get_extrinsic_identifier() if success else None + successes[hotkey_ss58] = { + "success": success, + "extrinsic_identifier": ext_id, + } if json_output: - return json_console.print(json.dumps({"success": successes})) + json_console.print(json.dumps({"success": successes})) # Extrinsics @@ -573,7 +566,7 @@ async def _unstake_extrinsic( hotkey_ss58: str, status=None, era: int = 3, -) -> bool: +) -> tuple[bool, Optional[AsyncExtrinsicReceipt]]: """Execute a standard unstake extrinsic. Args: @@ -621,8 +614,9 @@ async def _unstake_extrinsic( f"{failure_prelude} with error: " f"{format_error_message(await response.error_message)}" ) - return False + return False, None # Fetch latest balance and stake + await print_extrinsic_id(response) block_hash = await subtensor.substrate.get_chain_head() new_balance, new_stake = await asyncio.gather( subtensor.get_balance(wallet.coldkeypub.ss58_address, block_hash), @@ -642,11 +636,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 + return True, response except Exception as e: err_out(f"{failure_prelude} with error: {str(e)}") - return False + return False, None async def _safe_unstake_extrinsic( @@ -659,7 +653,7 @@ async def _safe_unstake_extrinsic( allow_partial_stake: bool, status=None, era: int = 3, -) -> bool: +) -> tuple[bool, Optional[AsyncExtrinsicReceipt]]: """Execute a safe unstake extrinsic with price limit. Args: @@ -725,14 +719,14 @@ async def _safe_unstake_extrinsic( ) else: err_out(f"\n{failure_prelude} with error: {format_error_message(e)}") - return False + return False, None if not await response.is_success: err_out( f"\n{failure_prelude} with error: {format_error_message(await response.error_message)}" ) - return False - + return False, None + await print_extrinsic_id(response) block_hash = await subtensor.substrate.get_chain_head() new_balance, new_stake = await asyncio.gather( subtensor.get_balance(wallet.coldkeypub.ss58_address, block_hash), @@ -762,7 +756,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 + return True, response async def _unstake_all_extrinsic( @@ -773,7 +767,7 @@ async def _unstake_all_extrinsic( unstake_all_alpha: bool, status=None, era: int = 3, -) -> None: +) -> tuple[bool, Optional[AsyncExtrinsicReceipt]]: """Execute an unstake all extrinsic. Args: @@ -791,7 +785,7 @@ async def _unstake_all_extrinsic( if status: status.update( - f"\n:satellite: {'Unstaking all Alpha stakes' if unstake_all_alpha else 'Unstaking all stakes'} from {hotkey_name} ..." + f"\n:satellite: Unstaking all {'Alpha ' if unstake_all_alpha else ''}stakes from {hotkey_name} ..." ) block_hash = await subtensor.substrate.get_chain_head() @@ -834,7 +828,9 @@ async def _unstake_all_extrinsic( f"{failure_prelude} with error: " f"{format_error_message(await response.error_message)}" ) - return + return False, None + else: + await print_extrinsic_id(response) # Fetch latest balance and stake block_hash = await subtensor.substrate.get_chain_head() @@ -872,9 +868,11 @@ async def _unstake_all_extrinsic( f"[blue]{previous_root_stake}[/blue] :arrow_right: " f"[{COLOR_PALETTE['STAKE']['STAKE_AMOUNT']}]{new_root_stake}" ) + return True, response except Exception as e: err_out(f"{failure_prelude} with error: {str(e)}") + return False, None async def _get_extrinsic_fee( @@ -1198,7 +1196,7 @@ def _get_hotkeys_to_unstake( print_verbose("Unstaking from all hotkeys") all_hotkeys_ = get_hotkey_wallets_for_wallet(wallet=wallet) wallet_hotkeys = [ - (wallet.hotkey_str, wallet.hotkey.ss58_address, None) + (wallet.hotkey_str, get_hotkey_pub_ss58(wallet), None) for wallet in all_hotkeys_ if wallet.hotkey_str not in exclude_hotkeys ] @@ -1230,7 +1228,7 @@ def _get_hotkeys_to_unstake( path=wallet.path, hotkey=hotkey_identifier, ) - result.append((wallet_.hotkey_str, wallet_.hotkey.ss58_address, None)) + result.append((wallet_.hotkey_str, get_hotkey_pub_ss58(wallet_), None)) return result # Only cli.config.wallet.hotkey is specified @@ -1238,7 +1236,7 @@ def _get_hotkeys_to_unstake( f"Unstaking from wallet: ({wallet.name}) from hotkey: ({wallet.hotkey_str})" ) assert wallet.hotkey is not None - return [(wallet.hotkey_str, wallet.hotkey.ss58_address, None)] + return [(wallet.hotkey_str, get_hotkey_pub_ss58(wallet), None)] def _create_unstake_table( diff --git a/bittensor_cli/src/commands/subnets/mechanisms.py b/bittensor_cli/src/commands/subnets/mechanisms.py new file mode 100644 index 000000000..2ad5d72db --- /dev/null +++ b/bittensor_cli/src/commands/subnets/mechanisms.py @@ -0,0 +1,498 @@ +import asyncio +import math +from typing import TYPE_CHECKING, Optional + +from bittensor_wallet import Wallet +from rich.prompt import Confirm, Prompt +from rich.table import Column, Table +from rich import box + +from bittensor_cli.src import COLOR_PALETTE +from bittensor_cli.src.commands import sudo +from bittensor_cli.src.bittensor.utils import ( + console, + err_console, + json_console, + U16_MAX, + print_extrinsic_id, +) + +if TYPE_CHECKING: + from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface + + +async def count( + subtensor: "SubtensorInterface", + netuid: int, + json_output: bool = False, +) -> Optional[int]: + """Display how many mechanisms exist for the provided subnet.""" + + block_hash = await subtensor.substrate.get_chain_head() + if not await subtensor.subnet_exists(netuid=netuid, block_hash=block_hash): + err_console.print(f"[red]Subnet {netuid} does not exist[/red]") + if json_output: + json_console.print_json( + data={"success": False, "error": f"Subnet {netuid} does not exist"} + ) + return None + + with console.status( + f":satellite:Retrieving mechanism count from {subtensor.network}...", + spinner="aesthetic", + ): + mechanism_count = await subtensor.get_subnet_mechanisms( + netuid, block_hash=block_hash + ) + if not mechanism_count: + if json_output: + json_console.print_json( + data={ + "netuid": netuid, + "count": None, + "error": "Failed to get mechanism count", + } + ) + else: + err_console.print( + "Subnet mechanism count: [red]Failed to get mechanism count[/red]" + ) + return None + + if json_output: + json_console.print_json( + data={ + "netuid": netuid, + "count": mechanism_count, + "error": "", + } + ) + else: + console.print( + f"[blue]Subnet {netuid}[/blue] currently has [blue]{mechanism_count}[/blue] mechanism" + f"{'s' if mechanism_count != 1 else ''}." + f"\n[dim](Tip: 1 mechanism means there are no mechanisms beyond the main subnet)[/dim]" + ) + + return mechanism_count + + +async def get_emission_split( + subtensor: "SubtensorInterface", + netuid: int, + json_output: bool = False, +) -> Optional[dict]: + """Display the emission split across mechanisms for a subnet.""" + + count_ = await subtensor.get_subnet_mechanisms(netuid) + if count_ == 1: + console.print( + f"Subnet {netuid} only has the primary mechanism (mechanism 0). No emission split to display." + ) + if json_output: + json_console.print_json( + data={ + "success": False, + "error": "Subnet only has the primary mechanism (mechanism 0). No emission split to display.", + } + ) + return None + + emission_split = await subtensor.get_mechanism_emission_split(netuid) or [] + + even_distribution = False + total_sum = sum(emission_split) + if total_sum == 0 and count_ > 0: + even_distribution = True + base, remainder = divmod(U16_MAX, count_) + emission_split = [base for _ in range(count_)] + if remainder: + emission_split[0] += remainder + total_sum = sum(emission_split) + + emission_percentages = ( + [round((value / total_sum) * 100, 6) for value in emission_split] + if total_sum > 0 + else [0.0 for _ in emission_split] + ) + + data = { + "netuid": netuid, + "raw_count": count_, + "visible_count": max(count_ - 1, 0), + "split": emission_split if count_ else [], + "percentages": emission_percentages if count_ else [], + "even_distribution": even_distribution, + } + + if json_output: + json_console.print_json(data=data) + else: + table = Table( + Column( + "[bold white]Mechanism Index[/]", + justify="center", + style=COLOR_PALETTE.G.NETUID, + ), + Column( + "[bold white]Weight (u16)[/]", + justify="right", + style=COLOR_PALETTE.STAKE.STAKE_ALPHA, + ), + Column( + "[bold white]Share (%)[/]", + justify="right", + style=COLOR_PALETTE.POOLS.EMISSION, + ), + title=f"\n[{COLOR_PALETTE.G.HEADER}]Subnet {netuid} • Emission split[/]\n" + f"[{COLOR_PALETTE.G.SUBHEAD}]Network: {subtensor.network}[/{COLOR_PALETTE.G.SUBHEAD}]", + box=box.SIMPLE, + show_footer=True, + border_style="bright_black", + ) + + total_weight = sum(emission_split) + share_percent = (total_weight / U16_MAX) * 100 if U16_MAX else 0 + + for idx, value in enumerate(emission_split): + share = ( + emission_percentages[idx] if idx < len(emission_percentages) else 0.0 + ) + table.add_row(str(idx), str(value), f"{share:.6f}") + + table.add_row( + "[dim]Total[/dim]", + f"[{COLOR_PALETTE.STAKE.STAKE_ALPHA}]{total_weight}[/{COLOR_PALETTE.STAKE.STAKE_ALPHA}]", + f"[{COLOR_PALETTE.POOLS.EMISSION}]{share_percent:.6f}[/{COLOR_PALETTE.POOLS.EMISSION}]", + ) + + console.print(table) + footer = "[dim]Totals are expressed as a fraction of 65535 (U16_MAX).[/dim]" + if even_distribution: + footer += ( + "\n[dim]No custom split found; displaying an even distribution.[/dim]" + ) + console.print(footer) + + return data + + +async def set_emission_split( + subtensor: "SubtensorInterface", + wallet: Wallet, + netuid: int, + new_emission_split: Optional[str], + wait_for_inclusion: bool, + wait_for_finalization: bool, + prompt: bool, + json_output: bool, +) -> bool: + """Set the emission split across mechanisms for a subnet.""" + + mech_count, existing_split = await asyncio.gather( + subtensor.get_subnet_mechanisms(netuid), + subtensor.get_mechanism_emission_split(netuid), + ) + + if mech_count == 0: + message = ( + f"Subnet {netuid} does not currently contain any mechanisms to configure." + ) + if json_output: + json_console.print_json(data={"success": False, "error": message}) + else: + err_console.print(message) + return False + + if not json_output: + await get_emission_split( + subtensor=subtensor, + netuid=netuid, + json_output=False, + ) + + existing_split = [int(value) for value in existing_split] + if len(existing_split) < mech_count: + existing_split.extend([0] * (mech_count - len(existing_split))) + + if new_emission_split is not None: + try: + weights = [ + float(item.strip()) + for item in new_emission_split.split(",") + if item.strip() != "" + ] + except ValueError: + message = ( + "Invalid `--split` values. Provide a comma-separated list of numbers." + ) + if json_output: + json_console.print_json(data={"success": False, "error": message}) + else: + err_console.print(message) + return False + else: + if not prompt: + err_console.print( + "Split values not supplied with `--no-prompt` flag. Cannot continue." + ) + return False + + weights: list[float] = [] + total_existing = sum(existing_split) or 1 + console.print("\n[dim]You either provide U16 values or percentages.[/dim]") + for idx in range(mech_count): + current_value = existing_split[idx] + current_percent = ( + (current_value / total_existing) * 100 if total_existing else 0 + ) + label = ( + "[blue]Main Mechanism (0)[/blue]" + if idx == 0 + else f"[blue]Mechanism {idx}[/blue]" + ) + response = Prompt.ask( + ( + f"Relative weight for {label} " + f"[{COLOR_PALETTE.STAKE.STAKE_ALPHA}](current: {current_value} ~ {current_percent:.2f}%)[/{COLOR_PALETTE.STAKE.STAKE_ALPHA}]" + ) + ) + try: + weights.append(float(response)) + except ValueError: + err_console.print("Invalid number provided. Aborting.") + return False + + if len(weights) != mech_count: + message = f"Expected {mech_count} weight values, received {len(weights)}." + if json_output: + json_console.print_json(data={"success": False, "error": message}) + else: + err_console.print(message) + return False + + if any(value < 0 for value in weights): + message = "Weights must be non-negative." + if json_output: + json_console.print_json(data={"success": False, "error": message}) + else: + err_console.print(message) + return False + + try: + normalized_weights, fractions = _normalize_emission_weights(weights) + except ValueError as exc: + message = str(exc) + if json_output: + json_console.print_json(data={"success": False, "error": message}) + else: + err_console.print(message) + return False + + if normalized_weights == existing_split: + message = ":white_heavy_check_mark: [dark_sea_green3]Emission split unchanged.[/dark_sea_green3]" + if json_output: + json_console.print_json( + data={ + "success": True, + "message": "Emission split unchanged.", + "split": normalized_weights, + "percentages": [round(value * 100, 6) for value in fractions], + "extrinsic_identifier": None, + } + ) + else: + console.print(message) + return True + + if not json_output: + table = Table( + Column( + "[bold white]Mechanism Index[/]", + justify="center", + style=COLOR_PALETTE.G.NETUID, + ), + Column( + "[bold white]Weight (u16)[/]", + justify="right", + style=COLOR_PALETTE.STAKE.STAKE_ALPHA, + ), + Column( + "[bold white]Share (%)[/]", + justify="right", + style=COLOR_PALETTE.POOLS.EMISSION, + ), + title=( + f"\n[{COLOR_PALETTE.G.HEADER}]Proposed emission split[/{COLOR_PALETTE.G.HEADER}]\n" + f"[{COLOR_PALETTE.G.SUBHEAD}]Subnet {netuid}[/{COLOR_PALETTE.G.SUBHEAD}]" + ), + box=box.SIMPLE, + show_footer=True, + border_style="bright_black", + ) + + total_weight = sum(normalized_weights) + total_share_percent = (total_weight / U16_MAX) * 100 if U16_MAX else 0 + + for idx, weight in enumerate(normalized_weights): + share_percent = fractions[idx] * 100 if idx < len(fractions) else 0.0 + table.add_row(str(idx), str(weight), f"{share_percent:.6f}") + + table.add_row("", "", "", style="dim") + table.add_row( + "[dim]Total[/dim]", + f"[{COLOR_PALETTE.STAKE.STAKE_ALPHA}]{total_weight}[/{COLOR_PALETTE.STAKE.STAKE_ALPHA}]", + f"[{COLOR_PALETTE.POOLS.EMISSION}]{total_share_percent:.6f}[/{COLOR_PALETTE.POOLS.EMISSION}]", + ) + + console.print(table) + + if not Confirm.ask("Proceed with these emission weights?", default=True): + console.print(":cross_mark: Aborted!") + return False + + success, err_msg, ext_id = await set_mechanism_emission( + wallet=wallet, + subtensor=subtensor, + netuid=netuid, + split=normalized_weights, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + json_output=json_output, + ) + + if json_output: + json_console.print_json( + data={ + "success": success, + "err_msg": err_msg, + "split": normalized_weights, + "percentages": [round(value * 100, 6) for value in fractions], + "extrinsic_identifier": ext_id, + } + ) + + return success + + +def _normalize_emission_weights(values: list[float]) -> tuple[list[int], list[float]]: + total = sum(values) + if total <= 0: + raise ValueError("Sum of emission weights must be greater than zero.") + + fractions = [value / total for value in values] + scaled = [fraction * U16_MAX for fraction in fractions] + base = [math.floor(value) for value in scaled] + remainder = int(U16_MAX - sum(base)) + + if remainder > 0: + fractional_parts = [value - math.floor(value) for value in scaled] + order = sorted( + range(len(base)), key=lambda idx_: fractional_parts[idx_], reverse=True + ) + idx = 0 + length = len(order) + while remainder > 0 and length > 0: + base[order[idx % length]] += 1 + remainder -= 1 + idx += 1 + + return [int(value) for value in base], fractions + + +async def set_mechanism_count( + wallet: Wallet, + subtensor: "SubtensorInterface", + netuid: int, + mechanism_count: int, + previous_count: int, + wait_for_inclusion: bool, + wait_for_finalization: bool, + json_output: bool, +) -> tuple[bool, str, Optional[str]]: + """Set the number of mechanisms for a subnet.""" + + if mechanism_count < 1: + err_msg = "Mechanism count must be greater than or equal to one." + if not json_output: + err_console.print(err_msg) + return False, err_msg, None + + if not await subtensor.subnet_exists(netuid): + err_msg = f"Subnet with netuid {netuid} does not exist." + if not json_output: + err_console.print(err_msg) + return False, err_msg, None + + if not Confirm.ask( + f"Subnet [blue]{netuid}[/blue] currently has [blue]{previous_count}[/blue] mechanism" + f"{'s' if previous_count != 1 else ''}." + f" Set it to [blue]{mechanism_count}[/blue]?" + ): + return False, "User cancelled", None + + success, err_msg, ext_receipt = await sudo.set_mechanism_count_extrinsic( + subtensor=subtensor, + wallet=wallet, + netuid=netuid, + mech_count=mechanism_count, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + ext_id = await ext_receipt.get_extrinsic_identifier() if success else None + + if json_output: + return success, err_msg, ext_id + + if success: + await print_extrinsic_id(ext_receipt) + console.print( + ":white_heavy_check_mark: " + f"[dark_sea_green3]Mechanism count set to {mechanism_count} for subnet {netuid}[/dark_sea_green3]" + ) + else: + err_console.print(f":cross_mark: [red]{err_msg}[/red]") + + return success, err_msg, ext_id + + +async def set_mechanism_emission( + wallet: Wallet, + subtensor: "SubtensorInterface", + netuid: int, + split: list[int], + wait_for_inclusion: bool, + wait_for_finalization: bool, + json_output: bool, +) -> tuple[bool, str, Optional[str]]: + """Set the emission split for mechanisms within a subnet.""" + + if not split: + err_msg = "Emission split must include at least one weight." + if not json_output: + err_console.print(err_msg) + return False, err_msg, None + + success, err_msg, ext_receipt = await sudo.set_mechanism_emission_extrinsic( + subtensor=subtensor, + wallet=wallet, + netuid=netuid, + split=split, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + ext_id = await ext_receipt.get_extrinsic_identifier() if success else None + + if json_output: + return success, err_msg, ext_id + + if success: + await print_extrinsic_id(ext_receipt) + console.print( + ":white_heavy_check_mark: " + f"[dark_sea_green3]Emission split updated for subnet {netuid}[/dark_sea_green3]" + ) + else: + err_console.print(f":cross_mark: [red]{err_msg}[/red]") + + return success, err_msg, ext_id diff --git a/bittensor_cli/src/commands/subnets/price.py b/bittensor_cli/src/commands/subnets/price.py index 461ed4325..8e488a95c 100644 --- a/bittensor_cli/src/commands/subnets/price.py +++ b/bittensor_cli/src/commands/subnets/price.py @@ -10,6 +10,7 @@ import plotly.graph_objects as go from bittensor_cli.src import COLOR_PALETTE +from bittensor_cli.src.bittensor.chain_data import DynamicInfo from bittensor_cli.src.bittensor.utils import ( console, err_console, @@ -27,7 +28,8 @@ async def price( subtensor: "SubtensorInterface", netuids: list[int], all_netuids: bool = False, - interval_hours: int = 24, + interval_hours: int = 4, + current_only: bool = False, html_output: bool = False, log_scale: bool = False, json_output: bool = False, @@ -41,45 +43,106 @@ async def price( blocks_per_hour = int(3600 / 12) # ~300 blocks per hour total_blocks = blocks_per_hour * interval_hours - with console.status(":chart_increasing: Fetching historical price data..."): - current_block_hash = await subtensor.substrate.get_chain_head() - current_block = await subtensor.substrate.get_block_number(current_block_hash) - - step = 300 - start_block = max(0, current_block - total_blocks) - block_numbers = list(range(start_block, current_block + 1, step)) - - # Block hashes - block_hash_cors = [ - subtensor.substrate.get_block_hash(bn) for bn in block_numbers - ] - block_hashes = await asyncio.gather(*block_hash_cors) + if not current_only: + with console.status(":chart_increasing: Fetching historical price data..."): + current_block_hash = await subtensor.substrate.get_chain_head() + current_block = await subtensor.substrate.get_block_number( + current_block_hash + ) - # We fetch all subnets when there is more than one netuid - if all_netuids or len(netuids) > 1: - subnet_info_cors = [subtensor.all_subnets(bh) for bh in block_hashes] - else: - # If there is only one netuid, we fetch the subnet info for that netuid - netuid = netuids[0] - subnet_info_cors = [subtensor.subnet(netuid, bh) for bh in block_hashes] - all_subnet_infos = await asyncio.gather(*subnet_info_cors) + step = 300 + start_block = max(0, current_block - total_blocks) + + # snap start block down to nearest multiple of 10 + start_block -= start_block % 10 + + block_numbers = [] + for b in range(start_block, current_block + 1, step): + if b == current_block: + block_numbers.append(b) # exact current block + else: + block_numbers.append(b - (b % 5)) # snap down to multiple of 10 + block_numbers = sorted(set(block_numbers)) + + # Block hashes + block_hash_cors = [ + subtensor.substrate.get_block_hash(bn) for bn in block_numbers + ] + block_hashes = await asyncio.gather(*block_hash_cors) + + # We fetch all subnets when there is more than one netuid + if all_netuids or len(netuids) > 1: + subnet_info_cors = [subtensor.all_subnets(bh) for bh in block_hashes] + else: + # If there is only one netuid, we fetch the subnet info for that netuid + netuid = netuids[0] + subnet_info_cors = [subtensor.subnet(netuid, bh) for bh in block_hashes] + all_subnet_infos = await asyncio.gather(*subnet_info_cors) subnet_data = _process_subnet_data( block_numbers, all_subnet_infos, netuids, all_netuids ) + if not subnet_data: + err_console.print("[red]No valid price data found for any subnet[/red]") + return - if not subnet_data: - err_console.print("[red]No valid price data found for any subnet[/red]") - return - - if html_output: - await _generate_html_output( - subnet_data, block_numbers, interval_hours, log_scale + if html_output: + 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) + else: + with console.status("Fetching current price data..."): + if all_netuids or len(netuids) > 1: + all_subnet_info = await subtensor.all_subnets() + else: + all_subnet_info = [await subtensor.subnet(netuid=netuids[0])] + subnet_data = _process_current_subnet_data( + all_subnet_info, netuids, all_netuids ) - elif json_output: - json_console.print(json.dumps(_generate_json_output(subnet_data))) + if json_output: + json_console.print(json.dumps(_generate_json_output(subnet_data))) + else: + _generate_cli_output_current(subnet_data) + + +def _process_current_subnet_data(subnet_infos: list[DynamicInfo], netuids, all_netuids): + subnet_data = {} + if all_netuids or len(netuids) > 1: + # Most recent data for statistics + for subnet_info in subnet_infos: + stats = { + "current_price": subnet_info.price, + "supply": subnet_info.alpha_in.tao + subnet_info.alpha_out.tao, + "market_cap": subnet_info.price.tao + * (subnet_info.alpha_in.tao + subnet_info.alpha_out.tao), + "emission": subnet_info.emission.tao, + "stake": subnet_info.alpha_out.tao, + "symbol": subnet_info.symbol, + "name": get_subnet_name(subnet_info), + } + subnet_data[subnet_info.netuid] = { + "stats": stats, + } else: - _generate_cli_output(subnet_data, block_numbers, interval_hours, log_scale) + subnet_info = subnet_infos[0] + stats = { + "current_price": subnet_info.price.tao, + "supply": subnet_info.alpha_in.tao + subnet_info.alpha_out.tao, + "market_cap": subnet_info.price.tao + * (subnet_info.alpha_in.tao + subnet_info.alpha_out.tao), + "emission": subnet_info.emission.tao, + "stake": subnet_info.alpha_out.tao, + "symbol": subnet_info.symbol, + "name": get_subnet_name(subnet_info), + } + subnet_data[subnet_info.netuid] = { + "stats": stats, + } + return subnet_data def _process_subnet_data(block_numbers, all_subnet_infos, netuids, all_netuids): @@ -626,3 +689,46 @@ def color_label(text): ) console.print(stats_text) + + +def _generate_cli_output_current(subnet_data): + for netuid, data in subnet_data.items(): + stats = data["stats"] + + if netuid != 0: + console.print( + f"\n[{COLOR_PALETTE.G.SYM}]Subnet {netuid} - {stats['symbol']} " + f"[cyan]{stats['name']}[/cyan][/{COLOR_PALETTE.G.SYM}]\n" + f"Current: [blue]{stats['current_price']:.6f}{stats['symbol']}[/blue]\n" + ) + else: + console.print( + f"\n[{COLOR_PALETTE.G.SYM}]Subnet {netuid} - {stats['symbol']} " + f"[cyan]{stats['name']}[/cyan][/{COLOR_PALETTE.G.SYM}]\n" + f"Current: [blue]{stats['symbol']} {stats['current_price']:.6f}[/blue]\n" + ) + + if netuid != 0: + stats_text = ( + "\nLatest stats:\n" + f"Supply: [{COLOR_PALETTE.P.ALPHA_IN}]" + f"{stats['supply']:,.2f} {stats['symbol']}[/{COLOR_PALETTE.P.ALPHA_IN}]\n" + f"Market Cap: [steel_blue3]{stats['market_cap']:,.2f} {stats['symbol']} / 21M[/steel_blue3]\n" + f"Emission: [{COLOR_PALETTE.P.EMISSION}]" + f"{stats['emission']:,.2f} {stats['symbol']}[/{COLOR_PALETTE.P.EMISSION}]\n" + f"Stake: [{COLOR_PALETTE.S.TAO}]" + f"{stats['stake']:,.2f} {stats['symbol']}[/{COLOR_PALETTE.S.TAO}]" + ) + else: + stats_text = ( + "\nLatest stats:\n" + f"Supply: [{COLOR_PALETTE.P.ALPHA_IN}]" + f"{stats['symbol']} {stats['supply']:,.2f}[/{COLOR_PALETTE.P.ALPHA_IN}]\n" + f"Market Cap: [steel_blue3]{stats['symbol']} {stats['market_cap']:,.2f} / 21M[/steel_blue3]\n" + f"Emission: [{COLOR_PALETTE.P.EMISSION}]" + f"{stats['symbol']} {stats['emission']:,.2f}[/{COLOR_PALETTE.P.EMISSION}]\n" + f"Stake: [{COLOR_PALETTE.S.TAO}]" + f"{stats['symbol']} {stats['stake']:,.2f}[/{COLOR_PALETTE.S.TAO}]" + ) + + console.print(stats_text) diff --git a/bittensor_cli/src/commands/subnets/subnets.py b/bittensor_cli/src/commands/subnets/subnets.py index 62a20b63b..c2346880b 100644 --- a/bittensor_cli/src/commands/subnets/subnets.py +++ b/bittensor_cli/src/commands/subnets/subnets.py @@ -36,6 +36,9 @@ unlock_key, blocks_to_duration, json_console, + get_hotkey_pub_ss58, + print_extrinsic_id, + check_img_mimetype, ) if TYPE_CHECKING: @@ -53,7 +56,7 @@ async def register_subnetwork_extrinsic( wait_for_inclusion: bool = False, wait_for_finalization: bool = True, prompt: bool = False, -) -> tuple[bool, Optional[int]]: +) -> tuple[bool, Optional[int], Optional[str]]: """Registers a new subnetwork. wallet (bittensor.wallet): @@ -65,9 +68,11 @@ async def register_subnetwork_extrinsic( prompt (bool): If true, the call waits for confirmation from the user before proceeding. Returns: - success (bool): - Flag is ``true`` if extrinsic was finalized or included in the block. - If we did not wait for finalization / inclusion, the response is ``true``. + tuple including: + success: Flag is `True` if extrinsic was finalized or included in the block. + If we did not wait for finalization/inclusion, the response is `True`. + error_message: Optional error message. + extrinsic_identifier: Optional extrinsic identifier, if the extrinsic was included. """ async def _find_event_attributes_in_extrinsic_receipt( @@ -102,7 +107,7 @@ async def _find_event_attributes_in_extrinsic_receipt( f"[{COLOR_PALETTE['POOLS']['TAO']}]{sn_burn_cost}[{COLOR_PALETTE['POOLS']['TAO']}] " f"to register a subnet." ) - return False, None + return False, None, None if prompt: console.print( @@ -111,10 +116,10 @@ async def _find_event_attributes_in_extrinsic_receipt( if not Confirm.ask( f"Do you want to burn [{COLOR_PALETTE['POOLS']['TAO']}]{sn_burn_cost} to register a subnet?" ): - return False, None + return False, None, None call_params = { - "hotkey": wallet.hotkey.ss58_address, + "hotkey": get_hotkey_pub_ss58(wallet), "mechid": 1, } call_function = "register_network" @@ -156,10 +161,10 @@ async def _find_event_attributes_in_extrinsic_receipt( f"[red]Error:[/red] Identity field [white]{field}[/white] must be <= {max_size} bytes.\n" f"Value '{value.decode()}' is {len(value)} bytes." ) - return False, None + return False, None, None if not unlock_key(wallet).success: - return False, None + return False, None, None with console.status(":satellite: Registering subnet...", spinner="earth"): substrate = subtensor.substrate @@ -180,24 +185,26 @@ async def _find_event_attributes_in_extrinsic_receipt( # We only wait here if we expect finalization. if not wait_for_finalization and not wait_for_inclusion: - return True, None + return True, None, None if not await response.is_success: err_console.print( f":cross_mark: [red]Failed[/red]: {format_error_message(await response.error_message)}" ) await asyncio.sleep(0.5) - return False, None + return False, None, None # Successful registration, final check for membership else: attributes = await _find_event_attributes_in_extrinsic_receipt( response, "NetworkAdded" ) + await print_extrinsic_id(response) + ext_id = await response.get_extrinsic_identifier() console.print( f":white_heavy_check_mark: [dark_sea_green3]Registered subnetwork with netuid: {attributes[0]}" ) - return True, int(attributes[0]) + return True, int(attributes[0]), ext_id # commands @@ -215,8 +222,13 @@ async def subnets_list( """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_hash = await subtensor.substrate.get_chain_head() + subnets_, mechanisms, block_number_, ema_tao_inflow = await asyncio.gather( + subtensor.all_subnets(block_hash=block_hash), + subtensor.get_all_subnet_mechanisms(block_hash=block_hash), + subtensor.substrate.get_block_number(block_hash=block_hash), + subtensor.get_all_subnet_ema_tao_inflow(block_hash=block_hash), + ) # 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) @@ -226,7 +238,7 @@ async def fetch_subnet_data(): reverse=True, ) sorted_subnets = [root_subnet] + other_subnets - return sorted_subnets, block_number_ + return sorted_subnets, block_number_, mechanisms, ema_tao_inflow def calculate_emission_stats( subnets_: list, block_number_: int @@ -253,6 +265,7 @@ def define_table( total_rate: float, total_netuids: int, tao_emission_percentage: str, + total_tao_flow_ema: float, ): defined_table = Table( title=f"\n[{COLOR_PALETTE['GENERAL']['HEADER']}]Subnets" @@ -291,6 +304,12 @@ def define_table( justify="left", footer=f"τ {total_emissions}", ) + defined_table.add_column( + f"[bold white]Net Inflow EMA ({Balance.get_unit(0)})", + style=COLOR_PALETTE["POOLS"]["ALPHA_OUT"], + justify="left", + footer=f"τ {total_tao_flow_ema}", + ) defined_table.add_column( f"[bold white]P ({Balance.get_unit(0)}_in, {Balance.get_unit(1)}_in)", style=COLOR_PALETTE["STAKE"]["TAO"], @@ -314,10 +333,15 @@ def define_table( justify="left", overflow="fold", ) + defined_table.add_column( + "[bold white]Mechanisms", + style=COLOR_PALETTE["GENERAL"]["SUBHEADING_EXTRA_1"], + justify="center", + ) return defined_table # Non-live mode - def _create_table(subnets_, block_number_): + def _create_table(subnets_, block_number_, mechanisms, ema_tao_inflow): rows = [] _, percentage_string = calculate_emission_stats(subnets_, block_number_) @@ -383,6 +407,13 @@ def _create_table(subnets_, block_number_): f" {get_subnet_name(subnet)}" ) emission_cell = f"τ {emission_tao:,.4f}" + + if netuid in ema_tao_inflow: + ema_value = ema_tao_inflow[netuid].tao + else: + ema_value = 0.0 + ema_flow_cell = f"τ {ema_value:,.4f}" + price_cell = f"{price_value} τ/{symbol}" alpha_out_cell = ( f"{alpha_out_value} {symbol}" @@ -397,6 +428,8 @@ def _create_table(subnets_, block_number_): else: tempo_cell = "-/-" + mechanisms_cell = str(mechanisms.get(netuid, 1)) + rows.append( ( netuid_cell, # Netuid @@ -404,10 +437,12 @@ def _create_table(subnets_, block_number_): price_cell, # Rate τ_in/α_in market_cap_cell, # Market Cap emission_cell, # Emission (τ) + ema_flow_cell, # EMA TAO Inflow (τ) liquidity_cell, # Liquidity (t_in, a_in) alpha_out_cell, # Stake α_out supply_cell, # Supply tempo_cell, # Tempo k/n + mechanisms_cell, # Mechanism count ) ) @@ -417,22 +452,30 @@ def _create_table(subnets_, block_number_): ), 4, ) + total_tao_flow_ema = round( + sum(inflow.tao for inflow in ema_tao_inflow.values()), 4 + ) total_rate = round( sum(float(subnet.price.tao) for subnet in subnets_ if subnet.netuid != 0), 4 ) total_netuids = len(subnets_) defined_table = define_table( - total_emissions, total_rate, total_netuids, percentage_string + total_emissions, + total_rate, + total_netuids, + percentage_string, + total_tao_flow_ema, ) for row in rows: defined_table.add_row(*row) return defined_table - def dict_table(subnets_, block_number_) -> dict: + def dict_table(subnets_, block_number_, mechanisms, ema_tao_inflow) -> dict: subnet_rows = {} total_tao_emitted, _ = calculate_emission_stats(subnets_, block_number_) total_emissions = 0.0 + total_tao_flow_ema = 0.0 total_rate = 0.0 total_netuids = len(subnets_) emission_percentage = (total_tao_emitted / block_number_) * 100 @@ -459,16 +502,22 @@ def dict_table(subnets_, block_number_) -> dict: ), "sn_tempo": (subnet.tempo if netuid != 0 else None), } + tao_flow_ema = None + if netuid in ema_tao_inflow: + tao_flow_ema = ema_tao_inflow[netuid].tao + total_tao_flow_ema += tao_flow_ema.tao subnet_rows[netuid] = { "netuid": netuid, "subnet_name": subnet_name, "price": price_value, "market_cap": market_cap, "emission": emission_tao, + "tao_flow_ema": tao_flow_ema, "liquidity": {"tao_in": tao_in, "alpha_in": alpha_in}, "alpha_out": alpha_out, "supply": supply, "tempo": tempo, + "mechanisms": mechanisms.get(netuid, 1), } output = { "total_tao_emitted": total_tao_emitted, @@ -476,12 +525,15 @@ def dict_table(subnets_, block_number_) -> dict: "total_rate": total_rate, "total_netuids": total_netuids, "emission_percentage": emission_percentage, + "total_tao_flow_ema": total_tao_flow_ema, "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_, mechanisms, ema_tao_inflow + ): def format_cell( value, previous_value, unit="", unit_first=False, precision=4, millify=False ): @@ -599,10 +651,15 @@ def format_liquidity_cell( market_cap = (subnet.alpha_in.tao + subnet.alpha_out.tao) * subnet.price.tao supply = subnet.alpha_in.tao + subnet.alpha_out.tao + tao_flow_ema = 0.0 + if netuid in ema_tao_inflow: + tao_flow_ema = ema_tao_inflow[netuid].tao + # Store current values for comparison current_data[netuid] = { "market_cap": market_cap, "emission_tao": emission_tao, + "tao_flow_ema": tao_flow_ema, "alpha_out": subnet.alpha_out.tao, "tao_in": subnet.tao_in.tao, "alpha_in": subnet.alpha_in.tao, @@ -630,6 +687,15 @@ def format_liquidity_cell( unit_first=True, precision=4, ) + + tao_flow_ema_cell = format_cell( + tao_flow_ema, + prev.get("tao_flow_ema"), + unit="τ", + unit_first=True, + precision=4, + ) + price_cell = format_cell( subnet.price.tao, prev.get("price"), @@ -713,10 +779,12 @@ def format_liquidity_cell( price_cell, # Rate τ_in/α_in market_cap_cell, # Market Cap emission_cell, # Emission (τ) + tao_flow_ema_cell, # EMA TAO Inflow (τ) liquidity_cell, # Liquidity (t_in, a_in) alpha_out_cell, # Stake α_out supply_cell, # Supply tempo_cell, # Tempo k/n + str(mechanisms.get(netuid, 1)), # Mechanisms ) ) @@ -730,13 +798,22 @@ def format_liquidity_cell( if not verbose else f"{_total_emissions:,.2f}" ) - + _total_tao_flow_ema = sum(inflow.tao for inflow in ema_tao_inflow.values()) + total_tao_flow_ema = ( + f"{millify_tao(_total_tao_flow_ema)}" + if not verbose + else f"{_total_tao_flow_ema:,.2f}" + ) 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}" ) table = define_table( - total_emissions, total_rate, total_netuids, percentage_string + total_emissions, + total_rate, + total_netuids, + percentage_string, + total_tao_flow_ema, ) for row in rows: @@ -763,7 +840,12 @@ def format_liquidity_cell( with Live(console=console, screen=True, auto_refresh=True) as live: try: while True: - subnets, block_number = await fetch_subnet_data() + ( + subnets, + block_number, + mechanisms, + ema_tao_inflow, + ) = await fetch_subnet_data() # Update block numbers previous_block = current_block @@ -775,7 +857,7 @@ def format_liquidity_cell( ) table, current_data = create_table_live( - subnets, previous_data, block_number + subnets, previous_data, block_number, mechanisms, ema_tao_inflow ) previous_data = current_data progress.reset(progress_task) @@ -801,11 +883,15 @@ def format_liquidity_cell( pass # Ctrl + C else: # Non-live mode - subnets, block_number = await fetch_subnet_data() + subnets, block_number, mechanisms, ema_tao_inflow = await fetch_subnet_data() if json_output: - json_console.print(json.dumps(dict_table(subnets, block_number))) + json_console.print( + json.dumps( + dict_table(subnets, block_number, mechanisms, ema_tao_inflow) + ) + ) else: - table = _create_table(subnets, block_number) + table = _create_table(subnets, block_number, mechanisms, ema_tao_inflow) console.print(table) return @@ -871,6 +957,8 @@ def format_liquidity_cell( async def show( subtensor: "SubtensorInterface", netuid: int, + mechanism_id: Optional[int] = None, + mechanism_count: Optional[int] = None, sort: bool = False, max_rows: Optional[int] = None, delegate_selection: bool = False, @@ -880,14 +968,21 @@ async def show( ) -> Optional[str]: async def show_root(): # TODO json_output for this, don't forget - block_hash = await subtensor.substrate.get_chain_head() - - 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), - ) + with console.status(":satellite: Retrieving root network information..."): + block_hash = await subtensor.substrate.get_chain_head() + ( + all_subnets, + root_state, + identities, + old_identities, + root_claim_types, + ) = await asyncio.gather( + subtensor.all_subnets(block_hash=block_hash), + subtensor.get_subnet_state(netuid=0, block_hash=block_hash), + subtensor.query_all_identities(block_hash=block_hash), + subtensor.get_delegate_identities(block_hash=block_hash), + subtensor.get_all_coldkeys_claim_type(block_hash=block_hash), + ) root_info = next((s for s in all_subnets if s.netuid == 0), None) if root_info is None: print_error("The root subnet does not exist") @@ -946,6 +1041,11 @@ async def show_root(): style=COLOR_PALETTE["GENERAL"]["SYMBOL"], justify="left", ) + table.add_column( + "[bold white]Claim Type", + style=COLOR_PALETTE["GENERAL"]["SUBHEADING"], + justify="center", + ) sorted_hotkeys = sorted( enumerate(root_state.hotkeys), @@ -976,6 +1076,9 @@ async def show_root(): else (hotkey_identity.display if hotkey_identity else "") ) + coldkey_ss58 = root_state.coldkeys[idx] + claim_type = root_claim_types.get(coldkey_ss58, "Swap") + sorted_rows.append( ( str((pos + 1)), # Position @@ -996,6 +1099,7 @@ async def show_root(): if not verbose else f"{root_state.coldkeys[idx]}", # Coldkey validator_identity, # Identity + claim_type, # Root Claim Type ) ) sorted_hks_delegation.append(root_state.hotkeys[idx]) @@ -1047,6 +1151,7 @@ async def show_root(): - Emission: The emission accrued to this hotkey across all subnets every block measured in TAO. - Hotkey: The hotkey ss58 address. - Coldkey: The coldkey ss58 address. + - Root Claim: The root claim type for this coldkey. 'Swap' converts Alpha to TAO every epoch. 'Keep' keeps Alpha emissions. """ ) if delegate_selection: @@ -1084,43 +1189,63 @@ async def show_root(): ) return selected_hotkey - async def show_subnet(netuid_: int): - if not await subtensor.subnet_exists(netuid=netuid): - err_console.print(f"[red]Subnet {netuid} does not exist[/red]") - return False - block_hash = await subtensor.substrate.get_chain_head() - ( - subnet_info, - subnet_state, - identities, - old_identities, - current_burn_cost, - ) = await asyncio.gather( - subtensor.subnet(netuid=netuid_, block_hash=block_hash), - subtensor.get_subnet_state(netuid=netuid_, block_hash=block_hash), - subtensor.query_all_identities(block_hash=block_hash), - subtensor.get_delegate_identities(block_hash=block_hash), - subtensor.get_hyperparameter( - param_name="Burn", netuid=netuid_, block_hash=block_hash - ), - ) - if subnet_state is None: - print_error(f"Subnet {netuid_} does not exist") + async def show_subnet( + netuid_: int, + mechanism_id: Optional[int], + mechanism_count: Optional[int], + ): + with console.status(":satellite: Retrieving subnet information..."): + block_hash = await subtensor.substrate.get_chain_head() + if not await subtensor.subnet_exists(netuid=netuid_, block_hash=block_hash): + err_console.print(f"[red]Subnet {netuid_} does not exist[/red]") + return False + ( + subnet_info, + identities, + old_identities, + current_burn_cost, + root_claim_types, + ema_tao_inflow, + ) = await asyncio.gather( + subtensor.subnet(netuid=netuid_, block_hash=block_hash), + subtensor.query_all_identities(block_hash=block_hash), + subtensor.get_delegate_identities(block_hash=block_hash), + subtensor.get_hyperparameter( + param_name="Burn", netuid=netuid_, block_hash=block_hash + ), + subtensor.get_all_coldkeys_claim_type(block_hash=block_hash), + subtensor.get_subnet_ema_tao_inflow( + netuid=netuid_, block_hash=block_hash + ), + ) + + selected_mechanism_id = mechanism_id or 0 + + metagraph_info = await subtensor.get_mechagraph_info( + netuid_, selected_mechanism_id, block_hash=block_hash + ) + + if metagraph_info is None: + print_error( + f"Subnet {netuid_} with mechanism: {selected_mechanism_id} does not exist" + ) return False if subnet_info is None: print_error(f"Subnet {netuid_} does not exist") return False - if len(subnet_state.hotkeys) == 0: + if len(metagraph_info.hotkeys) == 0: print_error(f"Subnet {netuid_} is currently empty with 0 UIDs registered.") return False # Define table properties + mechanism_label = f"Mechanism {selected_mechanism_id}" + table = Table( title=f"[{COLOR_PALETTE['GENERAL']['HEADER']}]Subnet [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{netuid_}" f"{': ' + get_subnet_name(subnet_info)}" - f"\nNetwork: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{subtensor.network}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}]\n", + f"\n[{COLOR_PALETTE['GENERAL']['SUBHEADING']}]Network: {subtensor.network} • {mechanism_label}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}]\n", show_footer=True, show_edge=False, header_style="bold white", @@ -1132,33 +1257,11 @@ async def show_subnet(netuid_: int): ) # For table footers - alpha_sum = sum( - [ - subnet_state.alpha_stake[idx].tao - for idx in range(len(subnet_state.alpha_stake)) - ] - ) - stake_sum = sum( - [ - subnet_state.total_stake[idx].tao - for idx in range(len(subnet_state.total_stake)) - ] - ) - tao_sum = sum( - [ - subnet_state.tao_stake[idx].tao * TAO_WEIGHT - for idx in range(len(subnet_state.tao_stake)) - ] - ) - dividends_sum = sum( - subnet_state.dividends[idx] for idx in range(len(subnet_state.dividends)) - ) - emission_sum = sum( - [ - subnet_state.emission[idx].tao - for idx in range(len(subnet_state.emission)) - ] - ) + alpha_sum = sum(stake.tao for stake in metagraph_info.alpha_stake) + stake_sum = sum(stake.tao for stake in metagraph_info.total_stake) + tao_sum = sum((stake * TAO_WEIGHT).tao for stake in metagraph_info.tao_stake) + dividends_sum = sum(metagraph_info.dividends) + emission_sum = sum(emission.tao for emission in metagraph_info.emission) owner_hotkeys = await subtensor.get_owned_hotkeys(subnet_info.owner_coldkey) if subnet_info.owner_hotkey not in owner_hotkeys: @@ -1173,7 +1276,7 @@ async def show_subnet(netuid_: int): break sorted_indices = sorted( - range(len(subnet_state.hotkeys)), + range(len(metagraph_info.hotkeys)), key=lambda i: ( # If sort is True, sort only by UIDs i @@ -1182,11 +1285,11 @@ async def show_subnet(netuid_: int): # Otherwise # Sort by owner status first not ( - subnet_state.coldkeys[i] == subnet_info.owner_coldkey - or subnet_state.hotkeys[i] in owner_hotkeys + metagraph_info.coldkeys[i] == subnet_info.owner_coldkey + or metagraph_info.hotkeys[i] in owner_hotkeys ), # Then sort by stake amount (higher stakes first) - -subnet_state.total_stake[i].tao, + -metagraph_info.total_stake[i].tao, ) ), ) @@ -1195,10 +1298,10 @@ async def show_subnet(netuid_: int): json_out_rows = [] for idx in sorted_indices: # Get identity for this uid - coldkey_identity = identities.get(subnet_state.coldkeys[idx], {}).get( + coldkey_identity = identities.get(metagraph_info.coldkeys[idx], {}).get( "name", "" ) - hotkey_identity = old_identities.get(subnet_state.hotkeys[idx]) + hotkey_identity = old_identities.get(metagraph_info.hotkeys[idx]) uid_identity = ( coldkey_identity if coldkey_identity @@ -1206,8 +1309,8 @@ async def show_subnet(netuid_: int): ) if ( - subnet_state.coldkeys[idx] == subnet_info.owner_coldkey - or subnet_state.hotkeys[idx] in owner_hotkeys + metagraph_info.coldkeys[idx] == subnet_info.owner_coldkey + or metagraph_info.hotkeys[idx] in owner_hotkeys ): if uid_identity == "~": uid_identity = ( @@ -1219,52 +1322,62 @@ async def show_subnet(netuid_: int): ) # Modify tao stake with TAO_WEIGHT - tao_stake = subnet_state.tao_stake[idx] * TAO_WEIGHT + tao_stake = metagraph_info.tao_stake[idx] * TAO_WEIGHT + + # Get claim type for this coldkey if applicable TAO stake + coldkey_ss58 = metagraph_info.coldkeys[idx] + if tao_stake.tao > 0: + claim_type = root_claim_types.get(coldkey_ss58, "Swap") + else: + claim_type = "-" + rows.append( ( str(idx), # UID - f"{subnet_state.total_stake[idx].tao:.4f} {subnet_info.symbol}" + f"{metagraph_info.total_stake[idx].tao:.4f} {subnet_info.symbol}" if verbose - else f"{millify_tao(subnet_state.total_stake[idx])} {subnet_info.symbol}", # Stake - f"{subnet_state.alpha_stake[idx].tao:.4f} {subnet_info.symbol}" + else f"{millify_tao(metagraph_info.total_stake[idx])} {subnet_info.symbol}", # Stake + f"{metagraph_info.alpha_stake[idx].tao:.4f} {subnet_info.symbol}" if verbose - else f"{millify_tao(subnet_state.alpha_stake[idx])} {subnet_info.symbol}", # Alpha Stake + else f"{millify_tao(metagraph_info.alpha_stake[idx])} {subnet_info.symbol}", # Alpha Stake f"τ {tao_stake.tao:.4f}" if verbose else f"τ {millify_tao(tao_stake)}", # Tao Stake - f"{subnet_state.dividends[idx]:.6f}", # Dividends - f"{subnet_state.incentives[idx]:.6f}", # Incentive - f"{Balance.from_tao(subnet_state.emission[idx].tao).set_unit(netuid_).tao:.6f} {subnet_info.symbol}", # Emissions - f"{subnet_state.hotkeys[idx][:6]}" + f"{metagraph_info.dividends[idx]:.6f}", # Dividends + f"{metagraph_info.incentives[idx]:.6f}", # Incentive + f"{Balance.from_tao(metagraph_info.emission[idx].tao).set_unit(netuid_).tao:.6f} {subnet_info.symbol}", # Emissions + f"{metagraph_info.hotkeys[idx][:6]}" if not verbose - else f"{subnet_state.hotkeys[idx]}", # Hotkey - f"{subnet_state.coldkeys[idx][:6]}" + else f"{metagraph_info.hotkeys[idx]}", # Hotkey + f"{metagraph_info.coldkeys[idx][:6]}" if not verbose - else f"{subnet_state.coldkeys[idx]}", # Coldkey + else f"{metagraph_info.coldkeys[idx]}", # Coldkey uid_identity, # Identity + claim_type, # Root Claim Type ) ) json_out_rows.append( { "uid": idx, - "stake": subnet_state.total_stake[idx].tao, - "alpha_stake": subnet_state.alpha_stake[idx].tao, + "stake": metagraph_info.total_stake[idx].tao, + "alpha_stake": metagraph_info.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) + "dividends": metagraph_info.dividends[idx], + "incentive": metagraph_info.incentives[idx], + "emissions": Balance.from_tao(metagraph_info.emission[idx].tao) .set_unit(netuid_) .tao, - "hotkey": subnet_state.hotkeys[idx], - "coldkey": subnet_state.coldkeys[idx], + "hotkey": metagraph_info.hotkeys[idx], + "coldkey": metagraph_info.coldkeys[idx], "identity": uid_identity, + "claim_type": claim_type, } ) # Add columns to the table table.add_column("UID", style="grey89", no_wrap=True, justify="center") table.add_column( - f"Stake ({Balance.get_unit(netuid_)})", + f"Stake ({subnet_info.symbol})", style=COLOR_PALETTE["POOLS"]["ALPHA_IN"], no_wrap=True, justify="right", @@ -1273,7 +1386,7 @@ async def show_subnet(netuid_: int): else f"{millify_tao(stake_sum)} {subnet_info.symbol}", ) table.add_column( - f"Alpha ({Balance.get_unit(netuid_)})", + f"Alpha ({subnet_info.symbol})", style=COLOR_PALETTE["POOLS"]["EXTRA_2"], no_wrap=True, justify="right", @@ -1299,11 +1412,11 @@ async def show_subnet(netuid_: int): ) table.add_column("Incentive", style="#5fd7ff", no_wrap=True, justify="center") table.add_column( - f"Emissions ({Balance.get_unit(netuid_)})", + f"Emissions ({subnet_info.symbol})", style=COLOR_PALETTE["POOLS"]["EMISSION"], no_wrap=True, justify="center", - footer=str(Balance.from_tao(emission_sum).set_unit(subnet_info.netuid)), + footer=f"{emission_sum:.4f} {subnet_info.symbol}", ) table.add_column( "Hotkey", @@ -1323,6 +1436,12 @@ async def show_subnet(netuid_: int): no_wrap=True, justify="left", ) + table.add_column( + "Claim Type", + style=COLOR_PALETTE["GENERAL"]["SUBHEADING"], + no_wrap=True, + justify="center", + ) for pos, row in enumerate(rows, 1): table_row = [] table_row.extend(row) @@ -1352,8 +1471,16 @@ async def show_subnet(netuid_: int): if current_burn_cost else Balance(0) ) + total_mechanisms = mechanism_count if mechanism_count is not None else 1 + output_dict = { "netuid": netuid_, + "mechanism_id": selected_mechanism_id, + **( + {"mechanism_count": mechanism_count} + if mechanism_count is not None + else {} + ), "name": subnet_name_display, "owner": subnet_info.owner_coldkey, "owner_identity": owner_identity, @@ -1371,11 +1498,25 @@ async def show_subnet(netuid_: int): if json_output: json_console.print(json.dumps(output_dict)) + mech_line = ( + f"\n Mechanism ID: [{COLOR_PALETTE['GENERAL']['SUBHEADING_EXTRA_1']}]#{selected_mechanism_id}" + f"[/{COLOR_PALETTE['GENERAL']['SUBHEADING_EXTRA_1']}]" + if total_mechanisms > 1 + else "" + ) + total_mech_line = ( + f"\n Total mechanisms: [{COLOR_PALETTE['GENERAL']['SUBHEADING_EXTRA_2']}]" + f"{total_mechanisms}[/{COLOR_PALETTE['GENERAL']['SUBHEADING_EXTRA_2']}]" + ) + console.print( f"[{COLOR_PALETTE['GENERAL']['SUBHEADING']}]Subnet {netuid_}{subnet_name_display}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}]" + f"{mech_line}" + f"{total_mech_line}" f"\n Owner: [{COLOR_PALETTE['GENERAL']['COLDKEY']}]{subnet_info.owner_coldkey}{' (' + owner_identity + ')' if owner_identity else ''}[/{COLOR_PALETTE['GENERAL']['COLDKEY']}]" f"\n Rate: [{COLOR_PALETTE['GENERAL']['HOTKEY']}]{subnet_info.price.tao:.4f} τ/{subnet_info.symbol}[/{COLOR_PALETTE['GENERAL']['HOTKEY']}]" - f"\n Emission: [{COLOR_PALETTE['GENERAL']['HOTKEY']}]τ {subnet_info.emission.tao:,.4f}[/{COLOR_PALETTE['GENERAL']['HOTKEY']}]" + f"\n EMA TAO Inflow: [{COLOR_PALETTE['STAKE']['TAO']}]τ {ema_tao_inflow.tao:.4f}[/{COLOR_PALETTE['STAKE']['TAO']}]" + f"\n Emission: [{COLOR_PALETTE['GENERAL']['HOTKEY']}]τ {subnet_info.tao_in_emission.tao:,.4f}[/{COLOR_PALETTE['GENERAL']['HOTKEY']}]" f"\n TAO Pool: [{COLOR_PALETTE['POOLS']['ALPHA_IN']}]τ {tao_pool}[/{COLOR_PALETTE['POOLS']['ALPHA_IN']}]" f"\n Alpha Pool: [{COLOR_PALETTE['POOLS']['ALPHA_IN']}]{alpha_pool} {subnet_info.symbol}[/{COLOR_PALETTE['POOLS']['ALPHA_IN']}]" # f"\n Stake: [{COLOR_PALETTE['STAKE']['STAKE_ALPHA']}]{subnet_info.alpha_out.tao:,.5f} {subnet_info.symbol}[/{COLOR_PALETTE['STAKE']['STAKE_ALPHA']}]" @@ -1418,7 +1559,7 @@ async def show_subnet(netuid_: int): # Check if the UID exists in the subnet if uid in [int(row[0]) for row in rows]: row_data = next(row for row in rows if int(row[0]) == uid) - hotkey = subnet_state.hotkeys[uid] + hotkey = metagraph_info.hotkeys[uid] identity = "" if row_data[9] == "~" else row_data[9] identity_str = f" ({identity})" if identity else "" console.print( @@ -1438,7 +1579,7 @@ async def show_subnet(netuid_: int): result = await show_root() return result else: - result = await show_subnet(netuid) + result = await show_subnet(netuid, mechanism_id, mechanism_count) return result @@ -1485,13 +1626,17 @@ async def create( """Register a subnetwork""" # Call register command. - success, netuid = await register_subnetwork_extrinsic( + success, netuid, ext_id = await register_subnetwork_extrinsic( subtensor, wallet, subnet_identity, prompt=prompt ) if json_output: # technically, netuid can be `None`, but only if not wait for finalization/inclusion. However, as of present # (2025/04/03), we always use the default `wait_for_finalization=True`, so it will always have a netuid. - json_console.print(json.dumps({"success": success, "netuid": netuid})) + json_console.print( + json.dumps( + {"success": success, "netuid": netuid, "extrinsic_identifier": ext_id} + ) + ) return success if success and prompt: # Prompt for user to set identity. @@ -1583,9 +1728,11 @@ async def register( err_console.print(f"[red]Subnet {netuid} does not exist[/red]") if json_output: json_console.print( - json.dumps( - {"success": False, "error": f"Subnet {netuid} does not exist"} - ) + data={ + "success": False, + "msg": f"Subnet {netuid} does not exist", + "extrinsic_identifier": None, + } ) return @@ -1603,9 +1750,12 @@ async def register( # Check balance is sufficient if balance < current_recycle: - err_console.print( - f"[red]Insufficient balance {balance} to register neuron. Current recycle is {current_recycle} TAO[/red]" - ) + err_msg = f"Insufficient balance {balance} to register neuron. Current recycle is {current_recycle} TAO" + err_console.print(f"[red]{err_msg}[/red]") + if json_output: + json_console.print_json( + data={"success": False, "msg": err_msg, "extrinsic_identifier": None} + ) return if prompt and not json_output: @@ -1654,7 +1804,7 @@ async def register( str(netuid), f"{Balance.get_unit(netuid)}", f"τ {current_recycle.tao:.4f}", - f"{wallet.hotkey.ss58_address}", + f"{get_hotkey_pub_ss58(wallet)}", f"{wallet.coldkeypub.ss58_address}", ) console.print(table) @@ -1670,9 +1820,9 @@ async def register( return if netuid == 0: - success, msg = await root_register_extrinsic(subtensor, wallet=wallet) + success, msg, ext_id = await root_register_extrinsic(subtensor, wallet=wallet) else: - success, msg = await burned_register_extrinsic( + success, msg, ext_id = await burned_register_extrinsic( subtensor, wallet=wallet, netuid=netuid, @@ -1680,7 +1830,9 @@ async def register( era=era, ) if json_output: - json_console.print(json.dumps({"success": success, "msg": msg})) + json_console.print( + json.dumps({"success": success, "msg": msg, "extrinsic_identifier": ext_id}) + ) else: if not success: err_console.print(f"Failure: {msg}") @@ -2206,12 +2358,33 @@ async def set_identity( netuid: int, subnet_identity: dict, prompt: bool = False, -) -> bool: +) -> tuple[bool, Optional[str]]: """Set identity information for a subnet""" - if not await subtensor.subnet_exists(netuid): + if prompt and (logo_url := subnet_identity.get("logo_url")): + sn_exists, img_validation = await asyncio.gather( + subtensor.subnet_exists(netuid), + check_img_mimetype(subnet_identity["logo_url"]), + ) + img_valid, content_type, err_msg = img_validation + if not img_valid: + confirmation_msg = f"Are you sure you want to use [blue]{logo_url}[/blue] as your image URL?" + if err_msg: + if not Confirm.ask(f"{err_msg}\n{confirmation_msg}"): + return False, None + else: + if not Confirm.ask( + f"The provided image's MIME type is {content_type}, which is not recognized as a valid" + f" image MIME type.\n{confirmation_msg}" + ): + return False, None + + else: + sn_exists = await subtensor.subnet_exists(netuid) + + if not sn_exists: err_console.print(f"Subnet {netuid} does not exist") - return False + return False, None identity_data = { "netuid": netuid, @@ -2226,13 +2399,13 @@ async def set_identity( } if not unlock_key(wallet).success: - return False + return False, None if prompt: if not Confirm.ask( "Are you sure you want to set subnet's identity? This is subject to a fee." ): - return False + return False, None call = await subtensor.substrate.compose_call( call_module="SubtensorModule", @@ -2244,12 +2417,15 @@ async def set_identity( " :satellite: [dark_sea_green3]Setting subnet identity on-chain...", spinner="earth", ): - success, err_msg = await subtensor.sign_and_send_extrinsic(call, wallet) + success, err_msg, ext_receipt = await subtensor.sign_and_send_extrinsic( + call, wallet + ) if not success: err_console.print(f"[red]:cross_mark: Failed![/red] {err_msg}") - return False - + return False, None + ext_id = await ext_receipt.get_extrinsic_identifier() + await print_extrinsic_id(ext_receipt) console.print( ":white_heavy_check_mark: [dark_sea_green3]Successfully set subnet identity\n" ) @@ -2274,7 +2450,7 @@ async def set_identity( table.add_row(key, str(value) if value else "~") console.print(table) - return True + return True, ext_id async def get_identity( @@ -2432,6 +2608,7 @@ async def start_subnet( ) if await response.is_success: + await print_extrinsic_id(response) console.print( f":white_heavy_check_mark: [green]Successfully started subnet {netuid}'s emission schedule.[/green]" ) @@ -2447,3 +2624,83 @@ async def start_subnet( await get_start_schedule(subtensor, netuid) print_error(f":cross_mark: Failed to start subnet: {error_msg}") return False + + +async def set_symbol( + wallet: "Wallet", + subtensor: "SubtensorInterface", + netuid: int, + symbol: str, + prompt: bool = False, + json_output: bool = False, +) -> bool: + """ + Set a subtensor's symbol, given the netuid and symbol. + + The symbol must be a symbol that subtensor recognizes as available + (defined in https://github.com/opentensor/subtensor/blob/main/pallets/subtensor/src/subnets/symbols.rs#L8) + """ + if not await subtensor.subnet_exists(netuid): + err = f"Subnet {netuid} does not exist." + if json_output: + json_console.print_json( + data={"success": False, "message": err, "extrinsic_identifier": None} + ) + else: + err_console.print(err) + return False + + if prompt and not json_output: + sn_info = await subtensor.subnet(netuid=netuid) + if not Confirm.ask( + f"Your current subnet symbol for SN{netuid} is {sn_info.symbol}. Do you want to update it to {symbol}?" + ): + return False + + if not (unlock_status := unlock_key(wallet, print_out=False)).success: + err = unlock_status.message + if json_output: + json_console.print_json(data={"success": False, "message": err}) + else: + console.print(err) + return False + + start_call = await subtensor.substrate.compose_call( + call_module="SubtensorModule", + call_function="update_symbol", + call_params={"netuid": netuid, "symbol": symbol.encode("utf-8")}, + ) + + signed_ext = await subtensor.substrate.create_signed_extrinsic( + call=start_call, + keypair=wallet.coldkey, + ) + + response = await subtensor.substrate.submit_extrinsic( + extrinsic=signed_ext, + wait_for_inclusion=True, + ) + if await response.is_success: + ext_id = await response.get_extrinsic_identifier() + await print_extrinsic_id(response) + message = f"Successfully updated SN{netuid}'s symbol to {symbol}." + if json_output: + json_console.print_json( + data={ + "success": True, + "message": message, + "extrinsic_identifier": ext_id, + } + ) + else: + console.print(f":white_heavy_check_mark:[dark_sea_green3] {message}\n") + return True + else: + err = format_error_message(await response.error_message) + if json_output: + json_console.print_json( + data={"success": False, "message": err, "extrinsic_identifier": None} + ) + else: + err_console.print(f":cross_mark: [red]Failed[/red]: {err}") + return False diff --git a/bittensor_cli/src/commands/sudo.py b/bittensor_cli/src/commands/sudo.py index 8004dc90f..76cc0addd 100644 --- a/bittensor_cli/src/commands/sudo.py +++ b/bittensor_cli/src/commands/sudo.py @@ -1,7 +1,8 @@ import asyncio import json -from typing import TYPE_CHECKING, Union, Optional +from typing import TYPE_CHECKING, Union, Optional, Type +from async_substrate_interface import AsyncExtrinsicReceipt from bittensor_wallet import Wallet from rich import box from rich.table import Column, Table @@ -11,6 +12,7 @@ from bittensor_cli.src import ( HYPERPARAMS, HYPERPARAMS_MODULE, + RootSudoOnly, DelegatesDetails, COLOR_PALETTE, ) @@ -26,6 +28,8 @@ json_console, string_to_u16, string_to_u64, + get_hotkey_pub_ss58, + print_extrinsic_id, ) if TYPE_CHECKING: @@ -33,6 +37,7 @@ SubtensorInterface, ProposalVoteData, ) + from scalecodec.types import GenericMetadataVersioned # helpers and extrinsics @@ -79,7 +84,7 @@ def allowed_value( return True, value -def string_to_bool(val) -> bool: +def string_to_bool(val) -> Union[bool, Type[ValueError]]: try: return {"true": True, "1": True, "0": False, "false": False}[val.lower()] except KeyError: @@ -90,8 +95,8 @@ def search_metadata( param_name: str, value: Union[str, bool, float, list[float]], netuid: int, - metadata, - pallet: str = DEFAULT_PALLET, + metadata: "GenericMetadataVersioned", + pallet_name: str = DEFAULT_PALLET, ) -> tuple[bool, Optional[dict]]: """ Searches the substrate metadata AdminUtils pallet for a given parameter name. Crafts a response dict to be used @@ -102,7 +107,7 @@ def search_metadata( value: the value to set the hyperparameter netuid: the specified netuid metadata: the subtensor.substrate.metadata - pallet: the name of the module to use for the query. If not set, the default value is DEFAULT_PALLET + pallet_name: the name of the module to use for the query. If not set, the default value is DEFAULT_PALLET Returns: (success, dict of call params) @@ -124,7 +129,7 @@ def type_converter_with_retry(type_, val, arg_name): call_crafter = {"netuid": netuid} - pallet = metadata.get_metadata_pallet(pallet) + pallet = metadata.get_metadata_pallet(pallet_name) for call in pallet.calls: if call.name == param_name: if "netuid" not in [x.name for x in call.args]: @@ -154,7 +159,7 @@ def requires_bool(metadata, param_name, pallet: str = DEFAULT_PALLET) -> bool: for call in pallet.calls: if call.name == param_name: if "netuid" not in [x.name for x in call.args]: - return False, None + return False call_args = [arg for arg in call.args if arg.value["name"] != "netuid"] if len(call_args) != 1: return False @@ -167,6 +172,84 @@ def requires_bool(metadata, param_name, pallet: str = DEFAULT_PALLET) -> bool: raise ValueError(f"{param_name} not found in pallet.") +async def set_mechanism_count_extrinsic( + subtensor: "SubtensorInterface", + wallet: "Wallet", + netuid: int, + mech_count: int, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, +) -> tuple[bool, str, Optional[AsyncExtrinsicReceipt]]: + """Sets the number of mechanisms for a subnet via AdminUtils.""" + + unlock_result = unlock_key(wallet) + if not unlock_result.success: + return False, unlock_result.message, None + + substrate = subtensor.substrate + call_params = {"netuid": netuid, "mechanism_count": mech_count} + + with console.status( + f":satellite: Setting mechanism count to [white]{mech_count}[/white] on " + f"[{COLOR_PALETTE.G.SUBHEAD}]{netuid}[/{COLOR_PALETTE.G.SUBHEAD}] ...", + spinner="earth", + ): + call = await substrate.compose_call( + call_module=DEFAULT_PALLET, + call_function="sudo_set_mechanism_count", + call_params=call_params, + ) + success, err_msg, ext_receipt = await subtensor.sign_and_send_extrinsic( + call, + wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + if not success: + return False, err_msg, None + + return True, "", ext_receipt + + +async def set_mechanism_emission_extrinsic( + subtensor: "SubtensorInterface", + wallet: "Wallet", + netuid: int, + split: list[int], + wait_for_inclusion: bool = True, + wait_for_finalization: bool = True, +) -> tuple[bool, str, Optional[AsyncExtrinsicReceipt]]: + """Sets the emission split for a subnet's mechanisms via AdminUtils.""" + + unlock_result = unlock_key(wallet) + if not unlock_result.success: + return False, unlock_result.message, None + + substrate = subtensor.substrate + + with console.status( + f":satellite: Setting emission split for subnet {netuid}...", + spinner="earth", + ): + call = await substrate.compose_call( + call_module=DEFAULT_PALLET, + call_function="sudo_set_mechanism_emission_split", + call_params={"netuid": netuid, "maybe_split": split}, + ) + success, err_msg, ext_receipt = await subtensor.sign_and_send_extrinsic( + call, + wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + if not success: + return False, err_msg, None + + return True, "", ext_receipt + + async def set_hyperparameter_extrinsic( subtensor: "SubtensorInterface", wallet: "Wallet", @@ -176,7 +259,7 @@ async def set_hyperparameter_extrinsic( wait_for_inclusion: bool = False, wait_for_finalization: bool = True, prompt: bool = True, -) -> bool: +) -> tuple[bool, str, Optional[str]]: """Sets a hyperparameter for a specific subnetwork. :param subtensor: initialized SubtensorInterface object @@ -188,9 +271,13 @@ async def set_hyperparameter_extrinsic( `False` if the extrinsic fails to enter the block within the timeout. :param wait_for_finalization: If set, waits for the extrinsic to be finalized on the chain before returning `True`, or returns `False` if the extrinsic fails to be finalized within the timeout. + :param prompt: If set to False, will not prompt the user. - :return: success: `True` if extrinsic was finalized or included in the block. If we did not wait for + :return: tuple including: + success: `True` if extrinsic was finalized or included in the block. If we did not wait for finalization/inclusion, the response is `True`. + message: error message if the extrinsic failed + extrinsic_identifier: optional extrinsic identifier if the extrinsic was included """ print_verbose("Confirming subnet owner") subnet_owner = await subtensor.query( @@ -199,17 +286,18 @@ async def set_hyperparameter_extrinsic( params=[netuid], ) if subnet_owner != wallet.coldkeypub.ss58_address: - err_console.print( + err_msg = ( ":cross_mark: [red]This wallet doesn't own the specified subnet.[/red]" ) - return False + err_console.print(err_msg) + return False, err_msg, None - if not unlock_key(wallet).success: - return False + if not (ulw := unlock_key(wallet)).success: + return False, ulw.message, None arbitrary_extrinsic = False - extrinsic, sudo_ = HYPERPARAMS.get(parameter, ("", False)) + extrinsic, sudo_ = HYPERPARAMS.get(parameter, ("", RootSudoOnly.FALSE)) call_params = {"netuid": netuid} if not extrinsic: arbitrary_extrinsic, call_params = search_metadata( @@ -217,92 +305,106 @@ async def set_hyperparameter_extrinsic( ) extrinsic = parameter if not arbitrary_extrinsic: - err_console.print( - ":cross_mark: [red]Invalid hyperparameter specified.[/red]" - ) - return False - if sudo_ and prompt: + err_msg = ":cross_mark: [red]Invalid hyperparameter specified.[/red]" + err_console.print(err_msg) + return False, err_msg, None + if sudo_ is RootSudoOnly.TRUE and prompt: if not Confirm.ask( "This hyperparam is only settable by root sudo users. If you are not, this will fail. Please confirm" ): - return False + return False, "This hyperparam is only settable by root sudo users", None substrate = subtensor.substrate msg_value = value if not arbitrary_extrinsic else call_params pallet = HYPERPARAMS_MODULE.get(parameter) or DEFAULT_PALLET - with console.status( - f":satellite: Setting hyperparameter [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{parameter}" - f"[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}] to [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]{msg_value}" - f"[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}] on subnet: [{COLOR_PALETTE['GENERAL']['SUBHEADING']}]" - f"{netuid}[/{COLOR_PALETTE['GENERAL']['SUBHEADING']}] ...", - spinner="earth", - ): - if not arbitrary_extrinsic: - extrinsic_params = await substrate.get_metadata_call_function( - module_name=pallet, call_function_name=extrinsic - ) - - # if input value is a list, iterate through the list and assign values - if isinstance(value, list): - # Ensure that there are enough values for all non-netuid parameters - non_netuid_fields = [ - pn_str - for param in extrinsic_params["fields"] - if "netuid" not in (pn_str := str(param["name"])) - ] - - if len(value) < len(non_netuid_fields): - err_console.print( - "Not enough values provided in the list for all parameters" - ) - return False + if not arbitrary_extrinsic: + extrinsic_params = await substrate.get_metadata_call_function( + module_name=pallet, call_function_name=extrinsic + ) - call_params.update( - {name: val for name, val in zip(non_netuid_fields, value)} - ) + # if input value is a list, iterate through the list and assign values + if isinstance(value, list): + # Ensure that there are enough values for all non-netuid parameters + non_netuid_fields = [ + pn_str + for param in extrinsic_params["fields"] + if "netuid" not in (pn_str := str(param["name"])) + ] + + if len(value) < len(non_netuid_fields): + err_msg = "Not enough values provided in the list for all parameters" + err_console.print(err_msg) + return False, err_msg, None + + call_params.update( + {name: val for name, val in zip(non_netuid_fields, value)} + ) - else: - if requires_bool( - substrate.metadata, param_name=extrinsic, pallet=pallet - ) and isinstance(value, str): - value = string_to_bool(value) - value_argument = extrinsic_params["fields"][ - len(extrinsic_params["fields"]) - 1 - ] - call_params[str(value_argument["name"])] = value - - # create extrinsic call - call_ = await substrate.compose_call( - call_module=pallet, - call_function=extrinsic, - call_params=call_params, + else: + if requires_bool( + substrate.metadata, param_name=extrinsic, pallet=pallet + ) and isinstance(value, str): + value = string_to_bool(value) + value_argument = extrinsic_params["fields"][ + len(extrinsic_params["fields"]) - 1 + ] + call_params[str(value_argument["name"])] = value + # create extrinsic call + call_ = await substrate.compose_call( + call_module=pallet, + call_function=extrinsic, + call_params=call_params, + ) + if sudo_ is RootSudoOnly.TRUE: + call = await substrate.compose_call( + call_module="Sudo", call_function="sudo", call_params={"call": call_} ) - if sudo_: + elif sudo_ is RootSudoOnly.COMPLICATED: + if not prompt: + to_sudo_or_not_to_sudo = True # default to sudo true when no-prompt is set + else: + to_sudo_or_not_to_sudo = Confirm.ask( + f"This hyperparam can be executed as sudo or not. Do you want to execute as sudo [y] or not [n]?" + ) + if to_sudo_or_not_to_sudo: call = await substrate.compose_call( - call_module="Sudo", call_function="sudo", call_params={"call": call_} + call_module="Sudo", + call_function="sudo", + call_params={"call": call_}, ) else: call = call_ - success, err_msg = await subtensor.sign_and_send_extrinsic( + else: + call = call_ + with console.status( + f":satellite: Setting hyperparameter [{COLOR_PALETTE.G.SUBHEAD}]{parameter}[/{COLOR_PALETTE.G.SUBHEAD}]" + f" to [{COLOR_PALETTE.G.SUBHEAD}]{msg_value}[/{COLOR_PALETTE.G.SUBHEAD}]" + f" on subnet: [{COLOR_PALETTE.G.SUBHEAD}]{netuid}[/{COLOR_PALETTE.G.SUBHEAD}] ...", + spinner="earth", + ): + success, err_msg, ext_receipt = await subtensor.sign_and_send_extrinsic( call, wallet, wait_for_inclusion, wait_for_finalization ) - if not success: - err_console.print(f":cross_mark: [red]Failed[/red]: {err_msg}") - return False - elif arbitrary_extrinsic: + if not success: + err_console.print(f":cross_mark: [red]Failed[/red]: {err_msg}") + return False, err_msg, None + else: + ext_id = await ext_receipt.get_extrinsic_identifier() + await print_extrinsic_id(ext_receipt) + if arbitrary_extrinsic: console.print( f":white_heavy_check_mark: " f"[dark_sea_green3]Hyperparameter {parameter} values changed to {call_params}[/dark_sea_green3]" ) - return True + return True, "", ext_id # Successful registration, final check for membership else: console.print( f":white_heavy_check_mark: " f"[dark_sea_green3]Hyperparameter {parameter} changed to {value}[/dark_sea_green3]" ) - return True + return True, "", ext_id async def _get_senate_members( @@ -497,13 +599,13 @@ async def vote_senate_extrinsic( call_module="SubtensorModule", call_function="vote", call_params={ - "hotkey": wallet.hotkey.ss58_address, + "hotkey": get_hotkey_pub_ss58(wallet), "proposal": proposal_hash, "index": proposal_idx, "approve": vote, }, ) - success, err_msg = await subtensor.sign_and_send_extrinsic( + success, err_msg, ext_receipt = await subtensor.sign_and_send_extrinsic( call, wallet, wait_for_inclusion, wait_for_finalization ) if not success: @@ -512,10 +614,12 @@ async def vote_senate_extrinsic( return False # Successful vote, final check for data else: + await print_extrinsic_id(ext_receipt) if vote_data := await subtensor.get_vote_data(proposal_hash): + hotkey_ss58 = get_hotkey_pub_ss58(wallet) if ( - vote_data.ayes.count(wallet.hotkey.ss58_address) > 0 - or vote_data.nays.count(wallet.hotkey.ss58_address) > 0 + vote_data.ayes.count(hotkey_ss58) > 0 + or vote_data.nays.count(hotkey_ss58) > 0 ): console.print(":white_heavy_check_mark: [green]Vote cast.[/green]") return True @@ -534,7 +638,7 @@ async def set_take_extrinsic( wallet: Wallet, delegate_ss58: str, take: float = 0.0, -) -> bool: +) -> tuple[bool, Optional[str]]: """ Set delegate hotkey take @@ -559,7 +663,7 @@ async def set_take_extrinsic( if take_u16 == current_take_u16: console.print("Nothing to do, take hasn't changed") - return True + return True, None if current_take_u16 < take_u16: console.print( @@ -577,7 +681,9 @@ async def set_take_extrinsic( "take": take_u16, }, ) - success, err = await subtensor.sign_and_send_extrinsic(call, wallet) + success, err, ext_receipt = await subtensor.sign_and_send_extrinsic( + call, wallet + ) else: console.print( @@ -595,15 +701,20 @@ async def set_take_extrinsic( "take": take_u16, }, ) - success, err = await subtensor.sign_and_send_extrinsic(call, wallet) + success, err, ext_receipt = await subtensor.sign_and_send_extrinsic( + call, wallet + ) if not success: err_console.print(err) + ext_id = None else: console.print( - ":white_heavy_check_mark: [dark_sea_green_3]Finalized[/dark_sea_green_3]" + ":white_heavy_check_mark: [dark_sea_green_3]Success[/dark_sea_green_3]" ) - return success + ext_id = await ext_receipt.get_extrinsic_identifier() + await print_extrinsic_id(ext_receipt) + return success, ext_id # commands @@ -617,25 +728,28 @@ async def sudo_set_hyperparameter( param_value: Optional[str], prompt: bool, json_output: bool, -): +) -> tuple[bool, str, Optional[str]]: """Set subnet hyperparameters.""" is_allowed_value, value = allowed_value(param_name, param_value) if not is_allowed_value: - err_console.print( + err_msg = ( f"Hyperparameter [dark_orange]{param_name}[/dark_orange] value is not within bounds. " f"Value is {param_value} but must be {value}" ) - return False - success = await set_hyperparameter_extrinsic( + err_console.print(err_msg) + return False, err_msg, None + if json_output: + prompt = False + success, err_msg, ext_id = await set_hyperparameter_extrinsic( subtensor, wallet, netuid, param_name, value, prompt=prompt ) if json_output: - return success + return success, err_msg, ext_id if success: console.print("\n") print_verbose("Fetching hyperparameters") await get_hyperparameters(subtensor, netuid=netuid) - return success + return success, err_msg, ext_id async def get_hyperparameters( @@ -859,10 +973,9 @@ async def senate_vote( return False print_verbose(f"Fetching senate status of {wallet.hotkey_str}") - if not await _is_senate_member(subtensor, hotkey_ss58=wallet.hotkey.ss58_address): - err_console.print( - f"Aborting: Hotkey {wallet.hotkey.ss58_address} isn't a senate member." - ) + hotkey_ss58 = get_hotkey_pub_ss58(wallet) + if not await _is_senate_member(subtensor, hotkey_ss58=hotkey_ss58): + err_console.print(f"Aborting: Hotkey {hotkey_ss58} isn't a senate member.") return False # Unlock the wallet. @@ -890,7 +1003,7 @@ async def senate_vote( async def get_current_take(subtensor: "SubtensorInterface", wallet: Wallet): - current_take = await subtensor.current_take(wallet.hotkey.ss58_address) + current_take = await subtensor.current_take(get_hotkey_pub_ss58(wallet)) return current_take @@ -903,50 +1016,116 @@ async def display_current_take(subtensor: "SubtensorInterface", wallet: Wallet) async def set_take( wallet: Wallet, subtensor: "SubtensorInterface", take: float -) -> bool: +) -> tuple[bool, Optional[str]]: """Set delegate take.""" - async def _do_set_take() -> bool: + async def _do_set_take() -> tuple[bool, Optional[str]]: if take > 0.18 or take < 0: err_console.print("ERROR: Take value should not exceed 18% or be below 0%") - return False + return False, None block_hash = await subtensor.substrate.get_chain_head() + hotkey_ss58 = get_hotkey_pub_ss58(wallet) netuids_registered = await subtensor.get_netuids_for_hotkey( - wallet.hotkey.ss58_address, block_hash=block_hash + hotkey_ss58, block_hash=block_hash ) if not len(netuids_registered) > 0: err_console.print( - f"Hotkey [{COLOR_PALETTE.G.HK}]{wallet.hotkey.ss58_address}[/{COLOR_PALETTE.G.HK}] is not registered to" + f"Hotkey [{COLOR_PALETTE.G.HK}]{hotkey_ss58}[/{COLOR_PALETTE.G.HK}] is not registered to" f" any subnet. Please register using [{COLOR_PALETTE.G.SUBHEAD}]`btcli subnets register`" f"[{COLOR_PALETTE.G.SUBHEAD}] and try again." ) - return False + return False, None - result: bool = await set_take_extrinsic( + result: tuple[bool, Optional[str]] = await set_take_extrinsic( subtensor=subtensor, wallet=wallet, - delegate_ss58=wallet.hotkey.ss58_address, + delegate_ss58=hotkey_ss58, take=take, ) + success, ext_id = result - if not result: + if not success: err_console.print("Could not set the take") - return False + return False, None else: new_take = await get_current_take(subtensor, wallet) console.print( f"New take is [{COLOR_PALETTE.P.RATE}]{new_take * 100.0:.2f}%" ) - return True + return True, ext_id console.print( f"Setting take on [{COLOR_PALETTE.G.LINKS}]network: {subtensor.network}" ) if not unlock_key(wallet, "hot").success and unlock_key(wallet, "cold").success: - return False + return False, None - result_ = await _do_set_take() + return await _do_set_take() - return result_ + +async def trim( + wallet: Wallet, + subtensor: "SubtensorInterface", + netuid: int, + max_n: int, + period: int, + prompt: bool, + json_output: bool, +) -> bool: + """ + Trims a subnet's UIDs to a specified amount + """ + print_verbose("Confirming subnet owner") + subnet_owner = await subtensor.query( + module="SubtensorModule", + storage_function="SubnetOwner", + params=[netuid], + ) + if subnet_owner != wallet.coldkeypub.ss58_address: + err_msg = "This wallet doesn't own the specified subnet." + if json_output: + json_console.print_json(data={"success": False, "message": err_msg}) + else: + err_console.print(f":cross_mark: [red]{err_msg}[/red]") + return False + if prompt and not json_output: + if not Confirm.ask( + f"You are about to trim UIDs on SN{netuid} to a limit of {max_n}", + default=False, + ): + err_console.print(":cross_mark: [red]User aborted.[/red]") + call = await subtensor.substrate.compose_call( + call_module="AdminUtils", + call_function="sudo_trim_to_max_allowed_uids", + call_params={"netuid": netuid, "max_n": max_n}, + ) + success, err_msg, ext_receipt = await subtensor.sign_and_send_extrinsic( + call=call, wallet=wallet, era={"period": period} + ) + if not success: + if json_output: + json_console.print_json( + data={ + "success": False, + "message": err_msg, + "extrinsic_identifier": None, + } + ) + else: + err_console.print(f":cross_mark: [red]{err_msg}[/red]") + return False + else: + ext_id = await ext_receipt.get_extrinsic_identifier() + msg = f"Successfully trimmed UIDs on SN{netuid} to {max_n}" + if json_output: + json_console.print_json( + data={"success": True, "message": msg, "extrinsic_identifier": ext_id} + ) + else: + await print_extrinsic_id(ext_receipt) + console.print( + f":white_heavy_check_mark: [dark_sea_green3]{msg}[/dark_sea_green3]" + ) + return True diff --git a/bittensor_cli/src/commands/wallets.py b/bittensor_cli/src/commands/wallets.py index 61efa696c..a0cb24ceb 100644 --- a/bittensor_cli/src/commands/wallets.py +++ b/bittensor_cli/src/commands/wallets.py @@ -3,7 +3,8 @@ import json import os from collections import defaultdict -from typing import Generator, Optional +from enum import Enum +from typing import Generator, Optional, Union import aiohttp from bittensor_wallet import Wallet, Keypair @@ -29,7 +30,10 @@ ) from bittensor_cli.src.bittensor.extrinsics.transfer import transfer_extrinsic from bittensor_cli.src.bittensor.networking import int_to_ip -from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface +from bittensor_cli.src.bittensor.subtensor_interface import ( + SubtensorInterface, + GENESIS_ADDRESS, +) from bittensor_cli.src.bittensor.utils import ( RAO_PER_TAO, console, @@ -48,9 +52,32 @@ WalletLike, blocks_to_duration, decode_account_id, + get_hotkey_pub_ss58, + print_extrinsic_id, ) +class SortByBalance(Enum): + name = "name" + free = "free" + staked = "staked" + total = "total" + + +def _sort_by_balance_key(sort_by: SortByBalance): + """Get the sort key function based on the enum""" + if sort_by == SortByBalance.name: + return lambda row: row[0].lower() # Case-insensitive alphabetical sort + elif sort_by == SortByBalance.free: + return lambda row: row[2] + elif sort_by == SortByBalance.staked: + return lambda row: row[3] + elif sort_by == SortByBalance.total: + return lambda row: row[4] + else: + raise ValueError("Invalid sort key") + + async def associate_hotkey( wallet: Wallet, subtensor: SubtensorInterface, @@ -97,7 +124,7 @@ async def associate_hotkey( ) with console.status(":satellite: Associating hotkey on-chain..."): - success, err_msg = await subtensor.sign_and_send_extrinsic( + success, err_msg, ext_receipt = await subtensor.sign_and_send_extrinsic( call, wallet, wait_for_inclusion=True, @@ -115,6 +142,7 @@ async def associate_hotkey( f"wallet [blue]{wallet.name}[/blue], " f"SS58: [{COLORS.GENERAL.CK}]{wallet.coldkeypub.ss58_address}[/{COLORS.GENERAL.CK}]" ) + await print_extrinsic_id(ext_receipt) return True @@ -159,7 +187,7 @@ async def regen_coldkey( "name": new_wallet.name, "path": new_wallet.path, "hotkey": new_wallet.hotkey_str, - "hotkey_ss58": new_wallet.hotkey.ss58_address, + "hotkey_ss58": get_hotkey_pub_ss58(new_wallet), "coldkey_ss58": new_wallet.coldkeypub.ss58_address, }, "error": "", @@ -209,7 +237,7 @@ async def regen_coldkey_pub( "name": new_coldkeypub.name, "path": new_coldkeypub.path, "hotkey": new_coldkeypub.hotkey_str, - "hotkey_ss58": new_coldkeypub.hotkey.ss58_address, + "hotkey_ss58": get_hotkey_pub_ss58(new_coldkeypub), "coldkey_ss58": new_coldkeypub.coldkeypub.ss58_address, }, "error": "", @@ -255,7 +283,7 @@ async def regen_hotkey( console.print( "\n✅ [dark_sea_green]Regenerated hotkey successfully!\n", f"[dark_sea_green]Wallet name: ({new_hotkey_.name}), path: ({new_hotkey_.path}), " - f"hotkey ss58: ({new_hotkey_.hotkey.ss58_address})", + f"hotkey ss58: ({new_hotkey_.hotkeypub.ss58_address})", ) if json_output: json_console.print( @@ -266,7 +294,7 @@ async def regen_hotkey( "name": new_hotkey_.name, "path": new_hotkey_.path, "hotkey": new_hotkey_.hotkey_str, - "hotkey_ss58": new_hotkey_.hotkey.ss58_address, + "hotkey_ss58": new_hotkey_.hotkeypub.ss58_address, "coldkey_ss58": new_hotkey_.coldkeypub.ss58_address, }, "error": "", @@ -287,6 +315,50 @@ async def regen_hotkey( ) +async def regen_hotkey_pub( + wallet: Wallet, + ss58_address: str, + public_key_hex: str, + overwrite: Optional[bool] = False, + json_output: bool = False, +): + """Creates a new hotkeypub under this wallet.""" + try: + new_hotkeypub = wallet.regenerate_hotkeypub( + ss58_address=ss58_address, + public_key=public_key_hex, + overwrite=overwrite, + ) + if isinstance(new_hotkeypub, Wallet): + console.print( + "\n✅ [dark_sea_green]Regenerated coldkeypub successfully!\n", + f"[dark_sea_green]Wallet name: ({new_hotkeypub.name}), path: ({new_hotkeypub.path}), " + f"coldkey ss58: ({new_hotkeypub.coldkeypub.ss58_address})", + ) + if json_output: + json_console.print( + json.dumps( + { + "success": True, + "data": { + "name": new_hotkeypub.name, + "path": new_hotkeypub.path, + "hotkey": new_hotkeypub.hotkey_str, + "hotkey_ss58": new_hotkeypub.hotkeypub.ss58_address, + "coldkey_ss58": new_hotkeypub.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_hotkey( wallet: Wallet, n_words: int, @@ -323,7 +395,7 @@ async def new_hotkey( "name": wallet.name, "path": wallet.path, "hotkey": wallet.hotkey_str, - "hotkey_ss58": wallet.hotkey.ss58_address, + "hotkey_ss58": get_hotkey_pub_ss58(wallet), "coldkey_ss58": wallet.coldkeypub.ss58_address, }, "error": "", @@ -402,19 +474,26 @@ async def wallet_create( json_output: bool = False, ): """Creates a new wallet.""" - output_dict = {"success": False, "error": "", "data": None} + output_dict: dict[str, Optional[Union[bool, str, 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) + wallet.set_coldkey(keypair=keypair, encrypt=False, overwrite=overwrite) + wallet.set_coldkeypub(keypair=keypair, encrypt=False, overwrite=overwrite) + wallet.set_hotkey(keypair=keypair, encrypt=False, overwrite=overwrite) + wallet.set_hotkeypub(keypair=keypair, encrypt=False, overwrite=overwrite) + wallet.set_coldkeypub(keypair=keypair, encrypt=False, overwrite=overwrite) output_dict["success"] = True output_dict["data"] = { "name": wallet.name, "path": wallet.path, "hotkey": wallet.hotkey_str, - "hotkey_ss58": wallet.hotkey.ss58_address, + "hotkey_ss58": wallet.hotkeypub.ss58_address, "coldkey_ss58": wallet.coldkeypub.ss58_address, } except Exception as e: @@ -455,7 +534,7 @@ async def wallet_create( "name": wallet.name, "path": wallet.path, "hotkey": wallet.hotkey_str, - "hotkey_ss58": wallet.hotkey.ss58_address, + "hotkey_ss58": wallet.hotkeypub.ss58_address, } except KeyFileError as error: err = str(error) @@ -497,7 +576,7 @@ def _get_coldkey_ss58_addresses_for_path(path: str) -> tuple[list[str], list[str coldkey_paths = [ os.path.join(abs_path, wallet, "coldkeypub.txt") for wallet in wallets - if os.path.exists(os.path.join(abs_path, wallet, "coldkeypub.txt")) + if os.path.isfile(os.path.join(abs_path, wallet, "coldkeypub.txt")) ] ss58_addresses = [Keyfile(path).keypair.ss58_address for path in coldkey_paths] @@ -511,6 +590,7 @@ async def wallet_balance( subtensor: SubtensorInterface, all_balances: bool, ss58_addresses: Optional[str] = None, + sort_by: Optional[SortByBalance] = None, json_output: bool = False, ): """Retrieves the current balance of the specified wallet""" @@ -540,8 +620,8 @@ async def wallet_balance( subtensor.get_total_stake_for_coldkey(*coldkeys, block_hash=block_hash), ) - total_free_balance = sum(free_balances.values()) - total_staked_balance = sum(stake[0] for stake in staked_balances.values()) + total_free_balance: Balance = sum(free_balances.values()) + total_staked_balance: Balance = sum(stake[0] for stake in staked_balances.values()) balances = { name: ( @@ -590,14 +670,26 @@ async def wallet_balance( width=None, leading=True, ) - - for name, (coldkey, free, staked) in balances.items(): + balance_rows = [ + (name, coldkey, free, staked, free + staked) + for (name, (coldkey, free, staked)) in balances.items() + ] + sorted_balances = ( + sorted( + balance_rows, + key=_sort_by_balance_key(sort_by), + reverse=(sort_by != SortByBalance.name), + ) + if sort_by is not None + else balance_rows + ) + for name, coldkey, free, staked, total in sorted_balances: table.add_row( name, coldkey, str(free), str(staked), - str(free + staked), + str(total), ) table.add_row() table.add_row( @@ -759,18 +851,28 @@ async def wallet_history(wallet: Wallet): console.print(table) -async def wallet_list(wallet_path: str, json_output: bool): +async def wallet_list( + wallet_path: str, json_output: bool, wallet_name: Optional[str] = None +): """Lists wallets.""" wallets = utils.get_coldkey_wallets_for_path(wallet_path) print_verbose(f"Using wallets path: {wallet_path}") if not wallets: err_console.print(f"[red]No wallets found in dir: {wallet_path}[/red]") + if wallet_name: + wallets = [wallet for wallet in wallets if wallet.name == wallet_name] + if not wallets: + err_console.print( + f"[red]Wallet '{wallet_name}' not found in dir: {wallet_path}[/red]" + ) + root = Tree("Wallets") main_data_dict = {"wallets": []} for wallet in wallets: if ( wallet.coldkeypub_file.exists_on_device() + and os.path.isfile(wallet.coldkeypub_file.path) and not wallet.coldkeypub_file.is_encrypted() ): coldkeypub_str = wallet.coldkeypub.ss58_address @@ -794,13 +896,26 @@ async def wallet_list(wallet_path: str, json_output: bool): data = f"[bold red]Hotkey[/bold red][green] {hkey}[/green] (?)" hk_data = {"name": hkey.name, "ss58_address": "?"} if hkey: + try: + hkey_ss58 = hkey.get_hotkey().ss58_address + pub_only = False + except KeyFileError: + hkey_ss58 = hkey.get_hotkeypub().ss58_address + pub_only = True + except AttributeError: + hkey_ss58 = hkey.hotkey.ss58_address + pub_only = False try: data = ( f"[bold red]Hotkey[/bold red] [green]{hkey.hotkey_str}[/green] " - f"ss58_address [green]{hkey.hotkey.ss58_address}[/green]\n" + f"ss58_address [green]{hkey_ss58}[/green]" ) + if pub_only: + data += " [blue](hotkeypub only)[/blue]\n" + else: + data += "\n" hk_data["name"] = hkey.hotkey_str - hk_data["ss58_address"] = hkey.hotkey.ss58_address + hk_data["ss58_address"] = hkey_ss58 except UnicodeDecodeError: pass wallet_tree.add(data) @@ -808,7 +923,12 @@ async def wallet_list(wallet_path: str, json_output: bool): if not wallets: print_verbose(f"No wallets found in path: {wallet_path}") - root.add("[bold red]No wallets found.") + message = ( + "[bold red]No wallets found." + if not wallet_name + else f"[bold red]Wallet '{wallet_name}' not found." + ) + root.add(message) if json_output: json_console.print(json.dumps(main_data_dict)) else: @@ -1253,7 +1373,7 @@ def _get_hotkeys( def is_hotkey_matched(wallet: Wallet, item: str) -> bool: if is_valid_ss58_address(item): - return wallet.hotkey.ss58_address == item + return get_hotkey_pub_ss58(wallet) == item else: return wallet.hotkey_str == item @@ -1285,9 +1405,10 @@ def _get_key_address(all_hotkeys: list[Wallet]) -> tuple[list[str], dict[str, Wa hotkey_coldkey_to_hotkey_wallet = {} for hotkey_wallet in all_hotkeys: if hotkey_wallet.coldkeypub: - if hotkey_wallet.hotkey.ss58_address not in hotkey_coldkey_to_hotkey_wallet: - hotkey_coldkey_to_hotkey_wallet[hotkey_wallet.hotkey.ss58_address] = {} - hotkey_coldkey_to_hotkey_wallet[hotkey_wallet.hotkey.ss58_address][ + hotkey_ss58 = get_hotkey_pub_ss58(hotkey_wallet) + if hotkey_ss58 not in hotkey_coldkey_to_hotkey_wallet: + hotkey_coldkey_to_hotkey_wallet[hotkey_ss58] = {} + hotkey_coldkey_to_hotkey_wallet[hotkey_ss58][ hotkey_wallet.coldkeypub.ss58_address ] = hotkey_wallet else: @@ -1414,7 +1535,7 @@ async def transfer( json_output: bool, ): """Transfer token of amount to destination.""" - result = await transfer_extrinsic( + result, ext_receipt = await transfer_extrinsic( subtensor=subtensor, wallet=wallet, destination=destination, @@ -1424,8 +1545,13 @@ async def transfer( era=era, prompt=prompt, ) + ext_id = (await ext_receipt.get_extrinsic_identifier()) if result else None if json_output: - json_console.print(json.dumps({"success": result})) + json_console.print( + json.dumps({"success": result, "extrinsic_identifier": ext_id}) + ) + else: + await print_extrinsic_id(ext_receipt) return result @@ -1471,7 +1597,7 @@ def neuron_row_maker( if hotkey_names := [ w.hotkey_str for w in hotkeys - if w.hotkey.ss58_address == n.hotkey + if get_hotkey_pub_ss58(w) == n.hotkey ]: hotkey_name = f"{hotkey_names[0]}-" yield [""] * 5 + [ @@ -1615,15 +1741,23 @@ async def swap_hotkey( json_output: bool, ): """Swap your hotkey for all registered axons on the network.""" - result = await swap_hotkey_extrinsic( + result, ext_receipt = await swap_hotkey_extrinsic( subtensor, original_wallet, new_wallet, netuid=netuid, prompt=prompt, ) + if result: + ext_id = await ext_receipt.get_extrinsic_identifier() + else: + ext_id = None if json_output: - json_console.print(json.dumps({"success": result})) + json_console.print( + json.dumps({"success": result, "extrinsic_identifier": ext_id}) + ) + else: + await print_extrinsic_id(ext_receipt) return result @@ -1664,7 +1798,7 @@ async def set_id( github_repo: str, prompt: bool, json_output: bool = False, -): +) -> bool: """Create a new or update existing identity on-chain.""" output_dict = {"success": False, "identity": None, "error": ""} identity_data = { @@ -1689,16 +1823,20 @@ async def set_id( with console.status( " :satellite: [dark_sea_green3]Updating identity on-chain...", spinner="earth" ): - success, err_msg = await subtensor.sign_and_send_extrinsic(call, wallet) + success, err_msg, ext_receipt = await subtensor.sign_and_send_extrinsic( + call, wallet + ) 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 + return False else: console.print(":white_heavy_check_mark: [dark_sea_green3]Success!") + ext_id = await ext_receipt.get_extrinsic_identifier() + await print_extrinsic_id(ext_receipt) output_dict["success"] = True identity = await subtensor.query_identity(wallet.coldkeypub.ss58_address) @@ -1707,9 +1845,12 @@ async def set_id( for key, value in identity.items(): table.add_row(key, str(value) if value else "~") output_dict["identity"] = identity - console.print(table) + output_dict["extrinsic_identifier"] = ext_id if json_output: json_console.print(json.dumps(output_dict)) + else: + console.print(table) + return True async def get_id( @@ -1782,7 +1923,7 @@ async def check_coldkey_swap(wallet: Wallet, subtensor: SubtensorInterface): async def sign( - wallet: Wallet, message: str, use_hotkey: str, json_output: bool = False + wallet: Wallet, message: str, use_hotkey: bool, json_output: bool = False ): """Sign a message using the provided wallet or hotkey.""" @@ -1949,9 +2090,9 @@ async def schedule_coldkey_swap( }, ), ) - + swap_info = None with console.status(":satellite: Scheduling coldkey swap on-chain..."): - success, err_msg = await subtensor.sign_and_send_extrinsic( + success, err_msg, ext_receipt = await subtensor.sign_and_send_extrinsic( call, wallet, wait_for_inclusion=True, @@ -1966,13 +2107,29 @@ async def schedule_coldkey_swap( console.print( ":white_heavy_check_mark: [green]Successfully scheduled coldkey swap" ) + await print_extrinsic_id(ext_receipt) + for event in await ext_receipt.triggered_events: + if ( + event.get("event", {}).get("module_id") == "SubtensorModule" + and event.get("event", {}).get("event_id") == "ColdkeySwapScheduled" + ): + attributes = event["event"].get("attributes", {}) + old_coldkey = decode_account_id(attributes["old_coldkey"][0]) - swap_info = await find_coldkey_swap_extrinsic( - subtensor=subtensor, - start_block=block_pre_call, - end_block=block_post_call, - wallet_ss58=wallet.coldkeypub.ss58_address, - ) + if old_coldkey == wallet.coldkeypub.ss58_address: + swap_info = { + "block_num": block_pre_call, + "dest_coldkey": decode_account_id(attributes["new_coldkey"][0]), + "execution_block": attributes["execution_block"], + } + + if not swap_info: + swap_info = await find_coldkey_swap_extrinsic( + subtensor=subtensor, + start_block=block_pre_call, + end_block=block_post_call, + wallet_ss58=wallet.coldkeypub.ss58_address, + ) if not swap_info: console.print( @@ -2107,10 +2264,8 @@ async def check_swap_status( chain_reported_completion_block, destination_address = await subtensor.query( "SubtensorModule", "ColdkeySwapScheduled", [origin_ss58] ) - if ( - chain_reported_completion_block != 0 - and destination_address != "5C4hrfjw9DjXZTzV3MwzrrAr9P1MJhSrvWGWqi1eSuyUpnhM" - ): + destination_address = decode_account_id(destination_address[0]) + if chain_reported_completion_block != 0 and destination_address != GENESIS_ADDRESS: is_pending = True else: is_pending = False diff --git a/bittensor_cli/src/commands/weights.py b/bittensor_cli/src/commands/weights.py index 68ec0308f..4bccb28a7 100644 --- a/bittensor_cli/src/commands/weights.py +++ b/bittensor_cli/src/commands/weights.py @@ -2,7 +2,7 @@ import json import os from datetime import datetime, timedelta -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from bittensor_wallet import Wallet import numpy as np @@ -15,6 +15,8 @@ console, format_error_message, json_console, + get_hotkey_pub_ss58, + print_extrinsic_id, ) from bittensor_cli.src.bittensor.extrinsics.root import ( convert_weights_and_uids_for_emit, @@ -53,7 +55,7 @@ def __init__( self.wait_for_inclusion = wait_for_inclusion self.wait_for_finalization = wait_for_finalization - async def set_weights_extrinsic(self) -> tuple[bool, str]: + async def set_weights_extrinsic(self) -> tuple[bool, str, Optional[str]]: """ Sets the inter-neuronal weights for the specified neuron. This process involves specifying the influence or trust a neuron places on other neurons in the network, which is a fundamental aspect @@ -79,7 +81,7 @@ async def set_weights_extrinsic(self) -> tuple[bool, str]: f"Do you want to set weights:\n[bold white]" f" weights: {formatted_weight_vals}\n uids: {weight_uids}[/bold white ]?" ): - return False, "Prompt refused." + return False, "Prompt refused.", None # Check if the commit-reveal mechanism is active for the given netuid. if bool( @@ -103,7 +105,7 @@ async def commit_weights( self, uids: list[int], weights: list[int], - ) -> tuple[bool, str]: + ) -> tuple[bool, Optional[str], Optional[str]]: """ Commits a hash of the neuron's weights to the Bittensor blockchain using the provided wallet. This action serves as a commitment or snapshot of the neuron's current weight distribution. @@ -120,15 +122,9 @@ async def commit_weights( enhancing transparency and accountability within the Bittensor network. """ - # _logger.info( - # "Committing weights with params: netuid={}, uids={}, weights={}, version_key={}".format( - # netuid, uids, weights, version_key - # ) - # ) - # Generate the hash of the weights commit_hash = generate_weight_hash( - address=self.wallet.hotkey.ss58_address, + address=get_hotkey_pub_ss58(self.wallet), netuid=self.netuid, uids=uids, values=weights, @@ -138,18 +134,21 @@ async def commit_weights( # _logger.info("Commit Hash: {}".format(commit_hash)) try: - success, message = await self.do_commit_weights(commit_hash=commit_hash) + success, message, ext_id = await self.do_commit_weights( + commit_hash=commit_hash + ) except SubstrateRequestException as e: err_console.print(f"Error committing weights: {format_error_message(e)}") # bittensor.logging.error(f"Error committing weights: {e}") success = False message = "No attempt made. Perhaps it is too soon to commit weights!" + ext_id = None - return success, message + return success, message, ext_id async def _commit_reveal( self, weight_uids: list[int], weight_vals: list[int] - ) -> tuple[bool, str]: + ) -> tuple[bool, str, Optional[str]]: interval = int( await self.subtensor.get_hyperparameter( param_name="get_commit_reveal_period", @@ -164,7 +163,7 @@ async def _commit_reveal( self.salt = list(os.urandom(salt_length)) # Attempt to commit the weights to the blockchain. - commit_success, commit_msg = await self.commit_weights( + commit_success, commit_msg, ext_id = await self.commit_weights( uids=weight_uids, weights=weight_vals, ) @@ -208,36 +207,42 @@ async def _commit_reveal( console.print(f":cross_mark: [red]Failed[/red]: error:{commit_msg}") # bittensor.logging.error(msg=commit_msg, prefix="Set weights with hash commit", # suffix=f"Failed: {commit_msg}") - return False, f"Failed to commit weights hash. {commit_msg}" + return False, f"Failed to commit weights hash. {commit_msg}", None - async def reveal(self, weight_uids, weight_vals) -> tuple[bool, str]: + async def reveal(self, weight_uids, weight_vals) -> tuple[bool, str, Optional[str]]: # Attempt to reveal the weights using the salt. - success, msg = await self.reveal_weights_extrinsic(weight_uids, weight_vals) + success, msg, ext_id = await self.reveal_weights_extrinsic( + weight_uids, weight_vals + ) if success: if not self.wait_for_finalization and not self.wait_for_inclusion: - return True, "Not waiting for finalization or inclusion." + return True, "Not waiting for finalization or inclusion.", ext_id console.print( ":white_heavy_check_mark: [green]Weights hash revealed on chain[/green]" ) # bittensor.logging.success(prefix="Weights hash revealed", suffix=str(msg)) - return True, "Successfully revealed previously commited weights hash." + return ( + True, + "Successfully revealed previously committed weights hash.", + ext_id, + ) else: # bittensor.logging.error( # msg=msg, - # prefix=f"Failed to reveal previously commited weights hash for salt: {salt}", + # prefix=f"Failed to reveal previously committed weights hash for salt: {salt}", # suffix="Failed: ", # ) - return False, "Failed to reveal weights." + return False, "Failed to reveal weights.", None async def _set_weights_without_commit_reveal( self, weight_uids, weight_vals, - ) -> tuple[bool, str]: - async def _do_set_weights(): + ) -> tuple[bool, str, Optional[str]]: + async def _do_set_weights() -> tuple[bool, str, Optional[str]]: call = await self.subtensor.substrate.compose_call( call_module="SubtensorModule", call_function="set_weights", @@ -261,37 +266,39 @@ async def _do_set_weights(): wait_for_finalization=self.wait_for_finalization, ) except SubstrateRequestException as e: - return False, format_error_message(e) + return False, format_error_message(e), None # We only wait here if we expect finalization. if not self.wait_for_finalization and not self.wait_for_inclusion: - return True, "Not waiting for finalization or inclusion." + return True, "Not waiting for finalization or inclusion.", None if await response.is_success: - return True, "Successfully set weights." + ext_id_ = await response.get_extrinsic_identifier() + await print_extrinsic_id(response) + return True, "Successfully set weights.", ext_id_ else: - return False, format_error_message(await response.error_message) + return False, format_error_message(await response.error_message), None with console.status( f":satellite: Setting weights on [white]{self.subtensor.network}[/white] ..." ): - success, error_message = await _do_set_weights() + success, error_message, ext_id = await _do_set_weights() if not self.wait_for_finalization and not self.wait_for_inclusion: - return True, "Not waiting for finalization or inclusion." + return True, "Not waiting for finalization or inclusion.", None if success: console.print(":white_heavy_check_mark: [green]Finalized[/green]") # bittensor.logging.success(prefix="Set weights", suffix="Finalized: " + str(success)) - return True, "Successfully set weights and finalized." + return True, "Successfully set weights and finalized.", ext_id else: # bittensor.logging.error(msg=error_message, prefix="Set weights", suffix="Failed: ") - return False, error_message + return False, error_message, None async def reveal_weights_extrinsic( self, weight_uids, weight_vals - ) -> tuple[bool, str]: + ) -> tuple[bool, str, Optional[str]]: if self.prompt and not Confirm.ask("Would you like to reveal weights?"): - return False, "User cancelled the operation." + return False, "User cancelled the operation.", None call = await self.subtensor.substrate.compose_call( call_module="SubtensorModule", @@ -315,28 +322,36 @@ async def reveal_weights_extrinsic( wait_for_finalization=self.wait_for_finalization, ) except SubstrateRequestException as e: - return False, format_error_message(e) + return False, format_error_message(e), None if not self.wait_for_finalization and not self.wait_for_inclusion: - success, error_message = True, "" + success, error_message, ext_id = True, "", None else: if await response.is_success: - success, error_message = True, "" + success, error_message, ext_id = ( + True, + "", + await response.get_extrinsic_identifier(), + ) + await print_extrinsic_id(response) else: - success, error_message = ( + success, error_message, ext_id = ( False, format_error_message(await response.error_message), + None, ) if success: # bittensor.logging.info("Successfully revealed weights.") - return True, "Successfully revealed weights." + return True, "Successfully revealed weights.", ext_id else: # bittensor.logging.error(f"Failed to reveal weights: {error_message}") - return False, error_message + return False, error_message, ext_id - async def do_commit_weights(self, commit_hash): + async def do_commit_weights( + self, commit_hash + ) -> tuple[bool, Optional[str], Optional[str]]: call = await self.subtensor.substrate.compose_call( call_module="SubtensorModule", call_function="commit_weights", @@ -356,12 +371,14 @@ async def do_commit_weights(self, commit_hash): ) if not self.wait_for_finalization and not self.wait_for_inclusion: - return True, None + return True, None, None if await response.is_success: - return True, None + ext_id = await response.get_extrinsic_identifier() + await print_extrinsic_id(response) + return True, None, ext_id else: - return False, await response.error_message + return False, await response.error_message, None # commands @@ -398,9 +415,13 @@ async def reveal_weights( extrinsic = SetWeightsExtrinsic( subtensor, wallet, netuid, uids_, weights_, list(salt_), version, prompt=prompt ) - success, message = await extrinsic.reveal(weight_uids, weight_vals) + success, message, ext_id = await extrinsic.reveal(weight_uids, weight_vals) if json_output: - json_console.print(json.dumps({"success": success, "message": message})) + json_console.print( + json.dumps( + {"success": success, "message": message, "extrinsic_identifier": ext_id} + ) + ) else: if success: console.print("Weights revealed successfully") @@ -435,9 +456,13 @@ async def commit_weights( extrinsic = SetWeightsExtrinsic( subtensor, wallet, netuid, uids_, weights_, list(salt_), version, prompt=prompt ) - success, message = await extrinsic.set_weights_extrinsic() + success, message, ext_id = await extrinsic.set_weights_extrinsic() if json_output: - json_console.print(json.dumps({"success": success, "message": message})) + json_console.print( + json.dumps( + {"success": success, "message": message, "extrinsic_identifier": ext_id} + ) + ) else: if success: console.print("Weights set successfully") diff --git a/pyproject.toml b/pyproject.toml index 5a9663334..e3458e2e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,35 +1,50 @@ [build-system] -requires = ["setuptools>=70.0.0", "wheel"] -build-backend = "setuptools.build_meta" +requires = ["flit_core >=3.11,<4"] +build-backend = "flit_core.buildapi" [project] name = "vanta-cli" -version = "2.0.0" +version = "2.0.1" description = "Vanta Network CLI" readme = "README.md" authors = [ {name = "taoshi.io" } ] -license = { file = "LICENSE" } +license = "MIT" scripts = { vanta = "vanta_cli.vanta:main" } requires-python = ">=3.9,<3.14" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: End Users/Desktop", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Scientific/Engineering", + "Topic :: Scientific/Engineering :: Mathematics", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Utilities" +] dependencies = [ "wheel", "collateral_sdk @ git+https://github.com/taoshidev/collateral_sdk.git@1.0.6", - "async-substrate-interface>=1.4.2", - "aiohttp~=3.10.2", + "async-substrate-interface>=1.5.2", + "aiohttp~=3.13", "backoff~=2.2.1", - "click<8.2.0", # typer.testing.CliRunner(mix_stderr=) is broken in click 8.2.0+ "GitPython>=3.0.0", "netaddr~=1.3.0", "numpy>=2.0.1,<3.0.0", "Jinja2", "pycryptodome>=3.0.0,<4.0.0", - "PyYAML~=6.0.1", + "PyYAML~=6.0", "rich>=13.7,<15.0", - "scalecodec>=1.2.11", - "typer>=0.12,<0.16", - "bittensor-wallet>=3.0.7", + "scalecodec==1.2.12", + "typer>=0.16", + "bittensor-wallet>=4.0.0", + "packaging", "plotille>=5.0.0", "plotly>=6.0.0", ] @@ -38,6 +53,8 @@ dependencies = [ cuda = [ "torch>=1.13.1,<3.0", ] + +[dependency-groups] dev = [ "pytest", "pytest-asyncio", @@ -48,23 +65,3 @@ dev = [ # more details can be found here homepage = "https://github.com/taoshidev/vanta-cli" Repository = "https://github.com/taoshidev/vanta-cli" - -[tool.flit.metadata] -classifiers = [ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: End Users/Desktop", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Topic :: Scientific/Engineering", - "Topic :: Scientific/Engineering :: Mathematics", - "Topic :: Scientific/Engineering :: Artificial Intelligence", - "Topic :: Utilities" -] - -[tool.setuptools] -package-dir = { "vanta_cli" = "vanta_cli" } -include-package-data = true diff --git a/tests/e2e_tests/conftest.py b/tests/e2e_tests/conftest.py index 1b93ac0ae..0e1b13cc6 100644 --- a/tests/e2e_tests/conftest.py +++ b/tests/e2e_tests/conftest.py @@ -1,4 +1,5 @@ import asyncio +from collections.abc import Iterator, Callable import logging import os import re @@ -8,11 +9,13 @@ import subprocess import sys import time +from typing import Generator +import bittensor_wallet.keypair import pytest from async_substrate_interface.async_substrate import AsyncSubstrateInterface -from .utils import setup_wallet +from .utils import setup_wallet, ExecCommand LOCALNET_IMAGE_NAME = "ghcr.io/opentensor/subtensor-localnet:devnet-ready" @@ -31,7 +34,7 @@ def wait_for_node_start(process, pattern, timestamp: int = None): # Fixture for setting up and tearing down a localnet.sh chain between tests @pytest.fixture(scope="function") -def local_chain(request): +def local_chain(request) -> Iterator[AsyncSubstrateInterface]: """Determines whether to run the localnet.sh script in a subprocess or a Docker container.""" args = request.param if hasattr(request, "param") else None params = "" if args is None else f"{args}" @@ -58,7 +61,7 @@ def local_chain(request): yield from legacy_runner(request) -def legacy_runner(request): +def legacy_runner(request) -> Iterator[AsyncSubstrateInterface]: param = request.param if hasattr(request, "param") else None # Get the environment variable for the script path script_path = os.getenv("LOCALNET_SH_PATH") @@ -103,7 +106,7 @@ def legacy_runner(request): process.wait() -def docker_runner(params): +def docker_runner(params) -> Iterator[AsyncSubstrateInterface]: """Starts a Docker container before tests and gracefully terminates it after.""" def is_docker_running(): @@ -211,7 +214,19 @@ def try_start_docker(): @pytest.fixture(scope="function") -def wallet_setup(): +def wallet_setup() -> Generator[ + Callable[ + [str], + tuple[ + bittensor_wallet.Keypair, + bittensor_wallet.Wallet, + str, + ExecCommand, + ], + ], + None, + None, +]: wallet_paths = [] def _setup_wallet(uri: str): diff --git a/tests/e2e_tests/test_hyperparams_setting.py b/tests/e2e_tests/test_hyperparams_setting.py new file mode 100644 index 000000000..c336f6615 --- /dev/null +++ b/tests/e2e_tests/test_hyperparams_setting.py @@ -0,0 +1,175 @@ +import asyncio +import json + +from bittensor_cli.src import HYPERPARAMS, RootSudoOnly +from .utils import turn_off_hyperparam_freeze_window + +""" +Verify commands: + +* btcli subnets create +* btcli sudo set +* btcli sudo get +""" + + +def test_hyperparams_setting(local_chain, wallet_setup): + netuid = 2 + wallet_path_alice = "//Alice" + # Create wallet for Alice + keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( + wallet_path_alice + ) + try: + asyncio.run(turn_off_hyperparam_freeze_window(local_chain, wallet_alice)) + except ValueError: + print( + "Skipping turning off hyperparams freeze window. This indicates the call does not exist on the chain you are testing." + ) + # Register a subnet with sudo as Alice + result = exec_command_alice( + command="subnets", + sub_command="create", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--subnet-name", + "Test Subnet", + "--repo", + "https://github.com/username/repo", + "--contact", + "alice@opentensor.dev", + "--url", + "https://testsubnet.com", + "--discord", + "alice#1234", + "--description", + "A test subnet for e2e testing", + "--additional-info", + "Created by Alice", + "--logo-url", + "https://testsubnet.com/logo.png", + "--no-prompt", + "--json-output", + ], + ) + result_output = json.loads(result.stdout) + assert result_output["success"] is True + assert result_output["netuid"] == netuid + assert isinstance(result_output["extrinsic_identifier"], str) + + # Fetch the hyperparameters of the subnet + hyperparams = exec_command_alice( + command="sudo", + sub_command="get", + extra_args=[ + "--network", + "ws://127.0.0.1:9945", + "--netuid", + netuid, + "--json-out", + ], + ) + + # Parse all hyperparameters and single out max_burn in TAO + all_hyperparams = json.loads(hyperparams.stdout) + hp = {} + for hyperparam in all_hyperparams: + hp[hyperparam["hyperparameter"]] = hyperparam["value"] + for key, (_, sudo_only) in HYPERPARAMS.items(): + if key in hp.keys() and sudo_only == RootSudoOnly.FALSE: + if isinstance(hp[key], bool): + new_val = not hp[key] + elif isinstance(hp[key], int): + if hp[key] < 100: + new_val = hp[key] + 1 + else: + new_val = hp[key] - 1 + else: + raise ValueError( + f"Unrecognized hyperparameter value type: {key}: {hp[key]}" + ) + cmd = exec_command_alice( + command="sudo", + sub_command="set", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--netuid", + netuid, + "--json-out", + "--no-prompt", + "--param", + key, + "--value", + new_val, + ], + ) + cmd_json = json.loads(cmd.stdout) + assert cmd_json["success"] is True, (key, new_val, cmd.stdout, cmd_json) + assert isinstance(cmd_json["extrinsic_identifier"], str) + print(f"Successfully set hyperparameter {key} to value {new_val}") + # also test hidden hyperparam + cmd = exec_command_alice( + command="sudo", + sub_command="set", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--netuid", + netuid, + "--json-out", + "--no-prompt", + "--param", + "min_allowed_uids", + "--value", + "110", + ], + ) + cmd_json = json.loads(cmd.stdout) + assert cmd_json["success"] is True, (cmd.stdout, cmd_json) + assert isinstance(cmd_json["extrinsic_identifier"], str) + print("Successfully set hyperparameters") + print("Testing trimming UIDs") + cmd = exec_command_alice( + command="sudo", + sub_command="trim", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--network", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--netuid", + netuid, + "--max", + "120", + "--json-out", + "--no-prompt", + ], + ) + cmd_json = json.loads(cmd.stdout) + assert cmd_json["success"] is True, (cmd.stdout, cmd_json) + assert isinstance(cmd_json["extrinsic_identifier"], str) + print("Successfully trimmed UIDs") diff --git a/tests/e2e_tests/test_liquidity.py b/tests/e2e_tests/test_liquidity.py index c8a7b7d4c..7a210f0a1 100644 --- a/tests/e2e_tests/test_liquidity.py +++ b/tests/e2e_tests/test_liquidity.py @@ -1,7 +1,10 @@ +import asyncio import json import re +import time from bittensor_cli.src.bittensor.balances import Balance +from .utils import turn_off_hyperparam_freeze_window """ Verify commands: @@ -14,25 +17,6 @@ def test_liquidity(local_chain, wallet_setup): - def liquidity_list(): - return exec_command_alice( - command="liquidity", - sub_command="list", - extra_args=[ - "--wallet-path", - wallet_path_alice, - "--chain", - "ws://127.0.0.1:9945", - "--wallet-name", - wallet_alice.name, - "--wallet-hotkey", - wallet_alice.hotkey_str, - "--netuid", - netuid, - "--json-output", - ], - ) - wallet_path_alice = "//Alice" netuid = 2 @@ -40,6 +24,13 @@ def liquidity_list(): keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( wallet_path_alice ) + try: + asyncio.run(turn_off_hyperparam_freeze_window(local_chain, wallet_alice)) + except ValueError: + print( + "Skipping turning off hyperparams freeze window. This indicates the call does not exist on the chain you are testing." + ) + time.sleep(10) # Register a subnet with sudo as Alice result = exec_command_alice( @@ -77,9 +68,26 @@ def liquidity_list(): result_output = json.loads(result.stdout) assert result_output["success"] is True assert result_output["netuid"] == netuid + assert isinstance(result_output["extrinsic_identifier"], str) # verify no results for list thus far (subnet not yet started) - liquidity_list_result = liquidity_list() + liquidity_list_result = exec_command_alice( + command="liquidity", + sub_command="list", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--netuid", + netuid, + "--json-output", + ], + ) result_output = json.loads(liquidity_list_result.stdout) assert result_output["success"] is False assert f"Subnet with netuid: {netuid} is not active" in result_output["err_msg"] @@ -107,11 +115,53 @@ def liquidity_list(): f"Successfully started subnet {netuid}'s emission schedule" in start_subnet_emissions.stdout ), start_subnet_emissions.stderr + assert "Your extrinsic has been included " in start_subnet_emissions.stdout - liquidity_list_result = liquidity_list() + stake_to_enable_v3 = exec_command_alice( + command="stake", + sub_command="add", + extra_args=[ + "--netuid", + "2", + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--hotkey", + wallet_alice.hotkey_str, + "--chain", + "ws://127.0.0.1:9945", + "--amount", + "1", + "--unsafe", + "--no-prompt", + "--era", + "144", + ], + ) + assert "✅ Finalized" in stake_to_enable_v3.stdout, stake_to_enable_v3.stderr + time.sleep(10) + liquidity_list_result = exec_command_alice( + command="liquidity", + sub_command="list", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--netuid", + netuid, + "--json-output", + ], + ) + print(">>>", liquidity_list_result.stdout, liquidity_list_result.stderr) result_output = json.loads(liquidity_list_result.stdout) - assert result_output["success"] is True - assert result_output["err_msg"] == "" + assert result_output["success"] is False + assert result_output["err_msg"] == "No liquidity positions found." assert result_output["positions"] == [] enable_user_liquidity = exec_command_alice( @@ -138,6 +188,7 @@ def liquidity_list(): ) enable_user_liquidity_result = json.loads(enable_user_liquidity.stdout) assert enable_user_liquidity_result["success"] is True + assert isinstance(enable_user_liquidity_result["extrinsic_identifier"], str) add_liquidity = exec_command_alice( command="liquidity", @@ -166,14 +217,31 @@ def liquidity_list(): add_liquidity_result = json.loads(add_liquidity.stdout) assert add_liquidity_result["success"] is True assert add_liquidity_result["message"] == "" + assert isinstance(add_liquidity_result["extrinsic_identifier"], str) - liquidity_list_result = liquidity_list() + liquidity_list_result = exec_command_alice( + command="liquidity", + sub_command="list", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--netuid", + netuid, + "--json-output", + ], + ) + print(">>>", liquidity_list_result.stdout, liquidity_list_result.stderr) liquidity_list_result = json.loads(liquidity_list_result.stdout) assert liquidity_list_result["success"] is True assert len(liquidity_list_result["positions"]) == 1 liquidity_position = liquidity_list_result["positions"][0] assert liquidity_position["liquidity"] == 1.0 - assert liquidity_position["id"] == 2 assert liquidity_position["fees_tao"] == 0.0 assert liquidity_position["fees_alpha"] == 0.0 assert liquidity_position["netuid"] == netuid @@ -204,11 +272,29 @@ def liquidity_list(): ) modify_liquidity_result = json.loads(modify_liquidity.stdout) assert modify_liquidity_result["success"] is True + assert isinstance(modify_liquidity_result["extrinsic_identifier"], str) - liquidity_list_result = json.loads(liquidity_list().stdout) + llr = exec_command_alice( + command="liquidity", + sub_command="list", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--netuid", + netuid, + "--json-output", + ], + ) + print(">>>", llr.stdout, llr.stderr) + liquidity_list_result = json.loads(llr.stdout) assert len(liquidity_list_result["positions"]) == 1 liquidity_position = liquidity_list_result["positions"][0] - assert liquidity_position["id"] == 2 assert liquidity_position["liquidity"] == 21.0 removal = exec_command_alice( @@ -232,7 +318,29 @@ def liquidity_list(): ) removal_result = json.loads(removal.stdout) assert removal_result[str(liquidity_position["id"])]["success"] is True + assert isinstance( + removal_result[str(liquidity_position["id"])]["extrinsic_identifier"], str + ) - liquidity_list_result = json.loads(liquidity_list().stdout) - assert liquidity_list_result["success"] is True + liquidity_list_result = exec_command_alice( + command="liquidity", + sub_command="list", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--netuid", + netuid, + "--json-output", + ], + ) + print(">>>", liquidity_list_result.stdout, liquidity_list_result.stderr) + liquidity_list_result = json.loads(liquidity_list_result.stdout) + assert liquidity_list_result["success"] is False + assert result_output["err_msg"] == "No liquidity positions found." assert liquidity_list_result["positions"] == [] diff --git a/tests/e2e_tests/test_senate.py b/tests/e2e_tests/test_senate.py index 4cf12bb2b..868f39020 100644 --- a/tests/e2e_tests/test_senate.py +++ b/tests/e2e_tests/test_senate.py @@ -9,10 +9,14 @@ import asyncio import json +import pytest from .utils import call_add_proposal +@pytest.mark.skip( + reason="See: https://github.com/opentensor/bittensor/pull/3102. Skipping until new governance is set up." +) def test_senate(local_chain, wallet_setup): """ Test the senate functionality in Bittensor @@ -77,6 +81,9 @@ def test_senate(local_chain, wallet_setup): ], ) assert "✅ Registered" in root_register.stdout, root_register.stderr + assert "Your extrinsic has been included " in root_register.stdout, ( + root_register.stderr + ) # Fetch the senate members after registering to root root_senate_after_reg = exec_command_bob( @@ -156,6 +163,7 @@ def test_senate(local_chain, wallet_setup): ], ) assert "✅ Vote cast" in vote_aye.stdout + assert "Your extrinsic has been included " in vote_aye.stdout # Fetch proposals after voting aye proposals_after_aye = exec_command_bob( @@ -219,6 +227,7 @@ def test_senate(local_chain, wallet_setup): ], ) assert "✅ Registered" in root_register.stdout + assert "Your extrinsic has been included " in root_register.stdout # Vote on the proposal by Alice (vote nay) vote_nay = exec_command_alice( @@ -240,6 +249,7 @@ def test_senate(local_chain, wallet_setup): ], ) assert "✅ Vote cast" in vote_nay.stdout + assert "Your extrinsic has been included " in vote_nay.stdout # Fetch proposals after voting proposals_after_nay = exec_command_bob( diff --git a/tests/e2e_tests/test_set_identity.py b/tests/e2e_tests/test_set_identity.py new file mode 100644 index 000000000..9c008cdcd --- /dev/null +++ b/tests/e2e_tests/test_set_identity.py @@ -0,0 +1,201 @@ +import json +from unittest.mock import MagicMock, AsyncMock, patch + + +""" +Verify commands: +* btcli s create +* btcli s set-identity +* btcli s get-identity +""" + + +def test_set_id(local_chain, wallet_setup): + """ + Tests that the user is prompted to confirm that the incorrect text/html URL is + indeed the one they wish to set as their logo URL, and that when the MIME type is 'image/jpeg' + they are not given this prompt. + """ + wallet_path_alice = "//Alice" + netuid = 2 + + # Create wallet for Alice + keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( + wallet_path_alice + ) + # Register a subnet with sudo as Alice + result = exec_command_alice( + command="subnets", + sub_command="create", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet_alice.name, + "--wallet-hotkey", + wallet_alice.hotkey_str, + "--subnet-name", + "Test Subnet", + "--repo", + "https://github.com/username/repo", + "--contact", + "alice@opentensor.dev", + "--url", + "https://testsubnet.com", + "--discord", + "alice#1234", + "--description", + "A test subnet for e2e testing", + "--additional-info", + "Created by Alice", + "--logo-url", + "https://testsubnet.com/logo.png", + "--no-prompt", + "--json-output", + ], + ) + result_output = json.loads(result.stdout) + assert result_output["success"] is True + + mock_response = MagicMock() + mock_response.status = 200 + mock_response.content_type = "text/html" # bad MIME type + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=None) + + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_response) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + + with patch("aiohttp.ClientSession", return_value=mock_session): + set_identity = exec_command_alice( + "subnets", + "set-identity", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--hotkey", + wallet_alice.hotkey_str, + "--chain", + "ws://127.0.0.1:9945", + "--netuid", + str(netuid), + "--subnet-name", + sn_name := "Test Subnet", + "--github-repo", + sn_github := "https://github.com/username/repo", + "--subnet-contact", + sn_contact := "alice@opentensor.dev", + "--subnet-url", + sn_url := "https://testsubnet.com", + "--discord", + sn_discord := "alice#1234", + "--description", + sn_description := "A test subnet for e2e testing", + "--logo-url", + sn_logo_url := "https://testsubnet.com/logo.png", + "--additional-info", + sn_add_info := "Created by Alice", + "--prompt", + ], + inputs=["Y", "Y"], + ) + assert ( + f"Are you sure you want to use {sn_logo_url} as your image URL?" + in set_identity.stdout + ) + get_identity = exec_command_alice( + "subnets", + "get-identity", + extra_args=[ + "--chain", + "ws://127.0.0.1:9945", + "--netuid", + netuid, + "--json-output", + ], + ) + get_identity_output = json.loads(get_identity.stdout) + assert get_identity_output["subnet_name"] == sn_name + assert get_identity_output["github_repo"] == sn_github + assert get_identity_output["subnet_contact"] == sn_contact + assert get_identity_output["subnet_url"] == sn_url + assert get_identity_output["discord"] == sn_discord + assert get_identity_output["description"] == sn_description + assert get_identity_output["logo_url"] == sn_logo_url + assert get_identity_output["additional"] == sn_add_info + + mock_response = MagicMock() + mock_response.status = 200 + mock_response.content_type = "image/jpeg" # good MIME type + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=None) + + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_response) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + with patch("aiohttp.ClientSession", return_value=mock_session): + set_identity = exec_command_alice( + "subnets", + "set-identity", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--hotkey", + wallet_alice.hotkey_str, + "--chain", + "ws://127.0.0.1:9945", + "--netuid", + str(netuid), + "--subnet-name", + sn_name := "Test Subnet", + "--github-repo", + sn_github := "https://github.com/username/repo", + "--subnet-contact", + sn_contact := "alice@opentensor.dev", + "--subnet-url", + sn_url := "https://testsubnet.com", + "--discord", + sn_discord := "alice#1234", + "--description", + sn_description := "A test subnet for e2e testing", + "--logo-url", + sn_logo_url := "https://testsubnet.com/logo.png", + "--additional-info", + sn_add_info := "Created by Alice", + "--prompt", + ], + inputs=["Y"], + ) + assert ( + f"Are you sure you want to use {sn_logo_url} as your image URL?" + not in set_identity.stdout + ) + get_identity = exec_command_alice( + "subnets", + "get-identity", + extra_args=[ + "--chain", + "ws://127.0.0.1:9945", + "--netuid", + netuid, + "--json-output", + ], + ) + get_identity_output = json.loads(get_identity.stdout) + assert get_identity_output["subnet_name"] == sn_name + assert get_identity_output["github_repo"] == sn_github + assert get_identity_output["subnet_contact"] == sn_contact + assert get_identity_output["subnet_url"] == sn_url + assert get_identity_output["discord"] == sn_discord + assert get_identity_output["description"] == sn_description + assert get_identity_output["logo_url"] == sn_logo_url + assert get_identity_output["additional"] == sn_add_info diff --git a/tests/e2e_tests/test_staking_sudo.py b/tests/e2e_tests/test_staking_sudo.py index 8cb5caca4..6bcaa60cc 100644 --- a/tests/e2e_tests/test_staking_sudo.py +++ b/tests/e2e_tests/test_staking_sudo.py @@ -1,7 +1,10 @@ +import asyncio import json import re +from typing import Union from bittensor_cli.src.bittensor.balances import Balance +from .utils import turn_off_hyperparam_freeze_window """ Verify commands: @@ -10,7 +13,9 @@ * btcli subnets create * btcli subnets set-identity * btcli subnets get-identity +* btcli subnets set-symbol * btcli subnets register +* btcli subnets price * btcli stake add * btcli stake remove * btcli stake show @@ -43,6 +48,12 @@ def test_staking(local_chain, wallet_setup): keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = wallet_setup( wallet_path_alice ) + try: + asyncio.run(turn_off_hyperparam_freeze_window(local_chain, wallet_alice)) + except ValueError: + print( + "Skipping turning off hyperparams freeze window. This indicates the call does not exist on the chain you are testing." + ) burn_cost = exec_command_alice( "subnets", @@ -95,6 +106,7 @@ def test_staking(local_chain, wallet_setup): result_output = json.loads(result.stdout) assert result_output["success"] is True assert result_output["netuid"] == netuid + assert isinstance(result_output["extrinsic_identifier"], str) # Register another subnet with sudo as Alice result_for_second_repo = exec_command_alice( @@ -132,6 +144,7 @@ def test_staking(local_chain, wallet_setup): result_output_second = json.loads(result_for_second_repo.stdout) assert result_output_second["success"] is True assert result_output_second["netuid"] == multiple_netuids[1] + assert isinstance(result_output_second["extrinsic_identifier"], str) # Register Alice in netuid = 2 using her hotkey register_subnet = exec_command_alice( @@ -152,6 +165,7 @@ def test_staking(local_chain, wallet_setup): ], ) assert "✅ Already Registered" in register_subnet.stdout + assert "Your extrinsic has been included" not in register_subnet.stdout register_subnet_json = exec_command_alice( command="subnets", @@ -174,6 +188,7 @@ def test_staking(local_chain, wallet_setup): register_subnet_json_output = json.loads(register_subnet_json.stdout) assert register_subnet_json_output["success"] is True assert register_subnet_json_output["msg"] == "Already registered" + assert register_subnet_json_output["extrinsic_identifier"] is None # set identity set_identity = exec_command_alice( @@ -212,6 +227,7 @@ def test_staking(local_chain, wallet_setup): ) set_identity_output = json.loads(set_identity.stdout) assert set_identity_output["success"] is True + assert isinstance(set_identity_output["extrinsic_identifier"], str) get_identity = exec_command_alice( "subnets", @@ -234,6 +250,54 @@ def test_staking(local_chain, wallet_setup): assert get_identity_output["logo_url"] == sn_logo_url assert get_identity_output["additional"] == sn_add_info + # set symbol + set_symbol = exec_command_alice( + "subnets", + "set-symbol", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--hotkey", + wallet_alice.hotkey_str, + "--chain", + "ws://127.0.0.1:9945", + "--netuid", + netuid, + "--json-output", + "--no-prompt", + "シ", + ], + ) + set_symbol_output = json.loads(set_symbol.stdout) + assert set_symbol_output["success"] is True, set_symbol_output + assert set_symbol_output["success"] is True, set_symbol_output + assert ( + set_symbol_output["message"] + == f"Successfully updated SN{netuid}'s symbol to シ." + ) + assert isinstance(set_identity_output["extrinsic_identifier"], str) + + get_s_price = exec_command_alice( + "subnets", + "price", + extra_args=[ + "--chain", + "ws://127.0.0.1:9945", + "--netuid", + netuid, + "--current", + "--json-output", + ], + ) + get_s_price_output = json.loads(get_s_price.stdout) + assert str(netuid) in get_s_price_output.keys() + stats = get_s_price_output[str(netuid)]["stats"] + assert stats["name"] == sn_name + assert stats["current_price"] == 0.0 + assert stats["market_cap"] == 0.0 + # Start emissions on SNs for netuid_ in multiple_netuids: start_subnet_emissions = exec_command_alice( @@ -257,6 +321,35 @@ def test_staking(local_chain, wallet_setup): f"Successfully started subnet {netuid_}'s emission schedule" in start_subnet_emissions.stdout ), start_subnet_emissions.stderr + assert "Your extrinsic has been included" in start_subnet_emissions.stdout, ( + start_subnet_emissions.stdout + ) + + # Add initial stake to enable V3 + for netuid_ in multiple_netuids: + stake_to_enable_v3 = exec_command_alice( + command="stake", + sub_command="add", + extra_args=[ + "--netuid", + netuid_, + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--hotkey", + wallet_alice.hotkey_str, + "--chain", + "ws://127.0.0.1:9945", + "--amount", + "1", + "--unsafe", + "--no-prompt", + "--era", + "144", + ], + ) + assert "✅ Finalized" in stake_to_enable_v3.stdout, stake_to_enable_v3.stderr # Add stake to Alice's hotkey add_stake_single = exec_command_alice( @@ -284,6 +377,9 @@ def test_staking(local_chain, wallet_setup): ], ) assert "✅ Finalized" in add_stake_single.stdout, add_stake_single.stderr + assert "Your extrinsic has been included" in add_stake_single.stdout, ( + add_stake_single.stdout + ) # Execute stake show for Alice's wallet show_stake_adding_single = exec_command_alice( @@ -351,6 +447,9 @@ def test_staking(local_chain, wallet_setup): ], ) assert "✅ Finalized" in remove_stake.stdout + assert "Your extrinsic has been included" in remove_stake.stdout, ( + remove_stake.stdout + ) add_stake_multiple = exec_command_alice( command="stake", @@ -379,18 +478,15 @@ def test_staking(local_chain, wallet_setup): ) add_stake_multiple_output = json.loads(add_stake_multiple.stdout) for netuid_ in multiple_netuids: - assert ( - add_stake_multiple_output["staking_success"][str(netuid_)][ - wallet_alice.hotkey.ss58_address - ] - is True - ) - assert ( - add_stake_multiple_output["error_messages"][str(netuid_)][ + + def line(key: str) -> Union[str, bool]: + return add_stake_multiple_output[key][str(netuid_)][ wallet_alice.hotkey.ss58_address ] - == "" - ) + + assert line("staking_success") is True + assert line("error_messages") == "" + assert isinstance(line("extrinsic_ids"), str) # Fetch the hyperparameters of the subnet hyperparams = exec_command_alice( @@ -450,6 +546,9 @@ def test_staking(local_chain, wallet_setup): assert ( "✅ Hyperparameter max_burn changed to 10000000000" in change_hyperparams.stdout ) + assert "Your extrinsic has been included" in change_hyperparams.stdout, ( + change_hyperparams.stdout + ) # Fetch the hyperparameters again to verify updated_hyperparams = exec_command_alice( @@ -519,6 +618,7 @@ def test_staking(local_chain, wallet_setup): assert change_yuma3_hyperparam_json["success"] is True, ( change_yuma3_hyperparam.stdout ) + assert isinstance(change_yuma3_hyperparam_json["extrinsic_identifier"], str) changed_yuma3_hyperparam = exec_command_alice( command="sudo", @@ -569,3 +669,4 @@ def test_staking(local_chain, wallet_setup): change_arbitrary_hyperparam.stdout, change_arbitrary_hyperparam.stderr, ) + assert isinstance(change_yuma3_hyperparam_json["extrinsic_identifier"], str) diff --git a/tests/e2e_tests/test_unstaking.py b/tests/e2e_tests/test_unstaking.py index 68af71087..f3173b5a7 100644 --- a/tests/e2e_tests/test_unstaking.py +++ b/tests/e2e_tests/test_unstaking.py @@ -4,7 +4,7 @@ from bittensor_cli.src.bittensor.balances import Balance -from btcli.tests.e2e_tests.utils import set_storage_extrinsic +from .utils import set_storage_extrinsic def test_unstaking(local_chain, wallet_setup): @@ -89,6 +89,7 @@ def test_unstaking(local_chain, wallet_setup): ], ) assert "✅ Registered subnetwork with netuid: 2" in result.stdout + assert "Your extrinsic has been included" in result.stdout, result.stdout # Create second subnet (netuid = 3) result = exec_command_alice( @@ -123,6 +124,7 @@ def test_unstaking(local_chain, wallet_setup): ], ) assert "✅ Registered subnetwork with netuid: 3" in result.stdout + assert "Your extrinsic has been included" in result.stdout, result.stdout # Start emission schedule for subnets start_call_netuid_0 = exec_command_alice( @@ -144,6 +146,9 @@ def test_unstaking(local_chain, wallet_setup): "Successfully started subnet 0's emission schedule." in start_call_netuid_0.stdout ) + assert "Your extrinsic has been included" in start_call_netuid_0.stdout, ( + start_call_netuid_0.stdout + ) start_call_netuid_2 = exec_command_alice( command="subnets", sub_command="start", @@ -163,6 +168,7 @@ def test_unstaking(local_chain, wallet_setup): "Successfully started subnet 2's emission schedule." in start_call_netuid_2.stdout ) + assert "Your extrinsic has been included" in start_call_netuid_2.stdout start_call_netuid_3 = exec_command_alice( command="subnets", @@ -183,6 +189,7 @@ def test_unstaking(local_chain, wallet_setup): "Successfully started subnet 3's emission schedule." in start_call_netuid_3.stdout ) + assert "Your extrinsic has been included" in start_call_netuid_3.stdout # Register Bob in one subnet register_result = exec_command_bob( command="subnets", @@ -204,6 +211,35 @@ def test_unstaking(local_chain, wallet_setup): ], ) assert "✅ Registered" in register_result.stdout, register_result.stderr + assert "Your extrinsic has been included" in register_result.stdout, ( + register_result.stdout + ) + + # Add initial stake to enable V3 + for netuid_ in [0, 2, 3]: + stake_to_enable_v3 = exec_command_bob( + command="stake", + sub_command="add", + extra_args=[ + "--netuid", + netuid_, + "--wallet-path", + wallet_path_bob, + "--wallet-name", + wallet_bob.name, + "--hotkey", + wallet_bob.hotkey_str, + "--chain", + "ws://127.0.0.1:9945", + "--amount", + "1", + "--unsafe", + "--no-prompt", + "--era", + "144", + ], + ) + assert "✅ Finalized" in stake_to_enable_v3.stdout, stake_to_enable_v3.stderr # Add stake to subnets for netuid in [0, 2, 3]: @@ -232,6 +268,9 @@ def test_unstaking(local_chain, wallet_setup): ], ) assert "✅ Finalized" in stake_result.stdout, stake_result.stderr + assert "Your extrinsic has been included" in stake_result.stdout, ( + stake_result.stdout + ) stake_list = exec_command_bob( command="stake", @@ -279,6 +318,9 @@ def test_unstaking(local_chain, wallet_setup): ], ) assert "✅ Finalized" in partial_unstake_netuid_2.stdout + assert "Your extrinsic has been included" in partial_unstake_netuid_2.stdout, ( + partial_unstake_netuid_2.stdout + ) # Verify partial unstake stake_list = exec_command_bob( @@ -348,6 +390,9 @@ def test_unstaking(local_chain, wallet_setup): assert ( "✅ Finalized: Successfully unstaked all Alpha stakes" in unstake_alpha.stdout ) + assert "Your extrinsic has been included" in unstake_alpha.stdout, ( + unstake_alpha.stdout + ) # Add stake again to subnets for netuid in [0, 2, 3]: @@ -376,6 +421,7 @@ def test_unstaking(local_chain, wallet_setup): ], ) assert "✅ Finalized" in stake_result.stdout + assert "Your extrinsic has been included" in stake_result.stdout # Remove all stakes unstake_all = exec_command_bob( @@ -397,4 +443,5 @@ def test_unstaking(local_chain, wallet_setup): ], ) assert "✅ Finalized: Successfully unstaked all stakes from" in unstake_all.stdout + assert "Your extrinsic has been included" in unstake_all.stdout, unstake_all.stdout print("Passed unstaking tests 🎉") diff --git a/tests/e2e_tests/test_wallet_creations.py b/tests/e2e_tests/test_wallet_creations.py index 019cad6b5..bf613120e 100644 --- a/tests/e2e_tests/test_wallet_creations.py +++ b/tests/e2e_tests/test_wallet_creations.py @@ -16,6 +16,7 @@ * btcli w regen_coldkey * btcli w regen_coldkeypub * btcli w regen_hotkey +* btcli w regen_hotkeypub """ @@ -327,7 +328,7 @@ def test_wallet_creations(wallet_setup): command="wallet", sub_command="list", extra_args=["--wallet-path", wallet_path] ) - # Verify hotkey "new_hotkey" is displyed with key + # Verify hotkey "new_hotkey" is displayed with key verify_key_pattern(result.stdout, "new_hotkey") # Physically verify "new_coldkey" and "new_hotkey" are present @@ -542,6 +543,36 @@ def test_wallet_regen(wallet_setup, capfd): ) print("Passed wallet regen_hotkey command ✅") + hotkeypub_path = os.path.join( + wallet_path, "new_wallet", "hotkeys", "new_hotkeypub.txt" + ) + initial_hotkeypub_mod_time = os.path.getmtime(hotkeypub_path) + result = exec_command( + command="wallet", + sub_command="regen-hotkeypub", + extra_args=[ + "--wallet-name", + "new_wallet", + "--hotkey", + "new_hotkey", + "--wallet-path", + wallet_path, + "--ss58-address", + ss58_address, + "--overwrite", + ], + ) + + # Wait a bit to ensure file system updates modification time + time.sleep(2) + + new_hotkeypub_mod_time = os.path.getmtime(hotkeypub_path) + + assert initial_hotkeypub_mod_time != new_hotkeypub_mod_time, ( + "Hotkey file was not regenerated as expected" + ) + print("Passed wallet regen_hotkeypub command ✅") + def test_wallet_balance_all(local_chain, wallet_setup, capfd): """ diff --git a/tests/e2e_tests/test_wallet_interactions.py b/tests/e2e_tests/test_wallet_interactions.py index e6a4bb22d..3b92c4965 100644 --- a/tests/e2e_tests/test_wallet_interactions.py +++ b/tests/e2e_tests/test_wallet_interactions.py @@ -421,7 +421,7 @@ def test_wallet_identities(local_chain, wallet_setup): wallet_alice.name, "--wallet-hotkey", wallet_alice.hotkey_str, - "--name", + "--id-name", alice_identity["name"], "--web-url", alice_identity["url"], @@ -443,17 +443,19 @@ def test_wallet_identities(local_chain, wallet_setup): assert "✅ Success!" in set_id.stdout set_id_output = set_id.stdout.splitlines() - assert alice_identity["name"] in set_id_output[6] - assert alice_identity["url"] in set_id_output[7] - assert alice_identity["github_repo"] in set_id_output[8] - assert alice_identity["image"] in set_id_output[9] - assert alice_identity["discord"] in set_id_output[10] - assert alice_identity["description"] in set_id_output[11] - assert alice_identity["additional"] in set_id_output[12] + assert "Your extrinsic has been included as" in set_id_output[1] + + assert alice_identity["name"] in set_id_output[7] + assert alice_identity["url"] in set_id_output[8] + assert alice_identity["github_repo"] in set_id_output[9] + assert alice_identity["image"] in set_id_output[10] + assert alice_identity["discord"] in set_id_output[11] + assert alice_identity["description"] in set_id_output[12] + assert alice_identity["additional"] in set_id_output[13] # TODO: Currently coldkey + hotkey are the same for test wallets. # Maybe we can add a new key to help in distinguishing - assert wallet_alice.coldkeypub.ss58_address in set_id_output[5] + assert wallet_alice.coldkeypub.ss58_address in set_id_output[6] # Execute btcli get-identity using hotkey get_identity = exec_command_alice( diff --git a/tests/e2e_tests/utils.py b/tests/e2e_tests/utils.py index 7a3c0993f..323797356 100644 --- a/tests/e2e_tests/utils.py +++ b/tests/e2e_tests/utils.py @@ -1,15 +1,19 @@ +import importlib import inspect import os import re import shutil import subprocess import sys -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, Protocol -from bittensor_cli.cli import CLIManager from bittensor_wallet import Keypair, Wallet +from click.testing import Result +from packaging.version import parse as parse_version, Version from typer.testing import CliRunner +from bittensor_cli.cli import CLIManager + if TYPE_CHECKING: from async_substrate_interface.async_substrate import AsyncSubstrateInterface @@ -17,7 +21,19 @@ templates_repo = "templates repository" -def setup_wallet(uri: str): +class ExecCommand(Protocol): + """Type Protocol for setup_wallet's exec_command fn""" + + def __call__( + self, + command: str, + sub_command: str, + extra_args: Optional[list[str]] = None, + inputs: Optional[list[str]] = None, + ) -> Result: ... + + +def setup_wallet(uri: str) -> tuple[Keypair, Wallet, str, ExecCommand]: keypair = Keypair.create_from_uri(uri) wallet_path = f"/tmp/btcli-e2e-wallet-{uri.strip('/')}" wallet = Wallet(path=wallet_path) @@ -29,7 +45,7 @@ def exec_command( command: str, sub_command: str, extra_args: Optional[list[str]] = None, - inputs: list[str] = None, + inputs: Optional[list[str]] = None, ): extra_args = extra_args or [] cli_manager = CLIManager() @@ -55,7 +71,10 @@ def exec_command( extra_args.extend(["--network", "ws://127.0.0.1:9945"]) # Capture stderr separately from stdout - runner = CliRunner(mix_stderr=False) + if parse_version(importlib.metadata.version("click")) < Version("8.2.0"): + runner = CliRunner(mix_stderr=False) + else: + runner = CliRunner() # Prepare the command arguments args = [ command, @@ -364,3 +383,29 @@ async def set_storage_extrinsic( print(":white_heavy_check_mark: [dark_sea_green_3]Success[/dark_sea_green_3]") return response + + +async def turn_off_hyperparam_freeze_window( + substrate: "AsyncSubstrateInterface", wallet: Wallet +): + call = await substrate.compose_call( + call_module="Sudo", + call_function="sudo", + call_params={ + "call": await substrate.compose_call( + call_module="AdminUtils", + call_function="sudo_set_admin_freeze_window", + call_params={"window": 0}, + ) + }, + ) + extrinsic = await substrate.create_signed_extrinsic( + call=call, keypair=wallet.coldkey + ) + response = await substrate.submit_extrinsic( + extrinsic, + wait_for_inclusion=True, + wait_for_finalization=True, + ) + + return await response.is_success, await response.error_message diff --git a/tests/unit_tests/test_cli.py b/tests/unit_tests/test_cli.py index b7933e226..a17ed8406 100644 --- a/tests/unit_tests/test_cli.py +++ b/tests/unit_tests/test_cli.py @@ -1,8 +1,8 @@ import pytest import typer -from bittensor_cli.cli import parse_mnemonic -from unittest.mock import AsyncMock, patch, MagicMock +from bittensor_cli.cli import parse_mnemonic, CLIManager +from unittest.mock import AsyncMock, patch, MagicMock, Mock def test_parse_mnemonic(): @@ -51,3 +51,180 @@ async def test_subnet_sets_price_correctly(): ) mock_price_method.assert_awaited_once_with(netuid=1, block_hash=None) assert subnet_info.price == mock_price + + +@patch("bittensor_cli.cli.Confirm") +@patch("bittensor_cli.cli.console") +def test_swap_hotkey_netuid_0_warning_with_prompt(mock_console, mock_confirm): + """ + Test that swap_hotkey shows warning when netuid=0 and prompt=True, + and exits when user declines confirmation + """ + # Setup + cli_manager = CLIManager() + mock_confirm.ask.return_value = False # User declines + + # Mock dependencies to prevent actual execution + with ( + patch.object(cli_manager, "verbosity_handler"), + patch.object(cli_manager, "wallet_ask") as mock_wallet_ask, + patch.object(cli_manager, "initialize_chain"), + ): + mock_wallet_ask.return_value = Mock() + + # Call the method with netuid=0 and prompt=True + result = cli_manager.wallet_swap_hotkey( + wallet_name="test_wallet", + wallet_path="/tmp/test", + wallet_hotkey="old_hotkey", + netuid=0, + all_netuids=False, + network=None, + destination_hotkey_name="new_hotkey", + quiet=False, + verbose=False, + prompt=True, + json_output=False, + ) + + # Assert: Warning was displayed (4 console.print calls for the warning) + assert mock_console.print.call_count >= 4 + warning_calls = [str(call) for call in mock_console.print.call_args_list] + assert any( + "WARNING" in str(call) and "netuid 0" in str(call) for call in warning_calls + ) + assert any("root network" in str(call) for call in warning_calls) + assert any( + "NOT move child hotkey delegation" in str(call) for call in warning_calls + ) + + # Assert: User was asked to confirm + mock_confirm.ask.assert_called_once() + confirm_message = mock_confirm.ask.call_args[0][0] + assert "SURE" in confirm_message + assert "netuid 0" in confirm_message or "root network" in confirm_message + + # Assert: Function returned None (early exit) because user declined + assert result is None + + +@patch("bittensor_cli.cli.Confirm") +@patch("bittensor_cli.cli.console") +def test_swap_hotkey_netuid_0_proceeds_with_confirmation(mock_console, mock_confirm): + """ + Test that swap_hotkey proceeds when netuid=0 and user confirms + """ + # Setup + cli_manager = CLIManager() + mock_confirm.ask.return_value = True # User confirms + + # Mock dependencies + with ( + patch.object(cli_manager, "verbosity_handler"), + patch.object(cli_manager, "wallet_ask") as mock_wallet_ask, + patch.object(cli_manager, "initialize_chain"), + patch.object(cli_manager, "_run_command") as mock_run_command, + ): + mock_wallet = Mock() + mock_wallet_ask.return_value = mock_wallet + + # Call the method + cli_manager.wallet_swap_hotkey( + wallet_name="test_wallet", + wallet_path="/tmp/test", + wallet_hotkey="old_hotkey", + netuid=0, + all_netuids=False, + network=None, + destination_hotkey_name="new_hotkey", + quiet=False, + verbose=False, + prompt=True, + json_output=False, + ) + + # Assert: Warning was shown and confirmed + mock_confirm.ask.assert_called_once() + + # Assert: Command execution proceeded + mock_run_command.assert_called_once() + + +@patch("bittensor_cli.cli.console") +def test_swap_hotkey_netuid_0_no_warning_with_no_prompt(mock_console): + """ + Test that swap_hotkey does NOT show warning when prompt=False + """ + # Setup + cli_manager = CLIManager() + + # Mock dependencies + with ( + patch.object(cli_manager, "verbosity_handler"), + patch.object(cli_manager, "wallet_ask") as mock_wallet_ask, + patch.object(cli_manager, "initialize_chain"), + patch.object(cli_manager, "_run_command"), + ): + mock_wallet = Mock() + mock_wallet_ask.return_value = mock_wallet + + # Call the method with prompt=False + cli_manager.wallet_swap_hotkey( + wallet_name="test_wallet", + wallet_path="/tmp/test", + wallet_hotkey="old_hotkey", + netuid=0, + all_netuids=False, + network=None, + destination_hotkey_name="new_hotkey", + quiet=False, + verbose=False, + prompt=False, # No prompt + json_output=False, + ) + + # Assert: No warning messages about netuid 0 + warning_calls = [str(call) for call in mock_console.print.call_args_list] + assert not any( + "WARNING" in str(call) and "netuid 0" in str(call) for call in warning_calls + ) + + +@patch("bittensor_cli.cli.console") +def test_swap_hotkey_netuid_1_no_warning(mock_console): + """ + Test that swap_hotkey does NOT show warning when netuid != 0 + """ + # Setup + cli_manager = CLIManager() + + # Mock dependencies + with ( + patch.object(cli_manager, "verbosity_handler"), + patch.object(cli_manager, "wallet_ask") as mock_wallet_ask, + patch.object(cli_manager, "initialize_chain"), + patch.object(cli_manager, "_run_command"), + ): + mock_wallet = Mock() + mock_wallet_ask.return_value = mock_wallet + + # Call the method with netuid=1 + cli_manager.wallet_swap_hotkey( + wallet_name="test_wallet", + wallet_path="/tmp/test", + wallet_hotkey="old_hotkey", + netuid=1, # Not 0 + all_netuids=False, + network=None, + destination_hotkey_name="new_hotkey", + quiet=False, + verbose=False, + prompt=True, + json_output=False, + ) + + # Assert: No warning messages about netuid 0 + warning_calls = [str(call) for call in mock_console.print.call_args_list] + assert not any( + "WARNING" in str(call) and "netuid 0" in str(call) for call in warning_calls + ) diff --git a/tests/unit_tests/test_utils.py b/tests/unit_tests/test_utils.py index 9aa737032..2f43b4605 100644 --- a/tests/unit_tests/test_utils.py +++ b/tests/unit_tests/test_utils.py @@ -1,5 +1,8 @@ from bittensor_cli.src.bittensor import utils import pytest +from unittest.mock import AsyncMock, patch, MagicMock + +from bittensor_cli.src.bittensor.utils import check_img_mimetype @pytest.mark.parametrize( @@ -27,3 +30,45 @@ ) def test_decode_hex_identity_dict(input_dict, expected_result): assert utils.decode_hex_identity_dict(input_dict) == expected_result + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "img_url,status,content_type,expected_result", + [ + ( + "https://github.com/dougsillars/dougsillars/blob/main/twitter.jpg", + 200, + "text/html", + (False, "text/html", ""), + ), + ( + "https://raw.githubusercontent.com/dougsillars/dougsillars/refs/heads/main/twitter.jpg", + 200, + "image/jpeg", + (True, "image/jpeg", ""), + ), + ( + "https://abs-0.twimg.com/emoji/v2/svg/1f5fv.svg", + 404, + "", + (False, "", "Could not fetch image"), + ), + ], +) +async def test_get_image_url(img_url, status, content_type, expected_result): + mock_response = MagicMock() + mock_response.status = status + mock_response.content_type = content_type + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=None) + + # Create mock session + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_response) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + + # Patch ClientSession + with patch("aiohttp.ClientSession", return_value=mock_session): + assert await check_img_mimetype(img_url) == expected_result diff --git a/vanta_cli/src/commands/asset/select.py b/vanta_cli/src/commands/asset/select.py index e0acd9d8d..7e6343c7b 100644 --- a/vanta_cli/src/commands/asset/select.py +++ b/vanta_cli/src/commands/asset/select.py @@ -40,7 +40,7 @@ async def select( hotkey = wallet.hotkey if prompt: - confirm = typer.confirm(f"Are you sure you want to select asset class {asset} for miner {coldkey.ss58_address}?") + confirm = typer.confirm(f"Are you sure you want to select asset class {asset} for miner {hotkey.ss58_address}?") if not confirm: console.print("[yellow]Asset Class Selection Cancelled[/yellow]") return False