//! The pending-basket-deposit queue: how root dividend credits get from an epoch into a //! validator's beta basket without doing valuation work inside epoch blocks. //! //! Epochs enqueue `(hotkey, origin subnet) -> alpha` credits into [`PendingBasketDeposits`] //! (cheap map mutations). Credits are flushed per hotkey as one batched deposit — a single //! full-NAV share mint for all queued origins ([`Pallet::deposit_root_alpha_batch`], the //! deposit engine at the bottom of this module) — either by the one-hotkey-per-block //! round-robin drain here, or eagerly by any operation that touches the hotkey's claimant //! base or fund (claims, basket stakes, root stake changes, hotkey swaps). The eager //! flushes are what make the deferral economically inert: the queue flushes before any //! stake change, so arriving stake can't capture — and departing stake doesn't forfeit — //! any flushable dividend. Only deliberately deferred sub-threshold dust ever crosses //! staker sets. use super::claim_root::BasketFunding; use super::*; use frame_support::storage::{TransactionOutcome, with_transaction}; use pallet_alpha_assets::AlphaAssetsInterface; use substrate_fixed::types::U64F64; use subtensor_swap_interface::SwapHandler; impl Pallet { /// Queue a root dividend credit for later batched deposit into the hotkey's basket, /// merging with any credit already queued for the same origin. pub fn enqueue_basket_deposit( hotkey: &T::AccountId, origin_netuid: NetUid, root_alpha: AlphaBalance, ) { if root_alpha.is_zero() { return; } PendingBasketDeposits::::mutate(hotkey, origin_netuid, |pending| { *pending = pending.saturating_add(root_alpha) }); } /// Deposit all of a hotkey's flushable queued credits into its basket as one batch. /// /// A credit is flushable when its origin subnet still exists and its spot value is at /// or above `RootClaimableThreshold[ROOT]`. The spot filter is deliberate and cheap /// (reserves ratio, no bignum pow): spot over-marks relative to the realizable quote, /// so anything it defers is certainly below the threshold. Sub-threshold credits stay /// queued and keep merging with future dividends until they are worth a deposit — this /// is what keeps dust from ever becoming a basket holding row (and what lets dust /// consolidation run on uncurated funds without fighting next epoch's accrual). /// Credits from dissolved subnets are recycled (issuance conservation) then dropped. /// `NetworksAdded` is cleared at dissolve start, so this path covers the in-progress /// window; the durable guarantee against netuid reuse is the /// `NetworkPendingBasketDeposits` dissolution phase, which purges every queued credit /// for the netuid before cleanup completes (and reuse becomes possible). /// /// Hotkeys no longer registered on root cannot earn more dividends to merge dust past /// the threshold, so every remaining queued credit is recycled and purged (the queued /// alpha only — basket holdings are untouched). Root replacement calls this after /// dropping membership so churn cannot leave permanent straggler rows. /// /// Returns `(work, last_key)`: the approximate quote work done (scan-priced into claim /// weights by `root_claim_for_hotkey`) and the raw storage key of the hotkey's last /// queue entry, which the drain stores as its cursor so a hotkey whose credits are all /// deferred dust still gets skipped past instead of pinning the queue head. pub(crate) fn flush_basket_deposits_for_hotkey( hotkey: &T::AccountId, ) -> (u64, Option>) { // While the seed migration owns the basket maps every deposit waits (epochs route // credits through `DeferredRootAlphaDividends` during the seed, so the queue should // be empty; stay defensive regardless). if crate::migrations::migrate_seed_beta_basket::seed_beta_basket_v2_in_progress::() { return (0, None); } let threshold: u64 = RootClaimableThreshold::::get(NetUid::ROOT).saturating_to_num::(); let on_root = Self::is_hotkey_registered_on_network(NetUid::ROOT, hotkey); let mut work: u64 = 0; let mut last_netuid: Option = None; let mut batch: Vec<(NetUid, AlphaBalance)> = Vec::new(); for (netuid, alpha) in PendingBasketDeposits::::iter_prefix(hotkey) { last_netuid = Some(netuid); work = work.saturating_add(1); if !Self::if_subnet_exist(netuid) { Self::drop_pending_basket_deposit(hotkey, netuid, alpha); continue; } // Root straggler: recycle the pending credit back into the origin subnet and // drop the row. Do not touch basket holdings — only the unqueued dividend. if !on_root { Self::drop_pending_basket_deposit(hotkey, netuid, alpha); continue; } let spot: U64F64 = T::SwapInterface::current_alpha_price(netuid.into()); #[cfg(test)] crate::tests::mock::inc_basket_quote_ops(); let value: u64 = spot .saturating_mul(U64F64::saturating_from_num(alpha.to_u64())) .saturating_to_num::(); if value < threshold { continue; } batch.push((netuid, alpha)); } let last_key = last_netuid.map(|netuid| PendingBasketDeposits::::hashed_key_for(hotkey, netuid)); if batch.is_empty() { return (work, last_key); } for (netuid, _) in batch.iter() { PendingBasketDeposits::::remove(hotkey, netuid); #[cfg(test)] crate::tests::mock::inc_basket_write_ops(); } work = work.saturating_add(Self::deposit_root_alpha_batch(hotkey, &batch)); (work, last_key) } /// The per-block queue drain: flush exactly one queued hotkey per block, round-robin /// via the stored cursor (a block that finds the cursor at the end of the map spends /// its turn resetting it). One per block is enough because root dividends only accrue /// to root-registered hotkeys and root churn finalizes (recycles + purges) any queued /// credits on the outgoing hotkey, so the queue stays capped by the root UID table — /// a full cycle fits well inside a tempo, each flush is itself bounded work (holdings + /// 2 x origins quotes), and anything urgent is flushed eagerly by the touch hooks /// (claims, basket stakes, root stake changes). The drain is only the janitor for /// untouched funds. Runs right after coinbase. pub fn flush_pending_basket_deposits_block() { if crate::migrations::migrate_seed_beta_basket::seed_beta_basket_v2_in_progress::() { return; } let cursor = PendingBasketFlushCursor::::get(); let mut keys = match cursor { Some(raw) => PendingBasketDeposits::::iter_keys_from(raw), None => PendingBasketDeposits::::iter_keys(), }; let Some((hotkey, _)) = keys.next() else { // End of the map (or empty queue): restart from the top next block. PendingBasketFlushCursor::::kill(); #[cfg(test)] crate::tests::mock::inc_basket_write_ops(); return; }; drop(keys); let (_work, last_key) = Self::flush_basket_deposits_for_hotkey(&hotkey); match last_key { Some(last_key) => { PendingBasketFlushCursor::::put(last_key); #[cfg(test)] crate::tests::mock::inc_basket_write_ops(); } // Nothing was queued under this hotkey after all (racing removal); // clear the cursor so the next pass restarts cleanly. None => { PendingBasketFlushCursor::::kill(); #[cfg(test)] crate::tests::mock::inc_basket_write_ops(); } } } /// Distributes a validator's root dividend (origin-subnet alpha, net of take) into its beta /// basket according to the validator's root weight vector `w` (set on subnet 0). /// /// Single-credit wrapper over [`Self::deposit_root_alpha_batch`]. Epochs no longer call /// this inline — they enqueue credits into [`PendingBasketDeposits`] and the queue /// flushes per hotkey in batches (see [`Self::flush_basket_deposits_for_hotkey`]) — but /// the deposit semantics described here are those of each batch. /// /// Curated flow: sell the origin alpha for TAO, then split that TAO across subnets per `w`, /// buying each subnet's alpha and staking it to the validator under the global escrow /// coldkey (a root-destination slice is held directly as the fund's root-stake cash /// position). The deposit then mints *fund shares* against the whole basket: /// `shares = value_added * P / N`, where `N` is the fund's pre-sale realizable NAV, `P` the /// outstanding shares, and `value_added` the realizable NAV the full sell-and-redeploy /// actually added (final NAV minus that pre-sale snapshot), so the deposit bears its own /// sell impact, buy slippage, and fees instead of socializing them, and existing holders /// are neither diluted nor taxed. Stakers accrue entitlement through the single /// per-validator `BasketRate += shares / total_root_stake` accumulator; no entitlement is /// ever denominated in a particular subnet's alpha, which is what allows holdings to be /// rebalanced without touching staker claims. /// /// Uncurated flow (no stored root weights, or explicit weights filtered to nothing): the /// dividend *accumulates in place* — the origin alpha is credited directly to the fund's /// holding on the origin subnet, with no sell and no redeploy. The default basket is /// therefore the emission-weighted portfolio the dividends themselves describe, the /// protocol executes zero trades (no swap fees, no slippage, no sell pressure) on behalf /// of a validator that expressed no preference, and shares still mint at NAV against the /// realizable value the alpha added (see /// [`Self::try_accumulate_root_alpha_batch`]). /// /// Attribution (both flows): the dividend was earned by the validator's WHOLE root stake, /// including the fund's own root-slot (escrow) position. Only the real stakers' fraction /// of the value mints shares; the escrow slot's fraction enters the fund unminted, so the /// fund's own cash yield accrues to existing share holders through N/P instead of /// leaking to root stakers as free shares. /// /// The whole operation is transactional: if any swap fails (or the deposit is dust), it is /// rolled back and the original alpha is re-queued for a later flush (multi-credit batches /// split into per-origin retries first). Dividends are recycled only when the validator /// has no root stake to apportion against. /// /// Protocol-flow accounting is symmetric with redemption: the origin sell is booked as an /// outflow on the origin subnet and each redistribution buy as an inflow on its dest subnet, /// so that a deposit-then-claim round-trip nets to ~0 on the dest pools (the claim sell is /// booked as an outflow in `root_claim_for_hotkey`). An in-place accumulation moves no TAO /// through any pool, so it records nothing; the eventual claim sell is a genuine net /// extraction and books its outflow then. pub fn distribute_root_alpha_to_basket( hotkey: &T::AccountId, origin_netuid: NetUid, root_alpha: AlphaBalance, ) { if root_alpha.is_zero() { return; } Self::deposit_root_alpha_batch(hotkey, &[(origin_netuid, root_alpha)]); } /// Deposits a batch of root-dividend credits — each `(origin_netuid, alpha)`, at most /// one entry per origin — into a validator's basket with a single share mint. This is /// the whole point of the pending-deposit queue: a hotkey's credits from many subnet /// epochs share one full-NAV valuation instead of paying one per origin. /// /// Semantics are those of [`Self::distribute_root_alpha_to_basket`] generalized to a /// batch. The batch is transactional as a whole; on soft failure a multi-credit batch /// splits into per-origin retries so one borked origin cannot sink healthy credits, and /// any credit that still fails is re-queued for a later flush (it may merge with future /// dividends and become depositable). Credits are recycled only when demonstrably /// unapportionable (no root stake) or when the seed migration owns the basket maps. /// /// Returns the approximate quote work performed (holdings valued plus origin quotes), /// scan-priced into claim weights by callers that flush inside an extrinsic. pub(crate) fn deposit_root_alpha_batch( hotkey: &T::AccountId, batch: &[(NetUid, AlphaBalance)], ) -> u64 { if batch.iter().all(|(_, alpha)| alpha.is_zero()) { return 0; } // Seed migration still converting legacy claim state. Coinbase records its calculated // per-hotkey credit in DeferredRootAlphaDividends; recycle an unexpected direct call // defensively rather than writing BasketRate/Shares that a later pass would overwrite. if crate::migrations::migrate_seed_beta_basket::seed_beta_basket_v2_in_progress::() { Self::recycle_basket_deposit_batch(batch); return 0; } let valid = Self::get_valid_basket_weights(hotkey); let escrow = Self::get_beta_escrow_account_id(); // Claimant base = real stakers' root stake. The escrow custody account is not a claimant, // so its own root-slot holdings are excluded; otherwise the fund's claimable rate would // be diluted and a slice of shares would become unclaimable. The escrow slot is kept // separately: it earned its pro-rata slice of this dividend, which is credited to the // fund unminted below. let escrow_root = Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, &escrow, NetUid::ROOT); let total_root = Self::get_stake_for_hotkey_on_subnet(hotkey, NetUid::ROOT).saturating_sub(escrow_root); // No root stake to apportion against: recycle. if total_root.is_zero() { Self::recycle_basket_deposit_batch(batch); return 0; } // Approximate quote units executed, charged whether the deposit commits or rolls // back (the quotes ran either way). Uncurated: one NAV sweep plus two quotes per // origin. Curated: pre-sale NAV, deployment's pre/post-buy NAV sweeps, one buy per // destination, plus one sell per origin. let holdings = Self::get_basket_holdings(hotkey).len() as u64; let credits = batch.len() as u64; let work = if valid.is_empty() { holdings.saturating_add(credits.saturating_mul(2)) } else { holdings .saturating_mul(3) .saturating_add(valid.len() as u64) .saturating_add(credits) }; let outcome = with_transaction(|| { let result = if valid.is_empty() { Self::try_accumulate_root_alpha_batch( hotkey, batch, total_root.to_u64(), escrow_root.to_u64(), ) } else { Self::try_distribute_root_alpha_batch( hotkey, batch, &valid, total_root.to_u64(), escrow_root.to_u64(), ) }; match result { Ok(()) => TransactionOutcome::Commit(Ok(())), Err(err) => TransactionOutcome::Rollback(Err(err)), } }); if outcome.is_ok() { return work; } // Soft failure: split a multi-credit batch so one bad origin cannot sink the rest. // Singleton failures re-queue — recoverable later (merge with future dividends, or a // healthier pool) rather than recycling healthy work away. if credits > 1 { let mut total = work; for credit in batch.iter().copied() { if credit.1.is_zero() { continue; } total = total.saturating_add(Self::deposit_root_alpha_batch(hotkey, &[credit])); } return total; } Self::requeue_basket_deposit_batch(hotkey, batch); work } /// Recycle every credit in an unapportionable deposit batch back into its origin subnet. fn recycle_basket_deposit_batch(batch: &[(NetUid, AlphaBalance)]) { for (origin_netuid, root_alpha) in batch { if !root_alpha.is_zero() { Self::recycle_subnet_alpha(*origin_netuid, *root_alpha); } } } /// Put rolled-back deposit credits back onto the pending queue so a later flush can /// retry them (they may merge with future origin dividends and clear the dust bar). fn requeue_basket_deposit_batch(hotkey: &T::AccountId, batch: &[(NetUid, AlphaBalance)]) { for (origin_netuid, root_alpha) in batch { Self::enqueue_basket_deposit(hotkey, *origin_netuid, *root_alpha); #[cfg(test)] if !root_alpha.is_zero() { crate::tests::mock::inc_basket_write_ops(); } } } /// Remove a queued pending-basket credit and recycle its alpha so earned dividends are /// not silently deleted from issuance. When `SubnetAlphaOut` still tracks the subnet /// (flush race / early dissolve), use the full recycle path; after stake teardown has /// removed that counter, recycle only the AlphaAssets side so we do not resurrect it. pub(crate) fn drop_pending_basket_deposit( hotkey: &T::AccountId, netuid: NetUid, alpha: AlphaBalance, ) { PendingBasketDeposits::::remove(hotkey, netuid); #[cfg(test)] crate::tests::mock::inc_basket_write_ops(); if alpha.is_zero() { return; } if SubnetAlphaOut::::contains_key(netuid) { Self::recycle_subnet_alpha(netuid, alpha); } else { let _ = T::AlphaAssets::recycle_alpha(netuid, alpha); } } /// Transactional body of [`Self::deposit_root_alpha_batch`]'s curated flow; any error /// rolls the whole batch back (the caller splits / re-queues). fn try_distribute_root_alpha_batch( hotkey: &T::AccountId, batch: &[(NetUid, AlphaBalance)], valid: &[(NetUid, u64)], total_root: u64, escrow_root: u64, ) -> DispatchResult { // A single-origin batch deploys straight from that origin's pot (the pre-queue // inline behavior — when a destination equals the origin no cash moves at all). // A multi-origin batch consolidates the cash on the root subnet account so the // deployment can fund every destination slice from a single pot. let mut origins = batch .iter() .filter(|(_, alpha)| !alpha.is_zero()) .map(|(netuid, _)| *netuid); let funding_netuid = match (origins.next(), origins.next()) { (Some(origin), None) => origin, _ => NetUid::ROOT, }; let root_account = Self::get_subnet_account_id(NetUid::ROOT).ok_or(Error::::RootNetworkDoesNotExist)?; // Snapshot realizable NAV before any origin sell. The fund may already hold // origin-subnet alpha; selling the dividend moves those pools against that holding. // Minting against this pre-sale baseline (with value_added = final − pre-sale) makes // the deposit bear that sale impact instead of taxing existing shareholders. A // non-positive transformation fails the dust check in the mint and rolls back. let pre_sale_nav: u64 = Self::get_validator_basket_nav_tao(hotkey).to_u64(); // 1. Sell each origin credit for TAO, booked as protocol outflow (TAO left that // origin pool). let mut tao_total: u64 = 0; for (origin_netuid, root_alpha) in batch { if root_alpha.is_zero() { continue; } let tao: TaoBalance = Self::swap_alpha_for_tao( *origin_netuid, *root_alpha, T::SwapInterface::min_price::(), true, )? .amount_paid_out; Self::record_protocol_outflow(*origin_netuid, tao); if *origin_netuid != funding_netuid && !origin_netuid.is_root() { Self::transfer_tao_from_subnet(*origin_netuid, &root_account, tao.into())?; } tao_total = tao_total.saturating_add(tao.to_u64()); } // 2. Deploy the TAO across the basket per the weight vector. `deploy_tao_into_basket` // still returns its post-sale pre-buy snapshot and buy-side delta; fold those into // the full-transformation value against the pre-sale NAV. let (post_sale_nav, deploy_delta) = Self::deploy_tao_into_basket( hotkey, valid, tao_total, BasketFunding::Protocol { origin_netuid: funding_netuid, }, )?; let value_added = post_sale_nav .saturating_add(deploy_delta) .saturating_sub(pre_sale_nav); // 3. Mint fund shares for the stakers' fraction of the value added. Self::mint_basket_dividend_shares( hotkey, pre_sale_nav, value_added, total_root, escrow_root, ) } /// Transactional body of [`Self::deposit_root_alpha_batch`]'s uncurated flow: each /// dividend credit is applied directly to the fund's holding on the subnet it arrived on. /// No swap runs — the alpha is already counted in `SubnetAlphaOut` (the recycle path /// decrements it when a credit is truly dropped), it just is not assigned to any stake /// position yet, so the whole deposit is a share-pool credit. Any error rolls the batch /// back (the caller splits / re-queues). /// /// Each credit is valued as the realizable delta on its origin holding alone: crediting /// stake moves no pool, so every other holding's quote is unchanged and the full-fund /// `nav_after` sweep the curated flow needs collapses to one extra quote per origin. /// The origins are distinct pools, so the per-origin deltas are independent and sum to /// exactly the value the batch added against the shared `nav_before` snapshot. /// Realizable valuation keeps deposit pricing honest on thin pools exactly as it does for /// bought alpha — the marginal alpha of a large holding quotes below spot, so existing /// share holders are never diluted by an over-marked deposit. fn try_accumulate_root_alpha_batch( hotkey: &T::AccountId, batch: &[(NetUid, AlphaBalance)], total_root: u64, escrow_root: u64, ) -> DispatchResult { let escrow = Self::get_beta_escrow_account_id(); let nav_before: u64 = Self::get_validator_basket_nav_tao(hotkey).to_u64(); let mut value_added: u64 = 0; for (origin_netuid, root_alpha) in batch { if root_alpha.is_zero() { continue; } let held_before: u64 = Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, &escrow, *origin_netuid) .to_u64(); let origin_before: u64 = Self::realizable_tao_for_alpha(*origin_netuid, held_before); Self::increase_stake_for_hotkey_and_coldkey_on_subnet( hotkey, &escrow, *origin_netuid, *root_alpha, ); // Re-read the holding after the credit so share-pool rounding is priced in. let held_after: u64 = Self::get_stake_for_hotkey_and_coldkey_on_subnet(hotkey, &escrow, *origin_netuid) .to_u64(); value_added = value_added.saturating_add( Self::realizable_tao_for_alpha(*origin_netuid, held_after) .saturating_sub(origin_before), ); } Self::mint_basket_dividend_shares(hotkey, nav_before, value_added, total_root, escrow_root) } }