Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions pallets/subtensor/src/benchmarks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<T>::do_try_associate_hotkey(&coldkey, &hot));

#[extrinsic_call]
_(RawOrigin::Signed(coldkey.clone()), hot.clone());
}

#[benchmark]
fn unstake_all() {
let coldkey: T::AccountId = whitelisted_caller();
Expand Down
20 changes: 20 additions & 0 deletions pallets/subtensor/src/macros/dispatches.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>, hotkey: T::AccountId) -> DispatchResult {
let coldkey = ensure_signed(origin)?;
Self::do_disassociate_hotkey(&coldkey, &hotkey)
}
}
}
4 changes: 4 additions & 0 deletions pallets/subtensor/src/macros/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
7 changes: 7 additions & 0 deletions pallets/subtensor/src/macros/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}
}
67 changes: 67 additions & 0 deletions pallets/subtensor/src/staking/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,71 @@ impl<T: Config> Pallet<T> {

Ok(())
}

pub fn do_disassociate_hotkey(coldkey: &T::AccountId, hotkey: &T::AccountId) -> DispatchResult {
// Ensure the hotkey exists.
ensure!(
Self::hotkey_account_exists(hotkey),
Error::<T>::HotKeyAccountNotExists
);

// Ensure the coldkey owns the hotkey.
ensure!(
Self::coldkey_owns_hotkey(coldkey, hotkey),
Error::<T>::NonAssociatedColdKey
);

// Ensure the hotkey is not registered on any subnet.
ensure!(
!Self::is_hotkey_registered_on_any_network(hotkey),
Error::<T>::HotkeyIsStillRegistered
);

// Ensure the hotkey has no outstanding stake from any coldkey.
ensure!(
Alpha::<T>::iter_prefix((hotkey,)).next().is_none(),
Error::<T>::HotkeyHasOutstandingStake
);

// Remove Owner entry.
Owner::<T>::remove(hotkey);

// Remove hotkey from OwnedHotkeys.
let mut owned = OwnedHotkeys::<T>::get(coldkey);
owned.retain(|h| h != hotkey);
if owned.is_empty() {
OwnedHotkeys::<T>::remove(coldkey);
} else {
OwnedHotkeys::<T>::insert(coldkey, owned);
}

// Remove hotkey from StakingHotkeys.
let mut staking = StakingHotkeys::<T>::get(coldkey);
staking.retain(|h| h != hotkey);
if staking.is_empty() {
StakingHotkeys::<T>::remove(coldkey);
} else {
StakingHotkeys::<T>::insert(coldkey, staking);
}

// Remove Delegates entry if present.
Delegates::<T>::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() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the key not registered in any subnet, it can not be set as auto stake destination. Should we remove it?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question!

You're right that a hotkey can't have auto-stake set if it's not registered on any subnet.

However, I believe there might be a case where stale entries could exist: when a hotkey gets deregistered via replace_neuron or when a subnet is dissolved via remove_network, AutoStakeDestination and AutoStakeDestinationColdkeys don't seem to be cleaned up, so orphaned entries could remain in storage from before the deregistration.

For example, a coldkey could set a registered hotkey as its auto-stake destination.
Later, that hotkey gets deregistered, but since replace_neuron and remove_network don't clean up auto-stake entries, they remain orphaned in storage.
The cleanup here is the only place (along with swap_hotkey and swap_coldkey) that catches those.

But happy to remove it if I'm missing something!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with you, sometimes there are stale entries. You can see this PR #2513, we are working on such cases. But it is better to use process like migration to remove these storages.
I don't think the new extrinsic will be called frequently. It is fine to keep the extra check in it.
I will approve it after you fix the clippy in CI.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The clippy issue was coming from the devnet-ready base being outdated. I've rebased on the latest devnet-ready and clippy passes clean locally. Could you re-trigger the CI?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My bad, seen in the benchmarks.rs the use of assert_*! instead. Should be solved now.

let coldkeys = AutoStakeDestinationColdkeys::<T>::get(hotkey, netuid);
for ck in &coldkeys {
AutoStakeDestination::<T>::remove(ck, netuid);
}
AutoStakeDestinationColdkeys::<T>::remove(hotkey, netuid);
}

Self::deposit_event(Event::HotkeyDisassociated {
coldkey: coldkey.clone(),
hotkey: hotkey.clone(),
});

Ok(())
}
}
209 changes: 208 additions & 1 deletion pallets/subtensor/src/tests/staking2.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#![allow(clippy::unwrap_used)]

use frame_support::{
assert_ok,
assert_noop, assert_ok,
dispatch::{GetDispatchInfo, Pays},
weights::Weight,
};
Expand Down Expand Up @@ -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::<Test>::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::<Test>::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::<Test>::insert(hotkey1, netuid, true);

// Try to disassociate - should fail because still registered
assert_noop!(
SubtensorModule::disassociate_hotkey(RuntimeOrigin::signed(coldkey1), hotkey1),
Error::<Test>::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::<Test>::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::<Test>::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::<Test>::insert(coldkey2, netuid, hotkey1);
AutoStakeDestinationColdkeys::<Test>::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::<Test>::get(coldkey2, netuid).is_none());
assert!(AutoStakeDestinationColdkeys::<Test>::get(hotkey1, netuid).is_empty());
});
}

#[test]
fn test_stake_fee_api() {
// The API should match the calculation
Expand Down