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/docs/query/validator-basket.mdx b/docs/query/validator-basket.mdx index 0eb76f6241..5c1f8f809e 100644 --- a/docs/query/validator-basket.mdx +++ b/docs/query/validator-basket.mdx @@ -44,6 +44,6 @@ Async is the same surface awaited: `async with bt.Subtensor() as client:`. ## On-chain implementation -- Runtime API [`BetaBasketRuntimeApi.get_validator_basket`](/code/pallets/subtensor/src/staking/basket_views.rs#L109-L117) +- Runtime API [`BetaBasketRuntimeApi.get_validator_basket`](/code/pallets/subtensor/src/staking/basket_views.rs#L155-L163) Every file is browsable under [/code](/code) exactly as built into the runtime. diff --git a/docs/tx/add-proxy.mdx b/docs/tx/add-proxy.mdx index 93aa891ede..ddf5eea6c3 100644 --- a/docs/tx/add-proxy.mdx +++ b/docs/tx/add-proxy.mdx @@ -21,7 +21,7 @@ only to keys you control or fully trust. | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `delegate_ss58` | string | yes | Key that will be allowed to sign for this account. | -| `proxy_type` | string | no | Scope of calls the delegation covers. One of: Any, Owner, NonCritical, NonTransfer, Senate, NonFungible, Triumvirate, Governance, Staking, Registration, Transfer, SmallTransfer, RootWeights, ChildKeys, SudoUncheckedSetCode, SwapHotkey, SubnetLeaseBeneficiary, RootClaim. Triumvirate, Senate, Governance, and RootWeights are deprecated on the current runtime: they deny all calls, so a proxy of those types can dispatch nothing. Prefer the narrowest type that covers your use; Any can do everything the account can, including transfers. | +| `proxy_type` | string | no | Scope of calls the delegation covers. One of: Any, Owner, NonCritical, NonTransfer, Senate, NonFungible, Triumvirate, Governance, Staking, Registration, Transfer, SmallTransfer, RootWeights, ChildKeys, SudoUncheckedSetCode, SwapHotkey, SubnetLeaseBeneficiary, RootClaim, Validate. Triumvirate, Senate, Governance, and RootWeights are deprecated on the current runtime: they deny all calls, so a proxy of those types can dispatch nothing. Prefer the narrowest type that covers your use; Any can do everything the account can, including transfers. | | `delay` | integer | no | Announcement delay in blocks: the delegate must announce each call and wait this long before executing it, giving you time to veto. 0 executes immediately. | Address parameters (`--hotkey`, `--coldkey`, `--dest`, ...) accept a raw ss58 diff --git a/docs/tx/claim-root-with-hotkey.mdx b/docs/tx/claim-root-with-hotkey.mdx index 44a70c9acc..2fc6259877 100644 --- a/docs/tx/claim-root-with-hotkey.mdx +++ b/docs/tx/claim-root-with-hotkey.mdx @@ -101,6 +101,6 @@ pub fn claim_root_with_hotkey( } ``` -Delegates to [`root_claim_fits_declared_budget`](/code/pallets/subtensor/src/staking/claim_root.rs#L837), [`do_root_claim`](/code/pallets/subtensor/src/staking/claim_root.rs#L874), [`maybe_add_coldkey_index`](/code/pallets/subtensor/src/staking/claim_root.rs#L903). +Delegates to [`root_claim_fits_declared_budget`](/code/pallets/subtensor/src/staking/claim_root.rs#L913), [`do_root_claim`](/code/pallets/subtensor/src/staking/claim_root.rs#L950), [`maybe_add_coldkey_index`](/code/pallets/subtensor/src/staking/claim_root.rs#L979). Every file is browsable under [/code](/code) exactly as built into the runtime, or as plain text under `/code/raw/` (index: [`/code/index.json`](/code/index.json)). diff --git a/docs/tx/claim-root.mdx b/docs/tx/claim-root.mdx index 0238cee055..2fccdec599 100644 --- a/docs/tx/claim-root.mdx +++ b/docs/tx/claim-root.mdx @@ -100,6 +100,6 @@ pub fn claim_root( } ``` -Delegates to [`root_claim_fits_declared_budget`](/code/pallets/subtensor/src/staking/claim_root.rs#L837), [`do_root_claim`](/code/pallets/subtensor/src/staking/claim_root.rs#L874), [`maybe_add_coldkey_index`](/code/pallets/subtensor/src/staking/claim_root.rs#L903). +Delegates to [`root_claim_fits_declared_budget`](/code/pallets/subtensor/src/staking/claim_root.rs#L913), [`do_root_claim`](/code/pallets/subtensor/src/staking/claim_root.rs#L950), [`maybe_add_coldkey_index`](/code/pallets/subtensor/src/staking/claim_root.rs#L979). Every file is browsable under [/code](/code) exactly as built into the runtime, or as plain text under `/code/raw/` (index: [`/code/index.json`](/code/index.json)). diff --git a/docs/tx/create-pure-proxy.mdx b/docs/tx/create-pure-proxy.mdx index c6fd5d12c2..35c283ce47 100644 --- a/docs/tx/create-pure-proxy.mdx +++ b/docs/tx/create-pure-proxy.mdx @@ -21,7 +21,7 @@ the pure proxy and anything it holds. | Parameter | Type | Required | Description | | --- | --- | --- | --- | -| `proxy_type` | string | no | Scope of calls the delegation covers. One of: Any, Owner, NonCritical, NonTransfer, Senate, NonFungible, Triumvirate, Governance, Staking, Registration, Transfer, SmallTransfer, RootWeights, ChildKeys, SudoUncheckedSetCode, SwapHotkey, SubnetLeaseBeneficiary, RootClaim. Triumvirate, Senate, Governance, and RootWeights are deprecated on the current runtime: they deny all calls, so a proxy of those types can dispatch nothing. Prefer the narrowest type that covers your use; Any can do everything the account can, including transfers. | +| `proxy_type` | string | no | Scope of calls the delegation covers. One of: Any, Owner, NonCritical, NonTransfer, Senate, NonFungible, Triumvirate, Governance, Staking, Registration, Transfer, SmallTransfer, RootWeights, ChildKeys, SudoUncheckedSetCode, SwapHotkey, SubnetLeaseBeneficiary, RootClaim, Validate. Triumvirate, Senate, Governance, and RootWeights are deprecated on the current runtime: they deny all calls, so a proxy of those types can dispatch nothing. Prefer the narrowest type that covers your use; Any can do everything the account can, including transfers. | | `delay` | integer | no | Announcement delay in blocks: the delegate must announce each call and wait this long before executing it, giving you time to veto. 0 executes immediately. | | `index` | integer | no | Disambiguator so one signer can create several pure proxies in one block; also part of the derived address. Keep 0 unless batching. | diff --git a/docs/tx/set-weights.mdx b/docs/tx/set-weights.mdx index 7f31dd3a6e..0a77421889 100644 --- a/docs/tx/set-weights.mdx +++ b/docs/tx/set-weights.mdx @@ -10,6 +10,11 @@ weights to the subnet's hyperparameters (max-weight clip, u16 quantization, 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/docs/tx/stake-into-basket.mdx b/docs/tx/stake-into-basket.mdx index 3007f232d1..2edcfa6d47 100644 --- a/docs/tx/stake-into-basket.mdx +++ b/docs/tx/stake-into-basket.mdx @@ -93,6 +93,6 @@ pub fn stake_into_basket( } ``` -Delegates to [`do_stake_into_basket`](/code/pallets/subtensor/src/staking/claim_root.rs#L365). +Delegates to [`do_stake_into_basket`](/code/pallets/subtensor/src/staking/claim_root.rs#L385). Every file is browsable under [/code](/code) exactly as built into the runtime, or as plain text under `/code/raw/` (index: [`/code/index.json`](/code/index.json)). diff --git a/docs/tx/unstake-all.mdx b/docs/tx/unstake-all.mdx index f8c11044d1..28839b4440 100644 --- a/docs/tx/unstake-all.mdx +++ b/docs/tx/unstake-all.mdx @@ -108,7 +108,7 @@ pub fn claim_root_with_hotkey( } ``` -Delegates to [`root_claim_fits_declared_budget`](/code/pallets/subtensor/src/staking/claim_root.rs#L837), [`do_root_claim`](/code/pallets/subtensor/src/staking/claim_root.rs#L874), [`maybe_add_coldkey_index`](/code/pallets/subtensor/src/staking/claim_root.rs#L903). +Delegates to [`root_claim_fits_declared_budget`](/code/pallets/subtensor/src/staking/claim_root.rs#L913), [`do_root_claim`](/code/pallets/subtensor/src/staking/claim_root.rs#L950), [`maybe_add_coldkey_index`](/code/pallets/subtensor/src/staking/claim_root.rs#L979). `Utility.batch_all` — [`pallets/utility/src/lib.rs#L314`](/code/pallets/utility/src/lib.rs#L308-L362): 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 d97b4172d8..b86893491b 100644 --- a/runtime/src/proxy_filters/call_groups.rs +++ b/runtime/src/proxy_filters/call_groups.rs @@ -634,6 +634,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 43d7b0ddae..d5652764df 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::>(); @@ -518,6 +522,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 2b03e92a47..6e7bff03e8 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 every mutation 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 ca1e45da11..ce7593d1d2 100644 --- a/sdk/python/bittensor/_substrate.py +++ b/sdk/python/bittensor/_substrate.py @@ -788,6 +788,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 17d9fea587..4399f78923 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 @@ -71,6 +72,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()``).""" @@ -145,6 +172,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 @@ -160,6 +188,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 @@ -179,7 +215,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 93e41ea047..6160547ed0 100644 --- a/sdk/python/bittensor/executor.py +++ b/sdk/python/bittensor/executor.py @@ -11,6 +11,7 @@ import asyncio import contextlib import inspect +import logging from dataclasses import fields as dataclass_fields from dataclasses import replace from typing import Any, Optional @@ -29,6 +30,7 @@ from .intents import build as build_intent from .intents.base import BuiltCall, IntentPreflight from .intents.proxy import check_proxy_type +from .keyfiles import Keypair from .result import ( ChainError, ExtrinsicResult, @@ -46,6 +48,8 @@ ) from .sp_core import ss58_decode +logger = logging.getLogger(__name__) + # Transaction-pool rejections that resolve themselves within a block or so (a # competing extrinsic at the same nonce, or a race against pool state). Worth # resubmitting; a fresh nonce is fetched on every attempt. @@ -288,6 +292,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 @@ -594,9 +628,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): @@ -635,6 +687,110 @@ 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: + logger.info( + "Building weights: netuid=%s mechid=%s target=1/1 hotkey=%s route=direct", + intent.netuid, + intent.mechid, + delegate, + ) + return await intent.build(self.substrate, wallet) + if not self.weight_targets: + logger.info( + "Skipping weight submission: netuid=%s mechid=%s no targets configured", + intent.netuid, + intent.mechid, + ) + 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 + route = "direct" if direct else "Validate proxy" + logger.info( + "Building weights: netuid=%s mechid=%s target=%s/%s hotkey=%s route=%s delegate=%s", + intent.netuid, + intent.mechid, + index + 1, + len(targets), + target, + route, + delegate, + ) + build_wallet = wallet if direct else _OriginView(target) + try: + built = await intent.build(self.substrate, build_wallet) + except ChainError as error: + build_errors[target] = error.message + logger.warning( + "Skipping weight target: netuid=%s mechid=%s target=%s/%s hotkey=%s " + "route=%s error=%s", + intent.netuid, + intent.mechid, + index + 1, + len(targets), + target, + route, + 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}) + logger.info( + "Built weight batch: netuid=%s mechid=%s delegate=%s submitted=%s failed=%s", + intent.netuid, + intent.mechid, + delegate, + len(submitted), + len(build_errors), + ) + call = await self.substrate.compose(generated_calls.Utility.force_batch(calls=composed)) + return BuiltCall(call, extras) + async def preflight( self, intent: Intent, @@ -725,15 +881,35 @@ 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) _origin, signer_address = _intent_accounts(self.substrate, intent, wallet, proxy_for) + 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, + ) preflight = await self._preflight( intent, wallet, @@ -753,6 +929,8 @@ async def plan( effects = list(preflight.effects) 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) violations.extend(preflight.blocks) @@ -827,6 +1005,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 @@ -842,10 +1037,38 @@ 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. - result = _with_nested_dispatch_failure(result) + if not tolerant_batch: + result = _with_nested_dispatch_failure(result) if result.success: data = dict(result.data) semantic_intent = _coerce_addresses(intent.semantic_intent()) 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 24352fcb7c..45b9071a7c 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 0762598b42..ebb00102f8 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 2502da8158..f04e71c9dd 100644 --- a/sdk/python/tests/unit/test_intents_table.py +++ b/sdk/python/tests/unit/test_intents_table.py @@ -647,6 +647,207 @@ 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, caplog + ): + 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) + + caplog.set_level("INFO", logger="bittensor.executor") + 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 + ) + messages = [record.getMessage() for record in caplog.records] + assert any(f"target=1/3 hotkey={BOB_HOT} route=Validate proxy" in msg for msg in messages) + assert any( + f"target=2/3 hotkey={wallet.hotkey.ss58_address} route=direct" in msg + for msg in messages + ) + assert any(f"target=3/3 hotkey={BOB} route=Validate proxy" in msg for msg in messages) + assert any("submitted=3 failed=0" in msg for msg in messages) + + 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, caplog + ): + 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] + caplog.set_level("WARNING", logger="bittensor.executor") + 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"] + assert any( + f"hotkey={BOB_HOT}" in record.getMessage() and "not registered" in record.getMessage() + for record in caplog.records + ) + 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 diff --git a/website/apps/bittensor-website/public/catalog/intents.json b/website/apps/bittensor-website/public/catalog/intents.json index fcf1842f1f..e11bde33b4 100644 --- a/website/apps/bittensor-website/public/catalog/intents.json +++ b/website/apps/bittensor-website/public/catalog/intents.json @@ -92,7 +92,7 @@ }, "proxy_type": { "type": "string", - "description": "Scope of calls the delegation covers. One of: Any, Owner, NonCritical, NonTransfer, Senate, NonFungible, Triumvirate, Governance, Staking, Registration, Transfer, SmallTransfer, RootWeights, ChildKeys, SudoUncheckedSetCode, SwapHotkey, SubnetLeaseBeneficiary, RootClaim. Triumvirate, Senate, Governance, and RootWeights are deprecated on the current runtime: they deny all calls, so a proxy of those types can dispatch nothing. Prefer the narrowest type that covers your use; Any can do everything the account can, including transfers." + "description": "Scope of calls the delegation covers. One of: Any, Owner, NonCritical, NonTransfer, Senate, NonFungible, Triumvirate, Governance, Staking, Registration, Transfer, SmallTransfer, RootWeights, ChildKeys, SudoUncheckedSetCode, SwapHotkey, SubnetLeaseBeneficiary, RootClaim, Validate. Triumvirate, Senate, Governance, and RootWeights are deprecated on the current runtime: they deny all calls, so a proxy of those types can dispatch nothing. Prefer the narrowest type that covers your use; Any can do everything the account can, including transfers." }, "delay": { "type": "integer", @@ -871,7 +871,7 @@ "properties": { "proxy_type": { "type": "string", - "description": "Scope of calls the delegation covers. One of: Any, Owner, NonCritical, NonTransfer, Senate, NonFungible, Triumvirate, Governance, Staking, Registration, Transfer, SmallTransfer, RootWeights, ChildKeys, SudoUncheckedSetCode, SwapHotkey, SubnetLeaseBeneficiary, RootClaim. Triumvirate, Senate, Governance, and RootWeights are deprecated on the current runtime: they deny all calls, so a proxy of those types can dispatch nothing. Prefer the narrowest type that covers your use; Any can do everything the account can, including transfers." + "description": "Scope of calls the delegation covers. One of: Any, Owner, NonCritical, NonTransfer, Senate, NonFungible, Triumvirate, Governance, Staking, Registration, Transfer, SmallTransfer, RootWeights, ChildKeys, SudoUncheckedSetCode, SwapHotkey, SubnetLeaseBeneficiary, RootClaim, Validate. Triumvirate, Senate, Governance, and RootWeights are deprecated on the current runtime: they deny all calls, so a proxy of those types can dispatch nothing. Prefer the narrowest type that covers your use; Any can do everything the account can, including transfers." }, "delay": { "type": "integer", @@ -3874,7 +3874,7 @@ { "name": "set_weights", "summary": "Set validator weights, auto-selecting plaintext or commit-reveal.", - "description": "Set validator weights, auto-selecting plaintext or commit-reveal.\n\nThe one entry point validators need for scoring miners: it conforms the\nweights to the subnet's hyperparameters (max-weight clip, u16 quantization,\nminimum weight count) and submits via whichever path the subnet runs \u2014 a\nplain `set_weights` when commit-reveal is off, or a timelock-encrypted\ncommit (auto-revealed by the chain at the drand reveal round) when it is on.\nSigned by the hotkey, which must be registered on the subnet. Before\nsigning it preflights registration and the rate limit, so those failures\nare caught fast with the same error the chain would return; the rate-limit\nerror says how many blocks to wait. The chain additionally enforces checks\nthat are not preflighted: the hotkey must hold the minimum stake to set\nweights, must hold a validator permit to set non-self weights (the subnet\nowner is exempt), and `version_key` must not be older than the subnet's\nrequired version. Prefer this over `commit_weights`/`reveal_weights`\nunless you specifically need to force one path.", + "description": "Set validator weights, auto-selecting plaintext or commit-reveal.\n\nThe one entry point validators need for scoring miners: it conforms the\nweights to the subnet's hyperparameters (max-weight clip, u16 quantization,\nminimum weight count) and submits via whichever path the subnet runs \u2014 a\nplain `set_weights` when commit-reveal is off, or a timelock-encrypted\ncommit (auto-revealed by the chain at the drand reveal round) when it is on.\nWhen the client has `weight_targets` configured, its exact combination of\nthe signing hotkey and zero-delay `Validate` delegations is submitted with\nper-target failure isolation; the chain verifies each proxy grant, and an\nempty list is a no-op.\nSubnet call sites do not change.\nSigned by the hotkey, which must be registered on the subnet. Before\nsigning it preflights registration and the rate limit, so those failures\nare caught fast with the same error the chain would return; the rate-limit\nerror says how many blocks to wait. The chain additionally enforces checks\nthat are not preflighted: the hotkey must hold the minimum stake to set\nweights, must hold a validator permit to set non-self weights (the subnet\nowner is exempt), and `version_key` must not be older than the subnet's\nrequired version. Prefer this over `commit_weights`/`reveal_weights`\nunless you specifically need to force one path.", "signer": "hotkey", "origin": "signed", "verify": null, diff --git a/website/apps/bittensor-website/public/catalog/reads.json b/website/apps/bittensor-website/public/catalog/reads.json index 134e54ce43..5be2c70200 100644 --- a/website/apps/bittensor-website/public/catalog/reads.json +++ b/website/apps/bittensor-website/public/catalog/reads.json @@ -2623,9 +2623,9 @@ "container": "BetaBasketRuntimeApi", "name": "get_validator_basket", "path": "pallets/subtensor/src/staking/basket_views.rs", - "line": 109, - "end_line": 117, - "url": "/code/pallets/subtensor/src/staking/basket_views.rs#L109-L117", + "line": 155, + "end_line": 163, + "url": "/code/pallets/subtensor/src/staking/basket_views.rs#L155-L163", "raw_url": "/code/raw/pallets/subtensor/src/staking/basket_views.rs" } ]