diff --git a/.github/workflows/e2e-subtensor-tests.yml b/.github/workflows/e2e-subtensor-tests.yml new file mode 100644 index 00000000..89879191 --- /dev/null +++ b/.github/workflows/e2e-subtensor-tests.yml @@ -0,0 +1,71 @@ +name: E2E Subtensor Tests + +concurrency: + group: e2e-subtensor-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: [main, development, staging] + + pull_request: + branches: [main, development, staging] + types: [ opened, synchronize, reopened, ready_for_review ] + + workflow_dispatch: + inputs: + verbose: + description: "Output more information when triggered manually" + required: false + default: "" + +env: + CARGO_TERM_COLOR: always + VERBOSE: ${{ github.event.inputs.verbose }} + +jobs: + run-tests: + runs-on: SubtensorCI + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} + timeout-minutes: 180 + env: + RELEASE_NAME: development + RUSTV: nightly-2024-03-05 + RUST_BACKTRACE: full + RUST_BIN_DIR: target/x86_64-unknown-linux-gnu + TARGET: x86_64-unknown-linux-gnu + + steps: + - name: Check-out repository under $GITHUB_WORKSPACE + uses: actions/checkout@v2 + + - name: Install dependencies + run: | + sudo apt-get update && + sudo apt-get install -y clang curl libssl-dev llvm libudev-dev protobuf-compiler + + - name: Install Rust ${{ env.RUSTV }} + uses: actions-rs/toolchain@v1.0.6 + with: + toolchain: ${{ env.RUSTV }} + components: rustfmt + profile: minimal + + - name: Add wasm32-unknown-unknown target + run: | + rustup target add wasm32-unknown-unknown --toolchain stable-x86_64-unknown-linux-gnu + rustup component add rust-src --toolchain stable-x86_64-unknown-linux-gnu + + - name: Clone subtensor repo + run: git clone https://github.com/opentensor/subtensor.git + + - name: Setup subtensor repo + working-directory: ${{ github.workspace }}/subtensor + run: git checkout testnet + + - name: Install Python dependencies + run: python3 -m pip install -r requirements.txt pytest + + - name: Run all tests + run: | + LOCALNET_SH_PATH="${{ github.workspace }}/subtensor/scripts/localnet.sh" pytest src/tests/e2e_tests -s \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..7aaa277e --- /dev/null +++ b/.gitignore @@ -0,0 +1,212 @@ +# Byte-compiled / optimized / DLL files +**/__pycache__/ +*.py[cod] +*$py.class +*.pyc + +# Remove notebooks. +*.ipynb + +# weigths and biases +wandb/ + +*.csv +*.torch +*.pt +*.log + +# runs/data/models/logs/~ +data/ +**/data/ + +# C extensions +*.so + +# IDE +*.idea/ + +# VSCODE +.vscode/ + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ +# Generated by Cargo +# will have compiled files and executables +**/target/ +# These are backup files generated by rustfmt +**/*.rs.bk + +# The cache for docker container dependency +.cargo + +# The cache for chain data in container +.local + +# State folder for all neurons. +**/data/* +!data/.gitkeep + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +# PIPY Stuff +bittensor.egg-info +bittensor*.egg +bdist.* + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +**/build/* +**/dist/* +**/runs/* +**/env/* +**/data/* +**/.data/* +**/tmp/* + +**/.bash_history +**/*.xml +**/*.pstats +**/*.png + +# Replicate library +**/.replicate +replicate.yaml +**/run.sh + +# Notebooks +*.ipynb \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cli.py b/cli.py index d31c9654..f1b71b89 100755 --- a/cli.py +++ b/cli.py @@ -41,6 +41,9 @@ class Options: """ wallet_name = typer.Option(None, "--wallet-name", "-w", help="Name of wallet") + wallet_name_req = typer.Option( + None, "--wallet-name", "-w", help="Name of wallet", prompt=True + ) wallet_path = typer.Option( None, "--wallet-path", "-p", help="Filepath of root of wallets" ) @@ -68,7 +71,7 @@ class Options: None, "--json-password", help="Password to decrypt the json file." ) use_password = typer.Option( - None, + True, help="Set true to protect the generated bittensor key with a password.", is_flag=True, flag_value=False, @@ -697,9 +700,9 @@ def wallet_transfer( destination address and amount before confirming the transaction to avoid errors or loss of funds. """ wallet = self.wallet_ask(wallet_name, wallet_path, wallet_hotkey) - self.initialize_chain(network, chain) + subtensor = self.initialize_chain(network, chain) return self._run_command( - wallets.transfer(wallet, self.not_subtensor, destination, amount) + wallets.transfer(wallet, subtensor, destination, amount) ) def wallet_swap_hotkey( @@ -930,14 +933,16 @@ def wallet_regen_coldkey( ### Example usage: ``` - btcli wallet regen_coldkey --mnemonic "word1 word2 ... word12" + btcli wallet regen-coldkey --mnemonic "word1 word2 ... word12" ``` ### Note: This command is critical for users who need to regenerate their coldkey, possibly for recovery or security reasons. It should be used with caution to avoid overwriting existing keys unintentionally. """ - wallet = self.wallet_ask(wallet_name, wallet_path, wallet_hotkey) + wallet = self.wallet_ask( + wallet_name, wallet_path, wallet_hotkey, validate=False + ) mnemonic, seed, json, json_password = get_creation_data( mnemonic, seed, json, json_password ) @@ -987,7 +992,9 @@ def wallet_regen_coldkey_pub( corruption or loss. It is a recovery-focused utility that ensures continued access to wallet functionalities. """ - wallet = self.wallet_ask(wallet_name, wallet_path, wallet_hotkey) + wallet = self.wallet_ask( + wallet_name, wallet_path, wallet_hotkey, validate=False + ) if not ss58_address and not public_key_hex: prompt_answer = typer.prompt( "Enter the ss58_address or the public key in hex" @@ -1003,7 +1010,7 @@ def wallet_regen_coldkey_pub( raise typer.Exit() return self._run_command( wallets.regen_coldkey_pub( - wallet, public_key_hex, ss58_address, overwrite_coldkeypub + wallet, ss58_address, public_key_hex, overwrite_coldkeypub ) ) @@ -1057,7 +1064,7 @@ def wallet_regen_hotkey( def wallet_new_hotkey( self, - wallet_name: Optional[str] = Options.wallet_name, + wallet_name: Optional[str] = Options.wallet_name_req, wallet_path: Optional[str] = Options.wallet_path, wallet_hotkey: Optional[str] = Options.wallet_hk_req, n_words: Optional[int] = None, @@ -1119,7 +1126,9 @@ def wallet_new_coldkey( setting up a new wallet. It's a foundational step in establishing a secure presence on the Bittensor network. """ - wallet = self.wallet_ask(wallet_name, wallet_path, wallet_hotkey) + wallet = self.wallet_ask( + wallet_name, wallet_path, wallet_hotkey, validate=False + ) n_words = get_n_words(n_words) return self._run_command( wallets.new_coldkey(wallet, n_words, use_password, overwrite_coldkey) @@ -1154,7 +1163,7 @@ def wallet_check_ck_swap( def wallet_create_wallet( self, - wallet_name: Optional[str] = Options.wallet_name, + wallet_name: Optional[str] = Options.wallet_name_req, wallet_path: Optional[str] = Options.wallet_path, wallet_hotkey: Optional[str] = Options.wallet_hk_req, n_words: Optional[int] = None, @@ -1206,12 +1215,8 @@ def wallet_balance( "-a", help="Whether to display the balances for all wallets.", ), - network: Optional[str] = typer.Option( - defaults.subtensor.network, - help="The subtensor network to connect to.", - prompt=True, - ), - chain: Optional[str] = Options.chain, + network: str = Options.network, + chain: str = Options.chain, ): """ # wallet balance @@ -1241,7 +1246,7 @@ def wallet_balance( btcli w balance --all ``` """ - subtensor = SubtensorInterface(network, chain) + subtensor = self.initialize_chain(network, chain) wallet = self.wallet_ask(wallet_name, wallet_path, wallet_hotkey) return self._run_command( wallets.wallet_balance(wallet, subtensor, all_balances) diff --git a/src/bittensor/async_substrate_interface.py b/src/bittensor/async_substrate_interface.py index 68804753..57988aa0 100644 --- a/src/bittensor/async_substrate_interface.py +++ b/src/bittensor/async_substrate_interface.py @@ -1345,7 +1345,7 @@ async def result_handler(message: dict, subscription_id) -> tuple[dict, bool]: ): # Created as a task because we don't actually care about the result self._forgettable_task = asyncio.create_task( - await self.rpc_request( + self.rpc_request( "author_unwatchExtrinsic", [subscription_id] ) ) diff --git a/src/bittensor/extrinsics/transfer.py b/src/bittensor/extrinsics/transfer.py index 82a2c05d..66454fa2 100644 --- a/src/bittensor/extrinsics/transfer.py +++ b/src/bittensor/extrinsics/transfer.py @@ -110,11 +110,13 @@ async def do_transfer() -> tuple[bool, str, str]: # Check balance. with console.status(":satellite: Checking balance and fees..."): # check existential deposit and fee - account_balance, existential_deposit, fee = await asyncio.gather( - subtensor.get_balance(wallet.coldkey.ss58_address), - subtensor.get_existential_deposit(reuse_block=True), - get_transfer_fee(), + block_hash = await subtensor.substrate.get_chain_head() + account_balance_, existential_deposit = await asyncio.gather( + subtensor.get_balance(wallet.coldkey.ss58_address, block_hash=block_hash), + subtensor.get_existential_deposit(block_hash=block_hash), ) + account_balance = account_balance_[wallet.coldkey.ss58_address] + fee = await get_transfer_fee() if not keep_alive: # Check if the transfer should keep_alive the account @@ -167,7 +169,7 @@ async def do_transfer() -> tuple[bool, str, str]: ) console.print( f"Balance:\n" - f" [blue]{account_balance}[/blue] :arrow_right: [green]{new_balance}[/green]" + f" [blue]{account_balance[wallet.coldkey.ss58_address]}[/blue] :arrow_right: [green]{new_balance[wallet.coldkey.ss58_address]}[/green]" ) return True diff --git a/src/commands/root.py b/src/commands/root.py index ec670978..a4fed3e2 100644 --- a/src/commands/root.py +++ b/src/commands/root.py @@ -300,7 +300,7 @@ async def burned_register_extrinsic( finalization/inclusion, the response is `True`. """ - if not subtensor.subnet_exists(netuid): + if not await subtensor.subnet_exists(netuid): err_console.print( f":cross_mark: [red]Failed[/red]: error: [bold white]subnet:{netuid}[/bold white] does not exist." ) @@ -370,7 +370,7 @@ async def burned_register_extrinsic( console.print( "Balance:\n" - f" [blue]{old_balance}[/blue] :arrow_right: [green]{new_balance}[/green]" + f" [blue]{old_balance}[/blue] :arrow_right: [green]{new_balance[wallet.coldkey.ss58_address]}[/green]" ) if len(netuids_for_hotkey) > 0: diff --git a/src/commands/subnets.py b/src/commands/subnets.py index d739c7dc..cac62cbf 100644 --- a/src/commands/subnets.py +++ b/src/commands/subnets.py @@ -89,43 +89,43 @@ def _find_event_attributes_in_extrinsic_receipt(response_, event_name: str) -> l wallet.unlock_coldkey() with console.status(":satellite: Registering subnet..."): - with subtensor.substrate as substrate: - # create extrinsic call - call = await substrate.compose_call( - call_module="SubtensorModule", - call_function="register_network", - call_params={"immunity_period": 0, "reg_allowed": True}, + substrate = subtensor.substrate + # create extrinsic call + call = await substrate.compose_call( + call_module="SubtensorModule", + call_function="register_network", + call_params={"immunity_period": 0, "reg_allowed": True}, + ) + extrinsic = await substrate.create_signed_extrinsic( + call=call, keypair=wallet.coldkey + ) + response = await substrate.submit_extrinsic( + extrinsic, + wait_for_inclusion=wait_for_inclusion, + wait_for_finalization=wait_for_finalization, + ) + + # We only wait here if we expect finalization. + if not wait_for_finalization and not wait_for_inclusion: + return True + + response.process_events() + if not response.is_success: + err_console.print( + f":cross_mark: [red]Failed[/red]: {format_error_message(response.error_message)}" ) - extrinsic = await substrate.create_signed_extrinsic( - call=call, keypair=wallet.coldkey + await asyncio.sleep(0.5) + return False + + # Successful registration, final check for membership + else: + attributes = _find_event_attributes_in_extrinsic_receipt( + response, "NetworkAdded" ) - response = await substrate.submit_extrinsic( - extrinsic, - wait_for_inclusion=wait_for_inclusion, - wait_for_finalization=wait_for_finalization, + console.print( + f":white_heavy_check_mark: [green]Registered subnetwork with netuid: {attributes[0]}[/green]" ) - - # We only wait here if we expect finalization. - if not wait_for_finalization and not wait_for_inclusion: - return True - - response.process_events() - if not response.is_success: - err_console.print( - f":cross_mark: [red]Failed[/red]: {format_error_message(response.error_message)}" - ) - await asyncio.sleep(0.5) - return False - - # Successful registration, final check for membership - else: - attributes = _find_event_attributes_in_extrinsic_receipt( - response, "NetworkAdded" - ) - console.print( - f":white_heavy_check_mark: [green]Registered subnetwork with netuid: {attributes[0]}[/green]" - ) - return True + return True # commands @@ -303,7 +303,7 @@ async def register(wallet: Wallet, subtensor: "SubtensorInterface", netuid: int) ) return - if not False: # TODO no-prompt + if not True: # TODO no-prompt if not ( Confirm.ask( f"Your balance is: [bold green]{balance}[/bold green]\nThe cost to register by recycle is " @@ -317,7 +317,7 @@ async def register(wallet: Wallet, subtensor: "SubtensorInterface", netuid: int) subtensor, wallet=wallet, netuid=netuid, - prompt=True, + prompt=False, recycle_amount=current_recycle, old_balance=balance, ) diff --git a/src/commands/wallets.py b/src/commands/wallets.py index 9fe7533f..ff80e7b9 100644 --- a/src/commands/wallets.py +++ b/src/commands/wallets.py @@ -131,7 +131,7 @@ async def new_coldkey( ) -def wallet_create( +async def wallet_create( wallet: Wallet, n_words: int = 12, use_password: bool = True, @@ -408,10 +408,10 @@ async def wallet_list(wallet_path: str): wallet_tree = root.add(f"\n[bold white]{wallet.name} ({coldkeypub_str})") hotkeys = utils.get_hotkey_wallets_for_wallet(wallet, show_nulls=True) for hkey in hotkeys: - data = f"[bold grey]{hkey.name} (?)" + data = f"[bold grey]{hkey.hotkey_str} (?)" if hkey: try: - data = f"[bold grey]{hkey.name} ({hkey.hotkey.ss58_address})" + data = f"[bold grey]{hkey.hotkey_str} ({hkey.hotkey.ss58_address})" except UnicodeDecodeError: pass wallet_tree.add(data) @@ -1050,9 +1050,13 @@ def _partial_decode(args): :return: (original netuid, decoded object) """ return_type, as_scale_bytes, custom_rpc_type_registry_, netuid_ = args - return netuid_, decode_scale_bytes( - return_type, as_scale_bytes, custom_rpc_type_registry_ - ) + decoded = decode_scale_bytes(return_type, as_scale_bytes, custom_rpc_type_registry_) + if decoded.startswith("0x"): + bytes_result = bytes.fromhex(decoded[2:]) + else: + bytes_result = bytes.fromhex(decoded) + + return netuid_, NeuronInfoLite.list_from_vec_u8(bytes_result) def _process_neurons_for_netuids( @@ -1075,18 +1079,11 @@ def make_map(res_): "get_neurons_lite" ]["type"] - all_results = [] preprocessed = [make_map(r) for r in netuids_with_all_neurons_hex_bytes] with ProcessPoolExecutor() as executor: - results = executor.map(_partial_decode, preprocessed) - for netuid, result in results: - all_results.append( - ( - netuid, - list(results), - ) - ) + results = list(executor.map(_partial_decode, preprocessed)) + all_results = [(netuid, result) for netuid, result in results] return all_results @@ -1133,28 +1130,31 @@ async def _filter_stake_info(stake_info: StakeInfo) -> bool: hotkey_ss58=stake_info.hotkey_ss58, reuse_block=True ) - all_staked_hotkeys = ( - x for x in all_stake_info_for_coldkey if await _filter_stake_info(x) + all_staked = await asyncio.gather( + *[_filter_stake_info(stake_info) for stake_info in all_stake_info_for_coldkey] ) - # List of (hotkey_addr, our_stake) tuples. - result = [ - ( - stake_info.hotkey_ss58, - stake_info.stake.tao, - ) # stake is a Balance object - for stake_info in all_staked_hotkeys - ] + # Collecting all filtered stake info using async for loop + all_staked_hotkeys = [] + for stake_info, staked in zip(all_stake_info_for_coldkey, all_staked): + if staked: + all_staked_hotkeys.append( + ( + stake_info.hotkey_ss58, + stake_info.stake.tao, + ) + ) - return coldkey_wallet, result, None + return coldkey_wallet, all_staked_hotkeys, None async def transfer( wallet: Wallet, subtensor: SubtensorInterface, destination: str, amount: float ): + # TODO: - work out prompts to be passed through the cli """Transfer token of amount to destination.""" await transfer_extrinsic( - subtensor, wallet, destination, Balance.from_tao(amount), prompt=True + subtensor, wallet, destination, Balance.from_tao(amount), prompt=False ) @@ -1166,7 +1166,7 @@ async def inspect( ): def delegate_row_maker( delegates_: list[tuple[DelegateInfo, Balance]], - ) -> Generator[list[str]]: + ) -> Generator[list[str], None, None]: for d_, staked in delegates_: if d_.hotkey_ss58 in registered_delegate_info: delegate_name = registered_delegate_info[d_.hotkey_ss58].name @@ -1182,7 +1182,9 @@ def delegate_row_maker( + [""] * 4 ) - def neuron_row_maker(wallet_, all_netuids_, nsd) -> Generator[list[str]]: + def neuron_row_maker( + wallet_, all_netuids_, nsd + ) -> Generator[list[str], None, None]: hotkeys = get_hotkey_wallets_for_wallet(wallet_) for netuid in all_netuids_: for n in nsd[netuid]: @@ -1299,11 +1301,12 @@ async def faucet( log_verbose: bool, max_successes: int = 3, ): + # TODO: - work out prompts to be passed through the cli success = await run_faucet_extrinsic( subtensor, wallet, tpb=threads_per_block, - prompt=True, + prompt=False, update_interval=update_interval, num_processes=processes, cuda=use_cuda, diff --git a/src/subtensor_interface.py b/src/subtensor_interface.py index 5fe9d277..b118f3b3 100644 --- a/src/subtensor_interface.py +++ b/src/subtensor_interface.py @@ -567,6 +567,9 @@ async def neuron_for_uid( This function is crucial for analyzing individual neurons' contributions and status within a specific subnet, offering insights into their roles in the network's consensus and validation mechanisms. """ + if uid is None: + return NeuronInfo.get_null_neuron() + params = [netuid, uid, block_hash] if block_hash else [netuid, uid] json_body = await self.substrate.rpc_request( method="neuronInfo_getNeuron", diff --git a/src/tests/__init__.py b/src/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/tests/e2e_tests/__init__.py b/src/tests/e2e_tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/tests/e2e_tests/conftest.py b/src/tests/e2e_tests/conftest.py new file mode 100644 index 00000000..4668e76b --- /dev/null +++ b/src/tests/e2e_tests/conftest.py @@ -0,0 +1,82 @@ +import logging +import os +import re +import shlex +import signal +import subprocess +import time + +import pytest + +from src.bittensor.async_substrate_interface import AsyncSubstrateInterface + +from .utils import ( + clone_or_update_templates, + install_templates, + uninstall_templates, +) + + +# Fixture for setting up and tearing down a localnet.sh chain between tests +@pytest.fixture(scope="function") +def local_chain(request): + param = request.param if hasattr(request, "param") else None + # Get the environment variable for the script path + script_path = os.getenv("LOCALNET_SH_PATH") + + if not script_path: + # Skip the test if the localhost.sh path is not set + logging.warning("LOCALNET_SH_PATH env variable is not set, e2e test skipped.") + pytest.skip("LOCALNET_SH_PATH environment variable is not set.") + + # Check if param is None, and handle it accordingly + args = "" if param is None else f"{param}" + + # Compile commands to send to process + cmds = shlex.split(f"{script_path} {args}") + # Start new node process + process = subprocess.Popen( + cmds, stdout=subprocess.PIPE, text=True, preexec_fn=os.setsid + ) + + # Pattern match indicates node is compiled and ready + pattern = re.compile(r"Imported #1") + + # Install neuron templates + logging.info("Downloading and installing neuron templates from github") + templates_dir = clone_or_update_templates() + install_templates(templates_dir) + + timestamp = int(time.time()) + + def wait_for_node_start(process, pattern): + for line in process.stdout: + print(line.strip()) + # 20 min as timeout + if int(time.time()) - timestamp > 20 * 60: + pytest.fail("Subtensor not started in time") + if pattern.search(line): + print("Node started!") + break + + wait_for_node_start(process, pattern) + + # Run the test, passing in substrate interface + yield AsyncSubstrateInterface(chain_endpoint="ws://127.0.0.1:9945") + + # Terminate the process group (includes all child processes) + os.killpg(os.getpgid(process.pid), signal.SIGTERM) + + # Give some time for the process to terminate + time.sleep(1) + + # If the process is not terminated, send SIGKILL + if process.poll() is None: + os.killpg(os.getpgid(process.pid), signal.SIGKILL) + + # Ensure the process has terminated + process.wait() + + # Clean up neuron templates + logging.info("Uninstalling neuron templates") + uninstall_templates(templates_dir) diff --git a/src/tests/e2e_tests/test_wallet_creations.py b/src/tests/e2e_tests/test_wallet_creations.py new file mode 100644 index 00000000..d73faaf4 --- /dev/null +++ b/src/tests/e2e_tests/test_wallet_creations.py @@ -0,0 +1,458 @@ +import logging +import os +import re +import time +from typing import Dict, Optional, Tuple + +from .utils import setup_wallet + +""" +Verify commands: + +* btcli w list +* btcli w create +* btcli w new_coldkey +* btcli w new_hotkey +* btcli w regen_coldkey +* btcli w regen_coldkeypub +* btcli w regen_hotkey +""" + + +def verify_wallet_dir( + base_path: str, + wallet_name: str, + hotkey_name: Optional[str] = None, + coldkeypub_name: Optional[str] = None, +) -> Tuple[bool, str]: + """ + Verifies the existence of wallet directory, coldkey, and optionally the hotkey. + + Args: + base_path (str): The base directory path where wallets are stored. + wallet_name (str): The name of the wallet directory to verify. + hotkey_name (str, optional): The name of the hotkey file to verify. If None, + only the wallet and coldkey file are checked. + coldkeypub_name (str, optional): The name of the coldkeypub file to verify. If None + only the wallet and coldkey is checked + + Returns: + tuple: Returns a tuple containing a boolean and a message. The boolean is True if + all checks pass, otherwise False. + """ + wallet_path = os.path.join(base_path, wallet_name) + + # Check if wallet directory exists + if not os.path.isdir(wallet_path): + return False, f"Wallet directory {wallet_name} not found in {base_path}" + + # Check if coldkey file exists + coldkey_path = os.path.join(wallet_path, "coldkey") + if not os.path.isfile(coldkey_path): + return False, f"Coldkey file not found in {wallet_name}" + + # Check if coldkeypub exists + if coldkeypub_name: + coldkeypub_path = os.path.join(wallet_path, coldkeypub_name) + if not os.path.isfile(coldkeypub_path): + return False, f"Coldkeypub file not found in {wallet_name}" + + # Check if hotkey directory and file exists + if hotkey_name: + hotkeys_path = os.path.join(wallet_path, "hotkeys") + if not os.path.isdir(hotkeys_path): + return False, f"Hotkeys directory not found in {wallet_name}" + + hotkey_file_path = os.path.join(hotkeys_path, hotkey_name) + if not os.path.isfile(hotkey_file_path): + return ( + False, + f"Hotkey file {hotkey_name} not found in {wallet_name}/hotkeys", + ) + + return True, f"Wallet {wallet_name} verified successfully" + + +def verify_key_pattern(output: str, wallet_name: str) -> Optional[str]: + """ + Verifies that a specific wallet key pattern exists in the output text. + + Args: + output (str): The string output where the wallet key should be verified. + wallet_name (str): The name of the wallet to search for in the output. + + Raises: + AssertionError: If the wallet key pattern is not found, or if the key does not + start with '5', or if the key is not exactly 48 characters long. + """ + split_output = output.splitlines() + pattern = rf"{wallet_name}\s*\((5[A-Za-z0-9]{{47}})\)" + found = False + + # Traverse each line to find instance of the pattern + for line in split_output: + match = re.search(pattern, line) + if match: + # Assert key starts with '5' + assert match.group(1).startswith( + "5" + ), f"{wallet_name} should start with '5'" + # Assert length of key is 48 characters + assert ( + len(match.group(1)) == 48 + ), f"Key for {wallet_name} should be 48 characters long" + found = True + return match.group(1) + + # If no match is found in any line, raise an assertion error + assert found, f"{wallet_name} not found in wallet list" + return None + + +def extract_ss58_address(output: str, wallet_name: str) -> str: + """ + Extracts the ss58 address from the given output for a specified wallet. + + Args: + output (str): The captured output. + wallet_name (str): The name of the wallet. + + Returns: + str: ss58 address. + """ + pattern = rf"{wallet_name}\s*\((5[A-Za-z0-9]{{47}})\)" + lines = output.splitlines() + for line in lines: + match = re.search(pattern, line) + if match: + return match.group(1) # Return the ss58 address + + raise ValueError(f"ss58 address not found for wallet {wallet_name}") + + +def extract_mnemonics_from_commands(output: str) -> Dict[str, Optional[str]]: + """ + Extracts mnemonics of coldkeys & hotkeys from the given output for a specified wallet. + + Args: + output (str): The captured output. + + Returns: + dict: A dictionary keys 'coldkey' and 'hotkey', each containing their mnemonics. + """ + mnemonics: Dict[str, Optional[str]] = {"coldkey": None, "hotkey": None} + lines = output.splitlines() + + key_types = ["coldkey", "hotkey"] + command_prefix = "btcli w regen_" + + for line in lines: + line = line.strip().lower() + + if line.startswith(command_prefix): + for key_type in key_types: + if line.startswith(f"{command_prefix}{key_type} --mnemonic "): + mnemonic_phrase = line.split("--mnemonic ")[1].strip() + mnemonics[key_type] = mnemonic_phrase + break + + return mnemonics + + +def test_wallet_creations(): + """ + Test the creation and verification of wallet keys and directories in the Bittensor network. + + Steps: + 1. List existing wallets and verify the default setup. + 2. Create a new wallet with both coldkey and hotkey, verify their presence in the output, + and check their physical existence. + 3. Create a new coldkey and verify both its display in the command line output and its physical file. + 4. Create a new hotkey for an existing coldkey, verify its display in the command line output, + and check for both coldkey and hotkey files. + + Raises: + AssertionError: If any of the checks or verifications fail + """ + + wallet_path_name = "//Alice" + keypair, wallet, wallet_path, exec_command = setup_wallet(wallet_path_name) + + result = exec_command( + command="wallet", sub_command="list", extra_args=["--wallet-path", wallet_path] + ) + + # Assert default keys are present before proceeding + assert "default" in result.stdout + assert "โ””โ”€โ”€ default" in result.stdout + wallet_status, message = verify_wallet_dir( + wallet_path, "default", hotkey_name="default" + ) + assert wallet_status, message + + # ----------------------------- + # Command 1: + # ----------------------------- + + logging.info("Testing wallet create command ๐Ÿงช") + # Create a new wallet (coldkey + hotkey) + exec_command( + command="wallet", + sub_command="create", + extra_args=[ + "--wallet-path", + wallet_path, + "--wallet-name", + "new_wallet", + "--hotkey", + "new_hotkey", + "--no-use-password", + "--overwrite-coldkey", + "--overwrite-hotkey", + "--n-words", + "12", + ], + ) + + # List the wallets + result = exec_command( + command="wallet", sub_command="list", extra_args=["--wallet-path", wallet_path] + ) + + # Verify coldkey "new_wallet" is displayed with key + verify_key_pattern(result.stdout, "new_wallet") + + # Verify hotkey "new_hotkey" is displayed with key + verify_key_pattern(result.stdout, "new_hotkey") + + # Physically verify "new_wallet" and "new_hotkey" are present + wallet_status, message = verify_wallet_dir( + wallet_path, "new_wallet", hotkey_name="new_hotkey" + ) + assert wallet_status, message + + # ----------------------------- + # Command 2: + # ----------------------------- + + logging.info("Testing wallet new_coldkey command ๐Ÿงช") + + # Create a new wallet (coldkey) + exec_command( + "wallet", + sub_command="new-coldkey", + extra_args=[ + "--overwrite-coldkey", + "--wallet-name", + "new_coldkey", + "--wallet-path", + wallet_path, + "--n-words", + "12", + "--no-use-password", + ], + ) + + # List the wallets + result = exec_command( + command="wallet", sub_command="list", extra_args=["--wallet-path", wallet_path] + ) + + # Verify coldkey "new_coldkey" is displayed with key + verify_key_pattern(result.stdout, "new_coldkey") + + # Physically verify "new_coldkey" is present + wallet_status, message = verify_wallet_dir(wallet_path, "new_coldkey") + assert wallet_status, message + + # ----------------------------- + # Command 3: + # ----------------------------- + + logging.info("Testing wallet new_hotkey command ๐Ÿงช") + # Create a new hotkey for new_coldkey wallet + result = exec_command( + "wallet", + sub_command="new-hotkey", + extra_args=[ + "--wallet-name", + "new_coldkey", + "--hotkey", + "new_hotkey", + "--overwrite-hotkey", + "--wallet-path", + wallet_path, + "--n-words", + "12", + "--no-use-password", + ], + ) + + # List the wallets + result = exec_command( + command="wallet", sub_command="list", extra_args=["--wallet-path", wallet_path] + ) + + # Verify hotkey "new_hotkey" is displyed with key + verify_key_pattern(result.stdout, "new_hotkey") + + # Physically verify "new_coldkey" and "new_hotkey" are present + wallet_status, message = verify_wallet_dir( + wallet_path, "new_coldkey", hotkey_name="new_hotkey" + ) + assert wallet_status, message + + +def test_wallet_regen(): + """ + Test the regeneration of coldkeys, hotkeys, and coldkeypub files using mnemonics or ss58 address. + + Steps: + 1. List existing wallets and verify the default setup. + 2. Regenerate the coldkey using the mnemonics and verify using mod time. + 3. Regenerate the coldkeypub using ss58 address and verify using mod time + 4. Regenerate the hotkey using mnemonics and verify using mod time. + + Raises: + AssertionError: If any of the checks or verifications fail + """ + wallet_path_name = "//Bob" + keypair, wallet, wallet_path, exec_command = setup_wallet(wallet_path_name) + + # Create a new wallet (coldkey + hotkey) + result = exec_command( + command="wallet", + sub_command="create", + extra_args=[ + "--wallet-path", + wallet_path, + "--wallet-name", + "new_wallet", + "--hotkey", + "new_hotkey", + "--no-use-password", + "--overwrite-coldkey", + "--overwrite-hotkey", + "--n-words", + "12", + ], + ) + + mnemonics = extract_mnemonics_from_commands(result.stdout) + + wallet_status, message = verify_wallet_dir( + wallet_path, + "new_wallet", + hotkey_name="new_hotkey", + coldkeypub_name="coldkeypub.txt", + ) + assert wallet_status, message # Ensure wallet exists + + # ----------------------------- + # Command 1: + # ----------------------------- + logging.info("Testing wallet regen_coldkey command ๐Ÿงช") + coldkey_path = os.path.join(wallet_path, "new_wallet", "coldkey") + initial_coldkey_mod_time = os.path.getmtime(coldkey_path) + + result = exec_command( + command="wallet", + sub_command="regen-coldkey", + extra_args=[ + "--wallet-name", + "new_wallet", + "--hotkey", + "new_hotkey", + "--wallet-path", + wallet_path, + "--overwrite-coldkey", + "--mnemonic", + mnemonics["coldkey"], + "--no-use-password", + ], + ) + + # Wait a bit to ensure file system updates modification time + time.sleep(1) + + new_coldkey_mod_time = os.path.getmtime(coldkey_path) + + assert ( + initial_coldkey_mod_time != new_coldkey_mod_time + ), "Coldkey file was not regenerated as expected" + logging.info("Passed wallet regen_coldkey command โœ…") + + # ----------------------------- + # Command 2: + # ----------------------------- + + logging.info("Testing wallet regen_coldkeypub command ๐Ÿงช") + coldkeypub_path = os.path.join(wallet_path, "new_wallet", "coldkeypub.txt") + initial_coldkeypub_mod_time = os.path.getmtime(coldkeypub_path) + + result = exec_command( + command="wallet", sub_command="list", extra_args=["--wallet-path", wallet_path] + ) + + ss58_address = extract_ss58_address(result.stdout, "new_wallet") + + result = exec_command( + command="wallet", + sub_command="regen-coldkeypub", + extra_args=[ + "--wallet-name", + "new_wallet", + "--hotkey", + "new_hotkey", + "--wallet-path", + wallet_path, + "--ss58-address", + ss58_address, + "--overwrite-coldkeypub", + ], + ) + + # Wait a bit to ensure file system updates modification time + time.sleep(1) + + new_coldkeypub_mod_time = os.path.getmtime(coldkeypub_path) + + assert ( + initial_coldkeypub_mod_time != new_coldkeypub_mod_time + ), "Coldkeypub file was not regenerated as expected" + logging.info("Passed wallet regen_coldkeypub command โœ…") + + # ----------------------------- + # Command 3: + # ----------------------------- + + logging.info("Testing wallet regen_hotkey command ๐Ÿงช") + hotkey_path = os.path.join(wallet_path, "new_wallet", "hotkeys", "new_hotkey") + initial_hotkey_mod_time = os.path.getmtime(hotkey_path) + + exec_command( + command="wallet", + sub_command="regen-hotkey", + extra_args=[ + "--wallet-name", + "new_wallet", + "--hotkey", + "new_hotkey", + "--wallet-path", + wallet_path, + "--mnemonic", + mnemonics["hotkey"], + "--overwrite-hotkey", + "--no-use-password", + ], + ) + + # Wait a bit to ensure file system updates modification time + time.sleep(1) + + new_hotkey_mod_time = os.path.getmtime(hotkey_path) + + assert ( + initial_hotkey_mod_time != new_hotkey_mod_time + ), "Hotkey file was not regenerated as expected" + logging.info("Passed wallet regen_hotkey command โœ…") diff --git a/src/tests/e2e_tests/test_wallet_interactions.py b/src/tests/e2e_tests/test_wallet_interactions.py new file mode 100644 index 00000000..159dbd65 --- /dev/null +++ b/src/tests/e2e_tests/test_wallet_interactions.py @@ -0,0 +1,355 @@ +import logging + +from btcli.src.bittensor.balances import Balance + +from .utils import ( + extract_coldkey_balance, + setup_wallet, + validate_wallet_inspect, + validate_wallet_overview, + verify_subnet_entry, +) + +""" +Verify commands: + +* btcli subnets create +* btcli subnets register +* btcli subnets list +* btcli w inspect +* btcli w overview +""" + + +def test_wallet_overview_inspect(local_chain): + """ + Test the overview and inspect commands of the wallet by interaction with subnets + + Steps: + 1. Create wallet for Alice + 2. Create a subnet, execute subnet list and verify subnet creation + 3. Register Alice in the subnet and extract her balance + 4. Execute wallet overview, inspect and assert correct data is displayed + + Raises: + AssertionError: If any of the checks or verifications fail + """ + logging.info("Testing wallet overview, inspect command ๐Ÿงช") + netuid = 1 + wallet_path_name = "//Alice" + + # Create wallet for Alice + keypair, wallet, wallet_path, exec_command = setup_wallet(wallet_path_name) + + # Register a subnet with sudo as Alice + result = exec_command( + command="subnets", + sub_command="create", + extra_args=[ + "--wallet-path", + wallet_path, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet.name, + "--network", + "local", + ], + ) + assert f"โœ… Registered subnetwork with netuid: {netuid}" in result.stdout + + # List all the subnets in the network + subnets_list = exec_command( + command="subnets", + sub_command="list", + extra_args=[ + "--chain", + "ws://127.0.0.1:9945", + "--network", + "local", + ], + ) + + # Assert using regex that the subnet is visible in subnets list + assert verify_subnet_entry(subnets_list.stdout, netuid, keypair.ss58_address) + + # Register Alice in netuid = 1 using her hotkey + register_subnet = exec_command( + command="subnets", + sub_command="register", + extra_args=[ + "--wallet-path", + wallet_path, + "--wallet-name", + wallet.name, + "--hotkey", + wallet.hotkey_str, + "--network", + "local", + "--netuid", + "1", + "--chain", + "ws://127.0.0.1:9945", + ], + ) + assert "โœ… Registered" in register_subnet.stdout + + # Check balance of Alice after registering to the subnet + wallet_balance = exec_command( + command="wallet", + sub_command="balance", + extra_args=[ + "--wallet-path", + wallet_path, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + wallet.name, + "--network", + "local", + ], + ) + + # Assert correct address is displayed + assert keypair.ss58_address in wallet_balance.stdout + + # Extract balance left after creating and registering into the subnet + balance = extract_coldkey_balance( + wallet_balance.stdout, + wallet_name=wallet.name, + coldkey_address=wallet.coldkey.ss58_address, + ) + + # Execute wallet overview command. + wallet_overview = exec_command( + command="wallet", + sub_command="overview", + extra_args=[ + "--wallet-path", + wallet_path, + "--wallet-name", + wallet.name, + "--network", + "local", + "--chain", + "ws://127.0.0.1:9945", + ], + ) + + # Assert correct entry is present in wallet overview + assert validate_wallet_overview( + output=wallet_overview.stdout, + uid=0, # Since Alice was the first one, she has uid = 0 + coldkey=wallet.name, + hotkey=wallet.hotkey_str, + hotkey_ss58=keypair.ss58_address, + axon_active=False, # Axon is not active until we run validator/miner + ) + + # Execute wallet inspect command + inspect = exec_command( + command="wallet", + sub_command="inspect", + extra_args=[ + "--wallet-path", + wallet_path, + "--wallet-name", + wallet.name, + "--network", + "local", + "--chain", + "ws://127.0.0.1:9945", + ], + ) + + # Assert correct entry is present in wallet inspect + assert validate_wallet_inspect( + inspect.stdout, + coldkey=wallet.name, + balance=Balance.from_tao(balance["free_balance"]), + delegates=None, # We have not delegated anywhere yet + hotkeys_netuid=[ + (1, f"default-{wallet.hotkey.ss58_address}", 0, False) + ], # (netuid, hotkey-display, stake, check_emissions) + ) + logging.info("Passed wallet overview, inspect command โœ…") + + +""" +Verify commands: + +* btcli w balance +* btcli w transfer +""" + + +def test_wallet_transfer(local_chain): + """ + Test the transfer and balance functionality in the Bittensor network. + + Steps: + 1. Create wallets for Alice and Bob with initial balance already + 2. Ensure initial balance is displayed correctly + 3. Transfer 100 TAO from Alice to Bob + 4. Assert amount was transferred along with transfer tolerance + 5. Assert transfer fails with no balance for Anakin + + Raises: + AssertionError: If any of the checks or verifications fail + """ + logging.info("Testing wallet transfer, balance command ๐Ÿงช") + wallet_path_alice = "//Alice" + wallet_path_bob = "//Bob" + + # Create wallets for Alice and Bob + keypair_alice, wallet_alice, wallet_path_alice, exec_command_alice = setup_wallet( + wallet_path_alice + ) + keypair_bob, wallet_bob, wallet_path_bob, exec_command_bob = setup_wallet( + wallet_path_bob + ) + + # Both Alice and Bob have initial balance through the local chain + alice_bob_initial_balance = Balance.from_tao(1_000_000) + + # Check balance of Alice + result = exec_command_alice( + command="wallet", + sub_command="balance", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--network", + "local", + ], + ) + + # Assert correct address is displayed + assert keypair_alice.ss58_address in result.stdout + + # Assert correct initial balance is shown + initial_balance = Balance.from_tao( + extract_coldkey_balance( + result.stdout, + wallet_name=wallet_alice.name, + coldkey_address=wallet_alice.coldkey.ss58_address, + )["free_balance"] + ) + assert initial_balance == alice_bob_initial_balance + + # Transfer of 100 tao for this test + expected_transfer = Balance.from_tao(100) + + # Execute the transfer command, with Bob's address as destination + result = exec_command_alice( + command="wallet", + sub_command="transfer", + extra_args=[ + "--dest", + keypair_bob.ss58_address, + "--wallet-path", + wallet_path_alice, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--network", + "local", + "--amount", + "100", + ], + ) + + # To-do: Assert correct output once transfer is fixed + + # Check balance of Alice after transfer + result = exec_command_alice( + command="wallet", + sub_command="balance", + extra_args=[ + "--wallet-path", + wallet_path_alice, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--network", + "local", + ], + ) + + # Extract balance after the transfer + balance_remaining = Balance.from_tao( + extract_coldkey_balance( + result.stdout, + wallet_name=wallet_alice.name, + coldkey_address=wallet_alice.coldkey.ss58_address, + )["free_balance"] + ) + + tolerance = Balance.from_rao(200_000) # Tolerance for transaction fee + balance_difference = initial_balance - balance_remaining + + # Assert transfer was successful w.r.t tolerance + assert expected_transfer <= balance_difference <= expected_transfer + tolerance + + # Check balance of Bob after transfer + result = exec_command_bob( + command="wallet", + sub_command="balance", + extra_args=[ + "--wallet-path", + wallet_path_bob, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--network", + "local", + ], + ) + + # Extract Bob's balance from output + new_balance_bob = Balance.from_tao( + extract_coldkey_balance( + result.stdout, + wallet_name=wallet_bob.name, + coldkey_address=wallet_bob.coldkey.ss58_address, + )["free_balance"] + ) + + # Assert correct balance was transferred from Bob + assert alice_bob_initial_balance + expected_transfer == new_balance_bob + + wallet_path_anakin = "//Anakin" + keypair_anakin, wallet_anakin, wallet_path_anakin, exec_command_anakin = ( + setup_wallet(wallet_path_anakin) + ) + + # Attempt transferring to Alice + result = exec_command_anakin( + command="wallet", + sub_command="transfer", + extra_args=[ + "--dest", + keypair_alice.ss58_address, + "--wallet-path", + wallet_path_anakin, + "--chain", + "ws://127.0.0.1:9945", + "--wallet-name", + "default", + "--network", + "local", + "--amount", + "100", + ], + ) + + # This transfer is expected to fail due to low balance + assert "โŒ Not enough balance" in result.stdout + logging.info("Testing wallet transfer, balance command โœ…") diff --git a/src/tests/e2e_tests/utils.py b/src/tests/e2e_tests/utils.py new file mode 100644 index 00000000..17fb989e --- /dev/null +++ b/src/tests/e2e_tests/utils.py @@ -0,0 +1,301 @@ +import asyncio +import logging +import os +import re +import shutil +import subprocess +import sys +from typing import List, Tuple + +from bittensor_wallet import Wallet +from substrateinterface import Keypair +from typer.testing import CliRunner + +from btcli.cli import CLIManager + +template_path = os.getcwd() + "/neurons/" +templates_repo = "templates repository" + + +def setup_wallet(uri: str): + keypair = Keypair.create_from_uri(uri) + wallet_path = f"/tmp/btcli-e2e-wallet-{uri.strip('/')}" + wallet = Wallet(path=wallet_path) + wallet.set_coldkey(keypair=keypair, encrypt=False, overwrite=True) + wallet.set_coldkeypub(keypair=keypair, encrypt=False, overwrite=True) + wallet.set_hotkey(keypair=keypair, encrypt=False, overwrite=True) + + def exec_command(command: str, sub_command: str, extra_args: List[str] = []): + cli_manager = CLIManager() + runner = CliRunner() + # Prepare the command arguments + args = [ + command, + sub_command, + ] + extra_args + result = runner.invoke(cli_manager.app, args, env={"COLUMNS": "1000"}) + return result + + return keypair, wallet, wallet_path, exec_command + + +def extract_coldkey_balance(text: str, wallet_name: str, coldkey_address: str) -> dict: + """ + Extracts the free, staked, and total balances for a + given wallet name and coldkey address from the input string. + + Args: + text (str): The input string from wallet list command. + wallet_name (str): The name of the wallet. + coldkey_address (str): The coldkey address. + + Returns: + dict: A dictionary with keys 'free_balance', 'staked_balance', and 'total_balance', + each containing the corresponding balance as a Balance object. + Returns a dictionary with all zeros if the wallet name or coldkey address is not found. + """ + pattern = ( + rf"{wallet_name}\s+{coldkey_address}\s+" + r"ฯ„([\d,]+\.\d+)\s+" # Free Balance + r"ฯ„([\d,]+\.\d+)\s+" # Staked Balance + r"ฯ„([\d,]+\.\d+)" # Total Balance + ) + + match = re.search(pattern, text) + + if not match: + return { + "free_balance": 0.0, + "staked_balance": 0.0, + "total_balance": 0.0, + } + + # Return the balances as a dictionary + return { + "free_balance": float(match.group(1).replace(",", "")), + "staked_balance": float(match.group(2).replace(",", "")), + "total_balance": float(match.group(3).replace(",", "")), + } + + +def verify_subnet_entry(output_text: str, netuid: str, ss58_address: str) -> bool: + """ + Verifies the presence of a specific subnet entry subnets list output. + + Args: + output_text (str): Output of execution command + netuid (str): The netuid to look for. + ss58_address (str): The SS58 address of the subnet owner + + Returns: + bool: True if the entry is found, False otherwise. + """ + + # Construct the regex pattern + pattern = rf"\s*{netuid}\s+" # NETUID + pattern += r"\d+\s+" # N (any number) + pattern += r"\d+(?:\.\d+)?\s*[KMB]?\s+" # MAX_N (number with optional decimal and K/M/B suffix) + pattern += r"\d+\.\d+%\s+" # EMISSION (percentage) + pattern += r"\d+\s+" # TEMPO (any number) + pattern += r"ฯ„\d+\.\d+\s+" # RECYCLE (ฯ„ followed by a number) + pattern += r"\d+(?:\.\d+)?\s*[KMB]\s+" # POW (number with optional decimal and K/M/B suffix) + pattern += rf"{re.escape(ss58_address)}\s*" # SUDO (exact SS58 address) + + # Search for the pattern + match = re.search(pattern, output_text) + + return bool(match) + + +def validate_wallet_overview( + output: str, + uid: int, + coldkey: str, + hotkey: str, + hotkey_ss58: str, + axon_active: bool = False, +): + """ + Validates the presence a registered neuron in wallet overview output. + + Returns: + bool: True if the entry is found, False otherwise. + """ + + # Construct the regex pattern + pattern = rf"{coldkey}\s+" # COLDKEY + pattern += rf"{hotkey}\s+" # HOTKEY + pattern += rf"{uid}\s+" # UID + pattern += r"True\s+" # ACTIVE - Always True immediately after we register + pattern += r"[\d.]+\s+" # STAKE(ฯ„) + pattern += r"[\d.]+\s+" # RANK + pattern += r"[\d.]+\s+" # TRUST + pattern += r"[\d.]+\s+" # CONSENSUS + pattern += r"[\d.]+\s+" # INCENTIVE + pattern += r"[\d.]+\s+" # DIVIDENDS + pattern += r"\d+\s+" # EMISSION(ฯ) + pattern += r"[\d.]+\s+" # VTRUST + pattern += r"(?:True|False)?\s*" # VPERMIT (optional) + pattern += r"[\d]+\s+" # UPDATED (any number) + pattern += ( + r"(?!none)\w+\s+" if axon_active else r"none\s+" + ) # AXON - True if axon is active + pattern += rf"{hotkey_ss58}\s*" # HOTKEY_SS58 + + # Search for the pattern in the wallet information + match = re.search(pattern, output) + + return bool(match) + + +def validate_wallet_inspect( + text: str, + coldkey: str, + balance: float, + delegates: List[Tuple[str, float, bool]], + hotkeys_netuid: List[Tuple[str, str, float, bool]], +): + # TODO: Handle stake in Balance format as well + """ + Validates the presence of specific coldkey, balance, delegates, and hotkeys/netuid in the wallet information. + + Args: + wallet_info (str): The string output to verify. + coldkey (str): The coldkey to check. + balance (float): The balance to verify for the coldkey. + delegates (list of tuple): List of delegates to check, Each tuple contains (ss58_address, stake, emission_flag). + hotkeys_netuid (list of tuple): List of hotkeys/netuids to check, Each tuple contains (netuid, hotkey, stake, emission_flag). + + Returns: + bool: True if all checks pass, False otherwise. + """ + lines = text.splitlines() + + def parse_value(value): + return float(value.replace("ฯ„", "").replace(",", "")) + + def check_stake(actual, expected): + return expected <= actual <= expected + 2 + + # Check coldkey and balance + # This is the first row when records of a coldkey start + coldkey_pattern = rf"{coldkey}\s+{balance}" + for line in lines: + match = re.search(coldkey_pattern, line) + if match: + break + else: + return False + + # This checks for presence of delegates in each row + if delegates: + for ss58, stake, check_emission in delegates: + delegate_pattern = rf"{ss58}\s+ฯ„([\d,.]+)\s+([\d.e-]+)" + for line in lines: + match = re.search(delegate_pattern, line) + if match: + actual_stake = parse_value(match.group(1)) + emission = float(match.group(2)) + if not check_stake(actual_stake, stake): + return False + if check_emission and emission == 0: + return False + break + else: + return False + + # This checks for hotkeys that are registered to subnets + if hotkeys_netuid: + for netuid, hotkey, stake, check_emission in hotkeys_netuid: + hotkey_pattern = rf"{netuid}\s+{hotkey}\s+ฯ„([\d,.]+)\s+ฯ„([\d,.]+)" + for line in lines: + match = re.search(hotkey_pattern, line) + if match: + actual_stake = parse_value(match.group(1)) + emission = parse_value(match.group(2)) + if not check_stake(actual_stake, stake): + return False + if check_emission and emission == 0: + return False + break + else: + return False + + return True + + +async def wait_epoch(subtensor, netuid=1): + q_tempo = [ + v.value + for [k, v] in subtensor.query_map_subtensor("Tempo") + if k.value == netuid + ] + if len(q_tempo) == 0: + raise Exception("could not determine tempo") + tempo = q_tempo[0] + logging.info(f"tempo = {tempo}") + await wait_interval(tempo, subtensor, netuid) + + +async def wait_interval(tempo, subtensor, netuid=1): + interval = tempo + 1 + current_block = subtensor.get_current_block() + last_epoch = current_block - 1 - (current_block + netuid + 1) % interval + next_tempo_block_start = last_epoch + interval + last_reported = None + while current_block < next_tempo_block_start: + await asyncio.sleep( + 1 + ) # Wait for 1 second before checking the block number again + current_block = subtensor.get_current_block() + if last_reported is None or current_block - last_reported >= 10: + last_reported = current_block + print( + f"Current Block: {current_block} Next tempo for netuid {netuid} at: {next_tempo_block_start}" + ) + logging.info( + f"Current Block: {current_block} Next tempo for netuid {netuid} at: {next_tempo_block_start}" + ) + + +def clone_or_update_templates(specific_commit=None): + install_dir = template_path + repo_mapping = { + templates_repo: "https://github.com/opentensor/bittensor-subnet-template.git", + } + os.makedirs(install_dir, exist_ok=True) + os.chdir(install_dir) + + for repo, git_link in repo_mapping.items(): + if not os.path.exists(repo): + print(f"\033[94mCloning {repo}...\033[0m") + subprocess.run(["git", "clone", git_link, repo], check=True) + else: + print(f"\033[94mUpdating {repo}...\033[0m") + os.chdir(repo) + subprocess.run(["git", "pull"], check=True) + os.chdir("..") + + # Here for pulling specific commit versions of repo + if specific_commit: + os.chdir(templates_repo) + print( + f"\033[94mChecking out commit {specific_commit} in {templates_repo}...\033[0m" + ) + subprocess.run(["git", "checkout", specific_commit], check=True) + os.chdir("..") + + return install_dir + templates_repo + "/" + + +def install_templates(install_dir): + subprocess.check_call([sys.executable, "-m", "pip", "install", install_dir]) + + +def uninstall_templates(install_dir): + # Uninstall templates + subprocess.check_call( + [sys.executable, "-m", "pip", "uninstall", "bittensor_subnet_template", "-y"] + ) + # Delete everything in directory + shutil.rmtree(install_dir)