diff --git a/chain-extensions/src/mock.rs b/chain-extensions/src/mock.rs index e3b7eb2e94..2c744db9c6 100644 --- a/chain-extensions/src/mock.rs +++ b/chain-extensions/src/mock.rs @@ -421,6 +421,7 @@ impl pallet_subtensor::Config for Test { type GetCommitments = (); type MaxImmuneUidsPercentage = MaxImmuneUidsPercentage; type CommitmentsInterface = CommitmentsI; + type PrecompileCleanupInterface = PrecompileCleanupI; type EvmKeyAssociateRateLimit = EvmKeyAssociateRateLimit; type AuthorshipProvider = MockAuthorshipProvider; type WeightInfo = (); @@ -463,6 +464,11 @@ impl CommitmentsInterface for CommitmentsI { fn purge_netuid(_netuid: NetUid) {} } +pub struct PrecompileCleanupI; +impl pallet_subtensor::PrecompileCleanupInterface for PrecompileCleanupI { + fn purge_netuid(_netuid: NetUid) {} +} + parameter_types! { pub MaximumSchedulerWeight: Weight = Perbill::from_percent(80) * BlockWeights::get().max_block; diff --git a/eco-tests/src/mock.rs b/eco-tests/src/mock.rs index 65cb6eac03..16a74a899e 100644 --- a/eco-tests/src/mock.rs +++ b/eco-tests/src/mock.rs @@ -306,6 +306,7 @@ impl pallet_subtensor::Config for Test { type GetCommitments = (); type MaxImmuneUidsPercentage = MaxImmuneUidsPercentage; type CommitmentsInterface = CommitmentsI; + type PrecompileCleanupInterface = PrecompileCleanupI; type EvmKeyAssociateRateLimit = EvmKeyAssociateRateLimit; type AuthorshipProvider = MockAuthorshipProvider; type WeightInfo = (); @@ -346,6 +347,11 @@ impl CommitmentsInterface for CommitmentsI { fn purge_netuid(_netuid: NetUid) {} } +pub struct PrecompileCleanupI; +impl pallet_subtensor::PrecompileCleanupInterface for PrecompileCleanupI { + fn purge_netuid(_netuid: NetUid) {} +} + parameter_types! { pub MaximumSchedulerWeight: Weight = Perbill::from_percent(80) * BlockWeights::get().max_block; diff --git a/pallets/admin-utils/src/tests/mock.rs b/pallets/admin-utils/src/tests/mock.rs index 2596d80069..157dd84b4b 100644 --- a/pallets/admin-utils/src/tests/mock.rs +++ b/pallets/admin-utils/src/tests/mock.rs @@ -230,6 +230,7 @@ impl pallet_subtensor::Config for Test { type GetCommitments = (); type MaxImmuneUidsPercentage = MaxImmuneUidsPercentage; type CommitmentsInterface = CommitmentsI; + type PrecompileCleanupInterface = PrecompileCleanupI; type EvmKeyAssociateRateLimit = EvmKeyAssociateRateLimit; type AuthorshipProvider = MockAuthorshipProvider; type WeightInfo = (); @@ -366,6 +367,11 @@ impl pallet_subtensor::CommitmentsInterface for CommitmentsI { fn purge_netuid(_netuid: NetUid) {} } +pub struct PrecompileCleanupI; +impl pallet_subtensor::PrecompileCleanupInterface for PrecompileCleanupI { + fn purge_netuid(_netuid: NetUid) {} +} + pub struct GrandpaInterfaceImpl; impl crate::GrandpaInterface for GrandpaInterfaceImpl { fn schedule_change( diff --git a/pallets/subtensor/src/coinbase/root.rs b/pallets/subtensor/src/coinbase/root.rs index ac157b2b30..4ec5379025 100644 --- a/pallets/subtensor/src/coinbase/root.rs +++ b/pallets/subtensor/src/coinbase/root.rs @@ -16,7 +16,7 @@ // DEALINGS IN THE SOFTWARE. use super::*; -use crate::CommitmentsInterface; +use crate::{CommitmentsInterface, PrecompileCleanupInterface}; use safe_math::*; use substrate_fixed::types::{I64F64, U96F32}; use subtensor_runtime_common::{AlphaBalance, NetUid, NetUidStorageIndex, TaoBalance, Token}; @@ -218,6 +218,7 @@ impl Pallet { Self::destroy_alpha_in_out_stakes(netuid)?; T::SwapInterface::clear_protocol_liquidity(netuid)?; T::CommitmentsInterface::purge_netuid(netuid); + T::PrecompileCleanupInterface::purge_netuid(netuid); // --- Remove the network Self::remove_network(netuid); @@ -273,8 +274,12 @@ impl Pallet { ValidatorPermit::::remove(netuid); ValidatorTrust::::remove(netuid); + // Strip IsNetworkMember for every hotkey on this subnet and retain a + // snapshot for the orphan-hotkey cleanup pass further down. + let mut subnet_hotkeys: Vec = Vec::with_capacity(keys.len()); for (_uid, key) in keys { - IsNetworkMember::::remove(key, netuid); + IsNetworkMember::::remove(&key, netuid); + subnet_hotkeys.push(key); } // --- 10. Erase network parameters. @@ -282,9 +287,14 @@ impl Pallet { Kappa::::remove(netuid); Difficulty::::remove(netuid); MaxAllowedUids::::remove(netuid); + MinAllowedUids::::remove(netuid); ImmunityPeriod::::remove(netuid); ActivityCutoff::::remove(netuid); MinAllowedWeights::::remove(netuid); + MaxWeightsLimit::::remove(netuid); + AdjustmentAlpha::::remove(netuid); + AdjustmentInterval::::remove(netuid); + MinNonImmuneUids::::remove(netuid); RegistrationsThisInterval::::remove(netuid); POWRegistrationsThisInterval::::remove(netuid); BurnRegistrationsThisInterval::::remove(netuid); @@ -300,6 +310,11 @@ impl Pallet { SubnetEmaTaoFlow::::remove(netuid); SubnetTaoProvided::::remove(netuid); + // --- 12. Root / emission split parameters. + RootProp::::remove(netuid); + RecycleOrBurn::::remove(netuid); + RootClaimableThreshold::::remove(netuid); + // --- 13. Token / mechanism / registration toggles. TokenSymbol::::remove(netuid); SubnetMechanism::::remove(netuid); @@ -362,12 +377,75 @@ impl Pallet { StakeWeight::::remove(netuid); LoadedEmission::::remove(netuid); + // --- 18b. Voting power. + let _ = VotingPower::::clear_prefix(netuid, u32::MAX, None); + VotingPowerTrackingEnabled::::remove(netuid); + VotingPowerDisableAtBlock::::remove(netuid); + VotingPowerEmaAlpha::::remove(netuid); + + // --- 18c. RootClaimable: outer key is hotkey, but the value is a + // BTreeMap. Strip this netuid from every entry and drop + // entries that become empty so no per-netuid state leaks past dereg. + // Note: finalize_all_subnet_root_dividends may have already stripped + // the netuid via mutate, leaving empty BTreeMaps — handle both cases. + let rc_hotkeys: sp_std::vec::Vec = + RootClaimable::::iter().map(|(hot, _)| hot).collect(); + for hot in rc_hotkeys { + let mut claimable = RootClaimable::::get(&hot); + let had_netuid = claimable.remove(&netuid).is_some(); + if claimable.is_empty() { + RootClaimable::::remove(&hot); + } else if had_netuid { + RootClaimable::::insert(&hot, claimable); + } + } + + // --- 18d. Hotkey orphan cleanup. Any hotkey that was registered on + // this subnet and has now lost its last IsNetworkMember entry is no + // longer reachable via any subnet, so drop its per-hotkey global + // bookkeeping (Owner, Delegates, OwnedHotkeys, StakingHotkeys, + // LastColdkeyHotkeyStakeBlock) to stop state piling up forever. + let mut orphans: sp_std::collections::btree_set::BTreeSet = + sp_std::collections::btree_set::BTreeSet::new(); + for hot in &subnet_hotkeys { + if IsNetworkMember::::iter_prefix(hot).next().is_none() { + orphans.insert(hot.clone()); + } + } + for hot in &orphans { + let cold = Owner::::get(hot); + Owner::::remove(hot); + Delegates::::remove(hot); + OwnedHotkeys::::mutate(&cold, |v| v.retain(|h| h != hot)); + if OwnedHotkeys::::get(&cold).is_empty() { + OwnedHotkeys::::remove(&cold); + } + } + // StakingHotkeys and LastColdkeyHotkeyStakeBlock are keyed by coldkey; + // scan all coldkeys that staked anywhere and strip references to any + // orphan hotkey. + if !orphans.is_empty() { + let staking_colds: Vec = + StakingHotkeys::::iter().map(|(c, _)| c).collect(); + for cold in staking_colds { + StakingHotkeys::::mutate(&cold, |v| v.retain(|h| !orphans.contains(h))); + if StakingHotkeys::::get(&cold).is_empty() { + StakingHotkeys::::remove(&cold); + } + for hot in &orphans { + LastColdkeyHotkeyStakeBlock::::remove(&cold, hot); + } + } + } + // --- 19. DMAPs where netuid is the FIRST key: clear by prefix. let _ = BlockAtRegistration::::clear_prefix(netuid, u32::MAX, None); let _ = Axons::::clear_prefix(netuid, u32::MAX, None); let _ = NeuronCertificates::::clear_prefix(netuid, u32::MAX, None); let _ = Prometheus::::clear_prefix(netuid, u32::MAX, None); let _ = AlphaDividendsPerSubnet::::clear_prefix(netuid, u32::MAX, None); + let _ = RootAlphaDividendsPerSubnet::::clear_prefix(netuid, u32::MAX, None); + let _ = RootClaimed::::clear_prefix((netuid,), u32::MAX, None); let _ = PendingChildKeys::::clear_prefix(netuid, u32::MAX, None); let _ = AssociatedEvmAddress::::clear_prefix(netuid, u32::MAX, None); diff --git a/pallets/subtensor/src/lib.rs b/pallets/subtensor/src/lib.rs index a1ae6cc3bb..0bb7e85a4e 100644 --- a/pallets/subtensor/src/lib.rs +++ b/pallets/subtensor/src/lib.rs @@ -2780,3 +2780,8 @@ impl ProxyInterface for () { pub trait CommitmentsInterface { fn purge_netuid(netuid: NetUid); } + +/// EVM precompile crates implement this to clean up storage when a subnet is removed. +pub trait PrecompileCleanupInterface { + fn purge_netuid(netuid: NetUid); +} diff --git a/pallets/subtensor/src/macros/config.rs b/pallets/subtensor/src/macros/config.rs index b3da63e437..280dd0a58e 100644 --- a/pallets/subtensor/src/macros/config.rs +++ b/pallets/subtensor/src/macros/config.rs @@ -6,7 +6,7 @@ use frame_support::pallet_macros::pallet_section; #[pallet_section] mod config { - use crate::{CommitmentsInterface, GetAlphaForTao, GetTaoForAlpha}; + use crate::{CommitmentsInterface, GetAlphaForTao, GetTaoForAlpha, PrecompileCleanupInterface}; use pallet_commitments::GetCommitments; use subtensor_runtime_common::AuthorshipInfo; use subtensor_swap_interface::{SwapEngine, SwapHandler}; @@ -60,6 +60,9 @@ mod config { /// Interface to clean commitments on network dissolution. type CommitmentsInterface: CommitmentsInterface; + /// Interface to clean EVM precompile storage on network dissolution. + type PrecompileCleanupInterface: PrecompileCleanupInterface; + /// Rate limit for associating an EVM key. type EvmKeyAssociateRateLimit: Get; diff --git a/pallets/subtensor/src/tests/mock.rs b/pallets/subtensor/src/tests/mock.rs index fa16b3d0f2..44fc33bd3c 100644 --- a/pallets/subtensor/src/tests/mock.rs +++ b/pallets/subtensor/src/tests/mock.rs @@ -319,6 +319,7 @@ impl crate::Config for Test { type GetCommitments = (); type MaxImmuneUidsPercentage = MaxImmuneUidsPercentage; type CommitmentsInterface = CommitmentsI; + type PrecompileCleanupInterface = PrecompileCleanupI; type EvmKeyAssociateRateLimit = EvmKeyAssociateRateLimit; type AuthorshipProvider = MockAuthorshipProvider; type WeightInfo = (); @@ -361,6 +362,11 @@ impl CommitmentsInterface for CommitmentsI { fn purge_netuid(_netuid: NetUid) {} } +pub struct PrecompileCleanupI; +impl PrecompileCleanupInterface for PrecompileCleanupI { + fn purge_netuid(_netuid: NetUid) {} +} + parameter_types! { pub MaximumSchedulerWeight: Weight = Perbill::from_percent(80) * BlockWeights::get().max_block; diff --git a/pallets/subtensor/src/tests/networks.rs b/pallets/subtensor/src/tests/networks.rs index c5107cffc5..c421e6e187 100644 --- a/pallets/subtensor/src/tests/networks.rs +++ b/pallets/subtensor/src/tests/networks.rs @@ -2684,6 +2684,137 @@ fn register_network_non_associated_hotkey_does_not_withdraw_or_write_owner_alpha }); } +#[test] +fn dissolve_network_clears_root_alpha_dividends() { + new_test_ext(0).execute_with(|| { + let cold = U256::from(1); + let hot = U256::from(2); + let net = add_dynamic_network(&hot, &cold); + + // Insert some root alpha dividend data for this subnet. + RootAlphaDividendsPerSubnet::::insert(net, hot, AlphaBalance::from(1000)); + assert!(RootAlphaDividendsPerSubnet::::contains_key(net, hot)); + + // Dissolve the network. + SubtensorModule::set_subnet_locked_balance(net, TaoBalance::from(0)); + SubnetTAO::::insert(net, TaoBalance::from(0)); + Emission::::insert(net, Vec::::new()); + assert_ok!(SubtensorModule::do_dissolve_network(net)); + + // RootAlphaDividendsPerSubnet should be cleared. + assert!( + !RootAlphaDividendsPerSubnet::::contains_key(net, hot), + "RootAlphaDividendsPerSubnet not cleaned up after network removal" + ); + }); +} + +#[test] +fn dissolve_network_clears_voting_power() { + new_test_ext(0).execute_with(|| { + let cold = U256::from(1); + let hot = U256::from(2); + let net = add_dynamic_network(&hot, &cold); + + // Insert voting power for a validator on this subnet. + VotingPower::::insert(net, hot, 9999u64); + assert!(VotingPower::::contains_key(net, hot)); + + // Dissolve the network. + SubtensorModule::set_subnet_locked_balance(net, TaoBalance::from(0)); + SubnetTAO::::insert(net, TaoBalance::from(0)); + Emission::::insert(net, Vec::::new()); + assert_ok!(SubtensorModule::do_dissolve_network(net)); + + assert!( + !VotingPower::::contains_key(net, hot), + "VotingPower not cleaned up after network removal" + ); + }); +} + +#[test] +fn dissolve_network_clears_root_claimed() { + new_test_ext(0).execute_with(|| { + let cold = U256::from(1); + let hot = U256::from(2); + let net = add_dynamic_network(&hot, &cold); + + // Simulate a recorded root claim for this subnet. + RootClaimed::::insert((net, hot, cold), 500u128); + assert!(RootClaimed::::contains_key((net, hot, cold))); + + // Dissolve the network. + SubtensorModule::set_subnet_locked_balance(net, TaoBalance::from(0)); + SubnetTAO::::insert(net, TaoBalance::from(0)); + Emission::::insert(net, Vec::::new()); + assert_ok!(SubtensorModule::do_dissolve_network(net)); + + assert!( + !RootClaimed::::contains_key((net, hot, cold)), + "RootClaimed not cleaned up after network removal" + ); + }); +} + +#[test] +fn dissolve_network_strips_netuid_from_root_claimable() { + new_test_ext(0).execute_with(|| { + let cold = U256::from(1); + let hot = U256::from(2); + let net = add_dynamic_network(&hot, &cold); + + // Another netuid that should remain in the BTreeMap after dereg. + let other_net = NetUid::from(999); + + let mut claimable: BTreeMap = BTreeMap::new(); + claimable.insert(net, I96F32::saturating_from_num(100)); + claimable.insert(other_net, I96F32::saturating_from_num(200)); + RootClaimable::::insert(hot, claimable); + + // Dissolve the network. + SubtensorModule::set_subnet_locked_balance(net, TaoBalance::from(0)); + SubnetTAO::::insert(net, TaoBalance::from(0)); + Emission::::insert(net, Vec::::new()); + assert_ok!(SubtensorModule::do_dissolve_network(net)); + + let remaining = RootClaimable::::get(hot); + assert!( + !remaining.contains_key(&net), + "RootClaimable BTreeMap still contains dissolved netuid" + ); + assert!( + remaining.contains_key(&other_net), + "RootClaimable BTreeMap lost unrelated netuid entry" + ); + }); +} + +#[test] +fn dissolve_network_drops_root_claimable_entry_when_empty() { + new_test_ext(0).execute_with(|| { + let cold = U256::from(1); + let hot = U256::from(2); + let net = add_dynamic_network(&hot, &cold); + + // Only this netuid in the hotkey's BTreeMap — entry should vanish. + let mut claimable: BTreeMap = BTreeMap::new(); + claimable.insert(net, I96F32::saturating_from_num(100)); + RootClaimable::::insert(hot, claimable); + assert!(RootClaimable::::contains_key(hot)); + + SubtensorModule::set_subnet_locked_balance(net, TaoBalance::from(0)); + SubnetTAO::::insert(net, TaoBalance::from(0)); + Emission::::insert(net, Vec::::new()); + assert_ok!(SubtensorModule::do_dissolve_network(net)); + + assert!( + !RootClaimable::::contains_key(hot), + "RootClaimable entry not dropped once its last netuid was stripped" + ); + }); +} + #[test] fn registered_subnet_counter_bumps_on_first_registration() { new_test_ext(1).execute_with(|| { @@ -2750,3 +2881,441 @@ fn registered_subnet_counter_survives_dissolve_and_bumps_on_reregistration() { ); }); } + + +// --------------------------------------------------------------------------- +// Storage-leak detection test: catches any per-subnet storage left behind +// after a full subnet lifecycle + dissolution. +// --------------------------------------------------------------------------- + +/// Walk every key in storage via `sp_io::storage::next_key`. +fn collect_all_storage_keys() -> sp_std::collections::btree_set::BTreeSet> { + let mut keys = sp_std::collections::btree_set::BTreeSet::new(); + let mut cursor = sp_std::vec::Vec::new(); + while let Some(next) = sp_io::storage::next_key(&cursor) { + keys.insert(next.clone()); + cursor = next; + } + keys +} + +/// Compute the 32-byte storage prefix `twox_128(pallet) ++ twox_128(item)`. +fn storage_item_prefix(pallet: &str, item: &str) -> [u8; 32] { + let p = sp_io::hashing::twox_128(pallet.as_bytes()); + let s = sp_io::hashing::twox_128(item.as_bytes()); + let mut out = [0u8; 32]; + out.get_mut(..16).expect("slice").copy_from_slice(&p); + out.get_mut(16..32).expect("slice").copy_from_slice(&s); + out +} + +/// Build the set of 32-byte prefixes for *global* (non-per-subnet) storage +/// items that legitimately gain or change keys during a subnet lifecycle. +/// These are NOT leaks even though they appear in the "after − before" diff. +fn build_global_allowlist() -> sp_std::collections::btree_set::BTreeSet<[u8; 32]> { + let sm = "SubtensorModule"; + [ + // Global counters / scalars that change as a side-effect. + storage_item_prefix(sm, "TotalNetworks"), + storage_item_prefix(sm, "TotalIssuance"), + storage_item_prefix(sm, "TotalStake"), + storage_item_prefix(sm, "TotalSubnetLocked"), + storage_item_prefix(sm, "NetworkLastLockCost"), + storage_item_prefix(sm, "NetworkLastRegisteredBlock"), + storage_item_prefix(sm, "NetworkRegistrationStartBlock"), + storage_item_prefix(sm, "NetworkMinLockCost"), + // Per-subnet version counter kept on purpose: makes prior-registration + // state unreachable after re-registration without unbounded iteration. + storage_item_prefix(sm, "RegisteredSubnetCounter"), + // Per-hotkey global maps (not per-subnet) that gain entries. + storage_item_prefix(sm, "StakingColdkeys"), + storage_item_prefix(sm, "StakingColdkeysByIndex"), + storage_item_prefix(sm, "NumStakingColdkeys"), + storage_item_prefix(sm, "RootClaimType"), + storage_item_prefix(sm, "HasMigrationRun"), + // Global iteration cursor / PoW anti-replay / rate limiting (not per-subnet). + storage_item_prefix(sm, "AlphaV2MapLastKey"), + storage_item_prefix(sm, "LastRateLimitedBlock"), + // Swap global state. + storage_item_prefix("Swap", "LastPositionId"), + ] + .into_iter() + .collect() +} + +/// Try to reverse-lookup a 32-byte prefix to a human-readable name. +/// Returns `"Pallet::Item"` if known, otherwise hex of the 32-byte prefix. +fn identify_storage_prefix(prefix_32: &[u8; 32]) -> alloc::string::String { + use core::fmt::Write; + + // Exhaustive list of per-subnet storage items to make error messages + // actionable. When someone adds a new map and forgets cleanup, the test + // will still fail even if it's not in this list — the name will just be + // shown as hex instead. + let known: &[(&str, &str)] = &[ + // --- SubtensorModule single maps (netuid → value) --- + ("SubtensorModule", "Tempo"), + ("SubtensorModule", "Kappa"), + ("SubtensorModule", "Difficulty"), + ("SubtensorModule", "MaxAllowedUids"), + ("SubtensorModule", "ImmunityPeriod"), + ("SubtensorModule", "ActivityCutoff"), + ("SubtensorModule", "MinAllowedWeights"), + ("SubtensorModule", "MaxWeightsLimit"), + ("SubtensorModule", "MinAllowedUids"), + ("SubtensorModule", "MinNonImmuneUids"), + ("SubtensorModule", "RegistrationsThisInterval"), + ("SubtensorModule", "POWRegistrationsThisInterval"), + ("SubtensorModule", "BurnRegistrationsThisInterval"), + ("SubtensorModule", "SubnetAlphaInEmission"), + ("SubtensorModule", "SubnetAlphaOutEmission"), + ("SubtensorModule", "SubnetTaoInEmission"), + ("SubtensorModule", "SubnetVolume"), + ("SubtensorModule", "SubnetMovingPrice"), + ("SubtensorModule", "SubnetTaoFlow"), + ("SubtensorModule", "SubnetEmaTaoFlow"), + ("SubtensorModule", "SubnetTaoProvided"), + ("SubtensorModule", "TokenSymbol"), + ("SubtensorModule", "SubnetMechanism"), + ("SubtensorModule", "SubnetOwner"), + ("SubtensorModule", "SubnetOwnerHotkey"), + ("SubtensorModule", "NetworkRegistrationAllowed"), + ("SubtensorModule", "NetworkPowRegistrationAllowed"), + ("SubtensorModule", "TransferToggle"), + ("SubtensorModule", "SubnetLocked"), + ("SubtensorModule", "LargestLocked"), + ("SubtensorModule", "FirstEmissionBlockNumber"), + ("SubtensorModule", "PendingValidatorEmission"), + ("SubtensorModule", "PendingServerEmission"), + ("SubtensorModule", "PendingRootAlphaDivs"), + ("SubtensorModule", "PendingOwnerCut"), + ("SubtensorModule", "BlocksSinceLastStep"), + ("SubtensorModule", "LastMechansimStepBlock"), + ("SubtensorModule", "LastAdjustmentBlock"), + ("SubtensorModule", "ServingRateLimit"), + ("SubtensorModule", "Rho"), + ("SubtensorModule", "AlphaSigmoidSteepness"), + ("SubtensorModule", "MaxAllowedValidators"), + ("SubtensorModule", "AdjustmentInterval"), + ("SubtensorModule", "BondsMovingAverage"), + ("SubtensorModule", "BondsPenalty"), + ("SubtensorModule", "BondsResetOn"), + ("SubtensorModule", "WeightsSetRateLimit"), + ("SubtensorModule", "ValidatorPruneLen"), + ("SubtensorModule", "ScalingLawPower"), + ("SubtensorModule", "TargetRegistrationsPerInterval"), + ("SubtensorModule", "AdjustmentAlpha"), + ("SubtensorModule", "CommitRevealWeightsEnabled"), + ("SubtensorModule", "Burn"), + ("SubtensorModule", "MinBurn"), + ("SubtensorModule", "MaxBurn"), + ("SubtensorModule", "MinDifficulty"), + ("SubtensorModule", "MaxDifficulty"), + ("SubtensorModule", "RegistrationsThisBlock"), + ("SubtensorModule", "EMAPriceHalvingBlocks"), + ("SubtensorModule", "RAORecycledForRegistration"), + ("SubtensorModule", "MaxRegistrationsPerBlock"), + ("SubtensorModule", "WeightsVersionKey"), + ("SubtensorModule", "LiquidAlphaOn"), + ("SubtensorModule", "Yuma3On"), + ("SubtensorModule", "AlphaValues"), + ("SubtensorModule", "SubtokenEnabled"), + ("SubtensorModule", "ImmuneOwnerUidsLimit"), + ("SubtensorModule", "StakeWeight"), + ("SubtensorModule", "LoadedEmission"), + ("SubtensorModule", "EffectiveRootProp"), + ("SubtensorModule", "RootProp"), + ("SubtensorModule", "RootClaimableThreshold"), + ("SubtensorModule", "RootClaimable"), + ("SubtensorModule", "RegisteredSubnetCounter"), + // Per-hotkey / per-coldkey bookkeeping that remove_network cleans up + // when a hotkey becomes orphan. Named here so any regression reports + // a readable name instead of a raw hash. + ("SubtensorModule", "Owner"), + ("SubtensorModule", "Delegates"), + ("SubtensorModule", "OwnedHotkeys"), + ("SubtensorModule", "StakingHotkeys"), + ("SubtensorModule", "LastColdkeyHotkeyStakeBlock"), + ("SubtensorModule", "UsedWork"), + ("SubtensorModule", "AlphaMapLastKey"), + ("SubtensorModule", "NetworkRegisteredAt"), + ("SubtensorModule", "SubnetworkN"), + ("SubtensorModule", "NetworksAdded"), + ("SubtensorModule", "RecycleOrBurn"), + ("SubtensorModule", "RevealPeriodEpochs"), + ("SubtensorModule", "MechanismCountCurrent"), + ("SubtensorModule", "MechanismEmissionSplit"), + ("SubtensorModule", "SubnetIdentitiesV3"), + ("SubtensorModule", "SubnetTAO"), + ("SubtensorModule", "SubnetAlphaIn"), + ("SubtensorModule", "SubnetAlphaInProvided"), + ("SubtensorModule", "SubnetAlphaOut"), + // --- SubtensorModule vectors (netuid → Vec) --- + ("SubtensorModule", "Rank"), + ("SubtensorModule", "Trust"), + ("SubtensorModule", "Active"), + ("SubtensorModule", "Emission"), + ("SubtensorModule", "Consensus"), + ("SubtensorModule", "Dividends"), + ("SubtensorModule", "PruningScores"), + ("SubtensorModule", "ValidatorPermit"), + ("SubtensorModule", "ValidatorTrust"), + ("SubtensorModule", "Incentive"), + ("SubtensorModule", "LastUpdate"), + // --- SubtensorModule double/n-maps with netuid as key --- + ("SubtensorModule", "Uids"), + ("SubtensorModule", "Keys"), + ("SubtensorModule", "Axons"), + ("SubtensorModule", "NeuronCertificates"), + ("SubtensorModule", "Prometheus"), + ("SubtensorModule", "AlphaDividendsPerSubnet"), + ("SubtensorModule", "RootAlphaDividendsPerSubnet"), + ("SubtensorModule", "PendingChildKeys"), + ("SubtensorModule", "AssociatedEvmAddress"), + ("SubtensorModule", "BlockAtRegistration"), + ("SubtensorModule", "Weights"), + ("SubtensorModule", "Bonds"), + ("SubtensorModule", "WeightCommits"), + ("SubtensorModule", "TimelockedWeightCommits"), + ("SubtensorModule", "CRV3WeightCommits"), + ("SubtensorModule", "CRV3WeightCommitsV2"), + ("SubtensorModule", "LastHotkeySwapOnNetuid"), + ("SubtensorModule", "ChildkeyTake"), + ("SubtensorModule", "ChildKeys"), + ("SubtensorModule", "ParentKeys"), + ("SubtensorModule", "LastHotkeyEmissionOnNetuid"), + ("SubtensorModule", "TotalHotkeyAlphaLastEpoch"), + ("SubtensorModule", "TransactionKeyLastBlock"), + ("SubtensorModule", "StakingOperationRateLimiter"), + ("SubtensorModule", "IsNetworkMember"), + ("SubtensorModule", "RootClaimed"), + ("SubtensorModule", "VotingPower"), + ("SubtensorModule", "VotingPowerTrackingEnabled"), + ("SubtensorModule", "VotingPowerDisableAtBlock"), + ("SubtensorModule", "VotingPowerEmaAlpha"), + ("SubtensorModule", "AutoStakeDestination"), + ("SubtensorModule", "AutoStakeDestinationColdkeys"), + ("SubtensorModule", "TotalHotkeyAlpha"), + ("SubtensorModule", "TotalHotkeyShares"), + ("SubtensorModule", "Alpha"), + ("SubtensorModule", "SubnetUidToLeaseId"), + ("SubtensorModule", "SubnetLeases"), + ("SubtensorModule", "SubnetLeaseShares"), + ("SubtensorModule", "AccumulatedLeaseDividends"), + // --- Swap pallet --- + ("Swap", "ScrapReservoirTao"), + ("Swap", "ScrapReservoirAlpha"), + ("Swap", "FeeRate"), + ("Swap", "EnabledUserLiquidity"), + ("Swap", "FeeGlobalTao"), + ("Swap", "FeeGlobalAlpha"), + ("Swap", "CurrentLiquidity"), + ("Swap", "CurrentTick"), + ("Swap", "AlphaSqrtPrice"), + ("Swap", "SwapV3Initialized"), + ("Swap", "Positions"), + ("Swap", "Ticks"), + ("Swap", "TickIndexBitmapWords"), + ]; + + for (pallet, item) in known { + if storage_item_prefix(pallet, item) == *prefix_32 { + let mut s = alloc::string::String::new(); + let _ = write!(s, "{}::{}", pallet, item); + return s; + } + } + + // Fall back: identify just the pallet. + let pallet_prefix: &[u8] = prefix_32.get(..16).expect("32 bytes"); + for pallet_name in &[ + "SubtensorModule", + "Swap", + "System", + "Balances", + "Timestamp", + "Scheduler", + "Preimage", + ] { + let hash = sp_io::hashing::twox_128(pallet_name.as_bytes()); + if pallet_prefix == hash { + let mut s = alloc::string::String::new(); + // Print the unknown storage-item hash for identification. + let item_hash = prefix_32.get(16..32).expect("32 bytes"); + let _ = write!(s, "{}::UNKNOWN(", pallet_name); + for b in item_hash { + let _ = write!(s, "{:02x}", b); + } + let _ = write!(s, ")"); + return s; + } + } + + let mut s = alloc::string::String::new(); + let _ = write!(s, "UNKNOWN_PALLET("); + for b in prefix_32 { + let _ = write!(s, "{:02x}", b); + } + let _ = write!(s, ")"); + s +} + +/// Detects per-subnet storage leaks after a full subnet lifecycle. +/// +/// This test: +/// 1. Snapshots ALL storage keys before subnet creation +/// 2. Creates a subnet, registers neurons, stakes, serves axon/prometheus, +/// sets childkeys, runs 2 epochs (to generate vpermit, bonds, dividends) +/// 3. Dissolves the subnet via root +/// 4. Snapshots ALL storage keys after dissolution +/// 5. Any key present in "after" but not "before" that belongs to the +/// SubtensorModule or Swap pallets (and is not on a global allowlist) +/// is a storage leak +/// +/// This is **future-proof**: when a developer adds a new per-netuid storage +/// map but forgets to clean it in `remove_network`, this test will catch it +/// automatically — no need to update the test. +#[test] +fn test_dissolve_network_no_storage_leak() { + new_test_ext(0).execute_with(|| { + // ==================================================== + // Phase 0: baseline snapshot (root network already exists) + // ==================================================== + let snapshot_before = collect_all_storage_keys(); + + // ==================================================== + // Phase 1: create and populate subnet + // ==================================================== + let owner_cold = U256::from(9000); + let owner_hot = U256::from(9001); + let netuid = add_dynamic_network(&owner_hot, &owner_cold); + + // Register multiple neurons + let hot1 = U256::from(3001); + let cold1 = U256::from(4001); + let hot2 = U256::from(3002); + let cold2 = U256::from(4002); + let hot3 = U256::from(3003); + let cold3 = U256::from(4003); + + MaxAllowedUids::::insert(netuid, 10); + register_ok_neuron(netuid, hot1, cold1, 100); + register_ok_neuron(netuid, hot2, cold2, 200); + register_ok_neuron(netuid, hot3, cold3, 300); + + // Stake into the subnet (writes Alpha, TotalHotkeyAlpha, TotalHotkeyShares, etc.) + SubtensorModule::add_balance_to_coldkey_account(&cold1, 1_000_000u64.into()); + SubtensorModule::add_balance_to_coldkey_account(&cold2, 1_000_000u64.into()); + increase_stake_on_coldkey_hotkey_account(&cold1, &hot1, 100_000u64.into(), netuid); + increase_stake_on_coldkey_hotkey_account(&cold2, &hot2, 50_000u64.into(), netuid); + + // Serve axon (writes Axons) + assert_ok!(SubtensorModule::serve_axon( + RuntimeOrigin::signed(hot1), + netuid, + 2, // version + 1676056785, // ip (valid IPv4) + 1234, // port + 4, // ip_type + 0, // protocol + 0, // placeholder1 + 0, // placeholder2 + )); + + // Serve prometheus (writes Prometheus) + assert_ok!(SubtensorModule::serve_prometheus( + RuntimeOrigin::signed(hot2), + netuid, + 2, // version + 1676056785, // ip (valid IPv4) + 9090, // port + 4, // ip_type + )); + + // Set childkeys (writes ChildKeys, ParentKeys) + assert_ok!(SubtensorModule::set_children( + RuntimeOrigin::signed(cold1), + hot1, + netuid, + vec![(u64::MAX, hot2)], + )); + + // Disable commit-reveal so we can set weights directly + SubtensorModule::set_commit_reveal_weights_enabled(netuid, false); + + // Set weights + let _ = SubtensorModule::set_weights( + RuntimeOrigin::signed(hot1), + netuid, + vec![0, 1, 2], + vec![u16::MAX / 3, u16::MAX / 3, u16::MAX / 3], + 0, + ); + + // Run 2 epochs to produce vpermit, bonds, dividends, incentives, etc. + step_epochs(2, netuid); + + // Seed RootClaimable so the dissolution path must strip the netuid + // from the BTreeMap hidden inside this global (hotkey → map) store. + let mut rc_map: BTreeMap = BTreeMap::new(); + rc_map.insert(netuid, I96F32::saturating_from_num(1u64)); + RootClaimable::::insert(hot1, rc_map); + + // ==================================================== + // Phase 2: dissolve the subnet + // ==================================================== + assert_ok!(SubtensorModule::do_dissolve_network(netuid)); + + // ==================================================== + // Phase 3: post-dissolution snapshot + // ==================================================== + let snapshot_after = collect_all_storage_keys(); + + // ==================================================== + // Phase 4: compute diff and find leaks + // ==================================================== + let subtensor_pallet_prefix = sp_io::hashing::twox_128(b"SubtensorModule"); + let swap_pallet_prefix = sp_io::hashing::twox_128(b"Swap"); + let allowlist = build_global_allowlist(); + + let mut leaked_names: sp_std::vec::Vec = sp_std::vec::Vec::new(); + + for key in snapshot_after.difference(&snapshot_before) { + // Only care about SubtensorModule and Swap pallets + let is_subtensor = key + .get(..16) + .map(|p| p == subtensor_pallet_prefix) + .unwrap_or(false); + let is_swap = key + .get(..16) + .map(|p| p == swap_pallet_prefix) + .unwrap_or(false); + if !is_subtensor && !is_swap { + continue; + } + + // Check the 32-byte prefix against the global allowlist + if key.len() >= 32 { + let mut prefix_32 = [0u8; 32]; + prefix_32.copy_from_slice(key.get(..32).expect("checked len")); + if allowlist.contains(&prefix_32) { + continue; + } + // This key is a leak! + let name = identify_storage_prefix(&prefix_32); + if !leaked_names.contains(&name) { + leaked_names.push(name); + } + } + } + + assert!( + leaked_names.is_empty(), + "Storage leak detected after dissolving subnet {netuid:?}!\n\ + The following storage items have keys that were not present before subnet creation\n\ + but remain after dissolution:\n - {}\n\n\ + Fix: add cleanup for these items in remove_network() or the dissolution path.", + leaked_names.join("\n - ") + ); + }); +} diff --git a/pallets/swap/src/pallet/impls.rs b/pallets/swap/src/pallet/impls.rs index 4386eae4bb..a78ff0ca0e 100644 --- a/pallets/swap/src/pallet/impls.rs +++ b/pallets/swap/src/pallet/impls.rs @@ -1023,6 +1023,8 @@ impl Pallet { let _ = TickIndexBitmapWords::::clear_prefix((netuid,), u32::MAX, None); FeeRate::::remove(netuid); EnabledUserLiquidity::::remove(netuid); + ScrapReservoirTao::::remove(netuid); + ScrapReservoirAlpha::::remove(netuid); log::debug!( "clear_protocol_liquidity: netuid={netuid:?}, protocol_burned: τ={burned_tao:?}, α={burned_alpha:?}; state cleared" diff --git a/pallets/transaction-fee/src/tests/mock.rs b/pallets/transaction-fee/src/tests/mock.rs index a897e82cb6..85e66898d8 100644 --- a/pallets/transaction-fee/src/tests/mock.rs +++ b/pallets/transaction-fee/src/tests/mock.rs @@ -302,6 +302,7 @@ impl pallet_subtensor::Config for Test { type GetCommitments = (); type MaxImmuneUidsPercentage = MaxImmuneUidsPercentage; type CommitmentsInterface = CommitmentsI; + type PrecompileCleanupInterface = PrecompileCleanupI; type EvmKeyAssociateRateLimit = EvmKeyAssociateRateLimit; type AuthorshipProvider = MockAuthorshipProvider; type WeightInfo = (); @@ -438,6 +439,11 @@ impl pallet_subtensor::CommitmentsInterface for CommitmentsI { fn purge_netuid(_netuid: NetUid) {} } +pub struct PrecompileCleanupI; +impl pallet_subtensor::PrecompileCleanupInterface for PrecompileCleanupI { + fn purge_netuid(_netuid: NetUid) {} +} + parameter_types! { pub MaximumSchedulerWeight: Weight = Perbill::from_percent(80) * BlockWeights::get().max_block; diff --git a/precompiles/src/lib.rs b/precompiles/src/lib.rs index a824ac39d4..2633de7405 100644 --- a/precompiles/src/lib.rs +++ b/precompiles/src/lib.rs @@ -38,7 +38,7 @@ pub use metagraph::MetagraphPrecompile; pub use neuron::NeuronPrecompile; pub use proxy::ProxyPrecompile; pub use sr25519::Sr25519Verify; -pub use staking::{StakingPrecompile, StakingPrecompileV2}; +pub use staking::{StakingPrecompile, StakingPrecompileV2, purge_netuid_allowances}; pub use storage_query::StorageQueryPrecompile; pub use subnet::SubnetPrecompile; pub use uid_lookup::UidLookupPrecompile; @@ -55,7 +55,7 @@ mod metagraph; mod neuron; mod proxy; mod sr25519; -mod staking; +pub mod staking; mod storage_query; mod subnet; mod uid_lookup; @@ -285,6 +285,16 @@ where } } +/// Implementation of [`pallet_subtensor::PrecompileCleanupInterface`] that cleans up +/// EVM precompile storage (e.g. staking allowances) when a subnet is deregistered. +pub struct PrecompileCleanup; + +impl pallet_subtensor::PrecompileCleanupInterface for PrecompileCleanup { + fn purge_netuid(netuid: subtensor_runtime_common::NetUid) { + purge_netuid_allowances(netuid.into()); + } +} + fn hash(a: u64) -> H160 { H160::from_low_u64_be(a) } diff --git a/precompiles/src/staking.rs b/precompiles/src/staking.rs index 3392de468e..e1a30643f8 100644 --- a/precompiles/src/staking.rs +++ b/precompiles/src/staking.rs @@ -75,6 +75,22 @@ pub type AllowancesStorage = StorageDoubleMap< ValueQuery, >; +/// Remove all AllowancesStorage entries whose key contains the given netuid. +pub fn purge_netuid_allowances(netuid: u16) { + let to_remove: Vec<(H160, (H160, u16))> = AllowancesStorage::iter() + .filter_map(|(approver, (spender, n), _)| { + if n == netuid { + Some((approver, (spender, n))) + } else { + None + } + }) + .collect(); + for (approver, key) in to_remove { + AllowancesStorage::remove(approver, key); + } +} + // Old StakingPrecompile had ETH-precision in values, which was not alligned with Substrate API. So // it's kinda deprecated, but exists for backward compatibility. Eventually, we should remove it // to stop supporting both precompiles. @@ -916,3 +932,32 @@ fn try_u64_from_u256(value: U256) -> Result { exit_status: ExitError::Other("the value is outside of u64 bounds".into()), }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn purge_netuid_allowances_removes_only_target_netuid() { + sp_io::TestExternalities::default().execute_with(|| { + let approver = H160::from_low_u64_be(1); + let spender = H160::from_low_u64_be(2); + let netuid_a: u16 = 5; + let netuid_b: u16 = 7; + + AllowancesStorage::insert(approver, (spender, netuid_a), U256::from(100)); + AllowancesStorage::insert(approver, (spender, netuid_b), U256::from(200)); + + purge_netuid_allowances(netuid_a); + + assert_eq!( + AllowancesStorage::get(approver, (spender, netuid_a)), + U256::zero(), + ); + assert_eq!( + AllowancesStorage::get(approver, (spender, netuid_b)), + U256::from(200), + ); + }); + } +} diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index ed6d4d6176..3080b7f697 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -1199,6 +1199,7 @@ impl pallet_subtensor::Config for Runtime { type GetCommitments = GetCommitmentsStruct; type MaxImmuneUidsPercentage = MaxImmuneUidsPercentage; type CommitmentsInterface = CommitmentsI; + type PrecompileCleanupInterface = subtensor_precompiles::PrecompileCleanup; type EvmKeyAssociateRateLimit = EvmKeyAssociateRateLimit; type AuthorshipProvider = BlockAuthorFromAura; type WeightInfo = pallet_subtensor::weights::SubstrateWeight;