diff --git a/pallets/subtensor/src/macros/hooks.rs b/pallets/subtensor/src/macros/hooks.rs index 899e8d32f2..25967ab368 100644 --- a/pallets/subtensor/src/macros/hooks.rs +++ b/pallets/subtensor/src/macros/hooks.rs @@ -166,7 +166,9 @@ mod hooks { // Fix staking hot keys .saturating_add(migrations::migrate_fix_staking_hot_keys::migrate_fix_staking_hot_keys::()) // Migrate coldkey swap scheduled to announcements - .saturating_add(migrations::migrate_coldkey_swap_scheduled_to_announcements::migrate_coldkey_swap_scheduled_to_announcements::()); + .saturating_add(migrations::migrate_coldkey_swap_scheduled_to_announcements::migrate_coldkey_swap_scheduled_to_announcements::()) + // Fix RootClaimed overclaim caused by single-subnet hotkey swap bug + .saturating_add(migrations::migrate_fix_root_claimed_overclaim::migrate_fix_root_claimed_overclaim::()); weight } diff --git a/pallets/subtensor/src/migrations/migrate_fix_root_claimed_overclaim.rs b/pallets/subtensor/src/migrations/migrate_fix_root_claimed_overclaim.rs new file mode 100644 index 0000000000..8ead82be49 --- /dev/null +++ b/pallets/subtensor/src/migrations/migrate_fix_root_claimed_overclaim.rs @@ -0,0 +1,112 @@ +use super::*; +use frame_support::pallet_prelude::Weight; +use frame_system::pallet_prelude::BlockNumberFor; +use scale_info::prelude::string::String; +use share_pool::SafeFloat; +use sp_core::crypto::Ss58Codec; +use sp_runtime::AccountId32; +use substrate_fixed::types::U64F64; + +pub fn decode_account_id32(ss58_string: &str) -> Option { + let account_id32: AccountId32 = AccountId32::from_ss58check(ss58_string).ok()?; + let mut account_id32_slice: &[u8] = account_id32.as_ref(); + T::AccountId::decode(&mut account_id32_slice).ok() +} + +/// 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: restore the pre-swap `RootClaimable` and `RootClaimed` storage maps +pub fn migrate_fix_root_claimed_overclaim() -> Weight { + let migration_name = b"migrate_fix_root_claimed_overclaim".to_vec(); + let mut weight = T::DbWeight::get().reads(1); + + if HasMigrationRun::::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) + ); + + // Only run on mainnet. + // Mainnet genesis: 0x2f0555cc76fc2840a25a6ea3b9637146806f1f44b090c175ffde2a7e5ab36c03 + let genesis_hash = frame_system::Pallet::::block_hash(BlockNumberFor::::zero()); + let genesis_bytes = genesis_hash.as_ref(); + let mainnet_genesis = + hex_literal::hex!("2f0555cc76fc2840a25a6ea3b9637146806f1f44b090c175ffde2a7e5ab36c03"); + if genesis_bytes == mainnet_genesis { + let old_hotkey_ss58 = "5GmvyePN9aYErXBBhBnxZKGoGk4LKZApE4NkaSzW62CYCYNA"; + let new_hotkey_ss58 = "5H6BqkzjYvViiqp7rQLXjpnaEmW7U9CoKxXhQ4efMqtX1mQw"; + let netuid = NetUid::from(27); + + if let (Some(old_hotkey), Some(new_hotkey)) = ( + decode_account_id32::(old_hotkey_ss58), + decode_account_id32::(new_hotkey_ss58), + ) { + // Reverting the Root Claimable because it only should happen for root subnet + Pallet::::transfer_root_claimable_for_new_hotkey(&new_hotkey, &old_hotkey); + weight.saturating_accrue(T::DbWeight::get().reads_writes(2, 2)); + + let old_alpha_values: Vec<((T::AccountId, NetUid), U64F64)> = + Alpha::::iter_prefix((&new_hotkey,)).collect(); + weight.saturating_accrue(T::DbWeight::get().reads(old_alpha_values.len() as u64)); + + let old_alpha_values_v2: Vec<((T::AccountId, NetUid), SafeFloat)> = + AlphaV2::::iter_prefix((&new_hotkey,)).collect(); + weight.saturating_accrue(T::DbWeight::get().reads(old_alpha_values_v2.len() as u64)); + + // Reverting back root claimed + for ((coldkey, netuid_alpha), alpha) in old_alpha_values { + if netuid == netuid_alpha && alpha != 0 { + Pallet::::transfer_root_claimed_for_new_keys( + netuid, + &new_hotkey, + &old_hotkey, + &coldkey, + &coldkey, + ); + + weight.saturating_accrue(T::DbWeight::get().reads_writes(2, 2)); + } + } + + // Reverting back root claimed + for ((coldkey, netuid_alpha), alpha) in old_alpha_values_v2 { + if netuid == netuid_alpha && !alpha.is_zero() { + Pallet::::transfer_root_claimed_for_new_keys( + netuid, + &new_hotkey, + &old_hotkey, + &coldkey, + &coldkey, + ); + } + } + } else { + log::error!("Failed to decode hotkeys, skipping"); + } + } + + // Mark migration as completed + HasMigrationRun::::insert(&migration_name, true); + weight.saturating_accrue(T::DbWeight::get().writes(1)); + + log::info!("Migration 'migrate_fix_root_claimed_overclaim' completed."); + + weight +} diff --git a/pallets/subtensor/src/migrations/mod.rs b/pallets/subtensor/src/migrations/mod.rs index 23a2899b94..dc47882249 100644 --- a/pallets/subtensor/src/migrations/mod.rs +++ b/pallets/subtensor/src/migrations/mod.rs @@ -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; diff --git a/pallets/subtensor/src/swap/swap_hotkey.rs b/pallets/subtensor/src/swap/swap_hotkey.rs index 1138ed1cde..8d9650a5cb 100644 --- a/pallets/subtensor/src/swap/swap_hotkey.rs +++ b/pallets/subtensor/src/swap/swap_hotkey.rs @@ -561,16 +561,9 @@ impl Pallet { 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.2. Insert the new alpha values. 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, - ); - let new_alpha = Alpha::::take((new_hotkey, &coldkey, netuid)); Alpha::::remove((old_hotkey, &coldkey, netuid)); @@ -596,10 +589,6 @@ impl Pallet { for ((coldkey, netuid_alpha), alpha) in old_alpha_values_v2 { if netuid == netuid_alpha { - Self::transfer_root_claimed_for_new_keys( - netuid, old_hotkey, new_hotkey, &coldkey, &coldkey, - ); - let new_alpha_v2 = AlphaV2::::take((new_hotkey, &coldkey, netuid)); AlphaV2::::remove((old_hotkey, &coldkey, netuid)); AlphaV2::::insert( @@ -620,6 +609,34 @@ impl Pallet { } } } + + // 9.3. Transfer root claimable and root claimed only for the root subnet + // NOTE: we shouldn't transfer root claimable and root claimed for other subnets, + // otherwise root stakers won't be able to receive dividends. + if netuid == NetUid::ROOT { + Self::transfer_root_claimable_for_new_hotkey(old_hotkey, new_hotkey); + weight.saturating_accrue(T::DbWeight::get().reads_writes(2, 2)); + + // After transfer, new_hotkey has the full RootClaimable map. + // We use it to know which subnets have outstanding claims. + let subnets: Vec = RootClaimable::::get(new_hotkey) + .keys() + .copied() + .collect(); + + for subnet in subnets { + let claimed_coldkeys: Vec = + RootClaimed::::iter_prefix((subnet, old_hotkey)) + .map(|(coldkey, _)| coldkey) + .collect(); + + for coldkey in claimed_coldkeys { + Self::transfer_root_claimed_for_new_keys( + subnet, old_hotkey, new_hotkey, &coldkey, &coldkey, + ); + } + } + } } Ok(()) diff --git a/pallets/subtensor/src/tests/claim_root.rs b/pallets/subtensor/src/tests/claim_root.rs index 0f628d6b86..e445a73c73 100644 --- a/pallets/subtensor/src/tests/claim_root.rs +++ b/pallets/subtensor/src/tests/claim_root.rs @@ -1284,22 +1284,19 @@ fn test_claim_root_with_swap_hotkey() { false, )); - // Check swapped keys claimed values - + // Check swapped keys claimed values. assert_eq!( - 0u128, + u128::from(new_stake), // It shouldn't change, because we didn't swap the root hotkey RootClaimed::::get((netuid, &hotkey, &coldkey,)) ); assert_eq!( - u128::from(new_stake), + 0u128, RootClaimed::::get((netuid, &new_hotkey, &coldkey,)) ); - assert!(!RootClaimable::::get(hotkey).contains_key(&netuid)); + assert!(RootClaimable::::get(hotkey).contains_key(&netuid)); - let _new_claimable = *RootClaimable::::get(new_hotkey) - .get(&netuid) - .expect("claimable must exist at this point"); + assert!(!RootClaimable::::get(new_hotkey).contains_key(&netuid)); }); } diff --git a/pallets/subtensor/src/tests/migration.rs b/pallets/subtensor/src/tests/migration.rs index 7f19c96b3d..d2509368bd 100644 --- a/pallets/subtensor/src/tests/migration.rs +++ b/pallets/subtensor/src/tests/migration.rs @@ -28,6 +28,7 @@ use pallet_scheduler::ScheduledOf; use scale_info::prelude::collections::VecDeque; use sp_core::{H256, U256, crypto::Ss58Codec}; use sp_io::hashing::twox_128; +use sp_runtime::AccountId32; use sp_runtime::{traits::Hash, traits::Zero}; use sp_std::marker::PhantomData; use substrate_fixed::types::extra::U2; @@ -3122,3 +3123,175 @@ fn test_migrate_coldkey_swap_scheduled_to_announcements() { ); }); } + +fn decode_account_id32_test(ss58_string: &str) -> U256 { + let account_id32: AccountId32 = AccountId32::from_ss58check(ss58_string).unwrap(); + let mut account_id32_slice: &[u8] = account_id32.as_ref(); + U256::decode(&mut account_id32_slice).unwrap() +} + +// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::migration::test_migrate_fix_root_claimed_overclaim --exact --nocapture +#[test] +fn test_migrate_fix_root_claimed_overclaim() { + use crate::migrations::migrate_fix_root_claimed_overclaim::*; + + let old_hotkey = decode_account_id32_test("5GmvyePN9aYErXBBhBnxZKGoGk4LKZApE4NkaSzW62CYCYNA"); + let new_hotkey = decode_account_id32_test("5H6BqkzjYvViiqp7rQLXjpnaEmW7U9CoKxXhQ4efMqtX1mQw"); + let coldkey = U256::from(42_u64); + + let netuid_target = NetUid::from(27_u16); + let netuid_other = NetUid::from(1_u16); + + let mainnet_genesis = + hex_literal::hex!("2f0555cc76fc2840a25a6ea3b9637146806f1f44b090c175ffde2a7e5ab36c03"); + const MIGRATION_NAME: &[u8] = b"migrate_fix_root_claimed_overclaim"; + + new_test_ext(1).execute_with(|| { + frame_system::BlockHash::::insert(0u64, H256::from_slice(&mainnet_genesis)); + + // Simulate post-bug state: + // transfer_root_claimable_for_new_hotkey wiped ALL subnets from old_hotkey + // and moved them to new_hotkey + let claimable_value_27 = I96F32::from_num(500_000_u64); + let claimable_value_other = I96F32::from_num(300_000_u64); + + RootClaimable::::mutate(new_hotkey, |map| { + map.insert(netuid_target, claimable_value_27); + map.insert(netuid_other, claimable_value_other); + }); + // old_hotkey RootClaimable is empty (wiped by bug) + + // RootClaimed watermark lives on new_hotkey for netuid=27 + let claimed_val: u128 = 999_999; + RootClaimed::::insert((netuid_target, new_hotkey, coldkey), claimed_val); + + // RootClaimed for netuid_other should not be touched (no Alpha entry) + let other_claimed_val: u128 = 111_111; + RootClaimed::::insert((netuid_other, new_hotkey, coldkey), other_claimed_val); + + // Alpha entry for new_hotkey on netuid=27 triggers transfer_root_claimed in the loop + Alpha::::insert( + (new_hotkey, coldkey, netuid_target), + U64F64::from_num(1_000_u64), + ); + // No Alpha entry for netuid_other — loop should not touch it + + assert!(!HasMigrationRun::::get(MIGRATION_NAME.to_vec())); + + let w = migrate_fix_root_claimed_overclaim::(); + assert!(!w.is_zero(), "weight must be non-zero"); + assert!(HasMigrationRun::::get(MIGRATION_NAME.to_vec())); + + // old_hotkey should have gotten back RootClaimable for both subnets + // (transfer_root_claimable_for_new_hotkey moves the entire map) + let old_claimable = RootClaimable::::get(old_hotkey); + assert!( + old_claimable.contains_key(&netuid_target), + "old_hotkey should have claimable restored for netuid=27" + ); + assert!( + old_claimable.contains_key(&netuid_other), + "old_hotkey should have claimable restored for netuid_other" + ); + assert_eq!( + old_claimable.get(&netuid_target).copied(), + Some(claimable_value_27), + ); + assert_eq!( + old_claimable.get(&netuid_other).copied(), + Some(claimable_value_other), + ); + + // new_hotkey should have lost its RootClaimable entirely + assert!( + RootClaimable::::get(new_hotkey).is_empty(), + "new_hotkey should have no claimable after migration" + ); + + // RootClaimed for netuid=27: watermark transferred from new_hotkey to old_hotkey + assert_eq!( + RootClaimed::::get((netuid_target, old_hotkey, coldkey)), + claimed_val, + ); + assert_eq!( + RootClaimed::::get((netuid_target, new_hotkey, coldkey)), + 0u128, + "RootClaimed for (netuid=27, new_hotkey, coldkey) should be cleared" + ); + + // RootClaimed for netuid_other on new_hotkey must be untouched (no Alpha entry) + assert_eq!( + RootClaimed::::get((netuid_other, new_hotkey, coldkey)), + other_claimed_val, + ); + }); + + // Check idempotency, already run -> no-op + new_test_ext(1).execute_with(|| { + frame_system::BlockHash::::insert(0u64, H256::from_slice(&mainnet_genesis)); + HasMigrationRun::::insert(MIGRATION_NAME.to_vec(), true); + + RootClaimable::::mutate(new_hotkey, |map| { + map.insert(netuid_target, I96F32::from_num(777_u64)); + }); + + let w = migrate_fix_root_claimed_overclaim::(); + assert_eq!( + w, + ::DbWeight::get().reads(1), + "second run should only read the migration flag" + ); + + assert!( + RootClaimable::::get(new_hotkey).contains_key(&netuid_target), + "second run must not modify new_hotkey data" + ); + assert!(RootClaimable::::get(old_hotkey).is_empty(),); + }); +} + +// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::migration::test_migrate_fix_root_claimed_incorrect_genesis --exact --nocapture +#[test] +fn test_migrate_fix_root_claimed_incorrect_genesis() { + use crate::migrations::migrate_fix_root_claimed_overclaim::*; + + let old_hotkey = decode_account_id32_test("5GmvyePN9aYErXBBhBnxZKGoGk4LKZApE4NkaSzW62CYCYNA"); + let new_hotkey = decode_account_id32_test("5H6BqkzjYvViiqp7rQLXjpnaEmW7U9CoKxXhQ4efMqtX1mQw"); + let coldkey = U256::from(42_u64); + + let netuid_target = NetUid::from(27_u16); + let netuid_other = NetUid::from(1_u16); + + let mainnet_genesis = + hex_literal::hex!("2f0555cc76fc2840a25a6ea3b9637146806f1f44b090c175ffde2a7e5ab36c03"); + const MIGRATION_NAME: &[u8] = b"migrate_fix_root_claimed_overclaim"; + + // CASE 2: non-mainnet genesis — full no-op + new_test_ext(1).execute_with(|| { + frame_system::BlockHash::::insert(0u64, H256::from_low_u64_be(0xdeadbeef)); + + RootClaimable::::mutate(new_hotkey, |map| { + map.insert(netuid_target, I96F32::from_num(123_u64)); + }); + Alpha::::insert( + (new_hotkey, coldkey, netuid_target), + U64F64::from_num(1_000_u64), + ); + + let w = migrate_fix_root_claimed_overclaim::(); + assert!( + !w.is_zero(), + "weight must be non-zero (writes migration flag)" + ); + assert!(HasMigrationRun::::get(MIGRATION_NAME.to_vec())); + + assert!( + RootClaimable::::get(old_hotkey).is_empty(), + "migration must not touch storage on non-mainnet" + ); + assert!( + RootClaimable::::get(new_hotkey).contains_key(&netuid_target), + "new_hotkey data must remain untouched on non-mainnet" + ); + }); +} diff --git a/pallets/subtensor/src/tests/swap_hotkey_with_subnet.rs b/pallets/subtensor/src/tests/swap_hotkey_with_subnet.rs index eb310d1202..0b61e1c3c6 100644 --- a/pallets/subtensor/src/tests/swap_hotkey_with_subnet.rs +++ b/pallets/subtensor/src/tests/swap_hotkey_with_subnet.rs @@ -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] @@ -2501,21 +2501,17 @@ fn test_revert_claim_root_with_swap_hotkey() { )); assert_eq!( - RootClaimed::::get((netuid, &hk1, &coldkey)), + RootClaimed::::get((netuid, &hk2, &coldkey)), 0u128, - "hk1 RootClaimed must be zero after swap" + "hk2 RootClaimed must be zero after swap" ); assert_eq!( - RootClaimed::::get((netuid, &hk2, &coldkey)), + RootClaimed::::get((netuid, &hk1, &coldkey)), hk1_root_claimed, "hk2 must have hk1's RootClaimed after swap" ); - assert!(!RootClaimable::::get(hk1).contains_key(&netuid)); - assert_eq!( - *RootClaimable::::get(hk2).get(&netuid).unwrap(), - hk1_claimable, - "hk2 must have hk1's RootClaimable after swap" - ); + assert!(RootClaimable::::get(hk1).contains_key(&netuid)); + assert!(!RootClaimable::::get(hk2).contains_key(&netuid)); // Revert: hk2 -> hk1 step_block(20); @@ -2546,3 +2542,272 @@ 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_claims_unchanged_if_not_root --exact --nocapture +#[test] +fn test_swap_hotkey_root_claims_unchanged_if_not_root() { + new_test_ext(1).execute_with(|| { + let owner_coldkey = U256::from(1001); + let neuron_hotkey = U256::from(1002); + let staker_coldkey = U256::from(1003); + let netuid = add_dynamic_network(&neuron_hotkey, &owner_coldkey); + let new_hotkey = U256::from(10030); + + SubtensorModule::add_balance_to_coldkey_account(&owner_coldkey, u64::MAX.into()); + SubtensorModule::set_tao_weight(u64::MAX); // Set TAO weight to 1.0 + + let root_stake = 2_000_000_000u64; + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &neuron_hotkey, + &staker_coldkey, + NetUid::ROOT, + root_stake.into(), + ); + + let initial_total_hotkey_alpha = 10_000_000_000u64; + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &neuron_hotkey, + &staker_coldkey, + netuid, + initial_total_hotkey_alpha.into(), + ); + + let validator_stake = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( + &neuron_hotkey, + &staker_coldkey, + netuid, + ); + assert_eq!(validator_stake, initial_total_hotkey_alpha.into()); + + // Distribute pending root alpha + let pending_root_alpha = 1_000_000_000u64; + SubtensorModule::distribute_emission( + netuid, + AlphaBalance::ZERO, + AlphaBalance::ZERO, + pending_root_alpha.into(), + AlphaBalance::ZERO, + ); + + assert_ok!(SubtensorModule::claim_root( + RuntimeOrigin::signed(staker_coldkey), + BTreeSet::from([netuid]) + )); + + let claimable = RootClaimable::::get(neuron_hotkey) + .get(&netuid) + .copied(); + + assert!(claimable.is_some()); + let claimable = claimable.unwrap_or_default(); + + assert!(claimable > 0); + + assert!(RootClaimed::::get((netuid, &neuron_hotkey, &staker_coldkey,)) > 0u128); + + step_block(20); + assert_ok!(SubtensorModule::do_swap_hotkey( + RuntimeOrigin::signed(owner_coldkey), + &neuron_hotkey, + &new_hotkey, + Some(netuid), + false + )); + + // Claimable and claimed should stay on old hotkey + assert_eq!( + RootClaimable::::get(neuron_hotkey) + .get(&netuid) + .copied(), + Some(claimable) + ); + assert!(RootClaimed::::get((netuid, &neuron_hotkey, &staker_coldkey,)) > 0u128); + }); +} + +// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::swap_hotkey_with_subnet::test_swap_hotkey_root_claims_changed_if_root --exact --nocapture +#[test] +fn test_swap_hotkey_root_claims_changed_if_root() { + new_test_ext(1).execute_with(|| { + let owner_coldkey = U256::from(1001); + let owner_hotkey = U256::from(1002); + + let neuron_coldkey = U256::from(1003); + let neuron_hotkey = U256::from(1004); + let neuron_hotkey_new = U256::from(1005); + + let staker_coldkey = U256::from(1006); + + let netuid_1 = add_dynamic_network(&owner_hotkey, &owner_coldkey); + register_ok_neuron(netuid_1, neuron_hotkey, neuron_coldkey, 1234); + + SubtensorModule::add_balance_to_coldkey_account(&neuron_coldkey, u64::MAX.into()); + SubtensorModule::set_tao_weight(u64::MAX); // Set TAO weight to 1.0 + + let root_stake = 2_000_000_000u64; + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &neuron_hotkey, + &staker_coldkey, + NetUid::ROOT, + root_stake.into(), + ); + + let initial_total_hotkey_alpha = 10_000_000_000u64; + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &neuron_hotkey, + &staker_coldkey, + netuid_1, + initial_total_hotkey_alpha.into(), + ); + + let validator_stake = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( + &neuron_hotkey, + &staker_coldkey, + netuid_1, + ); + assert_eq!(validator_stake, initial_total_hotkey_alpha.into()); + + // Distribute pending root alpha + let pending_root_alpha = 1_000_000_000u64; + SubtensorModule::distribute_emission( + netuid_1, + AlphaBalance::ZERO, + AlphaBalance::ZERO, + pending_root_alpha.into(), + AlphaBalance::ZERO, + ); + + assert_ok!(SubtensorModule::claim_root( + RuntimeOrigin::signed(staker_coldkey), + BTreeSet::from([netuid_1]) + )); + + let claimable = RootClaimable::::get(neuron_hotkey) + .get(&netuid_1) + .copied(); + assert!(claimable.is_some()); + let claimable = claimable.unwrap_or_default(); + + assert!(claimable > 0); + + let claimed = RootClaimed::::get((netuid_1, &neuron_hotkey, &staker_coldkey)); + assert!(claimed > 0u128); + + step_block(20); + assert_ok!(SubtensorModule::do_swap_hotkey( + RuntimeOrigin::signed(neuron_coldkey), + &neuron_hotkey, + &neuron_hotkey_new, + Some(NetUid::ROOT), + false + )); + + // Claimable and claimed should be transferred to new hotkey + assert_eq!( + RootClaimable::::get(neuron_hotkey_new) + .get(&netuid_1) + .copied(), + Some(claimable) + ); + assert_eq!( + RootClaimed::::get((netuid_1, &neuron_hotkey_new, &staker_coldkey,)), + claimed + ); + }); +} + +// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::swap_hotkey_with_subnet::test_swap_hotkey_root_claims_changed_if_all_subnets --exact --nocapture +#[test] +fn test_swap_hotkey_root_claims_changed_if_all_subnets() { + new_test_ext(1).execute_with(|| { + let owner_coldkey = U256::from(1001); + let owner_hotkey = U256::from(1002); + + let neuron_coldkey = U256::from(1003); + let neuron_hotkey = U256::from(1004); + let neuron_hotkey_new = U256::from(1005); + + let staker_coldkey = U256::from(1006); + + // Create root network + SubtensorModule::set_tao_weight(0); // Start tao weight at 0 + SubtokenEnabled::::insert(NetUid::ROOT, true); + NetworksAdded::::insert(NetUid::ROOT, true); + + let netuid_1 = add_dynamic_network(&owner_hotkey, &owner_coldkey); + register_ok_neuron(netuid_1, neuron_hotkey, neuron_coldkey, 1234); + + SubtensorModule::add_balance_to_coldkey_account(&neuron_coldkey, u64::MAX.into()); + SubtensorModule::set_tao_weight(u64::MAX); // Set TAO weight to 1.0 + + let root_stake = 2_000_000_000u64; + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &neuron_hotkey, + &staker_coldkey, + NetUid::ROOT, + root_stake.into(), + ); + + let initial_total_hotkey_alpha = 10_000_000_000u64; + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &neuron_hotkey, + &staker_coldkey, + netuid_1, + initial_total_hotkey_alpha.into(), + ); + + let validator_stake = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( + &neuron_hotkey, + &staker_coldkey, + netuid_1, + ); + assert_eq!(validator_stake, initial_total_hotkey_alpha.into()); + + // Distribute pending root alpha + let pending_root_alpha = 1_000_000_000u64; + SubtensorModule::distribute_emission( + netuid_1, + AlphaBalance::ZERO, + AlphaBalance::ZERO, + pending_root_alpha.into(), + AlphaBalance::ZERO, + ); + + assert_ok!(SubtensorModule::claim_root( + RuntimeOrigin::signed(staker_coldkey), + BTreeSet::from([netuid_1]) + )); + + let claimable = RootClaimable::::get(neuron_hotkey) + .get(&netuid_1) + .copied(); + assert!(claimable.is_some()); + let claimable = claimable.unwrap_or_default(); + + assert!(claimable > 0); + + let claimed = RootClaimed::::get((netuid_1, &neuron_hotkey, &staker_coldkey)); + assert!(claimed > 0u128); + + step_block(20); + assert_ok!(SubtensorModule::do_swap_hotkey( + RuntimeOrigin::signed(neuron_coldkey), + &neuron_hotkey, + &neuron_hotkey_new, + None, + false + )); + + // Claimable and claimed should be transferred to new hotkey + assert_eq!( + RootClaimable::::get(neuron_hotkey_new) + .get(&netuid_1) + .copied(), + Some(claimable) + ); + assert_eq!( + RootClaimed::::get((netuid_1, &neuron_hotkey_new, &staker_coldkey,)), + claimed + ); + }); +} diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 76645e790a..43955f73d7 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -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; diff --git a/ts-tests/suites/zombienet_staking/02.04-claim-root-hotkey-swap.test.ts b/ts-tests/suites/zombienet_staking/02.04-claim-root-hotkey-swap.test.ts new file mode 100644 index 0000000000..0124bae671 --- /dev/null +++ b/ts-tests/suites/zombienet_staking/02.04-claim-root-hotkey-swap.test.ts @@ -0,0 +1,285 @@ +import { expect, beforeAll } from "vitest"; +import { + addNewSubnetwork, + addStake, + burnedRegister, + forceSetBalance, + generateKeyringPair, + getRootClaimable, + startCall, + sudoSetAdminFreezeWindow, + sudoSetEmaPriceHalvingPeriod, + sudoSetLockReductionInterval, + sudoSetRootClaimThreshold, + sudoSetSubnetMovingAlpha, + sudoSetSubtokenEnabled, + sudoSetTempo, + tao, + waitForBlocks, +} from "../../utils"; +import { subtensor } from "@polkadot-api/descriptors"; +import type { TypedApi } from "polkadot-api"; +import { swapHotkey } from "../../utils/swap.ts"; +import { describeSuite } from "@moonwall/cli"; +import type { KeyringPair } from "@moonwall/util"; + +// Shared setup: creates two subnets, registers oldHotkey on both, +// stakes on ROOT and both subnets, waits for RootClaimable to accumulate. +async function setupTwoSubnetsWithClaimable( + api: TypedApi, + ROOT_NETUID: number, + log: (msg: string) => void +): Promise<{ + oldHotkey: KeyringPair; + oldHotkeyColdkey: KeyringPair; + newHotkey: KeyringPair; + netuid1: number; + netuid2: number; +}> { + const oldHotkey = generateKeyringPair("sr25519"); + const oldHotkeyColdkey = generateKeyringPair("sr25519"); + const newHotkey = generateKeyringPair("sr25519"); + const owner1Hotkey = generateKeyringPair("sr25519"); + const owner1Coldkey = generateKeyringPair("sr25519"); + const owner2Hotkey = generateKeyringPair("sr25519"); + const owner2Coldkey = generateKeyringPair("sr25519"); + + for (const kp of [ + oldHotkey, + oldHotkeyColdkey, + newHotkey, + owner1Hotkey, + owner1Coldkey, + owner2Hotkey, + owner2Coldkey, + ]) { + await forceSetBalance(api, kp.address); + } + + await sudoSetAdminFreezeWindow(api, 0); + await sudoSetSubtokenEnabled(api, ROOT_NETUID, true); + + const netuid1 = await addNewSubnetwork(api, owner1Hotkey, owner1Coldkey); + await startCall(api, netuid1, owner1Coldkey); + log(`Created netuid1: ${netuid1}`); + + const netuid2 = await addNewSubnetwork(api, owner2Hotkey, owner2Coldkey); + await startCall(api, netuid2, owner2Coldkey); + log(`Created netuid2: ${netuid2}`); + + for (const netuid of [netuid1, netuid2]) { + await sudoSetTempo(api, netuid, 1); + await sudoSetEmaPriceHalvingPeriod(api, netuid, 1); + await sudoSetRootClaimThreshold(api, netuid, 0n); + } + await sudoSetSubnetMovingAlpha(api, BigInt(4294967296)); + + // Register oldHotkey on both subnets so it appears in epoch hotkey_emission + // and receives root_alpha_dividends → RootClaimable on both netuids + await burnedRegister(api, netuid1, oldHotkey.address, oldHotkeyColdkey); + log("oldHotkey registered on netuid1"); + await burnedRegister(api, netuid2, oldHotkey.address, oldHotkeyColdkey); + log("oldHotkey registered on netuid2"); + + // ROOT stake drives root_alpha_dividends for oldHotkey + await addStake(api, oldHotkeyColdkey, oldHotkey.address, ROOT_NETUID, tao(100)); + log("Added ROOT stake for oldHotkey"); + + await addStake(api, oldHotkeyColdkey, oldHotkey.address, netuid1, tao(50)); + await addStake(api, oldHotkeyColdkey, oldHotkey.address, netuid2, tao(50)); + + await addStake(api, owner1Coldkey, owner1Hotkey.address, netuid1, tao(50)); + await addStake(api, owner2Coldkey, owner2Hotkey.address, netuid2, tao(50)); + + log("Waiting 30 blocks for RootClaimable to accumulate on both subnets..."); + await waitForBlocks(api, 30); + + return { oldHotkey, oldHotkeyColdkey, newHotkey, netuid1, netuid2 }; +} + +describeSuite({ + id: "0203_swap_hotkey_root_claimable", + title: "▶ swap_hotkey RootClaimable per-subnet transfer", + foundationMethods: "zombie", + testCases: ({ it, context, log }) => { + let api: TypedApi; + const ROOT_NETUID = 0; + + beforeAll(async () => { + api = context.papi("Node").getTypedApi(subtensor); + await sudoSetLockReductionInterval(api, 1); + }); + + it({ + id: "T01", + title: "single-subnet swap doesn't move root claimable if it is not root", + test: async () => { + const { oldHotkey, oldHotkeyColdkey, newHotkey, netuid1, netuid2 } = await setupTwoSubnetsWithClaimable( + api, + ROOT_NETUID, + log + ); + + const claimableMapBefore = await getRootClaimable(api, oldHotkey.address); + log( + `RootClaimable[oldHotkey] before swap: ${ + [...claimableMapBefore.entries()].map(([k, v]) => `netuid${k}=${v}`).join(", ") || "(none)" + }` + ); + + expect( + claimableMapBefore.get(netuid1) ?? 0n, + "oldHotkey should have RootClaimable on netuid1 before swap" + ).toBeGreaterThan(0n); + expect( + claimableMapBefore.get(netuid2) ?? 0n, + "oldHotkey should have RootClaimable on netuid2 before swap" + ).toBeGreaterThan(0n); + expect( + (await getRootClaimable(api, newHotkey.address)).size, + "newHotkey should have no RootClaimable before swap" + ).toBe(0); + + // Swap oldHotkey → newHotkey on netuid1 ONLY + log(`Swapping oldHotkey → newHotkey on netuid1=${netuid1} only...`); + await swapHotkey(api, oldHotkeyColdkey, oldHotkey.address, newHotkey.address, netuid1); + log("Swap done"); + + const oldAfter = await getRootClaimable(api, oldHotkey.address); + const newAfter = await getRootClaimable(api, newHotkey.address); + + log( + `RootClaimable[oldHotkey] after swap: netuid1=${oldAfter.get(netuid1) ?? 0n}, netuid2=${oldAfter.get(netuid2) ?? 0n}` + ); + log( + `RootClaimable[newHotkey] after swap: netuid1=${newAfter.get(netuid1) ?? 0n}, netuid2=${newAfter.get(netuid2) ?? 0n}` + ); + + expect(newAfter.get(netuid1) ?? 0n, "newHotkey should not have RootClaimable for netuid1").toEqual(0n); + expect( + oldAfter.get(netuid1) ?? 0n, + "oldHotkey should retain RootClaimable for netuid1" + ).toBeGreaterThan(0n); + + expect( + oldAfter.get(netuid2) ?? 0n, + "oldHotkey should retain RootClaimable for netuid2" + ).toBeGreaterThan(0n); + expect(newAfter.get(netuid2) ?? 0n, "newHotkey should have no RootClaimable for netuid2").toBe(0n); + + log( + "✅ Single-subnet swap doesn't transfer RootClaimable for the subnet if it was done for non-root subnet" + ); + }, + }); + + it({ + id: "T02", + title: "full swap (no netuid) moves RootClaimable for all subnets to newHotkey", + test: async () => { + const { oldHotkey, oldHotkeyColdkey, newHotkey, netuid1, netuid2 } = await setupTwoSubnetsWithClaimable( + api, + ROOT_NETUID, + log + ); + + const claimableMapBefore = await getRootClaimable(api, oldHotkey.address); + log( + `RootClaimable[oldHotkey] before swap: ${ + [...claimableMapBefore.entries()].map(([k, v]) => `netuid${k}=${v}`).join(", ") || "(none)" + }` + ); + + expect( + claimableMapBefore.get(netuid1) ?? 0n, + "oldHotkey should have RootClaimable on netuid1 before swap" + ).toBeGreaterThan(0n); + expect( + claimableMapBefore.get(netuid2) ?? 0n, + "oldHotkey should have RootClaimable on netuid2 before swap" + ).toBeGreaterThan(0n); + + // Full swap — no netuid + log("Swapping oldHotkey → newHotkey on ALL subnets..."); + await swapHotkey(api, oldHotkeyColdkey, oldHotkey.address, newHotkey.address); + log("Swap done"); + + const oldAfter = await getRootClaimable(api, oldHotkey.address); + const newAfter = await getRootClaimable(api, newHotkey.address); + + log( + `RootClaimable[oldHotkey] after swap: netuid1=${oldAfter.get(netuid1) ?? 0n}, netuid2=${oldAfter.get(netuid2) ?? 0n}` + ); + log( + `RootClaimable[newHotkey] after swap: netuid1=${newAfter.get(netuid1) ?? 0n}, netuid2=${newAfter.get(netuid2) ?? 0n}` + ); + + expect(newAfter.get(netuid1) ?? 0n, "newHotkey should have RootClaimable for netuid1").toBeGreaterThan( + 0n + ); + expect(newAfter.get(netuid2) ?? 0n, "newHotkey should have RootClaimable for netuid2").toBeGreaterThan( + 0n + ); + + expect(oldAfter.get(netuid1) ?? 0n, "oldHotkey should have no RootClaimable for netuid1").toBe(0n); + expect(oldAfter.get(netuid2) ?? 0n, "oldHotkey should have no RootClaimable for netuid2").toBe(0n); + + log("✅ Full swap correctly transferred RootClaimable for both subnets to newHotkey"); + }, + }); + + it({ + id: "T03", + title: "single-subnet swap moves root claimable if it is root", + test: async () => { + const { oldHotkey, oldHotkeyColdkey, newHotkey, netuid1, netuid2 } = await setupTwoSubnetsWithClaimable( + api, + ROOT_NETUID, + log + ); + + const claimableMapBefore = await getRootClaimable(api, oldHotkey.address); + log( + `RootClaimable[oldHotkey] before swap: ${ + [...claimableMapBefore.entries()].map(([k, v]) => `netuid${k}=${v}`).join(", ") || "(none)" + }` + ); + + expect( + claimableMapBefore.get(netuid1) ?? 0n, + "oldHotkey should have RootClaimable on netuid1 before swap" + ).toBeGreaterThan(0n); + expect( + claimableMapBefore.get(netuid2) ?? 0n, + "oldHotkey should have RootClaimable on netuid2 before swap" + ).toBeGreaterThan(0n); + + log("Swapping oldHotkey → newHotkey for root subnet..."); + await swapHotkey(api, oldHotkeyColdkey, oldHotkey.address, newHotkey.address, 0); + log("Swap done"); + + const oldAfter = await getRootClaimable(api, oldHotkey.address); + const newAfter = await getRootClaimable(api, newHotkey.address); + + log( + `RootClaimable[oldHotkey] after swap: netuid1=${oldAfter.get(netuid1) ?? 0n}, netuid2=${oldAfter.get(netuid2) ?? 0n}` + ); + log( + `RootClaimable[newHotkey] after swap: netuid1=${newAfter.get(netuid1) ?? 0n}, netuid2=${newAfter.get(netuid2) ?? 0n}` + ); + + expect(newAfter.get(netuid1) ?? 0n, "newHotkey should have RootClaimable for netuid1").toBeGreaterThan( + 0n + ); + expect(newAfter.get(netuid2) ?? 0n, "newHotkey should have RootClaimable for netuid2").toBeGreaterThan( + 0n + ); + + expect(oldAfter.get(netuid1) ?? 0n, "oldHotkey should have no RootClaimable for netuid1").toBe(0n); + expect(oldAfter.get(netuid2) ?? 0n, "oldHotkey should have no RootClaimable for netuid2").toBe(0n); + + log("✅ Single swap correctly transferred RootClaimable if it is done for root subnet"); + }, + }); + }, +}); diff --git a/ts-tests/utils/swap.ts b/ts-tests/utils/swap.ts new file mode 100644 index 0000000000..78086792a5 --- /dev/null +++ b/ts-tests/utils/swap.ts @@ -0,0 +1,19 @@ +import { waitForTransactionWithRetry } from "./transactions.js"; +import type { KeyringPair } from "@moonwall/util"; +import type { subtensor } from "@polkadot-api/descriptors"; +import type { TypedApi } from "polkadot-api"; + +export async function swapHotkey( + api: TypedApi, + coldkey: KeyringPair, + oldHotkey: string, + newHotkey: string, + netuid?: number +): Promise { + const tx = api.tx.SubtensorModule.swap_hotkey({ + hotkey: oldHotkey, + new_hotkey: newHotkey, + netuid: netuid ?? undefined, + }); + await waitForTransactionWithRetry(api, tx, coldkey, "swap_hotkey"); +}