From 5e934b6e8ccc121f49adeb0e3094c23bc4547498 Mon Sep 17 00:00:00 2001 From: "RUNE.CTZ" Date: Sun, 10 May 2026 20:38:16 -0700 Subject: [PATCH 1/2] fix(coinbase): clear AutoStakeDestination on subnet dissolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AutoStakeDestination` and its reverse index `AutoStakeDestinationColdkeys` both embed `netuid` as the second key of a `StorageDoubleMap`, but neither is cleaned up by `remove_network` during subnet dissolution. Because netuids are recycled by subsequent `do_register_network` calls, a stale `(coldkey, dissolved_netuid) → hotkey` mapping silently survives and applies to the *next* subnet registered on that netuid. The misdirection surfaces in `coinbase::run_coinbase` (auto-stake path), where the lookup `AutoStakeDestination::::get(&owner, netuid)` returns the stale destination from the previous occupant of that netuid. Mining incentive on the newly-registered subnet is then auto-staked to a hotkey that has no relationship to the new subnet. Add two `iter().filter_map().remove()` blocks in the `--- 21.` section of `remove_network`, matching the existing pattern used for the other DMaps that embed netuid as a non-leading key (ChildkeyTake, ChildKeys, ParentKeys, LastHotkeyEmissionOnNetuid, TotalHotkeyAlphaLastEpoch, StakingOperationRateLimiter). Extends `dissolve_clears_all_per_subnet_storages` to cover both maps and adds a focused regression `dissolve_clears_auto_stake_destination_preventing_stale_routing` that exercises the actual user-facing routing hazard. No new dependencies, no API changes, no migration required. --- pallets/subtensor/src/coinbase/root.rs | 22 ++++++++++++ pallets/subtensor/src/tests/networks.rs | 45 +++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/pallets/subtensor/src/coinbase/root.rs b/pallets/subtensor/src/coinbase/root.rs index b2926323db..2394de6dc1 100644 --- a/pallets/subtensor/src/coinbase/root.rs +++ b/pallets/subtensor/src/coinbase/root.rs @@ -475,6 +475,28 @@ impl Pallet { StakingOperationRateLimiter::::remove((hot, cold, netuid)); } } + // AutoStakeDestination: (cold, netuid) → hot. Without this cleanup, a + // stale destination from a dissolved subnet would silently redirect + // mining incentive when the same netuid is later re-registered (see + // `run_coinbase` auto-stake path). + { + let to_rm: sp_std::vec::Vec = AutoStakeDestination::::iter() + .filter_map(|(cold, n, _)| if n == netuid { Some(cold) } else { None }) + .collect(); + for cold in to_rm { + AutoStakeDestination::::remove(&cold, netuid); + } + } + // AutoStakeDestinationColdkeys: (hot, netuid) → Vec. Companion + // reverse-index to AutoStakeDestination; must be cleared in lockstep. + { + let to_rm: sp_std::vec::Vec = AutoStakeDestinationColdkeys::::iter() + .filter_map(|(hot, n, _)| if n == netuid { Some(hot) } else { None }) + .collect(); + for hot in to_rm { + AutoStakeDestinationColdkeys::::remove(&hot, netuid); + } + } // --- 22. Subnet leasing: remove mapping and any lease-scoped state linked to this netuid. if let Some(lease_id) = SubnetUidToLeaseId::::take(netuid) { diff --git a/pallets/subtensor/src/tests/networks.rs b/pallets/subtensor/src/tests/networks.rs index c4efc75825..c18584e39d 100644 --- a/pallets/subtensor/src/tests/networks.rs +++ b/pallets/subtensor/src/tests/networks.rs @@ -461,6 +461,10 @@ fn dissolve_clears_all_per_subnet_storages() { // EVM association indexed by (netuid, uid) AssociatedEvmAddress::::insert(net, 0u16, (sp_core::H160::zero(), 1u64)); + // Auto-stake destination (cold,netuid) -> hot + reverse index + AutoStakeDestination::::insert(owner_cold, net, owner_hot); + AutoStakeDestinationColdkeys::::mutate(owner_hot, net, |v| v.push(owner_cold)); + // (Optional) subnet -> lease link SubnetUidToLeaseId::::insert(net, 42u32); @@ -626,6 +630,10 @@ fn dissolve_clears_all_per_subnet_storages() { // Subnet -> lease link assert!(!SubnetUidToLeaseId::::contains_key(net)); + // Auto-stake destination + reverse index cleared + assert!(AutoStakeDestination::::get(owner_cold, net).is_none()); + assert!(AutoStakeDestinationColdkeys::::get(owner_hot, net).is_empty()); + // ------------------------------------------------------------------ // Final subnet removal confirmation // ------------------------------------------------------------------ @@ -633,6 +641,43 @@ fn dissolve_clears_all_per_subnet_storages() { }); } +// Focused regression for the AutoStakeDestination orphan: without cleanup on +// dissolve, a stale (coldkey, netuid) → hotkey mapping would survive the +// subnet's dissolution and silently redirect mining incentive when the same +// netuid is later re-registered (see `coinbase::run_coinbase` auto-stake +// path). This test proves the cleanup wipes both halves of the index. +#[test] +fn dissolve_clears_auto_stake_destination_preventing_stale_routing() { + new_test_ext(0).execute_with(|| { + let owner_cold = U256::from(101); + let owner_hot = U256::from(102); + let net = add_dynamic_network(&owner_hot, &owner_cold); + + let staker_cold = U256::from(201); + let stale_dest_hot = U256::from(202); + + AutoStakeDestination::::insert(staker_cold, net, stale_dest_hot); + AutoStakeDestinationColdkeys::::mutate(stale_dest_hot, net, |v| v.push(staker_cold)); + + // Sanity: both halves of the index are populated before dissolve. + assert_eq!( + AutoStakeDestination::::get(staker_cold, net), + Some(stale_dest_hot) + ); + assert_eq!( + AutoStakeDestinationColdkeys::::get(stale_dest_hot, net), + vec![staker_cold] + ); + + assert_ok!(SubtensorModule::do_dissolve_network(net)); + + assert!(AutoStakeDestination::::get(staker_cold, net).is_none()); + assert!( + AutoStakeDestinationColdkeys::::get(stale_dest_hot, net).is_empty() + ); + }); +} + #[test] fn dissolve_alpha_out_but_zero_tao_no_rewards() { new_test_ext(0).execute_with(|| { From 93c4127101bac589176de4c73ab40942ad1ca388 Mon Sep 17 00:00:00 2001 From: "RUNE.CTZ" Date: Mon, 11 May 2026 09:38:38 -0700 Subject: [PATCH 2/2] fix: cargo fmt + bump spec_version to 407 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `cargo fmt` collapses a wrapped `assert!` macro into a single line. - Bump runtime `spec_version` 406 → 407 per the `Check spec_version bump` CI requirement (this PR touches `remove_network`, which is reachable from a runtime extrinsic). --- pallets/subtensor/src/tests/networks.rs | 4 +--- runtime/src/lib.rs | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/pallets/subtensor/src/tests/networks.rs b/pallets/subtensor/src/tests/networks.rs index c18584e39d..3e988c0321 100644 --- a/pallets/subtensor/src/tests/networks.rs +++ b/pallets/subtensor/src/tests/networks.rs @@ -672,9 +672,7 @@ fn dissolve_clears_auto_stake_destination_preventing_stale_routing() { assert_ok!(SubtensorModule::do_dissolve_network(net)); assert!(AutoStakeDestination::::get(staker_cold, net).is_none()); - assert!( - AutoStakeDestinationColdkeys::::get(stale_dest_hot, net).is_empty() - ); + assert!(AutoStakeDestinationColdkeys::::get(stale_dest_hot, net).is_empty()); }); } diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 00d3839fa7..85a35d21c8 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -272,7 +272,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { // `spec_version`, and `authoring_version` are the same between Wasm and native. // This value is set to 100 to notify Polkadot-JS App (https://polkadot.js.org/apps) to use // the compatible custom types. - spec_version: 406, + spec_version: 407, impl_version: 1, apis: RUNTIME_API_VERSIONS, transaction_version: 1,