From 5e934b6e8ccc121f49adeb0e3094c23bc4547498 Mon Sep 17 00:00:00 2001 From: "RUNE.CTZ" Date: Sun, 10 May 2026 20:38:16 -0700 Subject: [PATCH 1/3] 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/3] 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, From ad9e649eb783fa9ea744d59865fec6bcbc4035f0 Mon Sep 17 00:00:00 2001 From: "RUNE.CTZ" Date: Fri, 15 May 2026 09:22:42 -0700 Subject: [PATCH 3/3] test(networks): assert dissolve only clears AutoStakeDestination for dissolved netuid Adds a companion regression test next to ``dissolve_clears_auto_stake_destination_preventing_stale_routing``: Sets up the same ``(staker_cold, stale_dest_hot)`` pair across three live netuids, dissolves one of them, and asserts that: * both halves of the index (``AutoStakeDestination`` forward map and ``AutoStakeDestinationColdkeys`` reverse map) are cleared for the dissolved netuid; and * the entries for the two surviving netuids are still intact. This pins the per-netuid scope of the cleanup so a future regression that clears the routing for every subnet a coldkey participates in (rather than just the dissolved one) would fail this test rather than silently misroute incentive on live subnets. Per @l0r1s review. --- pallets/subtensor/src/tests/networks.rs | 74 +++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/pallets/subtensor/src/tests/networks.rs b/pallets/subtensor/src/tests/networks.rs index 3e988c0321..d3424aa4a2 100644 --- a/pallets/subtensor/src/tests/networks.rs +++ b/pallets/subtensor/src/tests/networks.rs @@ -676,6 +676,80 @@ fn dissolve_clears_auto_stake_destination_preventing_stale_routing() { }); } +// Companion regression: dissolving one subnet must NOT clear +// AutoStakeDestination entries for *other* live subnets. Without per-netuid +// scoping in the cleanup, a single dissolve could blow away the auto-stake +// routing for every subnet a coldkey participates in. This test sets up the +// same (staker_cold, stale_dest_hot) pair across three distinct netuids and +// asserts that only the dissolved netuid's entries are removed. +#[test] +fn dissolve_only_clears_auto_stake_destination_for_dissolved_netuid() { + new_test_ext(0).execute_with(|| { + let owner_cold = U256::from(101); + let owner_hot = U256::from(102); + let other_owner_cold = U256::from(103); + let other_owner_hot = U256::from(104); + let third_owner_cold = U256::from(105); + let third_owner_hot = U256::from(106); + + let net = add_dynamic_network(&owner_hot, &owner_cold); + let other_net = add_dynamic_network(&other_owner_hot, &other_owner_cold); + let third_net = add_dynamic_network(&third_owner_hot, &third_owner_cold); + + // Same (staker_cold, dest_hot) pair routed across three netuids — only + // the dissolved one should disappear after the call. + let staker_cold = U256::from(201); + let stale_dest_hot = U256::from(202); + + for n in [net, other_net, third_net] { + AutoStakeDestination::::insert(staker_cold, n, stale_dest_hot); + AutoStakeDestinationColdkeys::::mutate(stale_dest_hot, n, |v| { + v.push(staker_cold) + }); + } + + // Sanity: all three netuids have their auto-stake routing in place. + for n in [net, other_net, third_net] { + assert_eq!( + AutoStakeDestination::::get(staker_cold, n), + Some(stale_dest_hot), + "pre-dissolve forward index missing for netuid {n:?}" + ); + assert_eq!( + AutoStakeDestinationColdkeys::::get(stale_dest_hot, n), + vec![staker_cold], + "pre-dissolve reverse index missing for netuid {n:?}" + ); + } + + assert_ok!(SubtensorModule::do_dissolve_network(net)); + + // Dissolved netuid: both halves of the index are gone. + assert!( + AutoStakeDestination::::get(staker_cold, net).is_none(), + "forward index was not cleared for dissolved netuid {net:?}" + ); + assert!( + AutoStakeDestinationColdkeys::::get(stale_dest_hot, net).is_empty(), + "reverse index was not cleared for dissolved netuid {net:?}" + ); + + // Surviving netuids: routing is intact, untouched by the dissolve. + for n in [other_net, third_net] { + assert_eq!( + AutoStakeDestination::::get(staker_cold, n), + Some(stale_dest_hot), + "forward index was incorrectly cleared for surviving netuid {n:?}" + ); + assert_eq!( + AutoStakeDestinationColdkeys::::get(stale_dest_hot, n), + vec![staker_cold], + "reverse index was incorrectly cleared for surviving netuid {n:?}" + ); + } + }); +} + #[test] fn dissolve_alpha_out_but_zero_tao_no_rewards() { new_test_ext(0).execute_with(|| {