From 7ac0548a89b4fe78eee5387d45f509f605295911 Mon Sep 17 00:00:00 2001 From: plind-dm <59729252+plind-dm@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:31:42 +0900 Subject: [PATCH 1/8] fix: clear RootAlphaDividendsPerSubnet on network removal RootAlphaDividendsPerSubnet is not cleaned up when a subnet is dissolved. Since netuids are recycled (subnet.rs:178-186), stale root dividend entries from the old subnet persist and become visible to the new subnet, potentially affecting run_coinbase dividend calculations. Add clear_prefix for RootAlphaDividendsPerSubnet alongside the existing AlphaDividendsPerSubnet cleanup in remove_network. Closes #1867 --- pallets/subtensor/src/coinbase/root.rs | 3 + pallets/subtensor/src/tests/networks.rs | 73 +++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/pallets/subtensor/src/coinbase/root.rs b/pallets/subtensor/src/coinbase/root.rs index a4f3c0df8c..413a503ce7 100644 --- a/pallets/subtensor/src/coinbase/root.rs +++ b/pallets/subtensor/src/coinbase/root.rs @@ -368,6 +368,9 @@ impl Pallet { 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 _ = VotingPower::::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/tests/networks.rs b/pallets/subtensor/src/tests/networks.rs index 216c256a75..c389bebac6 100644 --- a/pallets/subtensor/src/tests/networks.rs +++ b/pallets/subtensor/src/tests/networks.rs @@ -2632,3 +2632,76 @@ 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" + ); + }); +} From 0010253ab4e4f93de4cb8a7eb2922829e8ad8716 Mon Sep 17 00:00:00 2001 From: plind-dm <59729252+plind-dm@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:03:05 +0900 Subject: [PATCH 2/8] fix: clean up AllowancesStorage on subnet deregistration Add PrecompileCleanupInterface trait to allow the pallet to invoke cross-crate storage cleanup when a subnet is removed. The precompiles crate iterates and removes all AllowancesStorage entries matching the dissolved netuid, preventing stale EVM approvals from carrying over when the netuid is recycled. --- pallets/subtensor/src/coinbase/root.rs | 1 + pallets/subtensor/src/lib.rs | 5 +++ pallets/subtensor/src/macros/config.rs | 5 ++- pallets/subtensor/src/tests/mock.rs | 6 ++++ precompiles/src/lib.rs | 2 +- precompiles/src/staking.rs | 45 ++++++++++++++++++++++++++ runtime/src/lib.rs | 10 +++++- 7 files changed, 71 insertions(+), 3 deletions(-) diff --git a/pallets/subtensor/src/coinbase/root.rs b/pallets/subtensor/src/coinbase/root.rs index 413a503ce7..61bfeb260b 100644 --- a/pallets/subtensor/src/coinbase/root.rs +++ b/pallets/subtensor/src/coinbase/root.rs @@ -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); diff --git a/pallets/subtensor/src/lib.rs b/pallets/subtensor/src/lib.rs index 3c56cebfa2..21086f6d10 100644 --- a/pallets/subtensor/src/lib.rs +++ b/pallets/subtensor/src/lib.rs @@ -2747,3 +2747,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/precompiles/src/lib.rs b/precompiles/src/lib.rs index a824ac39d4..c0203a77d7 100644 --- a/precompiles/src/lib.rs +++ b/precompiles/src/lib.rs @@ -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; diff --git a/precompiles/src/staking.rs b/precompiles/src/staking.rs index 30d28aaa13..752b23df60 100644 --- a/precompiles/src/staking.rs +++ b/precompiles/src/staking.rs @@ -74,6 +74,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. @@ -895,3 +911,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 a077185341..626c4f4bce 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -41,7 +41,7 @@ use pallet_subtensor::rpc_info::{ stake_info::StakeInfo, subnet_info::{SubnetHyperparams, SubnetHyperparamsV2, SubnetInfo, SubnetInfov2}, }; -use pallet_subtensor::{CommitmentsInterface, ProxyInterface}; +use pallet_subtensor::{CommitmentsInterface, PrecompileCleanupInterface, ProxyInterface}; use pallet_subtensor_proxy as pallet_proxy; use pallet_subtensor_swap_runtime_api::{SimSwapResult, SubnetPrice}; use pallet_subtensor_utility as pallet_utility; @@ -878,6 +878,13 @@ impl CommitmentsInterface for CommitmentsI { } } +pub struct PrecompileCleanupI; +impl PrecompileCleanupInterface for PrecompileCleanupI { + fn purge_netuid(netuid: NetUid) { + subtensor_precompiles::staking::purge_netuid_allowances(>::from(netuid)); + } +} + parameter_types! { pub MaximumSchedulerWeight: Weight = Perbill::from_percent(80) * BlockWeights::get().max_block; @@ -1199,6 +1206,7 @@ impl pallet_subtensor::Config for Runtime { type GetCommitments = GetCommitmentsStruct; type MaxImmuneUidsPercentage = MaxImmuneUidsPercentage; type CommitmentsInterface = CommitmentsI; + type PrecompileCleanupInterface = PrecompileCleanupI; type EvmKeyAssociateRateLimit = EvmKeyAssociateRateLimit; type AuthorshipProvider = BlockAuthorFromAura; type WeightInfo = pallet_subtensor::weights::SubstrateWeight; From 2ebeb54d2284d3983d6de9a102af9107f24e471b Mon Sep 17 00:00:00 2001 From: plind-dm <59729252+plind-dm@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:04:38 +0900 Subject: [PATCH 3/8] style: fix rustfmt formatting --- runtime/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 626c4f4bce..bbcc3676b0 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -881,7 +881,9 @@ impl CommitmentsInterface for CommitmentsI { pub struct PrecompileCleanupI; impl PrecompileCleanupInterface for PrecompileCleanupI { fn purge_netuid(netuid: NetUid) { - subtensor_precompiles::staking::purge_netuid_allowances(>::from(netuid)); + subtensor_precompiles::staking::purge_netuid_allowances(>::from( + netuid, + )); } } From 7a8fbabfc07779a45f0b482b80ea48d891ba49cc Mon Sep 17 00:00:00 2001 From: plind-dm <59729252+plind-dm@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:11:32 +0900 Subject: [PATCH 4/8] fix: add missing orphaned storage cleanup and mock wiring Add PrecompileCleanupInterface no-op to all test mocks (chain-extensions, eco-tests, admin-utils, transaction-fee). Clean up additional orphaned storage items on subnet deregistration: MinAllowedUids, MaxWeightsLimit, AdjustmentAlpha, AdjustmentInterval, MinNonImmuneUids, RootProp, RecycleOrBurn, RootClaimableThreshold, VotingPower, VotingPowerTrackingEnabled, VotingPowerDisableAtBlock, VotingPowerEmaAlpha. Move PrecompileCleanup struct into the precompiles crate for cleaner wiring. --- chain-extensions/src/mock.rs | 6 ++++++ eco-tests/src/mock.rs | 6 ++++++ pallets/admin-utils/src/tests/mock.rs | 6 ++++++ pallets/subtensor/src/coinbase/root.rs | 18 +++++++++++++++++- pallets/transaction-fee/src/tests/mock.rs | 6 ++++++ precompiles/src/lib.rs | 12 +++++++++++- runtime/src/lib.rs | 13 ++----------- 7 files changed, 54 insertions(+), 13 deletions(-) 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 61bfeb260b..09f3329568 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}; @@ -283,9 +283,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); @@ -301,6 +306,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); @@ -363,6 +373,12 @@ 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); + // --- 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); 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 c0203a77d7..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; @@ -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/runtime/src/lib.rs b/runtime/src/lib.rs index bbcc3676b0..7cdedb9dfa 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -41,7 +41,7 @@ use pallet_subtensor::rpc_info::{ stake_info::StakeInfo, subnet_info::{SubnetHyperparams, SubnetHyperparamsV2, SubnetInfo, SubnetInfov2}, }; -use pallet_subtensor::{CommitmentsInterface, PrecompileCleanupInterface, ProxyInterface}; +use pallet_subtensor::{CommitmentsInterface, ProxyInterface}; use pallet_subtensor_proxy as pallet_proxy; use pallet_subtensor_swap_runtime_api::{SimSwapResult, SubnetPrice}; use pallet_subtensor_utility as pallet_utility; @@ -878,15 +878,6 @@ impl CommitmentsInterface for CommitmentsI { } } -pub struct PrecompileCleanupI; -impl PrecompileCleanupInterface for PrecompileCleanupI { - fn purge_netuid(netuid: NetUid) { - subtensor_precompiles::staking::purge_netuid_allowances(>::from( - netuid, - )); - } -} - parameter_types! { pub MaximumSchedulerWeight: Weight = Perbill::from_percent(80) * BlockWeights::get().max_block; @@ -1208,7 +1199,7 @@ impl pallet_subtensor::Config for Runtime { type GetCommitments = GetCommitmentsStruct; type MaxImmuneUidsPercentage = MaxImmuneUidsPercentage; type CommitmentsInterface = CommitmentsI; - type PrecompileCleanupInterface = PrecompileCleanupI; + type PrecompileCleanupInterface = subtensor_precompiles::PrecompileCleanup; type EvmKeyAssociateRateLimit = EvmKeyAssociateRateLimit; type AuthorshipProvider = BlockAuthorFromAura; type WeightInfo = pallet_subtensor::weights::SubstrateWeight; From 3a0cfc00e4641365122a40a21c8d6035a8d2038f Mon Sep 17 00:00:00 2001 From: plind-dm Date: Mon, 13 Apr 2026 00:09:41 +0900 Subject: [PATCH 5/8] fix: query stake via contract in approval test before hook Alpha storage was migrated to AlphaV2 (share-based) in January. The test's before hook was still calling api.query.SubtensorModule.Alpha.getValue, which now returns 0 for new stake and caused the stakeAfter > stakeBefore assertion to fail. Use contract.getStake on both sides instead, since that's what the rest of the suite does and it exercises the precompile directly. --- contract-tests/test/staking.precompile.approval.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/contract-tests/test/staking.precompile.approval.test.ts b/contract-tests/test/staking.precompile.approval.test.ts index 372e1ac661..82b99de083 100644 --- a/contract-tests/test/staking.precompile.approval.test.ts +++ b/contract-tests/test/staking.precompile.approval.test.ts @@ -57,17 +57,17 @@ describe("Test approval in staking precompile", () => { stakeNetuid = (await api.query.SubtensorModule.TotalNetworks.getValue()) - 1 // the unit in V2 is RAO, not ETH let stakeBalance = tao(20) - const stakeBefore = await api.query.SubtensorModule.Alpha.getValue(convertPublicKeyToSs58(hotkey.publicKey), convertH160ToSS58(wallet1.address), stakeNetuid) const contract = new ethers.Contract(ISTAKING_V2_ADDRESS, IStakingV2ABI, wallet1); + const stakeBefore = BigInt( + await contract.getStake(hotkey.publicKey, convertH160ToPublicKey(wallet1.address), stakeNetuid) + ); const tx = await contract.addStake(hotkey.publicKey, stakeBalance.toString(), stakeNetuid) await tx.wait() - const stakeFromContract = BigInt( + const stakeAfter = BigInt( await contract.getStake(hotkey.publicKey, convertH160ToPublicKey(wallet1.address), stakeNetuid) ); - assert.ok(stakeFromContract > stakeBefore) - const stakeAfter = await api.query.SubtensorModule.Alpha.getValue(convertPublicKeyToSs58(hotkey.publicKey), convertH160ToSS58(wallet1.address), stakeNetuid) assert.ok(stakeAfter > stakeBefore) } }) From 8ea6f5753bd1d6a37b252c6ddcb6b7bbc82d19e3 Mon Sep 17 00:00:00 2001 From: plind-dm Date: Mon, 20 Apr 2026 20:49:08 +0900 Subject: [PATCH 6/8] fix: strip per-netuid state from RootClaimable on subnet dissolution RootClaimable is keyed by hotkey but stores BTreeMap as its value, so a plain StorageMap walk misses the per-subnet state hiding inside each entry. After dereg the stripped netuid could linger (and empty BTreeMap entries could pile up) because finalize_all_subnet_root_ dividends uses mutate, which keeps zero-value entries under ValueQuery. Add a cleanup step in remove_network that removes the dissolved netuid from every hotkey's BTreeMap and drops the outer entry entirely when the map becomes empty, regardless of whether this pass or the earlier finalize call was the one to strip the netuid. Co-Authored-By: Claude Opus 4.7 --- pallets/subtensor/src/coinbase/root.rs | 17 ++++++++ pallets/subtensor/src/tests/networks.rs | 58 +++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/pallets/subtensor/src/coinbase/root.rs b/pallets/subtensor/src/coinbase/root.rs index c40485170e..444b10e946 100644 --- a/pallets/subtensor/src/coinbase/root.rs +++ b/pallets/subtensor/src/coinbase/root.rs @@ -379,6 +379,23 @@ impl Pallet { 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); + } + } + // --- 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); diff --git a/pallets/subtensor/src/tests/networks.rs b/pallets/subtensor/src/tests/networks.rs index 61e545a164..42170beb83 100644 --- a/pallets/subtensor/src/tests/networks.rs +++ b/pallets/subtensor/src/tests/networks.rs @@ -2757,6 +2757,64 @@ fn dissolve_network_clears_root_claimed() { }); } +#[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(|| { From 5828c5fd06ff7fb3e882c8f2319585c5945cafa1 Mon Sep 17 00:00:00 2001 From: Pawel Polewicz Date: Tue, 10 Feb 2026 06:06:16 +0000 Subject: [PATCH 7/8] Add storage leak detection test for subnet dissolution Introduces test_dissolve_network_no_storage_leak which automatically detects per-subnet storage that is not cleaned up when a subnet is dissolved. The test: 1. Snapshots ALL raw storage keys before subnet creation 2. Creates a subnet, registers neurons, stakes, serves axon/prometheus, sets childkeys, sets weights, and runs 2 epochs 3. Dissolves the subnet via root 4. Snapshots ALL storage keys after dissolution 5. Diffs the snapshots, filtering to SubtensorModule and Swap pallets, excluding known global storage items This is future-proof: when a developer adds a new per-netuid StorageMap but forgets cleanup in remove_network, this test fails automatically with a clear error message naming the leaked storage item. Also fixes Swap::ScrapReservoirTao and Swap::ScrapReservoirAlpha not being cleaned up in do_clear_protocol_liquidity. Co-Authored-By: Claude Opus 4.6 --- pallets/subtensor/src/coinbase/root.rs | 4 + pallets/subtensor/src/tests/networks.rs | 425 ++++++++++++++++++++++++ pallets/swap/src/pallet/impls.rs | 2 + 3 files changed, 431 insertions(+) diff --git a/pallets/subtensor/src/coinbase/root.rs b/pallets/subtensor/src/coinbase/root.rs index 444b10e946..7276b3ac06 100644 --- a/pallets/subtensor/src/coinbase/root.rs +++ b/pallets/subtensor/src/coinbase/root.rs @@ -396,6 +396,10 @@ impl Pallet { } } + // --- 18d. Root prop. + RootProp::::remove(netuid); + RootClaimableThreshold::::remove(netuid); + // --- 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); diff --git a/pallets/subtensor/src/tests/networks.rs b/pallets/subtensor/src/tests/networks.rs index 42170beb83..7ff2e85374 100644 --- a/pallets/subtensor/src/tests/networks.rs +++ b/pallets/subtensor/src/tests/networks.rs @@ -2881,3 +2881,428 @@ 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-hotkey global maps (not per-subnet) that gain entries. + storage_item_prefix(sm, "Owner"), + storage_item_prefix(sm, "Delegates"), + storage_item_prefix(sm, "OwnedHotkeys"), + storage_item_prefix(sm, "StakingHotkeys"), + storage_item_prefix(sm, "StakingColdkeys"), + storage_item_prefix(sm, "StakingColdkeysByIndex"), + storage_item_prefix(sm, "NumStakingColdkeys"), + storage_item_prefix(sm, "RootClaimable"), + storage_item_prefix(sm, "RootClaimType"), + storage_item_prefix(sm, "LastColdkeyHotkeyStakeBlock"), + storage_item_prefix(sm, "HasMigrationRun"), + // Global iteration cursor / PoW anti-replay / rate limiting (not per-subnet). + storage_item_prefix(sm, "AlphaMapLastKey"), + storage_item_prefix(sm, "AlphaV2MapLastKey"), + storage_item_prefix(sm, "UsedWork"), + 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", "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); + + // ==================================================== + // 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" From a39804fc012d76e555cdcfb2e1cf4bedf7f48e01 Mon Sep 17 00:00:00 2001 From: plind-dm Date: Wed, 22 Apr 2026 00:56:03 +0900 Subject: [PATCH 8/8] fix: drop orphan hotkey state and tighten subnet leak allowlist Extend subnet dissolution cleanup to drop per-hotkey global bookkeeping (Owner, Delegates, OwnedHotkeys, StakingHotkeys, LastColdkeyHotkeyStakeBlock) for hotkeys that lose their last IsNetworkMember entry when the subnet goes away. Without this, a hotkey registered only on a dissolved subnet leaves orphan entries forever. Tighten the leak-detection allowlist in the dissolution test to match: drop Owner/Delegates/OwnedHotkeys/StakingHotkeys/RootClaimable/ LastColdkeyHotkeyStakeBlock/UsedWork/AlphaMapLastKey, and add RegisteredSubnetCounter (the one intentional per-subnet counter). Seed RootClaimable with a per-netuid entry in the test lifecycle so the BTreeMap-hidden per-subnet state is exercised. Deduplicate VotingPower::clear_prefix (was in both 18b and 19) and RootProp/RootClaimableThreshold removals (already handled in section 12). Co-Authored-By: Claude Opus 4.7 --- pallets/subtensor/src/coinbase/root.rs | 47 ++++++++++++++++++++++--- pallets/subtensor/src/tests/networks.rs | 29 ++++++++++----- 2 files changed, 63 insertions(+), 13 deletions(-) diff --git a/pallets/subtensor/src/coinbase/root.rs b/pallets/subtensor/src/coinbase/root.rs index 7276b3ac06..4ec5379025 100644 --- a/pallets/subtensor/src/coinbase/root.rs +++ b/pallets/subtensor/src/coinbase/root.rs @@ -274,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. @@ -396,9 +400,43 @@ impl Pallet { } } - // --- 18d. Root prop. - RootProp::::remove(netuid); - RootClaimableThreshold::::remove(netuid); + // --- 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); @@ -407,7 +445,6 @@ impl Pallet { 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 _ = VotingPower::::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/tests/networks.rs b/pallets/subtensor/src/tests/networks.rs index 7ff2e85374..c421e6e187 100644 --- a/pallets/subtensor/src/tests/networks.rs +++ b/pallets/subtensor/src/tests/networks.rs @@ -2924,22 +2924,17 @@ fn build_global_allowlist() -> sp_std::collections::btree_set::BTreeSet<[u8; 32] 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, "Owner"), - storage_item_prefix(sm, "Delegates"), - storage_item_prefix(sm, "OwnedHotkeys"), - storage_item_prefix(sm, "StakingHotkeys"), storage_item_prefix(sm, "StakingColdkeys"), storage_item_prefix(sm, "StakingColdkeysByIndex"), storage_item_prefix(sm, "NumStakingColdkeys"), - storage_item_prefix(sm, "RootClaimable"), storage_item_prefix(sm, "RootClaimType"), - storage_item_prefix(sm, "LastColdkeyHotkeyStakeBlock"), storage_item_prefix(sm, "HasMigrationRun"), // Global iteration cursor / PoW anti-replay / rate limiting (not per-subnet). - storage_item_prefix(sm, "AlphaMapLastKey"), storage_item_prefix(sm, "AlphaV2MapLastKey"), - storage_item_prefix(sm, "UsedWork"), storage_item_prefix(sm, "LastRateLimitedBlock"), // Swap global state. storage_item_prefix("Swap", "LastPositionId"), @@ -3031,6 +3026,18 @@ fn identify_storage_prefix(prefix_32: &[u8; 32]) -> alloc::string::String { ("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"), @@ -3248,6 +3255,12 @@ fn test_dissolve_network_no_storage_leak() { // 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 // ====================================================