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/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/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/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/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 89745d42d8..5615113c89 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -271,6 +271,29 @@ 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. The signing + hotkey is direct and every other target must have granted it a `Validate` + proxy. Targets supplied by subnet code through the client constructor are + merged with `WEIGHT_TARGETS`, so validator operators can add targets without + changing the subnet. Duplicates are removed while constructor order is + preserved; an empty merged set disables weight submission entirely. + + ```console + WEIGHT_TARGETS=5F...DELEGATE,5F...VALIDATOR_A,5F...VALIDATOR_B + ``` + + The existing subnet call remains unchanged: + + ```python + 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. + - **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 b6007f629f..9fac93e969 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,32 @@ 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 + + +def _merge_weight_targets( + configured: Optional[list[str]], environment: Optional[list[str]] +) -> Optional[list[str]]: + if configured is None and environment is None: + return None + if configured is not None and not isinstance(configured, list): + raise TypeError("weight_targets must be a list of hotkey addresses") + merged = [] + for target in (configured or []) + (environment or []): + if target not in merged: + merged.append(target) + return merged + + @dataclass class BlockHeader: """A new block seen on a subscription (``client.blocks()``).""" @@ -144,6 +171,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 +187,14 @@ 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. These targets are merged + with the comma-separated ``WEIGHT_TARGETS`` environment variable, with + duplicates removed and constructor order preserved. If the merged set + is empty, ``SetWeights`` is a no-op; if both sources are omitted, 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 connection options above don't apply — they configure the default @@ -178,7 +214,11 @@ 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=_merge_weight_targets(weight_targets, _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 25341dbbb8..96e057d1dd 100644 --- a/sdk/python/bittensor/executor.py +++ b/sdk/python/bittensor/executor.py @@ -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,26 @@ ) +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 _is_transient(result: ExtrinsicResult) -> bool: message = (result.message or "").lower() return any(needle in message for needle in _TRANSIENT_SUBSTRINGS) @@ -101,7 +122,10 @@ async def _compose_intent_call( ) -> tuple[Any, dict]: """Compose semantic call -> sudo -> proxy -> execution adapter.""" semantic = _coerce_addresses(intent.semantic_intent()) - built = await semantic.build(substrate, wallet) + build_wallet = ( + _ProxyBuildWallet(wallet, semantic.signer, proxy_for) if proxy_for is not None else wallet + ) + built = await semantic.build(substrate, build_wallet) if isinstance(built, BuiltCall): call, extras = built.call, built.extras else: @@ -148,6 +172,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 @@ -454,9 +508,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): @@ -488,6 +560,69 @@ 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 weights for the exact hotkey list configured on this client. + + 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. + """ + if self.weight_targets is None: + return await intent.build(self.substrate, wallet) + if not self.weight_targets: + return BuiltCall( + None, + { + "weight_targets": [], + "submitted_weight_targets": [], + "weight_build_errors": {}, + "no_op": True, + }, + ) + + targets = self.weight_targets + composed = [] + 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) + 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 + build_extras.update( + {f"target:{index}.{key}": value for key, value in built.extras.items()} + ) + else: + inner = built + if direct: + composed.append(inner) + else: + composed.append( + await self.substrate.compose( + generated_calls.Proxy.proxy( + real=target, force_proxy_type="Validate", call=inner + ) + ) + ) + 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( self, intent: Intent, @@ -507,15 +642,42 @@ async def plan( """ wallet = as_wallet(wallet) intent = _coerce_addresses(intent) - call, extras = await _compose_intent_call( - self.substrate, - intent, - wallet, - proxy_for=proxy_for, - proxy_type=proxy_type, - ) pub = self._public_keypair(wallet, intent.signer) signer_address = pub.ss58_address + if intent.op == "set_weights" and proxy_for is None: + built = await self._build_validate_weights(intent, wallet, signer_address) + if isinstance(built, BuiltCall): + call, extras = built.call, built.extras + else: + call, extras = built, {} + else: + call, extras = await _compose_intent_call( + self.substrate, + intent, + wallet, + proxy_for=proxy_for, + proxy_type=proxy_type, + ) + 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, + ) + if intent.op == "set_weights" and proxy_for is None: + call = await _wrap_root_call(self.substrate, intent, call) + wrapped = await intent.wrap_call(self.substrate, wallet, call) + if isinstance(wrapped, BuiltCall): + call, extras = wrapped.call, {**extras, **wrapped.extras} + else: + call = wrapped # The account whose state the call actually touches. origin = proxy_for or signer_address @@ -531,6 +693,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("weight_targets"): + effects.append("weight targets: " + ", ".join(extras["weight_targets"])) violations = self._violations(intent, fee, policy) return Plan( @@ -604,6 +768,23 @@ 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 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) attempts = max(0, int(retries)) + 1 @@ -619,10 +800,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/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/intents/weights.py b/sdk/python/bittensor/intents/weights.py index c154b336db..304340e275 100644 --- a/sdk/python/bittensor/intents/weights.py +++ b/sdk/python/bittensor/intents/weights.py @@ -322,6 +322,11 @@ 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. + When the client has ``weight_targets`` configured, its exact combination of + 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/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/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 1c91cdede7..8377bbc85b 100644 --- a/sdk/python/tests/unit/test_intents_table.py +++ b/sdk/python/tests/unit/test_intents_table.py @@ -322,6 +322,193 @@ 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_set_weights_transparently_uses_validate_proxies( + self, 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) + + 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) + substrate.seed_default("SubtensorModule", "CommitRevealWeightsEnabled", True) + result = await client.execute(SetWeights(netuid=1, uids=[0], weights=[1.0]), wallet) + + assert result.success + assert len(substrate.submissions) == 1 + assert encrypted_for == [ + 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", "force_batch") + 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( + child.params["call"].function == "commit_timelocked_mechanism_weights" + for child in proxied + ) + + def test_client_merges_constructor_and_environment_weight_targets( + self, substrate: FakeSubstrate, wallet, monkeypatch + ): + monkeypatch.setenv("WEIGHT_TARGETS", f"{BOB_HOT},{BOB}") + + client = Client( + "local", + substrate=substrate, + weight_targets=[wallet.hotkey.ss58_address, BOB_HOT], + ) + + assert client._executor.weight_targets == [ + wallet.hotkey.ss58_address, + BOB_HOT, + BOB, + ] + + @pytest.mark.asyncio + async def test_set_weights_empty_target_list_is_noop( + self, substrate: FakeSubstrate, wallet, monkeypatch + ): + from bittensor.intents.weights import SetWeights + + 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"] == [] + assert result.data["weight_results"] == [] + assert substrate.submissions == [] + + @pytest.mark.asyncio + 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 tests.harness.fake_substrate import success_result + + 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": {}, + } + }, + ], + ) + ) + + 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( self, client: Client, substrate: FakeSubstrate, wallet