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
22 changes: 22 additions & 0 deletions sdk/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,28 @@ 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. Set the exact
targets through the environment: the signing hotkey is direct and every
other hotkey must have granted it a `Validate` proxy. Any mixture is
supported; an empty value disables weight submission entirely.

```console
WEIGHT_TARGETS=5F...DELEGATE,5F...VALIDATOR_A,5F...VALIDATOR_B
```

The existing subnet call remains unchanged:

```python
result = await client.execute(
bt.SetWeights(netuid=1, weights={0: 0.2, 1: 0.8}), delegate_wallet
)
```

Targets are dispatched with `Utility.force_batch`: one revoked or invalid
target is reported in `result.data["weight_results"]` without preventing the
remaining validators from setting weights. The client constructor's
`weight_targets=` option overrides the environment when supplied.

- **Atomic batch** — several intents in one all-or-nothing extrinsic:

```python
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
29 changes: 28 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,18 @@
FAST_BLOCK_TIME = 0.25


def _weight_targets_from_env() -> Optional[list[str]]:
raw = os.getenv("WEIGHT_TARGETS")
if raw is None:
return None
if not raw.strip():
return []
targets = [target.strip() for target in raw.split(",")]
if any(not target for target in targets):
raise ValueError("WEIGHT_TARGETS must be comma-separated ss58 addresses")
return targets


@dataclass
class BlockHeader:
"""A new block seen on a subscription (``client.blocks()``)."""
Expand Down Expand Up @@ -144,6 +157,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 +173,13 @@ def __init__(
``retry_forever`` connection failures never give up — the client keeps
cycling through the endpoint pool until one answers.

``weight_targets`` configures transparent multi-hotkey validation. The
signing hotkey may appear for a direct submission; every other address
must grant it a zero-delay ``Validate`` proxy. An explicit empty list
makes ``SetWeights`` a no-op. When omitted, the comma-separated
``WEIGHT_TARGETS`` environment variable is used; if that is also unset,
normal single-hotkey behavior is preserved.

``substrate`` swaps the chain-access backend: any :class:`Substrate`
implementation (e.g. an in-memory fake for tests). When set, the
connection options above don't apply — they configure the default
Expand All @@ -178,7 +199,13 @@ def __init__(
archive_endpoints=archive_endpoints,
retry_forever=retry_forever,
)
self._executor = Executor(self._substrate, policy=policy)
self._executor = Executor(
self._substrate,
policy=policy,
weight_targets=(
weight_targets if weight_targets is not None else _weight_targets_from_env()
),
)

# Typed read namespaces: projections over the read registry
# (bittensor.reads), one per category — curated methods plus every
Expand Down
Loading