diff --git a/bittensor/__init__.py b/bittensor/__init__.py index 151faa70be..04c178e3bf 100644 --- a/bittensor/__init__.py +++ b/bittensor/__init__.py @@ -286,6 +286,8 @@ def debug(on: bool = True): ProposalCallData, ProposalVoteData, ) + +from . import subtensor as subtensor_module from .subtensor import subtensor as subtensor from .cli import cli as cli, COMMANDS as ALL_COMMANDS from .btlogging import logging diff --git a/bittensor/subtensor.py b/bittensor/subtensor.py index a4bc4b20f3..f174466287 100644 --- a/bittensor/subtensor.py +++ b/bittensor/subtensor.py @@ -1,7 +1,6 @@ # The MIT License (MIT) # Copyright © 2021 Yuma Rao # Copyright © 2023 Opentensor Foundation -import functools # Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated # documentation files (the “Software”), to deal in the Software without restriction, including without limitation @@ -17,6 +16,7 @@ # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER # DEALINGS IN THE SOFTWARE. +import functools import os import copy import time @@ -85,9 +85,11 @@ from .extrinsics.root import root_register_extrinsic, set_root_weights_extrinsic from .types import AxonServeCallParams, PrometheusServeCallParams from .utils import U16_NORMALIZED_FLOAT, ss58_to_vec_u8, U64_NORMALIZED_FLOAT +from .utils.subtensor import get_subtensor_errors from .utils.balance import Balance from .utils.registration import POWSolution + logger = logging.getLogger("subtensor") KEY_NONCE: Dict[str, int] = {} @@ -170,6 +172,125 @@ class subtensor: principles and mechanisms described in the `NeurIPS paper `_. paper. """ + def __init__( + self, + network: Optional[str] = None, + config: Optional[bittensor.config] = None, + _mock: bool = False, + log_verbose: bool = True, + ) -> None: + """ + Initializes a Subtensor interface for interacting with the Bittensor blockchain. + + NOTE: + Currently subtensor defaults to the ``finney`` network. This will change in a future release. + + We strongly encourage users to run their own local subtensor node whenever possible. This increases + decentralization and resilience of the network. In a future release, local subtensor will become the + default and the fallback to ``finney`` removed. Please plan ahead for this change. We will provide detailed + instructions on how to run a local subtensor node in the documentation in a subsequent release. + + Args: + network (str, optional): The network name to connect to (e.g., ``finney``, ``local``). This can also be the chain endpoint (e.g., ``wss://entrypoint-finney.opentensor.ai:443``) and will be correctly parsed into the network and chain endpoint. If not specified, defaults to the main Bittensor network. + config (bittensor.config, optional): Configuration object for the subtensor. If not provided, a default configuration is used. + _mock (bool, optional): If set to ``True``, uses a mocked connection for testing purposes. + + This initialization sets up the connection to the specified Bittensor network, allowing for various + blockchain operations such as neuron registration, stake management, and setting weights. + + """ + # Determine config.subtensor.chain_endpoint and config.subtensor.network config. + # If chain_endpoint is set, we override the network flag, otherwise, the chain_endpoint is assigned by the network. + # Argument importance: network > chain_endpoint > config.subtensor.chain_endpoint > config.subtensor.network + + # Check if network is a config object. (Single argument passed as first positional) + if isinstance(network, bittensor.config): + if network.subtensor is None: + bittensor.logging.warning( + "If passing a bittensor config object, it must not be empty. Using default subtensor config." + ) + config = None + else: + config = network + network = None + + if config is None: + config = subtensor.config() + self.config = copy.deepcopy(config) # type: ignore + + # Setup config.subtensor.network and config.subtensor.chain_endpoint + self.chain_endpoint, self.network = subtensor.setup_config(network, config) # type: ignore + + if ( + self.network == "finney" + or self.chain_endpoint == bittensor.__finney_entrypoint__ + ) and log_verbose: + bittensor.logging.info( + f"You are connecting to {self.network} network with endpoint {self.chain_endpoint}." + ) + bittensor.logging.warning( + "We strongly encourage running a local subtensor node whenever possible. " + "This increases decentralization and resilience of the network." + ) + bittensor.logging.warning( + "In a future release, local subtensor will become the default endpoint. " + "To get ahead of this change, please run a local subtensor node and point to it." + ) + + # Returns a mocked connection with a background chain connection. + self.config.subtensor._mock = ( + _mock + if _mock != None + else self.config.subtensor.get("_mock", bittensor.defaults.subtensor._mock) + ) + if ( + self.config.subtensor._mock + ): # TODO: review this doesn't appear to be used anywhere. + config.subtensor._mock = True + return bittensor.MockSubtensor() # type: ignore + + # Attempt to connect to chosen endpoint. Fallback to finney if local unavailable. + try: + # Set up params. + self.substrate = SubstrateInterface( + ss58_format=bittensor.__ss58_format__, + use_remote_preset=True, + url=self.chain_endpoint, + type_registry=bittensor.__type_registry__, + ) + except ConnectionRefusedError as e: + bittensor.logging.error( + f"Could not connect to {self.network} network with {self.chain_endpoint} chain endpoint. Exiting..." + ) + bittensor.logging.info( + f"You can check if you have connectivity by runing this command: nc -vz localhost {self.chain_endpoint.split(':')[2]}" + ) + exit(1) + # TODO (edu/phil): Advise to run local subtensor and point to dev docs. + + try: + self.substrate.websocket.settimeout(600) + except: + bittensor.logging.warning("Could not set websocket timeout.") + + if log_verbose: + bittensor.logging.info( + f"Connected to {self.network} network and {self.chain_endpoint}." + ) + + self._subtensor_errors: Dict[str, Dict[str, str]] = {} + + def __str__(self) -> str: + if self.network == self.chain_endpoint: + # Connecting to chain endpoint without network known. + return "subtensor({})".format(self.chain_endpoint) + else: + # Connecting to network with endpoint known. + return "subtensor({}, {})".format(self.network, self.chain_endpoint) + + def __repr__(self) -> str: + return self.__str__() + @staticmethod def config() -> "bittensor.config": parser = argparse.ArgumentParser() @@ -324,123 +445,6 @@ def setup_config(network: str, config: bittensor.config): evaluated_network, ) - def __init__( - self, - network: Optional[str] = None, - config: Optional[bittensor.config] = None, - _mock: bool = False, - log_verbose: bool = True, - ) -> None: - """ - Initializes a Subtensor interface for interacting with the Bittensor blockchain. - - NOTE: - Currently subtensor defaults to the ``finney`` network. This will change in a future release. - - We strongly encourage users to run their own local subtensor node whenever possible. This increases - decentralization and resilience of the network. In a future release, local subtensor will become the - default and the fallback to ``finney`` removed. Please plan ahead for this change. We will provide detailed - instructions on how to run a local subtensor node in the documentation in a subsequent release. - - Args: - network (str, optional): The network name to connect to (e.g., ``finney``, ``local``). This can also be the chain endpoint (e.g., ``wss://entrypoint-finney.opentensor.ai:443``) and will be correctly parsed into the network and chain endpoint. If not specified, defaults to the main Bittensor network. - config (bittensor.config, optional): Configuration object for the subtensor. If not provided, a default configuration is used. - _mock (bool, optional): If set to ``True``, uses a mocked connection for testing purposes. - - This initialization sets up the connection to the specified Bittensor network, allowing for various - blockchain operations such as neuron registration, stake management, and setting weights. - - """ - # Determine config.subtensor.chain_endpoint and config.subtensor.network config. - # If chain_endpoint is set, we override the network flag, otherwise, the chain_endpoint is assigned by the network. - # Argument importance: network > chain_endpoint > config.subtensor.chain_endpoint > config.subtensor.network - - # Check if network is a config object. (Single argument passed as first positional) - if isinstance(network, bittensor.config): - if network.subtensor is None: - bittensor.logging.warning( - "If passing a bittensor config object, it must not be empty. Using default subtensor config." - ) - config = None - else: - config = network - network = None - - if config is None: - config = subtensor.config() - self.config = copy.deepcopy(config) # type: ignore - - # Setup config.subtensor.network and config.subtensor.chain_endpoint - self.chain_endpoint, self.network = subtensor.setup_config(network, config) # type: ignore - - if ( - self.network == "finney" - or self.chain_endpoint == bittensor.__finney_entrypoint__ - ) and log_verbose: - bittensor.logging.info( - f"You are connecting to {self.network} network with endpoint {self.chain_endpoint}." - ) - bittensor.logging.warning( - "We strongly encourage running a local subtensor node whenever possible. " - "This increases decentralization and resilience of the network." - ) - bittensor.logging.warning( - "In a future release, local subtensor will become the default endpoint. " - "To get ahead of this change, please run a local subtensor node and point to it." - ) - - # Returns a mocked connection with a background chain connection. - self.config.subtensor._mock = ( - _mock - if _mock != None - else self.config.subtensor.get("_mock", bittensor.defaults.subtensor._mock) - ) - if ( - self.config.subtensor._mock - ): # TODO: review this doesn't appear to be used anywhere. - config.subtensor._mock = True - return bittensor.MockSubtensor() # type: ignore - - # Attempt to connect to chosen endpoint. Fallback to finney if local unavailable. - try: - # Set up params. - self.substrate = SubstrateInterface( - ss58_format=bittensor.__ss58_format__, - use_remote_preset=True, - url=self.chain_endpoint, - type_registry=bittensor.__type_registry__, - ) - except ConnectionRefusedError as e: - bittensor.logging.error( - f"Could not connect to {self.network} network with {self.chain_endpoint} chain endpoint. Exiting..." - ) - bittensor.logging.info( - f"You can check if you have connectivity by runing this command: nc -vz localhost {self.chain_endpoint.split(':')[2]}" - ) - exit(1) - # TODO (edu/phil): Advise to run local subtensor and point to dev docs. - - try: - self.substrate.websocket.settimeout(600) - except: - bittensor.logging.warning("Could not set websocket timeout.") - - if log_verbose: - bittensor.logging.info( - f"Connected to {self.network} network and {self.chain_endpoint}." - ) - - def __str__(self) -> str: - if self.network == self.chain_endpoint: - # Connecting to chain endpoint without network known. - return "subtensor({})".format(self.chain_endpoint) - else: - # Connecting to network with endpoint known. - return "subtensor({}, {})".format(self.network, self.chain_endpoint) - - def __repr__(self) -> str: - return self.__str__() - #################### #### SubstrateInterface related #################### @@ -4509,3 +4513,20 @@ def get_block_hash(self, block_id: int) -> str: maintaining the trustworthiness of the blockchain. """ return self.substrate.get_block_hash(block_id=block_id) + + def get_error_info_by_index(self, error_index: int) -> Tuple[str, str]: + """Returns the error name and description from the Subtensor error list.""" + + unknown_error = ("Unknown Error", "") + + if not self._subtensor_errors: + self._subtensor_errors = get_subtensor_errors(self.substrate) + + name, description = self._subtensor_errors.get(str(error_index), unknown_error) + + if name == unknown_error[0]: + logger.warning( + f"Subtensor returned an error with an unknown index: {error_index}" + ) + + return name, description diff --git a/bittensor/utils/subtensor.py b/bittensor/utils/subtensor.py new file mode 100644 index 0000000000..484184e77f --- /dev/null +++ b/bittensor/utils/subtensor.py @@ -0,0 +1,139 @@ +# The MIT License (MIT) +# Copyright © 2021 Yuma Rao +# Copyright © 2022 Opentensor Foundation +# Copyright © 2023 Opentensor Technologies Inc + +# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +# documentation files (the “Software”), to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all copies or substantial portions of +# the Software. + +# THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO +# THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +"""Module providing common helper functions for working with Subtensor.""" + +import json +import logging +import os +from typing import Dict, Optional, Union, Any + +from substrateinterface.base import SubstrateInterface + +_logger = logging.getLogger("subtensor.errors_handler") + +_USER_HOME_DIR = os.path.expanduser("~") +_BT_DIR = os.path.join(_USER_HOME_DIR, ".bittensor") +_ERRORS_FILE_PATH = os.path.join(_BT_DIR, "subtensor_errors_map.json") +_ST_BUILD_ID = "subtensor_build_id" + +# Create directory if it doesn't exist +os.makedirs(_BT_DIR, exist_ok=True) + + +# Pallet's typing class `PalletMetadataV14` is defined only at +# https://github.com/polkascan/py-scale-codec/blob/master/scalecodec/type_registry/core.json#L1024 +# A class object is created dynamically at runtime. +# Circleci linter complains about string represented classes like 'PalletMetadataV14'. +def _get_errors_from_pallet(pallet) -> Optional[Dict[str, Dict[str, str]]]: + """Extracts and returns error information from the given pallet metadata. + + Args: + pallet (PalletMetadataV14): The pallet metadata containing error definitions. + + Returns: + dict[str, str]: A dictionary of errors indexed by their IDs. + + Raises: + ValueError: If the pallet does not contain error definitions or the list is empty. + """ + if not hasattr(pallet, "errors") or not pallet.errors: + _logger.warning( + "The pallet does not contain any error definitions or the list is empty." + ) + return None + + return { + str(error["index"]): { + "name": error["name"], + "description": " ".join(error["docs"]), + } + for error in pallet.errors + } + + +def _save_errors_to_cache(uniq_version: str, errors: Dict[str, Dict[str, str]]): + """Saves error details and unique version identifier to a JSON file. + + Args: + uniq_version (str): Unique version identifier for the Subtensor build. + errors (dict[str, str]): Error information to be cached. + """ + data = {_ST_BUILD_ID: uniq_version, "errors": errors} + try: + with open(_ERRORS_FILE_PATH, "w") as json_file: + json.dump(data, json_file, indent=4) + except IOError as e: + _logger.warning(f"Error saving to file: {e}") + + +def _get_errors_from_cache() -> Optional[Dict[str, Dict[str, Dict[str, str]]]]: + """Retrieves and returns the cached error information from a JSON file, if it exists. + + Returns: + A dictionary containing error information. + """ + if not os.path.exists(_ERRORS_FILE_PATH): + return None + + try: + with open(_ERRORS_FILE_PATH, "r") as json_file: + data = json.load(json_file) + except IOError as e: + _logger.warning(f"Error reading from file: {e}") + return None + + return data + + +def get_subtensor_errors( + substrate: SubstrateInterface, +) -> Union[Dict[str, Dict[str, str]], Dict[Any, Any]]: + """Fetches or retrieves cached Subtensor error definitions using metadata. + + Args: + substrate (SubstrateInterface): Instance of SubstrateInterface to access metadata. + + Returns: + dict[str, str]: A dictionary containing error information. + """ + if not substrate.metadata: + substrate.get_metadata() + + cached_errors_map = _get_errors_from_cache() + # TODO: Talk to the Nucleus team about a unique identification for each assembly (subtensor). Before that, use + # the metadata value for `subtensor_build_id` + subtensor_build_id = substrate.metadata[0].value + + if not cached_errors_map or subtensor_build_id != cached_errors_map.get( + _ST_BUILD_ID + ): + pallet = substrate.metadata.get_metadata_pallet("SubtensorModule") + subtensor_errors_map = _get_errors_from_pallet(pallet) + + if not subtensor_errors_map: + return {} + + _save_errors_to_cache( + uniq_version=substrate.metadata[0].value, errors=subtensor_errors_map + ) + _logger.info(f"File {_ERRORS_FILE_PATH} has been updated.") + return subtensor_errors_map + else: + return cached_errors_map.get("errors", {}) diff --git a/tests/unit_tests/test_subtensor.py b/tests/unit_tests/test_subtensor.py index 14dfe95b90..7496f2f814 100644 --- a/tests/unit_tests/test_subtensor.py +++ b/tests/unit_tests/test_subtensor.py @@ -25,7 +25,8 @@ # Application import bittensor -from bittensor.subtensor import subtensor as Subtensor +from bittensor.subtensor import subtensor as Subtensor, logger +from bittensor import subtensor_module def test_serve_axon_with_external_ip_set(): @@ -272,3 +273,41 @@ def test_determine_chain_endpoint_and_network( # Assert assert result_network == expected_network assert result_endpoint == expected_endpoint + + +@pytest.fixture +def substrate(): + class MockSubstrate: + pass + + return MockSubstrate() + + +@pytest.fixture +def subtensor(substrate): + mock.patch.object( + subtensor_module, + "get_subtensor_errors", + return_value={ + "1": ("ErrorOne", "Description one"), + "2": ("ErrorTwo", "Description two"), + }, + ).start() + return Subtensor() + + +def test_get_error_info_by_index_known_error(subtensor): + name, description = subtensor.get_error_info_by_index(1) + assert name == "ErrorOne" + assert description == "Description one" + + +def test_get_error_info_by_index_unknown_error(subtensor): + mock_logger = mock.patch.object(logger, "warning").start() + fake_index = 999 + name, description = subtensor.get_error_info_by_index(fake_index) + assert name == "Unknown Error" + assert description == "" + mock_logger.assert_called_once_with( + f"Subtensor returned an error with an unknown index: {fake_index}" + ) diff --git a/tests/unit_tests/utils/test_subtensor.py b/tests/unit_tests/utils/test_subtensor.py new file mode 100644 index 0000000000..1c1220bcea --- /dev/null +++ b/tests/unit_tests/utils/test_subtensor.py @@ -0,0 +1,99 @@ +# The MIT License (MIT) +# Copyright © 2022 Opentensor Foundation + +# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +# documentation files (the “Software”), to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all copies or substantial portions of +# the Software. + +# THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO +# THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import json + +import pytest + +import bittensor.utils.subtensor as st_utils + + +class MockPallet: + def __init__(self, errors): + self.errors = errors + + +@pytest.fixture +def pallet_with_errors(): + """Provide a mock pallet with sample errors.""" + return MockPallet( + [ + {"index": 1, "name": "ErrorOne", "docs": ["Description one."]}, + { + "index": 2, + "name": "ErrorTwo", + "docs": ["Description two.", "Continued."], + }, + ] + ) + + +@pytest.fixture +def empty_pallet(): + """Provide a mock pallet with no errors.""" + return MockPallet([]) + + +def test_get_errors_from_pallet_with_errors(pallet_with_errors): + """Ensure errors are correctly parsed from pallet.""" + expected = { + "1": {"name": "ErrorOne", "description": "Description one."}, + "2": {"name": "ErrorTwo", "description": "Description two. Continued."}, + } + assert st_utils._get_errors_from_pallet(pallet_with_errors) == expected + + +def test_get_errors_from_pallet_empty(empty_pallet): + """Test behavior with an empty list of errors.""" + assert st_utils._get_errors_from_pallet(empty_pallet) is None + + +def test_save_errors_to_cache(tmp_path): + """Ensure that errors are correctly saved to a file.""" + test_file = tmp_path / "subtensor_errors_map.json" + errors = {"1": {"name": "ErrorOne", "description": "Description one."}} + st_utils._ERRORS_FILE_PATH = test_file + st_utils._save_errors_to_cache("0x123", errors) + + with open(test_file, "r") as file: + data = json.load(file) + assert data["subtensor_build_id"] == "0x123" + assert data["errors"] == errors + + +def test_get_errors_from_cache(tmp_path): + """Test retrieval of errors from cache.""" + test_file = tmp_path / "subtensor_errors_map.json" + errors = {"1": {"name": "ErrorOne", "description": "Description one."}} + + st_utils._ERRORS_FILE_PATH = test_file + with open(test_file, "w") as file: + json.dump({"subtensor_build_id": "0x123", "errors": errors}, file) + assert st_utils._get_errors_from_cache() == { + "subtensor_build_id": "0x123", + "errors": errors, + } + + +def test_get_errors_no_cache(mocker, empty_pallet): + """Test get_errors function when no cache is available.""" + mocker.patch("bittensor.utils.subtensor._get_errors_from_cache", return_value=None) + mocker.patch("bittensor.utils.subtensor.SubstrateInterface") + substrate_mock = mocker.MagicMock() + substrate_mock.metadata.get_metadata_pallet.return_value = empty_pallet + substrate_mock.metadata[0].value = "0x123" + assert st_utils.get_subtensor_errors(substrate_mock) == {}