code/runtime/src/proxy_filters/mod.rs

mod.rs

659 lines · 24,907 bytes · 19a6485969RawGitHub
mod call_groups;

use alloc::{format, vec::Vec};

use call_groups::*;
use frame_support::traits::{Contains, InstanceFilter};
use subtensor_runtime_common::{
    CallFilterMetadata, FilterMode, ProxyFilterInfo, ProxyType, ProxyTypeInfo,
};

use crate::RuntimeCall;

// ============================================================================
// Per-proxy allow-lists
//
// Each proxy type's permission set is an *additive* union of whole call groups
// from `call_groups`. A call a proxy does not list is denied. `Any` allows
// everything; the deprecated proxies allow nothing.
//
// `Contains` for a tuple is logical OR (any member matches), so these aliases
// read as "allow if the call is in any of these groups".
// ============================================================================

/// All admin-utils configuration. Every broad proxy historically allowed every
/// admin call; the root-only (`RootConfigCalls`) and owner-key (`OwnerKeyCalls`)
/// calls are gated by the dispatch's own origin check, so granting them to a
/// signed proxy is inert.
type AdminAll = (SubnetManagementCalls, RootConfigCalls, OwnerKeyCalls);

/// `Transfer`: liquid value movement.
type TransferAllowed = (BalanceTransferCalls, StakeTransferCalls);

/// `Staking`: stake position management plus root-claim mode selection.
type StakingAllowed = (StakeManagementCalls, RootClaimTypeCalls);

/// `Registration`: acquire a slot (POW or by burn).
type RegistrationAllowed = (PowRegistrationCalls, BurnedRegistrationCalls);

/// `Owner`: run a subnet you own — subnet identity plus the owner-settable
/// admin config. Excludes root-only admin (it can't pass `ensure_root`) and
/// owner-key rotation.
type OwnerAllowed = (SubnetIdentityCalls, SubnetManagementCalls);

/// `SubnetLeaseBeneficiary`: operate a leased subnet (activation, identity, and
/// the owner-settable subnet management config).
type SubnetLeaseAllowed = (
    SubnetActivationCalls,
    SubnetIdentityCalls,
    SubnetManagementCalls,
);

/// `NonTransfer`: everything except liquid value movement and coldkey swaps.
type NonTransferAllowed = (
    InfraCommonCalls,
    AdminAll,
    SudoCalls,
    StakeManagementCalls,
    PowRegistrationCalls,
    BurnedRegistrationCalls,
    FaucetCalls,
    RootRegistrationCalls,
    HotkeySwapCalls,
    CriticalNetworkCalls,
    ChildKeyCalls,
    RootClaimCalls,
    RootClaimTypeCalls,
    SubnetIdentityCalls,
    SubnetActivationCalls,
    SubtensorCommonCalls,
);

/// `NonFungible`: nothing that moves TAO/alpha and no key swaps.
type NonFungibleAllowed = (
    InfraCommonCalls,
    AdminAll,
    SudoCalls,
    PowRegistrationCalls,
    FaucetCalls,
    CriticalNetworkCalls,
    ChildKeyCalls,
    RootClaimCalls,
    RootClaimTypeCalls,
    SubnetIdentityCalls,
    SubnetActivationCalls,
    SubtensorCommonCalls,
);

/// `NonCritical`: day-to-day operations including value movement, but no sudo,
/// network dissolution, root/burned registration, or coldkey swaps.
type NonCriticalAllowed = (
    InfraCommonCalls,
    AdminAll,
    BalanceTransferCalls,
    BalanceMaintenanceCalls,
    StakeManagementCalls,
    StakeTransferCalls,
    PowRegistrationCalls,
    FaucetCalls,
    HotkeySwapCalls,
    ChildKeyCalls,
    RootClaimCalls,
    RootClaimTypeCalls,
    SubnetIdentityCalls,
    SubnetActivationCalls,
    SubtensorCommonCalls,
);

pub(crate) fn proxy_type_filter(proxy_type: &ProxyType, call: &RuntimeCall) -> bool {
    match proxy_type {
        ProxyType::Any => true,
        ProxyType::Owner => OwnerAllowed::contains(call),
        ProxyType::NonCritical => NonCriticalAllowed::contains(call),
        ProxyType::NonTransfer => NonTransferAllowed::contains(call),
        ProxyType::NonFungible => NonFungibleAllowed::contains(call),
        ProxyType::Staking => StakingAllowed::contains(call),
        ProxyType::Registration => RegistrationAllowed::contains(call),
        ProxyType::Transfer => TransferAllowed::contains(call),
        ProxyType::SmallTransfer => SmallTransferCalls::contains(call),
        ProxyType::ChildKeys => ChildKeyCalls::contains(call),
        ProxyType::SwapHotkey => HotkeySwapCalls::contains(call),
        ProxyType::SubnetLeaseBeneficiary => SubnetLeaseAllowed::contains(call),
        ProxyType::RootClaim => RootClaimCalls::contains(call),
        ProxyType::SudoUncheckedSetCode => SudoSetCodeCalls::contains(call),
        ProxyType::Triumvirate
        | ProxyType::Senate
        | ProxyType::Governance
        | ProxyType::RootWeights => false,
    }
}

impl InstanceFilter<RuntimeCall> for ProxyType {
    fn filter(&self, call: &RuntimeCall) -> bool {
        proxy_type_filter(self, call)
    }

    fn is_superset(&self, other: &Self) -> bool {
        match (self, other) {
            (x, y) if x == y => true,
            (ProxyType::Any, _) => true,
            (_, ProxyType::Any) => false,
            // Keep this positive list explicit. A future proxy type that can
            // move value must not become addable through `NonTransfer` by
            // default.
            (
                ProxyType::NonTransfer,
                ProxyType::Owner
                | ProxyType::Senate
                | ProxyType::NonFungible
                | ProxyType::Triumvirate
                | ProxyType::Governance
                | ProxyType::Staking
                | ProxyType::Registration
                | ProxyType::RootWeights
                | ProxyType::ChildKeys
                | ProxyType::SudoUncheckedSetCode
                | ProxyType::SwapHotkey
                | ProxyType::SubnetLeaseBeneficiary
                | ProxyType::RootClaim,
            ) => true,
            (ProxyType::Transfer, ProxyType::SmallTransfer) => true,
            _ => false,
        }
    }
}

// ============================================================================
// Runtime API metadata
//
// The client-facing allowlist view is derived from the same call groups the
// filter uses, so the two cannot drift.
// ============================================================================

/// The filter mode (allow-all or an explicit allowlist) for one proxy type.
fn proxy_filter_mode(proxy_type: ProxyType) -> FilterMode {
    match proxy_type {
        ProxyType::Any => FilterMode::AllowAll,
        ProxyType::Owner => FilterMode::Allow(OwnerAllowed::call_infos()),
        ProxyType::NonCritical => FilterMode::Allow(NonCriticalAllowed::call_infos()),
        ProxyType::NonTransfer => FilterMode::Allow(NonTransferAllowed::call_infos()),
        ProxyType::NonFungible => FilterMode::Allow(NonFungibleAllowed::call_infos()),
        ProxyType::Staking => FilterMode::Allow(StakingAllowed::call_infos()),
        ProxyType::Registration => FilterMode::Allow(RegistrationAllowed::call_infos()),
        ProxyType::Transfer => FilterMode::Allow(TransferAllowed::call_infos()),
        ProxyType::SmallTransfer => FilterMode::Allow(SmallTransferCalls::call_infos()),
        ProxyType::ChildKeys => FilterMode::Allow(ChildKeyCalls::call_infos()),
        ProxyType::SwapHotkey => FilterMode::Allow(HotkeySwapCalls::call_infos()),
        ProxyType::SubnetLeaseBeneficiary => FilterMode::Allow(SubnetLeaseAllowed::call_infos()),
        ProxyType::RootClaim => FilterMode::Allow(RootClaimCalls::call_infos()),
        ProxyType::SudoUncheckedSetCode => FilterMode::Allow(SudoSetCodeCalls::call_infos()),
        ProxyType::Triumvirate
        | ProxyType::Senate
        | ProxyType::Governance
        | ProxyType::RootWeights => FilterMode::Allow(Vec::new()),
    }
}

/// Every proxy type with its on-chain index and deprecation flag.
pub fn get_all_proxy_type_infos() -> Vec<ProxyTypeInfo> {
    (0u8..=u8::MAX)
        .filter_map(|index| {
            ProxyType::try_from(index)
                .ok()
                .map(|proxy_type| ProxyTypeInfo {
                    name: format!("{:?}", proxy_type).into_bytes(),
                    index,
                    deprecated: proxy_type.is_deprecated(),
                })
        })
        .collect()
}

/// Filter metadata for the requested proxy types (all of them when `None`).
pub fn get_proxy_filters(proxy_types: Option<Vec<u8>>) -> Vec<ProxyFilterInfo> {
    (0u8..=u8::MAX)
        .filter_map(|index| {
            ProxyType::try_from(index)
                .ok()
                .map(|proxy_type| (index, proxy_type))
        })
        .filter(|(index, _)| {
            proxy_types
                .as_ref()
                .is_none_or(|selected| selected.contains(index))
        })
        .map(|(index, proxy_type)| ProxyFilterInfo {
            proxy_type: index,
            name: format!("{:?}", proxy_type).into_bytes(),
            deprecated: proxy_type.is_deprecated(),
            filter_mode: proxy_filter_mode(proxy_type),
        })
        .collect()
}

#[cfg(test)]
mod tests {
    #![allow(clippy::unwrap_used)]

    use super::*;
    use alloc::{
        collections::BTreeSet,
        string::{String, ToString},
        vec,
    };
    use frame_support::traits::GetCallMetadata;
    use subtensor_runtime_common::CallInfo;

    fn call_name(info: &CallInfo) -> String {
        format!(
            "{}::{}",
            String::from_utf8_lossy(&info.pallet_name),
            String::from_utf8_lossy(&info.call_name)
        )
    }

    /// All `pallet::call` names in the runtime, straight from `RuntimeCall`
    /// metadata.
    fn all_runtime_calls() -> BTreeSet<String> {
        RuntimeCall::get_module_names()
            .iter()
            .flat_map(|module| {
                RuntimeCall::get_call_names(module)
                    .iter()
                    .map(move |call| format!("{}::{}", module, call))
            })
            .collect()
    }

    fn group_calls<G: CallFilterMetadata>() -> BTreeSet<String> {
        G::call_infos().iter().map(call_name).collect()
    }

    /// The set of calls a proxy type allows, taken from its metadata view.
    fn allowed_calls(proxy_type: ProxyType) -> BTreeSet<String> {
        match proxy_filter_mode(proxy_type) {
            FilterMode::AllowAll => all_runtime_calls(),
            FilterMode::Allow(infos) => infos.iter().map(call_name).collect(),
        }
    }

    fn expected(calls: &[&str]) -> BTreeSet<String> {
        calls.iter().map(|c| c.to_string()).collect()
    }

    fn all_proxy_types() -> Vec<ProxyType> {
        (0u8..=u8::MAX)
            .filter_map(|index| ProxyType::try_from(index).ok())
            .collect()
    }

    #[test]
    fn any_allows_everything_and_deprecated_allow_nothing() {
        assert_eq!(allowed_calls(ProxyType::Any), all_runtime_calls());
        for deprecated in [
            ProxyType::Triumvirate,
            ProxyType::Senate,
            ProxyType::Governance,
            ProxyType::RootWeights,
        ] {
            assert!(allowed_calls(deprecated).is_empty());
        }
    }

    // Broad proxies are specified subtractively here (all calls minus a few
    // denied groups) and checked against the additive composition in the filter.
    // Because the inventory groups partition every runtime call, the two must
    // agree exactly; a missing or extra group in the filter shows up as a diff.
    #[test]
    fn non_transfer_is_everything_but_transfers_and_coldkey_swaps() {
        let denied = &(&group_calls::<BalanceTransferCalls>()
            | &group_calls::<BalanceMaintenanceCalls>())
            | &(&group_calls::<StakeTransferCalls>() | &group_calls::<ColdkeySwapCalls>());
        assert_eq!(
            allowed_calls(ProxyType::NonTransfer),
            &all_runtime_calls() - &denied
        );
    }

    #[test]
    fn non_fungible_is_everything_but_value_movement_and_key_swaps() {
        let denied = &(&(&group_calls::<BalanceTransferCalls>()
            | &group_calls::<BalanceMaintenanceCalls>())
            | &(&group_calls::<StakeManagementCalls>() | &group_calls::<StakeTransferCalls>()))
            | &(&(&group_calls::<BurnedRegistrationCalls>()
                | &group_calls::<RootRegistrationCalls>())
                | &(&group_calls::<HotkeySwapCalls>() | &group_calls::<ColdkeySwapCalls>()));
        assert_eq!(
            allowed_calls(ProxyType::NonFungible),
            &all_runtime_calls() - &denied
        );
    }

    #[test]
    fn non_critical_is_everything_but_sudo_and_critical_ops() {
        let denied = &(&(&group_calls::<SudoCalls>() | &group_calls::<BurnedRegistrationCalls>())
            | &(&group_calls::<RootRegistrationCalls>() | &group_calls::<CriticalNetworkCalls>()))
            | &group_calls::<ColdkeySwapCalls>();
        assert_eq!(
            allowed_calls(ProxyType::NonCritical),
            &all_runtime_calls() - &denied
        );
    }

    #[test]
    fn superset_relations_match_allowed_call_sets() {
        let proxy_types = all_proxy_types();
        let mut violations = Vec::new();

        for parent in &proxy_types {
            let parent_calls = allowed_calls(*parent);
            for child in &proxy_types {
                if !parent.is_superset(child) {
                    continue;
                }

                let child_calls = allowed_calls(*child);
                let missing = child_calls
                    .difference(&parent_calls)
                    .cloned()
                    .collect::<Vec<_>>();

                if !missing.is_empty() {
                    violations.push(format!(
                        "{:?}.is_superset({:?}) is missing:\n{}",
                        parent,
                        child,
                        missing
                            .iter()
                            .map(|call| format!("  {}", call))
                            .collect::<Vec<_>>()
                            .join("\n")
                    ));
                }
            }
        }

        assert!(
            violations.is_empty(),
            "is_superset claims proxy permissions that the parent filter does not allow:\n{}",
            violations.join("\n\n")
        );
    }

    #[test]
    fn non_transfer_superset_is_explicit_allowlist() {
        let actual = all_proxy_types()
            .into_iter()
            .filter(|proxy_type| ProxyType::NonTransfer.is_superset(proxy_type))
            .collect::<BTreeSet<_>>();
        let expected = [
            ProxyType::Owner,
            ProxyType::NonTransfer,
            ProxyType::Senate,
            ProxyType::NonFungible,
            ProxyType::Triumvirate,
            ProxyType::Governance,
            ProxyType::Staking,
            ProxyType::Registration,
            ProxyType::RootWeights,
            ProxyType::ChildKeys,
            ProxyType::SudoUncheckedSetCode,
            ProxyType::SwapHotkey,
            ProxyType::SubnetLeaseBeneficiary,
            ProxyType::RootClaim,
        ]
        .into_iter()
        .collect::<BTreeSet<_>>();

        assert_eq!(actual, expected);
    }

    #[test]
    fn owner_allows_only_owner_settable_config() {
        let owner = allowed_calls(ProxyType::Owner);
        // Owner-settable subnet params + subnet identity.
        assert!(owner.contains("AdminUtils::sudo_set_serving_rate_limit"));
        assert!(owner.contains("AdminUtils::sudo_set_max_difficulty"));
        assert!(owner.contains("SubtensorModule::set_subnet_identity"));
        // Canonical owner-or-root tempo control lives in AdminUtils; deprecated
        // Subtensor entry points remain available for encoded-call compatibility.
        assert!(owner.contains("AdminUtils::sudo_set_tempo"));
        assert!(owner.contains("AdminUtils::sudo_set_activity_cutoff_factor"));
        assert!(owner.contains("SubtensorModule::set_tempo"));
        assert!(owner.contains("SubtensorModule::set_activity_cutoff_factor"));
        // Root-only admin is not owner-settable (gated by `ensure_root`).
        assert!(!owner.contains("AdminUtils::sudo_set_kappa"));
        assert!(!owner.contains("AdminUtils::sudo_set_total_issuance"));
        assert!(!owner.contains("AdminUtils::swap_authorities"));
        // Never owner-key rotation.
        assert!(!owner.contains("AdminUtils::sudo_set_sn_owner_hotkey"));
        // Exactly subnet identity plus the owner-settable management config.
        let expected =
            &group_calls::<SubnetIdentityCalls>() | &group_calls::<SubnetManagementCalls>();
        assert_eq!(owner, expected);
    }

    #[test]
    fn subnet_lease_boundaries() {
        let lease = allowed_calls(ProxyType::SubnetLeaseBeneficiary);
        // Can activate and tune the subnet's owner-settable params...
        assert!(lease.contains("SubtensorModule::start_call"));
        assert!(lease.contains("SubtensorModule::set_subnet_identity"));
        assert!(lease.contains("AdminUtils::sudo_set_serving_rate_limit"));
        // ...but not root-only params, owner keys, authorities, or lease teardown.
        assert!(!lease.contains("AdminUtils::sudo_set_kappa"));
        assert!(!lease.contains("AdminUtils::sudo_set_total_issuance"));
        assert!(!lease.contains("AdminUtils::sudo_set_sn_owner_hotkey"));
        assert!(!lease.contains("AdminUtils::swap_authorities"));
        assert!(!lease.contains("SubtensorModule::terminate_lease"));
    }

    #[test]
    fn narrow_proxies_have_exact_allow_lists() {
        assert_eq!(
            allowed_calls(ProxyType::Transfer),
            expected(&[
                "Balances::transfer_keep_alive",
                "Balances::transfer_allow_death",
                "Balances::transfer_all",
                "SubtensorModule::transfer_stake",
            ])
        );
        assert_eq!(
            allowed_calls(ProxyType::SmallTransfer),
            expected(&[
                "Balances::transfer_keep_alive",
                "Balances::transfer_allow_death",
                "SubtensorModule::transfer_stake",
            ])
        );
        assert_eq!(
            allowed_calls(ProxyType::Staking),
            expected(&[
                "SubtensorModule::add_stake",
                "SubtensorModule::add_stake_limit",
                "SubtensorModule::remove_stake",
                "SubtensorModule::remove_stake_limit",
                "SubtensorModule::remove_stake_full_limit",
                "SubtensorModule::unstake_all",
                "SubtensorModule::unstake_all_alpha",
                "SubtensorModule::move_stake",
                "SubtensorModule::swap_stake",
                "SubtensorModule::swap_stake_limit",
                "SubtensorModule::set_root_claim_type",
            ])
        );
        assert_eq!(
            allowed_calls(ProxyType::Registration),
            expected(&[
                "SubtensorModule::register",
                "SubtensorModule::register_limit",
                "SubtensorModule::burned_register",
            ])
        );
        assert_eq!(
            allowed_calls(ProxyType::ChildKeys),
            expected(&[
                "SubtensorModule::set_children",
                "SubtensorModule::set_childkey_take",
            ])
        );
        assert_eq!(
            allowed_calls(ProxyType::SwapHotkey),
            expected(&[
                "SubtensorModule::swap_hotkey",
                "SubtensorModule::swap_hotkey_v2",
            ])
        );
        assert_eq!(
            allowed_calls(ProxyType::RootClaim),
            expected(&["SubtensorModule::claim_root"])
        );
        assert_eq!(
            allowed_calls(ProxyType::SudoUncheckedSetCode),
            expected(&["Sudo::sudo_unchecked_weight"])
        );
    }

    // The newer calls that leaked through `main`'s denylists must stay denied
    // for every broad proxy.
    #[test]
    fn tightened_denylist_leaks_stay_denied() {
        for proxy_type in [
            ProxyType::NonTransfer,
            ProxyType::NonFungible,
            ProxyType::NonCritical,
        ] {
            let allowed = allowed_calls(proxy_type);
            assert!(!allowed.contains("SubtensorModule::reset_coldkey_swap"));
            assert!(!allowed.contains("SubtensorModule::swap_coldkey"));
            assert!(!allowed.contains("SubtensorModule::schedule_swap_coldkey"));
        }
        // `root_dissolve_network` leaked into NonCritical specifically.
        assert!(
            !allowed_calls(ProxyType::NonCritical)
                .contains("SubtensorModule::root_dissolve_network")
        );
    }

    // The SmallTransfer / SudoUncheckedSetCode metadata must carry their
    // amount / nested-call constraints.
    #[test]
    fn conditional_proxies_expose_constraints() {
        use subtensor_runtime_common::CallConstraint;

        let small = match proxy_filter_mode(ProxyType::SmallTransfer) {
            FilterMode::Allow(infos) => infos,
            FilterMode::AllowAll => vec![],
        };
        assert!(
            small
                .iter()
                .all(|info| matches!(info.constraint, Some(CallConstraint::ParamLessThan { .. })))
        );

        let set_code = match proxy_filter_mode(ProxyType::SudoUncheckedSetCode) {
            FilterMode::Allow(infos) => infos,
            FilterMode::AllowAll => vec![],
        };
        assert!(set_code.iter().any(|info| matches!(
            &info.constraint,
            Some(CallConstraint::NestedCallMustBe { pallet_name, call_name, .. })
            if pallet_name == b"System" && call_name == b"set_code"
        )));
    }

    // The name-based golden tests above don't exercise the amount / nested-call
    // predicates, so check them directly through the filter.
    #[test]
    fn small_transfer_enforces_amount_limits() {
        use frame_system::Call as SystemCall;
        use pallet_balances::Call as BalancesCall;
        use pallet_subtensor::Call as SubtensorCall;
        use subtensor_runtime_common::{
            AccountId, AlphaBalance, NetUid, SMALL_ALPHA_TRANSFER_LIMIT, SMALL_TRANSFER_LIMIT,
            TaoBalance,
        };

        let dest = AccountId::new([2u8; 32]);

        let balance_transfer = |value: TaoBalance| {
            RuntimeCall::Balances(BalancesCall::transfer_allow_death {
                dest: dest.clone().into(),
                value,
            })
        };
        let stake_transfer = |alpha_amount: AlphaBalance| {
            RuntimeCall::SubtensorModule(SubtensorCall::transfer_stake {
                destination_coldkey: dest.clone(),
                hotkey: dest.clone(),
                origin_netuid: NetUid::from(1),
                destination_netuid: NetUid::from(1),
                alpha_amount,
            })
        };

        // Strictly-below the limit is allowed; at the limit is denied.
        assert!(proxy_type_filter(
            &ProxyType::SmallTransfer,
            &balance_transfer(TaoBalance::from(1))
        ));
        assert!(!proxy_type_filter(
            &ProxyType::SmallTransfer,
            &balance_transfer(SMALL_TRANSFER_LIMIT)
        ));
        assert!(proxy_type_filter(
            &ProxyType::SmallTransfer,
            &stake_transfer(AlphaBalance::from(1))
        ));
        assert!(!proxy_type_filter(
            &ProxyType::SmallTransfer,
            &stake_transfer(SMALL_ALPHA_TRANSFER_LIMIT)
        ));

        // A non-transfer call is never a small transfer.
        let remark = RuntimeCall::System(SystemCall::remark { remark: vec![] });
        assert!(!proxy_type_filter(&ProxyType::SmallTransfer, &remark));

        // `Transfer` is unconditional: the at-limit amount still passes.
        assert!(proxy_type_filter(
            &ProxyType::Transfer,
            &balance_transfer(SMALL_TRANSFER_LIMIT)
        ));
    }

    #[test]
    fn sudo_unchecked_set_code_only_matches_set_code() {
        use alloc::boxed::Box;
        use frame_support::weights::Weight;
        use frame_system::Call as SystemCall;
        use pallet_sudo::Call as SudoCall;

        let unchecked = |inner: RuntimeCall| {
            RuntimeCall::Sudo(SudoCall::sudo_unchecked_weight {
                call: Box::new(inner),
                weight: Weight::zero(),
            })
        };
        let set_code = RuntimeCall::System(SystemCall::set_code { code: vec![] });
        let remark = RuntimeCall::System(SystemCall::remark { remark: vec![] });

        // Allowed only when wrapping `System::set_code`.
        assert!(proxy_type_filter(
            &ProxyType::SudoUncheckedSetCode,
            &unchecked(set_code.clone())
        ));
        assert!(!proxy_type_filter(
            &ProxyType::SudoUncheckedSetCode,
            &unchecked(remark)
        ));
        // `Sudo::sudo` (checked) never matches, even wrapping set_code.
        let checked = RuntimeCall::Sudo(SudoCall::sudo {
            call: Box::new(set_code),
        });
        assert!(!proxy_type_filter(
            &ProxyType::SudoUncheckedSetCode,
            &checked
        ));
    }
}