code/pallets/subtensor/src/staking/claim_root.rs

claim_root.rs

1,516 lines · 72,285 bytes · f9e58e7394RawGitHub
use super::basket_flush::{BasketFlushWork, MAX_BASKET_ROWS};
use super::*;
use crate::weights::WeightInfo;
use frame_support::storage::{TransactionOutcome, with_transaction};
use frame_support::weights::{Weight, WeightMeter};
use sp_core::Get;
use sp_runtime::DispatchError;
use sp_runtime::traits::{AccountIdConversion, Zero};
use sp_std::collections::btree_map::BTreeMap;
use substrate_fixed::types::I96F32;
use subtensor_runtime_common::clear_prefix_with_meter;
use subtensor_swap_interface::{SwapFailureKind, SwapHandler};

/// A drained fund (shares outstanding but NAV marked at zero) may revive via a par mint
/// only when the stale shares are rounding dust: at most `value / DRAINED_FUND_DUST_DIVISOR`
/// (so stale holders capture <= ~1% of the reviving deposit).
const DRAINED_FUND_DUST_DIVISOR: u64 = 100;

/// Work actually performed by a fund-level root claim, used to size post-dispatch weight
/// (and aggregated across hotkeys for coldkey-wide claims).
#[derive(Default, Clone, Copy)]
pub struct RootClaimOutcome {
    /// TAO realized and staked back to root for the staker.
    pub tao: u64,
    /// Escrow holding rows scanned (each is a sim-swap valuation plus reads).
    pub rows: u32,
    /// Holdings actually redeemed (pro-rata take > 0: a swap plus stake writes).
    pub realized: u32,
    /// Dust holdings consolidated into the root slot (one swap each).
    pub swept: u32,
    /// Work spent flushing the hotkey's pending dividend credits before redeeming (priced
    /// by `basket_flush_weight`, the model every flushing extrinsic shares).
    pub flush: BasketFlushWork,
}

impl RootClaimOutcome {
    fn accumulate(&mut self, other: Self) {
        self.tao = self.tao.saturating_add(other.tao);
        self.rows = self.rows.saturating_add(other.rows);
        self.realized = self.realized.saturating_add(other.realized);
        self.swept = self.swept.saturating_add(other.swept);
        self.flush = self.flush.saturating_add(other.flush);
    }
}

impl<T: Config> Pallet<T> {
    /// Reject basket / root-stake mutations and subnet dissolution while the
    /// `migrate_seed_beta_basket_v2` cursor is present. Deposits, claims, swaps, root stake
    /// add/remove/transfer, and dissolution hard-error here.
    pub(crate) fn ensure_beta_basket_seed_idle() -> Result<(), Error<T>> {
        ensure!(
            !crate::migrations::migrate_seed_beta_basket::seed_beta_basket_v2_in_progress::<T>(),
            Error::<T>::BetaBasketSeedInProgress
        );
        Ok(())
    }

    /// The single global escrow coldkey that custodies every validator's basket.
    ///
    /// A validator's basket (fund) holdings are positions `(validator_hotkey, this_account,
    /// netuid)` in the normal alpha share pool, so they count toward each validator's stake and
    /// compound with that validator's dividends, while the account itself stays inert (no user
    /// controls it). A single global coldkey is used deliberately: positions stay distinct per
    /// validator via the hotkey key, and hotkey swaps migrate them by value automatically.
    pub fn get_beta_escrow_account_id() -> T::AccountId {
        T::SubtensorPalletId::get().into_sub_account_truncating(b"beta/esc")
    }

    /// A validator's basket holdings: every `(netuid, alpha)` position the escrow custodies for
    /// this hotkey, including the root slot (the fund's TAO/cash position, valued 1:1).
    pub fn get_basket_holdings(hotkey: &T::AccountId) -> Vec<(NetUid, AlphaBalance)> {
        let escrow = Self::get_beta_escrow_account_id();
        Self::alpha_iter_prefix((hotkey, &escrow))
            .map(|(netuid, _)| {
                (
                    netuid,
                    Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, &escrow, netuid),
                )
            })
            .filter(|(_, alpha)| !alpha.is_zero())
            .collect()
    }

    /// `a * b / denom` computed in u128 so the u64*u64 product cannot overflow, saturated
    /// back to u64 (a u64*u64 product can exceed U96F32's 96 integer bits at chain-scale
    /// magnitudes, which would silently saturate fixed-point math). Returns 0 when `denom`
    /// is zero.
    pub(crate) fn mul_div_u64(a: u64, b: u64, denom: u64) -> u64 {
        u128::from(a)
            .saturating_mul(u128::from(b))
            .checked_div(u128::from(denom))
            .unwrap_or(0)
            .min(u128::from(u64::MAX)) as u64
    }

    /// Fund shares to mint for `value` TAO of realizable value entering a fund with
    /// pre-deposit NAV `nav_before` and `shares_outstanding` (`P`) shares outstanding:
    /// `value * P / N` (deposit-at-NAV, so existing holders are neither diluted nor
    /// gifted). First deposit mints at par.
    fn basket_shares_for_value(value: u64, nav_before: u64, shares_outstanding: u64) -> u64 {
        if shares_outstanding == 0 {
            // Genuine first deposit: mint at par (1 share per TAO of value added).
            return value;
        }
        if nav_before == 0 {
            // Shares are outstanding but the fund marks to zero, so there is no NAV to
            // price a mint against. A par mint would hand the stale holders
            // `S_old / (S_old + minted)` of the fresh deposit. Tolerate that only when
            // the stale shares are rounding dust left by a full drain, so a drained fund
            // can revive; otherwise mint nothing and let the caller reject or recycle
            // the deposit rather than misprice it.
            if shares_outstanding <= value.saturating_div(DRAINED_FUND_DUST_DIVISOR) {
                return value;
            }
            return 0;
        }
        Self::mul_div_u64(value, shares_outstanding, nav_before)
    }

    /// Shared tail of both dividend deposit flows: attribute the value added between real
    /// stakers and the fund's own escrow slot, mint fund shares at the pre-deposit NAV, and
    /// advance the per-validator claimable rate. Errors on a dust deposit so the caller rolls
    /// back and re-queues (or recycles only when the credit is unapportionable).
    pub(super) fn mint_basket_dividend_shares(
        hotkey: &T::AccountId,
        nav_before: u64,
        value_added: u64,
        total_root: u64,
        escrow_root: u64,
    ) -> DispatchResult {
        let shares_outstanding: u64 = BasketShares::<T>::get(hotkey);

        // Attribution: the dividend was earned by the whole root stake, escrow slot
        // included. Only the real stakers' fraction mints shares; the escrow slot's
        // fraction stays unminted so its value raises N/P for existing share holders
        // (the fund's own cash yield belongs to the fund).
        let stakers_value: u64 = Self::mul_div_u64(
            value_added,
            total_root,
            total_root.saturating_add(escrow_root),
        );

        // Mint fund shares at the pre-deposit NAV: shares = stakers_value * P / N. A
        // deposit into an already-compounded fund (N/P > 1) mints fewer shares than TAO
        // added, so N/P is left unchanged.
        let shares: u64 =
            Self::basket_shares_for_value(stakers_value, nav_before, shares_outstanding);

        // Per-staker claimable rate increment: fund shares per unit of root stake.
        let increment: I96F32 = I96F32::saturating_from_num(shares)
            .checked_div(I96F32::saturating_from_num(total_root))
            .unwrap_or(I96F32::saturating_from_num(0));

        // Dust deposit (shares or rate round to zero): roll everything back so
        // `Σ owed == BasketShares` is never broken by uncredited value. The caller
        // re-queues the credit for a later attempt.
        ensure!(
            shares > 0 && increment != I96F32::saturating_from_num(0),
            DispatchError::Other("basket deposit too small")
        );

        // `nav_before == 0` with outstanding shares means `basket_shares_for_value`
        // took its dust-revival branch: this par mint starts a new fund life, so the
        // previous life's display baseline/TWR must not describe it.
        if nav_before == 0 && shares_outstanding > 0 {
            Self::retire_beta_display_state(hotkey);
        }

        BasketShares::<T>::mutate(hotkey, |p| *p = p.saturating_add(shares));
        BasketRate::<T>::mutate(hotkey, |rate| *rate = rate.saturating_add(increment));
        // Canonical staker total-return series (display state, see `BasketTwr`).
        Self::accrue_basket_twr(hotkey, stakers_value, total_root);
        BasketDepositedTao::<T>::mutate(hotkey, |total| {
            *total = total.saturating_add(value_added.into())
        });

        Self::deposit_event(Event::BasketDeposited {
            hotkey: hotkey.clone(),
            tao: value_added.into(),
            shares,
        });

        Ok(())
    }

    /// How a direct deposit is split across a fund, given its valued holdings (from
    /// [`Self::try_valued_basket_holdings`]).
    ///
    /// A fund with holdings is *mirrored*: each holding's slice is its realizable TAO value
    /// as a share of NAV, so the deposit buys exactly the exposure the fund already has.
    /// This is what makes a deposit's new shares a fair claim on the existing portfolio: a
    /// depositor could otherwise depress one holding, mint shares at the lower NAV without
    /// buying any of it, and redeem the recovery. Inflows never change composition; only
    /// [`Self::do_swap_basket`] does.
    ///
    /// A fund with no holdings has nothing to mirror. Its first deposit is held as TAO in
    /// the fund's root (cash) slot, valued 1:1, so the fund opens at par with no trade and no
    /// price impact on any pool; it takes on subnet exposure only as dividends arrive as
    /// alpha or the validator trades.
    ///
    /// A holding whose realizable value is zero (terminal garbage or unpriceable dust) has
    /// no fair slice, so the deposit is rejected (`AmountTooLow`): shares must not be sold
    /// against a position nobody can price. Every returned slice weight is positive, so the
    /// weight sum is positive.
    pub(super) fn basket_deployment_split(
        valued_holdings: &[(NetUid, AlphaBalance, u64)],
    ) -> Result<Vec<(NetUid, u64)>, DispatchError> {
        if valued_holdings.is_empty() {
            return Ok(vec![(NetUid::ROOT, 1)]);
        }
        valued_holdings
            .iter()
            .map(|(netuid, _, value)| {
                ensure!(*value > 0, Error::<T>::AmountTooLow);
                Ok((*netuid, *value))
            })
            .collect()
    }

    /// The direct-deposit engine: values the fund's holdings once, splits `tao` across them
    /// by realizable value ([`Self::basket_deployment_split`]), moves each slice from
    /// `coldkey` to the destination subnet account, buys that subnet's alpha into the escrow
    /// position (swap fees charged), and holds a root-slot slice as root stake (TAO at 1:1,
    /// mirroring `swap_tao_for_alpha`'s reserve bookkeeping by hand). The last slot absorbs
    /// the rounding remainder so the split sums exactly. Each buy is booked as protocol
    /// inflow: claims book the escrow's sells as outflow regardless of how the alpha
    /// entered, so entries must record the matching inflow or round trips skew the flow EMA.
    ///
    /// The deposit must buy every mirrored holding: a slice that rounds to zero TAO, or a
    /// buy that rounds to zero alpha, rejects the deposit (`AmountTooLow`) — otherwise a
    /// heavily discounted holding would reopen the free-repricing window at integer
    /// precision, or the deposit would silently donate TAO to a pool.
    ///
    /// Returns `(nav_before, value_added)`: the realizable NAV snapshotted immediately before
    /// the buys (the same valuation the split is taken from), and the realizable NAV the
    /// deployment actually added (ΔNAV, post-buy minus pre-buy). Both snapshots are marked
    /// identically in the same block, so the difference isolates exactly this deposit's
    /// effect — the deposit bears its own buy slippage/fees (a realizable delta is bounded by
    /// the TAO deployed, never amplified by the buys' own price impact on existing holdings
    /// the way a spot delta would be).
    ///
    /// Not transactional by itself: the caller runs it inside `with_transaction` and rolls
    /// back on error.
    pub(super) fn deploy_tao_into_basket(
        hotkey: &T::AccountId,
        coldkey: &T::AccountId,
        tao: u64,
    ) -> Result<(u64, u64), DispatchError> {
        let escrow = Self::get_beta_escrow_account_id();
        let valued_holdings = Self::try_valued_basket_holdings(hotkey)?;
        let nav_before: u64 = valued_holdings
            .iter()
            .fold(0u64, |nav, (_, _, value)| nav.saturating_add(*value));
        let split = Self::basket_deployment_split(&valued_holdings)?;
        let weight_sum: u64 = split
            .iter()
            .fold(0u64, |sum, (_, weight)| sum.saturating_add(*weight));

        let mut spent: u64 = 0;
        let last_idx = split.len().saturating_sub(1);
        for (i, (dest_netuid, weight)) in split.iter().enumerate() {
            // Last slot absorbs the rounding remainder so Σ tao_s == tao exactly.
            let tao_s: u64 = if i == last_idx {
                tao.saturating_sub(spent)
            } else {
                Self::mul_div_u64(tao, *weight, weight_sum)
            };
            ensure!(tao_s > 0, Error::<T>::AmountTooLow);
            spent = spent.saturating_add(tao_s);

            // Physically move the staker's TAO to the destination subnet account.
            let transferred = Self::transfer_tao_to_subnet(*dest_netuid, coldkey, tao_s.into())?;
            ensure!(
                transferred == TaoBalance::from(tao_s),
                Error::<T>::InsufficientTaoBalance
            );

            if dest_netuid.is_root() {
                Self::credit_root_slot(hotkey, &escrow, tao_s.into());
                continue;
            }
            let bought = Self::swap_basket_tao_for_alpha_chunks(*dest_netuid, tao_s.into())?;
            ensure!(!bought.is_zero(), Error::<T>::AmountTooLow);
            // Record the buy as protocol inflow (TAO entered the pool).
            Self::record_protocol_inflow(*dest_netuid, tao_s.into());
            Self::increase_stake_for_hotkey_and_coldkey_on_subnet(
                hotkey,
                &escrow,
                *dest_netuid,
                bought,
            );
        }

        let nav_after = Self::try_get_validator_basket_nav_tao(hotkey)?;
        Ok((nav_before, nav_after.saturating_sub(nav_before)))
    }

    /// Stakes `tao` from `coldkey`'s free balance directly into a root-registered
    /// validator's basket. The TAO enters by the fund's *current holdings*: it is split
    /// across them pro-rata by realizable value (each holding's share of NAV) and buys each
    /// one, so the deposit acquires exactly the exposure the fund already has (see
    /// [`Self::basket_deployment_split`] for why anything else is exploitable). A fund with
    /// no holdings holds its first deposit as the root (TAO cash) slot at NAV. Inflows never
    /// change a fund's composition; only the validator does, deliberately, with
    /// [`Self::do_swap_basket`].
    ///
    /// The resulting fund shares are credited to the staker through their signed claimed
    /// watermark — `owed = rate * root_stake - claimed`, so a negative watermark credit is
    /// an unconditional share grant that needs no root stake and survives stake-change
    /// rebasing (which is additive).
    ///
    /// Shares are priced at the pre-buy realizable NAV, then capped by the smallest fraction
    /// of any existing holding that the deposit actually acquired. The cap is the economic
    /// invariant required by proportional-alpha redemption: an immediate claim cannot sell
    /// more units of any holding than the deposit bought. Any acquisition beyond that common
    /// fraction remains in the fund for existing holders. Unlike dividend deposits there is no
    /// attribution split among root stakers. `BasketRate` is untouched — direct shares buy fund
    /// exposure, they do not change any staker's dividend accrual.
    pub fn do_stake_into_basket(
        coldkey: T::AccountId,
        hotkey: T::AccountId,
        tao: TaoBalance,
    ) -> Result<Weight, DispatchError> {
        Self::ensure_beta_basket_seed_idle()?;
        ensure!(
            Self::hotkey_account_exists(&hotkey),
            Error::<T>::HotKeyAccountNotExists
        );
        // Direct deposits open per-(caller, validator) entitlement state. Restricting
        // the target to a live root uid caps the validator axis at MaxAllowedUids on
        // root, the same bound as a normal root-stake position.
        ensure!(
            Self::is_hotkey_registered_on_network(NetUid::ROOT, &hotkey),
            Error::<T>::HotKeyNotRegisteredInSubNet
        );
        // Deposit queued dividend credits first so the share mint below prices against
        // the fund's full, current NAV. The flush work is priced into the post-dispatch
        // weight; the declared weight carries its flat allowance.
        let (flush_work, _, _) = Self::flush_basket_deposits_for_hotkey(&hotkey);
        ensure!(tao >= DefaultMinStake::<T>::get(), Error::<T>::AmountTooLow);
        ensure!(
            Self::can_remove_balance_from_coldkey_account(&coldkey, tao.into()),
            Error::<T>::NotEnoughBalanceToStake
        );

        // The deployment slots are the holdings the deposit mirrors, or the single root
        // cash slot that opens an empty fund. Each slot can add at most one new holding, so
        // pre-deploy holdings plus the slot count bounds the holdings the two NAV
        // valuations will sweep.
        let holdings = Self::get_basket_holdings(&hotkey);
        let num_slots = (holdings.len() as u64).max(1);
        let num_holdings = (holdings.len() as u64).saturating_add(num_slots);

        with_transaction(|| {
            match Self::try_stake_into_basket(&coldkey, &hotkey, tao, &holdings) {
                Ok(()) => TransactionOutcome::Commit(Ok(())),
                Err(err) => TransactionOutcome::Rollback(Err(err)),
            }
        })?;

        // A fund's very first successful mint stamps its frozen display baseline
        // (index splice). No-op (one read) for every later deposit.
        let stamp_work = Self::stamp_beta_baseline_if_new(&hotkey);

        Ok(
            Self::stake_into_basket_weight(num_slots, num_holdings.saturating_add(stamp_work))
                .saturating_add(Self::basket_flush_weight(flush_work)),
        )
    }

    /// Transactional body of [`Self::do_stake_into_basket`]; any error rolls the whole
    /// deposit back, including the balance transfers.
    fn try_stake_into_basket(
        coldkey: &T::AccountId,
        hotkey: &T::AccountId,
        tao: TaoBalance,
        holdings_before: &[(NetUid, AlphaBalance)],
    ) -> DispatchResult {
        let shares_outstanding: u64 = BasketShares::<T>::get(hotkey);

        // Deploy the staker's TAO across the basket by its current holdings. Price the
        // result by its ΔNAV, then apply the per-holding quantity bound below.
        let (nav_before, value_added) =
            Self::deploy_tao_into_basket(hotkey, coldkey, tao.to_u64())?;

        let nav_priced_shares: u64 =
            Self::basket_shares_for_value(value_added, nav_before, shares_outstanding);
        // Full-liquidation NAV is nonlinear: allocating TAO by holding value does not
        // necessarily buy the same asset fraction in every pool. Redemption, however, takes
        // the same share fraction of every holding. Bound the mint by the least-covered
        // holding so an immediate redemption cannot sell more units than this deposit added:
        //
        //     minted / P <= added_i / held_i
        //
        // which is equivalent to the post-mint redemption condition
        // `minted / (P + minted) * (held_i + added_i) <= added_i`.
        let shares = if holdings_before.is_empty() || shares_outstanding == 0 {
            nav_priced_shares
        } else {
            let escrow = Self::get_beta_escrow_account_id();
            holdings_before
                .iter()
                .fold(nav_priced_shares, |covered, (netuid, held_before)| {
                    let held_after =
                        Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, &escrow, *netuid)
                            .to_u64();
                    let added = held_after.saturating_sub(held_before.to_u64());
                    covered.min(Self::mul_div_u64(
                        added,
                        shares_outstanding,
                        held_before.to_u64(),
                    ))
                })
        };
        ensure!(shares > 0, Error::<T>::AmountTooLow);

        // `nav_before == 0` with outstanding shares means `basket_shares_for_value`
        // took its dust-revival branch: this par mint starts a new fund life, so the
        // previous life's display baseline/TWR must not describe it.
        if nav_before == 0 && shares_outstanding > 0 {
            Self::retire_beta_display_state(hotkey);
        }

        BasketShares::<T>::mutate(hotkey, |p| *p = p.saturating_add(shares));
        Self::grant_basket_shares(hotkey, coldkey, shares);
        BasketDepositedTao::<T>::mutate(hotkey, |total| {
            *total = total.saturating_add(value_added.into())
        });

        // Make sure claims (which walk `StakingHotkeys`) can find this position.
        let mut staking_hotkeys = StakingHotkeys::<T>::get(coldkey);
        if !staking_hotkeys.contains(hotkey) {
            staking_hotkeys.push(hotkey.clone());
            StakingHotkeys::<T>::insert(coldkey, staking_hotkeys);
        }
        Self::maybe_add_coldkey_index(coldkey);

        Self::deposit_event(Event::BasketStakedIn {
            hotkey: hotkey.clone(),
            coldkey: coldkey.clone(),
            tao,
            value: value_added.into(),
            shares,
        });

        Ok(())
    }

    /// Actual weight of a `stake_into_basket` call that deployed across `num_slots`
    /// deployment slots (the mirrored holdings, or the root cash slot of an empty fund) with
    /// `num_holdings` basket holdings. Per slot: a balance transfer to the subnet
    /// account, a swap, the escrow stake write, and protocol-flow bookkeeping. Per holding:
    /// two `sim_swap` valuations (the `nav_before` / `nav_after` sweeps), plus one stake-position
    /// lookup to verify the quantity acquired for direct-deposit share issuance.
    pub(crate) fn stake_into_basket_weight(num_slots: u64, num_holdings: u64) -> Weight {
        Weight::from_parts(25_000_000, 4000)
            .saturating_add(T::DbWeight::get().reads(6_u64))
            .saturating_add(T::DbWeight::get().writes(5_u64))
            .saturating_mul(num_slots.max(1))
            .saturating_add(Self::basket_nav_sweep_weight(num_holdings))
            .saturating_add(T::DbWeight::get().reads(num_holdings.saturating_mul(5_u64)))
            .saturating_add(T::DbWeight::get().reads_writes(8_u64, 6_u64))
    }

    /// Pre-dispatch weight of `stake_into_basket`: a cap sized for the row cap of holdings
    /// as deployment slots over the row cap of holdings, plus the flat pending-deposit flush allowance
    /// ([`Self::basket_flush_weight_bound`]) shared by every extrinsic that flushes. Refunded
    /// to actual post-dispatch.
    pub(crate) fn stake_into_basket_declared_weight() -> Weight {
        Self::stake_into_basket_weight(MAX_BASKET_ROWS, MAX_BASKET_ROWS)
            .saturating_add(Self::basket_flush_weight_bound())
    }

    /// Weight of one realizable-NAV sweep over `num_holdings` escrow rows: one sim-swap
    /// valuation plus reads per row. Shared by every basket path that values the fund.
    pub(crate) fn basket_nav_sweep_weight(num_holdings: u64) -> Weight {
        Weight::from_parts(10_000_000, 1000)
            .saturating_add(T::DbWeight::get().reads(4_u64))
            .saturating_mul(num_holdings.max(1))
    }

    /// A staker's gross *fund-share* entitlement on a validator: `BasketRate * root_stake`.
    /// Shares, not TAO — convert with `basket_payout_from` / `get_basket_payout_tao`.
    pub fn get_basket_claimable_shares(hotkey: &T::AccountId, coldkey: &T::AccountId) -> I96F32 {
        let root_stake: I96F32 = I96F32::saturating_from_num(
            Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, coldkey, NetUid::ROOT),
        );
        BasketRate::<T>::get(hotkey).saturating_mul(root_stake)
    }

    fn get_basket_owed_shares_float(hotkey: &T::AccountId, coldkey: &T::AccountId) -> I96F32 {
        let claimable = Self::get_basket_claimable_shares(hotkey, coldkey);

        // Subtract the already-claimed watermark (signed: unstake rebasing can push it below
        // zero) to avoid over- or under-claiming.
        let claimed: I96F32 = I96F32::saturating_from_num(BasketClaimed::<T>::get(hotkey, coldkey));

        claimable.saturating_sub(claimed)
    }

    /// A staker's net owed *fund shares* on a validator (floored at zero). Shares, not TAO.
    pub fn get_basket_owed_shares(hotkey: &T::AccountId, coldkey: &T::AccountId) -> u64 {
        let owed = Self::get_basket_owed_shares_float(hotkey, coldkey);
        if owed.is_negative() {
            0
        } else {
            owed.saturating_to_num::<u64>()
        }
    }

    /// Claims (redeems) a staker's share of a validator's basket.
    ///
    /// Redemption is fund-level and NAV-proportional: the staker's owed shares define a
    /// fraction `f = owed / P` of the fund's full-liquidation NAV. Exactly that fraction of
    /// every holding's alpha is removed and sold, but the claimant receives at most `f` of
    /// that holding's pre-sale realizable value. A concave AMM curve makes selling `f` of a
    /// holding realize more than `f` of the proceeds from selling the whole holding; that
    /// surplus is retained in the fund's root (TAO cash) slot for the remaining shareholders.
    /// The root-slot portion is reassigned directly because it is already TAO. A holding on a
    /// terminally shallow pool is removed as an explicit pro-rata write-off instead of aborting
    /// healthy slots; unknown swap or accounting errors still roll back the claim.
    ///
    /// Before redeeming, the fund's dust holdings (subnet rows realizably below the claim
    /// threshold) are consolidated into its root slot (see
    /// [`Self::consolidate_dust_basket_holdings`]); consolidation commits even when the
    /// redemption below no-ops or rolls back, so stale holding rows — and with them every
    /// staker's per-row claim weight — decay instead of persisting forever.
    ///
    /// Returns a [`RootClaimOutcome`]: the TAO realized (zero for every no-op path) plus
    /// the work counters the dispatcher charges weight from.
    pub fn root_claim_for_hotkey(
        hotkey: &T::AccountId,
        coldkey: &T::AccountId,
        ignore_minimum_condition: bool,
    ) -> Result<RootClaimOutcome, DispatchError> {
        let mut outcome = RootClaimOutcome::default();

        // Deposit any queued dividend credits first so the claim redeems against the
        // fund's full, current state. The flush work is priced into the outcome.
        let (flush_work, _, _) = Self::flush_basket_deposits_for_hotkey(hotkey);
        outcome.flush = flush_work;

        let owed_shares: u64 = Self::get_basket_owed_shares(hotkey, coldkey);
        if owed_shares == 0 {
            return Ok(outcome); // no-op
        }

        let shares_total: u64 = BasketShares::<T>::get(hotkey);
        // Nothing realizable yet (fund drained); leave the watermark untouched so the claim can
        // pay out once the fund has value again.
        if shares_total == 0 {
            return Ok(outcome);
        }
        // A claim can never redeem more than the outstanding fund.
        let owed_shares = owed_shares.min(shares_total);

        // Consolidate dust holdings first, outside the redemption transaction, so the
        // cleanup sticks regardless of how the claim itself resolves.
        outcome.swept = Self::consolidate_dust_basket_holdings(hotkey);

        let holdings = Self::get_basket_holdings(hotkey);
        outcome.rows = holdings.len() as u32;

        // Dust check against the estimated payout (owed fraction of the marked NAV).
        // Keep each slot's pre-sale value as well as the total: redemption caps every
        // slot independently at the same NAV fraction. Without that cap, selling a raw
        // alpha fraction on a concave AMM curve overpays the first redeemer and transfers
        // the loss to the remaining shareholders.
        let mut valued_holdings: Vec<(NetUid, AlphaBalance, u64, bool)> = Vec::new();
        for (netuid, alpha) in holdings {
            match Self::try_realizable_tao_for_alpha(netuid, alpha.to_u64())? {
                Some(value) => valued_holdings.push((netuid, alpha, value, false)),
                None => valued_holdings.push((netuid, alpha, 0, true)),
            }
        }
        let has_terminal_garbage = valued_holdings.iter().any(|(_, _, _, garbage)| *garbage);
        let nav: u64 = valued_holdings
            .iter()
            .fold(0u64, |acc, (_, _, value, _)| acc.saturating_add(*value));
        let estimated_payout: u64 = Self::basket_payout_from(owed_shares, nav, shares_total);
        if !ignore_minimum_condition
            && !has_terminal_garbage
            && I96F32::saturating_from_num(estimated_payout)
                < RootClaimableThreshold::<T>::get(NetUid::ROOT)
        {
            log::debug!(
                "root claim skipped (below threshold): payout={estimated_payout:?} h={hotkey:?} c={coldkey:?}"
            );
            return Ok(outcome); // no-op
        }
        if estimated_payout == 0 && !has_terminal_garbage {
            return Ok(outcome);
        }

        let escrow = Self::get_beta_escrow_account_id();

        // Redeemed slots are counted outside the transaction: a rolled-back redemption
        // still executed its swaps, so the work is charged either way.
        let realized = &mut outcome.realized;
        outcome.tao = with_transaction(|| {
            // TAO credited to the staker's root stake, split by source: the root-slot portion is
            // a stake reassignment (no new TAO on root), while subnet sells realize new TAO that
            // must also be credited to the root reserves.
            let mut root_slot_tao: u64 = 0;
            let mut claimant_swapped_tao: u64 = 0;
            let mut retained_swapped_tao: u64 = 0;
            let mut written_off: u32 = 0;

            for (netuid, slot_alpha, slot_value, terminal_garbage) in valued_holdings.iter() {
                let slot_entitlement =
                    Self::basket_payout_from(owed_shares, *slot_value, shares_total);
                // This staker's pro-rata slice of the holding: slot_alpha * owed / P.
                let proportional_take =
                    Self::mul_div_u64(slot_alpha.to_u64(), owed_shares, shares_total);
                // A high-value alpha row can owe at least one rao even when its proportional
                // alpha slice floors to zero. Sell one atomic alpha unit, pay no more than the
                // marked entitlement below, and retain the sale surplus as fund root cash.
                // Root is already denominated in rao, so take == entitlement there; terminal
                // rows have no realizable entitlement and keep the ordinary floor.
                let take = if proportional_take == 0
                    && slot_entitlement > 0
                    && !netuid.is_root()
                    && !terminal_garbage
                {
                    1
                } else {
                    proportional_take
                };
                if take == 0 {
                    continue;
                }
                *realized = realized.saturating_add(1);

                Self::decrease_stake_for_hotkey_and_coldkey_on_subnet(
                    hotkey,
                    &escrow,
                    *netuid,
                    take.into(),
                );

                if netuid.is_root() {
                    // Root slot: already TAO (1:1), just reassign custody escrow -> staker below.
                    root_slot_tao = root_slot_tao.saturating_add(take);
                    continue;
                }

                if *terminal_garbage {
                    Self::burn_subnet_alpha(*netuid, take.into());
                    Self::deposit_event(Event::BasketAlphaWrittenOff {
                        hotkey: hotkey.clone(),
                        netuid: *netuid,
                        alpha: take.into(),
                    });
                    written_off = written_off.saturating_add(1);
                    continue;
                }

                // Sell the slice to TAO.
                let tao = match Self::sell_basket_alpha_for_root_tao(*netuid, take.into()) {
                    Ok(tao) => tao,
                    Err(err)
                        if T::SwapInterface::classify_failure(&err)
                            == SwapFailureKind::TerminalLiquidity =>
                    {
                        // The sale helper rolls back all of its chunks on failure. The stake
                        // decrease above remains in this outer transaction, so the exact slice
                        // can be written off without disturbing any healthy slot.
                        Self::burn_subnet_alpha(*netuid, take.into());
                        Self::deposit_event(Event::BasketAlphaWrittenOff {
                            hotkey: hotkey.clone(),
                            netuid: *netuid,
                            alpha: take.into(),
                        });
                        written_off = written_off.saturating_add(1);
                        continue;
                    }
                    Err(err) => return TransactionOutcome::Rollback(Err(err)),
                };

                // Record root sell (reduces protocol cost).
                SubnetRootSellTao::<T>::mutate(*netuid, |total| {
                    *total = total.saturating_add(tao);
                });

                // Shares are minted and quoted against full-liquidation NAV. Selling a raw
                // alpha fraction on a concave AMM curve realizes more than the same NAV
                // fraction, so pay only the priced entitlement and retain the surplus as
                // fund cash. Otherwise a permissionless deposit followed by a claim can
                // extract the difference from earlier holders.
                let realized_tao = tao.to_u64();
                // A final claimant has no remaining holders to retain a surplus for. Give
                // them every realized rao so no root cash is stranded behind zero shares.
                let claimant_tao = if owed_shares == shares_total {
                    realized_tao
                } else {
                    realized_tao.min(slot_entitlement)
                };
                claimant_swapped_tao = claimant_swapped_tao.saturating_add(claimant_tao);
                retained_swapped_tao =
                    retained_swapped_tao.saturating_add(realized_tao.saturating_sub(claimant_tao));
            }

            let total_tao: u64 = root_slot_tao.saturating_add(claimant_swapped_tao);

            // Nothing was actually realized (every per-holding take floored to zero, or the
            // swaps returned zero TAO). The marked estimate above can be positive while the raw
            // alpha takes floor to zero (high-price, tiny-alpha holdings), so this must NOT
            // settle: roll back and leave the watermark untouched, otherwise the staker's owed
            // shares would be burned for a zero payout.
            if total_tao == 0 && written_off == 0 {
                return TransactionOutcome::Rollback(Ok(0));
            }

            // The sale surplus still belongs to the fund. It already landed in the root
            // subnet account, so book it into the fund's root cash slot before burning the
            // claimant's shares. Together, the remaining alpha and this cash retain the
            // unclaimed fraction of the pre-sale liquidation NAV (modulo integer floors).
            if retained_swapped_tao > 0 {
                Self::credit_root_slot(hotkey, &escrow, retained_swapped_tao.into());
            }

            // Stake the redeemed TAO on root for the staker. Only sold TAO is new on root;
            // the root-slot portion was already counted in the root reserves.
            if total_tao > 0 {
                Self::increase_stake_for_hotkey_and_coldkey_on_subnet(
                    hotkey,
                    coldkey,
                    NetUid::ROOT,
                    total_tao.into(),
                );
            }
            if claimant_swapped_tao > 0 {
                Self::credit_root_reserves(claimant_swapped_tao.into());
            }

            // Claimed root stake must start (or refresh) the unlock hold, same as a
            // direct add_stake on root — otherwise JIT snipers can deposit → epoch →
            // claim → immediate remove_stake.
            if total_tao > 0 {
                Self::touch_root_stake_age(coldkey, hotkey);
            }

            // The staker's root stake just grew; rebase their claimed watermark so the new stake
            // does not retroactively inflate their claimable.
            if total_tao > 0 {
                Self::add_stake_adjust_root_claimed_for_hotkey_and_coldkey(
                    hotkey, coldkey, total_tao,
                );
            }

            // Consume the claimed shares and advance the watermark.
            let remaining = BasketShares::<T>::mutate(hotkey, |p| {
                *p = p.saturating_sub(owed_shares);
                *p
            });
            if remaining == 0 {
                // This fund life just ended; retire its display baseline/TWR so a
                // future revival stamps fresh instead of inheriting a stale splice.
                Self::retire_beta_display_state(hotkey);
            }
            BasketClaimed::<T>::mutate(hotkey, coldkey, |claimed| {
                *claimed = claimed.saturating_add(i128::from(owed_shares));
            });
            BasketRedeemedTao::<T>::mutate(hotkey, |total| {
                *total = total.saturating_add(total_tao.into())
            });

            Self::deposit_event(Event::BasketClaimed {
                hotkey: hotkey.clone(),
                coldkey: coldkey.clone(),
                tao: total_tao.into(),
            });

            TransactionOutcome::Commit(Ok::<u64, DispatchError>(total_tao))
        })?;

        Ok(outcome)
    }

    /// Consolidates a fund's dust holdings into its root (TAO cash) slot: every subnet
    /// holding whose realizable value is below `RootClaimableThreshold` is sold in full and
    /// held as escrow root stake, deleting the holding row. Without this, dust rows live
    /// forever — a claim's pro-rata take floors to zero whenever `slot_alpha < P / owed`, so
    /// tiny holdings are never redeemed, yet every claim charges weight per holding row.
    ///
    /// The rule is keyed off the actual holdings alone; there is no exempt set. A holding
    /// below the claim threshold is by definition too small to pay any claimant, so nothing
    /// deliberate is lost by cashing it: a position bought with `swap_basket` is at least
    /// `DefaultMinStake` at entry, far above the threshold, and only reaches it after its
    /// value collapses. Nor does the sweep fight the next epoch's accrual — dividend credits
    /// are queue-gated by the same threshold in `flush_basket_deposits_for_hotkey`, so a
    /// swept row only re-forms from a deposit the gate valued at or above the threshold. The
    /// gate marks at spot while this sweep marks realizable, so a just-above-threshold
    /// deposit can still land a row realizably below it and get re-swept next claim; that
    /// churn is bounded by the spot-vs-realizable gap on a threshold-sized amount, not a
    /// treadmill. Without the sweep a fund's holding count only ever grows, and every
    /// deposit's NAV sweep pays a quote per row forever.
    ///
    /// Consolidation is NAV-continuous (minus slippage on a sub-threshold amount) and
    /// touches no shares or watermarks. Best-effort per holding: a failed swap leaves the
    /// row for a later attempt. Returns the number of holdings converted.
    pub(crate) fn consolidate_dust_basket_holdings(hotkey: &T::AccountId) -> u32 {
        let threshold: u64 =
            RootClaimableThreshold::<T>::get(NetUid::ROOT).saturating_to_num::<u64>();
        if threshold == 0 {
            return 0;
        }

        let escrow = Self::get_beta_escrow_account_id();
        let mut swept: u32 = 0;
        for (netuid, alpha) in Self::get_basket_holdings(hotkey) {
            if netuid.is_root() {
                continue;
            }
            match Self::try_realizable_tao_for_alpha(netuid, alpha.to_u64()) {
                Ok(Some(value)) if value < threshold => {
                    if Self::convert_basket_holding_to_root(hotkey, &escrow, netuid) {
                        swept = swept.saturating_add(1);
                    }
                }
                // A terminally shallow pool cannot recover merely by retrying this sell.
                // Convert the row through the same explicit write-off path used by claims.
                Ok(None) => {
                    if Self::convert_basket_holding_to_root(hotkey, &escrow, netuid) {
                        swept = swept.saturating_add(1);
                    }
                }
                Ok(Some(_)) => {}
                Err(err) => {
                    // Unknown failures remain retryable: do not silently value or delete the
                    // holding as zero.
                    log::warn!("Error valuing basket holding for dust conversion: {err:?}");
                }
            }
        }
        swept
    }

    /// Fixed admission budget for both claim paths.
    pub(crate) fn root_claim_declared_work() -> u32 {
        crate::MAX_ROOT_CLAIM_WORK
    }

    /// Pre-dispatch weight for every independently bounded dimension: full claim work,
    /// scan-only work, and the flat pending-deposit flush allowance
    /// ([`Self::basket_flush_weight_bound`]) shared by every extrinsic that flushes.
    pub(crate) fn root_claim_declared_weight() -> Weight {
        let limit = Self::root_claim_declared_work();
        <T as crate::pallet::Config>::WeightInfo::claim_root(limit)
            .saturating_add(<T as crate::pallet::Config>::WeightInfo::claim_root_scan(
                limit,
            ))
            .saturating_add(Self::basket_flush_weight_bound())
    }

    /// Hotkeys relevant to a coldkey-wide root claim. Ordinary subnet-only staking hotkeys
    /// are deliberately excluded. A negative basket watermark keeps an unstaked claimant
    /// eligible because it encodes shares which still need to be redeemed.
    pub(crate) fn root_claim_hotkeys(
        coldkey: &T::AccountId,
        staking_hotkeys: Vec<T::AccountId>,
    ) -> Vec<T::AccountId> {
        staking_hotkeys
            .into_iter()
            .filter(|hotkey| {
                !Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, coldkey, NetUid::ROOT)
                    .is_zero()
                    || BasketClaimed::<T>::get(hotkey, coldkey) < 0
            })
            .collect()
    }

    /// True when the hotkeys plus the basket storage rows the claim will scan fit the fixed
    /// admission envelope, and the pending-deposit flushes the claim runs first fit the flat
    /// flush allowance ([`Self::basket_flush_fits_declared_budget`]). Count raw Alpha/AlphaV2
    /// rows so legacy duplicates and malformed zero rows are charged conservatively, and stop
    /// as soon as a bound is exceeded.
    pub(crate) fn root_claim_fits_declared_budget(hotkeys: &[T::AccountId]) -> bool {
        let budget = Self::root_claim_declared_work();
        let mut work = u32::try_from(hotkeys.len()).unwrap_or(u32::MAX);
        if work > budget {
            return false;
        }

        let escrow = Self::get_beta_escrow_account_id();
        for hotkey in hotkeys {
            for _ in Alpha::<T>::iter_prefix((hotkey, &escrow)) {
                work = work.saturating_add(1);
                if work > budget {
                    return false;
                }
            }
            for _ in AlphaV2::<T>::iter_prefix((hotkey, &escrow)) {
                work = work.saturating_add(1);
                if work > budget {
                    return false;
                }
            }
        }
        Self::basket_flush_fits_declared_budget(hotkeys)
    }

    /// Actual post-dispatch weight of a root claim: full benchmark units for relationships
    /// classified and slots that did real work (redeemed or swept — a swap plus stake writes
    /// each, floored at the selected hotkey count) plus the cheap per-row scan cost for holdings
    /// that were only valued. This is what lets a fund's claim fee decay as dust rows are
    /// consolidated, and makes a below-threshold no-op cost a scan instead of a full claim.
    /// Work above the fixed admission budget
    /// is refused at dispatch (`RootClaimTooHeavy`) rather than admitted cheaply.
    pub(crate) fn root_claim_actual_weight(
        hotkey_count: u32,
        selection_scanned: u32,
        outcome: &RootClaimOutcome,
    ) -> Weight {
        let active = hotkey_count
            .max(outcome.realized.saturating_add(outcome.swept))
            // Classifying a StakingHotkeys relationship reads the position's share-pool state
            // and basket watermark. Price it conservatively as a full hotkey unit.
            .max(selection_scanned)
            .max(1);
        let scanned = outcome.rows.saturating_sub(outcome.realized);
        <T as crate::pallet::Config>::WeightInfo::claim_root(active)
            .saturating_add(<T as crate::pallet::Config>::WeightInfo::claim_root_scan(
                scanned,
            ))
            .saturating_add(Self::basket_flush_weight(outcome.flush))
    }

    pub fn do_root_claim(
        coldkey: T::AccountId,
        hotkeys: Vec<T::AccountId>,
    ) -> Result<RootClaimOutcome, DispatchError> {
        Self::ensure_beta_basket_seed_idle()?;
        with_transaction(|| match Self::try_do_root_claim(coldkey, &hotkeys) {
            Ok(outcome) => TransactionOutcome::Commit(Ok(outcome)),
            Err(err) => TransactionOutcome::Rollback(Err(err)),
        })
    }

    fn try_do_root_claim(
        coldkey: T::AccountId,
        hotkeys: &[T::AccountId],
    ) -> Result<RootClaimOutcome, DispatchError> {
        let mut total = RootClaimOutcome::default();
        for hotkey in hotkeys {
            let outcome = Self::root_claim_for_hotkey(hotkey, &coldkey, false)?;
            total.accumulate(outcome);
        }

        Self::deposit_event(Event::RootClaimed {
            coldkey,
            tao: total.tao.into(),
        });

        Ok(total)
    }

    pub fn maybe_add_coldkey_index(coldkey: &T::AccountId) {
        if !StakingColdkeys::<T>::contains_key(coldkey) {
            let n = NumStakingColdkeys::<T>::get();
            StakingColdkeysByIndex::<T>::insert(n, coldkey.clone());
            StakingColdkeys::<T>::insert(coldkey.clone(), n);
            NumStakingColdkeys::<T>::mutate(|n| *n = n.saturating_add(1));
        }
    }

    /// Returns true if `coldkey` still holds any root (netuid 0) stake on any of its
    /// staking hotkeys. Used to decide whether the coldkey should remain indexed in the
    /// staking-coldkey index.
    pub fn coldkey_has_root_stake(coldkey: &T::AccountId) -> bool {
        StakingHotkeys::<T>::get(coldkey).iter().any(|hotkey| {
            !Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, coldkey, NetUid::ROOT)
                .is_zero()
        })
    }

    /// Remove `coldkey` from the staking-coldkey index, compacting by moving the last
    /// entry into the freed slot so the index stays dense in `[0, n)`. This is the inverse
    /// of `maybe_add_coldkey_index` and keeps the
    /// `StakingColdkeys[c] == i <=> StakingColdkeysByIndex[i] == c` bijection consistent.
    pub fn maybe_remove_coldkey_index(coldkey: &T::AccountId) {
        if let Some(idx) = StakingColdkeys::<T>::take(coldkey) {
            let last = NumStakingColdkeys::<T>::get().saturating_sub(1);
            if idx != last
                && let Some(moved) = StakingColdkeysByIndex::<T>::take(last)
            {
                StakingColdkeysByIndex::<T>::insert(idx, moved.clone());
                StakingColdkeys::<T>::insert(moved, idx);
            } else {
                StakingColdkeysByIndex::<T>::remove(idx);
            }
            NumStakingColdkeys::<T>::put(last);
        }
    }

    /// Rebase a staker's claimed watermark by `rate * stake_delta` after their root stake
    /// changed, so a stake change never retroactively grants or destroys accrued claimable.
    /// The watermark is signed and may legitimately go negative (e.g. claim, then unstake).
    fn rebase_basket_claimed_for_stake_delta(
        hotkey: &T::AccountId,
        coldkey: &T::AccountId,
        stake_delta: i128,
    ) {
        let rate = BasketRate::<T>::get(hotkey);
        if rate == I96F32::saturating_from_num(0) {
            return;
        }
        BasketClaimed::<T>::mutate(hotkey, coldkey, |claimed| {
            *claimed = claimed.saturating_add(
                rate.saturating_mul(I96F32::saturating_from_num(stake_delta))
                    .saturating_to_num::<i128>(),
            );
        });
    }

    /// Grant `shares` fund shares to a staker unconditionally by decrementing their signed
    /// claimed watermark: `owed = rate * root_stake - claimed`, so a negative watermark is a
    /// share grant that needs no root stake and survives stake-change rebasing (which is
    /// additive). The caller must mint the same `shares` into [`BasketShares`], preserving
    /// `Σ owed == BasketShares`.
    fn grant_basket_shares(hotkey: &T::AccountId, coldkey: &T::AccountId, shares: u64) {
        BasketClaimed::<T>::mutate(hotkey, coldkey, |claimed| {
            *claimed = claimed.saturating_sub(i128::from(shares));
        });
    }

    /// Watermark rebase for a root-stake increase of `amount`.
    pub fn add_stake_adjust_root_claimed_for_hotkey_and_coldkey(
        hotkey: &T::AccountId,
        coldkey: &T::AccountId,
        amount: u64,
    ) {
        Self::rebase_basket_claimed_for_stake_delta(hotkey, coldkey, i128::from(amount));
    }

    /// Watermark rebase for a root-stake decrease of `amount`.
    pub fn remove_stake_adjust_root_claimed_for_hotkey_and_coldkey(
        hotkey: &T::AccountId,
        coldkey: &T::AccountId,
        amount: AlphaBalance,
    ) {
        Self::rebase_basket_claimed_for_stake_delta(
            hotkey,
            coldkey,
            i128::from(u64::from(amount)).saturating_neg(),
        );
    }

    /// Moves a staker's claimed watermark on `hotkey` to a new coldkey (used by coldkey swaps;
    /// hotkey swaps migrate all watermarks via `transfer_basket_for_new_hotkey`).
    pub fn transfer_basket_claimed_for_new_coldkey(
        hotkey: &T::AccountId,
        old_coldkey: &T::AccountId,
        new_coldkey: &T::AccountId,
    ) {
        // Sum the two already-claimed watermarks. When BOTH the source and the destination
        // hold a legitimate watermark — e.g. a coldkey swap onto a hotkey the new coldkey has
        // already staked to — the merged "already claimed" total is old + new. Taking the max
        // would drop one side, under-count what has already been claimed, and cause a future
        // over-payment / double-claim (see GHSA-2026-010 for the hotkey-swap analog, which is
        // prevented upstream by the root-swap cleanliness gate in `do_swap_hotkey`).
        let old_claimed: i128 = BasketClaimed::<T>::take(hotkey, old_coldkey);
        if old_claimed != 0 {
            BasketClaimed::<T>::mutate(hotkey, new_coldkey, |claimed| {
                *claimed = claimed.saturating_add(old_claimed);
            });
        }
    }

    /// Migrates a validator's entire fund to a new hotkey: shares, rate, per-coldkey watermarks,
    /// and every escrow holding, moved by value. The caller must guarantee the new hotkey is
    /// clean on root (enforced by `do_swap_hotkey`), so this is a move, not a merge.
    ///
    /// Returns the number of `BasketClaimed` plus queued `PendingBasketDeposits` rows moved
    /// so the caller can charge weight. Claimant rows are unbounded (same class of work as
    /// moving stake coldkeys): a popular root validator must still be able to hotkey-swap;
    /// the extrinsic pays the resulting weight rather than hard-failing above
    /// [`crate::MAX_ROOT_CLAIM_WORK`].
    pub fn transfer_basket_for_new_hotkey(
        old_hotkey: &T::AccountId,
        new_hotkey: &T::AccountId,
    ) -> u32 {
        let shares = BasketShares::<T>::take(old_hotkey);
        if shares != 0 {
            BasketShares::<T>::mutate(new_hotkey, |p| *p = p.saturating_add(shares));
        }

        let rate = BasketRate::<T>::take(old_hotkey);
        if rate != I96F32::saturating_from_num(0) {
            BasketRate::<T>::mutate(new_hotkey, |r| *r = r.saturating_add(rate));
        }

        // Display state (frozen baseline + TWR) follows the fund; the clean-root gate
        // guarantees the destination holds none, so this is a pure move.
        Self::transfer_beta_display_state(old_hotkey, new_hotkey);

        // Lifetime performance counters follow the fund.
        let deposited = BasketDepositedTao::<T>::take(old_hotkey);
        if !deposited.is_zero() {
            BasketDepositedTao::<T>::mutate(new_hotkey, |t| *t = t.saturating_add(deposited));
        }
        let redeemed = BasketRedeemedTao::<T>::take(old_hotkey);
        if !redeemed.is_zero() {
            BasketRedeemedTao::<T>::mutate(new_hotkey, |t| *t = t.saturating_add(redeemed));
        }

        // One row per historical coldkey — may be large; weight is charged by the caller.
        let claimed_entries: Vec<(T::AccountId, i128)> =
            BasketClaimed::<T>::iter_prefix(old_hotkey).collect();
        let mut moved_rows = claimed_entries.len() as u32;
        for (coldkey, claimed) in claimed_entries {
            BasketClaimed::<T>::remove(old_hotkey, &coldkey);
            BasketClaimed::<T>::mutate(new_hotkey, &coldkey, |c| {
                *c = c.saturating_add(claimed);
            });
        }

        // Queued dividend credits follow the fund. The clean-root guard doesn't inspect
        // the queue, so the new hotkey may hold threshold-deferred dust credits of its
        // own; per-origin amounts merge additively, which is exactly enqueue semantics.
        let pending: Vec<(NetUid, AlphaBalance)> =
            PendingBasketDeposits::<T>::drain_prefix(old_hotkey).collect();
        moved_rows = moved_rows.saturating_add(pending.len() as u32);
        for (netuid, alpha) in pending {
            PendingBasketDeposits::<T>::mutate(new_hotkey, netuid, |p| {
                *p = p.saturating_add(alpha);
            });
        }

        let escrow = Self::get_beta_escrow_account_id();
        for (netuid, alpha) in Self::get_basket_holdings(old_hotkey) {
            let alpha_moved = Self::decrease_stake_for_hotkey_and_coldkey_on_subnet(
                old_hotkey, &escrow, netuid, alpha,
            );
            Self::increase_stake_for_hotkey_and_coldkey_on_subnet(
                new_hotkey,
                &escrow,
                netuid,
                alpha_moved,
            );
        }

        // Trading guardrails follow the fund so a hotkey swap can neither escape a
        // governance freeze nor refill the turnover bucket. The freeze is copied, not
        // moved: a later swap back onto the old hotkey must still find it frozen. The
        // bucket is carried conservatively: the lower level and the later refill block.
        if BasketTradingFrozen::<T>::contains_key(old_hotkey) {
            BasketTradingFrozen::<T>::insert(new_hotkey, ());
        }
        if let Some((old_level, old_block)) = BasketTradeBucket::<T>::take(old_hotkey) {
            let carried = match BasketTradeBucket::<T>::get(new_hotkey) {
                Some((new_level, new_block)) => {
                    (old_level.min(new_level), old_block.max(new_block))
                }
                None => (old_level, old_block),
            };
            BasketTradeBucket::<T>::insert(new_hotkey, carried);
        }

        moved_rows
    }

    /// Converts validators' basket holdings on a dissolving subnet into each fund's root
    /// (TAO) slot, metered and resumable via `last_key` over [`BasketShares`] keys.
    ///
    /// Escrow alpha is sold once per fund and held as root stake under the same escrow.
    /// Fund shares, rates, and watermarks are untouched — NAV is continuous across the
    /// conversion (minus slippage). A terminally shallow holding is explicitly written off;
    /// any unknown failure is logged and leaves the slot for generic teardown. Returns
    /// `(done, next_cursor)`.
    pub fn convert_subnet_basket_holdings_to_root(
        netuid: NetUid,
        weight_meter: &mut WeightMeter,
        last_key: Option<Vec<u8>>,
    ) -> (bool, Option<Vec<u8>>) {
        // Budget covers stake reads, AMM swap bookkeeping, TAO transfer, root-slot credit,
        // and the conversion event for one non-empty holding.
        let per_key = T::DbWeight::get().reads_writes(25, 20);
        let escrow = Self::get_beta_escrow_account_id();

        let mut keys = match &last_key {
            Some(raw_key) => BasketShares::<T>::iter_keys_from(raw_key.clone()),
            None => BasketShares::<T>::iter_keys(),
        };

        // Preserve the inbound cursor if this call cannot afford even one key, so a tight
        // weight budget does not rewind the scan and re-convert already-handled funds.
        let mut cursor = last_key;
        for hotkey in keys.by_ref() {
            if !weight_meter.can_consume(per_key) {
                return (false, cursor);
            }
            weight_meter.consume(per_key);
            Self::convert_basket_holding_to_root(&hotkey, &escrow, netuid);
            cursor = Some(BasketShares::<T>::hashed_key_for(hotkey));
        }

        (true, None)
    }

    /// Returns `true` when the holding was converted (false: nothing held, or the
    /// conversion rolled back on a failed swap).
    fn convert_basket_holding_to_root(
        hotkey: &T::AccountId,
        escrow: &T::AccountId,
        netuid: NetUid,
    ) -> bool {
        let holding_alpha =
            Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, escrow, netuid);
        if holding_alpha.is_zero() {
            return false;
        }

        let terminal_garbage =
            match Self::try_realizable_tao_for_alpha(netuid, holding_alpha.to_u64()) {
                Ok(Some(_)) => false,
                Ok(None) => true,
                Err(err) => {
                    log::error!("Error valuing basket holding before conversion: {err:?}");
                    return false;
                }
            };

        with_transaction(|| {
            Self::decrease_stake_for_hotkey_and_coldkey_on_subnet(
                hotkey,
                escrow,
                netuid,
                holding_alpha,
            );

            if terminal_garbage {
                // The position is economically unusable and no executable sale exists. Remove
                // it explicitly instead of allowing one bad subnet to pin claim/dissolution
                // progress forever. This is a proportional loss to the fund as a whole.
                Self::burn_subnet_alpha(netuid, holding_alpha);
                Self::deposit_event(Event::BasketAlphaWrittenOff {
                    hotkey: hotkey.clone(),
                    netuid,
                    alpha: holding_alpha,
                });
                return TransactionOutcome::Commit(Ok::<(), DispatchError>(()));
            }

            let tao = match Self::sell_basket_alpha_for_root_tao(netuid, holding_alpha) {
                Ok(tao) => tao,
                Err(err)
                    if T::SwapInterface::classify_failure(&err)
                        == SwapFailureKind::TerminalLiquidity =>
                {
                    // A late shallow-pool failure is safe to write off because the sale
                    // helper atomically rolled back every attempted chunk.
                    Self::burn_subnet_alpha(netuid, holding_alpha);
                    Self::deposit_event(Event::BasketAlphaWrittenOff {
                        hotkey: hotkey.clone(),
                        netuid,
                        alpha: holding_alpha,
                    });
                    return TransactionOutcome::Commit(Ok::<(), DispatchError>(()));
                }
                Err(err) => {
                    log::error!("Error converting basket holding to root: {err:?}");
                    return TransactionOutcome::Rollback(Err(err));
                }
            };

            // Hold the realized TAO as the fund's root-slot (cash) position.
            Self::credit_root_slot(hotkey, escrow, tao);

            Self::deposit_event(Event::BasketHoldingConverted {
                hotkey: hotkey.clone(),
                netuid,
                tao,
            });

            TransactionOutcome::Commit(Ok::<(), DispatchError>(()))
        })
        .is_ok()
    }

    /// Sells basket `alpha` on `netuid` for TAO and lands it in the root subnet account, booking
    /// the protocol outflow. The alpha must already have been removed from the escrow position.
    /// Shared by claim redemption and dissolution conversion; callers stay transactional.
    fn sell_basket_alpha_for_root_tao(
        netuid: NetUid,
        alpha: AlphaBalance,
    ) -> Result<TaoBalance, DispatchError> {
        let tao = Self::swap_basket_alpha_for_tao_chunks(netuid, alpha)
            .inspect_err(|err| log::warn!("Unable to swap basket alpha for TAO: {err:?}"))?;

        let root_subnet_account_id =
            Self::get_subnet_account_id(NetUid::ROOT).ok_or(Error::<T>::RootNetworkDoesNotExist)?;

        Self::transfer_tao_from_subnet(netuid, &root_subnet_account_id, tao.into())
            .inspect_err(|err| log::error!("Error transferring basket TAO from subnet: {err:?}"))?;

        Self::record_protocol_outflow(netuid, tao);

        Ok(tao)
    }

    /// Execute a fee-free protocol alpha sale in reserve-bounded chunks. This is both the
    /// money-moving implementation and the engine used under a rollback overlay for NAV quotes,
    /// so an oversized full holding is valued exactly as it would be liquidated.
    pub(crate) fn swap_basket_alpha_for_tao_chunks(
        netuid: NetUid,
        alpha: AlphaBalance,
    ) -> Result<TaoBalance, DispatchError> {
        with_transaction(|| {
            let result = (|| {
                if alpha.is_zero() {
                    return Ok(TaoBalance::ZERO);
                }
                if SubnetMechanism::<T>::get(netuid) != 1 {
                    return Self::swap_alpha_for_tao(
                        netuid,
                        alpha,
                        T::SwapInterface::min_price::<TaoBalance>(),
                        true,
                    )
                    .map(|out| out.amount_paid_out);
                }

                let mut remaining = alpha.to_u64();
                let mut total_tao = 0u64;
                while remaining > 0 {
                    let maximum =
                        T::SwapInterface::max_swap_input::<GetTaoForAlpha<T>>(netuid).to_u64();
                    // Let the engine return its concrete failure when the input reserve is zero.
                    let chunk = if maximum == 0 {
                        remaining
                    } else {
                        remaining.min(maximum)
                    };
                    let out = Self::swap_alpha_for_tao(
                        netuid,
                        chunk.into(),
                        T::SwapInterface::min_price::<TaoBalance>(),
                        true,
                    )?;
                    let consumed = out
                        .amount_paid_in
                        .to_u64()
                        .saturating_add(out.fee_paid.to_u64());
                    ensure!(consumed > 0, Error::<T>::AmountTooLow);
                    remaining = remaining.saturating_sub(consumed);
                    total_tao = total_tao.saturating_add(out.amount_paid_out.to_u64());
                }
                Ok(total_tao.into())
            })();
            match result {
                Ok(tao) => TransactionOutcome::Commit(Ok(tao)),
                Err(err) => TransactionOutcome::Rollback(Err(err)),
            }
        })
    }

    /// Buy basket alpha in reserve-bounded chunks for oversized user deposits. Swap fees are
    /// charged: the depositor pays their own entry cost.
    pub(crate) fn swap_basket_tao_for_alpha_chunks(
        netuid: NetUid,
        tao: TaoBalance,
    ) -> Result<AlphaBalance, DispatchError> {
        with_transaction(|| {
            let result = (|| {
                if tao.is_zero() {
                    return Ok(AlphaBalance::ZERO);
                }
                if SubnetMechanism::<T>::get(netuid) != 1 {
                    return Self::swap_tao_for_alpha(
                        netuid,
                        tao,
                        T::SwapInterface::max_price(),
                        false,
                    )
                    .map(|out| out.amount_paid_out);
                }

                let mut remaining = tao.to_u64();
                let mut total_alpha = 0u64;
                while remaining > 0 {
                    let maximum =
                        T::SwapInterface::max_swap_input::<GetAlphaForTao<T>>(netuid).to_u64();
                    let chunk = if maximum == 0 {
                        remaining
                    } else {
                        remaining.min(maximum)
                    };
                    let out = Self::swap_tao_for_alpha(
                        netuid,
                        chunk.into(),
                        T::SwapInterface::max_price(),
                        false,
                    )?;
                    let consumed = out
                        .amount_paid_in
                        .to_u64()
                        .saturating_add(out.fee_paid.to_u64());
                    ensure!(consumed > 0, Error::<T>::AmountTooLow);
                    remaining = remaining.saturating_sub(consumed);
                    total_alpha = total_alpha.saturating_add(out.amount_paid_out.to_u64());
                }
                Ok(total_alpha.into())
            })();
            match result {
                Ok(alpha) => TransactionOutcome::Commit(Ok(alpha)),
                Err(err) => TransactionOutcome::Rollback(Err(err)),
            }
        })
    }

    /// Drop a dissolving subnet's entries from the LEGACY per-subnet claimable rates. The
    /// live basket state is fund-level (no per-subnet entitlement), so only the legacy
    /// storage — kept for `migrate_seed_beta_basket` — needs per-subnet cleanup.
    pub fn clean_up_root_claimable_for_subnet(
        netuid: NetUid,
        weight_meter: &mut WeightMeter,
        last_key: Option<Vec<u8>>,
    ) -> (bool, Option<Vec<u8>>) {
        let iter = match last_key {
            Some(raw_key) => RootClaimable::<T>::iter_from(raw_key),
            None => RootClaimable::<T>::iter(),
        };

        fn filter_claimable(
            claimable: &BTreeMap<NetUid, I96F32>,
            netuid: NetUid,
        ) -> BTreeMap<NetUid, I96F32> {
            let mut result = claimable.clone();
            if result.contains_key(&netuid) {
                result.remove(&netuid);
            }
            result
        }

        let (read_all, last_item) = Self::remove_storage_entries_for_netuid(
            weight_meter,
            iter,
            |(_, _)| true,
            |(hotkey, claimable)| (hotkey.clone(), claimable.clone()),
            |(hotkey, claimable)| {
                RootClaimable::<T>::insert(hotkey, filter_claimable(claimable, netuid))
            },
            1,
        );

        (
            read_all,
            last_item.map(|(hotkey, _)| RootClaimable::<T>::hashed_key_for(&hotkey)),
        )
    }

    /// Drop a dissolving subnet's LEGACY claimed watermarks (kept for `migrate_seed_beta_basket`).
    pub fn clean_up_root_claimed_for_subnet(
        netuid: NetUid,
        weight_meter: &mut WeightMeter,
    ) -> bool {
        clear_prefix_with_meter(weight_meter, T::DbWeight::get().writes(1), |limit| {
            RootClaimed::<T>::clear_prefix((netuid,), limit, None)
        })
    }

    /// Credit `amount` TAO onto the root pool's reserves. Root has no AMM pool, so whenever TAO is
    /// placed on root these three storages must be moved in lockstep by hand (subnets get this for
    /// free inside `swap_tao_for_alpha`). Single source of truth for that invariant.
    pub(super) fn credit_root_reserves(amount: TaoBalance) {
        SubnetTAO::<T>::mutate(NetUid::ROOT, |total| *total = total.saturating_add(amount));
        SubnetAlphaOut::<T>::mutate(NetUid::ROOT, |total| {
            *total = total.saturating_add(u64::from(amount).into())
        });
        TotalStake::<T>::mutate(|total| *total = total.saturating_add(amount));
    }

    /// Exact inverse of [`Self::credit_root_reserves`]: TAO leaving the root slot (e.g. a
    /// basket trade selling out of the fund's cash position) unwinds the same three storages.
    pub(super) fn debit_root_reserves(amount: TaoBalance) {
        SubnetTAO::<T>::mutate(NetUid::ROOT, |total| *total = total.saturating_sub(amount));
        SubnetAlphaOut::<T>::mutate(NetUid::ROOT, |total| {
            *total = total.saturating_sub(u64::from(amount).into())
        });
        TotalStake::<T>::mutate(|total| *total = total.saturating_sub(amount));
    }

    /// Place `tao` into the fund's root cash slot: the escrow's root stake row (TAO at 1:1,
    /// there is no pool to buy from) and the root reserves move together.
    pub(super) fn credit_root_slot(hotkey: &T::AccountId, escrow: &T::AccountId, tao: TaoBalance) {
        Self::increase_stake_for_hotkey_and_coldkey_on_subnet(
            hotkey,
            escrow,
            NetUid::ROOT,
            tao.to_u64().into(),
        );
        Self::credit_root_reserves(tao);
    }

    /// Exact inverse of [`Self::credit_root_slot`]: take `tao` out of the fund's root cash
    /// slot, moving the escrow's root stake row and the root reserves together. Returns the
    /// TAO that really left the slot; the reserves move by that amount, never by `tao`, so a
    /// short debit of the stake row cannot leave the reserves understated.
    pub(super) fn debit_root_slot(
        hotkey: &T::AccountId,
        escrow: &T::AccountId,
        tao: TaoBalance,
    ) -> TaoBalance {
        let removed: TaoBalance = Self::decrease_stake_for_hotkey_and_coldkey_on_subnet(
            hotkey,
            escrow,
            NetUid::ROOT,
            tao.to_u64().into(),
        )
        .to_u64()
        .into();
        Self::debit_root_reserves(removed);
        removed
    }
}