From 748c389ca810d8d44f7b404fb22f40498ae6eea9 Mon Sep 17 00:00:00 2001 From: cisterciansis Date: Tue, 4 Aug 2026 13:00:45 -0400 Subject: [PATCH 1/4] feat: add validator proxy SDK support --- common/src/proxy.rs | 14 ++++ runtime/src/proxy_filters/call_groups.rs | 23 +++++++ runtime/src/proxy_filters/mod.rs | 27 +++++++- sdk/python/README.md | 11 +++ sdk/python/bittensor/client.py | 6 ++ sdk/python/bittensor/executor.py | 76 ++++++++++++++++++++- sdk/python/bittensor/intents/proxy.py | 1 + sdk/python/bittensor/sync.py | 3 + sdk/python/tests/unit/test_intents_table.py | 39 +++++++++++ 9 files changed, 197 insertions(+), 3 deletions(-) diff --git a/common/src/proxy.rs b/common/src/proxy.rs index f40b3f2076..7428a14d6b 100644 --- a/common/src/proxy.rs +++ b/common/src/proxy.rs @@ -42,6 +42,7 @@ pub enum ProxyType { SwapHotkey, SubnetLeaseBeneficiary, RootClaim, + Validate, } impl TryFrom for ProxyType { @@ -67,6 +68,7 @@ impl TryFrom for ProxyType { 15 => Ok(Self::SwapHotkey), 16 => Ok(Self::SubnetLeaseBeneficiary), 17 => Ok(Self::RootClaim), + 18 => Ok(Self::Validate), _ => Err(()), } } @@ -93,6 +95,7 @@ impl From for u8 { ProxyType::SwapHotkey => 15, ProxyType::SubnetLeaseBeneficiary => 16, ProxyType::RootClaim => 17, + ProxyType::Validate => 18, } } } @@ -112,6 +115,17 @@ impl Default for ProxyType { } } +#[cfg(test)] +mod tests { + use super::ProxyType; + + #[test] + fn validate_proxy_type_id_is_stable() { + assert_eq!(u8::from(ProxyType::Validate), 18); + assert_eq!(ProxyType::try_from(18), Ok(ProxyType::Validate)); + } +} + /// Extra constraint attached to an allowed call. #[derive(Clone, PartialEq, Eq, Encode, Decode, Debug, TypeInfo)] pub enum CallConstraint { diff --git a/runtime/src/proxy_filters/call_groups.rs b/runtime/src/proxy_filters/call_groups.rs index 4575ab9b7a..869c711cb7 100644 --- a/runtime/src/proxy_filters/call_groups.rs +++ b/runtime/src/proxy_filters/call_groups.rs @@ -630,6 +630,29 @@ call_filter_group!(SudoSetCodeCalls, [ where nested(call) == RuntimeCall::System(SystemCall::set_code), ]); +// `Validate`: operate a validator hotkey without granting stake or value movement. +call_filter_group!( + ValidateCalls, + [ + RuntimeCall::SubtensorModule(SubtensorCall::serve_axon), + RuntimeCall::SubtensorModule(SubtensorCall::serve_axon_tls), + RuntimeCall::SubtensorModule(SubtensorCall::associate_evm_key), + RuntimeCall::SubtensorModule(SubtensorCall::set_weights), + RuntimeCall::SubtensorModule(SubtensorCall::set_mechanism_weights), + RuntimeCall::SubtensorModule(SubtensorCall::batch_set_weights), + RuntimeCall::SubtensorModule(SubtensorCall::commit_weights), + RuntimeCall::SubtensorModule(SubtensorCall::commit_mechanism_weights), + RuntimeCall::SubtensorModule(SubtensorCall::batch_commit_weights), + RuntimeCall::SubtensorModule(SubtensorCall::commit_crv3_mechanism_weights), + RuntimeCall::SubtensorModule(SubtensorCall::commit_timelocked_weights), + RuntimeCall::SubtensorModule(SubtensorCall::commit_timelocked_mechanism_weights), + RuntimeCall::SubtensorModule(SubtensorCall::reveal_weights), + RuntimeCall::SubtensorModule(SubtensorCall::reveal_mechanism_weights), + RuntimeCall::SubtensorModule(SubtensorCall::batch_reveal_weights), + RuntimeCall::Commitments(CommitmentsCall::set_commitment), + ] +); + // Full inventory of every runtime call, used only by the coverage test that // checks it against `RuntimeCall` metadata. Nested in three blocks so the // flattened tuple stays within the `CallFilterMetadata` tuple-impl arity; diff --git a/runtime/src/proxy_filters/mod.rs b/runtime/src/proxy_filters/mod.rs index 7ec3925aa4..da84b4f123 100644 --- a/runtime/src/proxy_filters/mod.rs +++ b/runtime/src/proxy_filters/mod.rs @@ -118,6 +118,7 @@ pub(crate) fn proxy_type_filter(proxy_type: &ProxyType, call: &RuntimeCall) -> b ProxyType::SubnetLeaseBeneficiary => SubnetLeaseAllowed::contains(call), ProxyType::RootClaim => RootClaimCalls::contains(call), ProxyType::SudoUncheckedSetCode => SudoSetCodeCalls::contains(call), + ProxyType::Validate => ValidateCalls::contains(call), ProxyType::Triumvirate | ProxyType::Senate | ProxyType::Governance @@ -152,7 +153,8 @@ impl InstanceFilter for ProxyType { | ProxyType::SudoUncheckedSetCode | ProxyType::SwapHotkey | ProxyType::SubnetLeaseBeneficiary - | ProxyType::RootClaim, + | ProxyType::RootClaim + | ProxyType::Validate, ) => true, (ProxyType::Transfer, ProxyType::SmallTransfer) => true, _ => false, @@ -184,6 +186,7 @@ fn proxy_filter_mode(proxy_type: ProxyType) -> FilterMode { ProxyType::SubnetLeaseBeneficiary => FilterMode::Allow(SubnetLeaseAllowed::call_infos()), ProxyType::RootClaim => FilterMode::Allow(RootClaimCalls::call_infos()), ProxyType::SudoUncheckedSetCode => FilterMode::Allow(SudoSetCodeCalls::call_infos()), + ProxyType::Validate => FilterMode::Allow(ValidateCalls::call_infos()), ProxyType::Triumvirate | ProxyType::Senate | ProxyType::Governance @@ -398,6 +401,7 @@ mod tests { ProxyType::SwapHotkey, ProxyType::SubnetLeaseBeneficiary, ProxyType::RootClaim, + ProxyType::Validate, ] .into_iter() .collect::>(); @@ -517,6 +521,27 @@ mod tests { allowed_calls(ProxyType::SudoUncheckedSetCode), expected(&["Sudo::sudo_unchecked_weight"]) ); + assert_eq!( + allowed_calls(ProxyType::Validate), + expected(&[ + "Commitments::set_commitment", + "SubtensorModule::associate_evm_key", + "SubtensorModule::batch_commit_weights", + "SubtensorModule::batch_reveal_weights", + "SubtensorModule::batch_set_weights", + "SubtensorModule::commit_crv3_mechanism_weights", + "SubtensorModule::commit_mechanism_weights", + "SubtensorModule::commit_timelocked_mechanism_weights", + "SubtensorModule::commit_timelocked_weights", + "SubtensorModule::commit_weights", + "SubtensorModule::reveal_mechanism_weights", + "SubtensorModule::reveal_weights", + "SubtensorModule::serve_axon", + "SubtensorModule::serve_axon_tls", + "SubtensorModule::set_mechanism_weights", + "SubtensorModule::set_weights", + ]) + ); } // The newer calls that leaked through `main`'s denylists must stay denied diff --git a/sdk/python/README.md b/sdk/python/README.md index 89745d42d8..983fd1164a 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -271,6 +271,17 @@ These compose with any intent: On the CLI: `--proxy-for ` on any `btcli tx` command. Manage delegations with the `add-proxy` / `remove-proxy` intents and the `proxies` read. + A validator operator can submit the same weights for several hotkeys that + granted its delegate a `Validate` proxy: + + ```python + results = await client.execute_for_proxies( + bt.SetWeights(netuid=1, weights={0: 0.2, 1: 0.8}), + delegate_wallet, + proxy_for=[validator_a, validator_b], + ) + ``` + - **Atomic batch** — several intents in one all-or-nothing extrinsic: ```python diff --git a/sdk/python/bittensor/client.py b/sdk/python/bittensor/client.py index b6007f629f..78c73012d9 100644 --- a/sdk/python/bittensor/client.py +++ b/sdk/python/bittensor/client.py @@ -428,6 +428,12 @@ async def execute(self, intent: Intent, wallet: WalletLike, **kwargs) -> Extrins """ return await self._executor.execute(intent, wallet, **kwargs) + async def execute_for_proxies( + self, intent: Intent, wallet: WalletLike, proxy_for: list[str], **kwargs + ) -> dict[str, ExtrinsicResult]: + """Submit the same validator intent for a list of proxied hotkeys.""" + return await self._executor.execute_for_proxies(intent, wallet, proxy_for, **kwargs) + async def execute_tool( self, op: str, args: dict, wallet: WalletLike, **kwargs ) -> ExtrinsicResult: diff --git a/sdk/python/bittensor/executor.py b/sdk/python/bittensor/executor.py index 5a7dab1e53..2cb4cc5d9c 100644 --- a/sdk/python/bittensor/executor.py +++ b/sdk/python/bittensor/executor.py @@ -12,7 +12,7 @@ import inspect from dataclasses import fields as dataclass_fields from dataclasses import replace -from typing import Any, Optional +from typing import Any, Optional, Sequence # Module import + attribute access (not `from bittensor_core import ...`): # ty cannot see into the compiled extension, so named imports fail its check. @@ -27,6 +27,7 @@ from .intents import build as build_intent from .intents.base import BuiltCall from .intents.proxy import check_proxy_type +from .keyfiles import Keypair from .result import ( BittensorError, ChainError, @@ -54,6 +55,45 @@ ) +class _ProxyBuildWallet: + """Public identity of the proxied account plus the delegate wallet's keys.""" + + def __init__(self, wallet: Any, role: str, address: str): + self._wallet = wallet + self._role = role + self._account = Keypair(ss58_address=address) + + @property + def hotkey(self): + return self._account if self._role == "hotkey" else self._wallet.hotkey + + @property + def coldkeypub(self): + return self._account if self._role == "coldkey" else self._wallet.coldkeypub + + def __getattr__(self, name: str): + return getattr(self._wallet, name) + + +def _proxy_targets(proxy_for: Sequence[str]) -> list[str]: + if isinstance(proxy_for, str): + raise TypeError("proxy_for must be a sequence of ss58 accounts, not one string") + targets = list(proxy_for) + if not targets: + raise ValueError("proxy_for must contain at least one account") + if len(targets) > 256: + raise ValueError("proxy_for supports at most 256 accounts per submission batch") + if any(not isinstance(target, str) or not target for target in targets): + raise TypeError("every proxy_for account must be a non-empty ss58 string") + if len(set(targets)) != len(targets): + raise ValueError("proxy_for accounts must be unique") + # Decode every address before the first submission so malformed input cannot + # leave a batch half-applied. + for target in targets: + Keypair(ss58_address=target) + return targets + + def _is_transient(result: ExtrinsicResult) -> bool: message = (result.message or "").lower() return any(needle in message for needle in _TRANSIENT_SUBSTRINGS) @@ -476,7 +516,10 @@ async def plan( """ wallet = as_wallet(wallet) intent = _coerce_addresses(intent) - built = await intent.build(self.substrate, wallet) + build_wallet = ( + _ProxyBuildWallet(wallet, intent.signer, proxy_for) if proxy_for is not None else wallet + ) + built = await intent.build(self.substrate, build_wallet) if isinstance(built, BuiltCall): call, extras = built.call, built.extras else: @@ -640,6 +683,35 @@ async def execute( ) return result + async def execute_for_proxies( + self, + intent: Intent, + wallet: WalletLike, + proxy_for: Sequence[str], + *, + proxy_type: str = "Validate", + **kwargs, + ) -> dict[str, ExtrinsicResult]: + """Submit one validator intent for each proxied account, sequentially. + + The delegate wallet signs every outer ``Proxy.proxy`` call. Sequential + submission avoids nonce races when all calls use the same delegate key. + Chain dispatch failures are returned per account; a local build/signing + exception stops the remaining submissions. + """ + check_proxy_type(proxy_type) + targets = _proxy_targets(proxy_for) + return { + target: await self.execute( + intent, + wallet, + proxy_for=target, + proxy_type=proxy_type, + **kwargs, + ) + for target in targets + } + async def execute_tool( self, op: str, args: dict, wallet: WalletLike, **kwargs ) -> ExtrinsicResult: diff --git a/sdk/python/bittensor/intents/proxy.py b/sdk/python/bittensor/intents/proxy.py index ddb8018a59..8872fcb77c 100644 --- a/sdk/python/bittensor/intents/proxy.py +++ b/sdk/python/bittensor/intents/proxy.py @@ -37,6 +37,7 @@ "SwapHotkey", "SubnetLeaseBeneficiary", "RootClaim", + "Validate", ) diff --git a/sdk/python/bittensor/sync.py b/sdk/python/bittensor/sync.py index e23212a465..e1b7b5ff84 100644 --- a/sdk/python/bittensor/sync.py +++ b/sdk/python/bittensor/sync.py @@ -424,6 +424,9 @@ def plan(self, intent, wallet, **kwargs): def execute(self, intent, wallet, **kwargs): return self._call(self._client.execute(intent, wallet, **kwargs)) + def execute_for_proxies(self, intent, wallet, proxy_for, **kwargs): + return self._call(self._client.execute_for_proxies(intent, wallet, proxy_for, **kwargs)) + def execute_tool(self, op, args, wallet, **kwargs): return self._call(self._client.execute_tool(op, args, wallet, **kwargs)) diff --git a/sdk/python/tests/unit/test_intents_table.py b/sdk/python/tests/unit/test_intents_table.py index 1c91cdede7..3eb688371c 100644 --- a/sdk/python/tests/unit/test_intents_table.py +++ b/sdk/python/tests/unit/test_intents_table.py @@ -322,6 +322,45 @@ async def test_proxy_wraps_call_and_detects_inner_failure( assert not result.success assert "nested call failed" in result.message + @pytest.mark.asyncio + async def test_validate_proxy_uses_each_real_hotkey_for_weights( + self, client: Client, substrate: FakeSubstrate, wallet, monkeypatch + ): + from bittensor.intents.weights import SetWeights + from bittensor.keyfiles import Keypair + + encrypted_for = [] + + def encrypt(**kwargs): + encrypted_for.append(kwargs["hotkey"]) + return b"encrypted", 123 + + monkeypatch.setattr("bittensor.intents.weights._core.get_encrypted_commit_v2", encrypt) + + substrate.seed("SubtensorModule", "Uids", [1, wallet.hotkey.ss58_address], None) + substrate.seed("SubtensorModule", "Uids", [1, BOB_HOT], 0) + substrate.seed("SubtensorModule", "Uids", [1, BOB], 1) + substrate.seed_default("SubtensorModule", "CommitRevealWeightsEnabled", True) + results = await client.execute_for_proxies( + SetWeights(netuid=1, uids=[0], weights=[1.0]), + wallet, + [BOB_HOT, BOB], + ) + + assert list(results) == [BOB_HOT, BOB] + assert all(result.success for result in results.values()) + assert len(substrate.submissions) == 2 + assert encrypted_for == [ + bytes(Keypair(ss58_address=BOB_HOT).public_key), + bytes(Keypair(ss58_address=BOB).public_key), + ] + call, signer, _ = substrate.submissions[-1] + assert signer == wallet.hotkey.ss58_address + assert (call.module, call.function) == ("Proxy", "proxy") + assert call.params["real"] == BOB + assert call.params["force_proxy_type"] == "Validate" + assert call.params["call"].function == "commit_timelocked_mechanism_weights" + @pytest.mark.asyncio async def test_transient_pool_rejection_is_retried( self, client: Client, substrate: FakeSubstrate, wallet From d3434412a9987492739225268cb86d737b51d22a Mon Sep 17 00:00:00 2001 From: cisterciansis Date: Tue, 4 Aug 2026 16:44:12 -0400 Subject: [PATCH 2/4] refactor: make validate proxy weights transparent --- sdk/python/README.md | 18 ++- sdk/python/bittensor/client.py | 6 - sdk/python/bittensor/executor.py | 163 +++++++++++++------- sdk/python/bittensor/intents/weights.py | 3 + sdk/python/bittensor/sync.py | 3 - sdk/python/tests/unit/test_intents_table.py | 78 ++++++++-- 6 files changed, 190 insertions(+), 81 deletions(-) diff --git a/sdk/python/README.md b/sdk/python/README.md index 983fd1164a..b4bbcab97d 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -271,14 +271,20 @@ These compose with any intent: On the CLI: `--proxy-for ` on any `btcli tx` command. Manage delegations with the `add-proxy` / `remove-proxy` intents and the `proxies` read. - A validator operator can submit the same weights for several hotkeys that - granted its delegate a `Validate` proxy: + A zero-delay `Validate` proxy needs no subnet code changes. Add each real + validator hotkey to the existing local proxy book once: + + ```console + btcli proxy book add --name validator-a --address 5F...REAL \ + --spawner 5F...DELEGATE --proxy-type Validate + ``` + + The usual weight call then verifies those grants on-chain and submits the + direct and proxied calls together atomically: ```python - results = await client.execute_for_proxies( - bt.SetWeights(netuid=1, weights={0: 0.2, 1: 0.8}), - delegate_wallet, - proxy_for=[validator_a, validator_b], + result = await client.execute( + bt.SetWeights(netuid=1, weights={0: 0.2, 1: 0.8}), delegate_wallet ) ``` diff --git a/sdk/python/bittensor/client.py b/sdk/python/bittensor/client.py index 78c73012d9..b6007f629f 100644 --- a/sdk/python/bittensor/client.py +++ b/sdk/python/bittensor/client.py @@ -428,12 +428,6 @@ async def execute(self, intent: Intent, wallet: WalletLike, **kwargs) -> Extrins """ return await self._executor.execute(intent, wallet, **kwargs) - async def execute_for_proxies( - self, intent: Intent, wallet: WalletLike, proxy_for: list[str], **kwargs - ) -> dict[str, ExtrinsicResult]: - """Submit the same validator intent for a list of proxied hotkeys.""" - return await self._executor.execute_for_proxies(intent, wallet, proxy_for, **kwargs) - async def execute_tool( self, op: str, args: dict, wallet: WalletLike, **kwargs ) -> ExtrinsicResult: diff --git a/sdk/python/bittensor/executor.py b/sdk/python/bittensor/executor.py index 2cb4cc5d9c..2c8a6477b3 100644 --- a/sdk/python/bittensor/executor.py +++ b/sdk/python/bittensor/executor.py @@ -12,13 +12,15 @@ import inspect from dataclasses import fields as dataclass_fields from dataclasses import replace -from typing import Any, Optional, Sequence +from typing import Any, Optional # Module import + attribute access (not `from bittensor_core import ...`): # ty cannot see into the compiled extension, so named imports fail its check. import bittensor_core as _core +from . import config from ._generated import calls as generated_calls +from ._generated import storage as generated_storage from ._substrate import Substrate from ._transport.contract import UnsignedExtrinsic from ._transport.utils.receipt import nested_dispatch_error @@ -75,25 +77,6 @@ def __getattr__(self, name: str): return getattr(self._wallet, name) -def _proxy_targets(proxy_for: Sequence[str]) -> list[str]: - if isinstance(proxy_for, str): - raise TypeError("proxy_for must be a sequence of ss58 accounts, not one string") - targets = list(proxy_for) - if not targets: - raise ValueError("proxy_for must contain at least one account") - if len(targets) > 256: - raise ValueError("proxy_for supports at most 256 accounts per submission batch") - if any(not isinstance(target, str) or not target for target in targets): - raise TypeError("every proxy_for account must be a non-empty ss58 string") - if len(set(targets)) != len(targets): - raise ValueError("proxy_for accounts must be unique") - # Decode every address before the first submission so malformed input cannot - # leave a batch half-applied. - for target in targets: - Keypair(ss58_address=target) - return targets - - def _is_transient(result: ExtrinsicResult) -> bool: message = (result.message or "").lower() return any(needle in message for needle in _TRANSIENT_SUBSTRINGS) @@ -497,6 +480,102 @@ def _enforce_raw_call(self, policy: Optional[Policy]) -> None: if violations: raise PolicyError(violations) + async def _build_validate_weights(self, intent: Any, wallet: Any, delegate: str): + """Build normal weights plus configured zero-delay Validate delegations. + + The existing local proxy book is the operator's allowlist. Point reads + verify every entry on-chain without scanning the global proxy map or + accepting unsolicited delegations. Existing ``set_weights`` call sites + remain unchanged. + """ + targets = [] + for entry in config.load_proxies(): + if ( + str(entry.get("spawner")) != delegate + or entry.get("proxy_type") != "Validate" + or entry.get("delay", 0) != 0 + ): + continue + target = entry.get("address") + if not isinstance(target, str) or not target: + raise BittensorError("configured Validate proxy has no real account address") + Keypair(ss58_address=target) + if target not in targets: + targets.append(target) + + if not targets: + return await intent.build(self.substrate, wallet) + + block = await self.substrate.block_number() + block_hash = await self.substrate.block_hash(block) + proxies_item = generated_storage.Proxy.Proxies + values = await self.substrate.query_batch( + proxies_item.container, + proxies_item.name, + [[target] for target in targets], + block_hash=block_hash, + ) + if len(values) != len(targets): + raise ChainError("Proxy.Proxies returned an incomplete response") + for target, value in zip(targets, values): + if not isinstance(value, (list, tuple)) or len(value) != 2: + raise ChainError(f"configured Validate proxy {target} is absent on-chain") + definitions = value[0] + if not isinstance(definitions, (list, tuple)): + raise ChainError("Proxy.Proxies returned invalid proxy definitions") + granted = any( + isinstance(definition, dict) + and str(definition.get("delegate")) == delegate + and str(definition.get("proxy_type")) == "Validate" + and definition.get("delay") == 0 + for definition in definitions + ) + if not granted: + raise ChainError(f"{target} has not granted {delegate} a zero-delay Validate proxy") + + composed = [] + extras: dict[str, Any] = {"validate_proxy_for": targets} + uids_item = generated_storage.SubtensorModule.Uids + registered = await self.substrate.query( + uids_item.container, + uids_item.name, + [intent.netuid, delegate], + block_hash=block_hash, + ) + if registered is not None: + built = await intent.build(self.substrate, wallet) + if isinstance(built, BuiltCall): + composed.append(built.call) + extras.update(built.extras) + else: + composed.append(built) + + for index, target in enumerate(targets): + built = await intent.build( + self.substrate, _ProxyBuildWallet(wallet, intent.signer, target) + ) + if isinstance(built, BuiltCall): + inner = built.call + extras.update( + {f"proxy:{index}.{key}": value for key, value in built.extras.items()} + ) + else: + inner = built + composed.append( + await self.substrate.compose( + generated_calls.Proxy.proxy( + real=target, force_proxy_type="Validate", call=inner + ) + ) + ) + + call = ( + composed[0] + if len(composed) == 1 + else await self.substrate.compose(generated_calls.Utility.batch_all(calls=composed)) + ) + return BuiltCall(call, extras) + async def plan( self, intent: Intent, @@ -516,10 +595,16 @@ async def plan( """ wallet = as_wallet(wallet) intent = _coerce_addresses(intent) + pub = self._public_keypair(wallet, intent.signer) + signer_address = pub.ss58_address build_wallet = ( _ProxyBuildWallet(wallet, intent.signer, proxy_for) if proxy_for is not None else wallet ) - built = await intent.build(self.substrate, build_wallet) + built = ( + await self._build_validate_weights(intent, wallet, signer_address) + if intent.op == "set_weights" and proxy_for is None + else await intent.build(self.substrate, build_wallet) + ) if isinstance(built, BuiltCall): call, extras = built.call, built.extras else: @@ -528,8 +613,6 @@ async def plan( # and execution cannot drift (an intent that forgets Sudo.sudo still # dispatches as root, and docs stay authoritative). call = await _wrap_root_call(self.substrate, intent, call) - pub = self._public_keypair(wallet, intent.signer) - signer_address = pub.ss58_address # The account whose state the call actually touches. origin = proxy_for or signer_address @@ -553,6 +636,11 @@ async def plan( effects = list(await intent.effects(self.substrate, origin)) if proxy_for is not None: effects.append(f"dispatched via proxy as {proxy_for} (signed by {signer_address})") + elif extras.get("validate_proxy_for"): + effects.append( + "also dispatched through Validate proxies for: " + + ", ".join(extras["validate_proxy_for"]) + ) violations = self._violations(intent, fee, policy) return Plan( @@ -683,35 +771,6 @@ async def execute( ) return result - async def execute_for_proxies( - self, - intent: Intent, - wallet: WalletLike, - proxy_for: Sequence[str], - *, - proxy_type: str = "Validate", - **kwargs, - ) -> dict[str, ExtrinsicResult]: - """Submit one validator intent for each proxied account, sequentially. - - The delegate wallet signs every outer ``Proxy.proxy`` call. Sequential - submission avoids nonce races when all calls use the same delegate key. - Chain dispatch failures are returned per account; a local build/signing - exception stops the remaining submissions. - """ - check_proxy_type(proxy_type) - targets = _proxy_targets(proxy_for) - return { - target: await self.execute( - intent, - wallet, - proxy_for=target, - proxy_type=proxy_type, - **kwargs, - ) - for target in targets - } - async def execute_tool( self, op: str, args: dict, wallet: WalletLike, **kwargs ) -> ExtrinsicResult: diff --git a/sdk/python/bittensor/intents/weights.py b/sdk/python/bittensor/intents/weights.py index c154b336db..1ca5851c39 100644 --- a/sdk/python/bittensor/intents/weights.py +++ b/sdk/python/bittensor/intents/weights.py @@ -322,6 +322,9 @@ class SetWeights(Intent): minimum weight count) and submits via whichever path the subnet runs — a plain ``set_weights`` when commit-reveal is off, or a timelock-encrypted commit (auto-revealed by the chain at the drand reveal round) when it is on. + Zero-delay ``Validate`` delegations saved in the local proxy book are + verified on-chain and included automatically, so subnet call sites do not + change; the direct and proxied calls are submitted atomically. Signed by the hotkey, which must be registered on the subnet. Before signing it preflights registration and the rate limit, so those failures are caught fast with the same error the chain would return; the rate-limit diff --git a/sdk/python/bittensor/sync.py b/sdk/python/bittensor/sync.py index e1b7b5ff84..e23212a465 100644 --- a/sdk/python/bittensor/sync.py +++ b/sdk/python/bittensor/sync.py @@ -424,9 +424,6 @@ def plan(self, intent, wallet, **kwargs): def execute(self, intent, wallet, **kwargs): return self._call(self._client.execute(intent, wallet, **kwargs)) - def execute_for_proxies(self, intent, wallet, proxy_for, **kwargs): - return self._call(self._client.execute_for_proxies(intent, wallet, proxy_for, **kwargs)) - def execute_tool(self, op, args, wallet, **kwargs): return self._call(self._client.execute_tool(op, args, wallet, **kwargs)) diff --git a/sdk/python/tests/unit/test_intents_table.py b/sdk/python/tests/unit/test_intents_table.py index 3eb688371c..06f859511a 100644 --- a/sdk/python/tests/unit/test_intents_table.py +++ b/sdk/python/tests/unit/test_intents_table.py @@ -323,7 +323,7 @@ async def test_proxy_wraps_call_and_detects_inner_failure( assert "nested call failed" in result.message @pytest.mark.asyncio - async def test_validate_proxy_uses_each_real_hotkey_for_weights( + async def test_set_weights_transparently_uses_validate_proxies( self, client: Client, substrate: FakeSubstrate, wallet, monkeypatch ): from bittensor.intents.weights import SetWeights @@ -337,29 +337,79 @@ def encrypt(**kwargs): monkeypatch.setattr("bittensor.intents.weights._core.get_encrypted_commit_v2", encrypt) - substrate.seed("SubtensorModule", "Uids", [1, wallet.hotkey.ss58_address], None) + monkeypatch.setattr( + "bittensor.executor.config.load_proxies", + lambda: [ + { + "name": "validator-a", + "address": BOB_HOT, + "spawner": wallet.hotkey.ss58_address, + "proxy_type": "Validate", + "delay": 0, + }, + { + "name": "validator-b", + "address": BOB, + "spawner": wallet.hotkey.ss58_address, + "proxy_type": "Validate", + "delay": 0, + }, + ], + ) + substrate.seed( + "Proxy", + "Proxies", + [BOB_HOT], + ( + [ + { + "delegate": wallet.hotkey.ss58_address, + "proxy_type": "Validate", + "delay": 0, + } + ], + 0, + ), + ) + substrate.seed( + "Proxy", + "Proxies", + [BOB], + ( + [ + { + "delegate": wallet.hotkey.ss58_address, + "proxy_type": "Validate", + "delay": 0, + } + ], + 0, + ), + ) + substrate.seed("SubtensorModule", "Uids", [1, wallet.hotkey.ss58_address], 0) substrate.seed("SubtensorModule", "Uids", [1, BOB_HOT], 0) substrate.seed("SubtensorModule", "Uids", [1, BOB], 1) substrate.seed_default("SubtensorModule", "CommitRevealWeightsEnabled", True) - results = await client.execute_for_proxies( - SetWeights(netuid=1, uids=[0], weights=[1.0]), - wallet, - [BOB_HOT, BOB], - ) + result = await client.execute(SetWeights(netuid=1, uids=[0], weights=[1.0]), wallet) - assert list(results) == [BOB_HOT, BOB] - assert all(result.success for result in results.values()) - assert len(substrate.submissions) == 2 + assert result.success + assert len(substrate.submissions) == 1 assert encrypted_for == [ + bytes(wallet.hotkey.public_key), bytes(Keypair(ss58_address=BOB_HOT).public_key), bytes(Keypair(ss58_address=BOB).public_key), ] call, signer, _ = substrate.submissions[-1] assert signer == wallet.hotkey.ss58_address - assert (call.module, call.function) == ("Proxy", "proxy") - assert call.params["real"] == BOB - assert call.params["force_proxy_type"] == "Validate" - assert call.params["call"].function == "commit_timelocked_mechanism_weights" + assert (call.module, call.function) == ("Utility", "batch_all") + direct, *proxied = call.params["calls"] + assert direct.function == "commit_timelocked_mechanism_weights" + assert [child.params["real"] for child in proxied] == [BOB_HOT, BOB] + assert all(child.params["force_proxy_type"] == "Validate" for child in proxied) + assert all( + child.params["call"].function == "commit_timelocked_mechanism_weights" + for child in proxied + ) @pytest.mark.asyncio async def test_transient_pool_rejection_is_retried( From d454b437d60c979f49f7accb1188571518eabde1 Mon Sep 17 00:00:00 2001 From: cisterciansis Date: Tue, 4 Aug 2026 17:09:34 -0400 Subject: [PATCH 3/4] feat: configure validator weight targets on client --- sdk/python/README.md | 17 +- sdk/python/bittensor/client.py | 8 +- sdk/python/bittensor/executor.py | 170 +++++++++++--------- sdk/python/bittensor/intents/weights.py | 7 +- sdk/python/bittensor/sync.py | 2 + sdk/python/tests/unit/test_intents_table.py | 54 ++++--- 6 files changed, 144 insertions(+), 114 deletions(-) diff --git a/sdk/python/README.md b/sdk/python/README.md index b4bbcab97d..657d848641 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -271,18 +271,15 @@ These compose with any intent: On the CLI: `--proxy-for ` on any `btcli tx` command. Manage delegations with the `add-proxy` / `remove-proxy` intents and the `proxies` read. - A zero-delay `Validate` proxy needs no subnet code changes. Add each real - validator hotkey to the existing local proxy book once: - - ```console - btcli proxy book add --name validator-a --address 5F...REAL \ - --spawner 5F...DELEGATE --proxy-type Validate - ``` - - The usual weight call then verifies those grants on-chain and submits the - direct and proxied calls together atomically: + A zero-delay `Validate` proxy needs no subnet code changes. Configure the + exact hotkeys on the client: the signing hotkey is direct and every other + hotkey must have granted it a `Validate` proxy. Any mixture is supported; + `weight_targets=[]` disables weight submission entirely. ```python + client = bt.Client( + weight_targets=[delegate_wallet.hotkey.ss58_address, validator_a, validator_b] + ) result = await client.execute( bt.SetWeights(netuid=1, weights={0: 0.2, 1: 0.8}), delegate_wallet ) diff --git a/sdk/python/bittensor/client.py b/sdk/python/bittensor/client.py index b6007f629f..e6225c7d5d 100644 --- a/sdk/python/bittensor/client.py +++ b/sdk/python/bittensor/client.py @@ -144,6 +144,7 @@ def __init__( fallback_endpoints: Optional[list[str]] = None, archive_endpoints: Optional[list[str]] = None, retry_forever: bool = False, + weight_targets: Optional[list[str]] = None, substrate: Optional[Substrate] = None, ): """Create a client for a network name (``finney``/``test``/``local``) or a @@ -159,6 +160,11 @@ def __init__( ``retry_forever`` connection failures never give up — the client keeps cycling through the endpoint pool until one answers. + ``weight_targets`` configures transparent multi-hotkey validation. The + signing hotkey may appear for a direct submission; every other address + must grant it a zero-delay ``Validate`` proxy. An explicit empty list + makes ``SetWeights`` a no-op; ``None`` keeps normal single-hotkey behavior. + ``substrate`` swaps the chain-access backend: any :class:`Substrate` implementation (e.g. an in-memory fake for tests). When set, the connection options above don't apply — they configure the default @@ -178,7 +184,7 @@ def __init__( archive_endpoints=archive_endpoints, retry_forever=retry_forever, ) - self._executor = Executor(self._substrate, policy=policy) + self._executor = Executor(self._substrate, policy=policy, weight_targets=weight_targets) # Typed read namespaces: projections over the read registry # (bittensor.reads), one per category — curated methods plus every diff --git a/sdk/python/bittensor/executor.py b/sdk/python/bittensor/executor.py index 2c8a6477b3..a05bf71786 100644 --- a/sdk/python/bittensor/executor.py +++ b/sdk/python/bittensor/executor.py @@ -18,7 +18,6 @@ # ty cannot see into the compiled extension, so named imports fail its check. import bittensor_core as _core -from . import config from ._generated import calls as generated_calls from ._generated import storage as generated_storage from ._substrate import Substrate @@ -446,9 +445,27 @@ def _pure_created_data(result: ExtrinsicResult) -> dict[str, Any]: class Executor: - def __init__(self, substrate: Substrate, policy: Optional[Policy] = None): + def __init__( + self, + substrate: Substrate, + policy: Optional[Policy] = None, + weight_targets: Optional[list[str]] = None, + ): self.substrate = substrate self.policy = policy + if weight_targets is not None: + if not isinstance(weight_targets, list): + raise TypeError("weight_targets must be a list of hotkey addresses") + if len(weight_targets) > 256: + raise ValueError("weight_targets supports at most 256 hotkeys") + for target in weight_targets: + if not isinstance(target, str) or not target: + raise TypeError("every weight target must be a non-empty ss58 string") + Keypair(ss58_address=target) + if len(set(weight_targets)) != len(weight_targets): + raise ValueError("weight_targets must not contain duplicates") + weight_targets = list(weight_targets) + self.weight_targets = weight_targets @staticmethod def _public_keypair(wallet: WalletLike, signer: str): @@ -481,93 +498,72 @@ def _enforce_raw_call(self, policy: Optional[Policy]) -> None: raise PolicyError(violations) async def _build_validate_weights(self, intent: Any, wallet: Any, delegate: str): - """Build normal weights plus configured zero-delay Validate delegations. + """Build weights for the exact hotkey list configured on this client. - The existing local proxy book is the operator's allowlist. Point reads - verify every entry on-chain without scanning the global proxy map or - accepting unsolicited delegations. Existing ``set_weights`` call sites - remain unchanged. + The signing hotkey is direct; every other target must have granted it a + zero-delay Validate proxy. ``None`` preserves the ordinary single-wallet + behavior, while an explicit empty list is a no-op. """ - targets = [] - for entry in config.load_proxies(): - if ( - str(entry.get("spawner")) != delegate - or entry.get("proxy_type") != "Validate" - or entry.get("delay", 0) != 0 - ): - continue - target = entry.get("address") - if not isinstance(target, str) or not target: - raise BittensorError("configured Validate proxy has no real account address") - Keypair(ss58_address=target) - if target not in targets: - targets.append(target) - - if not targets: + if self.weight_targets is None: return await intent.build(self.substrate, wallet) - - block = await self.substrate.block_number() - block_hash = await self.substrate.block_hash(block) - proxies_item = generated_storage.Proxy.Proxies - values = await self.substrate.query_batch( - proxies_item.container, - proxies_item.name, - [[target] for target in targets], - block_hash=block_hash, - ) - if len(values) != len(targets): - raise ChainError("Proxy.Proxies returned an incomplete response") - for target, value in zip(targets, values): - if not isinstance(value, (list, tuple)) or len(value) != 2: - raise ChainError(f"configured Validate proxy {target} is absent on-chain") - definitions = value[0] - if not isinstance(definitions, (list, tuple)): - raise ChainError("Proxy.Proxies returned invalid proxy definitions") - granted = any( - isinstance(definition, dict) - and str(definition.get("delegate")) == delegate - and str(definition.get("proxy_type")) == "Validate" - and definition.get("delay") == 0 - for definition in definitions + if not self.weight_targets: + return BuiltCall(None, {"weight_targets": [], "no_op": True}) + + targets = self.weight_targets + proxied = [target for target in targets if target != delegate] + if proxied: + block = await self.substrate.block_number() + block_hash = await self.substrate.block_hash(block) + proxies_item = generated_storage.Proxy.Proxies + values = await self.substrate.query_batch( + proxies_item.container, + proxies_item.name, + [[target] for target in proxied], + block_hash=block_hash, ) - if not granted: - raise ChainError(f"{target} has not granted {delegate} a zero-delay Validate proxy") + if len(values) != len(proxied): + raise ChainError("Proxy.Proxies returned an incomplete response") + for target, value in zip(proxied, values): + if not isinstance(value, (list, tuple)) or len(value) != 2: + raise ChainError(f"configured Validate proxy {target} is absent on-chain") + definitions = value[0] + if not isinstance(definitions, (list, tuple)): + raise ChainError("Proxy.Proxies returned invalid proxy definitions") + granted = any( + isinstance(definition, dict) + and str(definition.get("delegate")) == delegate + and str(definition.get("proxy_type")) == "Validate" + and definition.get("delay") == 0 + for definition in definitions + ) + if not granted: + raise ChainError( + f"{target} has not granted {delegate} a zero-delay Validate proxy" + ) composed = [] - extras: dict[str, Any] = {"validate_proxy_for": targets} - uids_item = generated_storage.SubtensorModule.Uids - registered = await self.substrate.query( - uids_item.container, - uids_item.name, - [intent.netuid, delegate], - block_hash=block_hash, - ) - if registered is not None: - built = await intent.build(self.substrate, wallet) - if isinstance(built, BuiltCall): - composed.append(built.call) - extras.update(built.extras) - else: - composed.append(built) - + extras: dict[str, Any] = {"weight_targets": targets} for index, target in enumerate(targets): - built = await intent.build( - self.substrate, _ProxyBuildWallet(wallet, intent.signer, target) - ) + direct = target == delegate + build_wallet = wallet if direct else _ProxyBuildWallet(wallet, intent.signer, target) + built = await intent.build(self.substrate, build_wallet) if isinstance(built, BuiltCall): inner = built.call extras.update( - {f"proxy:{index}.{key}": value for key, value in built.extras.items()} + {f"target:{index}.{key}": value for key, value in built.extras.items()} ) else: inner = built - composed.append( - await self.substrate.compose( - generated_calls.Proxy.proxy( - real=target, force_proxy_type="Validate", call=inner + if direct: + composed.append(inner) + else: + composed.append( + await self.substrate.compose( + generated_calls.Proxy.proxy( + real=target, force_proxy_type="Validate", call=inner + ) ) ) - ) call = ( composed[0] @@ -609,6 +605,19 @@ async def plan( call, extras = built.call, built.extras else: call, extras = built, {} + if extras.get("no_op"): + return Plan( + op=intent.op, + summary=intent.summary(), + signer=intent.signer, + signer_address=signer_address, + fee=None, + effects=["no weight targets configured; nothing will be submitted"], + warnings=[], + violations=self._violations(intent, None, policy), + call=None, + extras=extras, + ) # Root intents declare privilege via ``origin``; wrap here so metadata # and execution cannot drift (an intent that forgets Sudo.sudo still # dispatches as root, and docs stay authoritative). @@ -636,11 +645,8 @@ async def plan( effects = list(await intent.effects(self.substrate, origin)) if proxy_for is not None: effects.append(f"dispatched via proxy as {proxy_for} (signed by {signer_address})") - elif extras.get("validate_proxy_for"): - effects.append( - "also dispatched through Validate proxies for: " - + ", ".join(extras["validate_proxy_for"]) - ) + elif extras.get("weight_targets"): + effects.append("weight targets: " + ", ".join(extras["weight_targets"])) violations = self._violations(intent, fee, policy) return Plan( @@ -712,6 +718,12 @@ async def execute( ) if not plan.ok: raise PolicyError(plan.violations) + if plan.extras.get("no_op"): + return ExtrinsicResult( + success=True, + message="No weight targets configured; nothing submitted.", + data=plan.extras, + ) keypair = resolve_signer(wallet, intent.signer) attempts = max(0, int(retries)) + 1 diff --git a/sdk/python/bittensor/intents/weights.py b/sdk/python/bittensor/intents/weights.py index 1ca5851c39..955cb79bbe 100644 --- a/sdk/python/bittensor/intents/weights.py +++ b/sdk/python/bittensor/intents/weights.py @@ -322,9 +322,10 @@ class SetWeights(Intent): minimum weight count) and submits via whichever path the subnet runs — a plain ``set_weights`` when commit-reveal is off, or a timelock-encrypted commit (auto-revealed by the chain at the drand reveal round) when it is on. - Zero-delay ``Validate`` delegations saved in the local proxy book are - verified on-chain and included automatically, so subnet call sites do not - change; the direct and proxied calls are submitted atomically. + When the client has ``weight_targets`` configured, its exact combination of + the signing hotkey and zero-delay ``Validate`` delegations is verified and + submitted atomically; an empty list is a no-op. Subnet call sites do not + change. Signed by the hotkey, which must be registered on the subnet. Before signing it preflights registration and the rate limit, so those failures are caught fast with the same error the chain would return; the rate-limit diff --git a/sdk/python/bittensor/sync.py b/sdk/python/bittensor/sync.py index e23212a465..94c24c4e27 100644 --- a/sdk/python/bittensor/sync.py +++ b/sdk/python/bittensor/sync.py @@ -205,6 +205,7 @@ def __init__( fallback_endpoints: Optional[list[str]] = None, archive_endpoints: Optional[list[str]] = None, retry_forever: bool = False, + weight_targets: Optional[list[str]] = None, substrate: Optional[Substrate] = None, ): self._client = Client( @@ -213,6 +214,7 @@ def __init__( fallback_endpoints=fallback_endpoints, archive_endpoints=archive_endpoints, retry_forever=retry_forever, + weight_targets=weight_targets, substrate=substrate, ) self.network = self._client.network diff --git a/sdk/python/tests/unit/test_intents_table.py b/sdk/python/tests/unit/test_intents_table.py index 06f859511a..f83b7448a0 100644 --- a/sdk/python/tests/unit/test_intents_table.py +++ b/sdk/python/tests/unit/test_intents_table.py @@ -324,7 +324,7 @@ async def test_proxy_wraps_call_and_detects_inner_failure( @pytest.mark.asyncio async def test_set_weights_transparently_uses_validate_proxies( - self, client: Client, substrate: FakeSubstrate, wallet, monkeypatch + self, substrate: FakeSubstrate, wallet, monkeypatch ): from bittensor.intents.weights import SetWeights from bittensor.keyfiles import Keypair @@ -337,24 +337,10 @@ def encrypt(**kwargs): monkeypatch.setattr("bittensor.intents.weights._core.get_encrypted_commit_v2", encrypt) - monkeypatch.setattr( - "bittensor.executor.config.load_proxies", - lambda: [ - { - "name": "validator-a", - "address": BOB_HOT, - "spawner": wallet.hotkey.ss58_address, - "proxy_type": "Validate", - "delay": 0, - }, - { - "name": "validator-b", - "address": BOB, - "spawner": wallet.hotkey.ss58_address, - "proxy_type": "Validate", - "delay": 0, - }, - ], + client = Client( + "local", + substrate=substrate, + weight_targets=[BOB_HOT, wallet.hotkey.ss58_address, BOB], ) substrate.seed( "Proxy", @@ -395,15 +381,16 @@ def encrypt(**kwargs): assert result.success assert len(substrate.submissions) == 1 assert encrypted_for == [ - bytes(wallet.hotkey.public_key), bytes(Keypair(ss58_address=BOB_HOT).public_key), + bytes(wallet.hotkey.public_key), bytes(Keypair(ss58_address=BOB).public_key), ] call, signer, _ = substrate.submissions[-1] assert signer == wallet.hotkey.ss58_address assert (call.module, call.function) == ("Utility", "batch_all") - direct, *proxied = call.params["calls"] + first, direct, last = call.params["calls"] assert direct.function == "commit_timelocked_mechanism_weights" + proxied = [first, last] assert [child.params["real"] for child in proxied] == [BOB_HOT, BOB] assert all(child.params["force_proxy_type"] == "Validate" for child in proxied) assert all( @@ -411,6 +398,31 @@ def encrypt(**kwargs): for child in proxied ) + @pytest.mark.asyncio + async def test_set_weights_empty_target_list_is_noop(self, substrate: FakeSubstrate, wallet): + from bittensor.intents.weights import SetWeights + + client = Client("local", substrate=substrate, weight_targets=[]) + result = await client.execute(SetWeights(netuid=1, uids=[0], weights=[1.0]), wallet) + + assert result.success + assert result.message == "No weight targets configured; nothing submitted." + assert result.data == {"weight_targets": [], "no_op": True} + assert substrate.submissions == [] + + @pytest.mark.asyncio + async def test_set_weights_rejects_unverified_proxy_target( + self, substrate: FakeSubstrate, wallet + ): + from bittensor.intents.weights import SetWeights + from bittensor.result import ChainError + + client = Client("local", substrate=substrate, weight_targets=[BOB]) + + with pytest.raises(ChainError, match="absent on-chain"): + await client.execute(SetWeights(netuid=1, uids=[0], weights=[1.0]), wallet) + assert substrate.submissions == [] + @pytest.mark.asyncio async def test_transient_pool_rejection_is_retried( self, client: Client, substrate: FakeSubstrate, wallet From 8d2423daf08a414b026aa3da94590a79d62ed859 Mon Sep 17 00:00:00 2001 From: cisterciansis Date: Wed, 5 Aug 2026 00:01:13 -0400 Subject: [PATCH 4/4] fix: isolate validate proxy weight failures --- .../test-proxy-filter-security-regressions.ts | 34 +++- pallets/utility/src/tests.rs | 19 +++ runtime/src/transaction_payment_wrapper.rs | 14 ++ sdk/python/README.md | 22 ++- sdk/python/bittensor/_substrate.py | 2 + sdk/python/bittensor/client.py | 25 ++- sdk/python/bittensor/executor.py | 145 +++++++++++----- sdk/python/bittensor/intents/weights.py | 7 +- sdk/python/tests/conftest.py | 7 + sdk/python/tests/unit/test_intents_table.py | 159 +++++++++++++----- 10 files changed, 331 insertions(+), 103 deletions(-) diff --git a/clones/js-tests/tests/test-proxy-filter-security-regressions.ts b/clones/js-tests/tests/test-proxy-filter-security-regressions.ts index 1736632b13..da57215e42 100644 --- a/clones/js-tests/tests/test-proxy-filter-security-regressions.ts +++ b/clones/js-tests/tests/test-proxy-filter-security-regressions.ts @@ -12,7 +12,7 @@ const FUND_SOURCE_URI = process.env.PROXY_FILTER_FUND_SOURCE_URI ?? "//Alice"; const FUND_AMOUNT = BigInt(process.env.PROXY_FILTER_FUND_AMOUNT ?? "5000000000000"); const ZERO_HASH = `0x${"00".repeat(32)}`; -const PROXY_TYPES = ["NonFungible", "SwapHotkey", "NonTransfer", "Owner"]; +const PROXY_TYPES = ["NonFungible", "SwapHotkey", "NonTransfer", "Owner", "Validate"]; const keyring = new Keyring({ type: "sr25519" }); const fundSource = keyring.addFromUri(FUND_SOURCE_URI); @@ -76,6 +76,33 @@ async function main() { api.tx.adminUtils.sudoSetSnOwnerHotkey(0, replacementHotkey.address) ); + const validateCalls = [ + ["set weights", api.tx.subtensorModule.setMechanismWeights(0, 0, [], [], 0)], + ["serve axon", api.tx.subtensorModule.serveAxon(0, 1, 2130706433, 8091, 4, 0, 0, 0)], + [ + "serve axon TLS", + api.tx.subtensorModule.serveAxonTls(0, 1, 2130706433, 8092, 4, 0, 0, 0, "0x"), + ], + [ + "associate EVM key", + api.tx.subtensorModule.associateEvmKey( + 0, + `0x${"11".repeat(20)}`, + 1, + `0x${"22".repeat(65)}` + ), + ], + ["set commitment", api.tx.commitments.setCommitment(0, { fields: [] })], + ]; + for (const [name, call] of validateCalls) { + await expectProxyTypeAllowed(`Validate allows ${name}`, "Validate", call); + } + await expectProxyTypeDenied( + "Validate denies transfer", + "Validate", + balancesTransfer(dummyHotkey.address, 1n) + ); + console.log("proxy filter security regressions: ok"); } finally { await api?.disconnect(); @@ -101,6 +128,11 @@ async function assertMetadataAvailable() { // sudo_set_sn_owner_hotkey (call 67); the Owner-proxy denial property // is the same. ["AdminUtils.sudoSetSnOwnerHotkey", api.tx.adminUtils?.sudoSetSnOwnerHotkey], + ["SubtensorModule.setMechanismWeights", api.tx.subtensorModule?.setMechanismWeights], + ["SubtensorModule.serveAxon", api.tx.subtensorModule?.serveAxon], + ["SubtensorModule.serveAxonTls", api.tx.subtensorModule?.serveAxonTls], + ["SubtensorModule.associateEvmKey", api.tx.subtensorModule?.associateEvmKey], + ["Commitments.setCommitment", api.tx.commitments?.setCommitment], ].filter(([, value]) => !value); assert.equal( diff --git a/pallets/utility/src/tests.rs b/pallets/utility/src/tests.rs index 14020ec8bf..504c52ba70 100644 --- a/pallets/utility/src/tests.rs +++ b/pallets/utility/src/tests.rs @@ -747,6 +747,25 @@ fn force_batch_works() { }); } +#[test] +fn force_batch_handles_successful_weight_refund() { + new_test_ext().execute_with(|| { + let declared = Weight::from_parts(100, 0); + let actual = Weight::from_parts(75, 0); + let batch_len = 4; + let calls = vec![call_foobar(false, declared, Some(actual)); batch_len]; + let call = RuntimeCall::Utility(UtilityCall::force_batch { calls }); + let info = call.get_dispatch_info(); + let result = call.dispatch(RuntimeOrigin::signed(1)); + + assert_ok!(result); + assert_eq!( + extract_actual_weight(&result, &info), + info.call_weight - (declared - actual) * batch_len as u64 + ); + }); +} + #[test] fn none_origin_does_not_work() { new_test_ext().execute_with(|| { diff --git a/runtime/src/transaction_payment_wrapper.rs b/runtime/src/transaction_payment_wrapper.rs index 67c032ebad..797e4277ea 100644 --- a/runtime/src/transaction_payment_wrapper.rs +++ b/runtime/src/transaction_payment_wrapper.rs @@ -700,6 +700,20 @@ mod tests { }); } + #[test] + fn force_batch_of_weight_calls_remains_fee_free() { + let direct = call_set_weights(); + let proxied = proxy_call(real_a(), call_set_weights()); + assert_eq!(direct.get_dispatch_info().pays_fee, Pays::No); + assert_eq!(proxied.get_dispatch_info().pays_fee, Pays::No); + assert_eq!( + force_batch_call(vec![direct, proxied]) + .get_dispatch_info() + .pays_fee, + Pays::No + ); + } + #[test] fn batch_charges_outer_real_when_only_outer_opted_in() { new_test_ext().execute_with(|| { diff --git a/sdk/python/README.md b/sdk/python/README.md index 657d848641..6b40796eec 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -271,20 +271,28 @@ These compose with any intent: On the CLI: `--proxy-for ` on any `btcli tx` command. Manage delegations with the `add-proxy` / `remove-proxy` intents and the `proxies` read. - A zero-delay `Validate` proxy needs no subnet code changes. Configure the - exact hotkeys on the client: the signing hotkey is direct and every other - hotkey must have granted it a `Validate` proxy. Any mixture is supported; - `weight_targets=[]` disables weight submission entirely. + A zero-delay `Validate` proxy needs no subnet code changes. Set the exact + targets through the environment: the signing hotkey is direct and every + other hotkey must have granted it a `Validate` proxy. Any mixture is + supported; an empty value disables weight submission entirely. + + ```console + WEIGHT_TARGETS=5F...DELEGATE,5F...VALIDATOR_A,5F...VALIDATOR_B + ``` + + The existing subnet call remains unchanged: ```python - client = bt.Client( - weight_targets=[delegate_wallet.hotkey.ss58_address, validator_a, validator_b] - ) result = await client.execute( bt.SetWeights(netuid=1, weights={0: 0.2, 1: 0.8}), delegate_wallet ) ``` + Targets are dispatched with `Utility.force_batch`: one revoked or invalid + target is reported in `result.data["weight_results"]` without preventing the + remaining validators from setting weights. The client constructor's + `weight_targets=` option overrides the environment when supplied. + - **Atomic batch** — several intents in one all-or-nothing extrinsic: ```python diff --git a/sdk/python/bittensor/_substrate.py b/sdk/python/bittensor/_substrate.py index aaf6d43fbd..ba1297f629 100644 --- a/sdk/python/bittensor/_substrate.py +++ b/sdk/python/bittensor/_substrate.py @@ -781,6 +781,8 @@ def _result_from_report(self, report: InclusionReport, waited: bool) -> Extrinsi block_hash=report.block_hash, extrinsic_id=extrinsic_id, explorer_url=explorer, + fee=Balance.from_rao(report.total_fee_amount or 0), + events=list(report.triggered_events), error=ChainError(text, name), ) diff --git a/sdk/python/bittensor/client.py b/sdk/python/bittensor/client.py index e6225c7d5d..1e9011d173 100644 --- a/sdk/python/bittensor/client.py +++ b/sdk/python/bittensor/client.py @@ -18,6 +18,7 @@ import asyncio import contextlib +import os from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Any, AsyncIterator, Optional, Union @@ -70,6 +71,18 @@ FAST_BLOCK_TIME = 0.25 +def _weight_targets_from_env() -> Optional[list[str]]: + raw = os.getenv("WEIGHT_TARGETS") + if raw is None: + return None + if not raw.strip(): + return [] + targets = [target.strip() for target in raw.split(",")] + if any(not target for target in targets): + raise ValueError("WEIGHT_TARGETS must be comma-separated ss58 addresses") + return targets + + @dataclass class BlockHeader: """A new block seen on a subscription (``client.blocks()``).""" @@ -163,7 +176,9 @@ def __init__( ``weight_targets`` configures transparent multi-hotkey validation. The signing hotkey may appear for a direct submission; every other address must grant it a zero-delay ``Validate`` proxy. An explicit empty list - makes ``SetWeights`` a no-op; ``None`` keeps normal single-hotkey behavior. + makes ``SetWeights`` a no-op. When omitted, the comma-separated + ``WEIGHT_TARGETS`` environment variable is used; if that is also unset, + normal single-hotkey behavior is preserved. ``substrate`` swaps the chain-access backend: any :class:`Substrate` implementation (e.g. an in-memory fake for tests). When set, the @@ -184,7 +199,13 @@ def __init__( archive_endpoints=archive_endpoints, retry_forever=retry_forever, ) - self._executor = Executor(self._substrate, policy=policy, weight_targets=weight_targets) + self._executor = Executor( + self._substrate, + policy=policy, + weight_targets=( + weight_targets if weight_targets is not None else _weight_targets_from_env() + ), + ) # Typed read namespaces: projections over the read registry # (bittensor.reads), one per category — curated methods plus every diff --git a/sdk/python/bittensor/executor.py b/sdk/python/bittensor/executor.py index a05bf71786..fe0be3dab7 100644 --- a/sdk/python/bittensor/executor.py +++ b/sdk/python/bittensor/executor.py @@ -19,7 +19,6 @@ import bittensor_core as _core from ._generated import calls as generated_calls -from ._generated import storage as generated_storage from ._substrate import Substrate from ._transport.contract import UnsignedExtrinsic from ._transport.utils.receipt import nested_dispatch_error @@ -139,6 +138,36 @@ def _event_parts(entry: Any) -> tuple[Optional[str], Optional[str], Any, Optiona return event.get("module_id"), event.get("event_id"), event.get("attributes"), index +def _weight_batch_results(events: list, targets: list[str]) -> Optional[list[dict[str, Any]]]: + """Per-item outcomes from a completed ``Utility.force_batch``.""" + results = [] + proxy_error = None + completed = False + for entry in events: + module, event, attributes, _ = _event_parts(entry) + if module == "Proxy" and event == "ProxyExecuted": + dispatch = attributes.get("result") if isinstance(attributes, dict) else attributes + if isinstance(dispatch, dict) and "Err" in dispatch: + proxy_error = dispatch["Err"] + elif module == "Utility" and event in {"ItemCompleted", "ItemFailed"}: + if len(results) >= len(targets): + return None + error = proxy_error + if event == "ItemFailed": + error = attributes.get("error") if isinstance(attributes, dict) else attributes + item = {"target": targets[len(results)], "success": error is None} + if error is not None: + item["error"] = chain_error_from_dispatch(error).message + results.append(item) + proxy_error = None + elif module == "Utility" and event in { + "BatchCompleted", + "BatchCompletedWithErrors", + }: + completed = True + return results if completed and len(results) == len(targets) else None + + def _event_netuid(attributes: Any) -> Optional[int]: """Read the netuid from named or tuple-style Subtensor events.""" value = attributes.get("netuid") if isinstance(attributes, dict) else attributes @@ -507,49 +536,32 @@ async def _build_validate_weights(self, intent: Any, wallet: Any, delegate: str) if self.weight_targets is None: return await intent.build(self.substrate, wallet) if not self.weight_targets: - return BuiltCall(None, {"weight_targets": [], "no_op": True}) - - targets = self.weight_targets - proxied = [target for target in targets if target != delegate] - if proxied: - block = await self.substrate.block_number() - block_hash = await self.substrate.block_hash(block) - proxies_item = generated_storage.Proxy.Proxies - values = await self.substrate.query_batch( - proxies_item.container, - proxies_item.name, - [[target] for target in proxied], - block_hash=block_hash, + return BuiltCall( + None, + { + "weight_targets": [], + "submitted_weight_targets": [], + "weight_build_errors": {}, + "no_op": True, + }, ) - if len(values) != len(proxied): - raise ChainError("Proxy.Proxies returned an incomplete response") - for target, value in zip(proxied, values): - if not isinstance(value, (list, tuple)) or len(value) != 2: - raise ChainError(f"configured Validate proxy {target} is absent on-chain") - definitions = value[0] - if not isinstance(definitions, (list, tuple)): - raise ChainError("Proxy.Proxies returned invalid proxy definitions") - granted = any( - isinstance(definition, dict) - and str(definition.get("delegate")) == delegate - and str(definition.get("proxy_type")) == "Validate" - and definition.get("delay") == 0 - for definition in definitions - ) - if not granted: - raise ChainError( - f"{target} has not granted {delegate} a zero-delay Validate proxy" - ) + targets = self.weight_targets composed = [] - extras: dict[str, Any] = {"weight_targets": targets} + submitted = [] + build_errors = {} + build_extras = {} for index, target in enumerate(targets): direct = target == delegate build_wallet = wallet if direct else _ProxyBuildWallet(wallet, intent.signer, target) - built = await intent.build(self.substrate, build_wallet) + try: + built = await intent.build(self.substrate, build_wallet) + except ChainError as error: + build_errors[target] = error.message + continue if isinstance(built, BuiltCall): inner = built.call - extras.update( + build_extras.update( {f"target:{index}.{key}": value for key, value in built.extras.items()} ) else: @@ -564,12 +576,17 @@ async def _build_validate_weights(self, intent: Any, wallet: Any, delegate: str) ) ) ) - - call = ( - composed[0] - if len(composed) == 1 - else await self.substrate.compose(generated_calls.Utility.batch_all(calls=composed)) - ) + submitted.append(target) + + extras: dict[str, Any] = { + "weight_targets": targets, + "submitted_weight_targets": submitted, + "weight_build_errors": build_errors, + **build_extras, + } + if not composed: + return BuiltCall(None, {**extras, "no_op": True}) + call = await self.substrate.compose(generated_calls.Utility.force_batch(calls=composed)) return BuiltCall(call, extras) async def plan( @@ -719,10 +736,21 @@ async def execute( if not plan.ok: raise PolicyError(plan.violations) if plan.extras.get("no_op"): + build_errors = plan.extras.get("weight_build_errors", {}) return ExtrinsicResult( success=True, - message="No weight targets configured; nothing submitted.", - data=plan.extras, + message=( + "No valid weight targets; nothing submitted." + if build_errors + else "No weight targets configured; nothing submitted." + ), + data={ + **plan.extras, + "weight_results": [ + {"target": target, "success": False, "error": error} + for target, error in build_errors.items() + ], + }, ) keypair = resolve_signer(wallet, intent.signer) @@ -739,10 +767,37 @@ async def execute( break # One block, as the chain measures it (0.25s on fast-blocks localnets). await asyncio.sleep(await self.substrate.block_time()) + batch_results = _weight_batch_results( + result.events, plan.extras.get("submitted_weight_targets", []) + ) + tolerant_batch = batch_results is not None + if tolerant_batch: + submitted_results = iter(batch_results) + build_errors = plan.extras.get("weight_build_errors", {}) + ordered = [ + ( + {"target": target, "success": False, "error": build_errors[target]} + if target in build_errors + else next(submitted_results) + ) + for target in plan.extras["weight_targets"] + ] + failures = sum(not item["success"] for item in ordered) + result = replace( + result, + success=True, + message=( + "All weight targets completed." + if failures == 0 + else f"Weight submission completed with {failures} target failure(s)." + ), + error=None, + data={**result.data, "weight_results": ordered}, + ) # Defense for backends that mark ExtrinsicSuccess without decoding # nested Sudo/Proxy/Multisig Results (e.g. in-memory fakes). The RPC # path already fails these in resolve_outcome. - if result.success: + if result.success and not tolerant_batch: inner_error = nested_dispatch_error(result.events) if inner_error is not None: error = chain_error_from_dispatch(inner_error) diff --git a/sdk/python/bittensor/intents/weights.py b/sdk/python/bittensor/intents/weights.py index 955cb79bbe..304340e275 100644 --- a/sdk/python/bittensor/intents/weights.py +++ b/sdk/python/bittensor/intents/weights.py @@ -323,9 +323,10 @@ class SetWeights(Intent): plain ``set_weights`` when commit-reveal is off, or a timelock-encrypted commit (auto-revealed by the chain at the drand reveal round) when it is on. When the client has ``weight_targets`` configured, its exact combination of - the signing hotkey and zero-delay ``Validate`` delegations is verified and - submitted atomically; an empty list is a no-op. Subnet call sites do not - change. + the signing hotkey and zero-delay ``Validate`` delegations is submitted with + per-target failure isolation; the chain verifies each proxy grant, and an + empty list is a no-op. + Subnet call sites do not change. Signed by the hotkey, which must be registered on the subnet. Before signing it preflights registration and the rate limit, so those failures are caught fast with the same error the chain would return; the rate-limit diff --git a/sdk/python/tests/conftest.py b/sdk/python/tests/conftest.py index f2258849f8..6a886b835b 100644 --- a/sdk/python/tests/conftest.py +++ b/sdk/python/tests/conftest.py @@ -12,11 +12,18 @@ from functools import lru_cache from pathlib import Path +import pytest + from bittensor._transport.codec import RuntimeCodec, strip_option_opaque_metadata GOLDEN_FIXTURE = Path(__file__).parent / "fixtures" / "golden.json" +@pytest.fixture(autouse=True) +def _isolate_weight_targets_env(monkeypatch): + monkeypatch.delenv("WEIGHT_TARGETS", raising=False) + + @lru_cache(maxsize=1) def golden() -> dict: return json.loads(GOLDEN_FIXTURE.read_text()) diff --git a/sdk/python/tests/unit/test_intents_table.py b/sdk/python/tests/unit/test_intents_table.py index f83b7448a0..dc92c608bf 100644 --- a/sdk/python/tests/unit/test_intents_table.py +++ b/sdk/python/tests/unit/test_intents_table.py @@ -337,41 +337,8 @@ def encrypt(**kwargs): monkeypatch.setattr("bittensor.intents.weights._core.get_encrypted_commit_v2", encrypt) - client = Client( - "local", - substrate=substrate, - weight_targets=[BOB_HOT, wallet.hotkey.ss58_address, BOB], - ) - substrate.seed( - "Proxy", - "Proxies", - [BOB_HOT], - ( - [ - { - "delegate": wallet.hotkey.ss58_address, - "proxy_type": "Validate", - "delay": 0, - } - ], - 0, - ), - ) - substrate.seed( - "Proxy", - "Proxies", - [BOB], - ( - [ - { - "delegate": wallet.hotkey.ss58_address, - "proxy_type": "Validate", - "delay": 0, - } - ], - 0, - ), - ) + monkeypatch.setenv("WEIGHT_TARGETS", f"{BOB_HOT}, {wallet.hotkey.ss58_address}, {BOB}") + client = Client("local", substrate=substrate) substrate.seed("SubtensorModule", "Uids", [1, wallet.hotkey.ss58_address], 0) substrate.seed("SubtensorModule", "Uids", [1, BOB_HOT], 0) substrate.seed("SubtensorModule", "Uids", [1, BOB], 1) @@ -387,7 +354,7 @@ def encrypt(**kwargs): ] call, signer, _ = substrate.submissions[-1] assert signer == wallet.hotkey.ss58_address - assert (call.module, call.function) == ("Utility", "batch_all") + assert (call.module, call.function) == ("Utility", "force_batch") first, direct, last = call.params["calls"] assert direct.function == "commit_timelocked_mechanism_weights" proxied = [first, last] @@ -399,29 +366,131 @@ def encrypt(**kwargs): ) @pytest.mark.asyncio - async def test_set_weights_empty_target_list_is_noop(self, substrate: FakeSubstrate, wallet): + async def test_set_weights_empty_target_list_is_noop( + self, substrate: FakeSubstrate, wallet, monkeypatch + ): from bittensor.intents.weights import SetWeights - client = Client("local", substrate=substrate, weight_targets=[]) + monkeypatch.setenv("WEIGHT_TARGETS", "") + client = Client("local", substrate=substrate) result = await client.execute(SetWeights(netuid=1, uids=[0], weights=[1.0]), wallet) assert result.success assert result.message == "No weight targets configured; nothing submitted." - assert result.data == {"weight_targets": [], "no_op": True} + assert result.data["weight_targets"] == [] + assert result.data["weight_results"] == [] assert substrate.submissions == [] @pytest.mark.asyncio - async def test_set_weights_rejects_unverified_proxy_target( + async def test_set_weights_force_batch_reports_failure_and_continues( self, substrate: FakeSubstrate, wallet ): + from dataclasses import replace + from bittensor.intents.weights import SetWeights - from bittensor.result import ChainError + from tests.harness.fake_substrate import success_result - client = Client("local", substrate=substrate, weight_targets=[BOB]) + targets = [BOB_HOT, wallet.hotkey.ss58_address, BOB] + client = Client("local", substrate=substrate, weight_targets=targets) + substrate.queue_result( + replace( + success_result(), + success=False, + message="NotProxy", + events=[ + { + "event": { + "module_id": "Utility", + "event_id": "ItemFailed", + "attributes": {"error": "NotProxy"}, + } + }, + { + "event": { + "module_id": "Utility", + "event_id": "ItemCompleted", + "attributes": {}, + } + }, + { + "event": { + "module_id": "Proxy", + "event_id": "ProxyExecuted", + "attributes": {"result": {"Ok": None}}, + } + }, + { + "event": { + "module_id": "Utility", + "event_id": "ItemCompleted", + "attributes": {}, + } + }, + { + "event": { + "module_id": "Utility", + "event_id": "BatchCompletedWithErrors", + "attributes": {}, + } + }, + ], + ) + ) - with pytest.raises(ChainError, match="absent on-chain"): - await client.execute(SetWeights(netuid=1, uids=[0], weights=[1.0]), wallet) - assert substrate.submissions == [] + result = await client.execute(SetWeights(netuid=1, uids=[0], weights=[1.0]), wallet) + + assert result.success + assert result.message == "Weight submission completed with 1 target failure(s)." + assert result.data["weight_results"] == [ + {"target": BOB_HOT, "success": False, "error": "NotProxy"}, + {"target": wallet.hotkey.ss58_address, "success": True}, + {"target": BOB, "success": True}, + ] + assert len(substrate.submissions) == 1 + + @pytest.mark.asyncio + async def test_set_weights_preflight_failure_does_not_block_other_targets( + self, substrate: FakeSubstrate, wallet + ): + from dataclasses import replace + + from bittensor.intents.weights import SetWeights + from tests.harness.fake_substrate import success_result + + targets = [BOB_HOT, wallet.hotkey.ss58_address] + client = Client("local", substrate=substrate, weight_targets=targets) + substrate.seed("SubtensorModule", "Uids", [1, BOB_HOT], None) + substrate.seed("SubtensorModule", "Uids", [1, wallet.hotkey.ss58_address], 0) + substrate.queue_result( + replace( + success_result(), + events=[ + { + "event": { + "module_id": "Utility", + "event_id": "ItemCompleted", + "attributes": {}, + } + }, + { + "event": { + "module_id": "Utility", + "event_id": "BatchCompleted", + "attributes": {}, + } + }, + ], + ) + ) + + result = await client.execute(SetWeights(netuid=1, uids=[0], weights=[1.0]), wallet) + + assert result.success + assert [item["success"] for item in result.data["weight_results"]] == [False, True] + assert "not registered" in result.data["weight_results"][0]["error"] + call, _, _ = substrate.submissions[-1] + assert (call.module, call.function) == ("Utility", "force_batch") + assert len(call.params["calls"]) == 1 @pytest.mark.asyncio async def test_transient_pool_rejection_is_retried(