code/pallets/subtensor/src/migrations/migrate_alpha_v2.rs

migrate_alpha_v2.rs

1,207 lines · 52,856 bytes · 5c6e83e2f4RawGitHub
//! 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<T: Config> = StorageNMap<
        Pallet<T>,
        (
            NMapKey<Blake2_128Concat, <T as frame_system::Config>::AccountId>,
            NMapKey<Blake2_128Concat, <T as frame_system::Config>::AccountId>,
            NMapKey<Identity, NetUid>,
        ),
        U64F64,
        ValueQuery,
    >;

    #[storage_alias]
    pub type TotalHotkeyShares<T: Config> = StorageDoubleMap<
        Pallet<T>,
        Blake2_128Concat,
        <T as frame_system::Config>::AccountId,
        Identity,
        NetUid,
        U64F64,
        ValueQuery,
    >;

    #[storage_alias]
    pub type AlphaMapLastKey<T: Config> = StorageValue<Pallet<T>, Option<Vec<u8>>, 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<T: Config>() -> (Vec<(T::AccountId, T::AccountId, NetUid)>, Weight) {
    let mut weight = Weight::zero();
    for (hotkey, netuid, shares) in retired::TotalHotkeyShares::<T>::drain() {
        TotalHotkeySharesV2::<T>::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::<T>::drain() {
        AlphaV2::<T>::insert((&hotkey, &coldkey, netuid), SafeFloat::from(shares));
        legacy.push((hotkey, coldkey, netuid));
        weight.saturating_accrue(T::DbWeight::get().reads_writes(1, 2));
    }
    retired::AlphaMapLastKey::<T>::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<T: Config>() {
    let _ = convert::<T>();
}

/// 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<T: Config>(hotkey: &T::AccountId, coldkey: &T::AccountId, netuid: NetUid) {
    use crate::staking::stake_utils::HotkeyAlphaSharePoolDataOperations;
    let mut ops = HotkeyAlphaSharePoolDataOperations::<T>::new(hotkey.clone(), netuid);
    if !Pallet::<T>::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::<T>::remove((hotkey, coldkey, netuid));
    AlphaShareEpoch::<T>::remove((hotkey, coldkey, netuid));
    Pallet::<T>::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<T: Config>(hotkey: &T::AccountId, coldkey: &T::AccountId, netuid: NetUid) {
    use crate::staking::stake_utils::HotkeyAlphaSharePoolDataOperations;
    let alpha = Pallet::<T>::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::<T>::new(hotkey.clone(), netuid);
    if !alpha.is_zero() {
        ops.set_shared_value(ops.get_shared_value().saturating_sub(alpha.to_u64()));
        SubnetAlphaOut::<T>::mutate(netuid, |total| *total = total.saturating_sub(alpha));
        Pallet::<T>::increase_provided_alpha_reserve(netuid, alpha);
    }
    if netuid.is_root() && !alpha.is_zero() {
        Pallet::<T>::remove_stake_adjust_root_claimed_for_hotkey_and_coldkey(
            hotkey, coldkey, alpha,
        );
    }
    remove_share::<T>(hotkey, coldkey, netuid);
    if netuid.is_root() && !Pallet::<T>::coldkey_has_root_stake(coldkey) {
        Pallet::<T>::maybe_remove_coldkey_index(coldkey);
    }
    Pallet::<T>::cleanup_lock_if_zero(coldkey, netuid);
    Pallet::<T>::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<Vec<u8>>,
    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<T: Config> = StorageValue<Pallet<T>, Progress, OptionQuery>;

pub fn in_progress<T: Config>() -> bool {
    AlphaV2Migration::<T>::get().is_some_and(|p| p.phase != 3)
}

/// Scheduling is constant work, including when the upgrade is replayed.
pub fn migrate<T: Config>() -> Weight {
    let weight = T::DbWeight::get().reads(2);
    if HasMigrationRun::<T>::get(MIGRATION_NAME) || in_progress::<T>() {
        return weight;
    }
    AlphaV2Migration::<T>::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<T: Config>(netuid: NetUid, amount: u64) -> DispatchResult {
    if amount == 0 {
        return Ok(());
    }
    let tao = TaoBalance::from(amount);
    ensure!(
        SubnetTAO::<T>::get(netuid) >= tao,
        Error::<T>::InsufficientTaoBalance
    );
    let collector =
        Pallet::<T>::get_subnet_account_id(NetUid::ROOT).ok_or(Error::<T>::SubnetNotExists)?;
    let source = Pallet::<T>::get_subnet_account_id(netuid).ok_or(Error::<T>::SubnetNotExists)?;
    if source != collector {
        Pallet::<T>::transfer_tao(&source, &collector, tao)?;
    }
    Pallet::<T>::decrease_provided_tao_reserve(netuid, tao);
    Pallet::<T>::record_tao_outflow(netuid, tao);
    TotalStake::<T>::mutate(|stake| *stake = stake.saturating_sub(tao));
    Ok(())
}

fn flush_burn<T: Config>(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::<T>::get_coldkey_balance(&burn).is_zero()
        && amount < <T as Config>::Currency::minimum_balance().max(MIN_PAYOUT_TAO.into())
    {
        return Ok(());
    }
    let collector =
        Pallet::<T>::get_subnet_account_id(NetUid::ROOT).ok_or(Error::<T>::SubnetNotExists)?;
    with_storage_layer(|| Pallet::<T>::burn_tao(&collector, amount))?;
    progress.burned = progress.burned.saturating_add(progress.pending_burn);
    progress.pending_burn = 0;
    Ok(())
}

fn convert_row<T: Config>(hotkey: &T::AccountId, coldkey: &T::AccountId, netuid: NetUid) {
    if retired::TotalHotkeyShares::<T>::contains_key(hotkey, netuid) {
        let shares = retired::TotalHotkeyShares::<T>::take(hotkey, netuid);
        TotalHotkeySharesV2::<T>::insert(hotkey, netuid, SafeFloat::from(shares));
    }
    let shares = retired::Alpha::<T>::take((hotkey, coldkey, netuid));
    AlphaV2::<T>::insert((hotkey, coldkey, netuid), SafeFloat::from(shares));
    // Do not stamp AlphaShareEpoch: doing so would revive retired positions.
    if netuid.is_root()
        && !Pallet::<T>::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, coldkey, netuid)
            .is_zero()
    {
        Pallet::<T>::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<T: Config>(
    hotkey: &T::AccountId,
    coldkey: &T::AccountId,
    netuid: NetUid,
    alpha: AlphaBalance,
) -> bool {
    coldkey != &Pallet::<T>::get_beta_escrow_account_id()
        && Pallet::<T>::ensure_available_to_unstake(coldkey, netuid, alpha).is_ok()
        && Pallet::<T>::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<T: Config>(netuid: NetUid, alpha: AlphaBalance) -> Option<u64> {
    if SubnetMechanism::<T>::get(netuid) != 1 {
        return Some(alpha.to_u64());
    }
    let order = GetTaoForAlpha::<T>::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<T: Config>(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::<T>::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::<T>::iter().next();
            if let Some((hotkey, netuid, shares)) = next {
                retired::TotalHotkeyShares::<T>::remove(&hotkey, netuid);
                TotalHotkeySharesV2::<T>::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::<T>::iter_from(key.clone()),
                None => retired::Alpha::<T>::iter(),
            };
            iter.next().map(|(key, _)| key)
        } else {
            let mut iter = match progress.after.as_ref() {
                Some(key) => AlphaV2::<T>::iter_from(key.clone()),
                None => AlphaV2::<T>::iter(),
            };
            iter.next().map(|(key, _)| key)
        };
        let Some((hotkey, coldkey, netuid)) = next else {
            if legacy {
                progress.after = None;
                if retired::Alpha::<T>::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 = <T as Config>::WeightInfo::remove_stake();
        if !used.saturating_add(quote_cost).all_lte(limit) {
            break;
        }
        used.saturating_accrue(quote_cost);
        let alpha =
            Pallet::<T>::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &coldkey, netuid);
        let executable_value = executable_tao_value::<T>(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::<T>(&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::<T>::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::<T>::iter_key_prefix(&hotkey)
                    .next()
                    .is_some()
            {
                flush_allowance = Pallet::<T>::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::<T>(&hotkey, &coldkey, netuid);
            }
            if dust {
                let burn = executable_value.unwrap_or_default();
                collect_burn::<T>(netuid, burn)?;
                delete_dust::<T>(&hotkey, &coldkey, netuid);
                Ok((burn, 0, Weight::zero()))
            } else if payout {
                ensure!(
                    coldkey != Pallet::<T>::get_beta_escrow_account_id(),
                    Error::<T>::NotEnoughStakeToWithdraw
                );
                let (tao, flush_work) = Pallet::<T>::unstake_from_subnet_with_flush_work(
                    &hotkey,
                    &coldkey,
                    &coldkey,
                    netuid,
                    alpha,
                    T::SwapInterface::min_price(),
                    true,
                    true,
                )?;
                ensure!(
                    Pallet::<T>::get_stake_for_hotkey_and_coldkey_on_subnet(
                        &hotkey, &coldkey, netuid
                    )
                    .is_zero(),
                    Error::<T>::NotEnoughStakeToWithdraw
                );
                Pallet::<T>::queue_childkey_threshold_check(&hotkey);
                Ok((
                    0,
                    tao.to_u64(),
                    Pallet::<T>::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::<T>::hashed_key_for((
                        &hotkey, &coldkey, netuid,
                    )));
                } else {
                    progress.scanned = progress.scanned.saturating_add(1);
                    progress.after =
                        Some(AlphaV2::<T>::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::<T>::hashed_key_for((
                        &hotkey, &coldkey, netuid,
                    )));
                } else {
                    break;
                }
            }
        }
    }
    if let Err(error) = flush_burn::<T>(&mut progress) {
        log::error!(target: "runtime", "AlphaV2 migration burn deferred: {error:?}");
    }
    if finished && progress.pending_burn == 0 {
        retired::AlphaMapLastKey::<T>::kill();
        HasMigrationRun::<T>::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::<T>::put(progress);
    used
}

pub struct Migration<T>(PhantomData<T>);

impl<T: Config> OnRuntimeUpgrade for Migration<T> {
    fn on_runtime_upgrade() -> Weight {
        migrate::<T>()
    }

    #[cfg(feature = "try-runtime")]
    fn pre_upgrade() -> Result<Vec<u8>, 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(<T as Config>::Currency::total_issuance().encode())
    }

    #[cfg(feature = "try-runtime")]
    fn post_upgrade(state: Vec<u8>) -> Result<(), sp_runtime::TryRuntimeError> {
        let issuance = TaoBalance::decode(&mut &state[..])
            .map_err(|_| "invalid AlphaV2 migration pre-upgrade state")?;
        ensure!(
            HasMigrationRun::<T>::get(MIGRATION_NAME) || in_progress::<T>(),
            "AlphaV2 migration was not scheduled"
        );
        ensure!(
            <T as Config>::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::<Test>();
        for _ in 0..20 {
            if !in_progress::<Test>() {
                break;
            }
            continue_migration::<Test>(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::<Test>::insert(cold, vec![hot]);
        retired::Alpha::<Test>::insert((hot, cold, netuid), U64F64::from_num(alpha));
        retired::TotalHotkeyShares::<Test>::insert(hot, netuid, U64F64::from_num(alpha));
        TotalHotkeyAlpha::<Test>::insert(hot, netuid, AlphaBalance::from(alpha));
        SubnetAlphaOut::<Test>::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 = <Test as Config>::Currency::total_issuance();
            run_batches();
            assert!(HasMigrationRun::<Test>::get(MIGRATION_NAME));
            assert!(retired::Alpha::<Test>::iter().next().is_none());
            assert!(retired::TotalHotkeyShares::<Test>::iter().next().is_none());
            assert!(!AlphaV2::<Test>::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!(<Test as Config>::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(<Test as Config>::SwapInterface::current_alpha_price(netuid));
            let executable = executable_tao_value::<Test>(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::<Test>::contains_key((hot, cold, netuid)));
            assert_eq!(
                SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hot, &cold, netuid),
                AlphaBalance::ZERO
            );
            assert!(HasMigrationRun::<Test>::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::<Test>::insert(netuid, 0);
            legacy_position(hot, cold, netuid, alpha);
            AlphaV2::<Test>::insert((hot, cold, netuid), SafeFloat::from(1u64));
            TotalHotkeySharesV2::<Test>::insert(hot, netuid, SafeFloat::from(2u64));
            run_batches();
            assert_eq!(
                AlphaV2::<Test>::get((hot, cold, netuid)),
                SafeFloat::from(alpha)
            );
            assert_eq!(
                TotalHotkeySharesV2::<Test>::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::<Test>::insert((hot, cold, netuid), U64F64::from_num(0.000001));
            TotalHotkeyAlpha::<Test>::insert(hot, netuid, AlphaBalance::from(1000u64));
            AlphaV2::<Test>::insert(
                (hot, U256::from(4), netuid),
                SafeFloat::from(U64F64::from_num(0.999999)),
            );
            run_batches();
            assert!(!AlphaV2::<Test>::contains_key((hot, cold, netuid)));
            assert!(!StakingHotkeys::<Test>::get(cold).contains(&hot));
            assert_eq!(
                TotalHotkeySharesV2::<Test>::get(hot, netuid),
                SafeFloat::from(1u64)
                    .sub(&SafeFloat::from(U64F64::from_num(0.000001)))
                    .expect("positive denominator")
            );
            assert_eq!(TotalHotkeyAlpha::<Test>::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::<Test>::insert(netuid, 0);
            legacy_position(hot, cold, netuid, 1000);
            convert_for_test::<Test>();
            let share = AlphaV2::<Test>::get((hot, cold, netuid));
            run_batches();
            assert_eq!(AlphaV2::<Test>::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::<Test>::insert(netuid, 0);
            let burn: U256 = <Test as Config>::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 _ = <Test as Config>::Currency::set_balance(&U256::from(21), TaoBalance::ZERO);
            let burn_before = SubtensorModule::get_coldkey_balance(&burn);
            let issuance = <Test as Config>::Currency::total_issuance();
            let tracked_issuance = TotalIssuance::<Test>::get();
            run_batches();
            for i in 0..5 {
                assert!(!AlphaV2::<Test>::contains_key((
                    U256::from(i + 10),
                    U256::from(i + 20),
                    netuid
                )));
                assert!(TotalHotkeySharesV2::<Test>::get(U256::from(i + 10), netuid).is_zero());
                assert_eq!(
                    TotalHotkeyAlpha::<Test>::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!(<Test as Config>::Currency::total_issuance(), issuance);
            assert_eq!(TotalIssuance::<Test>::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::<Test>::insert(netuid, 0);
            legacy_position(hot, cold, netuid, 499);
            AlphaV2::<Test>::insert((hot, other, netuid), SafeFloat::from(10_000u64));
            retired::TotalHotkeyShares::<Test>::insert(hot, netuid, U64F64::from_num(10_499));
            TotalHotkeyAlpha::<Test>::insert(hot, netuid, AlphaBalance::from(10_499u64));
            TotalAlphaStaked::<Test>::insert(netuid, AlphaBalance::from(10_499u64));
            let burn: U256 = <Test as Config>::BurnAccountId::get().into_account_truncating();
            add_balance_to_coldkey_account(&burn, 500u64.into());
            TotalStake::<Test>::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::<Test>::get(netuid);
            run_batches();
            assert!(!AlphaV2::<Test>::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::<Test>::get(hot, netuid),
                SafeFloat::from(10_000u64)
            );
            assert_eq!(TotalAlphaStaked::<Test>::get(netuid), 10_000u64.into());
            assert_eq!(SubnetAlphaOut::<Test>::get(netuid), AlphaBalance::ZERO);
            assert_eq!(
                SubnetAlphaIn::<Test>::get(netuid),
                alpha_in + AlphaBalance::from(499u64)
            );
            assert_eq!(
                SubnetTAO::<Test>::get(netuid),
                TaoBalance::from(1_000_000_000_000u64 - 499)
            );
            assert_eq!(TotalStake::<Test>::get(), SubnetTAO::<Test>::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::<Test>::insert(a, 0);
            SubnetMechanism::<Test>::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 = <Test as Config>::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 = <Test as Config>::Currency::total_issuance();
            run_batches();
            assert!(!AlphaV2::<Test>::contains_key((
                U256::from(2),
                U256::from(3),
                a
            )));
            assert!(!AlphaV2::<Test>::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!(<Test as Config>::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 _ = <Test as Config>::Currency::set_balance(&subnet_account, TaoBalance::ZERO);
            let reserves = (
                SubnetTAO::<Test>::get(netuid),
                SubnetAlphaIn::<Test>::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::<Test>::get(netuid),
                    SubnetAlphaIn::<Test>::get(netuid)
                ),
                reserves
            );
            assert_eq!(SubtensorModule::get_coldkey_balance(&cold), balance);
            assert!(retired::Alpha::<Test>::contains_key((hot, cold, netuid)));
            assert!(!HasMigrationRun::<Test>::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::<Test>();
            assert_ok!(SubtensorModule::do_lock_stake(
                &cold,
                netuid,
                &hot,
                MAX_DUST_TAO.into()
            ));
            AlphaV2::<Test>::remove((hot, cold, netuid));
            retired::Alpha::<Test>::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::<Test>::get().contains(&doomed));

            migrate::<Test>();
            for _ in 0..20 {
                if !in_progress::<Test>() {
                    break;
                }
                SubtensorModule::on_idle(0, Weight::MAX);
            }
            assert!(HasMigrationRun::<Test>::get(MIGRATION_NAME));
            assert!(retired::Alpha::<Test>::iter().next().is_none());
            assert!(AlphaV2::<Test>::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::<Test>::get().contains(&doomed) {
                    break;
                }
                SubtensorModule::on_idle(0, Weight::MAX);
            }
            assert!(!DissolveCleanupQueue::<Test>::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::<Test>::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::<Test>(&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::<Test>::insert(
                (netuid, collateral_hot, collateral_cold),
                collateral.clone(),
            );
            ColdkeyMinerCollateral::<Test>::insert(netuid, collateral_cold, protected_alpha);
            ColdkeyCollateralHotkeys::<Test>::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::<Test>::get(MIGRATION_NAME));
            assert!(retired::Alpha::<Test>::iter().next().is_none());
            assert!(AlphaV2::<Test>::contains_key((
                locked_hot,
                locked_cold,
                netuid
            )));
            assert!(AlphaV2::<Test>::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::<Test>::get((netuid, collateral_hot, collateral_cold)),
                Some(collateral),
            );
            assert_eq!(
                ColdkeyMinerCollateral::<Test>::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::<Test>::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::<Test>::contains_key((retired_hot, cold, netuid)));
            assert_eq!(
                TotalHotkeySharesV2::<Test>::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::<Test>();
            assert!(retired::Alpha::<Test>::contains_key((hot, cold, netuid)));
            assert!(!HasMigrationRun::<Test>::get(MIGRATION_NAME));
            let root = sp_io::storage::root(sp_runtime::StateVersion::V1);
            migrate::<Test>();
            assert_eq!(sp_io::storage::root(sp_runtime::StateVersion::V1), root);
            assert_eq!(continue_migration::<Test>(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::<Test>();
            let budget = Weight::from_parts(10_000_000_000, u64::MAX);
            assert!(continue_migration::<Test>(budget).all_lte(budget));
            let progress = AlphaV2Migration::<Test>::get().expect("scheduled");
            assert!(progress.legacy > 0 && progress.legacy < 20);
            assert!(!HasMigrationRun::<Test>::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::<Test>::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::<Test>(budget).all_lte(budget));
                if !in_progress::<Test>() {
                    break;
                }
            }
            assert!(HasMigrationRun::<Test>::get(MIGRATION_NAME));
            assert_eq!(
                SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hot, &cold, netuid),
                stake
            );
            assert!(retired::Alpha::<Test>::iter().next().is_none());
            assert_eq!(AlphaV2::<Test>::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::<Test>::insert(netuid, 0);
                let hot = U256::from(10);
                let cold = U256::from(20);
                legacy_position(hot, cold, netuid, alpha);
                convert_for_test::<Test>();
                let burn: U256 = <Test as Config>::BurnAccountId::get().into_account_truncating();
                add_balance_to_coldkey_account(&burn, 500u64.into());
                let balance = SubtensorModule::get_coldkey_balance(&cold);
                run_batches();
                assert!(HasMigrationRun::<Test>::get(MIGRATION_NAME));
                assert_eq!(
                    AlphaV2::<Test>::contains_key((hot, cold, netuid)),
                    alpha >= 1000
                );
                assert_eq!(SubtensorModule::get_coldkey_balance(&cold), balance);
                let progress = AlphaV2Migration::<Test>::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::<Test>();
            let original: Vec<_> = AlphaV2::<Test>::iter_keys().collect();
            let burn: U256 = <Test as Config>::BurnAccountId::get().into_account_truncating();
            add_balance_to_coldkey_account(&burn, 500u64.into());
            migrate::<Test>();
            let budget = Weight::from_parts(10_000_000_000, u64::MAX);
            assert!(continue_migration::<Test>(budget).all_lte(budget));
            let progress = AlphaV2Migration::<Test>::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::<Test>::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::<Test>::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::<Test>::get(MIGRATION_NAME));
            let progress = AlphaV2Migration::<Test>::get().expect("completed sweep");
            assert_eq!(progress.passes, 1);
            assert_eq!(progress.scanned, 21);
            assert_eq!(progress.deleted, 21);
            assert_eq!(AlphaV2::<Test>::iter().count(), 1);
            assert!(AlphaV2::<Test>::contains_key((hot, cold, netuid)));
            assert!(!AlphaV2::<Test>::contains_key((new_hot, cold, netuid)));
            assert!(retired::Alpha::<Test>::iter().next().is_none());
            assert!(retired::TotalHotkeyShares::<Test>::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::<Test>::insert(netuid, 0);
            legacy_position(U256::from(2), U256::from(3), netuid, 249);
            convert_for_test::<Test>();
            run_batches();
            let progress = AlphaV2Migration::<Test>::get().expect("pending burn");
            assert_eq!(progress.pending_burn, 249);
            assert_eq!(progress.passes, 1);
            assert_eq!(progress.scanned, 1);
            assert!(!HasMigrationRun::<Test>::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 = <Test as Config>::BurnAccountId::get().into_account_truncating();
            add_balance_to_coldkey_account(&burn, 500u64.into());
            run_batches();
            assert!(HasMigrationRun::<Test>::get(MIGRATION_NAME));
            assert_eq!(SubtensorModule::get_coldkey_balance(&burn), 749u64.into());
            let progress = AlphaV2Migration::<Test>::get().expect("settled burn");
            assert_eq!(progress.scanned, 1);
            assert_eq!(progress.passes, 1);
            assert!(AlphaV2::<Test>::contains_key((
                U256::from(2),
                U256::from(3),
                netuid
            )));
        });
    }
}