diff --git a/CHANGELOG.md b/CHANGELOG.md index 3558de5eae..f88b8ddb94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 10.3.2 /2026-05-15 + +## What's Changed +* Fix `--help` hijacking on import bittensor by @basfroman in https://github.com/latent-to/bittensor/pull/3347 +* Hopefully, this is a fix for the flaky e2e test by @basfroman in https://github.com/latent-to/bittensor/pull/3348 +* fix(axon): narrow preprocess exception handling and chain causes by @RUNECTZ33 in https://github.com/latent-to/bittensor/pull/3346 +* run do_take_checks at pool validation by @thewhaleking in https://github.com/latent-to/bittensor/pull/3350 +* Mev Shield Nonce Increment Fix by @thewhaleking in https://github.com/latent-to/bittensor/pull/3349 +* Test Fixes by @thewhaleking in https://github.com/latent-to/bittensor/pull/3352 +* Use the public API for clearing nonce cache by @thewhaleking in https://github.com/latent-to/bittensor/pull/3351 + +## New Contributors +* @RUNECTZ33 made their first contribution in https://github.com/latent-to/bittensor/pull/3346 + +**Full Changelog**: https://github.com/latent-to/bittensor/compare/v10.3.1...v10.3.2 + ## 10.3.1 /2026-05-06 ## What's Changed diff --git a/bittensor/core/async_subtensor.py b/bittensor/core/async_subtensor.py index 669d2fc326..00eb1a8e9c 100644 --- a/bittensor/core/async_subtensor.py +++ b/bittensor/core/async_subtensor.py @@ -48,7 +48,11 @@ decode_revealed_commitment_with_hotkey, ) from bittensor.core.config import Config -from bittensor.core.errors import ChainError, SubstrateRequestException +from bittensor.core.errors import ( + ChainError, + SubstrateRequestException, + chain_error_from_substrate_exception, +) from bittensor.core.extrinsics.asyncex.children import ( root_set_pending_childkey_cooldown_extrinsic, set_children_extrinsic, @@ -6145,8 +6149,15 @@ async def sign_and_send_extrinsic( return extrinsic_response except SubstrateRequestException as error: + # The extrinsic was rejected before inclusion (pool validation, dropped, + # invalid, etc.), so the nonce was not consumed on-chain. Clear the cached + # next-index so the next call refetches the true on-chain value. + self.substrate.clear_nonce_cache_for_account(signing_keypair.ss58_address) + typed = chain_error_from_substrate_exception(error) + if typed is not None: + error = typed if raise_error: - raise + raise error from None extrinsic_response.success = False extrinsic_response.message = format_error_message(error) diff --git a/bittensor/core/axon.py b/bittensor/core/axon.py index 0895cf708a..3fd9dd46c2 100644 --- a/bittensor/core/axon.py +++ b/bittensor/core/axon.py @@ -15,6 +15,7 @@ from typing import Any, Awaitable, Callable, Optional, Tuple from async_substrate_interface.utils import json +from pydantic import ValidationError import uvicorn from bittensor_wallet import Wallet, Keypair from fastapi import APIRouter, Depends, FastAPI @@ -1233,13 +1234,17 @@ async def preprocess(self, request: "Request") -> "Synapse": This method sets the foundation for the subsequent steps in the request handling process, ensuring that all necessary information is encapsulated within the Synapse object. """ - # Extracts the request name from the URL path. + # Extracts the request name from the URL path. `split("/")[1]` can + # only realistically fail with `IndexError` (empty path) or + # `AttributeError` (malformed/mocked request without a `url`); a bare + # `except Exception` here would swallow `MemoryError`, real bugs from + # `starlette`, etc., and relabel them as malformed-request errors. try: request_name = request.url.path.split("/")[1] - except Exception: + except (IndexError, AttributeError) as e: raise InvalidRequestNameError( f"Improperly formatted request. Could not parser request {request.url.path}." - ) + ) from e # Creates a synapse instance from the headers using the appropriate forward class type # based on the request name obtained from the URL path. @@ -1249,12 +1254,17 @@ async def preprocess(self, request: "Request") -> "Synapse": f"Synapse name '{request_name}' not found. Available synapses {list(self.axon.forward_class_types.keys())}" ) + # `from_headers` constructs a pydantic model from header inputs; the + # realistic failure modes are `ValidationError` (field-level), + # `TypeError` (bad kwarg types), and `ValueError` (custom validators). + # Narrowing keeps unrelated infrastructure failures visible instead of + # relabeling them as malformed-request errors. try: synapse = request_synapse.from_headers(request.headers) # type: ignore - except Exception: + except (ValidationError, TypeError, ValueError) as e: raise SynapseParsingError( f"Improperly formatted request. Could not parse headers {request.headers} into synapse of type {request_name}." - ) + ) from e synapse.name = request_name # Fills the local axon information into the synapse. diff --git a/bittensor/core/config.py b/bittensor/core/config.py index 483e15828e..1fc1d076d4 100644 --- a/bittensor/core/config.py +++ b/bittensor/core/config.py @@ -21,9 +21,11 @@ import sys from copy import deepcopy from typing import Any, Optional -from bittensor.core.settings import DEFAULTS + import yaml +from bittensor.core.settings import DEFAULTS, no_parse_cli + class DefaultMunch(dict): """ @@ -130,12 +132,6 @@ def __init__( strict: bool = False, default: Any = DEFAULTS, ) -> None: - no_parse_cli = os.getenv("BT_NO_PARSE_CLI_ARGS", "").lower() in ( - "1", - "true", - "yes", - "on", - ) # Fallback to defaults if not provided default = deepcopy(default or DEFAULTS) @@ -153,7 +149,7 @@ def __init__( self.__is_set = {} # If CLI parsing disabled, stop here - if no_parse_cli or parser is None: + if no_parse_cli() or parser is None: return self._add_default_arguments(parser) diff --git a/bittensor/core/errors.py b/bittensor/core/errors.py index 53e2f982ce..e616a9fccd 100644 --- a/bittensor/core/errors.py +++ b/bittensor/core/errors.py @@ -1,3 +1,4 @@ +import ast from typing import Optional, TYPE_CHECKING from async_substrate_interface.errors import ( @@ -58,6 +59,7 @@ "UnknownSynapseError", "UnstakeError", "SHIELD_VALIDATION_ERRORS", + "chain_error_from_substrate_exception", "map_shield_error", ] @@ -278,6 +280,57 @@ def __init__( } +_CUSTOM_ERROR_CODE_TO_EXCEPTION: dict[int, type["ChainError"]] = { + 3: SubnetNotExists, + 4: HotKeyAccountNotExists, + 6: TxRateLimitExceeded, + 25: NonAssociatedColdKey, +} + + +def _extract_custom_error_code(error: Exception) -> Optional[int]: + """Walk a SubstrateRequestException's args looking for a transaction-pool + validity error like ``{'error': {'code': 1010, ..., 'data': 'Custom error: N'}}`` + and return ``N``. Returns ``None`` if the shape doesn't match.""" + for arg in getattr(error, "args", ()): + parsed = arg + if isinstance(parsed, str): + try: + parsed = ast.literal_eval(parsed) + except (ValueError, SyntaxError, MemoryError, RecursionError, TypeError): + continue + if not isinstance(parsed, dict): + continue + inner = parsed.get("error", parsed) + if not isinstance(inner, dict): + continue + data = inner.get("data") + if isinstance(data, str) and data.startswith("Custom error:"): + try: + return int(data.split(":", 1)[1].strip()) + except ValueError: + continue + return None + + +def chain_error_from_substrate_exception( + error: SubstrateRequestException, +) -> Optional["ChainError"]: + """Translate a transaction-pool validation error into a typed + :class:`ChainError`. Returns ``None`` when the exception doesn't carry a + recognised ``Custom error: N`` code from subtensor's + ``CustomTransactionError`` enum, so callers can keep the original.""" + if isinstance(error, ChainError): + return None + code = _extract_custom_error_code(error) + if code is None: + return None + exc_cls = _CUSTOM_ERROR_CODE_TO_EXCEPTION.get(code) + if exc_cls is None: + return None + return exc_cls(*error.args) + + def map_shield_error(raw_message: str) -> str: """Map a raw shield validation error to a human-readable description. diff --git a/bittensor/core/extrinsics/asyncex/coldkey_swap.py b/bittensor/core/extrinsics/asyncex/coldkey_swap.py index 202c41fa6a..3b8044afd6 100644 --- a/bittensor/core/extrinsics/asyncex/coldkey_swap.py +++ b/bittensor/core/extrinsics/asyncex/coldkey_swap.py @@ -392,6 +392,13 @@ async def swap_coldkey_announced_extrinsic( ) if response.success: + # The swap may reap the old coldkey's account on chain (resetting its + # nonce). Drop the in-process nonce cache entry so the next extrinsic + # signed by this ss58 re-fetches from the chain instead of incrementing + # a stale value. + subtensor.substrate.clear_nonce_cache_for_account( + wallet.coldkeypub.ss58_address + ) logging.debug("[green]Coldkey swap executed successfully.[/green]") else: logging.error(f"[red]{response.message}[/red]") diff --git a/bittensor/core/extrinsics/asyncex/sudo.py b/bittensor/core/extrinsics/asyncex/sudo.py index 935a656d2e..066aebc4a1 100644 --- a/bittensor/core/extrinsics/asyncex/sudo.py +++ b/bittensor/core/extrinsics/asyncex/sudo.py @@ -93,7 +93,7 @@ async def swap_coldkey_extrinsic( Notes: - This function can only called by root. """ - return await sudo_call_extrinsic( + response = await sudo_call_extrinsic( subtensor=subtensor, wallet=wallet, call_module="SubtensorModule", @@ -108,6 +108,13 @@ async def swap_coldkey_extrinsic( wait_for_inclusion=wait_for_inclusion, wait_for_finalization=wait_for_finalization, ) + if response.success: + # The swap may reap the old coldkey's account on chain (resetting its + # nonce). Drop the in-process nonce cache entry so the next extrinsic + # signed by this ss58 re-fetches from the chain instead of incrementing + # a stale value. + subtensor.substrate.clear_nonce_cache_for_account(old_coldkey_ss58) + return response async def sudo_set_admin_freeze_window_extrinsic( diff --git a/bittensor/core/settings.py b/bittensor/core/settings.py index 0fb2bc9b55..debb9a647a 100644 --- a/bittensor/core/settings.py +++ b/bittensor/core/settings.py @@ -166,3 +166,13 @@ class wallet: e * (_version_int_base**i) for i, e in enumerate(reversed(_version_info)) ) assert version_as_int < 2**31 # fits in int32 + + +# used for backwards compatibility in config.py and loggingmachine.py +def no_parse_cli(): + return not os.getenv("BT_NO_PARSE_CLI_ARGS", "true").lower() in ( + "0", + "false", + "no", + "off", + ) diff --git a/bittensor/core/subtensor.py b/bittensor/core/subtensor.py index 6c634aa2e0..ce51579431 100644 --- a/bittensor/core/subtensor.py +++ b/bittensor/core/subtensor.py @@ -48,7 +48,7 @@ decode_revealed_commitment_with_hotkey, ) from bittensor.core.config import Config -from bittensor.core.errors import ChainError +from bittensor.core.errors import ChainError, chain_error_from_substrate_exception from bittensor.core.extrinsics.children import ( root_set_pending_childkey_cooldown_extrinsic, set_children_extrinsic, @@ -5012,8 +5012,11 @@ def sign_and_send_extrinsic( return extrinsic_response except SubstrateRequestException as error: + typed = chain_error_from_substrate_exception(error) + if typed is not None: + error = typed if raise_error: - raise + raise error from None extrinsic_response.success = False extrinsic_response.message = format_error_message(error) diff --git a/bittensor/utils/btlogging/loggingmachine.py b/bittensor/utils/btlogging/loggingmachine.py index 1575bd745c..0487dd773f 100644 --- a/bittensor/utils/btlogging/loggingmachine.py +++ b/bittensor/utils/btlogging/loggingmachine.py @@ -17,7 +17,7 @@ from statemachine import State, StateMachine from bittensor.core.config import Config -from bittensor.core.settings import DEFAULTS +from bittensor.core.settings import DEFAULTS, no_parse_cli from bittensor.utils.btlogging.console import BittensorConsole from .defines import ( BITTENSOR_LOGGER_NAME, @@ -686,6 +686,8 @@ def config(cls) -> "Config": Return: Configuration object with settings from command-line arguments. """ + if no_parse_cli(): + return Config() parser = argparse.ArgumentParser() cls.add_args(parser) return Config(parser) diff --git a/pyproject.toml b/pyproject.toml index 360932a3a4..fb67624b5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "bittensor" -version = "10.3.1" +version = "10.3.2" description = "Bittensor SDK" readme = "README.md" authors = [ @@ -33,7 +33,7 @@ dependencies = [ "uvicorn", "bittensor-drand>=1.3.0,<2.0.0", "bittensor-wallet==4.0.1", - "async-substrate-interface>=2.0.3,<3.0.0", + "async-substrate-interface>=2.0.4,<3.0.0", ] [project.optional-dependencies] diff --git a/tests/e2e_tests/test_commit_weights.py b/tests/e2e_tests/test_commit_weights.py index 536c8c86af..9443e579e4 100644 --- a/tests/e2e_tests/test_commit_weights.py +++ b/tests/e2e_tests/test_commit_weights.py @@ -245,6 +245,11 @@ async def test_commit_and_reveal_weights_legacy_async(async_subtensor, alice_wal > 0 ), "Invalid RevealPeriodEpochs" + # Wait until the reveal block range + await async_subtensor.wait_for_block( + await async_subtensor.subnets.get_next_epoch_start_block(alice_sn.netuid) + 1 + ) + # Reveal weights success, message = await async_subtensor.extrinsics.reveal_weights( wallet=alice_wallet, diff --git a/tests/e2e_tests/test_staking.py b/tests/e2e_tests/test_staking.py index 5d571249b5..0060957d81 100644 --- a/tests/e2e_tests/test_staking.py +++ b/tests/e2e_tests/test_staking.py @@ -1737,6 +1737,9 @@ def test_unstaking_with_limit( rate_tolerance=rate_tolerance, ).success + # time for chain update + subtensor.wait_for_block() + # Make sure both unstake were successful. bob_stakes = subtensor.staking.get_stake_info_for_coldkey( bob_wallet.coldkey.ss58_address @@ -1835,6 +1838,9 @@ async def test_unstaking_with_limit_async( ) ).success + # time for chain update + await async_subtensor.wait_for_block() + # Make sure both unstake were successful. bob_stakes = await async_subtensor.staking.get_stake_info_for_coldkey( bob_wallet.coldkey.ss58_address diff --git a/tests/e2e_tests/utils/e2e_test_utils.py b/tests/e2e_tests/utils/e2e_test_utils.py index 2e7239bffe..bd787f796d 100644 --- a/tests/e2e_tests/utils/e2e_test_utils.py +++ b/tests/e2e_tests/utils/e2e_test_utils.py @@ -131,6 +131,7 @@ def __exit__(self, exc_type, exc_value, traceback): async def __aenter__(self): env = os.environ.copy() env["BT_LOGGING_DEBUG"] = "1" + env["BT_NO_PARSE_CLI_ARGS"] = "false" self.process = await asyncio.create_subprocess_exec( sys.executable, f"{self.dir}/miner.py", @@ -195,6 +196,7 @@ def __init__(self, dir, wallet, netuid): async def __aenter__(self): env = os.environ.copy() env["BT_LOGGING_DEBUG"] = "1" + env["BT_NO_PARSE_CLI_ARGS"] = "false" self.process = await asyncio.create_subprocess_exec( sys.executable, f"{self.dir}/validator.py", diff --git a/tests/integration_tests/test_config_does_not_process_cli_args.py b/tests/integration_tests/test_config_does_not_process_cli_args.py index 0c7e846d81..16e4521b78 100644 --- a/tests/integration_tests/test_config_does_not_process_cli_args.py +++ b/tests/integration_tests/test_config_does_not_process_cli_args.py @@ -1,6 +1,6 @@ import argparse import sys - +import subprocess import pytest import bittensor as bt @@ -32,6 +32,7 @@ def _config_call(): def test_bittensor_cli_parser_enabled(monkeypatch): """Tests that the bt cli args are processed.""" + monkeypatch.setenv("BT_NO_PARSE_CLI_ARGS", "false") monkeypatch.setattr(sys, "argv", TEST_ARGS) with pytest.raises(InvalidConfigFile) as error: @@ -42,7 +43,6 @@ def test_bittensor_cli_parser_enabled(monkeypatch): def test_bittensor_cli_parser_disabled(monkeypatch): """Tests that the bt cli args are not processed.""" - monkeypatch.setenv("BT_NO_PARSE_CLI_ARGS", "true") monkeypatch.setattr(sys, "argv", TEST_ARGS) config = _config_call() @@ -50,3 +50,23 @@ def test_bittensor_cli_parser_disabled(monkeypatch): assert config.config is False assert config.strict is False assert config.no_version_checking is False + + +def test_import_does_not_hijack_help(): + """Importing bittensor should not intercept --help.""" + result = subprocess.run( + [ + sys.executable, + "-c", + "import bittensor; import argparse; " + "p = argparse.ArgumentParser('user_script'); " + "p.add_argument('--my-arg', default=1); " + "p.parse_args()", + "--help", + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert "--my-arg" in result.stdout + assert "--logging" not in result.stdout diff --git a/tests/unit_tests/extrinsics/asyncex/conftest.py b/tests/unit_tests/extrinsics/asyncex/conftest.py index e2fc3c10b6..afd9bb9f87 100644 --- a/tests/unit_tests/extrinsics/asyncex/conftest.py +++ b/tests/unit_tests/extrinsics/asyncex/conftest.py @@ -9,8 +9,13 @@ def mock_substrate(mocker): "bittensor.core.async_subtensor.AsyncSubstrateInterface", autospec=True, ) + instance = mocked.return_value + # `autospec=True` doesn't pick up instance attributes set in __init__, + # so callers that touch `substrate._nonces` (the per-account nonce cache) + # would AttributeError. Provide a real dict. + instance._nonces = {} - return mocked.return_value + return instance @pytest.fixture diff --git a/tests/unit_tests/test_axon.py b/tests/unit_tests/test_axon.py index d356883721..d1b8e03a97 100644 --- a/tests/unit_tests/test_axon.py +++ b/tests/unit_tests/test_axon.py @@ -507,6 +507,50 @@ async def test_preprocess(self): # Check if the preprocess function sets the request name correctly assert synapse.name == "request_name" + @pytest.mark.asyncio + async def test_preprocess_wraps_validation_error_with_cause(self): + # Regression: pydantic ValidationError from `from_headers` is wrapped + # as SynapseParsingError, but `__cause__` preserves the original so + # the field-level validation failure remains debuggable. + class ValidationFailingSynapse(Synapse): + @classmethod + def from_headers(cls, headers): + raise pydantic.ValidationError.from_exception_data( + "Synapse", [{"type": "missing", "loc": ("name",), "input": {}}] + ) + + self.mock_axon.forward_class_types = {"vfail": ValidationFailingSynapse} + + request = MagicMock(spec=Request) + request.url.path = "/vfail" + request.headers = {} + + from bittensor.core.errors import SynapseParsingError + + with pytest.raises(SynapseParsingError) as exc_info: + await self.axon_middleware.preprocess(request) + + assert isinstance(exc_info.value.__cause__, pydantic.ValidationError) + + @pytest.mark.asyncio + async def test_preprocess_does_not_swallow_unrelated_errors(self): + # Regression: `preprocess` no longer catches every `Exception` from + # `from_headers`. Unrelated infrastructure errors propagate unchanged + # instead of being relabeled as a malformed request. + class BoomSynapse(Synapse): + @classmethod + def from_headers(cls, headers): + raise RuntimeError("infra exploded") + + self.mock_axon.forward_class_types = {"boom": BoomSynapse} + + request = MagicMock(spec=Request) + request.url.path = "/boom" + request.headers = {} + + with pytest.raises(RuntimeError, match="infra exploded"): + await self.axon_middleware.preprocess(request) + class SynapseHTTPClient(TestClient): def post_synapse(self, synapse: Synapse): diff --git a/tests/unit_tests/test_errors.py b/tests/unit_tests/test_errors.py index 8b09b892e0..59da9d3e5a 100644 --- a/tests/unit_tests/test_errors.py +++ b/tests/unit_tests/test_errors.py @@ -1,6 +1,12 @@ +from async_substrate_interface.errors import SubstrateRequestException + from bittensor.core.errors import ( ChainError, HotKeyAccountNotExists, + NonAssociatedColdKey, + SubnetNotExists, + TxRateLimitExceeded, + chain_error_from_substrate_exception, map_shield_error, ) @@ -74,3 +80,45 @@ def test_unrelated_error_returned_unchanged(self): msg = "Something completely unrelated went wrong" result = map_shield_error(msg) assert result == msg + + +def _validity_error(code: int) -> SubstrateRequestException: + payload = { + "jsonrpc": "2.0", + "id": "Xc18", + "error": { + "code": 1010, + "message": "Invalid Transaction", + "data": f"Custom error: {code}", + }, + } + return SubstrateRequestException(str(payload)) + + +class TestChainErrorFromSubstrateException: + def test_maps_hotkey_account_not_exists(self): + exc = chain_error_from_substrate_exception(_validity_error(4)) + assert isinstance(exc, HotKeyAccountNotExists) + + def test_maps_non_associated_coldkey(self): + exc = chain_error_from_substrate_exception(_validity_error(25)) + assert isinstance(exc, NonAssociatedColdKey) + + def test_maps_rate_limit_exceeded(self): + exc = chain_error_from_substrate_exception(_validity_error(6)) + assert isinstance(exc, TxRateLimitExceeded) + + def test_maps_subnet_not_exists(self): + exc = chain_error_from_substrate_exception(_validity_error(3)) + assert isinstance(exc, SubnetNotExists) + + def test_unmapped_code_returns_none(self): + assert chain_error_from_substrate_exception(_validity_error(255)) is None + + def test_non_validity_error_returns_none(self): + err = SubstrateRequestException("not a validity error") + assert chain_error_from_substrate_exception(err) is None + + def test_already_typed_returns_none(self): + err = HotKeyAccountNotExists("already typed") + assert chain_error_from_substrate_exception(err) is None