-
Notifications
You must be signed in to change notification settings - Fork 294
Description
Bug: swap_hotkey on a single subnet wipes all root dividend accumulations
Summary
perform_hotkey_swap_on_one_subnet (swap_hotkey.rs:510) unconditionally calls
transfer_root_claimable_for_new_hotkey, which transfers the entire
RootClaimable BTreeMap (accumulated dividend rates for all subnets) from the
old hotkey to the new hotkey — even when only swapping on a single non-root subnet.
Meanwhile, RootClaimed is only transferred for the one subnet being swapped
(line 515). The old hotkey retains its root stake and RootClaimed entries for all
other subnets, putting it into a permanently overclaimed state where
claimed >> claimable, yielding effectively zero root dividends.
Impact
Any validator who performs a single-subnet hotkey swap (not an all-subnet swap)
will have their root dividends frozen to near-zero for weeks or months until the
re-accumulating RootClaimable rates catch up to the orphaned RootClaimed
watermarks.
On-Chain Proof
Hotkey: 5HK5tp6t2S59DywmHRWPBVJeJ86T61KjurYqeooqj8sREpeN
Coldkey: 5EAdYiz1M8BznMNE9WvTajEq64UpGKBFGZDJBNvMDj5fWpeG
Block: 7,670,707 (finney, ~March 4 2026)
Extrinsic: SubtensorModule::swap_hotkey (extrinsic #8 in block)
| Metric | Before swap (block 7,670,706) | After swap (block 7,670,707) | 15 days later |
|---|---|---|---|
RootClaimable rate_sum |
2.139 (127 subnets) | 0 (null — wiped) | 0.296 (re-accumulating) |
RootClaimed total |
~35,692 α | ~35,692 α (unchanged) | ~35,692 α |
| Root stake | ~17,065 TAO | ~17,065 TAO (unchanged) | ~17,065 TAO |
Claimable (rate × stake) |
~36,500 α | 0 | ~5,051 α |
Owed (claimable - claimed) |
~800 α | 0 | ~43 α |
| Overclaimed subnets | 0 / 126 | 126 / 126 | 122 / 126 |
The swap transferred the old hotkey to a new hotkey on a single subnet (not root).
Root stake remained on the old hotkey. But all RootClaimable rates were wiped.
Root Cause
File: pallets/subtensor/src/swap/swap_hotkey.rs
In perform_hotkey_swap_on_one_subnet (line 321), which is called for both
single-subnet swaps and as a loop body for all-subnet swaps:
// Line 510: Transfers ALL subnets' accumulated rates — entire BTreeMap
Self::transfer_root_claimable_for_new_hotkey(old_hotkey, new_hotkey);
// Lines 513-517: Transfers RootClaimed for ONLY the one subnet being swapped
for ((coldkey, netuid_alpha), alpha) in old_alpha_values {
if netuid == netuid_alpha {
Self::transfer_root_claimed_for_new_keys(
netuid, old_hotkey, new_hotkey, &coldkey, &coldkey,
);
// ...
}
}The mismatch
| Storage | What gets transferred | Scope |
|---|---|---|
RootClaimable (line 510) |
Entire BTreeMap (all subnets' rates) | All subnets |
RootClaimed (line 515) |
One entry per coldkey | Only the swapped subnet |
transfer_root_claimable_for_new_hotkey (claim_root.rs:373-389)
pub fn transfer_root_claimable_for_new_hotkey(
old_hotkey: &T::AccountId,
new_hotkey: &T::AccountId,
) {
let src_root_claimable = RootClaimable::<T>::get(old_hotkey);
let mut dst_root_claimable = RootClaimable::<T>::get(new_hotkey);
RootClaimable::<T>::remove(old_hotkey); // ← Removes ENTIRE map
for (netuid, claimable_rate) in src_root_claimable.into_iter() {
dst_root_claimable
.entry(netuid)
.and_modify(|total| *total = total.saturating_add(claimable_rate))
.or_insert(claimable_rate);
}
RootClaimable::<T>::insert(new_hotkey, dst_root_claimable);
}transfer_root_claimed_for_new_keys (claim_root.rs:359-372)
pub fn transfer_root_claimed_for_new_keys(
netuid: NetUid, // ← Only one subnet
old_hotkey: &T::AccountId,
new_hotkey: &T::AccountId,
old_coldkey: &T::AccountId,
new_coldkey: &T::AccountId,
) {
let old_root_claimed = RootClaimed::<T>::get((netuid, old_hotkey, old_coldkey));
RootClaimed::<T>::remove((netuid, old_hotkey, old_coldkey));
RootClaimed::<T>::mutate((netuid, new_hotkey, new_coldkey), |new_root_claimed| {
*new_root_claimed = old_root_claimed.saturating_add(*new_root_claimed);
});
}Why it works for all-subnet swaps (but not single-subnet)
In perform_hotkey_swap_on_all_subnets (line 158), the per-subnet function is
called in a loop:
for netuid in Self::get_all_subnet_netuids() {
Self::perform_hotkey_swap_on_one_subnet(old_hotkey, new_hotkey, weight, netuid)?;
}- First iteration:
transfer_root_claimable_for_new_hotkeytransfers all rates.
transfer_root_claimed_for_new_keystransfersRootClaimedfor subnet 0. - Subsequent iterations:
transfer_root_claimable_for_new_hotkeyis a no-op
(old hotkey already empty).transfer_root_claimed_for_new_keystransfers
RootClaimedfor each subsequent subnet. - End result: Both
RootClaimableandRootClaimedfully transferred. ✅
For single-subnet swap via swap_hotkey_on_subnet (line 239):
Self::perform_hotkey_swap_on_one_subnet(old_hotkey, new_hotkey, &mut weight, netuid)?;transfer_root_claimable_for_new_hotkeytransfers all rates → old hotkey wipedtransfer_root_claimed_for_new_keystransfersRootClaimedfor one subnet only- Old hotkey keeps root stake +
RootClaimedfor ~125 other subnets - End result:
claimed >> claimable→ zero dividends ❌
Proposed Fix
Code change
Move transfer_root_claimable_for_new_hotkey out of perform_hotkey_swap_on_one_subnet
and into perform_hotkey_swap_on_all_subnets only. For single-subnet swaps, the old
hotkey retains its root stake, so it must retain its accumulated RootClaimable rates.
--- a/pallets/subtensor/src/swap/swap_hotkey.rs
+++ b/pallets/subtensor/src/swap/swap_hotkey.rs
@@ -189,6 +189,9 @@ impl<T: Config> Pallet<T> {
// 5. execute the hotkey swap on all subnets
for netuid in Self::get_all_subnet_netuids() {
Self::perform_hotkey_swap_on_one_subnet(old_hotkey, new_hotkey, weight, netuid)?;
}
+
+ // 5.1. Transfer root claimable (all subnets at once, only for full swap)
+ Self::transfer_root_claimable_for_new_hotkey(old_hotkey, new_hotkey);
@@ -507,9 +510,6 @@ impl<T: Config> Pallet<T> {
- // 9.1. Transfer root claimable
-
- Self::transfer_root_claimable_for_new_hotkey(old_hotkey, new_hotkey);
-
// 9.2. Insert the new alpha values.Migration
A storage migration is needed to repair currently-affected hotkeys:
For each hotkey where RootClaimed(netuid, hotkey, coldkey) > RootClaimable(hotkey)[netuid] × root_stake(hotkey, coldkey):
- Reset
RootClaimeddown to the currentclaimablevalue, soowedreturns to zero
(rather than staying stuck in overclaimed state).
Alternatively, simply clear RootClaimed for affected hotkeys — they will lose any
small pending owed amounts but will immediately resume earning dividends.
Investigation Timeline
| Step | Finding |
|---|---|
Queried RootClaimable + RootClaimed for hotkey |
122/126 subnets overclaimed; total claimed 7× claimable |
| Historical root stake check | Root stake was never > 25k TAO — cannot explain 35k claimed |
Historical RootClaimable rate check |
rate_sum was 1.86 at 30d ago, 0.296 now — rates were reset |
| Binary search for reset block | Narrowed to block 7,670,707 |
| Block-by-block trace | RootClaimable present at 7,670,706, null at 7,670,707 |
| Other hotkeys check | NOT affected — this was hotkey-specific, not global |
| Extrinsic decode | Block 7,670,707 extrinsic #8 = SubtensorModule::swap_hotkey |
| Code analysis | transfer_root_claimable_for_new_hotkey transfers all subnets; transfer_root_claimed_for_new_keys transfers only one |