//! Entitlements to dividend alpha whose conversion into basket shares is deferred. //! //! For each origin/generation, `owed_alpha = root_stake * rate - debt`. //! A root stake change of `delta` adds `delta * rate` to the account's signed debt, //! so neither arrivals nor departures change the alpha already earned. Rates are //! activated lazily before the first stake/fund change that cannot settle a batch; //! subsequent epoch credits record their earning stake immediately. Unsnapshotted //! sub-threshold dust retains the existing queue policy. //! //! Once conversion is safe, `F = minted_shares / remaining_alpha` advances //! BasketRate by `rate * F`. Signed debts become BasketClaimed corrections by //! `debt * F`, lazily on the account's next operation. A generation's conversion //! remains until every debt using it is consumed. This avoids walking stakers. //! A claimant may instead sell their own alpha directly. Dissolution uses the //! same accounting with a separate TAO accumulator, outside the basket NAV. use super::*; use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; use scale_info::TypeInfo; use substrate_fixed::types::{I64F64, I96F32}; use subtensor_swap_interface::SwapHandler; /// Unpriced alpha earned per unit of real root stake. Stake changes adjust a /// separate signed debt, preserving the entitlement earned before the change. #[derive(Clone, Encode, Decode, DecodeWithMemTracking, MaxEncodedLen, TypeInfo)] pub struct DeferredBasketState { pub generation: u64, pub rate: I64F64, pub debt_count: u32, } #[derive(Clone, Encode, Decode, DecodeWithMemTracking, MaxEncodedLen, TypeInfo)] pub struct DeferredBasketDebt { pub generation: u64, pub amount: I64F64, } /// Conversion is retained until every signed debt has been converted to a /// BasketClaimed watermark. Accounts with unchanged stake need no debt row. #[derive(Clone, Encode, Decode, DecodeWithMemTracking, MaxEncodedLen, TypeInfo)] pub struct DeferredBasketConversion { pub shares_per_alpha: I64F64, pub cash_per_alpha: I64F64, pub debt_count: u32, } impl Pallet { /// Make a pending credit independent of future root stake and escrow changes. /// The fund's own dividend is credited in alpha immediately; it belongs to /// the shareholders who held the fund when the dividend was earned. pub(crate) fn snapshot_deferred_dividends(hotkey: &T::AccountId) -> DispatchResult { let pending: Vec<_> = PendingBasketDeposits::::iter_prefix(hotkey) .take((crate::MAX_ROOT_CLAIM_WORK as usize).saturating_add(1)) .collect(); ensure!( pending.len() <= crate::MAX_ROOT_CLAIM_WORK as usize, Error::::BasketDepositPending ); for (netuid, alpha) in pending { if !DeferredBasketRates::::contains_key(hotkey, netuid) { Self::record_deferred_dividend(hotkey, netuid, alpha)?; } } Ok(()) } fn record_deferred_dividend( hotkey: &T::AccountId, netuid: NetUid, alpha: AlphaBalance, ) -> DispatchResult { if alpha.is_zero() { return Ok(()); } ensure!( DeferredBasketRates::::contains_key(hotkey, netuid) || DeferredBasketRates::::iter_prefix(hotkey) .take(crate::MAX_ROOT_CLAIM_WORK as usize) .count() < crate::MAX_ROOT_CLAIM_WORK as usize, Error::::BasketDepositPending ); let escrow = Self::get_beta_escrow_account_id(); let escrow_stake = Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, &escrow, NetUid::ROOT) .to_u64(); let total = Self::get_stake_for_hotkey_on_subnet(hotkey, NetUid::ROOT).to_u64(); let root = total.saturating_sub(escrow_stake); let real_alpha = Self::mul_div_u64(alpha.to_u64(), root, total); let fund_alpha = alpha.to_u64().saturating_sub(real_alpha); if fund_alpha > 0 && escrow_stake > 0 && BasketShares::::get(hotkey) > 0 { Self::increase_stake_for_hotkey_and_coldkey_on_subnet( hotkey, &escrow, netuid, fund_alpha.into(), ); } else if fund_alpha > 0 { Self::recycle_subnet_alpha(netuid, fund_alpha.into()); } if real_alpha == 0 { PendingBasketDeposits::::remove(hotkey, netuid); return Ok(()); } let increment = I64F64::checked_from_num(real_alpha) .ok_or(sp_runtime::ArithmeticError::Overflow)? .checked_div( I64F64::checked_from_num(root).ok_or(sp_runtime::ArithmeticError::Overflow)?, ) .ok_or(sp_runtime::ArithmeticError::Overflow)?; let state = match DeferredBasketRates::::get(hotkey, netuid) { Some(mut state) => { state.rate = state .rate .checked_add(increment) .ok_or(sp_runtime::ArithmeticError::Overflow)?; state } None => { let generation = DeferredBasketGeneration::::get(hotkey) .checked_add(1) .ok_or(sp_runtime::ArithmeticError::Overflow)?; DeferredBasketGeneration::::insert(hotkey, generation); DeferredBasketState { generation, rate: increment, debt_count: 0, } } }; DeferredBasketRates::::insert(hotkey, netuid, state); // Dissolution scans this fund index even if all shareholders/root // stakers have exited and only deferred dividend rights remain. BasketShares::::mutate(hotkey, |_| {}); PendingBasketDeposits::::insert(hotkey, netuid, AlphaBalance::from(real_alpha)); Ok(()) } /// Coinbase uses this entry point; a retry must only requeue alpha, never /// record the same earned dividend a second time. pub(crate) fn enqueue_root_dividend( hotkey: &T::AccountId, netuid: NetUid, alpha: AlphaBalance, ) { if DeferredBasketRates::::iter_prefix(hotkey) .next() .is_none() { Self::enqueue_basket_deposit(hotkey, netuid, alpha); return; } let old = PendingBasketDeposits::::get(hotkey, netuid); // All writes, including the escrow yield, roll back on accounting failure. let outcome = frame_support::storage::with_transaction(|| { match Self::record_deferred_dividend(hotkey, netuid, alpha) { Ok(()) => { PendingBasketDeposits::::mutate(hotkey, netuid, |pending| { *pending = pending.saturating_add(old) }); frame_support::storage::TransactionOutcome::Commit(Ok(())) } Err(error) => frame_support::storage::TransactionOutcome::Rollback(Err(error)), } }); // Overflow is not a reason to silently assign the credit to a later // claimant base. Leave the original accounting intact and recycle it. if outcome.is_err() { Self::recycle_subnet_alpha(netuid, alpha); } } fn converted_debt(debt: &DeferredBasketDebt, factor: I64F64) -> Result { // Round toward +infinity: negative corrections cannot grant more shares // than earned, positive corrections cannot let a new staker capture dust. let shares = debt .amount .checked_mul(factor) .ok_or(sp_runtime::ArithmeticError::Overflow)?; Ok(shares .checked_ceil() .ok_or(sp_runtime::ArithmeticError::Overflow)? .saturating_to_num::()) } pub(super) fn deferred_claimed_correction( hotkey: &T::AccountId, coldkey: &T::AccountId, ) -> i128 { DeferredBasketDebts::::get(hotkey, coldkey) .iter() .fold( 0i128, |sum, (netuid, debt)| match DeferredBasketConversions::::get(( hotkey, netuid, debt.generation, )) { Some(conversion) => sum.saturating_add( Self::converted_debt(debt, conversion.shares_per_alpha) .unwrap_or(i128::MAX), ), None => sum, }, ) } pub(super) fn settle_deferred_debts( hotkey: &T::AccountId, coldkey: &T::AccountId, ) -> DispatchResult { frame_support::storage::with_storage_layer(|| { DeferredBasketDebts::::try_mutate_exists( hotkey, coldkey, |entry| -> DispatchResult { let Some(debts) = entry else { return Ok(()); }; let mut completed = Vec::new(); for (netuid, debt) in debts.iter() { if let Some(mut conversion) = DeferredBasketConversions::::get((hotkey, netuid, debt.generation)) { let correction = Self::converted_debt(debt, conversion.shares_per_alpha)?; BasketClaimed::::try_mutate( hotkey, coldkey, |claimed| -> DispatchResult { *claimed = claimed .checked_add(correction) .ok_or(sp_runtime::ArithmeticError::Overflow)?; Ok(()) }, )?; let cash = Self::converted_debt(debt, conversion.cash_per_alpha)?; if cash != 0 { DeferredBasketCashClaimed::::try_mutate( hotkey, coldkey, |claimed| -> DispatchResult { *claimed = claimed .checked_add(cash) .ok_or(sp_runtime::ArithmeticError::Overflow)?; Ok(()) }, )?; } conversion.debt_count = conversion.debt_count.saturating_sub(1); if conversion.debt_count == 0 { DeferredBasketConversions::::remove(( hotkey, netuid, debt.generation, )); } else { DeferredBasketConversions::::insert( (hotkey, netuid, debt.generation), conversion, ); } completed.push(*netuid); } } for netuid in completed { debts.remove(&netuid); } if debts.is_empty() { *entry = None; } Ok(()) }, ) }) } pub(crate) fn rebase_deferred_dividends( hotkey: &T::AccountId, coldkey: &T::AccountId, delta: i128, ) -> DispatchResult { Self::settle_deferred_debts(hotkey, coldkey)?; let cash = DeferredBasketCashRate::::get(hotkey) .checked_mul( I64F64::checked_from_num(delta).ok_or(sp_runtime::ArithmeticError::Overflow)?, ) .ok_or(sp_runtime::ArithmeticError::Overflow)?; if cash != I64F64::from_num(0) { DeferredBasketCashClaimed::::try_mutate( hotkey, coldkey, |claimed| -> DispatchResult { *claimed = claimed .checked_add( cash.checked_ceil() .ok_or(sp_runtime::ArithmeticError::Overflow)? .saturating_to_num::(), ) .ok_or(sp_runtime::ArithmeticError::Overflow)?; Ok(()) }, )?; } if DeferredBasketRates::::iter_prefix(hotkey) .next() .is_none() { return Ok(()); } DeferredBasketDebts::::try_mutate(hotkey, coldkey, |debts| -> DispatchResult { for (netuid, mut state) in DeferredBasketRates::::iter_prefix(hotkey) { let correction = state .rate .checked_mul( I64F64::checked_from_num(delta) .ok_or(sp_runtime::ArithmeticError::Overflow)?, ) .ok_or(sp_runtime::ArithmeticError::Overflow)?; if let Some(debt) = debts.get_mut(&netuid) { ensure!( debt.generation == state.generation, DispatchError::Other("unsettled dividend generation") ); debt.amount = debt .amount .checked_add(correction) .ok_or(sp_runtime::ArithmeticError::Overflow)?; } else { debts .try_insert( netuid, DeferredBasketDebt { generation: state.generation, amount: correction, }, ) .map_err(|_| Error::::BasketDepositPending)?; state.debt_count = state .debt_count .checked_add(1) .ok_or(sp_runtime::ArithmeticError::Overflow)?; DeferredBasketRates::::insert(hotkey, netuid, state); } } Ok(()) }) } /// Bind a settled credit's share mint to the root stake that earned its alpha, /// without iterating stakers. Closed generations are applied lazily per coldkey. pub(super) fn convert_deferred_dividend( hotkey: &T::AccountId, netuid: NetUid, alpha: u64, shares: u64, ) -> DispatchResult { let state = DeferredBasketRates::::take(hotkey, netuid) .ok_or(DispatchError::Other("missing dividend entitlement"))?; let factor = I64F64::checked_from_num(shares) .ok_or(sp_runtime::ArithmeticError::Overflow)? .checked_div( I64F64::checked_from_num(alpha).ok_or(sp_runtime::ArithmeticError::Overflow)?, ) .ok_or(sp_runtime::ArithmeticError::Overflow)?; let increment = state .rate .checked_mul(factor) .ok_or(sp_runtime::ArithmeticError::Overflow)?; BasketRate::::try_mutate(hotkey, |rate| -> DispatchResult { *rate = rate .checked_add(I96F32::from_num(increment)) .ok_or(sp_runtime::ArithmeticError::Overflow)?; Ok(()) })?; if state.debt_count > 0 { DeferredBasketConversions::::insert( (hotkey, netuid, state.generation), DeferredBasketConversion { shares_per_alpha: factor, cash_per_alpha: I64F64::from_num(0), debt_count: state.debt_count, }, ); } Ok(()) } /// A claimant can redeem their own deferred alpha without repricing the /// basket. Only that claimant bears the sale price/slippage, so this needs /// no common EMA window and cannot transfer value from basket shareholders. pub(super) fn claim_deferred_dividends( hotkey: &T::AccountId, coldkey: &T::AccountId, ignore_minimum: bool, ) -> Result<(u64, u32), DispatchError> { let states: Vec<_> = DeferredBasketRates::::iter_prefix(hotkey).collect(); Self::settle_deferred_debts(hotkey, coldkey)?; let mut total = 0u64; let mut rows = 0u32; for (netuid, state) in states { let stake = Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, coldkey, NetUid::ROOT) .to_u64(); let debt = DeferredBasketDebts::::get(hotkey, coldkey) .get(&netuid) .map_or(I64F64::from_num(0), |debt| debt.amount); let owed = state .rate .checked_mul( I64F64::checked_from_num(stake).ok_or(sp_runtime::ArithmeticError::Overflow)?, ) .and_then(|earned| earned.checked_sub(debt)) .ok_or(sp_runtime::ArithmeticError::Overflow)?; if owed <= I64F64::from_num(0) { continue; } let alpha = owed .saturating_to_num::() .min(PendingBasketDeposits::::get(hotkey, netuid).to_u64()); if alpha == 0 { continue; } let value = Self::try_realizable_tao_for_alpha(netuid, alpha)?; let Some(value) = value else { continue; }; if value == 0 || (!ignore_minimum && I96F32::from_num(value) < RootClaimableThreshold::::get(NetUid::ROOT)) { continue; } Self::ensure_staking_hotkeys_can_grow(coldkey, hotkey)?; let tao = frame_support::storage::with_storage_layer(|| -> Result { // Reserve the paid entitlement before the sale and before its TAO // becomes new root stake. The stake rebase below is separate. DeferredBasketDebts::::try_mutate( hotkey, coldkey, |debts| -> DispatchResult { if let Some(debt) = debts.get_mut(&netuid) { debt.amount = debt .amount .checked_add( I64F64::checked_from_num(alpha) .ok_or(sp_runtime::ArithmeticError::Overflow)?, ) .ok_or(sp_runtime::ArithmeticError::Overflow)?; } else { debts .try_insert( netuid, DeferredBasketDebt { generation: state.generation, amount: I64F64::checked_from_num(alpha) .ok_or(sp_runtime::ArithmeticError::Overflow)?, }, ) .map_err(|_| Error::::BasketDepositPending)?; DeferredBasketRates::::mutate(hotkey, netuid, |state| { if let Some(state) = state { state.debt_count = state.debt_count.saturating_add(1); } }); } Ok(()) }, )?; let tao = Self::sell_basket_alpha_for_root_tao(netuid, alpha.into())?.to_u64(); ensure!(tao > 0, Error::::AmountTooLow); PendingBasketDeposits::::mutate(hotkey, netuid, |pending| { *pending = pending.saturating_sub(alpha.into()) }); SubnetRootSellTao::::mutate(netuid, |total| { *total = total.saturating_add(tao.into()) }); Self::increase_stake_for_hotkey_and_coldkey_on_subnet( hotkey, coldkey, NetUid::ROOT, tao.into(), ); Self::credit_root_reserves(tao.into()); Self::add_stake_adjust_root_claimed_for_hotkey_and_coldkey( hotkey, coldkey, tao, )?; Self::touch_root_stake_age(coldkey, hotkey); if PendingBasketDeposits::::get(hotkey, netuid).is_zero() { PendingBasketDeposits::::remove(hotkey, netuid); Self::convert_deferred_dividend(hotkey, netuid, 1, 0)?; } Ok(tao) })?; total = total .checked_add(tao) .ok_or(sp_runtime::ArithmeticError::Overflow)?; rows = rows.saturating_add(1); } let stake = Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, coldkey, NetUid::ROOT) .to_u64(); let owed_cash = DeferredBasketCashRate::::get(hotkey) .checked_mul( I64F64::checked_from_num(stake).ok_or(sp_runtime::ArithmeticError::Overflow)?, ) .ok_or(sp_runtime::ArithmeticError::Overflow)? .saturating_to_num::() .saturating_sub(DeferredBasketCashClaimed::::get(hotkey, coldkey)); let cash = u64::try_from(owed_cash) .unwrap_or(0) .min(DeferredBasketCashReserve::::get(hotkey)); if cash > 0 && (ignore_minimum || I96F32::from_num(cash) >= RootClaimableThreshold::::get(NetUid::ROOT)) { Self::ensure_staking_hotkeys_can_grow(coldkey, hotkey)?; frame_support::storage::with_storage_layer(|| -> DispatchResult { DeferredBasketCashClaimed::::try_mutate( hotkey, coldkey, |claimed| -> DispatchResult { *claimed = claimed .checked_add(i128::from(cash)) .ok_or(sp_runtime::ArithmeticError::Overflow)?; Ok(()) }, )?; DeferredBasketCashReserve::::mutate(hotkey, |reserve| { *reserve = reserve.saturating_sub(cash) }); Self::increase_stake_for_hotkey_and_coldkey_on_subnet( hotkey, coldkey, NetUid::ROOT, cash.into(), ); Self::add_stake_adjust_root_claimed_for_hotkey_and_coldkey(hotkey, coldkey, cash)?; Self::touch_root_stake_age(coldkey, hotkey); Ok(()) })?; total = total .checked_add(cash) .ok_or(sp_runtime::ArithmeticError::Overflow)?; } if total > 0 { BasketRedeemedTao::::mutate(hotkey, |redeemed| { *redeemed = redeemed.saturating_add(total.into()) }); Self::deposit_event(Event::BasketClaimed { hotkey: hotkey.clone(), coldkey: coldkey.clone(), tao: total.into(), }); } Ok((total, rows)) } /// Close a dissolving origin into a separate cash entitlement, never into /// basket shares priced against unrelated (possibly manipulated) pools. pub(crate) fn dissolve_deferred_dividend( hotkey: &T::AccountId, netuid: NetUid, alpha: AlphaBalance, ) -> DispatchResult { frame_support::storage::with_storage_layer(|| -> DispatchResult { let state = DeferredBasketRates::::take(hotkey, netuid) .ok_or(DispatchError::Other("missing dividend entitlement"))?; let total_stake_before_sale = (!NetworksAdded::::get(netuid)).then(TotalStake::::get); let tao = if alpha.is_zero() { 0 } else { match Self::sell_basket_alpha_for_root_tao(netuid, alpha) { Ok(tao) => tao.to_u64(), Err(error) if T::SwapInterface::classify_failure(&error) == subtensor_swap_interface::SwapFailureKind::TerminalLiquidity => { Self::burn_subnet_alpha(netuid, alpha); Self::deposit_event(Event::BasketAlphaWrittenOff { hotkey: hotkey.clone(), netuid, alpha, }); 0 } Err(error) => return Err(error), } }; // Dissolve start already removed this subnet's reserves from TotalStake. if let Some(total) = total_stake_before_sale { TotalStake::::put(total); } SubnetRootSellTao::::mutate(netuid, |total| { *total = total.saturating_add(tao.into()) }); let factor = I64F64::checked_from_num(tao) .ok_or(sp_runtime::ArithmeticError::Overflow)? .checked_div( I64F64::checked_from_num(alpha.to_u64().max(1)) .ok_or(sp_runtime::ArithmeticError::Overflow)?, ) .ok_or(sp_runtime::ArithmeticError::Overflow)?; DeferredBasketCashRate::::try_mutate(hotkey, |rate| -> DispatchResult { *rate = rate .checked_add( state .rate .checked_mul(factor) .ok_or(sp_runtime::ArithmeticError::Overflow)?, ) .ok_or(sp_runtime::ArithmeticError::Overflow)?; Ok(()) })?; DeferredBasketCashReserve::::try_mutate(hotkey, |reserve| -> DispatchResult { *reserve = reserve .checked_add(tao) .ok_or(sp_runtime::ArithmeticError::Overflow)?; Ok(()) })?; if state.debt_count > 0 { DeferredBasketConversions::::insert( (hotkey, netuid, state.generation), DeferredBasketConversion { shares_per_alpha: I64F64::from_num(0), cash_per_alpha: factor, debt_count: state.debt_count, }, ); } Self::credit_root_reserves(tao.into()); PendingBasketDeposits::::remove(hotkey, netuid); Ok(()) }) } pub(super) fn move_deferred_dividends(old: &T::AccountId, new: &T::AccountId) -> u32 { let generation = DeferredBasketGeneration::::take(old); DeferredBasketGeneration::::insert(new, generation); let rates: Vec<_> = DeferredBasketRates::::drain_prefix(old).collect(); let debts: Vec<_> = DeferredBasketDebts::::drain_prefix(old).collect(); let conversions: Vec<_> = DeferredBasketConversions::::drain_prefix((old,)).collect(); let cash_claimed: Vec<_> = DeferredBasketCashClaimed::::drain_prefix(old).collect(); let rows = rates .len() .saturating_add(debts.len()) .saturating_add(conversions.len()) .saturating_add(cash_claimed.len()) as u32; let cash_rate = DeferredBasketCashRate::::take(old); if cash_rate != I64F64::from_num(0) { DeferredBasketCashRate::::insert(new, cash_rate); } let cash_reserve = DeferredBasketCashReserve::::take(old); if cash_reserve != 0 { DeferredBasketCashReserve::::insert(new, cash_reserve); } if !rates.is_empty() { BasketShares::::mutate(new, |_| {}); } for (coldkey, claimed) in cash_claimed { DeferredBasketCashClaimed::::insert(new, &coldkey, claimed); if claimed < 0 && !StakingHotkeys::::get(&coldkey).contains(new) { HotkeySwapBasketCredits::::insert(&coldkey, new, ()); } } for (netuid, state) in rates { DeferredBasketRates::::insert(new, netuid, state); } for (coldkey, debt) in debts { let retained = debt .values() .any(|entry| entry.amount < I64F64::from_num(0)); DeferredBasketDebts::::insert(new, &coldkey, debt); if (HotkeySwapBasketCredits::::take(&coldkey, old).is_some() || retained) && !StakingHotkeys::::get(&coldkey).contains(new) { HotkeySwapBasketCredits::::insert(&coldkey, new, ()); } } for ((netuid, generation), conversion) in conversions { DeferredBasketConversions::::insert((new, netuid, generation), conversion); } rows } }