Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
4 changes: 3 additions & 1 deletion pallets/subtensor/src/macros/hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,9 @@ mod hooks {
// Fix staking hot keys
.saturating_add(migrations::migrate_fix_staking_hot_keys::migrate_fix_staking_hot_keys::<T>())
// Migrate coldkey swap scheduled to announcements
.saturating_add(migrations::migrate_coldkey_swap_scheduled_to_announcements::migrate_coldkey_swap_scheduled_to_announcements::<T>());
.saturating_add(migrations::migrate_coldkey_swap_scheduled_to_announcements::migrate_coldkey_swap_scheduled_to_announcements::<T>())
// Fix RootClaimed overclaim caused by single-subnet hotkey swap bug
.saturating_add(migrations::migrate_fix_root_claimed_overclaim::migrate_fix_root_claimed_overclaim::<T>());
weight
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
use super::*;
use frame_support::pallet_prelude::Weight;
use scale_info::prelude::string::String;
use substrate_fixed::types::I96F32;

/// Fixes the consequences of a bug in `perform_hotkey_swap_on_one_subnet` where
/// `transfer_root_claimable_for_new_hotkey` unconditionally transferred the **entire**
/// `RootClaimable` BTreeMap (all subnets) from the old hotkey to the new hotkey, even
/// during a single-subnet swap.
///
/// This left the old hotkey with:
/// - `RootClaimable[old_hotkey]` = empty (wiped for ALL subnets)
/// - `RootClaimed[(subnet, old_hotkey, coldkey)]` = old watermarks (for non-swapped subnets)
///
/// Resulting in `owed = claimable_rate * root_stake - root_claimed = 0 - positive = negative → 0`,
/// effectively freezing root dividends for the old hotkey.
///
/// Remediation: for every (netuid, hotkey, coldkey) where claimed > claimable * root_stake,
/// reset claimed = claimable * root_stake so owed starts at 0 instead of being permanently
/// negative. Future epoch increments will then produce positive owed normally.
pub fn migrate_fix_root_claimed_overclaim<T: Config>() -> Weight {
let migration_name = b"migrate_fix_root_claimed_overclaim".to_vec();
let mut weight = T::DbWeight::get().reads(1);

if HasMigrationRun::<T>::get(&migration_name) {
log::info!(
"Migration '{:?}' has already run. Skipping.",
String::from_utf8_lossy(&migration_name)
);
return weight;
}

log::info!(
"Running migration '{}'",
String::from_utf8_lossy(&migration_name)
);

// --- Fix overclaimed RootClaimed watermarks ---
let mut fixed_count: u64 = 0;
let mut total_count: u64 = 0;

for ((netuid, hotkey, coldkey), claimed) in RootClaimed::<T>::iter() {
total_count = total_count.saturating_add(1);
weight.saturating_accrue(T::DbWeight::get().reads(1));

if claimed == 0u128 {
continue;
}

let root_claimable_map = RootClaimable::<T>::get(&hotkey);
weight.saturating_accrue(T::DbWeight::get().reads(1));

let claimable_rate = root_claimable_map
.get(&netuid)
.copied()
.unwrap_or(I96F32::from_num(0));

let root_stake = Pallet::<T>::get_stake_for_hotkey_and_coldkey_on_subnet(
&hotkey,
&coldkey,
NetUid::ROOT,
);
weight.saturating_accrue(T::DbWeight::get().reads(1));

let claimable: u128 = claimable_rate
.saturating_mul(I96F32::from_num(u64::from(root_stake)))
.saturating_to_num::<u128>();

if claimed > claimable {
RootClaimed::<T>::insert((&netuid, &hotkey, &coldkey), claimable);
weight.saturating_accrue(T::DbWeight::get().writes(1));
fixed_count = fixed_count.saturating_add(1);
}
}
Comment on lines +48 to +103
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

would be a nice macro @sam0x17

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

make sure to only clear the RootClaim- able/ed maps if the root stake is still == 0. Otherwise we can just leave it on the old one.


// Mark migration as completed
HasMigrationRun::<T>::insert(&migration_name, true);
weight.saturating_accrue(T::DbWeight::get().writes(1));

log::info!(
"Migration 'migrate_fix_root_claimed_overclaim' completed. \
Checked {} RootClaimed entries, fixed {} overclaimed.",
total_count,
fixed_count,
);

weight
}
1 change: 1 addition & 0 deletions pallets/subtensor/src/migrations/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ pub mod migrate_delete_subnet_3;
pub mod migrate_disable_commit_reveal;
pub mod migrate_fix_childkeys;
pub mod migrate_fix_is_network_member;
pub mod migrate_fix_root_claimed_overclaim;
pub mod migrate_fix_root_subnet_tao;
pub mod migrate_fix_root_tao_and_alpha_in;
pub mod migrate_fix_staking_hot_keys;
Expand Down
26 changes: 15 additions & 11 deletions pallets/subtensor/src/staking/claim_root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -370,22 +370,26 @@ impl<T: Config> Pallet<T> {
*new_root_claimed = old_root_claimed.saturating_add(*new_root_claimed);
});
}

/// Transfer only a single subnet's RootClaimable rate from old_hotkey to new_hotkey.
/// Used during single-subnet hotkey swaps to avoid wiping claimable rates for
/// subnets that are not being swapped.
pub fn transfer_root_claimable_for_new_hotkey(
old_hotkey: &T::AccountId,
new_hotkey: &T::AccountId,
netuid: NetUid,
) {
let src_root_claimable = RootClaimable::<T>::get(old_hotkey);
let mut dst_root_claimable = RootClaimable::<T>::get(new_hotkey);
RootClaimable::<T>::remove(old_hotkey);

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);
// Remove the rate for this specific subnet from the old hotkey's map.
let rate = RootClaimable::<T>::mutate(old_hotkey, |claimable| claimable.remove(&netuid));

// If the old hotkey had a rate for this subnet, add it to the new hotkey's map.
if let Some(claimable_rate) = rate {
RootClaimable::<T>::mutate(new_hotkey, |dst| {
dst.entry(netuid)
.and_modify(|total| *total = total.saturating_add(claimable_rate))
.or_insert(claimable_rate);
});
}

RootClaimable::<T>::insert(new_hotkey, dst_root_claimable);
}

/// Claim all root dividends for subnet and remove all associated data.
Expand Down
9 changes: 7 additions & 2 deletions pallets/subtensor/src/swap/swap_hotkey.rs
Original file line number Diff line number Diff line change
Expand Up @@ -561,8 +561,13 @@ impl<T: Config> Pallet<T> {
weight.saturating_accrue(T::DbWeight::get().reads(old_alpha_values_v2.len() as u64));
weight.saturating_accrue(T::DbWeight::get().writes(old_alpha_values_v2.len() as u64));

// 9.1. Transfer root claimable
Self::transfer_root_claimable_for_new_hotkey(old_hotkey, new_hotkey);
// 9.1. Transfer root claimable for this subnet only.
// NOTE: We must NOT transfer the entire RootClaimable map here because this
// function may be swapping on a single non-root subnet. Wiping all claimable
// rates from the old hotkey would freeze root dividends on every other subnet
// where the old hotkey still has root stake and RootClaimed watermarks.
Self::transfer_root_claimable_for_new_hotkey(old_hotkey, new_hotkey, netuid);
weight.saturating_accrue(T::DbWeight::get().reads_writes(2, 2));

// 9.2. Insert the new alpha values.
for ((coldkey, netuid_alpha), alpha) in old_alpha_values {
Expand Down
129 changes: 128 additions & 1 deletion pallets/subtensor/src/tests/swap_hotkey_with_subnet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use share_pool::SafeFloat;
use sp_core::{Get, H160, H256, U256};
use sp_runtime::SaturatedConversion;
use std::collections::BTreeSet;
use substrate_fixed::types::U64F64;
use substrate_fixed::types::{I96F32, U64F64};

// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --test swap_hotkey_with_subnet -- test_swap_owner --exact --nocapture
#[test]
Expand Down Expand Up @@ -2546,3 +2546,130 @@ fn test_revert_claim_root_with_swap_hotkey() {
);
});
}

// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::swap_hotkey_with_subnet::test_swap_hotkey_root_claimable_per_subnet --exact --nocapture
#[test]
fn test_swap_hotkey_root_claimable_per_subnet() {
new_test_ext(1).execute_with(|| {
// Scenario:
// - oldHotkey is registered on netuid1 AND netuid2
// - oldHotkey accumulates RootClaimable on both subnets
// - swap_hotkey called for netuid1 ONLY
// - RootClaimable[netuid1] moves to newHotkey
// RootClaimable[netuid2] stays on oldHotkey

let owner_coldkey = U256::from(1001);
let old_hotkey = U256::from(1);
let new_hotkey = U256::from(2);
let coldkey = U256::from(3);

let netuid1 = add_dynamic_network(&old_hotkey, &owner_coldkey);
let netuid2 = add_dynamic_network(&old_hotkey, &owner_coldkey);

SubtensorModule::add_balance_to_coldkey_account(&owner_coldkey, u64::MAX.into());
SubtensorModule::add_balance_to_coldkey_account(&coldkey, u64::MAX.into());
SubtensorModule::set_tao_weight(u64::MAX);

let root_stake = 2_000_000u64;
SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet(
&old_hotkey,
&coldkey,
NetUid::ROOT,
root_stake.into(),
);

SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet(
&old_hotkey,
&owner_coldkey,
netuid1,
10_000_000u64.into(),
);
SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet(
&old_hotkey,
&owner_coldkey,
netuid2,
10_000_000u64.into(),
);

let pending_root_alpha = 1_000_000u64;
SubtensorModule::distribute_emission(
netuid1,
AlphaBalance::ZERO,
AlphaBalance::ZERO,
pending_root_alpha.into(),
AlphaBalance::ZERO,
);
SubtensorModule::distribute_emission(
netuid2,
AlphaBalance::ZERO,
AlphaBalance::ZERO,
pending_root_alpha.into(),
AlphaBalance::ZERO,
);

let zero = I96F32::from_num(0);
let claimable_before = RootClaimable::<Test>::get(old_hotkey);
let claimable_netuid1_before = RootClaimable::<Test>::get(old_hotkey)
.get(&netuid1)
.copied()
.unwrap_or(zero);
let claimable_netuid2_before = RootClaimable::<Test>::get(old_hotkey)
.get(&netuid2)
.copied()
.unwrap_or(zero);

let claimable_netuid1_before = RootClaimable::<Test>::get(old_hotkey)
.get(&netuid1)
.copied()
.unwrap_or(zero);
let claimable_netuid2_before = RootClaimable::<Test>::get(old_hotkey)
.get(&netuid2)
.copied()
.unwrap_or(zero);

assert!(
claimable_netuid1_before > zero,
"oldHotkey should have RootClaimable on netuid1 before swap"
);
assert!(
claimable_netuid2_before > zero,
"oldHotkey should have RootClaimable on netuid2 before swap"
);
assert!(
RootClaimable::<Test>::get(new_hotkey).is_empty(),
"newHotkey should have no RootClaimable before swap"
);

System::set_block_number(System::block_number() + HotkeySwapOnSubnetInterval::get());
assert_ok!(SubtensorModule::do_swap_hotkey(
RuntimeOrigin::signed(owner_coldkey),
&old_hotkey,
&new_hotkey,
Some(netuid1),
false
));

let old_claimable_after = RootClaimable::<Test>::get(old_hotkey);
let new_claimable_after = RootClaimable::<Test>::get(new_hotkey);

assert_eq!(
old_claimable_after.get(&netuid1).copied().unwrap_or(zero),
zero,
"oldHotkey should have no RootClaimable for netuid1 after swap"
);
assert!(
new_claimable_after.get(&netuid1).copied().unwrap_or(zero) > zero,
"newHotkey should have received RootClaimable for netuid1"
);
assert_eq!(
old_claimable_after.get(&netuid2).copied().unwrap_or(zero),
claimable_netuid2_before,
"oldHotkey should retain RootClaimable for netuid2 (not swapped)"
);
assert_eq!(
new_claimable_after.get(&netuid2).copied().unwrap_or(zero),
zero,
"newHotkey should have no RootClaimable for netuid2 (was not swapped)"
);
});
}
2 changes: 1 addition & 1 deletion runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1117,7 +1117,7 @@ parameter_types! {
// 0 days
pub const InitialStartCallDelay: u64 = 0;
pub const SubtensorInitialKeySwapOnSubnetCost: TaoBalance = TaoBalance::new(1_000_000); // 0.001 TAO
pub const HotkeySwapOnSubnetInterval : BlockNumber = 24 * 60 * 60 / 12; // 1 day
pub const HotkeySwapOnSubnetInterval : BlockNumber = prod_or_fast!(24 * 60 * 60 / 12, 1); // 1 day
pub const LeaseDividendsDistributionInterval: BlockNumber = 100; // 100 blocks
pub const MaxImmuneUidsPercentage: Percent = Percent::from_percent(80);
pub const EvmKeyAssociateRateLimit: u64 = EVM_KEY_ASSOCIATE_RATELIMIT;
Expand Down
Loading
Loading