Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion clones/js-tests/tests/test-proxy-filter-security-regressions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand All @@ -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(
Expand Down
14 changes: 14 additions & 0 deletions common/src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ pub enum ProxyType {
SwapHotkey,
SubnetLeaseBeneficiary,
RootClaim,
Validate,
}

impl TryFrom<u8> for ProxyType {
Expand All @@ -67,6 +68,7 @@ impl TryFrom<u8> for ProxyType {
15 => Ok(Self::SwapHotkey),
16 => Ok(Self::SubnetLeaseBeneficiary),
17 => Ok(Self::RootClaim),
18 => Ok(Self::Validate),
_ => Err(()),
}
}
Expand All @@ -93,6 +95,7 @@ impl From<ProxyType> for u8 {
ProxyType::SwapHotkey => 15,
ProxyType::SubnetLeaseBeneficiary => 16,
ProxyType::RootClaim => 17,
ProxyType::Validate => 18,
}
}
}
Expand All @@ -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 {
Expand Down
19 changes: 19 additions & 0 deletions pallets/utility/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(|| {
Expand Down
23 changes: 23 additions & 0 deletions runtime/src/proxy_filters/call_groups.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
27 changes: 26 additions & 1 deletion runtime/src/proxy_filters/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -152,7 +153,8 @@ impl InstanceFilter<RuntimeCall> for ProxyType {
| ProxyType::SudoUncheckedSetCode
| ProxyType::SwapHotkey
| ProxyType::SubnetLeaseBeneficiary
| ProxyType::RootClaim,
| ProxyType::RootClaim
| ProxyType::Validate,
) => true,
(ProxyType::Transfer, ProxyType::SmallTransfer) => true,
_ => false,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -398,6 +401,7 @@ mod tests {
ProxyType::SwapHotkey,
ProxyType::SubnetLeaseBeneficiary,
ProxyType::RootClaim,
ProxyType::Validate,
]
.into_iter()
.collect::<BTreeSet<_>>();
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions runtime/src/transaction_payment_wrapper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(|| {
Expand Down
23 changes: 23 additions & 0 deletions sdk/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,29 @@ These compose with any intent:
On the CLI: `--proxy-for <ss58|wallet>` 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
Expand Down
2 changes: 2 additions & 0 deletions sdk/python/bittensor/_substrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)

Expand Down
42 changes: 41 additions & 1 deletion sdk/python/bittensor/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()``)."""
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading