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

migrate_reconcile_share_pools.rs

717 lines · 27,395 bytes · c004cebf36RawGitHub
use crate::staking::stake_utils::HotkeyAlphaSharePoolDataOperations;
use crate::{Alpha, AlphaV2, Config, Event, HasMigrationRun, Pallet};
use alloc::collections::BTreeSet;
use codec::Decode;
#[cfg(feature = "try-runtime")]
use codec::Encode;
use frame_support::{traits::Get, weights::Weight};
use scale_info::prelude::string::String;
use share_pool::{SafeFloat, SharePoolDataOperations};
use sp_core::crypto::Ss58Codec;
use sp_runtime::AccountId32;
use subtensor_runtime_common::NetUid;

pub(crate) const MIGRATION_NAME: &[u8] = b"migrate_reconcile_share_pools_v1";

/// Most `Alpha` + `AlphaV2` keys this one-shot may visit on one hotkey prefix.
///
/// The walk is `(hotkey,)` across every subnet, not just the target netuid. Live mainnet
/// for the pinned target (`5DXdHix…L1S1`) is 2,376 `Alpha` + 10,273 `AlphaV2` = 12,649
/// prefix keys, and only ~58 rows on netuid 73. The old 2,048 visit cap aborted
/// `try-runtime` / skipped the write on that prefix. 65,536 is ~5× today's live
/// prefix (headroom for more nominators before the 459→464 upgrade) and still
/// bounded: RocksDbWeight at the cap is 65,536 × 25e6 ≈ 1.64e12, under the 4e12
/// block. A larger prefix is skipped, not scanned without a cap.
pub(crate) const MAX_RECONCILE_PREFIX_VISITS: u64 = 65_536;
/// Most rows of the target `(hotkey, netuid)` pool this one-shot will sum. Live
/// target has ~58 members; 1,024 is ~17× that. Not the failing cap on mainnet.
pub(crate) const MAX_RECONCILE_POOL_ROWS: u64 = 1_024;

/// Pools whose live shares no longer sum to their denominator, so a member is quoted more
/// than its fraction of the pool value. Identified by a full scan of production state at a
/// pinned block; every other pool was within rounding of its denominator.
pub(crate) const RECONCILE_TARGETS: &[(&str, u16)] =
    &[("5DXdHixxtCvoa6GHKs2Jgrdzc61882Ftx1zN2sYFQuwgL1S1", 73)];

fn decode_account_id32<T: Config>(ss58_string: &str) -> Option<T::AccountId> {
    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()
}

/// Result of a bounded walk of one pool's live shares.
pub struct LiveShareSum {
    pub sum: SafeFloat,
    pub rows: u64,
    pub visits: u64,
    pub oversized: bool,
}

/// Sum of the live (current-epoch, non-zero) shares of every row of the pool.
///
/// Walks `Alpha` and `AlphaV2` prefixes for `hotkey` without collecting every subnet into a
/// map. Stops if the prefix visit cap or the per-pool row cap is exceeded; the caller then
/// skips the write.
pub fn live_share_sum<T: Config>(hotkey: &T::AccountId, netuid: NetUid) -> LiveShareSum {
    let ops = HotkeyAlphaSharePoolDataOperations::<T>::new(hotkey.clone(), netuid);
    let mut sum = SafeFloat::zero();
    let mut rows: u64 = 0;
    let mut visits: u64 = 0;
    let mut seen: BTreeSet<T::AccountId> = BTreeSet::new();

    // Legacy first so a (coldkey, netuid) present in both maps is counted once; `try_get_share`
    // still prefers the V1 row.
    if !accumulate_prefix::<T, _>(
        Alpha::<T>::iter_prefix((hotkey.clone(),))
            .map(|((coldkey, row_netuid), _)| (coldkey, row_netuid)),
        netuid,
        &ops,
        &mut seen,
        &mut sum,
        &mut rows,
        &mut visits,
    ) {
        return LiveShareSum {
            sum,
            rows,
            visits,
            oversized: true,
        };
    }
    if !accumulate_prefix::<T, _>(
        AlphaV2::<T>::iter_prefix((hotkey,))
            .map(|((coldkey, row_netuid), _)| (coldkey, row_netuid)),
        netuid,
        &ops,
        &mut seen,
        &mut sum,
        &mut rows,
        &mut visits,
    ) {
        return LiveShareSum {
            sum,
            rows,
            visits,
            oversized: true,
        };
    }

    LiveShareSum {
        sum,
        rows,
        visits,
        oversized: false,
    }
}

/// Visit one share-map prefix. Returns `false` when a cap is hit.
fn accumulate_prefix<T, I>(
    keys: I,
    netuid: NetUid,
    ops: &HotkeyAlphaSharePoolDataOperations<T>,
    seen: &mut BTreeSet<T::AccountId>,
    sum: &mut SafeFloat,
    rows: &mut u64,
    visits: &mut u64,
) -> bool
where
    T: Config,
    I: Iterator<Item = (T::AccountId, NetUid)>,
{
    for (coldkey, row_netuid) in keys {
        *visits = visits.saturating_add(1);
        if *visits > MAX_RECONCILE_PREFIX_VISITS {
            return false;
        }
        if row_netuid != netuid || !seen.insert(coldkey.clone()) {
            continue;
        }
        *rows = rows.saturating_add(1);
        if *rows > MAX_RECONCILE_POOL_ROWS {
            return false;
        }
        if let Ok(share) = ops.try_get_share(&coldkey)
            && !share.is_zero()
        {
            // A failed add is not a partial sum we may write. Treat it as
            // oversized so the caller skips the write and does not stamp.
            match sum.add(&share) {
                Some(next) => *sum = next,
                None => return false,
            }
        }
    }
    true
}

/// Result of attempting to reconcile one target pool.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ReconcileOutcome {
    /// Denominator was rewritten to the live share sum.
    Written,
    /// Pool was already consistent, or had no live rows.
    Unchanged,
    /// Prefix or row cap hit, or a share add did not fit. Do not stamp.
    Oversized,
    /// Target SS58 did not decode. Do not stamp.
    Undecodable,
}

/// Set the pool's denominator to the sum of its live shares when they differ. Pool value is
/// untouched, so every member ends up quoted exactly its fraction of the same value. Returns
/// the weight spent and whether a write happened.
pub fn reconcile_pool<T: Config>(
    hotkey: &T::AccountId,
    netuid: NetUid,
) -> (Weight, ReconcileOutcome) {
    let scan = live_share_sum::<T>(hotkey, netuid);
    // One read per prefix key visited, plus share + epoch per matching row.
    let mut weight = T::DbWeight::get().reads(
        scan.visits
            .saturating_add(scan.rows.saturating_mul(2))
            .saturating_add(3),
    );
    if scan.oversized {
        log::warn!(
            "Migration skipped an oversized pool (visits={}, rows={})",
            scan.visits,
            scan.rows
        );
        return (weight, ReconcileOutcome::Oversized);
    }
    if scan.sum.is_zero() {
        // No live rows: nothing to reconcile against. Left for a product decision.
        return (weight, ReconcileOutcome::Unchanged);
    }
    let mut ops = HotkeyAlphaSharePoolDataOperations::<T>::new(hotkey.clone(), netuid);
    let denominator = ops.get_denominator();
    if !scan.sum.gt(&denominator) && !denominator.gt(&scan.sum) {
        return (weight, ReconcileOutcome::Unchanged);
    }
    ops.set_denominator(scan.sum);
    weight.saturating_accrue(T::DbWeight::get().writes(2));
    Pallet::<T>::deposit_event(Event::SharePoolDenominatorReconciled {
        hotkey: hotkey.clone(),
        netuid,
    });
    (weight, ReconcileOutcome::Written)
}

/// Stamp only when every target decoded and was in-bound. An oversized or
/// undecodable target must stay retryable on a later spec.
fn should_stamp_reconcile(outcomes: &[ReconcileOutcome]) -> bool {
    !outcomes.is_empty()
        && outcomes.iter().all(|outcome| {
            matches!(
                outcome,
                ReconcileOutcome::Written | ReconcileOutcome::Unchanged
            )
        })
}

/// Retry after spec 464: same targets, same stamp rule. Needed because 464
/// stamped `migrate_reconcile_share_pools_v1` even when a target was skipped.
pub const MIGRATION_NAME_V2: &[u8] = b"migrate_reconcile_share_pools_v2";

/// One-shot for the 464 key. v2 is the only walker on this spec.
///
/// Do not catch up after v2 stamps. try-runtime-cli re-runs
/// `on_runtime_upgrade` and compares storage; a late v1 walk/stamp
/// changes `HasMigrationRun` on that second pass and fails
/// idempotency. Live 459→465 already paid one prefix walk in v2.
/// Already-stamped v1 (464→465) stays a single read. A later spec
/// that drops v2 can walk v1 to stamp it.
pub fn migrate_reconcile_share_pools<T: Config>() -> Weight {
    let _ = HasMigrationRun::<T>::get(MIGRATION_NAME);
    T::DbWeight::get().reads(1)
}

pub fn migrate_reconcile_share_pools_v2<T: Config>() -> Weight {
    migrate_reconcile_share_pools_named::<T>(MIGRATION_NAME_V2)
}

fn migrate_reconcile_share_pools_named<T: Config>(migration_name: &[u8]) -> Weight {
    let mut weight = T::DbWeight::get().reads(1);
    if HasMigrationRun::<T>::get(migration_name) {
        return weight;
    }
    let mut reconciled: u32 = 0;
    let mut outcomes: sp_std::vec::Vec<ReconcileOutcome> = sp_std::vec::Vec::new();
    for (ss58, netuid) in RECONCILE_TARGETS {
        let Some(hotkey) = decode_account_id32::<T>(ss58) else {
            log::warn!(
                "Migration '{}' skipped an undecodable target",
                String::from_utf8_lossy(migration_name)
            );
            outcomes.push(ReconcileOutcome::Undecodable);
            continue;
        };
        let (spent, outcome) = reconcile_pool::<T>(&hotkey, NetUid::from(*netuid));
        weight.saturating_accrue(spent);
        if outcome == ReconcileOutcome::Written {
            reconciled = reconciled.saturating_add(1);
        }
        outcomes.push(outcome);
    }
    if should_stamp_reconcile(&outcomes) {
        HasMigrationRun::<T>::insert(migration_name, true);
        weight.saturating_accrue(T::DbWeight::get().writes(1));
        log::info!(
            "Migration '{}' completed: {} pools reconciled",
            String::from_utf8_lossy(migration_name),
            reconciled
        );
    } else {
        log::warn!(
            "Migration '{}' did not stamp HasMigrationRun ({} pools written); retry later",
            String::from_utf8_lossy(migration_name),
            reconciled
        );
    }
    weight
}

/// `Σ shares` is within one part in 10^12 of the denominator (the rounding the share
/// arithmetic itself allows) for a pool with live rows.
#[cfg(any(feature = "try-runtime", test))]
pub fn pool_is_consistent<T: Config>(hotkey: &T::AccountId, netuid: NetUid) -> bool {
    let scan = live_share_sum::<T>(hotkey, netuid);
    if scan.oversized {
        return false;
    }
    let sum = scan.sum;
    if sum.is_zero() {
        return true;
    }
    let ops = HotkeyAlphaSharePoolDataOperations::<T>::new(hotkey.clone(), netuid);
    let denominator = ops.get_denominator();
    let one = SafeFloat::new(1, 0).unwrap_or_default();
    let trillion = SafeFloat::new(1, 12).unwrap_or_default();
    let tolerance = denominator
        .mul_div(&one, &trillion)
        .unwrap_or_else(SafeFloat::zero);
    let upper = denominator
        .add(&tolerance)
        .unwrap_or_else(|| denominator.clone());
    let lower = denominator.sub(&tolerance).unwrap_or_else(SafeFloat::zero);
    !sum.gt(&upper) && !lower.gt(&sum)
}

/// Shared try-runtime state for v1 and v2. Stamp rule matches production:
/// skip (oversized / undecodable) → no stamp.
#[cfg(feature = "try-runtime")]
#[derive(Encode, Decode)]
struct ReconcileTargetSnap {
    value: u64,
    rows: u64,
    oversized: bool,
    decodable: bool,
}

#[cfg(feature = "try-runtime")]
#[derive(Encode, Decode)]
struct ReconcilePreUpgradeState {
    already_run: bool,
    /// Live v1 is a no-op (one marker read). Treat any unstamped v1 as
    /// deferred so try-runtime does not require a stamp after v2.
    deferred_to_v2: bool,
    targets: sp_std::vec::Vec<ReconcileTargetSnap>,
}

#[cfg(feature = "try-runtime")]
fn reconcile_pre_upgrade<T: Config>(
    migration_name: &[u8],
) -> Result<sp_std::vec::Vec<u8>, sp_runtime::TryRuntimeError> {
    use crate::TotalHotkeyAlpha;
    use codec::Encode;
    use subtensor_runtime_common::Token;

    let already_run = HasMigrationRun::<T>::get(migration_name.to_vec());
    let deferred_to_v2 = migration_name == MIGRATION_NAME && !already_run;

    let mut targets = sp_std::vec::Vec::new();
    for (ss58, netuid) in RECONCILE_TARGETS {
        match decode_account_id32::<T>(ss58) {
            None => targets.push(ReconcileTargetSnap {
                value: 0,
                rows: 0,
                oversized: false,
                decodable: false,
            }),
            Some(hotkey) => {
                let netuid = NetUid::from(*netuid);
                let scan = live_share_sum::<T>(&hotkey, netuid);
                targets.push(ReconcileTargetSnap {
                    value: TotalHotkeyAlpha::<T>::get(&hotkey, netuid).to_u64(),
                    rows: scan.rows,
                    oversized: scan.oversized,
                    decodable: true,
                });
            }
        }
    }
    Ok(ReconcilePreUpgradeState {
        already_run,
        deferred_to_v2,
        targets,
    }
    .encode())
}

#[cfg(feature = "try-runtime")]
fn reconcile_post_upgrade<T: Config>(
    migration_name: &[u8],
    state: sp_std::vec::Vec<u8>,
) -> Result<(), sp_runtime::TryRuntimeError> {
    use crate::TotalHotkeyAlpha;
    use frame_support::ensure;
    use subtensor_runtime_common::Token;

    let before: ReconcilePreUpgradeState =
        Decode::decode(&mut &state[..]).map_err(|_| "pre_upgrade state must decode")?;
    let stamped = HasMigrationRun::<T>::get(migration_name.to_vec());
    let skip = before
        .targets
        .iter()
        .any(|snap| !snap.decodable || snap.oversized);

    if before.deferred_to_v2 {
        ensure!(!stamped, "v1 must not stamp when v2 is the walker");
    } else if before.already_run {
        ensure!(stamped, "already-run marker must stay set");
    } else if skip {
        ensure!(!stamped, "skip must not stamp");
    } else {
        ensure!(stamped, "in-bound reconcile must stamp");
    }

    for ((ss58, netuid), snap) in RECONCILE_TARGETS.iter().zip(before.targets.iter()) {
        if !snap.decodable {
            continue;
        }
        let hotkey = decode_account_id32::<T>(ss58).ok_or("target hotkey must decode")?;
        let netuid = NetUid::from(*netuid);
        let scan = live_share_sum::<T>(&hotkey, netuid);
        ensure!(
            TotalHotkeyAlpha::<T>::get(&hotkey, netuid).to_u64() == snap.value,
            "reconciliation must not change pool value"
        );
        ensure!(
            scan.rows == snap.rows,
            "reconciliation must not add or remove rows"
        );
        if !before.already_run && !before.deferred_to_v2 && !skip {
            ensure!(
                pool_is_consistent::<T>(&hotkey, netuid),
                "target pool shares must sum to its denominator"
            );
        }
    }
    Ok(())
}

/// [`OnRuntimeUpgrade`](frame_support::traits::OnRuntimeUpgrade) wrapper with try-runtime
/// validation: every target pool ends with `Σ shares == D` (within rounding), its value and
/// row set are unchanged, and the marker is set.
pub mod reconcile_share_pools {
    use super::*;
    use frame_support::traits::OnRuntimeUpgrade;
    use sp_std::marker::PhantomData;

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

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

        #[cfg(feature = "try-runtime")]
        fn pre_upgrade() -> Result<sp_std::vec::Vec<u8>, sp_runtime::TryRuntimeError> {
            reconcile_pre_upgrade::<T>(MIGRATION_NAME)
        }

        #[cfg(feature = "try-runtime")]
        fn post_upgrade(state: sp_std::vec::Vec<u8>) -> Result<(), sp_runtime::TryRuntimeError> {
            reconcile_post_upgrade::<T>(MIGRATION_NAME, state)
        }
    }
}

/// Retry after spec 464: same targets and stamp rule, new `HasMigrationRun` key.
/// try-runtime hooks are the v1 checks against this marker.
pub mod reconcile_share_pools_v2 {
    use super::*;
    use frame_support::traits::OnRuntimeUpgrade;
    use sp_std::marker::PhantomData;

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

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

        #[cfg(feature = "try-runtime")]
        fn pre_upgrade() -> Result<sp_std::vec::Vec<u8>, sp_runtime::TryRuntimeError> {
            reconcile_pre_upgrade::<T>(MIGRATION_NAME_V2)
        }

        #[cfg(feature = "try-runtime")]
        fn post_upgrade(state: sp_std::vec::Vec<u8>) -> Result<(), sp_runtime::TryRuntimeError> {
            reconcile_post_upgrade::<T>(MIGRATION_NAME_V2, state)
        }
    }
}

#[cfg(test)]
#[allow(
    clippy::arithmetic_side_effects,
    clippy::unwrap_used,
    clippy::expect_used,
    clippy::indexing_slicing
)]
mod tests {
    use super::*;
    use crate::{tests::mock::*, *};
    use sp_core::U256;

    // A pool whose shares add up to more than its denominator quotes a member more than its
    // fraction; the reconciliation sets the denominator to the sum so quotes are exact again
    // and the pool value is untouched.
    #[test]
    fn divergent_pool_denominator_becomes_the_share_sum() {
        new_test_ext(1).execute_with(|| {
            let netuid = NetUid::from(2);
            let hotkey = U256::from(1);
            let (alice, bob) = (U256::from(11), U256::from(12));
            add_network(netuid, 1, 0);
            SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet(
                &hotkey,
                &alice,
                netuid,
                1_000_000u64.into(),
            );
            SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet(
                &hotkey,
                &bob,
                netuid,
                1_000_000u64.into(),
            );
            // Pre-fix state: one member's share row inflated beyond what the denominator covers.
            let inflated = SafeFloat::new(1_500_000, 0).unwrap();
            AlphaV2::<Test>::insert((hotkey, alice, netuid), inflated.clone());
            assert!(!pool_is_consistent::<Test>(&hotkey, netuid));
            let value_before = TotalHotkeyAlpha::<Test>::get(hotkey, netuid);
            let alice_quote_before = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(
                &hotkey, &alice, netuid,
            );

            let (_, changed) = reconcile_pool::<Test>(&hotkey, netuid);
            assert_eq!(changed, ReconcileOutcome::Written);

            let ops = HotkeyAlphaSharePoolDataOperations::<Test>::new(hotkey, netuid);
            let expected = inflated
                .add(&SafeFloat::new(1_000_000, 0).unwrap())
                .unwrap();
            assert!(!ops.get_denominator().gt(&expected) && !expected.gt(&ops.get_denominator()));
            assert_eq!(TotalHotkeyAlpha::<Test>::get(hotkey, netuid), value_before);
            assert!(pool_is_consistent::<Test>(&hotkey, netuid));
            // Quotes now split the value by the real share fractions: 1.5M / 2.5M of 2M.
            let alice_quote = SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(
                &hotkey, &alice, netuid,
            );
            let bob_quote =
                SubtensorModule::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &bob, netuid);
            assert!(alice_quote < alice_quote_before);
            assert_eq!(alice_quote, 1_200_000u64.into());
            assert_eq!(bob_quote, 800_000u64.into());

            // Idempotent: a second pass finds nothing to change.
            let (_, changed_again) = reconcile_pool::<Test>(&hotkey, netuid);
            assert_eq!(changed_again, ReconcileOutcome::Unchanged);
        });
    }

    // Healthy pools and pools without live rows are left alone; the one-shot runs once.
    #[test]
    fn healthy_and_rowless_pools_are_left_alone() {
        new_test_ext(1).execute_with(|| {
            let netuid = NetUid::from(2);
            let hotkey = U256::from(1);
            add_network(netuid, 1, 0);
            SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet(
                &hotkey,
                &U256::from(11),
                netuid,
                1_000_000u64.into(),
            );
            let denominator = TotalHotkeySharesV2::<Test>::get(hotkey, netuid);
            let (_, changed) = reconcile_pool::<Test>(&hotkey, netuid);
            assert_eq!(changed, ReconcileOutcome::Unchanged);
            let now = TotalHotkeySharesV2::<Test>::get(hotkey, netuid);
            assert!(!now.gt(&denominator) && !denominator.gt(&now));

            // Value with a denominator but no rows: untouched.
            let stranded = U256::from(2);
            TotalHotkeyAlpha::<Test>::insert(stranded, netuid, AlphaBalance::from(5_000_000u64));
            TotalHotkeySharesV2::<Test>::insert(
                stranded,
                netuid,
                SafeFloat::new(5_000_000, 0).unwrap(),
            );
            let (_, changed) = reconcile_pool::<Test>(&stranded, netuid);
            assert_eq!(changed, ReconcileOutcome::Unchanged);
            assert_eq!(
                TotalHotkeyAlpha::<Test>::get(stranded, netuid),
                5_000_000u64.into()
            );

            assert!(!HasMigrationRun::<Test>::get(MIGRATION_NAME.to_vec()));
            assert!(!HasMigrationRun::<Test>::get(MIGRATION_NAME_V2.to_vec()));
            let deferred = migrate_reconcile_share_pools::<Test>();
            assert_eq!(
                deferred,
                <Test as frame_system::Config>::DbWeight::get().reads(1)
            );
            assert!(!HasMigrationRun::<Test>::get(MIGRATION_NAME.to_vec()));
            migrate_reconcile_share_pools_v2::<Test>();
            assert!(HasMigrationRun::<Test>::get(MIGRATION_NAME_V2.to_vec()));
            let second = migrate_reconcile_share_pools_v2::<Test>();
            assert_eq!(
                second,
                <Test as frame_system::Config>::DbWeight::get().reads(1)
            );
        });
    }

    #[test]
    fn skip_outcomes_do_not_stamp() {
        assert!(should_stamp_reconcile(&[ReconcileOutcome::Written]));
        assert!(should_stamp_reconcile(&[ReconcileOutcome::Unchanged]));
        assert!(should_stamp_reconcile(&[
            ReconcileOutcome::Written,
            ReconcileOutcome::Unchanged
        ]));
        assert!(!should_stamp_reconcile(&[ReconcileOutcome::Oversized]));
        assert!(!should_stamp_reconcile(&[ReconcileOutcome::Undecodable]));
        assert!(!should_stamp_reconcile(&[
            ReconcileOutcome::Written,
            ReconcileOutcome::Undecodable
        ]));
        assert!(!should_stamp_reconcile(&[]));
    }

    fn target_hotkey_and_netuid() -> (U256, NetUid) {
        let (ss58, netuid) = RECONCILE_TARGETS[0];
        (
            decode_account_id32::<Test>(ss58).expect("pinned target decodes in tests"),
            NetUid::from(netuid),
        )
    }

    /// v1 already stamped must not block v2: a still-divergent target is repaired,
    /// v2 stamps, and a second run is a one-read no-op.
    #[test]
    fn v2_repairs_after_v1_stamp_and_is_idempotent() {
        new_test_ext(1).execute_with(|| {
            let (hotkey, netuid) = target_hotkey_and_netuid();
            let (alice, bob) = (U256::from(11), U256::from(12));
            add_network(netuid, 1, 0);
            SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet(
                &hotkey,
                &alice,
                netuid,
                1_000_000u64.into(),
            );
            SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet(
                &hotkey,
                &bob,
                netuid,
                1_000_000u64.into(),
            );
            AlphaV2::<Test>::insert(
                (hotkey, alice, netuid),
                SafeFloat::new(1_500_000, 0).unwrap(),
            );
            assert!(!pool_is_consistent::<Test>(&hotkey, netuid));
            let value_before = TotalHotkeyAlpha::<Test>::get(hotkey, netuid);

            HasMigrationRun::<Test>::insert(MIGRATION_NAME.to_vec(), true);
            assert!(!HasMigrationRun::<Test>::get(MIGRATION_NAME_V2.to_vec()));

            migrate_reconcile_share_pools_v2::<Test>();

            assert!(HasMigrationRun::<Test>::get(MIGRATION_NAME.to_vec()));
            assert!(HasMigrationRun::<Test>::get(MIGRATION_NAME_V2.to_vec()));
            assert!(pool_is_consistent::<Test>(&hotkey, netuid));
            assert_eq!(TotalHotkeyAlpha::<Test>::get(hotkey, netuid), value_before);

            let second = migrate_reconcile_share_pools_v2::<Test>();
            assert_eq!(
                second,
                <Test as frame_system::Config>::DbWeight::get().reads(1)
            );
            assert!(HasMigrationRun::<Test>::get(MIGRATION_NAME_V2.to_vec()));
        });
    }

    /// 459→465: v1 must not walk; v2 is the only walker.
    #[test]
    fn v1_defers_walk_when_v2_will_run() {
        new_test_ext(1).execute_with(|| {
            let (hotkey, netuid) = target_hotkey_and_netuid();
            let (alice, bob) = (U256::from(11), U256::from(12));
            add_network(netuid, 1, 0);
            SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet(
                &hotkey,
                &alice,
                netuid,
                1_000_000u64.into(),
            );
            SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet(
                &hotkey,
                &bob,
                netuid,
                1_000_000u64.into(),
            );
            AlphaV2::<Test>::insert(
                (hotkey, alice, netuid),
                SafeFloat::new(1_500_000, 0).unwrap(),
            );
            assert!(!pool_is_consistent::<Test>(&hotkey, netuid));

            migrate_reconcile_share_pools::<Test>();
            assert!(!HasMigrationRun::<Test>::get(MIGRATION_NAME.to_vec()));
            assert!(!pool_is_consistent::<Test>(&hotkey, netuid));

            migrate_reconcile_share_pools_v2::<Test>();
            assert!(HasMigrationRun::<Test>::get(MIGRATION_NAME_V2.to_vec()));
            assert!(pool_is_consistent::<Test>(&hotkey, netuid));

            // Second hook (try-runtime idempotency) must not stamp or walk.
            let again = migrate_reconcile_share_pools::<Test>();
            assert_eq!(
                again,
                <Test as frame_system::Config>::DbWeight::get().reads(1)
            );
            assert!(!HasMigrationRun::<Test>::get(MIGRATION_NAME.to_vec()));
        });
    }

    /// An oversized target must not stamp v2, so a later spec can retry.
    #[test]
    fn v2_skip_leaves_marker_clear() {
        new_test_ext(1).execute_with(|| {
            let (hotkey, netuid) = target_hotkey_and_netuid();
            add_network(netuid, 1, 0);
            for i in 0..=MAX_RECONCILE_POOL_ROWS {
                AlphaV2::<Test>::insert(
                    (hotkey, U256::from(10_000 + i), netuid),
                    SafeFloat::new(1, 0).unwrap(),
                );
            }
            migrate_reconcile_share_pools_v2::<Test>();
            assert!(!HasMigrationRun::<Test>::get(MIGRATION_NAME_V2.to_vec()));
        });
    }
}