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

basket_trade.rs

481 lines · 22,688 bytes · 5e65086c3eRawGitHub
//! Validator-directed beta basket rebalancing (`swap_basket`): the money-moving path only.
//! Read-only views (`get_basket_trading_status`, budget arithmetic) live in `basket_views.rs`.

use super::basket_flush::MAX_BASKET_ROWS;
use super::*;
use crate::weights::WeightInfo;
use frame_support::storage::{TransactionOutcome, with_transaction};
use frame_support::weights::Weight;
use safe_math::*;
use sp_core::Get;
use sp_runtime::DispatchError;
use sp_runtime::traits::Zero;
use substrate_fixed::types::U64F64;
use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance};
use subtensor_swap_interface::SwapHandler;

/// Basis-point denominator for [`crate::BASKET_TRADE_MAX_SLIPPAGE_BPS`].
const BPS_DENOMINATOR: u64 = 10_000;

/// Scale of the AMM `price_limit` argument (TAO per alpha × 10⁹), see `order_swap.rs`.
const PRICE_LIMIT_SCALE: u64 = 1_000_000_000;

/// Which side of the TAO middle a trade leg is on.
#[derive(Clone, Copy)]
enum Leg {
    /// Origin alpha -> TAO. Price bound is a floor.
    Sell,
    /// TAO -> destination alpha. Price bound is a ceiling.
    Buy,
}

/// Outcome of one executed basket trade, used for the event and the post-dispatch weight.
struct BasketTradeOutcome {
    /// TAO that passed through the middle of the swap.
    tao_mid: u64,
    /// Alpha (or TAO for a root destination) credited to the destination holding.
    alpha_bought: u64,
    /// Escrow holding rows valued: the pre-trade sweep, plus the destination row when the
    /// trade opens it.
    holdings: u64,
}

impl<T: Config> Pallet<T> {
    /// Validator-directed basket rebalance: sell `amount` of the fund's `origin_netuid`
    /// holding for TAO and buy `destination_netuid` with it. Either side may be root
    /// (netuid 0), the fund's TAO cash slot. Fund shares, rates, and watermarks are
    /// untouched: only the composition of the escrow holdings changes.
    ///
    /// Guardrails (see the storage docs on [`crate::BasketDailyTurnoverCap`]):
    /// * each AMM leg must fill fully within [`crate::BASKET_TRADE_MAX_SLIPPAGE_BPS`] of
    ///   the strictest of the subnet's slow moving (EMA) price, its fast moving price, and
    ///   its spot price ([`Self::basket_trade_price_limit`]);
    /// * the TAO through the middle is taken from the fund's turnover bucket, sized from
    ///   the fund's guarded NAV ([`Self::guarded_basket_holding_value`]);
    /// * the destination holding may not end above [`crate::BasketLiquidityCap`] of the
    ///   destination pool's alpha reserve;
    /// * the destination holding may not end above [`crate::BasketConcentrationCap`] of the
    ///   fund's guarded NAV.
    ///
    /// AMM fees are charged like any user swap; the block-author fee is settled through the
    /// same helpers `stake_into_subnet` / `unstake_from_subnet` use.
    ///
    /// `min_amount_out` is the caller's own floor on what the buy leg credits (destination
    /// alpha, or TAO when the destination is root); `0` means none. It sits on top of the
    /// protocol band, which is unchanged.
    pub fn do_swap_basket(
        coldkey: T::AccountId,
        hotkey: T::AccountId,
        origin_netuid: NetUid,
        destination_netuid: NetUid,
        amount: u64,
        min_amount_out: u64,
    ) -> Result<Weight, DispatchError> {
        ensure!(
            BasketTradingEnabled::<T>::get(),
            Error::<T>::BasketTradingDisabled
        );
        ensure!(
            !BasketTradingFrozen::<T>::contains_key(&hotkey),
            Error::<T>::BasketTradingFrozen
        );
        Self::ensure_beta_basket_seed_idle()?;
        ensure!(
            origin_netuid != destination_netuid,
            Error::<T>::BasketSameSubnet
        );
        ensure!(
            Self::coldkey_owns_hotkey(&coldkey, &hotkey),
            Error::<T>::NonAssociatedColdKey
        );
        ensure!(
            Self::is_hotkey_registered_on_network(NetUid::ROOT, &hotkey),
            Error::<T>::HotKeyNotRegisteredInSubNet
        );
        ensure!(
            origin_netuid.is_root() || Self::if_subnet_exist(origin_netuid),
            Error::<T>::SubnetNotExists
        );
        ensure!(
            destination_netuid.is_root() || Self::if_subnet_exist(destination_netuid),
            Error::<T>::SubnetNotExists
        );
        if !destination_netuid.is_root() {
            Self::ensure_subtoken_enabled(destination_netuid)?;
        }
        ensure!(amount > 0, Error::<T>::AmountTooLow);

        // Settle queued dividend credits first so the budget and the cap are measured
        // 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);

        let escrow = Self::get_beta_escrow_account_id();
        let held =
            Self::get_stake_for_hotkey_and_coldkey_on_subnet(&hotkey, &escrow, origin_netuid)
                .to_u64();
        ensure!(amount <= held, Error::<T>::NotEnoughStakeToWithdraw);

        let outcome = with_transaction(|| {
            match Self::try_swap_basket(
                &hotkey,
                &escrow,
                origin_netuid,
                destination_netuid,
                amount,
                min_amount_out,
            ) {
                Ok(outcome) => TransactionOutcome::Commit(Ok(outcome)),
                Err(err) => TransactionOutcome::Rollback(Err(err)),
            }
        })?;

        Self::deposit_event(Event::BasketSwapped {
            hotkey,
            origin_netuid,
            destination_netuid,
            alpha_sold: amount.into(),
            tao_mid: outcome.tao_mid.into(),
            alpha_bought: outcome.alpha_bought.into(),
        });

        Ok(Self::swap_basket_weight(outcome.holdings)
            .saturating_add(Self::basket_flush_weight(flush_work)))
    }

    /// Transactional body of [`Self::do_swap_basket`]; any error rolls the whole trade back.
    fn try_swap_basket(
        hotkey: &T::AccountId,
        escrow: &T::AccountId,
        origin_netuid: NetUid,
        destination_netuid: NetUid,
        amount: u64,
        min_amount_out: u64,
    ) -> Result<BasketTradeOutcome, DispatchError> {
        // Every holding is valued twice: at its realizable quote (the NAV the fund could
        // pay out, used for bookkeeping) and at its guarded mark (the same, capped at the
        // slow-EMA value of the alpha — the figure the turnover budget and the
        // concentration cap are measured against, see [`Self::guarded_basket_holding_value`]).
        let before = Self::try_valued_basket_holdings(hotkey)?;
        let guarded_before: Vec<(NetUid, u64)> = before
            .iter()
            .map(|(netuid, alpha, value)| {
                (
                    *netuid,
                    Self::guarded_basket_holding_value(*netuid, alpha.to_u64(), *value),
                )
            })
            .collect();
        let guarded_nav_before: u64 = guarded_before
            .iter()
            .fold(0u64, |nav, (_, value)| nav.saturating_add(*value));
        let guarded_value_before = |netuid: NetUid| -> u64 {
            guarded_before
                .iter()
                .find(|(row, _)| *row == netuid)
                .map(|(_, value)| *value)
                .unwrap_or(0)
        };
        let destination_is_new = !before.iter().any(|(row, _, _)| *row == destination_netuid);

        // --- 1. Sell leg: origin holding -> free TAO on the origin pot.
        let tao_mid: u64 = Self::sell_basket_leg(hotkey, escrow, origin_netuid, amount.into())?;
        ensure!(
            TaoBalance::from(tao_mid) >= DefaultMinStake::<T>::get(),
            Error::<T>::AmountTooLow
        );

        // --- 2. Turnover budget, charged on the TAO through the middle and sized from the
        // guarded (un-pumpable) NAV.
        Self::consume_basket_trade_budget(hotkey, guarded_nav_before, tao_mid)?;

        // --- 3. Move the cash from the origin pot to the destination pot.
        let destination_account =
            Self::get_subnet_account_id(destination_netuid).ok_or(Error::<T>::SubnetNotExists)?;
        Self::transfer_tao_from_subnet(origin_netuid, &destination_account, tao_mid.into())?;

        // --- 4. Buy leg: TAO -> destination holding.
        let alpha_bought =
            Self::buy_basket_leg(hotkey, escrow, destination_netuid, tao_mid.into())?;

        // --- 4a. Caller's floor on the credited amount (fees already settled in the leg).
        // The unit follows the destination: alpha, or rao of TAO for the root cash slot.
        ensure!(
            alpha_bought.to_u64() >= min_amount_out,
            Error::<T>::BasketMinOutNotMet
        );

        // --- 4b. Liquidity rule: the fund may not hold more of the destination than
        // `BasketLiquidityCap` of the pool's alpha reserve.
        Self::ensure_within_liquidity_cap(hotkey, escrow, destination_netuid)?;

        // --- 5. Shape rule on the post-trade fund. The trade moved only the origin and
        // destination pools (root is 1:1), so every other row's mark is unchanged and the
        // full re-valuation collapses to re-quoting those two holdings. The destination is
        // measured at its realizable value (a pump there only makes the check stricter);
        // the fund it is measured against is the guarded NAV, which a same-block pump of
        // any held pool cannot inflate.
        let (_, origin_after) = Self::basket_holding_marks(hotkey, escrow, origin_netuid)?;
        let (destination_value, destination_after) =
            Self::basket_holding_marks(hotkey, escrow, destination_netuid)?;
        let guarded_nav_after: u64 = guarded_nav_before
            .saturating_sub(guarded_value_before(origin_netuid))
            .saturating_sub(guarded_value_before(destination_netuid))
            .saturating_add(origin_after)
            .saturating_add(destination_after);
        Self::ensure_within_concentration_cap(destination_value, guarded_nav_after)?;

        Ok(BasketTradeOutcome {
            tao_mid,
            alpha_bought: alpha_bought.to_u64(),
            holdings: (before.len() as u64).saturating_add(u64::from(destination_is_new)),
        })
    }

    /// The fund's current holding on `netuid` at both marks: `(realizable, guarded)`, priced
    /// like a row of [`Self::try_valued_basket_holdings`] (terminal garbage is zero) and
    /// then capped by [`Self::guarded_basket_holding_value`].
    fn basket_holding_marks(
        hotkey: &T::AccountId,
        escrow: &T::AccountId,
        netuid: NetUid,
    ) -> Result<(u64, u64), DispatchError> {
        let held =
            Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, escrow, netuid).to_u64();
        let realizable = Self::try_realizable_tao_for_alpha(netuid, held)?.unwrap_or(0);
        Ok((
            realizable,
            Self::guarded_basket_holding_value(netuid, held, realizable),
        ))
    }

    /// The guarded mark of `alpha` on `netuid` given its `realizable` quote:
    /// `min(realizable, alpha × slow EMA)`. Root cash is TAO 1:1 and passes through.
    ///
    /// The realizable quote is bounded only by the pool's TAO reserve, and a pumper grows
    /// that reserve by whatever TAO they deposit — so for one block a held thin pool can be
    /// marked at roughly the pump size, inflating the NAV the turnover budget and the
    /// concentration cap are measured against (a refused oversize buy becomes admitted).
    /// The slow EMA cannot be moved inside a block, so capping the mark at the EMA value
    /// of the alpha makes both guards un-pumpable. The cap is one-sided on purpose: a pool
    /// trading above its monthly EMA — or above parity, where the slow EMA clamps at 1.0 —
    /// is under-marked, which only tightens the guards (smaller budget, stricter cap).
    pub(crate) fn guarded_basket_holding_value(netuid: NetUid, alpha: u64, realizable: u64) -> u64 {
        if netuid.is_root() {
            return realizable;
        }
        let ema_value: u64 = Self::get_moving_alpha_price(netuid)
            .saturating_mul(U64F64::saturating_from_num(alpha))
            .saturating_to_num::<u64>();
        realizable.min(ema_value)
    }

    /// Sell `alpha` of the fund's `netuid` holding for TAO, leaving the TAO on the subnet's
    /// pot for the caller to move on. Root is the fund's cash slot: TAO 1:1, no pool. A
    /// dynamic subnet must fill fully at or above the price floor.
    fn sell_basket_leg(
        hotkey: &T::AccountId,
        escrow: &T::AccountId,
        netuid: NetUid,
        alpha: AlphaBalance,
    ) -> Result<u64, DispatchError> {
        // Only alpha that really left the holding may be sold (or, on root, moved on): a
        // short debit would otherwise still be swapped for TAO in full, letting the fund sell
        // alpha it never held.
        if netuid.is_root() {
            let removed = Self::debit_root_slot(hotkey, escrow, alpha.to_u64().into());
            ensure!(
                removed.to_u64() == alpha.to_u64(),
                Error::<T>::NotEnoughStakeToWithdraw
            );
            return Ok(alpha.to_u64());
        }
        let alpha_removed =
            Self::decrease_stake_for_hotkey_and_coldkey_on_subnet(hotkey, escrow, netuid, alpha);
        ensure!(alpha_removed == alpha, Error::<T>::NotEnoughStakeToWithdraw);
        let floor = Self::basket_trade_price_limit(netuid, Leg::Sell)?;
        let out = Self::swap_alpha_for_tao(netuid, alpha, floor, false)?;
        let consumed = out.amount_paid_in.saturating_add(out.fee_paid);
        ensure!(consumed == alpha, Error::<T>::SlippageTooHigh);
        ensure!(!out.amount_paid_out.is_zero(), Error::<T>::AmountTooLow);

        let fee_outflow = Self::settle_alpha_fee_to_author(netuid, out.fee_to_block_author)?;
        Self::record_protocol_outflow(netuid, out.amount_paid_out.saturating_add(fee_outflow));
        Ok(out.amount_paid_out.to_u64())
    }

    /// Buy alpha on `netuid` with `tao` already sitting on the subnet's pot and credit it to
    /// the fund's holding. Root is the fund's cash slot: TAO 1:1, no pool. A dynamic subnet
    /// must fill fully at or below the price ceiling.
    fn buy_basket_leg(
        hotkey: &T::AccountId,
        escrow: &T::AccountId,
        netuid: NetUid,
        tao: TaoBalance,
    ) -> Result<AlphaBalance, DispatchError> {
        if netuid.is_root() {
            Self::credit_root_slot(hotkey, escrow, tao);
            return Ok(tao.to_u64().into());
        }
        let ceiling = Self::basket_trade_price_limit(netuid, Leg::Buy)?;
        let out = Self::swap_tao_for_alpha(netuid, tao, ceiling, false)?;
        let consumed = out.amount_paid_in.saturating_add(out.fee_paid);
        ensure!(consumed == tao, Error::<T>::SlippageTooHigh);
        ensure!(!out.amount_paid_out.is_zero(), Error::<T>::AmountTooLow);

        Self::settle_tao_fee_to_author(netuid, out.fee_to_block_author)?;
        // Same basis as `stake_into_subnet`: what entered the pool, fee excluded.
        Self::record_protocol_inflow(netuid, out.amount_paid_in);
        Self::increase_stake_for_hotkey_and_coldkey_on_subnet(
            hotkey,
            escrow,
            netuid,
            out.amount_paid_out,
        );
        Ok(out.amount_paid_out)
    }

    /// AMM `price_limit` (TAO per alpha × 10⁹) for one trade leg: the strictest of the
    /// subnet's slow moving (emission EMA) price, its fast moving price
    /// ([`crate::SubnetFastMovingPrice`]), and its spot price, shifted by
    /// [`crate::BASKET_TRADE_MAX_SLIPPAGE_BPS`] against the trade (up for a buy ceiling,
    /// down for a sell floor).
    ///
    /// Each anchor closes a different hole. Spot caps the trade's own price impact. The fast
    /// EMA (two-hour half-life, updated from the previous block's closing spot) is a price
    /// nobody can move inside a block: a key holder who lifts spot and has the fund buy, or
    /// dumps spot and has the fund sell, finds the fund bound to within 2% of where the pool
    /// traded before the move, so the fund never fills at the manipulated price. Pulling
    /// the anchor along means holding the pump — and the capital behind it — against
    /// arbitrage for several half-lives. The slow EMA (monthly) stays in the min/max as the
    /// level cap on how far even a held pump can carry the fund. A subnet missing either
    /// EMA (never emitted, or not yet updated since the fast series was introduced) is
    /// refused.
    ///
    /// When spot has already crossed the anchored bound the engine would reject the order
    /// with its own `PriceLimitExceeded`; refusing here keeps the caller-facing error
    /// `SlippageTooHigh` for every way a leg can miss the band.
    fn basket_trade_price_limit(netuid: NetUid, leg: Leg) -> Result<TaoBalance, DispatchError> {
        let slow: U64F64 = Self::get_moving_alpha_price(netuid);
        ensure!(
            slow > U64F64::saturating_from_num(0),
            Error::<T>::SlippageTooHigh
        );
        let fast: U64F64 =
            SubnetFastMovingPrice::<T>::get(netuid).ok_or(Error::<T>::SlippageTooHigh)?;
        ensure!(
            fast > U64F64::saturating_from_num(0),
            Error::<T>::SlippageTooHigh
        );
        let spot: U64F64 = T::SwapInterface::current_alpha_price(netuid.into());
        let (reference, bps) = match leg {
            Leg::Buy => (
                slow.min(fast).min(spot),
                BPS_DENOMINATOR.saturating_add(crate::BASKET_TRADE_MAX_SLIPPAGE_BPS),
            ),
            Leg::Sell => (
                slow.max(fast).max(spot),
                BPS_DENOMINATOR.saturating_sub(crate::BASKET_TRADE_MAX_SLIPPAGE_BPS),
            ),
        };
        let bound: U64F64 = reference
            .saturating_mul(U64F64::saturating_from_num(bps))
            .safe_div(U64F64::saturating_from_num(BPS_DENOMINATOR));
        let already_past = match leg {
            Leg::Buy => spot > bound,
            Leg::Sell => spot < bound,
        };
        ensure!(!already_past, Error::<T>::SlippageTooHigh);

        let limit = bound.saturating_mul(U64F64::saturating_from_num(PRICE_LIMIT_SCALE));
        Ok(limit.saturating_to_num::<u64>().into())
    }

    /// Take `tao_mid` out of the fund's turnover bucket after refilling it for the blocks
    /// elapsed (capacity `nav_before × BasketDailyTurnoverCap / u16::MAX` with `nav_before`
    /// the guarded NAV, full refill over [`crate::BASKET_TRADE_REFILL_BLOCKS`]).
    fn consume_basket_trade_budget(
        hotkey: &T::AccountId,
        nav_before: u64,
        tao_mid: u64,
    ) -> DispatchResult {
        let now = Self::get_current_block_as_u64();
        let budget = Self::basket_trade_budget_tao(nav_before);
        let available = Self::basket_trade_bucket_at(hotkey, now, budget);
        let remaining = available
            .checked_sub(tao_mid)
            .ok_or(Error::<T>::BasketTurnoverBudgetExceeded)?;
        BasketTradeBucket::<T>::insert(hotkey, (remaining, now));
        Ok(())
    }

    /// Post-buy liquidity check: the fund's holding on `netuid` may not exceed
    /// [`crate::BasketLiquidityCap`] of the pool's alpha reserve. Root is the fund's cash
    /// slot with no pool and is exempt. Realizable value (the concentration cap's measure)
    /// is bounded by the pool's TAO reserve, so it cannot see a fund accumulating a thin
    /// pool's supply while counterparties sell into its price support; this rule can.
    fn ensure_within_liquidity_cap(
        hotkey: &T::AccountId,
        escrow: &T::AccountId,
        netuid: NetUid,
    ) -> DispatchResult {
        if netuid.is_root() {
            return Ok(());
        }
        let held =
            Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, escrow, netuid).to_u64();
        let reserve = SubnetAlphaIn::<T>::get(netuid).to_u64();
        let cap = BasketLiquidityCap::<T>::get() as u64;
        ensure!(
            Self::share_within_cap(held, reserve, cap),
            Error::<T>::BasketLiquidityCapExceeded
        );
        Ok(())
    }

    /// The basket concentration cap ([`crate::BasketConcentrationCap`], u16-normalized) when
    /// it is enforceable with `available` subnets, else `None`. A cap of 1/16 needs at least
    /// 16 destinations to be satisfiable, so the rule is skipped while the chain has fewer
    /// (young chains, tests).
    pub(crate) fn binding_basket_concentration_cap(available: u64) -> Option<u64> {
        let cap = BasketConcentrationCap::<T>::get() as u64;
        let min_dests_for_cap = (u16::MAX as u64).div_ceil(cap.max(1));
        (available >= min_dests_for_cap).then_some(cap)
    }

    /// `part / whole <= cap / u16::MAX`, computed in u128 so chain-scale TAO values cannot
    /// overflow. `whole == 0` (empty fund) trivially passes.
    pub(crate) fn share_within_cap(part: u64, whole: u64, cap: u64) -> bool {
        u128::from(part).saturating_mul(u128::from(u16::MAX))
            <= u128::from(cap).saturating_mul(u128::from(whole))
    }

    /// Post-trade concentration check: the destination holding's realizable value may not
    /// exceed [`crate::BasketConcentrationCap`] of the fund's guarded NAV.
    fn ensure_within_concentration_cap(destination_value: u64, nav: u64) -> DispatchResult {
        let available = Self::get_all_subnet_netuids().len() as u64;
        if let Some(cap) = Self::binding_basket_concentration_cap(available) {
            ensure!(
                Self::share_within_cap(destination_value, nav, cap),
                Error::<T>::BasketConcentrationCapExceeded
            );
        }
        Ok(())
    }

    /// Weight of one basket trade over `num_holdings` escrow rows: two AMM legs with fee
    /// settlement plus the pre-trade realizable-NAV sweep and the two post-trade re-quotes
    /// (origin and destination), as benchmarked.
    pub(crate) fn swap_basket_weight(num_holdings: u64) -> Weight {
        <T as crate::pallet::Config>::WeightInfo::swap_basket(
            u32::try_from(num_holdings).unwrap_or(u32::MAX),
        )
    }

    /// Pre-dispatch weight of `swap_basket`: the trade over the row cap 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 swap_basket_declared_weight() -> Weight {
        Self::swap_basket_weight(MAX_BASKET_ROWS).saturating_add(Self::basket_flush_weight_bound())
    }
}