//! Finish lazy share conversion and make one dust sweep in metered idle-block batches. use crate::weights::WeightInfo; use crate::*; use frame_support::traits::fungible::Inspect; use frame_support::{ storage::with_storage_layer, storage_alias, traits::OnRuntimeUpgrade, weights::Weight, }; use share_pool::{SafeFloat, SharePoolDataOperations}; use sp_runtime::traits::AccountIdConversion; use sp_std::marker::PhantomData; use substrate_fixed::types::U64F64; use subtensor_swap_interface::{Order, SwapHandler}; pub const MIGRATION_NAME: &[u8] = b"migrate_alpha_v2_and_unstake_dust_v1"; /// Inclusive threshold in TAO rao, valued when the row is processed. pub const MAX_DUST_TAO: u64 = 3_000_000; /// Positions below this value are deleted without a coldkey payout. pub const MIN_PAYOUT_TAO: u64 = 1_000; /// Retired storage, excluded from metadata. Transitional getters and lazy writes still /// understand these keys until the bounded conversion finishes. pub mod retired { use super::*; #[storage_alias] pub type Alpha = StorageNMap< Pallet, ( NMapKey::AccountId>, NMapKey::AccountId>, NMapKey, ), U64F64, ValueQuery, >; #[storage_alias] pub type TotalHotkeyShares = StorageDoubleMap< Pallet, Blake2_128Concat, ::AccountId, Identity, NetUid, U64F64, ValueQuery, >; #[storage_alias] pub type AlphaMapLastKey = StorageValue, Option>, ValueQuery>; } /// Copy the legacy formats before invoking any V2-only runtime logic. Legacy takes /// precedence on an overlapping key, exactly as the pre-upgrade share-pool getter did. /// No epoch is stamped: doing so would revive a retired share. #[cfg(test)] fn convert() -> (Vec<(T::AccountId, T::AccountId, NetUid)>, Weight) { let mut weight = Weight::zero(); for (hotkey, netuid, shares) in retired::TotalHotkeyShares::::drain() { TotalHotkeySharesV2::::insert(&hotkey, netuid, SafeFloat::from(shares)); weight.saturating_accrue(T::DbWeight::get().reads_writes(1, 2)); } let mut legacy = Vec::new(); for ((hotkey, coldkey, netuid), shares) in retired::Alpha::::drain() { AlphaV2::::insert((&hotkey, &coldkey, netuid), SafeFloat::from(shares)); legacy.push((hotkey, coldkey, netuid)); weight.saturating_accrue(T::DbWeight::get().reads_writes(1, 2)); } retired::AlphaMapLastKey::::kill(); weight.saturating_accrue(T::DbWeight::get().reads_writes(2, 1)); (legacy, weight) } /// Historical regression fixtures cross the format-conversion boundary before exercising /// normal staking. Dust policy itself is tested through the complete migration below. #[cfg(test)] pub(crate) fn convert_for_test() { let _ = convert::(); } /// Remove a row and its live denominator contribution without reviving retired /// shares. The caller must first settle any positive value being removed. fn remove_share(hotkey: &T::AccountId, coldkey: &T::AccountId, netuid: NetUid) { use crate::staking::stake_utils::HotkeyAlphaSharePoolDataOperations; let mut ops = HotkeyAlphaSharePoolDataOperations::::new(hotkey.clone(), netuid); if !Pallet::::alpha_share_is_retired(hotkey, coldkey, netuid) { let share = ops.get_share(coldkey); if !share.is_zero() { let denominator = ops.get_denominator(); ops.set_denominator(denominator.sub(&share).unwrap_or_default()); } } AlphaV2::::remove((hotkey, coldkey, netuid)); AlphaShareEpoch::::remove((hotkey, coldkey, netuid)); Pallet::::maybe_remove_staking_hotkey(hotkey, coldkey); } /// Settle a deleted dust position at its executable valuation. There is no committed /// AMM swap: the policy burns the rounded TAO proceeds that the same fee-free alpha /// sale would return at the state observed while processing the row. /// Return the position's alpha to the protocol reserve. The caller settles all /// corresponding TAO together, in the same storage transaction. fn delete_dust(hotkey: &T::AccountId, coldkey: &T::AccountId, netuid: NetUid) { use crate::staking::stake_utils::HotkeyAlphaSharePoolDataOperations; let alpha = Pallet::::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, coldkey, netuid); // Debit the whole quoted position and then remove its complete share. Going // through the pool data operations also maintains TotalAlphaStaked. let mut ops = HotkeyAlphaSharePoolDataOperations::::new(hotkey.clone(), netuid); if !alpha.is_zero() { ops.set_shared_value(ops.get_shared_value().saturating_sub(alpha.to_u64())); SubnetAlphaOut::::mutate(netuid, |total| *total = total.saturating_sub(alpha)); Pallet::::increase_provided_alpha_reserve(netuid, alpha); } if netuid.is_root() && !alpha.is_zero() { Pallet::::remove_stake_adjust_root_claimed_for_hotkey_and_coldkey( hotkey, coldkey, alpha, ); } remove_share::(hotkey, coldkey, netuid); if netuid.is_root() && !Pallet::::coldkey_has_root_stake(coldkey) { Pallet::::maybe_remove_coldkey_index(coldkey); } Pallet::::cleanup_lock_if_zero(coldkey, netuid); Pallet::::queue_childkey_threshold_check(hotkey); } /// A cursor is saved only after a row's accounting and deletion commit together. /// phase: 0 = legacy positions, 1 = leftover denominators, 2 = V2 dust sweep. #[crate::freeze_struct("58ba3d23df9e1568")] #[derive(Encode, Decode, DecodeWithMemTracking, Clone, PartialEq, Eq, Debug, TypeInfo, Default)] pub struct Progress { pub phase: u8, pub after: Option>, pub legacy: u64, pub denominators: u64, pub scanned: u64, pub deleted: u64, pub refunded: u64, pub burned: u64, pub pending_burn: u64, /// Number of V2 records deleted in the single sweep. pub pass_deleted: u64, /// Zero until the V2 cursor is exhausted, then one even while a burn is pending. pub passes: u64, pub deferred: u64, } #[storage_alias] pub type AlphaV2Migration = StorageValue, Progress, OptionQuery>; pub fn in_progress() -> bool { AlphaV2Migration::::get().is_some_and(|p| p.phase != 3) } /// Scheduling is constant work, including when the upgrade is replayed. pub fn migrate() -> Weight { let weight = T::DbWeight::get().reads(2); if HasMigrationRun::::get(MIGRATION_NAME) || in_progress::() { return weight; } AlphaV2Migration::::put(Progress::default()); log::info!(target: "runtime", "AlphaV2 migration scheduled"); weight.saturating_add(T::DbWeight::get().writes(1)) } /// Root's existing protocol account holds forfeited TAO until a burn transfer can /// meet ED. Its reserve ledger excludes the pending amount, so it is not stake /// backing and cannot be withdrawn by stakers. Root cannot be dissolved. fn collect_burn(netuid: NetUid, amount: u64) -> DispatchResult { if amount == 0 { return Ok(()); } let tao = TaoBalance::from(amount); ensure!( SubnetTAO::::get(netuid) >= tao, Error::::InsufficientTaoBalance ); let collector = Pallet::::get_subnet_account_id(NetUid::ROOT).ok_or(Error::::SubnetNotExists)?; let source = Pallet::::get_subnet_account_id(netuid).ok_or(Error::::SubnetNotExists)?; if source != collector { Pallet::::transfer_tao(&source, &collector, tao)?; } Pallet::::decrease_provided_tao_reserve(netuid, tao); Pallet::::record_tao_outflow(netuid, tao); TotalStake::::mutate(|stake| *stake = stake.saturating_sub(tao)); Ok(()) } fn flush_burn(progress: &mut Progress) -> DispatchResult { if progress.pending_burn == 0 { return Ok(()); } let burn: T::AccountId = T::BurnAccountId::get().into_account_truncating(); let amount = TaoBalance::from(progress.pending_burn); if Pallet::::get_coldkey_balance(&burn).is_zero() && amount < ::Currency::minimum_balance().max(MIN_PAYOUT_TAO.into()) { return Ok(()); } let collector = Pallet::::get_subnet_account_id(NetUid::ROOT).ok_or(Error::::SubnetNotExists)?; with_storage_layer(|| Pallet::::burn_tao(&collector, amount))?; progress.burned = progress.burned.saturating_add(progress.pending_burn); progress.pending_burn = 0; Ok(()) } fn convert_row(hotkey: &T::AccountId, coldkey: &T::AccountId, netuid: NetUid) { if retired::TotalHotkeyShares::::contains_key(hotkey, netuid) { let shares = retired::TotalHotkeyShares::::take(hotkey, netuid); TotalHotkeySharesV2::::insert(hotkey, netuid, SafeFloat::from(shares)); } let shares = retired::Alpha::::take((hotkey, coldkey, netuid)); AlphaV2::::insert((hotkey, coldkey, netuid), SafeFloat::from(shares)); // Do not stamp AlphaShareEpoch: doing so would revive retired positions. if netuid.is_root() && !Pallet::::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, coldkey, netuid) .is_zero() { Pallet::::maybe_add_coldkey_index(coldkey); } } /// Whether the complete position can leave staking without violating a /// conviction lock or registration collateral. Protected positions still /// complete format conversion, but are retained in V2 instead of settled. fn can_settle_position( hotkey: &T::AccountId, coldkey: &T::AccountId, netuid: NetUid, alpha: AlphaBalance, ) -> bool { coldkey != &Pallet::::get_beta_escrow_account_id() && Pallet::::ensure_available_to_unstake(coldkey, netuid, alpha).is_ok() && Pallet::::ensure_hotkey_covers_collateral(coldkey, hotkey, netuid, alpha).is_ok() } /// Rounded TAO proceeds from executing the same fee-free alpha sale used by an /// actual migration payout. Dynamic swaps execute in rollback mode, while stable /// subnets use their normal 1:1 conversion. Failed quotes are preserved in V2 so /// they cannot stall format conversion or destroy a position whose value is unknown. fn executable_tao_value(netuid: NetUid, alpha: AlphaBalance) -> Option { if SubnetMechanism::::get(netuid) != 1 { return Some(alpha.to_u64()); } let order = GetTaoForAlpha::::with_amount(alpha); T::SwapInterface::swap(netuid, order, T::SwapInterface::min_price(), true, true) .ok() .map(|result| result.amount_paid_out.to_u64()) } /// Process only work which fits the remaining block weight. Protected positions /// complete format conversion without settlement. Other failed settlements leave /// the row and cursor unchanged and cannot produce a false completion marker. /// Normal staking can still update a queued position. pub fn continue_migration(limit: Weight) -> Weight { // Progress read/write, completion marker/cursor cleanup and a burn transfer. let overhead = T::DbWeight::get().reads_writes(12, 10); if !overhead.all_lte(limit) { return Weight::zero(); } let Some(mut progress) = AlphaV2Migration::::get() else { return T::DbWeight::get().reads(1); }; if progress.phase == 3 { return T::DbWeight::get().reads(1); } let mut used = overhead; let mut finished = false; // Hard cap also bounds iterations independently of the configured DB weights. for _ in 0..10_000 { if progress.phase == 2 && progress.passes != 0 { // A pending burn may need another block, but must not restart the sweep. finished = true; break; } // Reserve classification reads before loading the row or its current value. let inspect = T::DbWeight::get().reads(20); if !used.saturating_add(inspect).all_lte(limit) { break; } used.saturating_accrue(inspect); if progress.phase == 1 { let cost = T::DbWeight::get().writes(2); if !used.saturating_add(cost).all_lte(limit) { break; } let next = retired::TotalHotkeyShares::::iter().next(); if let Some((hotkey, netuid, shares)) = next { retired::TotalHotkeyShares::::remove(&hotkey, netuid); TotalHotkeySharesV2::::insert(&hotkey, netuid, SafeFloat::from(shares)); progress.denominators = progress.denominators.saturating_add(1); used.saturating_accrue(cost); } else { progress.phase = 2; } continue; } let legacy = progress.phase == 0; let next = if legacy { let mut iter = match progress.after.as_ref() { Some(key) => retired::Alpha::::iter_from(key.clone()), None => retired::Alpha::::iter(), }; iter.next().map(|(key, _)| key) } else { let mut iter = match progress.after.as_ref() { Some(key) => AlphaV2::::iter_from(key.clone()), None => AlphaV2::::iter(), }; iter.next().map(|(key, _)| key) }; let Some((hotkey, coldkey, netuid)) = next else { if legacy { progress.after = None; if retired::Alpha::::iter_keys().next().is_some() { break; // Retry failed rows next block, without starving other rows. } progress.phase = 1; } else { progress.passes = progress.passes.saturating_add(1); // Emissions and valuation changes behind the cursor may leave dust. // Completion requires one full sweep, not a globally dust-free V2 map. finished = true; break; } continue; }; // Charge the quote before executing it so a row is never inspected beyond // the caller's weight limit, including when the remaining work does not fit. let quote_cost = ::WeightInfo::remove_stake(); if !used.saturating_add(quote_cost).all_lte(limit) { break; } used.saturating_accrue(quote_cost); let alpha = Pallet::::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid); let executable_value = executable_tao_value::(netuid, alpha); let below_minimum = executable_value.is_some_and(|value| value < MIN_PAYOUT_TAO); let within_payout_limit = legacy && executable_value .is_some_and(|value| (MIN_PAYOUT_TAO..=MAX_DUST_TAO).contains(&value)); let settlement_candidate = below_minimum || within_payout_limit; let can_settle = !settlement_candidate || can_settle_position::(&hotkey, &coldkey, netuid, alpha); let dust = below_minimum && can_settle; let payout = within_payout_limit && can_settle; let mut cost = Weight::zero(); if legacy { cost.saturating_accrue(T::DbWeight::get().reads_writes(8, 8)); } let mut flush_allowance = Weight::zero(); if settlement_candidate { cost.saturating_accrue(Pallet::::staking_hotkeys_walk_actual(&coldkey)); // Removing a share also checks both alpha key prefixes and BasketClaimed, // then may read/rewrite StakingHotkeys. Reserve this before settling the row. cost.saturating_accrue(T::DbWeight::get().reads_writes(4, 1)); if netuid.is_root() && payout && PendingBasketDeposits::::iter_key_prefix(&hotkey) .next() .is_some() { flush_allowance = Pallet::::basket_flush_weight_bound(); cost.saturating_accrue(flush_allowance); } } if !used.saturating_add(cost).all_lte(limit) { break; } used.saturating_accrue(cost); let result: Result<(u64, u64, Weight), DispatchError> = with_storage_layer(|| { if legacy { convert_row::(&hotkey, &coldkey, netuid); } if dust { let burn = executable_value.unwrap_or_default(); collect_burn::(netuid, burn)?; delete_dust::(&hotkey, &coldkey, netuid); Ok((burn, 0, Weight::zero())) } else if payout { ensure!( coldkey != Pallet::::get_beta_escrow_account_id(), Error::::NotEnoughStakeToWithdraw ); let (tao, flush_work) = Pallet::::unstake_from_subnet_with_flush_work( &hotkey, &coldkey, &coldkey, netuid, alpha, T::SwapInterface::min_price(), true, true, )?; ensure!( Pallet::::get_stake_for_hotkey_and_coldkey_on_subnet( &hotkey, &coldkey, netuid ) .is_zero(), Error::::NotEnoughStakeToWithdraw ); Pallet::::queue_childkey_threshold_check(&hotkey); Ok(( 0, tao.to_u64(), Pallet::::basket_flush_weight(flush_work), )) } else { Ok((0, 0, Weight::zero())) } }); match result { Ok((burn, refund, flush_weight)) => { used = used.saturating_sub(flush_allowance.saturating_sub(flush_weight)); progress.pending_burn = progress.pending_burn.saturating_add(burn); progress.refunded = progress.refunded.saturating_add(refund); if legacy { progress.legacy = progress.legacy.saturating_add(1); progress.after = Some(retired::Alpha::::hashed_key_for(( &hotkey, &coldkey, netuid, ))); } else { progress.scanned = progress.scanned.saturating_add(1); progress.after = Some(AlphaV2::::hashed_key_for((&hotkey, &coldkey, netuid))); } if dust { progress.deleted = progress.deleted.saturating_add(1); if !legacy { progress.pass_deleted = progress.pass_deleted.saturating_add(1); } } } Err(error) => { log::error!(target: "runtime", "AlphaV2 migration row deferred: {hotkey:?}/{coldkey:?}/{netuid:?}: {error:?}"); progress.deferred = progress.deferred.saturating_add(1); if legacy { progress.after = Some(retired::Alpha::::hashed_key_for(( &hotkey, &coldkey, netuid, ))); } else { break; } } } } if let Err(error) = flush_burn::(&mut progress) { log::error!(target: "runtime", "AlphaV2 migration burn deferred: {error:?}"); } if finished && progress.pending_burn == 0 { retired::AlphaMapLastKey::::kill(); HasMigrationRun::::insert(MIGRATION_NAME, true); // Keep final counters for independent chain audits. The completion marker // gates subsequent idle calls, and phase 3 makes completion observable. progress.phase = 3; log::info!(target: "runtime", "AlphaV2 migration complete: {progress:?}"); } AlphaV2Migration::::put(progress); used } pub struct Migration(PhantomData); impl OnRuntimeUpgrade for Migration { fn on_runtime_upgrade() -> Weight { migrate::() } #[cfg(feature = "try-runtime")] fn pre_upgrade() -> Result, sp_runtime::TryRuntimeError> { // Issuance must not change: refunds and burn_tao both transfer existing TAO // out of subnet accounts. Burning here is not issuance-reducing recycling. Ok(::Currency::total_issuance().encode()) } #[cfg(feature = "try-runtime")] fn post_upgrade(state: Vec) -> Result<(), sp_runtime::TryRuntimeError> { let issuance = TaoBalance::decode(&mut &state[..]) .map_err(|_| "invalid AlphaV2 migration pre-upgrade state")?; ensure!( HasMigrationRun::::get(MIGRATION_NAME) || in_progress::(), "AlphaV2 migration was not scheduled" ); ensure!( ::Currency::total_issuance() == issuance, "AlphaV2 migration changed issuance" ); Ok(()) } } #[cfg(test)] #[allow( clippy::expect_used, reason = "fixtures require a successfully created subnet" )] mod tests { use super::*; use crate::tests::mock::*; use frame_support::assert_ok; use sp_core::U256; fn run_batches() { migrate::(); for _ in 0..20 { if !in_progress::() { break; } continue_migration::(Weight::from_parts(4_000_000_000_000, u64::MAX)); } } fn network() -> NetUid { let root = SubtensorModule::get_subnet_account_id(NetUid::ROOT).expect("root account"); add_balance_to_coldkey_account(&root, 1_000_000_000_000u64.into()); let netuid = add_dynamic_network(&U256::from(1001), &U256::from(1002)); setup_reserves( netuid, 1_000_000_000_000u64.into(), 1_000_000_000_000u64.into(), ); let account = SubtensorModule::get_subnet_account_id(netuid).expect("test subnet account"); add_balance_to_coldkey_account(&account, 1_000_000_000_000u64.into()); netuid } fn legacy_position(hot: U256, cold: U256, netuid: NetUid, alpha: u64) { assert_ok!(SubtensorModule::create_account_if_non_existent(&cold, &hot)); add_balance_to_coldkey_account(&cold, 1_000_000_000u64.into()); StakingHotkeys::::insert(cold, vec![hot]); retired::Alpha::::insert((hot, cold, netuid), U64F64::from_num(alpha)); retired::TotalHotkeyShares::::insert(hot, netuid, U64F64::from_num(alpha)); TotalHotkeyAlpha::::insert(hot, netuid, AlphaBalance::from(alpha)); SubnetAlphaOut::::mutate(netuid, |v| *v = v.saturating_add(alpha.into())); } #[test] fn inclusive_threshold_refunds_and_converts_in_batches() { new_test_ext(1).execute_with(|| { let netuid = network(); let cold = U256::from(2); let small = U256::from(3); let large = U256::from(4); legacy_position(small, cold, netuid, MAX_DUST_TAO); legacy_position(large, U256::from(5), netuid, MAX_DUST_TAO * 2); let before = SubtensorModule::get_coldkey_balance(&cold); let issuance = ::Currency::total_issuance(); run_batches(); assert!(HasMigrationRun::::get(MIGRATION_NAME)); assert!(retired::Alpha::::iter().next().is_none()); assert!(retired::TotalHotkeyShares::::iter().next().is_none()); assert!(!AlphaV2::::contains_key((small, cold, netuid))); assert!(SubtensorModule::get_coldkey_balance(&cold) > before); assert_eq!( SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( &large, &U256::from(5), netuid ), (MAX_DUST_TAO * 2).into() ); assert_eq!(::Currency::total_issuance(), issuance); let root = sp_io::storage::root(sp_runtime::StateVersion::V1); run_batches(); assert_eq!(sp_io::storage::root(sp_runtime::StateVersion::V1), root); }); } #[test] fn candidate_selection_uses_exact_executable_tao() { new_test_ext(1).execute_with(|| { let netuid = network(); setup_reserves( netuid, 1_500_000_000_000u64.into(), 1_000_000_000_000u64.into(), ); let hot = U256::from(2); let cold = U256::from(3); let alpha = 2_000_001u64; legacy_position(hot, cold, netuid, alpha); let spot = U64F64::from_num(alpha) .saturating_mul(::SwapInterface::current_alpha_price(netuid)); let executable = executable_tao_value::(netuid, alpha.into()).expect("quote"); assert!(spot > U64F64::from_num(MAX_DUST_TAO)); assert!((MIN_PAYOUT_TAO..=MAX_DUST_TAO).contains(&executable)); run_batches(); assert!(!AlphaV2::::contains_key((hot, cold, netuid))); assert_eq!( SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hot, &cold, netuid), AlphaBalance::ZERO ); assert!(HasMigrationRun::::get(MIGRATION_NAME)); }); } #[test] fn overlapping_legacy_rows_preserve_old_getter_precedence() { new_test_ext(1).execute_with(|| { let netuid = network(); let hot = U256::from(2); let cold = U256::from(3); let alpha = MAX_DUST_TAO + 1; SubnetMechanism::::insert(netuid, 0); legacy_position(hot, cold, netuid, alpha); AlphaV2::::insert((hot, cold, netuid), SafeFloat::from(1u64)); TotalHotkeySharesV2::::insert(hot, netuid, SafeFloat::from(2u64)); run_batches(); assert_eq!( AlphaV2::::get((hot, cold, netuid)), SafeFloat::from(alpha) ); assert_eq!( TotalHotkeySharesV2::::get(hot, netuid), SafeFloat::from(alpha) ); assert_eq!( SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hot, &cold, netuid), alpha.into() ); }); } #[test] fn fractional_empty_share_is_removed_from_live_denominator() { new_test_ext(1).execute_with(|| { let netuid = network(); let hot = U256::from(2); let cold = U256::from(3); legacy_position(hot, cold, netuid, 1); retired::Alpha::::insert((hot, cold, netuid), U64F64::from_num(0.000001)); TotalHotkeyAlpha::::insert(hot, netuid, AlphaBalance::from(1000u64)); AlphaV2::::insert( (hot, U256::from(4), netuid), SafeFloat::from(U64F64::from_num(0.999999)), ); run_batches(); assert!(!AlphaV2::::contains_key((hot, cold, netuid))); assert!(!StakingHotkeys::::get(cold).contains(&hot)); assert_eq!( TotalHotkeySharesV2::::get(hot, netuid), SafeFloat::from(1u64) .sub(&SafeFloat::from(U64F64::from_num(0.000001))) .expect("positive denominator") ); assert_eq!(TotalHotkeyAlpha::::get(hot, netuid), 1000u64.into()); }); } #[test] fn existing_v2_above_deletion_threshold_is_preserved() { new_test_ext(1).execute_with(|| { let netuid = network(); let hot = U256::from(2); let cold = U256::from(3); SubnetMechanism::::insert(netuid, 0); legacy_position(hot, cold, netuid, 1000); convert_for_test::(); let share = AlphaV2::::get((hot, cold, netuid)); run_batches(); assert_eq!(AlphaV2::::get((hot, cold, netuid)), share); }); } #[test] fn sub_1000_rao_positions_are_deleted_and_whole_rao_are_burned() { use sp_runtime::traits::AccountIdConversion; new_test_ext(1).execute_with(|| { let netuid = network(); // Stable pricing isolates the payout boundary from earlier reserve changes. SubnetMechanism::::insert(netuid, 0); let burn: U256 = ::BurnAccountId::get().into_account_truncating(); assert_eq!( SubtensorModule::get_coldkey_balance(&burn), TaoBalance::ZERO ); for (i, alpha) in [0, 1, 499, 999, 1000].into_iter().enumerate() { legacy_position(U256::from(i + 10), U256::from(i + 20), netuid, alpha); } // A missing destination account must not prevent deletion and burning. use frame_support::traits::fungible::Mutate; let _ = ::Currency::set_balance(&U256::from(21), TaoBalance::ZERO); let burn_before = SubtensorModule::get_coldkey_balance(&burn); let issuance = ::Currency::total_issuance(); let tracked_issuance = TotalIssuance::::get(); run_batches(); for i in 0..5 { assert!(!AlphaV2::::contains_key(( U256::from(i + 10), U256::from(i + 20), netuid ))); assert!(TotalHotkeySharesV2::::get(U256::from(i + 10), netuid).is_zero()); assert_eq!( TotalHotkeyAlpha::::get(U256::from(i + 10), netuid), AlphaBalance::ZERO ); } for i in 0..4 { let expected = if i == 1 { 0 } else { 1_000_000_000 }; assert_eq!( SubtensorModule::get_coldkey_balance(&U256::from(i + 20)), expected.into() ); } // 0 + 1 + 499 + 999, no coldkey payout. assert_eq!( SubtensorModule::get_coldkey_balance(&burn), burn_before + TaoBalance::from(1499u64) ); // Exactly 1000 is paid normally, not deleted by the burn branch. assert!( SubtensorModule::get_coldkey_balance(&U256::from(24)) > 1_000_000_000u64.into() ); assert_eq!(::Currency::total_issuance(), issuance); assert_eq!(TotalIssuance::::get(), tracked_issuance); let root = sp_io::storage::root(sp_runtime::StateVersion::V1); run_batches(); assert_eq!(sp_io::storage::root(sp_runtime::StateVersion::V1), root); }); } #[test] fn dust_deletion_keeps_other_pool_members_and_reserve_accounting_intact() { use sp_runtime::traits::AccountIdConversion; new_test_ext(1).execute_with(|| { let netuid = network(); let hot = U256::from(2); let cold = U256::from(3); let other = U256::from(4); SubnetMechanism::::insert(netuid, 0); legacy_position(hot, cold, netuid, 499); AlphaV2::::insert((hot, other, netuid), SafeFloat::from(10_000u64)); retired::TotalHotkeyShares::::insert(hot, netuid, U64F64::from_num(10_499)); TotalHotkeyAlpha::::insert(hot, netuid, AlphaBalance::from(10_499u64)); TotalAlphaStaked::::insert(netuid, AlphaBalance::from(10_499u64)); let burn: U256 = ::BurnAccountId::get().into_account_truncating(); add_balance_to_coldkey_account(&burn, 500u64.into()); TotalStake::::put(TaoBalance::from(1_000_000_000_000u64)); let account = SubtensorModule::get_subnet_account_id(netuid).expect("subnet account"); let balance = SubtensorModule::get_coldkey_balance(&account); let alpha_in = SubnetAlphaIn::::get(netuid); run_batches(); assert!(!AlphaV2::::contains_key((hot, cold, netuid))); assert_eq!( SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hot, &other, netuid), 10_000u64.into() ); assert_eq!( TotalHotkeySharesV2::::get(hot, netuid), SafeFloat::from(10_000u64) ); assert_eq!(TotalAlphaStaked::::get(netuid), 10_000u64.into()); assert_eq!(SubnetAlphaOut::::get(netuid), AlphaBalance::ZERO); assert_eq!( SubnetAlphaIn::::get(netuid), alpha_in + AlphaBalance::from(499u64) ); assert_eq!( SubnetTAO::::get(netuid), TaoBalance::from(1_000_000_000_000u64 - 499) ); assert_eq!(TotalStake::::get(), SubnetTAO::::get(netuid)); assert_eq!( SubtensorModule::get_coldkey_balance(&account), balance - TaoBalance::from(499u64) ); }); } #[test] fn dust_burns_are_aggregated_across_subnets_before_creating_burn_account() { use sp_runtime::traits::AccountIdConversion; new_test_ext(1).execute_with(|| { let a = network(); let b = add_dynamic_network(&U256::from(2001), &U256::from(2002)); setup_reserves(b, 1_000_000_000_000u64.into(), 1_000_000_000_000u64.into()); SubnetMechanism::::insert(a, 0); SubnetMechanism::::insert(b, 0); let account_a = SubtensorModule::get_subnet_account_id(a).expect("subnet a"); let account_b = SubtensorModule::get_subnet_account_id(b).expect("subnet b"); add_balance_to_coldkey_account(&account_b, 1_000_000_000_000u64.into()); legacy_position(U256::from(2), U256::from(3), a, 499); legacy_position(U256::from(4), U256::from(5), b, 501); let burn: U256 = ::BurnAccountId::get().into_account_truncating(); assert_eq!( SubtensorModule::get_coldkey_balance(&burn), TaoBalance::ZERO ); let before_a = SubtensorModule::get_coldkey_balance(&account_a); let before_b = SubtensorModule::get_coldkey_balance(&account_b); let issuance = ::Currency::total_issuance(); run_batches(); assert!(!AlphaV2::::contains_key(( U256::from(2), U256::from(3), a ))); assert!(!AlphaV2::::contains_key(( U256::from(4), U256::from(5), b ))); assert_eq!( SubtensorModule::get_coldkey_balance(&account_a), before_a - TaoBalance::from(499u64) ); assert_eq!( SubtensorModule::get_coldkey_balance(&account_b), before_b - TaoBalance::from(501u64) ); assert_eq!(SubtensorModule::get_coldkey_balance(&burn), 1000u64.into()); let transfers: Vec<_> = System::events() .into_iter() .filter_map(|record| { if let RuntimeEvent::Balances(pallet_balances::Event::Transfer { to, amount, .. }) = record.event && to == burn { Some(amount) } else { None } }) .collect(); // No individual sub-threshold transfer is sent to the initially empty account. assert_eq!(transfers, vec![TaoBalance::from(1000u64)]); assert_eq!(::Currency::total_issuance(), issuance); }); } #[test] fn failed_payout_rolls_back_and_prevents_false_completion() { new_test_ext(1).execute_with(|| { let netuid = network(); let hot = U256::from(2); let cold = U256::from(3); legacy_position(hot, cold, netuid, MAX_DUST_TAO); let subnet_account = SubtensorModule::get_subnet_account_id(netuid).expect("test subnet account"); use frame_support::traits::fungible::Mutate; let _ = ::Currency::set_balance(&subnet_account, TaoBalance::ZERO); let reserves = ( SubnetTAO::::get(netuid), SubnetAlphaIn::::get(netuid), ); let balance = SubtensorModule::get_coldkey_balance(&cold); run_batches(); assert_eq!( SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hot, &cold, netuid), MAX_DUST_TAO.into() ); assert_eq!( ( SubnetTAO::::get(netuid), SubnetAlphaIn::::get(netuid) ), reserves ); assert_eq!(SubtensorModule::get_coldkey_balance(&cold), balance); assert!(retired::Alpha::::contains_key((hot, cold, netuid))); assert!(!HasMigrationRun::::get(MIGRATION_NAME)); }); } #[test] fn permanently_locked_legacy_position_converts_and_dissolution_resumes() { new_test_ext(1).execute_with(|| { let netuid = network(); let hot = U256::from(2); let cold = U256::from(3); legacy_position(hot, cold, netuid, MAX_DUST_TAO); convert_for_test::(); assert_ok!(SubtensorModule::do_lock_stake( &cold, netuid, &hot, MAX_DUST_TAO.into() )); AlphaV2::::remove((hot, cold, netuid)); retired::Alpha::::insert((hot, cold, netuid), U64F64::from_num(MAX_DUST_TAO)); let doomed = add_dynamic_network(&U256::from(20), &U256::from(21)); assert_ok!(SubtensorModule::do_dissolve_network(doomed)); assert!(DissolveCleanupQueue::::get().contains(&doomed)); migrate::(); for _ in 0..20 { if !in_progress::() { break; } SubtensorModule::on_idle(0, Weight::MAX); } assert!(HasMigrationRun::::get(MIGRATION_NAME)); assert!(retired::Alpha::::iter().next().is_none()); assert!(AlphaV2::::contains_key((hot, cold, netuid))); assert_eq!( SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hot, &cold, netuid), MAX_DUST_TAO.into() ); assert_eq!( SubtensorModule::get_coldkey_lock(&cold, netuid).map(|state| state.locked_mass), Some(MAX_DUST_TAO.into()), ); for _ in 0..30 { if !DissolveCleanupQueue::::get().contains(&doomed) { break; } SubtensorModule::on_idle(0, Weight::MAX); } assert!(!DissolveCleanupQueue::::get().contains(&doomed)); assert!(!SubtensorModule::if_subnet_exist(doomed)); }); } #[test] fn locked_and_collateral_backed_sub_1000_rao_positions_are_preserved() { new_test_ext(1).execute_with(|| { let netuid = network(); SubnetMechanism::::insert(netuid, 0); let collateral_hot = U256::from(2); let collateral_cold = U256::from(3); let locked_hot = U256::from(4); let locked_cold = U256::from(5); let protected_alpha = AlphaBalance::from(999u64); // Seed an existing V2 row so protection is exercised during the V2 sweep. legacy_position(collateral_hot, collateral_cold, netuid, 999); convert_row::(&collateral_hot, &collateral_cold, netuid); let collateral = MinerCollateralState { locked: protected_alpha, drain_ratio: U64F64::from_num(1), min_locked: AlphaBalance::ZERO, earned: AlphaBalance::ZERO, }; MinerCollateral::::insert( (netuid, collateral_hot, collateral_cold), collateral.clone(), ); ColdkeyMinerCollateral::::insert(netuid, collateral_cold, protected_alpha); ColdkeyCollateralHotkeys::::mutate(netuid, collateral_cold, |hotkeys| { hotkeys .try_push(collateral_hot) .expect("test collateral index within bound"); }); // Seed legacy dust protected by a conviction lock. legacy_position(locked_hot, locked_cold, netuid, 999); assert_ok!(SubtensorModule::do_lock_stake( &locked_cold, netuid, &locked_hot, protected_alpha, )); run_batches(); assert!(HasMigrationRun::::get(MIGRATION_NAME)); assert!(retired::Alpha::::iter().next().is_none()); assert!(AlphaV2::::contains_key(( locked_hot, locked_cold, netuid ))); assert!(AlphaV2::::contains_key(( collateral_hot, collateral_cold, netuid ))); assert_eq!( SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet( &locked_hot, &locked_cold, netuid, ), protected_alpha, ); assert_eq!( SubtensorModule::get_coldkey_lock(&locked_cold, netuid) .map(|state| state.locked_mass), Some(protected_alpha), ); assert_eq!( MinerCollateral::::get((netuid, collateral_hot, collateral_cold)), Some(collateral), ); assert_eq!( ColdkeyMinerCollateral::::get(netuid, collateral_cold), protected_alpha, ); }); } #[test] fn escrow_and_retired_epochs_are_not_revived_or_cashed_out() { new_test_ext(1).execute_with(|| { let netuid = network(); let hot = U256::from(2); let escrow = SubtensorModule::get_beta_escrow_account_id(); legacy_position(hot, escrow, netuid, 1000); let retired_hot = U256::from(4); let cold = U256::from(5); legacy_position(retired_hot, cold, netuid, 1000); AlphaSharePoolEpoch::::insert(retired_hot, netuid, 1); let denominator = SafeFloat::from(1000u64); run_batches(); assert_eq!( SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hot, &escrow, netuid), 1000.into() ); assert!(!AlphaV2::::contains_key((retired_hot, cold, netuid))); assert_eq!( TotalHotkeySharesV2::::get(retired_hot, netuid), denominator ); }); } #[test] fn scheduling_and_insufficient_budget_do_not_scan_or_mutate_positions() { new_test_ext(1).execute_with(|| { let netuid = network(); let hot = U256::from(2); let cold = U256::from(3); legacy_position(hot, cold, netuid, 499); let before = SubtensorModule::get_coldkey_balance(&cold); migrate::(); assert!(retired::Alpha::::contains_key((hot, cold, netuid))); assert!(!HasMigrationRun::::get(MIGRATION_NAME)); let root = sp_io::storage::root(sp_runtime::StateVersion::V1); migrate::(); assert_eq!(sp_io::storage::root(sp_runtime::StateVersion::V1), root); assert_eq!(continue_migration::(Weight::zero()), Weight::zero()); assert_eq!(sp_io::storage::root(sp_runtime::StateVersion::V1), root); assert_eq!(SubtensorModule::get_coldkey_balance(&cold), before); }); } #[test] fn batches_resume_and_respect_a_top_up_between_blocks() { new_test_ext(1).execute_with(|| { let netuid = network(); for i in 10..30 { legacy_position(U256::from(i), U256::from(i + 100), netuid, 499); } migrate::(); let budget = Weight::from_parts(10_000_000_000, u64::MAX); assert!(continue_migration::(budget).all_lte(budget)); let progress = AlphaV2Migration::::get().expect("scheduled"); assert!(progress.legacy > 0 && progress.legacy < 20); assert!(!HasMigrationRun::::get(MIGRATION_NAME)); // Simulate a live share-pool write: it must read the legacy pool and // atomically promote both formats, retaining the new stake. let ((hot, cold, _), _) = retired::Alpha::::iter() .next() .expect("remaining row"); SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( &hot, &cold, netuid, 10_000_000u64.into(), ); let stake = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hot, &cold, netuid); assert!(stake > MAX_DUST_TAO.into()); for _ in 0..100 { assert!(continue_migration::(budget).all_lte(budget)); if !in_progress::() { break; } } assert!(HasMigrationRun::::get(MIGRATION_NAME)); assert_eq!( SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hot, &cold, netuid), stake ); assert!(retired::Alpha::::iter().next().is_none()); assert_eq!(AlphaV2::::iter().count(), 1); }); } #[test] fn v2_cutoff_uses_exact_executable_rao() { for alpha in [0u64, 1, 2, 998, 999, 1000, 2000] { new_test_ext(1).execute_with(|| { let netuid = network(); SubnetMechanism::::insert(netuid, 0); let hot = U256::from(10); let cold = U256::from(20); legacy_position(hot, cold, netuid, alpha); convert_for_test::(); let burn: U256 = ::BurnAccountId::get().into_account_truncating(); add_balance_to_coldkey_account(&burn, 500u64.into()); let balance = SubtensorModule::get_coldkey_balance(&cold); run_batches(); assert!(HasMigrationRun::::get(MIGRATION_NAME)); assert_eq!( AlphaV2::::contains_key((hot, cold, netuid)), alpha >= 1000 ); assert_eq!(SubtensorModule::get_coldkey_balance(&cold), balance); let progress = AlphaV2Migration::::get().expect("final counters"); assert_eq!(progress.deleted, u64::from(alpha < 1000)); assert_eq!(progress.burned, if alpha < 1000 { alpha } else { 0 }); assert_eq!(progress.pending_burn, 0); assert_eq!(progress.refunded, 0); }); } } #[test] fn v2_sweep_finishes_without_revisiting_new_dust_behind_cursor() { new_test_ext(1).execute_with(|| { let netuid = network(); for i in 10..30 { legacy_position(U256::from(i), U256::from(i + 100), netuid, 499); } convert_for_test::(); let original: Vec<_> = AlphaV2::::iter_keys().collect(); let burn: U256 = ::BurnAccountId::get().into_account_truncating(); add_balance_to_coldkey_account(&burn, 500u64.into()); migrate::(); let budget = Weight::from_parts(10_000_000_000, u64::MAX); assert!(continue_migration::(budget).all_lte(budget)); let progress = AlphaV2Migration::::get().expect("partial sweep"); assert_eq!(progress.phase, 2); assert!(progress.scanned > 0 && progress.scanned < 20); let &(hot, cold, _) = original.first().expect("initial V2 positions"); assert!(!AlphaV2::::contains_key((hot, cold, netuid))); // Normal emissions can recreate a deleted position behind the cursor. SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( &hot, &cold, netuid, 7u64.into(), ); // New positions ahead of the cursor may also be cleaned; no snapshot is needed. let new_hot = (1000..2000) .map(U256::from) .find(|candidate| { AlphaV2::::hashed_key_for((candidate, &cold, netuid)) > progress.after.clone().expect("cursor") }) .expect("new key ahead of cursor"); SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( &new_hot, &cold, netuid, 7u64.into(), ); run_batches(); assert!(HasMigrationRun::::get(MIGRATION_NAME)); let progress = AlphaV2Migration::::get().expect("completed sweep"); assert_eq!(progress.passes, 1); assert_eq!(progress.scanned, 21); assert_eq!(progress.deleted, 21); assert_eq!(AlphaV2::::iter().count(), 1); assert!(AlphaV2::::contains_key((hot, cold, netuid))); assert!(!AlphaV2::::contains_key((new_hot, cold, netuid))); assert!(retired::Alpha::::iter().next().is_none()); assert!(retired::TotalHotkeyShares::::iter().next().is_none()); }); } #[test] fn insufficient_burn_account_ed_is_persisted_and_not_reported_complete() { new_test_ext(1).execute_with(|| { let netuid = network(); SubnetMechanism::::insert(netuid, 0); legacy_position(U256::from(2), U256::from(3), netuid, 249); convert_for_test::(); run_batches(); let progress = AlphaV2Migration::::get().expect("pending burn"); assert_eq!(progress.pending_burn, 249); assert_eq!(progress.passes, 1); assert_eq!(progress.scanned, 1); assert!(!HasMigrationRun::::get(MIGRATION_NAME)); // Waiting for burn settlement must not restart a completed V2 sweep. SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( &U256::from(2), &U256::from(3), netuid, 7u64.into(), ); let burn: U256 = ::BurnAccountId::get().into_account_truncating(); add_balance_to_coldkey_account(&burn, 500u64.into()); run_batches(); assert!(HasMigrationRun::::get(MIGRATION_NAME)); assert_eq!(SubtensorModule::get_coldkey_balance(&burn), 749u64.into()); let progress = AlphaV2Migration::::get().expect("settled burn"); assert_eq!(progress.scanned, 1); assert_eq!(progress.passes, 1); assert!(AlphaV2::::contains_key(( U256::from(2), U256::from(3), netuid ))); }); } }