From 24e1fddf942796753fae544ce6956f046557b5fb Mon Sep 17 00:00:00 2001 From: "RUNE.CTZ" Date: Tue, 12 May 2026 12:39:27 -0700 Subject: [PATCH 01/20] fix(axon): narrow preprocess exception handling and chain causes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AxonMiddleware.preprocess` previously wrapped any `Exception` from URL path parsing and `Synapse.from_headers` as `InvalidRequestNameError` / `SynapseParsingError`. This swallowed unrelated infrastructure failures (e.g. `RuntimeError`, `MemoryError`, real bugs in starlette or pydantic plugins) and lost their original traceback, surfacing them as misleading "malformed request" errors. - Narrow the path-parse catch to `(IndexError, AttributeError)` — the only failures `request.url.path.split("/")[1]` can realistically produce. - Narrow the synapse-parse catch to `(ValidationError, TypeError, ValueError)` — the realistic failure modes of pydantic model construction from header inputs. - `raise ... from e` on both paths so the original exception remains on `__cause__` for debugging. - Regression tests assert that `ValidationError` is wrapped with `__cause__` set and that an unrelated `RuntimeError` propagates unchanged. Mirrors the exception-narrowing pattern used in #3318 for `get_external_ip`. --- bittensor/core/axon.py | 20 ++++++++++++---- tests/unit_tests/test_axon.py | 44 +++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) 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/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): From 8c481fe089ca15d2a1600983ace0d587deadf94d Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Wed, 13 May 2026 12:16:54 -0700 Subject: [PATCH 02/20] move `no_parse_cli` to settings.py --- bittensor/core/settings.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bittensor/core/settings.py b/bittensor/core/settings.py index 0fb2bc9b55..6fdbe6b2f0 100644 --- a/bittensor/core/settings.py +++ b/bittensor/core/settings.py @@ -166,3 +166,11 @@ 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 +no_parse_cli = os.getenv("BT_NO_PARSE_CLI_ARGS", "true").lower() in ( + "1", + "true", + "yes", + "on", +) From 514ad45ee26bed90853c1cefe6c1375e11350f53 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Wed, 13 May 2026 12:17:19 -0700 Subject: [PATCH 03/20] improve check `no_parse_cli` logic in logmachine and config --- bittensor/core/config.py | 10 +++------- bittensor/utils/btlogging/loggingmachine.py | 4 +++- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/bittensor/core/config.py b/bittensor/core/config.py index 483e15828e..e2f780c518 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) diff --git a/bittensor/utils/btlogging/loggingmachine.py b/bittensor/utils/btlogging/loggingmachine.py index 1575bd745c..b90a18e98e 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) From 0e20b594eeea4d2d939e4be6c2296dc682688f06 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Wed, 13 May 2026 12:52:30 -0700 Subject: [PATCH 04/20] call function to have up-to-date result always --- bittensor/core/config.py | 3 ++- bittensor/core/settings.py | 14 ++++++++------ bittensor/utils/btlogging/loggingmachine.py | 2 +- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/bittensor/core/config.py b/bittensor/core/config.py index e2f780c518..f1d056fd43 100644 --- a/bittensor/core/config.py +++ b/bittensor/core/config.py @@ -148,8 +148,9 @@ def __init__( self.__is_set = {} + print(">>> no_parse_cli", no_parse_cli()) # 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/settings.py b/bittensor/core/settings.py index 6fdbe6b2f0..5f3f51425a 100644 --- a/bittensor/core/settings.py +++ b/bittensor/core/settings.py @@ -167,10 +167,12 @@ class wallet: ) assert version_as_int < 2**31 # fits in int32 + # used for backwards compatibility in config.py and loggingmachine.py -no_parse_cli = os.getenv("BT_NO_PARSE_CLI_ARGS", "true").lower() in ( - "1", - "true", - "yes", - "on", -) +def no_parse_cli(): + return os.getenv("BT_NO_PARSE_CLI_ARGS", "true").lower() in ( + "1", + "true", + "yes", + "on", + ) diff --git a/bittensor/utils/btlogging/loggingmachine.py b/bittensor/utils/btlogging/loggingmachine.py index b90a18e98e..0487dd773f 100644 --- a/bittensor/utils/btlogging/loggingmachine.py +++ b/bittensor/utils/btlogging/loggingmachine.py @@ -686,7 +686,7 @@ def config(cls) -> "Config": Return: Configuration object with settings from command-line arguments. """ - if no_parse_cli: + if no_parse_cli(): return Config() parser = argparse.ArgumentParser() cls.add_args(parser) From ba271e453d49697df3951669c7ffbc67b59cba9c Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Wed, 13 May 2026 12:52:48 -0700 Subject: [PATCH 05/20] fix test + add test for user script --- .../test_config_does_not_process_cli_args.py | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) 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..d0db87fd0e 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,8 +1,8 @@ import argparse import sys - +import subprocess import pytest - +import os import bittensor as bt from bittensor.core.config import InvalidConfigFile @@ -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 From d5dbe8ff5f2453ae64278d35a0c8d33c62fee99b Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Wed, 13 May 2026 12:55:31 -0700 Subject: [PATCH 06/20] opps, remove debug --- bittensor/core/config.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bittensor/core/config.py b/bittensor/core/config.py index f1d056fd43..1fc1d076d4 100644 --- a/bittensor/core/config.py +++ b/bittensor/core/config.py @@ -148,7 +148,6 @@ def __init__( self.__is_set = {} - print(">>> no_parse_cli", no_parse_cli()) # If CLI parsing disabled, stop here if no_parse_cli() or parser is None: return From 9154e034e4b7f7e4be0472660ee5e7fe1016c648 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Wed, 13 May 2026 13:21:51 -0700 Subject: [PATCH 07/20] remove unused import --- .../integration_tests/test_config_does_not_process_cli_args.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d0db87fd0e..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 @@ -2,7 +2,7 @@ import sys import subprocess import pytest -import os + import bittensor as bt from bittensor.core.config import InvalidConfigFile From 6a0829b3c1f8f257b513f0400cab599affbd03b6 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Wed, 13 May 2026 13:55:14 -0700 Subject: [PATCH 08/20] invert arguments --- bittensor/core/settings.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bittensor/core/settings.py b/bittensor/core/settings.py index 5f3f51425a..debb9a647a 100644 --- a/bittensor/core/settings.py +++ b/bittensor/core/settings.py @@ -170,9 +170,9 @@ class wallet: # used for backwards compatibility in config.py and loggingmachine.py def no_parse_cli(): - return os.getenv("BT_NO_PARSE_CLI_ARGS", "true").lower() in ( - "1", - "true", - "yes", - "on", + return not os.getenv("BT_NO_PARSE_CLI_ARGS", "true").lower() in ( + "0", + "false", + "no", + "off", ) From e43acb4aad5912842fbb246098fbc14cfe88dfac Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Wed, 13 May 2026 13:55:33 -0700 Subject: [PATCH 09/20] pass env var value to e2e tests --- tests/e2e_tests/utils/e2e_test_utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/e2e_tests/utils/e2e_test_utils.py b/tests/e2e_tests/utils/e2e_test_utils.py index 2e7239bffe..92768d51a8 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", From d7049680719c9c48d31b3598df22febf22cdb1bb Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Wed, 13 May 2026 14:16:53 -0700 Subject: [PATCH 10/20] fix missed Validator's setup --- tests/e2e_tests/utils/e2e_test_utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/e2e_tests/utils/e2e_test_utils.py b/tests/e2e_tests/utils/e2e_test_utils.py index 92768d51a8..bd787f796d 100644 --- a/tests/e2e_tests/utils/e2e_test_utils.py +++ b/tests/e2e_tests/utils/e2e_test_utils.py @@ -196,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", From f97108b75b2170f1b2c1c3a331e398c2213dafb4 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Wed, 13 May 2026 15:52:32 -0700 Subject: [PATCH 11/20] add time for chain update after unstake with fast blocks e2e test --- tests/e2e_tests/test_staking.py | 6 ++++++ 1 file changed, 6 insertions(+) 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 From 53f8a9a41a1e20bb354f2719febafe04260f9659 Mon Sep 17 00:00:00 2001 From: BD Himes Date: Thu, 14 May 2026 15:35:14 +0200 Subject: [PATCH 12/20] each sign_and_send_extrinsic call previously triggered create_signed_extrinsic's cache-incrementing fallback --- bittensor/core/async_subtensor.py | 9 ++++++--- bittensor/core/extrinsics/asyncex/mev_shield.py | 6 ++---- bittensor/core/extrinsics/mev_shield.py | 2 +- bittensor/core/subtensor.py | 9 ++++++--- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/bittensor/core/async_subtensor.py b/bittensor/core/async_subtensor.py index 669d2fc326..916eed8a7d 100644 --- a/bittensor/core/async_subtensor.py +++ b/bittensor/core/async_subtensor.py @@ -6096,10 +6096,13 @@ async def sign_and_send_extrinsic( raise AttributeError( f"'nonce_key' must be either 'coldkey', 'hotkey' or 'coldkeypub', not '{nonce_key}'" ) - next_nonce = await self.substrate.get_account_next_index( - getattr(wallet, nonce_key).ss58_address + extrinsic_data["nonce"] = await self.substrate.get_account_next_index( + getattr(wallet, nonce_key).ss58_address, use_cache=False + ) + else: + extrinsic_data["nonce"] = await self.substrate.get_account_next_index( + signing_keypair.ss58_address, use_cache=False ) - extrinsic_data["nonce"] = next_nonce if period is not None: extrinsic_data["era"] = {"period": period} diff --git a/bittensor/core/extrinsics/asyncex/mev_shield.py b/bittensor/core/extrinsics/asyncex/mev_shield.py index d4bf360997..e681d0cff9 100644 --- a/bittensor/core/extrinsics/asyncex/mev_shield.py +++ b/bittensor/core/extrinsics/asyncex/mev_shield.py @@ -151,11 +151,9 @@ async def submit_encrypted_extrinsic( era = {"period": effective_period} current_nonce = await subtensor.substrate.get_account_next_index( - account_address=inner_signing_keypair.ss58_address - ) - next_nonce = await subtensor.substrate.get_account_next_index( - account_address=inner_signing_keypair.ss58_address + account_address=inner_signing_keypair.ss58_address, use_cache=False ) + next_nonce = current_nonce + 1 signed_extrinsic = await subtensor.substrate.create_signed_extrinsic( call=call, keypair=inner_signing_keypair, nonce=next_nonce, era=era ) diff --git a/bittensor/core/extrinsics/mev_shield.py b/bittensor/core/extrinsics/mev_shield.py index fd618a6ee8..cde83ef925 100644 --- a/bittensor/core/extrinsics/mev_shield.py +++ b/bittensor/core/extrinsics/mev_shield.py @@ -151,7 +151,7 @@ def submit_encrypted_extrinsic( era = {"period": effective_period} current_nonce = subtensor.substrate.get_account_next_index( - account_address=inner_signing_keypair.ss58_address + account_address=inner_signing_keypair.ss58_address, use_cache=False ) next_nonce = current_nonce + 1 signed_extrinsic = subtensor.substrate.create_signed_extrinsic( diff --git a/bittensor/core/subtensor.py b/bittensor/core/subtensor.py index 6c634aa2e0..42af302c14 100644 --- a/bittensor/core/subtensor.py +++ b/bittensor/core/subtensor.py @@ -4962,10 +4962,13 @@ def sign_and_send_extrinsic( raise AttributeError( f"'nonce_key' must be either 'coldkey', 'hotkey' or 'coldkeypub', not '{nonce_key}'" ) - next_nonce = self.substrate.get_account_next_index( - getattr(wallet, nonce_key).ss58_address + extrinsic_data["nonce"] = self.substrate.get_account_next_index( + getattr(wallet, nonce_key).ss58_address, use_cache=False + ) + else: + extrinsic_data["nonce"] = self.substrate.get_account_next_index( + signing_keypair.ss58_address, use_cache=False ) - extrinsic_data["nonce"] = next_nonce if period is not None: extrinsic_data["era"] = {"period": period} From e1d849a9525940b22fec379fa02c2f2418ed2c51 Mon Sep 17 00:00:00 2001 From: BD Himes Date: Thu, 14 May 2026 15:51:59 +0200 Subject: [PATCH 13/20] Revert and apply only to swaps --- bittensor/core/async_subtensor.py | 9 +++------ bittensor/core/extrinsics/asyncex/coldkey_swap.py | 5 +++++ bittensor/core/extrinsics/asyncex/mev_shield.py | 6 ++++-- bittensor/core/extrinsics/asyncex/sudo.py | 9 ++++++++- bittensor/core/extrinsics/mev_shield.py | 2 +- bittensor/core/subtensor.py | 9 +++------ 6 files changed, 24 insertions(+), 16 deletions(-) diff --git a/bittensor/core/async_subtensor.py b/bittensor/core/async_subtensor.py index 916eed8a7d..669d2fc326 100644 --- a/bittensor/core/async_subtensor.py +++ b/bittensor/core/async_subtensor.py @@ -6096,13 +6096,10 @@ async def sign_and_send_extrinsic( raise AttributeError( f"'nonce_key' must be either 'coldkey', 'hotkey' or 'coldkeypub', not '{nonce_key}'" ) - extrinsic_data["nonce"] = await self.substrate.get_account_next_index( - getattr(wallet, nonce_key).ss58_address, use_cache=False - ) - else: - extrinsic_data["nonce"] = await self.substrate.get_account_next_index( - signing_keypair.ss58_address, use_cache=False + next_nonce = await self.substrate.get_account_next_index( + getattr(wallet, nonce_key).ss58_address ) + extrinsic_data["nonce"] = next_nonce if period is not None: extrinsic_data["era"] = {"period": period} diff --git a/bittensor/core/extrinsics/asyncex/coldkey_swap.py b/bittensor/core/extrinsics/asyncex/coldkey_swap.py index 202c41fa6a..6fb74ff393 100644 --- a/bittensor/core/extrinsics/asyncex/coldkey_swap.py +++ b/bittensor/core/extrinsics/asyncex/coldkey_swap.py @@ -392,6 +392,11 @@ 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._nonces.pop(wallet.coldkeypub.ss58_address, None) logging.debug("[green]Coldkey swap executed successfully.[/green]") else: logging.error(f"[red]{response.message}[/red]") diff --git a/bittensor/core/extrinsics/asyncex/mev_shield.py b/bittensor/core/extrinsics/asyncex/mev_shield.py index e681d0cff9..d4bf360997 100644 --- a/bittensor/core/extrinsics/asyncex/mev_shield.py +++ b/bittensor/core/extrinsics/asyncex/mev_shield.py @@ -151,9 +151,11 @@ async def submit_encrypted_extrinsic( era = {"period": effective_period} current_nonce = await subtensor.substrate.get_account_next_index( - account_address=inner_signing_keypair.ss58_address, use_cache=False + account_address=inner_signing_keypair.ss58_address + ) + next_nonce = await subtensor.substrate.get_account_next_index( + account_address=inner_signing_keypair.ss58_address ) - next_nonce = current_nonce + 1 signed_extrinsic = await subtensor.substrate.create_signed_extrinsic( call=call, keypair=inner_signing_keypair, nonce=next_nonce, era=era ) diff --git a/bittensor/core/extrinsics/asyncex/sudo.py b/bittensor/core/extrinsics/asyncex/sudo.py index 935a656d2e..ef49dd1204 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._nonces.pop(old_coldkey_ss58, None) + return response async def sudo_set_admin_freeze_window_extrinsic( diff --git a/bittensor/core/extrinsics/mev_shield.py b/bittensor/core/extrinsics/mev_shield.py index cde83ef925..fd618a6ee8 100644 --- a/bittensor/core/extrinsics/mev_shield.py +++ b/bittensor/core/extrinsics/mev_shield.py @@ -151,7 +151,7 @@ def submit_encrypted_extrinsic( era = {"period": effective_period} current_nonce = subtensor.substrate.get_account_next_index( - account_address=inner_signing_keypair.ss58_address, use_cache=False + account_address=inner_signing_keypair.ss58_address ) next_nonce = current_nonce + 1 signed_extrinsic = subtensor.substrate.create_signed_extrinsic( diff --git a/bittensor/core/subtensor.py b/bittensor/core/subtensor.py index 42af302c14..6c634aa2e0 100644 --- a/bittensor/core/subtensor.py +++ b/bittensor/core/subtensor.py @@ -4962,13 +4962,10 @@ def sign_and_send_extrinsic( raise AttributeError( f"'nonce_key' must be either 'coldkey', 'hotkey' or 'coldkeypub', not '{nonce_key}'" ) - extrinsic_data["nonce"] = self.substrate.get_account_next_index( - getattr(wallet, nonce_key).ss58_address, use_cache=False - ) - else: - extrinsic_data["nonce"] = self.substrate.get_account_next_index( - signing_keypair.ss58_address, use_cache=False + next_nonce = self.substrate.get_account_next_index( + getattr(wallet, nonce_key).ss58_address ) + extrinsic_data["nonce"] = next_nonce if period is not None: extrinsic_data["era"] = {"period": period} From 9d24be0f0dd1e7804691d06ec602e263d90a5241 Mon Sep 17 00:00:00 2001 From: BD Himes Date: Thu, 14 May 2026 16:11:50 +0200 Subject: [PATCH 14/20] Mock test fix --- tests/unit_tests/extrinsics/asyncex/conftest.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 From a03b7b5a0a8f2c5cf8c2cc6c20e2ccae2443d9a4 Mon Sep 17 00:00:00 2001 From: BD Himes Date: Thu, 14 May 2026 18:50:27 +0200 Subject: [PATCH 15/20] run do_take_checks at pool validation --- bittensor/core/async_subtensor.py | 11 +++++-- bittensor/core/errors.py | 53 +++++++++++++++++++++++++++++++ bittensor/core/subtensor.py | 7 ++-- tests/unit_tests/test_errors.py | 48 ++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 4 deletions(-) diff --git a/bittensor/core/async_subtensor.py b/bittensor/core/async_subtensor.py index 669d2fc326..0f5665adde 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,11 @@ async 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/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/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/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 From 0e91511ec97c256046a230595af823132aaba467 Mon Sep 17 00:00:00 2001 From: BD Himes Date: Thu, 14 May 2026 21:01:49 +0200 Subject: [PATCH 16/20] Use the public API --- bittensor/core/extrinsics/asyncex/coldkey_swap.py | 2 +- bittensor/core/extrinsics/asyncex/sudo.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bittensor/core/extrinsics/asyncex/coldkey_swap.py b/bittensor/core/extrinsics/asyncex/coldkey_swap.py index 6fb74ff393..041e4df6fb 100644 --- a/bittensor/core/extrinsics/asyncex/coldkey_swap.py +++ b/bittensor/core/extrinsics/asyncex/coldkey_swap.py @@ -396,7 +396,7 @@ async def swap_coldkey_announced_extrinsic( # 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._nonces.pop(wallet.coldkeypub.ss58_address, None) + 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 ef49dd1204..066aebc4a1 100644 --- a/bittensor/core/extrinsics/asyncex/sudo.py +++ b/bittensor/core/extrinsics/asyncex/sudo.py @@ -113,7 +113,7 @@ async def swap_coldkey_extrinsic( # 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._nonces.pop(old_coldkey_ss58, None) + subtensor.substrate.clear_nonce_cache_for_account(old_coldkey_ss58) return response diff --git a/pyproject.toml b/pyproject.toml index 360932a3a4..322cd80603 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] From 35992fd858913353a1c7cf4ae08f643cb32270c8 Mon Sep 17 00:00:00 2001 From: BD Himes Date: Thu, 14 May 2026 21:04:38 +0200 Subject: [PATCH 17/20] Ruff --- bittensor/core/extrinsics/asyncex/coldkey_swap.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bittensor/core/extrinsics/asyncex/coldkey_swap.py b/bittensor/core/extrinsics/asyncex/coldkey_swap.py index 041e4df6fb..3b8044afd6 100644 --- a/bittensor/core/extrinsics/asyncex/coldkey_swap.py +++ b/bittensor/core/extrinsics/asyncex/coldkey_swap.py @@ -396,7 +396,9 @@ async def swap_coldkey_announced_extrinsic( # 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) + 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]") From ce734c9f025e4c5ac894bc4f1e7c76f1eaf49ec4 Mon Sep 17 00:00:00 2001 From: BD Himes Date: Fri, 15 May 2026 10:18:45 +0200 Subject: [PATCH 18/20] Async test_commit_weights.py fix --- tests/e2e_tests/test_commit_weights.py | 5 +++++ 1 file changed, 5 insertions(+) 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, From 5bebfbdc0fb8bcaf3688b35f7a096cdd02568c0c Mon Sep 17 00:00:00 2001 From: BD Himes Date: Fri, 15 May 2026 10:28:57 +0200 Subject: [PATCH 19/20] Clear nonce cache on extrinsic submission failure --- bittensor/core/async_subtensor.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bittensor/core/async_subtensor.py b/bittensor/core/async_subtensor.py index 0f5665adde..00eb1a8e9c 100644 --- a/bittensor/core/async_subtensor.py +++ b/bittensor/core/async_subtensor.py @@ -6149,6 +6149,10 @@ 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 From 3bf570ccddefaf4a0d3b9940d858d882044c23e3 Mon Sep 17 00:00:00 2001 From: BD Himes Date: Fri, 15 May 2026 10:51:19 +0200 Subject: [PATCH 20/20] Version + changelog --- CHANGELOG.md | 16 ++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) 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/pyproject.toml b/pyproject.toml index 322cd80603..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 = [