diff --git a/pallets/subtensor/src/benchmarks.rs b/pallets/subtensor/src/benchmarks.rs index 8a768c95a3..e4de0c9c2b 100644 --- a/pallets/subtensor/src/benchmarks.rs +++ b/pallets/subtensor/src/benchmarks.rs @@ -1377,6 +1377,18 @@ mod pallet_benchmarks { _(RawOrigin::Signed(coldkey.clone()), hot.clone()); } + #[benchmark] + fn disassociate_hotkey() { + let coldkey: T::AccountId = whitelisted_caller(); + let hot: T::AccountId = account("A", 0, 1); + + // First associate, then disassociate + assert_ok!(Pallet::::do_try_associate_hotkey(&coldkey, &hot)); + + #[extrinsic_call] + _(RawOrigin::Signed(coldkey.clone()), hot.clone()); + } + #[benchmark] fn unstake_all() { let coldkey: T::AccountId = whitelisted_caller(); diff --git a/pallets/subtensor/src/macros/dispatches.rs b/pallets/subtensor/src/macros/dispatches.rs index f70b83f52d..c2ef2cc2dc 100644 --- a/pallets/subtensor/src/macros/dispatches.rs +++ b/pallets/subtensor/src/macros/dispatches.rs @@ -2626,5 +2626,25 @@ mod dispatches { Self::deposit_event(Event::ColdkeySwapCleared { who }); Ok(()) } + + /// Disassociates a hotkey from the calling coldkey. + /// + /// The reverse of `try_associate_hotkey`. Removes the ownership link between + /// the coldkey and hotkey. The hotkey must not be registered on any subnet + /// and must have no outstanding stake. + /// + /// # Arguments + /// * `origin` - The origin of the transaction, which must be signed by the coldkey that owns the `hotkey`. + /// * `hotkey` - The hotkey to disassociate from the coldkey. + #[pallet::call_index(134)] + #[pallet::weight(( + Weight::from_parts(54_300_000, 0).saturating_add(T::DbWeight::get().reads_writes(10, 8)), + DispatchClass::Normal, + Pays::Yes + ))] + pub fn disassociate_hotkey(origin: OriginFor, hotkey: T::AccountId) -> DispatchResult { + let coldkey = ensure_signed(origin)?; + Self::do_disassociate_hotkey(&coldkey, &hotkey) + } } } diff --git a/pallets/subtensor/src/macros/errors.rs b/pallets/subtensor/src/macros/errors.rs index 637704f970..917d0ca141 100644 --- a/pallets/subtensor/src/macros/errors.rs +++ b/pallets/subtensor/src/macros/errors.rs @@ -286,5 +286,9 @@ mod errors { ColdkeySwapDisputed, /// Coldkey swap clear too early. ColdkeySwapClearTooEarly, + /// Cannot disassociate a hotkey that is still registered on a subnet. + HotkeyIsStillRegistered, + /// Cannot disassociate a hotkey that still has outstanding stake. + HotkeyHasOutstandingStake, } } diff --git a/pallets/subtensor/src/macros/events.rs b/pallets/subtensor/src/macros/events.rs index 0370ad2e06..d1a2ca996a 100644 --- a/pallets/subtensor/src/macros/events.rs +++ b/pallets/subtensor/src/macros/events.rs @@ -547,5 +547,12 @@ mod events { /// Resulting swapped TAO amount tao_amount: TaoBalance, }, + /// A hotkey has been disassociated from its coldkey. + HotkeyDisassociated { + /// The account ID of the coldkey. + coldkey: T::AccountId, + /// The account ID of the hotkey. + hotkey: T::AccountId, + }, } } diff --git a/pallets/subtensor/src/staking/account.rs b/pallets/subtensor/src/staking/account.rs index 20a6ff3036..13903c940c 100644 --- a/pallets/subtensor/src/staking/account.rs +++ b/pallets/subtensor/src/staking/account.rs @@ -10,4 +10,71 @@ impl Pallet { Ok(()) } + + pub fn do_disassociate_hotkey(coldkey: &T::AccountId, hotkey: &T::AccountId) -> DispatchResult { + // Ensure the hotkey exists. + ensure!( + Self::hotkey_account_exists(hotkey), + Error::::HotKeyAccountNotExists + ); + + // Ensure the coldkey owns the hotkey. + ensure!( + Self::coldkey_owns_hotkey(coldkey, hotkey), + Error::::NonAssociatedColdKey + ); + + // Ensure the hotkey is not registered on any subnet. + ensure!( + !Self::is_hotkey_registered_on_any_network(hotkey), + Error::::HotkeyIsStillRegistered + ); + + // Ensure the hotkey has no outstanding stake from any coldkey. + ensure!( + Alpha::::iter_prefix((hotkey,)).next().is_none(), + Error::::HotkeyHasOutstandingStake + ); + + // Remove Owner entry. + Owner::::remove(hotkey); + + // Remove hotkey from OwnedHotkeys. + let mut owned = OwnedHotkeys::::get(coldkey); + owned.retain(|h| h != hotkey); + if owned.is_empty() { + OwnedHotkeys::::remove(coldkey); + } else { + OwnedHotkeys::::insert(coldkey, owned); + } + + // Remove hotkey from StakingHotkeys. + let mut staking = StakingHotkeys::::get(coldkey); + staking.retain(|h| h != hotkey); + if staking.is_empty() { + StakingHotkeys::::remove(coldkey); + } else { + StakingHotkeys::::insert(coldkey, staking); + } + + // Remove Delegates entry if present. + Delegates::::remove(hotkey); + + // Clean up AutoStakeDestination references. + // Other coldkeys may have set this hotkey as their auto-stake destination. + for netuid in Self::get_all_subnet_netuids() { + let coldkeys = AutoStakeDestinationColdkeys::::get(hotkey, netuid); + for ck in &coldkeys { + AutoStakeDestination::::remove(ck, netuid); + } + AutoStakeDestinationColdkeys::::remove(hotkey, netuid); + } + + Self::deposit_event(Event::HotkeyDisassociated { + coldkey: coldkey.clone(), + hotkey: hotkey.clone(), + }); + + Ok(()) + } } diff --git a/pallets/subtensor/src/tests/staking2.rs b/pallets/subtensor/src/tests/staking2.rs index 536a14579a..e6cec859bb 100644 --- a/pallets/subtensor/src/tests/staking2.rs +++ b/pallets/subtensor/src/tests/staking2.rs @@ -1,7 +1,7 @@ #![allow(clippy::unwrap_used)] use frame_support::{ - assert_ok, + assert_noop, assert_ok, dispatch::{GetDispatchInfo, Pays}, weights::Weight, }; @@ -637,6 +637,213 @@ fn test_try_associate_hotkey() { }); } +// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::staking2::test_disassociate_hotkey --exact --show-output --nocapture +#[test] +fn test_disassociate_hotkey() { + new_test_ext(1).execute_with(|| { + let hotkey1 = U256::from(1); + let coldkey1 = U256::from(2); + + // Associate hotkey1 with coldkey1 + assert_ok!(SubtensorModule::try_associate_hotkey( + RuntimeOrigin::signed(coldkey1), + hotkey1 + )); + assert!(SubtensorModule::hotkey_account_exists(&hotkey1)); + assert!(SubtensorModule::get_owned_hotkeys(&coldkey1).contains(&hotkey1)); + + // Disassociate hotkey1 from coldkey1 + assert_ok!(SubtensorModule::disassociate_hotkey( + RuntimeOrigin::signed(coldkey1), + hotkey1 + )); + + // Verify hotkey is fully removed + assert!(!SubtensorModule::hotkey_account_exists(&hotkey1)); + assert!(!SubtensorModule::get_owned_hotkeys(&coldkey1).contains(&hotkey1)); + assert!(!SubtensorModule::get_all_staked_hotkeys(&coldkey1).contains(&hotkey1)); + + // Verify the extrinsic charges a fee + let call = + RuntimeCall::SubtensorModule(crate::Call::disassociate_hotkey { hotkey: hotkey1 }); + let dispatch_info = call.get_dispatch_info(); + assert!(dispatch_info.call_weight.all_gte(Weight::from_all(0))); + assert_eq!(dispatch_info.pays_fee, Pays::Yes); + }); +} + +// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::staking2::test_disassociate_hotkey_not_exists --exact --show-output --nocapture +#[test] +fn test_disassociate_hotkey_not_exists() { + new_test_ext(1).execute_with(|| { + let hotkey1 = U256::from(1); + let coldkey1 = U256::from(2); + + // Try to disassociate a hotkey that doesn't exist + assert_noop!( + SubtensorModule::disassociate_hotkey(RuntimeOrigin::signed(coldkey1), hotkey1), + Error::::HotKeyAccountNotExists + ); + }); +} + +// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::staking2::test_disassociate_hotkey_non_owner --exact --show-output --nocapture +#[test] +fn test_disassociate_hotkey_non_owner() { + new_test_ext(1).execute_with(|| { + let hotkey1 = U256::from(1); + let coldkey1 = U256::from(2); + let coldkey2 = U256::from(3); + + // Associate hotkey1 with coldkey1 + assert_ok!(SubtensorModule::try_associate_hotkey( + RuntimeOrigin::signed(coldkey1), + hotkey1 + )); + + // Try to disassociate from a different coldkey + assert_noop!( + SubtensorModule::disassociate_hotkey(RuntimeOrigin::signed(coldkey2), hotkey1), + Error::::NonAssociatedColdKey + ); + + // Verify hotkey is still associated + assert!(SubtensorModule::hotkey_account_exists(&hotkey1)); + }); +} + +// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::staking2::test_disassociate_hotkey_still_registered --exact --show-output --nocapture +#[test] +fn test_disassociate_hotkey_still_registered() { + new_test_ext(1).execute_with(|| { + let hotkey1 = U256::from(1); + let coldkey1 = U256::from(2); + let netuid = NetUid::from(1); + + // Associate hotkey1 with coldkey1 + assert_ok!(SubtensorModule::try_associate_hotkey( + RuntimeOrigin::signed(coldkey1), + hotkey1 + )); + + // Register hotkey on a subnet + IsNetworkMember::::insert(hotkey1, netuid, true); + + // Try to disassociate - should fail because still registered + assert_noop!( + SubtensorModule::disassociate_hotkey(RuntimeOrigin::signed(coldkey1), hotkey1), + Error::::HotkeyIsStillRegistered + ); + + // Verify hotkey is still associated + assert!(SubtensorModule::hotkey_account_exists(&hotkey1)); + }); +} + +// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::staking2::test_disassociate_hotkey_has_stake --exact --show-output --nocapture +#[test] +fn test_disassociate_hotkey_has_stake() { + new_test_ext(1).execute_with(|| { + let hotkey1 = U256::from(1); + let coldkey1 = U256::from(2); + let netuid = NetUid::from(1); + + // Associate hotkey1 with coldkey1 + assert_ok!(SubtensorModule::try_associate_hotkey( + RuntimeOrigin::signed(coldkey1), + hotkey1 + )); + + // Add some alpha (stake) for this hotkey + Alpha::::insert( + (hotkey1, coldkey1, netuid), + substrate_fixed::types::U64F64::from_num(1000u64), + ); + + // Try to disassociate - should fail because has outstanding stake + assert_noop!( + SubtensorModule::disassociate_hotkey(RuntimeOrigin::signed(coldkey1), hotkey1), + Error::::HotkeyHasOutstandingStake + ); + + // Verify hotkey is still associated + assert!(SubtensorModule::hotkey_account_exists(&hotkey1)); + }); +} + +// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::staking2::test_disassociate_hotkey_reassociate --exact --show-output --nocapture +#[test] +fn test_disassociate_hotkey_reassociate() { + new_test_ext(1).execute_with(|| { + let hotkey1 = U256::from(1); + let coldkey1 = U256::from(2); + let coldkey2 = U256::from(3); + + // Associate hotkey1 with coldkey1 + assert_ok!(SubtensorModule::try_associate_hotkey( + RuntimeOrigin::signed(coldkey1), + hotkey1 + )); + assert_eq!( + SubtensorModule::get_owning_coldkey_for_hotkey(&hotkey1), + coldkey1 + ); + + // Disassociate hotkey1 from coldkey1 + assert_ok!(SubtensorModule::disassociate_hotkey( + RuntimeOrigin::signed(coldkey1), + hotkey1 + )); + assert!(!SubtensorModule::hotkey_account_exists(&hotkey1)); + + // Now coldkey2 can associate the same hotkey + assert_ok!(SubtensorModule::try_associate_hotkey( + RuntimeOrigin::signed(coldkey2), + hotkey1 + )); + assert_eq!( + SubtensorModule::get_owning_coldkey_for_hotkey(&hotkey1), + coldkey2 + ); + assert!(SubtensorModule::get_owned_hotkeys(&coldkey2).contains(&hotkey1)); + assert!(!SubtensorModule::get_owned_hotkeys(&coldkey1).contains(&hotkey1)); + }); +} + +// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::staking2::test_disassociate_hotkey_cleans_auto_stake --exact --show-output --nocapture +#[test] +fn test_disassociate_hotkey_cleans_auto_stake() { + new_test_ext(1).execute_with(|| { + let hotkey1 = U256::from(1); + let coldkey1 = U256::from(2); + let coldkey2 = U256::from(3); + let netuid = NetUid::from(1); + + // Setup: add network so get_all_subnet_netuids returns it + add_network(netuid, 10, 0); + + // Associate hotkey1 with coldkey1 + assert_ok!(SubtensorModule::try_associate_hotkey( + RuntimeOrigin::signed(coldkey1), + hotkey1 + )); + + // coldkey2 sets auto-stake destination to hotkey1 on netuid + AutoStakeDestination::::insert(coldkey2, netuid, hotkey1); + AutoStakeDestinationColdkeys::::insert(hotkey1, netuid, vec![coldkey2]); + + // Disassociate hotkey1 from coldkey1 + assert_ok!(SubtensorModule::disassociate_hotkey( + RuntimeOrigin::signed(coldkey1), + hotkey1 + )); + + // Verify auto-stake entries are cleaned up + assert!(AutoStakeDestination::::get(coldkey2, netuid).is_none()); + assert!(AutoStakeDestinationColdkeys::::get(hotkey1, netuid).is_empty()); + }); +} + #[test] fn test_stake_fee_api() { // The API should match the calculation