diff --git a/CHANGELOG.md b/CHANGELOG.md index f06cebcbe..cded2eb43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 9.8.0/2025-07-07 + +## What's Changed +* Feat/logo urls in subnet identity by @ibraheem-abe in https://github.com/opentensor/btcli/pull/504 +* Feat/swap hotkey with netuids by @ibraheem-abe in https://github.com/opentensor/btcli/pull/503 +* Backmerge main staging by @ibraheem-abe in https://github.com/opentensor/btcli/pull/508 +* Ensures network local is used if forgotten in e2e tests by @thewhaleking in https://github.com/opentensor/btcli/pull/497 +* Convert hyperparams from strings by @thewhaleking in https://github.com/opentensor/btcli/pull/510 +* Ensure we parse strings for param names by @thewhaleking in https://github.com/opentensor/btcli/pull/511 +* add snake case aliases by @thewhaleking in https://github.com/opentensor/btcli/pull/514 +* Better checks the swap status by @thewhaleking in https://github.com/opentensor/btcli/pull/485 +* Integrate Liquidity Provider feature by @basfroman in https://github.com/opentensor/btcli/pull/515 +* Updates safe staking/unstaking limits by @ibraheem-abe in https://github.com/opentensor/btcli/pull/519 + +**Full Changelog**: https://github.com/opentensor/btcli/compare/v9.7.1...v9.8.0 + ## 9.7.1/2025-06-26 ## What's Changed diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index ecb936e7d..9128f83fe 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 import asyncio -import curses import copy +import curses import importlib import json import os.path @@ -10,13 +10,13 @@ import sys import traceback import warnings +from dataclasses import fields from pathlib import Path from typing import Coroutine, Optional, Union -from dataclasses import fields +import numpy as np import rich import typer -import numpy as np from async_substrate_interface.errors import ( SubstrateRequestException, ConnectionClosed, @@ -39,21 +39,10 @@ COLORS, HYPERPARAMS, ) -from bittensor_cli.version import __version__, __version_as_int__ from bittensor_cli.src.bittensor import utils from bittensor_cli.src.bittensor.balances import Balance -from bittensor_cli.src.commands import sudo, wallets, view -from bittensor_cli.src.commands import weights as weights_cmds -from bittensor_cli.src.commands.subnets import price, subnets -from bittensor_cli.src.commands.stake import ( - children_hotkeys, - list as list_stake, - move as move_stake, - add as add_stake, - remove as remove_stake, -) -from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface from bittensor_cli.src.bittensor.chain_data import SubnetHyperparameters +from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface from bittensor_cli.src.bittensor.utils import ( console, err_console, @@ -70,6 +59,22 @@ prompt_for_subnet_identity, validate_rate_tolerance, ) +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.liquidity.utils import ( + prompt_liquidity, + prompt_position_id, +) +from bittensor_cli.src.commands.stake import ( + children_hotkeys, + list as list_stake, + move as move_stake, + add as add_stake, + remove as remove_stake, +) +from bittensor_cli.src.commands.subnets import price, subnets +from bittensor_cli.version import __version__, __version_as_int__ try: from git import Repo, GitError @@ -656,6 +661,7 @@ def __init__(self): self.subnets_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) # config alias self.app.add_typer( @@ -929,11 +935,13 @@ def __init__(self): )(self.view_dashboard) # Sub command aliases - # Weights + # Wallet self.wallet_app.command( "swap_hotkey", hidden=True, )(self.wallet_swap_hotkey) + self.wallet_app.command("swap_coldkey", hidden=True)(self.wallet_swap_coldkey) + self.wallet_app.command("swap_check", hidden=True)(self.wallet_check_ck_swap) self.wallet_app.command( "regen_coldkey", hidden=True, @@ -962,16 +970,44 @@ def __init__(self): "get_identity", hidden=True, )(self.wallet_get_id) + self.wallet_app.command("associate_hotkey")(self.wallet_associate_hotkey) # Subnets self.subnets_app.command("burn_cost", hidden=True)(self.subnets_burn_cost) self.subnets_app.command("pow_register", hidden=True)(self.subnets_pow_register) + 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) # 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) + # Liquidity + self.app.add_typer( + self.liquidity_app, + name="liquidity", + short_help="liquidity commands, aliases: `l`", + no_args_is_help=True, + ) + self.app.add_typer( + self.liquidity_app, name="l", hidden=True, no_args_is_help=True + ) + # liquidity commands + self.liquidity_app.command( + "add", rich_help_panel=HELP_PANELS["LIQUIDITY"]["LIQUIDITY_MGMT"] + )(self.liquidity_add) + self.liquidity_app.command( + "list", rich_help_panel=HELP_PANELS["LIQUIDITY"]["LIQUIDITY_MGMT"] + )(self.liquidity_list) + self.liquidity_app.command( + "modify", rich_help_panel=HELP_PANELS["LIQUIDITY"]["LIQUIDITY_MGMT"] + )(self.liquidity_modify) + self.liquidity_app.command( + "remove", rich_help_panel=HELP_PANELS["LIQUIDITY"]["LIQUIDITY_MGMT"] + )(self.liquidity_remove) + def generate_command_tree(self) -> Tree: """ Generates a rich.Tree of the commands, subcommands, and groups of this app @@ -1603,7 +1639,8 @@ def wallet_ask( wallet_hotkey: Optional[str], ask_for: Optional[list[str]] = None, validate: WV = WV.WALLET, - ) -> Wallet: + return_wallet_and_hotkey: bool = False, + ) -> Union[Wallet, tuple[Wallet, str]]: """ Generates a wallet object based on supplied values, validating the wallet is valid if flag is set :param wallet_name: name of the wallet @@ -1611,7 +1648,8 @@ def wallet_ask( :param wallet_hotkey: name of the wallet hotkey file :param validate: flag whether to check for the wallet's validity :param ask_for: aspect of the wallet (name, path, hotkey) to prompt the user for - :return: created Wallet object + :param return_wallet_and_hotkey: if specified, will return both the wallet object, and the hotkey SS58 + :return: created Wallet object (or wallet, hotkey ss58) """ ask_for = ask_for or [] # Prompt for missing attributes specified in ask_for @@ -1636,8 +1674,9 @@ def wallet_ask( ) else: wallet_hotkey = Prompt.ask( - "Enter the [blue]wallet hotkey[/blue]" - + " [dark_sea_green3 italic](Hint: You can set this with `btcli config set --wallet-hotkey`)[/dark_sea_green3 italic]", + "Enter the [blue]wallet hotkey[/blue][dark_sea_green3 italic]" + "(Hint: You can set this with `btcli config set --wallet-hotkey`)" + "[/dark_sea_green3 italic]", default=defaults.wallet.hotkey, ) if wallet_path: @@ -1655,7 +1694,8 @@ def wallet_ask( if WO.PATH in ask_for and not wallet_path: wallet_path = Prompt.ask( "Enter the [blue]wallet path[/blue]" - + " [dark_sea_green3 italic](Hint: You can set this with `btcli config set --wallet-path`)[/dark_sea_green3 italic]", + "[dark_sea_green3 italic](Hint: You can set this with `btcli config set --wallet-path`)" + "[/dark_sea_green3 italic]", default=defaults.wallet.path, ) # Create the Wallet object @@ -1679,7 +1719,28 @@ def wallet_ask( f"Please verify your wallet information: {wallet}[/red]" ) raise typer.Exit() - return wallet + if return_wallet_and_hotkey: + valid = utils.is_valid_wallet(wallet) + if valid[1]: + return wallet, wallet.hotkey.ss58_address + else: + if wallet_hotkey and is_valid_ss58_address(wallet_hotkey): + return wallet, wallet_hotkey + else: + hotkey = ( + Prompt.ask( + "Enter the SS58 of the hotkey to use for this transaction." + ) + ).strip() + if not is_valid_ss58_address(hotkey): + err_console.print( + f"[red]Error: {hotkey} is not valid SS58 address." + ) + raise typer.Exit(1) + else: + return wallet, hotkey + else: + return wallet def wallet_list( self, @@ -5025,6 +5086,7 @@ def subnets_create( description: Optional[str] = typer.Option( None, "--description", help="Description" ), + logo_url: Optional[str] = typer.Option(None, "--logo-url", help="Logo URL"), additional_info: Optional[str] = typer.Option( None, "--additional-info", help="Additional information" ), @@ -5067,6 +5129,7 @@ def subnets_create( subnet_url=subnet_url, discord=discord, description=description, + logo_url=logo_url, additional=additional_info, ) self._run_command( @@ -5088,7 +5151,7 @@ def subnets_check_start( This command verifies if a subnet's emission schedule can be started based on the subnet's registration block. Example: - [green]$[/green] btcli subnets check_start --netuid 1 + [green]$[/green] btcli subnets check-start --netuid 1 """ self.verbosity_handler(quiet, verbose) return self._run_command( @@ -5190,6 +5253,7 @@ def subnets_set_identity( description: Optional[str] = typer.Option( None, "--description", help="Description" ), + logo_url: Optional[str] = typer.Option(None, "--logo-url", help="Logo URL"), additional_info: Optional[str] = typer.Option( None, "--additional-info", help="Additional information" ), @@ -5241,6 +5305,7 @@ def subnets_set_identity( subnet_url=subnet_url, discord=discord, description=description, + logo_url=logo_url, additional=additional_info, ) @@ -5752,6 +5817,257 @@ def view_dashboard( ) ) + def liquidity_add( + 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: Optional[int] = Options.netuid, + liquidity_: Optional[float] = typer.Option( + None, + "--liquidity", + help="Amount of liquidity to add to the subnet.", + ), + price_low: Optional[float] = typer.Option( + None, + "--price-low", + "--price_low", + "--liquidity-price-low", + "--liquidity_price_low", + help="Low price for the adding liquidity position.", + ), + price_high: Optional[float] = typer.Option( + None, + "--price-high", + "--price_high", + "--liquidity-price-high", + "--liquidity_price_high", + help="High price for the adding liquidity position.", + ), + prompt: bool = Options.prompt, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """Add liquidity to the swap (as a combination of TAO + Alpha).""" + self.verbosity_handler(quiet, verbose, json_output) + if not netuid: + netuid = Prompt.ask( + f"Enter the [{COLORS.G.SUBHEAD_MAIN}]netuid[/{COLORS.G.SUBHEAD_MAIN}] to use", + default=None, + show_default=False, + ) + + wallet, hotkey = self.wallet_ask( + wallet_name=wallet_name, + wallet_path=wallet_path, + wallet_hotkey=wallet_hotkey, + ask_for=[WO.NAME, WO.HOTKEY, WO.PATH], + validate=WV.WALLET, + return_wallet_and_hotkey=True, + ) + # Determine the liquidity amount. + if liquidity_: + liquidity_ = Balance.from_tao(liquidity_) + else: + liquidity_ = prompt_liquidity("Enter the amount of liquidity") + + # Determine price range + if price_low: + price_low = Balance.from_tao(price_low) + else: + price_low = prompt_liquidity("Enter liquidity position low price") + + if price_high: + price_high = Balance.from_tao(price_high) + else: + price_high = prompt_liquidity( + "Enter liquidity position high price (must be greater than low price)" + ) + + if price_low >= price_high: + err_console.print("The low price must be lower than the high price.") + return False + + return self._run_command( + liquidity.add_liquidity( + subtensor=self.initialize_chain(network), + wallet=wallet, + hotkey_ss58=hotkey, + netuid=netuid, + liquidity=liquidity_, + price_low=price_low, + price_high=price_high, + prompt=prompt, + json_output=json_output, + ) + ) + + def liquidity_list( + 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: Optional[int] = Options.netuid, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """Displays liquidity positions in given subnet.""" + self.verbosity_handler(quiet, verbose, json_output) + if not netuid: + netuid = IntPrompt.ask( + f"Enter the [{COLORS.G.SUBHEAD_MAIN}]netuid[/{COLORS.G.SUBHEAD_MAIN}] to use", + 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, + ) + self._run_command( + liquidity.show_liquidity_list( + subtensor=self.initialize_chain(network), + wallet=wallet, + netuid=netuid, + json_output=json_output, + ) + ) + + def liquidity_remove( + 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: Optional[int] = Options.netuid, + position_id: Optional[int] = typer.Option( + None, + "--position-id", + "--position_id", + help="Position ID for modification or removal.", + ), + all_liquidity_ids: Optional[bool] = typer.Option( + False, + "--all", + "--a", + help="Whether to remove all liquidity positions for given subnet.", + ), + prompt: bool = Options.prompt, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """Remove liquidity from the swap (as a combination of TAO + Alpha).""" + + self.verbosity_handler(quiet, verbose, json_output) + + if all_liquidity_ids and position_id: + print_error("Cannot specify both --all and --position-id.") + return + + if not position_id and not all_liquidity_ids: + position_id = prompt_position_id() + + if not netuid: + netuid = IntPrompt.ask( + f"Enter the [{COLORS.G.SUBHEAD_MAIN}]netuid[/{COLORS.G.SUBHEAD_MAIN}] to use", + default=None, + show_default=False, + ) + + wallet, hotkey = self.wallet_ask( + wallet_name=wallet_name, + wallet_path=wallet_path, + wallet_hotkey=wallet_hotkey, + ask_for=[WO.NAME, WO.HOTKEY, WO.PATH], + validate=WV.WALLET, + return_wallet_and_hotkey=True, + ) + return self._run_command( + liquidity.remove_liquidity( + subtensor=self.initialize_chain(network), + wallet=wallet, + hotkey_ss58=hotkey, + netuid=netuid, + position_id=position_id, + prompt=prompt, + all_liquidity_ids=all_liquidity_ids, + json_output=json_output, + ) + ) + + def liquidity_modify( + 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: Optional[int] = Options.netuid, + position_id: Optional[int] = typer.Option( + None, + "--position-id", + "--position_id", + help="Position ID for modification or removing.", + ), + liquidity_delta: Optional[float] = typer.Option( + None, + "--liquidity-delta", + "--liquidity_delta", + help="Liquidity amount for modification.", + ), + prompt: bool = Options.prompt, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + json_output: bool = Options.json_output, + ): + """Modifies the liquidity position for the given subnet.""" + self.verbosity_handler(quiet, verbose, json_output) + if not netuid: + netuid = IntPrompt.ask( + f"Enter the [{COLORS.G.SUBHEAD_MAIN}]netuid[/{COLORS.G.SUBHEAD_MAIN}] to use", + ) + + wallet, hotkey = self.wallet_ask( + wallet_name=wallet_name, + wallet_path=wallet_path, + wallet_hotkey=wallet_hotkey, + ask_for=[WO.NAME, WO.HOTKEY, WO.PATH], + validate=WV.WALLET, + return_wallet_and_hotkey=True, + ) + + if not position_id: + position_id = prompt_position_id() + + if liquidity_delta: + liquidity_delta = Balance.from_tao(liquidity_delta) + else: + liquidity_delta = prompt_liquidity( + f"Enter the [blue]liquidity delta[/blue] to modify position with id " + f"[blue]{position_id}[/blue] (can be positive or negative)", + negative_allowed=True, + ) + + return self._run_command( + liquidity.modify_liquidity( + subtensor=self.initialize_chain(network), + wallet=wallet, + hotkey_ss58=hotkey, + netuid=netuid, + position_id=position_id, + liquidity_delta=liquidity_delta, + prompt=prompt, + json_output=json_output, + ) + ) + @staticmethod @utils_app.command("convert") def convert( diff --git a/bittensor_cli/src/__init__.py b/bittensor_cli/src/__init__.py index 76041ec71..d7b1a9f70 100644 --- a/bittensor_cli/src/__init__.py +++ b/bittensor_cli/src/__init__.py @@ -660,6 +660,11 @@ class WalletValidationTypes(Enum): ), "yuma3_enabled": ("sudo_set_yuma3_enabled", False), "alpha_sigmoid_steepness": ("sudo_set_alpha_sigmoid_steepness", True), + "user_liquidity_enabled": ("toggle_user_liquidity", True), +} + +HYPERPARAMS_MODULE = { + "user_liquidity_enabled": "Swap", } # Help Panels for cli help @@ -699,6 +704,9 @@ class WalletValidationTypes(Enum): "VIEW": { "DASHBOARD": "Network Dashboard", }, + "LIQUIDITY": { + "LIQUIDITY_MGMT": "Liquidity Management", + }, } diff --git a/bittensor_cli/src/bittensor/chain_data.py b/bittensor_cli/src/bittensor/chain_data.py index 1f9401ce4..6fe7b3da7 100644 --- a/bittensor_cli/src/bittensor/chain_data.py +++ b/bittensor_cli/src/bittensor/chain_data.py @@ -148,13 +148,49 @@ def get(self, item, default=None): @dataclass class SubnetHyperparameters(InfoBase): - """Dataclass for subnet hyperparameters.""" + """ + This class represents the hyperparameters for a subnet. + Attributes: + rho (int): The rate of decay of some value. + kappa (int): A constant multiplier used in calculations. + immunity_period (int): The period during which immunity is active. + min_allowed_weights (int): Minimum allowed weights. + max_weight_limit (float): Maximum weight limit. + tempo (int): The tempo or rate of operation. + min_difficulty (int): Minimum difficulty for some operations. + max_difficulty (int): Maximum difficulty for some operations. + weights_version (int): The version number of the weights used. + weights_rate_limit (int): Rate limit for processing weights. + adjustment_interval (int): Interval at which adjustments are made. + activity_cutoff (int): Activity cutoff threshold. + registration_allowed (bool): Indicates if registration is allowed. + target_regs_per_interval (int): Target number of registrations per interval. + min_burn (int): Minimum burn value. + max_burn (int): Maximum burn value. + bonds_moving_avg (int): Moving average of bonds. + max_regs_per_block (int): Maximum number of registrations per block. + serving_rate_limit (int): Limit on the rate of service. + max_validators (int): Maximum number of validators. + adjustment_alpha (int): Alpha value for adjustments. + difficulty (int): Difficulty level. + commit_reveal_period (int): Interval for commit-reveal weights. + commit_reveal_weights_enabled (bool): Flag indicating if commit-reveal weights are enabled. + alpha_high (int): High value of alpha. + alpha_low (int): Low value of alpha. + liquid_alpha_enabled (bool): Flag indicating if liquid alpha is enabled. + alpha_sigmoid_steepness (float): + yuma_version (int): Version of yuma. + subnet_is_active (bool): Indicates if subnet is active after START CALL. + transfers_enabled (bool): Flag indicating if transfers are enabled. + bonds_reset_enabled (bool): Flag indicating if bonds are reset enabled. + user_liquidity_enabled (bool): Flag indicating if user liquidity is enabled. + """ rho: int kappa: int immunity_period: int min_allowed_weights: int - max_weights_limit: float + max_weight_limit: float tempo: int min_difficulty: int max_difficulty: int @@ -177,43 +213,53 @@ class SubnetHyperparameters(InfoBase): alpha_high: int alpha_low: int liquid_alpha_enabled: bool - yuma3_enabled: bool - alpha_sigmoid_steepness: int + alpha_sigmoid_steepness: float + yuma_version: int + subnet_is_active: bool + transfers_enabled: bool + bonds_reset_enabled: bool + user_liquidity_enabled: bool @classmethod def _fix_decoded( cls, decoded: Union[dict, "SubnetHyperparameters"] ) -> "SubnetHyperparameters": return cls( - rho=decoded.get("rho"), - kappa=decoded.get("kappa"), - immunity_period=decoded.get("immunity_period"), - min_allowed_weights=decoded.get("min_allowed_weights"), - max_weights_limit=decoded.get("max_weights_limit"), - tempo=decoded.get("tempo"), - min_difficulty=decoded.get("min_difficulty"), - max_difficulty=decoded.get("max_difficulty"), - weights_version=decoded.get("weights_version"), - weights_rate_limit=decoded.get("weights_rate_limit"), - adjustment_interval=decoded.get("adjustment_interval"), - activity_cutoff=decoded.get("activity_cutoff"), - registration_allowed=decoded.get("registration_allowed"), - target_regs_per_interval=decoded.get("target_regs_per_interval"), - min_burn=decoded.get("min_burn"), - max_burn=decoded.get("max_burn"), - bonds_moving_avg=decoded.get("bonds_moving_avg"), - max_regs_per_block=decoded.get("max_regs_per_block"), - serving_rate_limit=decoded.get("serving_rate_limit"), - max_validators=decoded.get("max_validators"), - adjustment_alpha=decoded.get("adjustment_alpha"), - difficulty=decoded.get("difficulty"), - commit_reveal_period=decoded.get("commit_reveal_period"), - commit_reveal_weights_enabled=decoded.get("commit_reveal_weights_enabled"), - alpha_high=decoded.get("alpha_high"), - alpha_low=decoded.get("alpha_low"), - liquid_alpha_enabled=decoded.get("liquid_alpha_enabled"), - yuma3_enabled=decoded.get("yuma3_enabled"), - alpha_sigmoid_steepness=decoded.get("alpha_sigmoid_steepness"), + activity_cutoff=decoded["activity_cutoff"], + adjustment_alpha=decoded["adjustment_alpha"], + adjustment_interval=decoded["adjustment_interval"], + alpha_high=decoded["alpha_high"], + alpha_low=decoded["alpha_low"], + alpha_sigmoid_steepness=fixed_to_float( + decoded["alpha_sigmoid_steepness"], frac_bits=32 + ), + bonds_moving_avg=decoded["bonds_moving_avg"], + bonds_reset_enabled=decoded["bonds_reset_enabled"], + commit_reveal_weights_enabled=decoded["commit_reveal_weights_enabled"], + commit_reveal_period=decoded["commit_reveal_period"], + difficulty=decoded["difficulty"], + immunity_period=decoded["immunity_period"], + kappa=decoded["kappa"], + liquid_alpha_enabled=decoded["liquid_alpha_enabled"], + max_burn=decoded["max_burn"], + max_difficulty=decoded["max_difficulty"], + max_regs_per_block=decoded["max_regs_per_block"], + max_validators=decoded["max_validators"], + max_weight_limit=decoded["max_weights_limit"], + min_allowed_weights=decoded["min_allowed_weights"], + min_burn=decoded["min_burn"], + min_difficulty=decoded["min_difficulty"], + registration_allowed=decoded["registration_allowed"], + rho=decoded["rho"], + serving_rate_limit=decoded["serving_rate_limit"], + subnet_is_active=decoded["subnet_is_active"], + target_regs_per_interval=decoded["target_regs_per_interval"], + tempo=decoded["tempo"], + transfers_enabled=decoded["transfers_enabled"], + user_liquidity_enabled=decoded["user_liquidity_enabled"], + weights_rate_limit=decoded["weights_rate_limit"], + weights_version=decoded["weights_version"], + yuma_version=decoded["yuma_version"], ) @@ -630,6 +676,7 @@ class SubnetIdentity(InfoBase): subnet_url: str discord: str description: str + logo_url: str additional: str @classmethod @@ -641,6 +688,7 @@ def _fix_decoded(cls, decoded: dict) -> "SubnetIdentity": subnet_url=bytes(decoded["subnet_url"]).decode(), discord=bytes(decoded["discord"]).decode(), description=bytes(decoded["description"]).decode(), + logo_url=bytes(decoded["logo_url"]).decode(), additional=bytes(decoded["additional"]).decode(), ) diff --git a/bittensor_cli/src/bittensor/subtensor_interface.py b/bittensor_cli/src/bittensor/subtensor_interface.py index 7b632af31..c274246ff 100644 --- a/bittensor_cli/src/bittensor/subtensor_interface.py +++ b/bittensor_cli/src/bittensor/subtensor_interface.py @@ -531,6 +531,33 @@ async def get_netuids_for_hotkey( res.append(record[0]) return res + async def is_subnet_active( + self, + netuid: int, + block_hash: Optional[str] = None, + reuse_block: bool = False, + ) -> bool: + """Verify if subnet with provided netuid is active. + + Args: + netuid (int): The unique identifier of the subnet. + block_hash (Optional[str]): The blockchain block_hash representation of block id. + reuse_block (bool): Whether to reuse the last-used block hash. + + Returns: + True if subnet is active, False otherwise. + + This means whether the `start_call` was initiated or not. + """ + query = await self.substrate.query( + module="SubtensorModule", + storage_function="FirstEmissionBlockNumber", + block_hash=block_hash, + reuse_block_hash=reuse_block, + params=[netuid], + ) + return True if query and query.value > 0 else False + async def subnet_exists( self, netuid: int, block_hash: Optional[str] = None, reuse_block: bool = False ) -> bool: @@ -1128,22 +1155,13 @@ async def get_subnet_hyperparameters( Understanding the hyperparameters is crucial for comprehending how subnets are configured and managed, and how they interact with the network's consensus and incentive mechanisms. """ - main_result, yuma3_result, sigmoid_steepness = await asyncio.gather( - self.query_runtime_api( - runtime_api="SubnetInfoRuntimeApi", - method="get_subnet_hyperparams", - params=[netuid], - block_hash=block_hash, - ), - self.query("SubtensorModule", "Yuma3On", [netuid]), - self.query("SubtensorModule", "AlphaSigmoidSteepness", [netuid]), + result = await self.query_runtime_api( + runtime_api="SubnetInfoRuntimeApi", + method="get_subnet_hyperparams_v2", + params=[netuid], + block_hash=block_hash, ) - result = { - **main_result, - **{"yuma3_enabled": yuma3_result}, - **{"alpha_sigmoid_steepness": sigmoid_steepness}, - } - if not main_result: + if not result: return [] return SubnetHyperparameters.from_any(result) diff --git a/bittensor_cli/src/bittensor/utils.py b/bittensor_cli/src/bittensor/utils.py index 0ea43d727..6ac75cee0 100644 --- a/bittensor_cli/src/bittensor/utils.py +++ b/bittensor_cli/src/bittensor/utils.py @@ -1177,6 +1177,7 @@ def prompt_for_subnet_identity( subnet_url: Optional[str], discord: Optional[str], description: Optional[str], + logo_url: Optional[str], additional: Optional[str], ): """ @@ -1237,6 +1238,13 @@ def prompt_for_subnet_identity( lambda x: x and len(x.encode("utf-8")) > 1024, "[red]Error:[/red] Description must be <= 1024 bytes.", ), + ( + "logo_url", + "[blue]Logo URL [dim](optional)[/blue]", + logo_url, + lambda x: x and len(x.encode("utf-8")) > 1024, + "[red]Error:[/red] Logo URL must be <= 1024 bytes.", + ), ( "additional", "[blue]Additional information [dim](optional)[/blue]", @@ -1371,6 +1379,7 @@ def unlock_key( err_msg = f"The password used to decrypt your {unlock_type.capitalize()}key Keyfile is invalid." if print_out: err_console.print(f":cross_mark: [red]{err_msg}[/red]") + return unlock_key(wallet, unlock_type, print_out) return UnlockStatus(False, err_msg) except KeyFileError: err_msg = f"{unlock_type.capitalize()}key Keyfile is corrupt, non-writable, or non-readable, or non-existent." diff --git a/bittensor_cli/src/commands/liquidity/__init__.py b/bittensor_cli/src/commands/liquidity/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bittensor_cli/src/commands/liquidity/liquidity.py b/bittensor_cli/src/commands/liquidity/liquidity.py new file mode 100644 index 000000000..60f5c6529 --- /dev/null +++ b/bittensor_cli/src/commands/liquidity/liquidity.py @@ -0,0 +1,628 @@ +import asyncio +import json +from typing import TYPE_CHECKING, Optional + +from rich.prompt import Confirm +from rich.table import Column, Table + +from bittensor_cli.src import COLORS +from bittensor_cli.src.bittensor.utils import ( + unlock_key, + console, + err_console, + json_console, +) +from bittensor_cli.src.bittensor.balances import Balance, fixed_to_float +from bittensor_cli.src.commands.liquidity.utils import ( + LiquidityPosition, + calculate_fees, + get_fees, + price_to_tick, + tick_to_price, +) + +if TYPE_CHECKING: + from bittensor_wallet import Wallet + from bittensor_cli.src.bittensor.subtensor_interface import SubtensorInterface + + +async def add_liquidity_extrinsic( + subtensor: "SubtensorInterface", + wallet: "Wallet", + hotkey_ss58: str, + netuid: int, + liquidity: Balance, + price_low: Balance, + price_high: Balance, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, +) -> tuple[bool, str]: + """ + Adds liquidity to the specified price range. + + Arguments: + subtensor: The Subtensor client instance used for blockchain interaction. + wallet: The wallet used to sign the extrinsic (must be unlocked). + hotkey_ss58: the SS58 of the hotkey to use for this transaction. + netuid: The UID of the target subnet for which the call is being initiated. + liquidity: The amount of liquidity to be added. + price_low: The lower bound of the price tick range. + price_high: The upper bound of the price tick range. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. Defaults to True. + wait_for_finalization: Whether to wait for finalization of the extrinsic. Defaults to False. + + Returns: + Tuple[bool, str]: + - True and a success message if the extrinsic is successfully submitted or processed. + - False and an error message if the submission fails or the wallet cannot be unlocked. + + Note: Adding is allowed even when user liquidity is enabled in specified subnet. Call + `toggle_user_liquidity_extrinsic` to enable/disable user liquidity. + """ + if not (unlock := unlock_key(wallet)).success: + return False, unlock.message + + tick_low = price_to_tick(price_low.tao) + tick_high = price_to_tick(price_high.tao) + + call = await subtensor.substrate.compose_call( + call_module="Swap", + call_function="add_liquidity", + call_params={ + "hotkey": hotkey_ss58, + "netuid": netuid, + "tick_low": tick_low, + "tick_high": tick_high, + "liquidity": liquidity.rao, + }, + ) + + return await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + +async def modify_liquidity_extrinsic( + subtensor: "SubtensorInterface", + wallet: "Wallet", + hotkey_ss58: str, + netuid: int, + position_id: int, + liquidity_delta: Balance, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, +) -> tuple[bool, str]: + """Modifies liquidity in liquidity position by adding or removing liquidity from it. + + Arguments: + subtensor: The Subtensor client instance used for blockchain interaction. + wallet: The wallet used to sign the extrinsic (must be unlocked). + hotkey_ss58: the SS58 of the hotkey to use for this transaction. + netuid: The UID of the target subnet for which the call is being initiated. + position_id: The id of the position record in the pool. + liquidity_delta: The amount of liquidity to be added or removed (add if positive or remove if negative). + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. Defaults to True. + wait_for_finalization: Whether to wait for finalization of the extrinsic. Defaults to False. + + Returns: + Tuple[bool, str]: + - True and a success message if the extrinsic is successfully submitted or processed. + - False and an error message if the submission fails or the wallet cannot be unlocked. + + Note: Modifying is allowed even when user liquidity is enabled in specified subnet. + Call `toggle_user_liquidity_extrinsic` to enable/disable user liquidity. + """ + if not (unlock := unlock_key(wallet)).success: + return False, unlock.message + + call = await subtensor.substrate.compose_call( + call_module="Swap", + call_function="modify_position", + call_params={ + "hotkey": hotkey_ss58, + "netuid": netuid, + "position_id": position_id, + "liquidity_delta": liquidity_delta.rao, + }, + ) + + return await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + +async def remove_liquidity_extrinsic( + subtensor: "SubtensorInterface", + wallet: "Wallet", + hotkey_ss58: str, + netuid: int, + position_id: int, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, +) -> tuple[bool, str]: + """Remove liquidity and credit balances back to wallet's hotkey stake. + + Arguments: + subtensor: The Subtensor client instance used for blockchain interaction. + wallet: The wallet used to sign the extrinsic (must be unlocked). + hotkey_ss58: the SS58 of the hotkey to use for this transaction. + netuid: The UID of the target subnet for which the call is being initiated. + position_id: The id of the position record in the pool. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. Defaults to True. + wait_for_finalization: Whether to wait for finalization of the extrinsic. Defaults to False. + + Returns: + Tuple[bool, str]: + - True and a success message if the extrinsic is successfully submitted or processed. + - False and an error message if the submission fails or the wallet cannot be unlocked. + + Note: Adding is allowed even when user liquidity is enabled in specified subnet. + Call `toggle_user_liquidity_extrinsic` to enable/disable user liquidity. + """ + if not (unlock := unlock_key(wallet)).success: + return False, unlock.message + + call = await subtensor.substrate.compose_call( + call_module="Swap", + call_function="remove_liquidity", + call_params={ + "hotkey": hotkey_ss58, + "netuid": netuid, + "position_id": position_id, + }, + ) + + return await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + +async def toggle_user_liquidity_extrinsic( + subtensor: "SubtensorInterface", + wallet: "Wallet", + netuid: int, + enable: bool, + wait_for_inclusion: bool = True, + wait_for_finalization: bool = False, +) -> tuple[bool, str]: + """Allow to toggle user liquidity for specified subnet. + + Arguments: + subtensor: The Subtensor client instance used for blockchain interaction. + wallet: The wallet used to sign the extrinsic (must be unlocked). + netuid: The UID of the target subnet for which the call is being initiated. + enable: Boolean indicating whether to enable user liquidity. + wait_for_inclusion: Whether to wait for the extrinsic to be included in a block. Defaults to True. + wait_for_finalization: Whether to wait for finalization of the extrinsic. Defaults to False. + + Returns: + Tuple[bool, str]: + - True and a success message if the extrinsic is successfully submitted or processed. + - 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 + + call = await subtensor.substrate.compose_call( + call_module="Swap", + call_function="toggle_user_liquidity", + call_params={"netuid": netuid, "enable": enable}, + ) + + return await subtensor.sign_and_send_extrinsic( + call=call, + wallet=wallet, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + +# Command +async def add_liquidity( + subtensor: "SubtensorInterface", + wallet: "Wallet", + hotkey_ss58: str, + netuid: Optional[int], + liquidity: Optional[float], + price_low: Optional[float], + price_high: Optional[float], + 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 + + # Check that the subnet exists. + if not await subtensor.subnet_exists(netuid=netuid): + return False, f"Subnet with netuid: {netuid} does not exist in {subtensor}." + + if prompt: + console.print( + "You are about to add a LiquidityPosition with:\n" + f"\tliquidity: {liquidity}\n" + f"\tprice low: {price_low}\n" + f"\tprice high: {price_high}\n" + f"\tto SN: {netuid}\n" + f"\tusing wallet with name: {wallet.name}" + ) + + if not Confirm.ask("Would you like to continue?"): + return False, "User cancelled operation." + + success, message = await add_liquidity_extrinsic( + subtensor=subtensor, + wallet=wallet, + hotkey_ss58=hotkey_ss58, + netuid=netuid, + liquidity=liquidity, + price_low=price_low, + price_high=price_high, + ) + if json_output: + json_console.print(json.dumps({"success": success, "message": message})) + else: + if success: + console.print( + "[green]LiquidityPosition has been successfully added.[/green]" + ) + else: + err_console.print(f"[red]Error: {message}[/red]") + + +async def get_liquidity_list( + subtensor: "SubtensorInterface", + wallet: "Wallet", + netuid: Optional[int], +) -> tuple[bool, str, list]: + """ + Args: + wallet: wallet object + subtensor: SubtensorInterface object + netuid: the netuid to stake to (None indicates all subnets) + + Returns: + Tuple of (success, error message, liquidity list) + """ + + if not await subtensor.subnet_exists(netuid=netuid): + return False, f"Subnet with netuid: {netuid} does not exist in {subtensor}.", [] + + if not await subtensor.is_subnet_active(netuid=netuid): + return False, f"Subnet with netuid: {netuid} is not active in {subtensor}.", [] + + block_hash = await subtensor.substrate.get_chain_head() + ( + positions_response, + fee_global_tao, + fee_global_alpha, + current_sqrt_price, + ) = await asyncio.gather( + subtensor.substrate.query_map( + module="Swap", + storage_function="Positions", + params=[netuid, wallet.coldkeypub.ss58_address], + block_hash=block_hash, + ), + subtensor.query( + module="Swap", + storage_function="FeeGlobalTao", + params=[netuid], + block_hash=block_hash, + ), + subtensor.query( + module="Swap", + storage_function="FeeGlobalAlpha", + params=[netuid], + block_hash=block_hash, + ), + subtensor.query( + module="Swap", + storage_function="AlphaSqrtPrice", + params=[netuid], + block_hash=block_hash, + ), + ) + + current_sqrt_price = fixed_to_float(current_sqrt_price) + fee_global_tao = fixed_to_float(fee_global_tao) + fee_global_alpha = fixed_to_float(fee_global_alpha) + + current_price = current_sqrt_price * current_sqrt_price + current_tick = price_to_tick(current_price) + + preprocessed_positions = [] + positions_futures = [] + + async for _, p in positions_response: + position = p.value + tick_index_low = position.get("tick_low")[0] + tick_index_high = position.get("tick_high")[0] + preprocessed_positions.append((position, tick_index_low, tick_index_high)) + + # Get ticks for the position (for below/above fees) + positions_futures.append( + asyncio.gather( + subtensor.query( + module="Swap", + storage_function="Ticks", + params=[netuid, tick_index_low], + block_hash=block_hash, + ), + subtensor.query( + module="Swap", + storage_function="Ticks", + params=[netuid, tick_index_high], + block_hash=block_hash, + ), + ) + ) + + awaited_futures = await asyncio.gather(*positions_futures) + + positions = [] + + for (position, tick_index_low, tick_index_high), (tick_low, tick_high) in zip( + preprocessed_positions, awaited_futures + ): + tao_fees_below_low = get_fees( + current_tick=current_tick, + tick=tick_low, + tick_index=tick_index_low, + quote=True, + global_fees_tao=fee_global_tao, + global_fees_alpha=fee_global_alpha, + above=False, + ) + tao_fees_above_high = get_fees( + current_tick=current_tick, + tick=tick_high, + tick_index=tick_index_high, + quote=True, + global_fees_tao=fee_global_tao, + global_fees_alpha=fee_global_alpha, + above=True, + ) + alpha_fees_below_low = get_fees( + current_tick=current_tick, + tick=tick_low, + tick_index=tick_index_low, + quote=False, + global_fees_tao=fee_global_tao, + global_fees_alpha=fee_global_alpha, + above=False, + ) + alpha_fees_above_high = get_fees( + current_tick=current_tick, + tick=tick_high, + tick_index=tick_index_high, + quote=False, + global_fees_tao=fee_global_tao, + global_fees_alpha=fee_global_alpha, + above=True, + ) + + # Get position accrued fees + fees_tao, fees_alpha = calculate_fees( + position=position, + global_fees_tao=fee_global_tao, + global_fees_alpha=fee_global_alpha, + tao_fees_below_low=tao_fees_below_low, + tao_fees_above_high=tao_fees_above_high, + alpha_fees_below_low=alpha_fees_below_low, + alpha_fees_above_high=alpha_fees_above_high, + netuid=netuid, + ) + + lp = LiquidityPosition( + **{ + "id": position.get("id")[0], + "price_low": Balance.from_tao( + tick_to_price(position.get("tick_low")[0]) + ), + "price_high": Balance.from_tao( + tick_to_price(position.get("tick_high")[0]) + ), + "liquidity": Balance.from_rao(position.get("liquidity")), + "fees_tao": fees_tao, + "fees_alpha": fees_alpha, + "netuid": position.get("netuid"), + } + ) + positions.append(lp) + + return True, "", positions + + +async def show_liquidity_list( + subtensor: "SubtensorInterface", + 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) + ) + if not success: + if json_output: + json_console.print( + json.dumps({"success": success, "err_msg": err_msg, "positions": []}) + ) + return False + else: + err_console.print(f"Error: {err_msg}") + return False + liquidity_table = Table( + Column("ID", justify="center"), + Column("Liquidity", justify="center"), + Column("Alpha", justify="center"), + Column("Tao", justify="center"), + Column("Price low", justify="center"), + Column("Price high", justify="center"), + Column("Fee TAO", justify="center"), + Column("Fee Alpha", justify="center"), + title=f"\n[{COLORS.G.HEADER}]{'Liquidity Positions of '}{wallet.name} wallet in SN #{netuid}\n" + "Alpha and Tao columns are respective portions of liquidity.", + show_footer=False, + show_edge=True, + header_style="bold white", + border_style="bright_black", + style="bold", + title_justify="center", + show_lines=False, + pad_edge=True, + ) + json_table = [] + current_price = current_price_.price + lp: LiquidityPosition + for lp in positions: + alpha, tao = lp.to_token_amounts(current_price) + liquidity_table.add_row( + str(lp.id), + str(lp.liquidity.tao), + str(alpha), + str(tao), + str(lp.price_low), + str(lp.price_high), + str(lp.fees_tao), + str(lp.fees_alpha), + ) + json_table.append( + { + "id": lp.id, + "liquidity": lp.liquidity.tao, + "token_amounts": {"alpha": alpha.tao, "tao": tao.tao}, + "price_low": lp.price_low.tao, + "price_high": lp.price_high.tao, + "fees_tao": lp.fees_tao.tao, + "fees_alpha": lp.fees_alpha.tao, + "netuid": lp.netuid, + } + ) + if not json_output: + console.print(liquidity_table) + else: + json_console.print( + json.dumps({"success": True, "err_msg": "", "positions": json_table}) + ) + + +async def remove_liquidity( + subtensor: "SubtensorInterface", + wallet: "Wallet", + hotkey_ss58: str, + netuid: int, + position_id: Optional[int] = None, + prompt: Optional[bool] = None, + all_liquidity_ids: Optional[bool] = None, + json_output: bool = False, +) -> tuple[bool, str]: + """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}." + + 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} + ) + else: + return err_console.print(f"Error: {msg}") + else: + position_ids = [p.id for p in positions] + else: + position_ids = [position_id] + + if prompt: + console.print("You are about to remove LiquidityPositions with:") + console.print(f"\tSubnet: {netuid}") + console.print(f"\tWallet name: {wallet.name}") + for pos in position_ids: + console.print(f"\tPosition id: {pos}") + + if not Confirm.ask("Would you like to continue?"): + return False, "User cancelled operation." + + results = await asyncio.gather( + *[ + remove_liquidity_extrinsic( + subtensor=subtensor, + wallet=wallet, + hotkey_ss58=hotkey_ss58, + netuid=netuid, + position_id=pos_id, + ) + for pos_id in position_ids + ] + ) + if not json_output: + for (success, msg), posid in zip(results, position_ids): + if success: + 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)) + + +async def modify_liquidity( + subtensor: "SubtensorInterface", + wallet: "Wallet", + hotkey_ss58: str, + netuid: int, + position_id: int, + liquidity_delta: Optional[float], + prompt: Optional[bool] = None, + json_output: bool = False, +) -> bool: + """Modify liquidity position in provided subnet.""" + if not await subtensor.subnet_exists(netuid=netuid): + err_msg = f"Subnet with netuid: {netuid} does not exist in {subtensor}." + if json_output: + json_console.print(json.dumps({"success": False, "err_msg": err_msg})) + else: + err_console.print(err_msg) + return False + + if prompt: + console.print( + "You are about to modify a LiquidityPosition with:" + f"\tSubnet: {netuid}\n" + f"\tPosition id: {position_id}\n" + f"\tWallet name: {wallet.name}\n" + f"\tLiquidity delta: {liquidity_delta}" + ) + + if not Confirm.ask("Would you like to continue?"): + return False + + success, msg = await modify_liquidity_extrinsic( + subtensor=subtensor, + wallet=wallet, + hotkey_ss58=hotkey_ss58, + netuid=netuid, + position_id=position_id, + liquidity_delta=liquidity_delta, + ) + if json_output: + json_console.print(json.dumps({"success": success, "err_msg": msg})) + else: + if success: + console.print(f"[green] Position {position_id} has been modified.") + else: + err_console.print(f"[red] Error modifying {position_id}: {msg}") diff --git a/bittensor_cli/src/commands/liquidity/utils.py b/bittensor_cli/src/commands/liquidity/utils.py new file mode 100644 index 000000000..76f7ea8a7 --- /dev/null +++ b/bittensor_cli/src/commands/liquidity/utils.py @@ -0,0 +1,200 @@ +""" +This module provides utilities for managing liquidity positions and price conversions in the Bittensor network. The +module handles conversions between TAO and Alpha tokens while maintaining precise calculations for liquidity +provisioning and fee distribution. +""" + +import math +from dataclasses import dataclass +from typing import Any + +from rich.prompt import IntPrompt, FloatPrompt + +from bittensor_cli.src.bittensor.balances import Balance, fixed_to_float +from bittensor_cli.src.bittensor.utils import ( + console, +) + +# These three constants are unchangeable at the level of Uniswap math +MIN_TICK = -887272 +MAX_TICK = 887272 +PRICE_STEP = 1.0001 + + +@dataclass +class LiquidityPosition: + id: int + price_low: Balance # RAO + price_high: Balance # RAO + liquidity: Balance # TAO + ALPHA (sqrt by TAO balance * Alpha Balance -> math under the hood) + fees_tao: Balance # RAO + fees_alpha: Balance # RAO + netuid: int + + def to_token_amounts( + self, current_subnet_price: Balance + ) -> tuple[Balance, Balance]: + """Convert a position to token amounts. + + Arguments: + current_subnet_price: current subnet price in Alpha. + + Returns: + tuple[int, int]: + Amount of Alpha in liquidity + Amount of TAO in liquidity + + Liquidity is a combination of TAO and Alpha depending on the price of the subnet at the moment. + """ + sqrt_price_low = math.sqrt(self.price_low) + sqrt_price_high = math.sqrt(self.price_high) + sqrt_current_subnet_price = math.sqrt(current_subnet_price) + + if sqrt_current_subnet_price < sqrt_price_low: + amount_alpha = self.liquidity * (1 / sqrt_price_low - 1 / sqrt_price_high) + amount_tao = 0 + elif sqrt_current_subnet_price > sqrt_price_high: + amount_alpha = 0 + amount_tao = self.liquidity * (sqrt_price_high - sqrt_price_low) + else: + amount_alpha = self.liquidity * ( + 1 / sqrt_current_subnet_price - 1 / sqrt_price_high + ) + amount_tao = self.liquidity * (sqrt_current_subnet_price - sqrt_price_low) + return Balance.from_rao(int(amount_alpha)).set_unit( + self.netuid + ), Balance.from_rao(int(amount_tao)) + + +def price_to_tick(price: float) -> int: + """Converts a float price to the nearest Uniswap V3 tick index.""" + if price <= 0: + raise ValueError(f"Price must be positive, got `{price}`.") + + tick = int(math.log(price) / math.log(PRICE_STEP)) + + if not (MIN_TICK <= tick <= MAX_TICK): + raise ValueError( + f"Resulting tick {tick} is out of allowed range ({MIN_TICK} to {MAX_TICK})" + ) + return tick + + +def tick_to_price(tick: int) -> float: + """Convert an integer Uniswap V3 tick index to float price.""" + if not (MIN_TICK <= tick <= MAX_TICK): + raise ValueError("Tick is out of allowed range") + return PRICE_STEP**tick + + +def get_fees( + current_tick: int, + tick: dict, + tick_index: int, + quote: bool, + global_fees_tao: float, + global_fees_alpha: float, + above: bool, +) -> float: + """Returns the liquidity fee.""" + tick_fee_key = "fees_out_tao" if quote else "fees_out_alpha" + tick_fee_value = fixed_to_float(tick.get(tick_fee_key)) + global_fee_value = global_fees_tao if quote else global_fees_alpha + + if above: + return ( + global_fee_value - tick_fee_value + if tick_index <= current_tick + else tick_fee_value + ) + return ( + tick_fee_value + if tick_index <= current_tick + else global_fee_value - tick_fee_value + ) + + +def get_fees_in_range( + quote: bool, + global_fees_tao: float, + global_fees_alpha: float, + fees_below_low: float, + fees_above_high: float, +) -> float: + """Returns the liquidity fee value in a range.""" + global_fees = global_fees_tao if quote else global_fees_alpha + return global_fees - fees_below_low - fees_above_high + + +# Calculate fees for a position +def calculate_fees( + position: dict[str, Any], + global_fees_tao: float, + global_fees_alpha: float, + tao_fees_below_low: float, + tao_fees_above_high: float, + alpha_fees_below_low: float, + alpha_fees_above_high: float, + netuid: int, +) -> tuple[Balance, Balance]: + fee_tao_agg = get_fees_in_range( + quote=True, + global_fees_tao=global_fees_tao, + global_fees_alpha=global_fees_alpha, + fees_below_low=tao_fees_below_low, + fees_above_high=tao_fees_above_high, + ) + + fee_alpha_agg = get_fees_in_range( + quote=False, + global_fees_tao=global_fees_tao, + global_fees_alpha=global_fees_alpha, + fees_below_low=alpha_fees_below_low, + fees_above_high=alpha_fees_above_high, + ) + + fee_tao = fee_tao_agg - fixed_to_float(position["fees_tao"]) + fee_alpha = fee_alpha_agg - fixed_to_float(position["fees_alpha"]) + liquidity_frac = position["liquidity"] + + fee_tao = liquidity_frac * fee_tao + fee_alpha = liquidity_frac * fee_alpha + + return Balance.from_rao(int(fee_tao)), Balance.from_rao(int(fee_alpha)).set_unit( + netuid + ) + + +def prompt_liquidity(prompt: str, negative_allowed: bool = False) -> Balance: + """Prompt the user for the amount of liquidity. + + Arguments: + prompt: Prompt to display to the user. + negative_allowed: Whether negative amounts are allowed. + + Returns: + Balance converted from input to TAO. + """ + while True: + amount = FloatPrompt.ask(prompt) + try: + if amount <= 0 and not negative_allowed: + console.print("[red]Amount must be greater than 0[/red].") + continue + return Balance.from_tao(amount) + except ValueError: + console.print("[red]Please enter a valid number[/red].") + + +def prompt_position_id() -> int: + """Ask the user for the ID of the liquidity position to remove.""" + while True: + position_id = IntPrompt.ask("Enter the [blue]liquidity position ID[/blue]") + + try: + if position_id <= 1: + console.print("[red]Position ID must be greater than 1[/red].") + continue + return position_id + except ValueError: + console.print("[red]Please enter a valid number[/red].") diff --git a/bittensor_cli/src/commands/stake/add.py b/bittensor_cli/src/commands/stake/add.py index 18f3517c1..a8d1ecc59 100644 --- a/bittensor_cli/src/commands/stake/add.py +++ b/bittensor_cli/src/commands/stake/add.py @@ -342,14 +342,14 @@ async def stake_extrinsic( # If we are staking safe, add price tolerance if safe_staking: if subnet_info.is_dynamic: - rate = 1 / subnet_info.price.tao or 1 + rate = amount_to_stake.rao / received_amount.rao _rate_with_tolerance = rate * ( 1 + rate_tolerance ) # Rate only for display rate_with_tolerance = f"{_rate_with_tolerance:.4f}" - price_with_tolerance = subnet_info.price.rao * ( - 1 + rate_tolerance - ) # Actual price to pass to extrinsic + price_with_tolerance = Balance.from_tao( + _rate_with_tolerance + ).rao # Actual price to pass to extrinsic else: rate_with_tolerance = "1" price_with_tolerance = Balance.from_rao(1) diff --git a/bittensor_cli/src/commands/stake/remove.py b/bittensor_cli/src/commands/stake/remove.py index c7a72ffed..b2ef8b608 100644 --- a/bittensor_cli/src/commands/stake/remove.py +++ b/bittensor_cli/src/commands/stake/remove.py @@ -248,13 +248,13 @@ async def unstake( # Additional fields for safe unstaking if safe_staking: if subnet_info.is_dynamic: - rate = subnet_info.price.tao or 1 + rate = received_amount.rao / amount_to_unstake_as_balance.rao rate_with_tolerance = rate * ( 1 - rate_tolerance ) # Rate only for display - price_with_tolerance = subnet_info.price.rao * ( - 1 - rate_tolerance - ) # Actual price to pass to extrinsic + price_with_tolerance = Balance.from_tao( + rate_with_tolerance + ).rao # Actual price to pass to extrinsic else: rate_with_tolerance = 1 price_with_tolerance = 1 diff --git a/bittensor_cli/src/commands/subnets/subnets.py b/bittensor_cli/src/commands/subnets/subnets.py index 99a619d8e..e94402953 100644 --- a/bittensor_cli/src/commands/subnets/subnets.py +++ b/bittensor_cli/src/commands/subnets/subnets.py @@ -140,6 +140,9 @@ async def _find_event_attributes_in_extrinsic_receipt( "description": subnet_identity["description"].encode() if subnet_identity.get("description") else b"", + "logo_url": subnet_identity["logo_url"].encode() + if subnet_identity.get("logo_url") + else b"", "additional": subnet_identity["additional"].encode() if subnet_identity.get("additional") else b"", @@ -2207,6 +2210,7 @@ async def set_identity( "subnet_url": subnet_identity.get("subnet_url", ""), "discord": subnet_identity.get("discord", ""), "description": subnet_identity.get("description", ""), + "logo_url": subnet_identity.get("logo_url", ""), "additional": subnet_identity.get("additional", ""), } @@ -2252,6 +2256,7 @@ async def set_identity( "subnet_url", "discord", "description", + "logo_url", "additional", ]: value = getattr(identity, key, None) @@ -2301,6 +2306,7 @@ async def get_identity( "subnet_url", "discord", "description", + "logo_url", "additional", ]: value = getattr(identity, key, None) diff --git a/bittensor_cli/src/commands/sudo.py b/bittensor_cli/src/commands/sudo.py index de02993bb..8d7950392 100644 --- a/bittensor_cli/src/commands/sudo.py +++ b/bittensor_cli/src/commands/sudo.py @@ -8,7 +8,12 @@ from rich.prompt import Confirm from scalecodec import GenericCall -from bittensor_cli.src import HYPERPARAMS, DelegatesDetails, COLOR_PALETTE +from bittensor_cli.src import ( + HYPERPARAMS, + HYPERPARAMS_MODULE, + DelegatesDetails, + COLOR_PALETTE, +) from bittensor_cli.src.bittensor.chain_data import decode_account_id from bittensor_cli.src.bittensor.utils import ( console, @@ -31,6 +36,7 @@ # helpers and extrinsics +DEFAULT_PALLET = "AdminUtils" def allowed_value( @@ -73,7 +79,7 @@ def allowed_value( return True, value -def string_to_bool(val) -> bool: +def string_to_bool(val) -> bool | type[ValueError]: try: return {"true": True, "1": True, "0": False, "false": False}[val.lower()] except KeyError: @@ -81,7 +87,11 @@ def string_to_bool(val) -> bool: def search_metadata( - param_name: str, value: Union[str, bool, float, list[float]], netuid: int, metadata + param_name: str, + value: Union[str, bool, float, list[float]], + netuid: int, + metadata, + pallet: 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 @@ -92,6 +102,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 Returns: (success, dict of call params) @@ -113,7 +124,7 @@ def type_converter_with_retry(type_, val, arg_name): call_crafter = {"netuid": netuid} - pallet = metadata.get_metadata_pallet("AdminUtils") + pallet = metadata.get_metadata_pallet(pallet) for call in pallet.calls: if call.name == param_name: if "netuid" not in [x.name for x in call.args]: @@ -135,11 +146,11 @@ def type_converter_with_retry(type_, val, arg_name): return False, None -def requires_bool(metadata, param_name) -> bool: +def requires_bool(metadata, param_name, pallet: str = DEFAULT_PALLET) -> bool: """ Determines whether a given hyperparam takes a single arg (besides netuid) that is of bool type. """ - pallet = metadata.get_metadata_pallet("AdminUtils") + pallet = metadata.get_metadata_pallet(pallet) for call in pallet.calls: if call.name == param_name: if "netuid" not in [x.name for x in call.args]: @@ -218,6 +229,8 @@ async def set_hyperparameter_extrinsic( 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}" @@ -227,7 +240,7 @@ async def set_hyperparameter_extrinsic( ): if not arbitrary_extrinsic: extrinsic_params = await substrate.get_metadata_call_function( - "AdminUtils", extrinsic + module_name=pallet, call_function_name=extrinsic ) # if input value is a list, iterate through the list and assign values @@ -251,7 +264,7 @@ async def set_hyperparameter_extrinsic( else: if requires_bool( - substrate.metadata, param_name=extrinsic + substrate.metadata, param_name=extrinsic, pallet=pallet ) and isinstance(value, str): value = string_to_bool(value) value_argument = extrinsic_params["fields"][ @@ -261,7 +274,7 @@ async def set_hyperparameter_extrinsic( # create extrinsic call call_ = await substrate.compose_call( - call_module="AdminUtils", + call_module=pallet, call_function=extrinsic, call_params=call_params, ) diff --git a/bittensor_cli/src/commands/wallets.py b/bittensor_cli/src/commands/wallets.py index c1c4f4261..1c2de6a3e 100644 --- a/bittensor_cli/src/commands/wallets.py +++ b/bittensor_cli/src/commands/wallets.py @@ -2002,11 +2002,12 @@ async def check_swap_status( Args: subtensor: Connection to the network origin_ss58: The SS58 address of the original coldkey - block_number: Optional block number where the swap was scheduled + expected_block_number: Optional block number where the swap was scheduled + """ - scheduled_swaps = await subtensor.get_scheduled_coldkey_swap() if not origin_ss58: + scheduled_swaps = await subtensor.get_scheduled_coldkey_swap() if not scheduled_swaps: console.print("[yellow]No pending coldkey swaps found.[/yellow]") return @@ -2035,11 +2036,20 @@ async def check_swap_status( console.print(table) console.print( - "\n[dim]Tip: Check specific swap details by providing the original coldkey SS58 address and the block number.[/dim]" + "\n[dim]Tip: Check specific swap details by providing the original coldkey " + "SS58 address and the block number.[/dim]" ) return - - is_pending = origin_ss58 in scheduled_swaps + chain_reported_completion_block, destination_address = await subtensor.query( + "SubtensorModule", "ColdkeySwapScheduled", [origin_ss58] + ) + if ( + chain_reported_completion_block != 0 + and destination_address != "5C4hrfjw9DjXZTzV3MwzrrAr9P1MJhSrvWGWqi1eSuyUpnhM" + ): + is_pending = True + else: + is_pending = False if not is_pending: console.print( @@ -2052,23 +2062,10 @@ async def check_swap_status( ) if expected_block_number is None: - return - - swap_info = await find_coldkey_swap_extrinsic( - subtensor=subtensor, - start_block=expected_block_number, - end_block=expected_block_number, - wallet_ss58=origin_ss58, - ) - - if not swap_info: - console.print( - f"[yellow]Warning: Could not find swap extrinsic at block {expected_block_number}[/yellow]" - ) - return + expected_block_number = chain_reported_completion_block current_block = await subtensor.substrate.get_block_number() - remaining_blocks = swap_info["execution_block"] - current_block + remaining_blocks = expected_block_number - current_block if remaining_blocks <= 0: console.print("[green]Swap period has completed![/green]") @@ -2076,9 +2073,8 @@ async def check_swap_status( console.print( "\n[green]Coldkey swap details:[/green]" - f"\nScheduled at block: {swap_info['block_num']}" f"\nOriginal address: [{COLORS.G.CK}]{origin_ss58}[/{COLORS.G.CK}]" - f"\nDestination address: [{COLORS.G.CK}]{swap_info['dest_coldkey']}[/{COLORS.G.CK}]" - f"\nCompletion block: {swap_info['execution_block']}" + f"\nDestination address: [{COLORS.G.CK}]{destination_address}[/{COLORS.G.CK}]" + f"\nCompletion block: {chain_reported_completion_block}" f"\nTime remaining: {blocks_to_duration(remaining_blocks)}" ) diff --git a/pyproject.toml b/pyproject.toml index ff7a019e3..d733a105e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "bittensor-cli" -version = "9.7.1" +version = "9.8.0" description = "Bittensor CLI" readme = "README.md" authors = [ diff --git a/tests/e2e_tests/test_liquidity.py b/tests/e2e_tests/test_liquidity.py new file mode 100644 index 000000000..c8a7b7d4c --- /dev/null +++ b/tests/e2e_tests/test_liquidity.py @@ -0,0 +1,238 @@ +import json +import re + +from bittensor_cli.src.bittensor.balances import Balance + +""" +Verify commands: + +* btcli liquidity add +* btcli liquidity list +* btcli liquidity modify +* btcli liquidity remove +""" + + +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 + + # 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 + assert result_output["netuid"] == netuid + + # verify no results for list thus far (subnet not yet started) + liquidity_list_result = liquidity_list() + 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"] + assert result_output["positions"] == [] + + # start emissions schedule + start_subnet_emissions = exec_command_alice( + command="subnets", + sub_command="start", + extra_args=[ + "--netuid", + netuid, + "--wallet-path", + wallet_path_alice, + "--wallet-name", + wallet_alice.name, + "--hotkey", + wallet_alice.hotkey_str, + "--network", + "ws://127.0.0.1:9945", + "--no-prompt", + ], + ) + assert ( + f"Successfully started subnet {netuid}'s emission schedule" + in start_subnet_emissions.stdout + ), start_subnet_emissions.stderr + + liquidity_list_result = liquidity_list() + result_output = json.loads(liquidity_list_result.stdout) + assert result_output["success"] is True + assert result_output["err_msg"] == "" + assert result_output["positions"] == [] + + enable_user_liquidity = exec_command_alice( + command="sudo", + sub_command="set", + 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, + "--param", + "user_liquidity_enabled", + "--value", + "1", + "--json-output", + "--no-prompt", + ], + ) + enable_user_liquidity_result = json.loads(enable_user_liquidity.stdout) + assert enable_user_liquidity_result["success"] is True + + add_liquidity = exec_command_alice( + command="liquidity", + sub_command="add", + 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, + "--liquidity", + "1.0", + "--price-low", + "1.7", + "--price-high", + "1.8", + "--no-prompt", + "--json-output", + ], + ) + add_liquidity_result = json.loads(add_liquidity.stdout) + assert add_liquidity_result["success"] is True + assert add_liquidity_result["message"] == "" + + liquidity_list_result = liquidity_list() + 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 + assert abs(liquidity_position["price_high"] - 1.8) < 0.0001 + assert abs(liquidity_position["price_low"] - 1.7) < 0.0001 + + modify_liquidity = exec_command_alice( + command="liquidity", + sub_command="modify", + 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, + "--position-id", + str(liquidity_position["id"]), + "--liquidity-delta", + "20.0", + "--json-output", + "--no-prompt", + ], + ) + modify_liquidity_result = json.loads(modify_liquidity.stdout) + assert modify_liquidity_result["success"] is True + + liquidity_list_result = json.loads(liquidity_list().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( + command="liquidity", + sub_command="remove", + 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, + "--all", + "--no-prompt", + "--json-output", + ], + ) + removal_result = json.loads(removal.stdout) + assert removal_result[str(liquidity_position["id"])]["success"] is True + + liquidity_list_result = json.loads(liquidity_list().stdout) + assert liquidity_list_result["success"] is True + assert liquidity_list_result["positions"] == [] diff --git a/tests/e2e_tests/test_staking_sudo.py b/tests/e2e_tests/test_staking_sudo.py index 8bc2107c2..3225b9e6e 100644 --- a/tests/e2e_tests/test_staking_sudo.py +++ b/tests/e2e_tests/test_staking_sudo.py @@ -86,6 +86,8 @@ def test_staking(local_chain, wallet_setup): "A test subnet for e2e testing", "--additional-info", "Created by Alice", + "--logo-url", + "https://testsubnet.com/logo.png", "--no-prompt", "--json-output", ], @@ -121,6 +123,8 @@ def test_staking(local_chain, wallet_setup): "A test subnet for e2e testing", "--additional-info", "Created by Alice", + "--logo-url", + "https://testsubnet.com/logo.png", "--no-prompt", "--json-output", ], @@ -129,7 +133,7 @@ def test_staking(local_chain, wallet_setup): assert result_output_second["success"] is True assert result_output_second["netuid"] == multiple_netuids[1] - # Register Alice in netuid = 1 using her hotkey + # Register Alice in netuid = 2 using her hotkey register_subnet = exec_command_alice( command="subnets", sub_command="register", @@ -198,6 +202,8 @@ def test_staking(local_chain, wallet_setup): 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", "--json-output", @@ -225,6 +231,7 @@ def test_staking(local_chain, wallet_setup): 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 # Start emissions on SNs @@ -269,7 +276,7 @@ def test_staking(local_chain, wallet_setup): "--amount", "100", "--tolerance", - "0.1", + "0.2", "--partial", "--no-prompt", "--era", @@ -526,12 +533,12 @@ def test_staking(local_chain, wallet_setup): yuma3_val = next( filter( - lambda x: x["hyperparameter"] == "yuma3_enabled", + lambda x: x["hyperparameter"] == "yuma_version", json.loads(changed_yuma3_hyperparam.stdout), ) ) - assert yuma3_val["value"] is True - assert yuma3_val["normalized_value"] is True + assert yuma3_val["value"] == 3 + assert yuma3_val["normalized_value"] == 3 print("✅ Passed staking and sudo commands") change_arbitrary_hyperparam = exec_command_alice( diff --git a/tests/e2e_tests/test_unstaking.py b/tests/e2e_tests/test_unstaking.py index bfbb77e85..e28c54df6 100644 --- a/tests/e2e_tests/test_unstaking.py +++ b/tests/e2e_tests/test_unstaking.py @@ -83,6 +83,8 @@ def test_unstaking(local_chain, wallet_setup): "A test subnet for e2e testing", "--additional-info", "Test subnet", + "--logo-url", + "https://testsubnet.com/logo.png", "--no-prompt", ], ) @@ -115,6 +117,8 @@ def test_unstaking(local_chain, wallet_setup): "A test subnet for e2e testing", "--additional-info", "Test subnet", + "--logo-url", + "https://testsubnet.com/logo.png", "--no-prompt", ], ) diff --git a/tests/e2e_tests/test_wallet_creations.py b/tests/e2e_tests/test_wallet_creations.py index 7b573dbba..019cad6b5 100644 --- a/tests/e2e_tests/test_wallet_creations.py +++ b/tests/e2e_tests/test_wallet_creations.py @@ -587,7 +587,13 @@ def test_wallet_balance_all(local_chain, wallet_setup, capfd): result = exec_command( command="wallet", sub_command="balance", - extra_args=["--wallet-path", wallet_path, "--all"], + extra_args=[ + "--wallet-path", + wallet_path, + "--all", + "--chain", + "ws://127.0.0.1:9945", + ], ) output = result.stdout @@ -600,7 +606,14 @@ def test_wallet_balance_all(local_chain, wallet_setup, capfd): json_results = exec_command( "wallet", "balance", - extra_args=["--wallet-path", wallet_path, "--all", "--json-output"], + extra_args=[ + "--wallet-path", + wallet_path, + "--all", + "--json-output", + "--chain", + "ws://127.0.0.1:9945", + ], ) json_results_output = json.loads(json_results.stdout) for wallet_name in wallet_names: diff --git a/tests/e2e_tests/test_wallet_interactions.py b/tests/e2e_tests/test_wallet_interactions.py index 08b2c73c4..e6a4bb22d 100644 --- a/tests/e2e_tests/test_wallet_interactions.py +++ b/tests/e2e_tests/test_wallet_interactions.py @@ -69,6 +69,8 @@ def test_wallet_overview_inspect(local_chain, wallet_setup): "test#1234", "--description", "A test subnet for e2e testing", + "--logo-url", + "https://testsubnet.com/logo.png", "--additional-info", "Test subnet", "--no-prompt", @@ -388,6 +390,8 @@ def test_wallet_identities(local_chain, wallet_setup): "A test subnet for e2e testing", "--additional-info", "Created by Alice", + "--logo-url", + "https://testsubnet.com/logo.png", "--no-prompt", ], ) diff --git a/tests/e2e_tests/utils.py b/tests/e2e_tests/utils.py index 07f2fbd93..7a3c0993f 100644 --- a/tests/e2e_tests/utils.py +++ b/tests/e2e_tests/utils.py @@ -1,3 +1,4 @@ +import inspect import os import re import shutil @@ -32,6 +33,27 @@ def exec_command( ): extra_args = extra_args or [] cli_manager = CLIManager() + for group in cli_manager.app.registered_groups: + if group.name == command: + for command_ in group.typer_instance.registered_commands: + if command_.name == sub_command: + if "network" in inspect.getcallargs( + command_.callback + ).keys() and not any( + ( + x in extra_args + for x in ( + "--network", + "--chain", + "--subtensor.network", + "--subtensor.chain_endpoint", + ) + ) + ): + # Ensure if we forget to add `--network ws://127.0.0.1:9945` that it will run still + # using the local chain + extra_args.extend(["--network", "ws://127.0.0.1:9945"]) + # Capture stderr separately from stdout runner = CliRunner(mix_stderr=False) # Prepare the command arguments